<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>동키 블로그</title><description>직접 만들고, 실험하고, 이해한 것을 기록합니다.</description><link>https://dongkey.tech/</link><language>ko</language><item><title>COMMIT 직후 서버가 꺼져도 값은 남을까</title><link>https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/update-data-undo-redo/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/update-data-undo-redo/</guid><description>UPDATE가 데이터 페이지와 Undo, Redo에 남기는 흔적을 따라갑니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;다음 트랜잭션이 성공했다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;START TRANSACTION&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;UPDATE&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; reservations&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;SET&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; status&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &apos;CANCELLED&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; id &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;COMMIT&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;원래 값은 &lt;code&gt;BOOKED&lt;/code&gt;였습니다. 애플리케이션은 COMMIT 성공 응답을 받았고, 그 직후 MySQL 서버가 비정상 종료됐습니다. 다시 시작했을 때 &lt;code&gt;id=1&lt;/code&gt;의 상태는 무엇이어야 할까요?&lt;/p&gt;
&lt;p&gt;Redo Log가 활성화된 일반 영구 InnoDB 테이블에서 &lt;code&gt;innodb_flush_log_at_trx_commit=1&lt;/code&gt;을 사용했다면, 저장 계층이 Flush 요청을 정상적으로 처리한다는 전제에서 재시작 뒤에도 &lt;code&gt;CANCELLED&lt;/code&gt;가 보여야 합니다. Data File의 Page가 아직 &lt;code&gt;BOOKED&lt;/code&gt;여도 마찬가지입니다.&lt;/p&gt;
&lt;p&gt;UPDATE는 행의 현재 값만 바꾸지 않습니다. InnoDB는 메모리에 있는 Page를 바꾸고, 이전 상태를 되돌릴 정보와 장애 뒤 변경을 다시 적용할 정보를 함께 관리합니다.&lt;/p&gt;
&lt;h2 id=&quot;innodb는-행이-아니라-page를-읽고-쓴다&quot;&gt;InnoDB는 행이 아니라 Page를 읽고 쓴다&lt;/h2&gt;
&lt;p&gt;SQL에서 다루는 단위는 행입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;id=1, status=BOOKED&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 InnoDB가 Buffer Pool과 Data Tablespace 사이에서 읽고 쓰는 단위는 Page입니다. 하나의 Page에는 여러 행이 들어갈 수 있습니다. 기본 Page 크기는 16KiB지만 &lt;code&gt;innodb_page_size&lt;/code&gt; 설정에 따라 달라질 수 있습니다.&lt;/p&gt;
&lt;p&gt;InnoDB는 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-buffer-pool.html&quot;&gt;Buffer Pool&lt;/a&gt;에 테이블과 인덱스 데이터를 Page 단위로 캐시합니다. 필요한 Page가 이미 있다면 바로 사용하고, 없다면 Data Tablespace에서 읽어 올립니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Data Tablespace&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;      │ Page Read&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;      ▼&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Buffer Pool의 Data/Index Page&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;id=1&lt;/code&gt;은 기본 키를 기준으로 Clustered Index Record에 들어 있습니다. 상태를 바꿀 때는 Buffer Pool에 올라온 Clustered Index Page의 Record를 변경합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Buffer Pool의 Clustered Index Page&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;id=1, status=BOOKED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;             ↓ UPDATE&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;id=1, status=CANCELLED&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;변경은 우선 메모리에서 일어납니다. UPDATE를 실행했다고 Data Tablespace의 Page가 즉시 바뀌는 것은 아닙니다.&lt;/p&gt;
&lt;h2 id=&quot;update는-세-가지-흔적을-남긴다&quot;&gt;UPDATE는 세 가지 흔적을 남긴다&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;BOOKED&lt;/code&gt;를 &lt;code&gt;CANCELLED&lt;/code&gt;로 바꾸려면 두 상황에 대비해야 합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;트랜잭션을 취소하면 다시 &lt;code&gt;BOOKED&lt;/code&gt;로 돌아가야 합니다.&lt;/li&gt;
&lt;li&gt;변경된 Page가 Data File에 저장되기 전에 장애가 나면 &lt;code&gt;CANCELLED&lt;/code&gt;를 다시 만들 수 있어야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;첫 번째 상황에는 Undo가 필요하고, 두 번째 상황에는 Redo가 필요합니다.&lt;/p&gt;
&lt;p&gt;개념적으로 보면 이 UPDATE는 이전 값을 되돌릴 Undo 정보와 Data/Index Page의 변경을 함께 만듭니다.&lt;/p&gt;
&lt;figure class=&quot;reading-figure&quot; data-reading-figure=&quot;&quot;&gt;&lt;div id=&quot;reading-figure-1-image&quot; class=&quot;figure-image&quot;&gt;&lt;a href=&quot;https://dongkey.tech/_astro/update-undo-redo.hbk6Cc6Y_6yTcP.svg&quot; data-figure-open=&quot;&quot; aria-label=&quot;크게 보기: UPDATE가 Undo Log Record, Dirty Data Page, Redo Log Record를 남기는 과정&quot;&gt;&lt;img alt=&quot;UPDATE가 Undo Log Record, Dirty Data Page, Redo Log Record를 남기는 과정&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1600&quot; height=&quot;900&quot; src=&quot;https://dongkey.tech/_astro/update-undo-redo.hbk6Cc6Y_6yTcP.svg&quot;&gt;&lt;/a&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;세 정보는 같은 내용을 복사해 둔 것이 아닙니다.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;UPDATE는 세 가지 흔적을 남긴다 · 표 1, 가로 스크롤&quot;&gt;




















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;구조&lt;/th&gt;&lt;th&gt;이번 UPDATE에서 맡는 역할&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Undo Log Record&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;를 &lt;code&gt;BOOKED&lt;/code&gt;로 되돌릴 정보를 남깁니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Dirty Data/Index Page&lt;/td&gt;&lt;td&gt;Buffer Pool에서 보이는 현재 값 &lt;code&gt;CANCELLED&lt;/code&gt;를 담습니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Redo Log Records&lt;/td&gt;&lt;td&gt;Data/Index Page와 Undo Page의 변경을 다시 적용할 정보를 남깁니다.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;h2 id=&quot;undo는-이전-상태를-복원한다&quot;&gt;Undo는 이전 상태를 복원한다&lt;/h2&gt;
&lt;p&gt;트랜잭션이 Rollback되면 &lt;code&gt;CANCELLED&lt;/code&gt;를 &lt;code&gt;BOOKED&lt;/code&gt;로 되돌려야 합니다. Undo Log Record에는 최근 변경을 취소할 수 있는 정보가 들어갑니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-undo-logs.html&quot;&gt;Undo Log&lt;/a&gt;는 Rollback뿐 아니라 Consistent Read에서 이전 버전을 재구성할 때도 사용됩니다. UPDATE와 DELETE가 만든 Undo는 다른 트랜잭션의 Read View가 필요로 할 수 있어 COMMIT 직후 곧바로 사라지지 않습니다.&lt;/p&gt;
&lt;p&gt;Undo Log는 별도의 메모리 버퍼가 아닙니다. 일반 영구 테이블의 Undo Record는 Undo Page에 기록되고, 이 Page도 Buffer Pool에서 바뀐 뒤 Undo Tablespace로 Flush됩니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RAM                                      Storage&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Buffer Pool의 Undo Page   ── Page Flush ──&amp;gt;   Undo Tablespace&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;이전 값을 복원할 정보                         Undo Pages&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Undo Page의 변경도 장애 뒤 복원할 수 있도록 Redo가 보호합니다. Undo는 이전 행 상태를 되돌리고, Redo는 Data Page와 Undo Page의 물리 변경을 다시 적용합니다.&lt;/p&gt;
&lt;h2 id=&quot;dirty-page는-미커밋-page라는-뜻이-아니다&quot;&gt;Dirty Page는 미커밋 Page라는 뜻이 아니다&lt;/h2&gt;
&lt;p&gt;Buffer Pool의 Page가 &lt;code&gt;CANCELLED&lt;/code&gt;로 바뀌면 이 Page는 Dirty Page가 됩니다. Dirty는 트랜잭션이 커밋되지 않았다는 뜻이 아닙니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Buffer Pool의 Page       status=CANCELLED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Data Tablespace의 Page   status=BOOKED&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;메모리의 Page가 대상 Data File보다 최신이면 Dirty입니다. 트랜잭션이 아직 ACTIVE여도 Dirty Page가 생길 수 있고, COMMIT된 뒤에도 Page가 Flush되지 않았다면 계속 Dirty일 수 있습니다.&lt;/p&gt;
&lt;p&gt;반대로 미커밋 변경이 들어간 Page도 WAL 조건을 지킨 뒤 Data File에 먼저 저장될 수 있습니다. 따라서 Page의 Dirty 여부와 트랜잭션 상태는 따로 봐야 합니다.&lt;/p&gt;
&lt;h2 id=&quot;redo는-page-변경을-다시-적용한다&quot;&gt;Redo는 Page 변경을 다시 적용한다&lt;/h2&gt;
&lt;p&gt;Redo Log Record는 &lt;code&gt;UPDATE reservations ...&lt;/code&gt; 같은 SQL 문장을 그대로 저장한 것이 아닙니다. InnoDB가 Data/Index Page와 Undo Page에 가한 변경을 장애 뒤 재현할 수 있는 형태로 기록합니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-redo-log.html&quot;&gt;Redo Log&lt;/a&gt;는 Crash Recovery에 사용됩니다. 정상 실행 중 발생한 변경은 Redo에 추가되고, Data File에 반영되지 못한 변경은 재시작 과정에서 필요한 만큼 다시 적용됩니다.&lt;/p&gt;
&lt;p&gt;Redo는 처음부터 파일에 바로 기록되지 않습니다. 먼저 메모리의 Log Buffer를 거칩니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RAM                                      Storage&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Log Buffer          ── write ──&amp;gt;   OS I/O 경로와 Redo Log File&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Page 변경 Redo Records              Flush 전에는 휘발 가능&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;                               ── Flush ──&amp;gt; 비휘발성 저장 영역&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-redo-log-buffer.html&quot;&gt;Log Buffer&lt;/a&gt;는 Redo Log File에 쓸 데이터를 보관하는 메모리 영역입니다. 미커밋 트랜잭션의 Redo도 내부 작업이나 주기적인 Log 처리에 따라 먼저 파일에 기록될 수 있습니다. Redo가 파일에 있다는 사실만으로 COMMIT 여부를 판단할 수는 없습니다.&lt;/p&gt;
&lt;h2 id=&quot;sql-transaction과-mini-transaction은-다르다&quot;&gt;SQL Transaction과 Mini-Transaction은 다르다&lt;/h2&gt;
&lt;p&gt;한 번의 SQL UPDATE도 InnoDB 안에서는 여러 Page를 바꿀 수 있습니다. 이번 예시에서도 Undo Page와 Clustered Index Page를 다룹니다. 대상 컬럼이 Secondary Index에 포함돼 있다면 추가 Index Page도 바뀔 수 있습니다.&lt;/p&gt;
&lt;p&gt;InnoDB는 내부 Page 변경을 Mini-Transaction(mtr) 단위로 묶습니다. Redo를 남기는 하나의 mtr는 여러 Page를 바꿀 수 있습니다. mtr에서 만들어진 Redo Log Records는 하나의 연속된 묶음으로 Log Buffer에 기록되고 연속된 LSN 범위를 차지합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;SQL Transaction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;├─ mtr A: 하나 이상의 Page 변경&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;├─ mtr B: 하나 이상의 Page 변경&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;└─ ...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제 mtr의 개수와 경계는 작업에 따라 달라집니다.&lt;/p&gt;
&lt;p&gt;mtr가 끝났다고 해서 SQL Transaction이 COMMIT되거나 Redo가 저장 장치까지 영속화된 것은 아닙니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;mtr 완료&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;≠ SQL Transaction COMMIT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;≠ Redo Log 영속화 완료&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;COMMIT 성공과 Data Page 저장 완료는 같은 사건이 아닙니다.&lt;/p&gt;</content:encoded></item><item><title>COMMIT과 Page Flush는 같은 일이 아니다</title><link>https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/wal-steal-no-force/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/wal-steal-no-force/</guid><description>COMMIT과 페이지 플러시가 나뉘는 이유를 WAL과 STEAL/NO-FORCE로 살펴봅니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;UPDATE 직후 Buffer Pool의 Clustered Index Page에는 &lt;code&gt;CANCELLED&lt;/code&gt;가 있지만 Tablespace의 같은 Page에는 아직 &lt;code&gt;BOOKED&lt;/code&gt;가 남아 있을 수 있습니다. 그렇다면 COMMIT할 때 이 Page까지 바로 저장해야 할까요?&lt;/p&gt;
&lt;p&gt;Redo Log가 활성화된 일반 영구 InnoDB 테이블에서는 COMMIT할 때 변경된 Page 전체가 저장되기를 기다리지 않습니다. COMMIT과 Page Flush는 서로 다른 경로로 진행되며, Page를 저장할 때는 관련 Redo가 먼저 영속화돼야 한다는 WAL 조건을 지켜야 합니다.&lt;/p&gt;
&lt;figure class=&quot;reading-figure&quot; data-reading-figure=&quot;&quot;&gt;&lt;div id=&quot;reading-figure-1-image&quot; class=&quot;figure-image&quot;&gt;&lt;a href=&quot;https://dongkey.tech/_astro/commit-page-flush.CG_FmKFj_6yTcP.svg&quot; data-figure-open=&quot;&quot; aria-label=&quot;크게 보기: Transaction COMMIT 경로와 Background Page Flush 경로가 나뉘는 과정&quot;&gt;&lt;img alt=&quot;Transaction COMMIT 경로와 Background Page Flush 경로가 나뉘는 과정&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1600&quot; height=&quot;900&quot; src=&quot;https://dongkey.tech/_astro/commit-page-flush.CG_FmKFj_6yTcP.svg&quot;&gt;&lt;/a&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;h2 id=&quot;두-경로는-서로-다른-일을-한다&quot;&gt;두 경로는 서로 다른 일을 한다&lt;/h2&gt;
&lt;p&gt;InnoDB 내부의 COMMIT 경로는 트랜잭션을 완료 상태로 바꾸고, 설정에 따라 필요한 Redo가 파일에 쓰이거나 저장 장치까지 Flush되기를 기다립니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;UPDATE를 구성한 mtr 완료&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;COMMIT에 필요한 내부 상태 변경&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;기다릴 Redo의 목표 LSN 결정&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;설정에 따른 Redo write·flush 대기&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;COMMIT 성공 응답&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Page Flush 경로는 Buffer Pool의 Dirty Page를 골라 대상 Tablespace에 기록합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Dirty Page 선택&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;해당 Page 변경의 Redo가 영속화됐는지 확인&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;필요하면 Redo를 먼저 flush&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Dirty Page를 대상 Tablespace에 기록&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Dirty Page는 보통 Page Cleaner가 백그라운드에서 Flush합니다. &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-buffer-pool-flushing.html&quot;&gt;LRU eviction이나 User Thread가 저장에 참여할 때&lt;/a&gt;도 Flush가 일어날 수 있습니다.&lt;/p&gt;
&lt;p&gt;COMMIT은 특정 트랜잭션을 완료하는 경로이고, Page Flush는 Buffer Pool의 Page 사본을 Data File에 반영하는 경로입니다. 서로 다른 일을 하므로 두 경로의 실행 순서는 고정되지 않습니다.&lt;/p&gt;
&lt;h2 id=&quot;wal은-page를-바꾸기-전에-fsync하라는-뜻이-아니다&quot;&gt;WAL은 Page를 바꾸기 전에 fsync하라는 뜻이 아니다&lt;/h2&gt;
&lt;p&gt;WAL은 Write-Ahead Logging의 약자입니다. 이름만 보면 메모리의 Page를 바꾸기 전에 Redo를 디스크에 저장해야 한다고 생각하기 쉽습니다.&lt;/p&gt;
&lt;p&gt;InnoDB는 Buffer Pool의 Page를 먼저 바꾸고 Redo Log Record를 만들 수 있습니다. WAL이 정하는 순서는 Dirty Page를 Data File에 쓰려는 시점에 적용됩니다. 개념적으로는 다음 조건입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;log.flushed_to_disk_lsn&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    ≥&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;bpage.get_newest_lsn()&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;get_newest_lsn()&lt;/code&gt;은 Buffer Pool Page에 반영된 최신 변경의 LSN을 반환합니다. Page를 쓰기 전에 그 지점까지 Redo가 영속화돼 있어야 합니다. &lt;a href=&quot;https://github.com/mysql/mysql-server/blob/mysql-8.4.10/storage/innobase/buf/buf0flu.cc#L1197-L1214&quot;&gt;MySQL 8.4.10의 Buffer Flush 구현&lt;/a&gt;에도 같은 조건이 있습니다.&lt;/p&gt;
&lt;p&gt;이 순서를 지키지 않으면 새 값이 들어간 Data Page만 남고, 장애 뒤 그 변경과 Undo 구조를 복원할 Redo는 사라질 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Buffer Pool Page 변경&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Redo Log Records 생성&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Page를 Data File에 쓰기 전에 관련 Redo 영속화&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Page Flush&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;page-flush가-commit보다-먼저-끝날-수-있다&quot;&gt;Page Flush가 COMMIT보다 먼저 끝날 수 있다&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;id=1&lt;/code&gt;을 &lt;code&gt;CANCELLED&lt;/code&gt;로 바꾼 뒤 아직 COMMIT하지 않았다고 해보겠습니다. 이때도 Buffer Pool 관리상 Page를 내보내야 할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;UPDATE&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Buffer Pool Page = CANCELLED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Transaction = ACTIVE&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;관련 Redo 영속화&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Data Page Flush&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;나중에 COMMIT 또는 ROLLBACK&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;미완료 트랜잭션의 변경이 포함된 Page도 먼저 Data File에 저장할 수 있는 정책을 복구 이론에서는 &lt;strong&gt;STEAL&lt;/strong&gt;이라고 부릅니다. Buffer Pool 공간을 다른 Page에 사용할 수 있다는 뜻입니다.&lt;/p&gt;
&lt;p&gt;Page Flush 뒤 트랜잭션이 Rollback되거나 서버가 비정상 종료되면 Data File에는 &lt;code&gt;CANCELLED&lt;/code&gt;가 남아 있을 수 있습니다. 최종 상태는 &lt;code&gt;BOOKED&lt;/code&gt;여야 하므로 Undo가 필요합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Data File = CANCELLED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Transaction = ACTIVE&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓ Crash Recovery&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Undo로 CANCELLED → BOOKED&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;commit이-page-flush보다-먼저-끝날-수도-있다&quot;&gt;COMMIT이 Page Flush보다 먼저 끝날 수도 있다&lt;/h2&gt;
&lt;p&gt;반대 순서도 정상입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;UPDATE&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Buffer Pool Page = CANCELLED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Data File Page = BOOKED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;COMMIT에 필요한 Redo 영속화&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;COMMIT 성공&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;나중에 Data Page Flush&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;COMMIT할 때 변경된 모든 Page를 강제로 저장하지 않는 정책을 &lt;strong&gt;NO-FORCE&lt;/strong&gt;라고 부릅니다. 여러 Data Page와 Index Page, Undo Page가 바뀌었더라도 COMMIT 경로에서 모두 찾아 쓰기를 기다리지 않습니다.&lt;/p&gt;
&lt;p&gt;Page Flush보다 먼저 장애가 발생하면 Data File에는 &lt;code&gt;BOOKED&lt;/code&gt;가 남아 있을 수 있습니다. COMMIT에 필요한 Redo가 영속화됐다면 복구 과정에서 Page 변경을 다시 적용해 &lt;code&gt;CANCELLED&lt;/code&gt;를 만들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Data File = BOOKED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Transaction = COMMITTED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓ Crash Recovery&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Redo로 BOOKED → CANCELLED&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;dirty와-트랜잭션-상태는-서로-다른-축이다&quot;&gt;Dirty와 트랜잭션 상태는 서로 다른 축이다&lt;/h2&gt;
&lt;p&gt;아래 표는 COMMIT에 필요한 Redo가 장애 뒤에도 남아 있다는 전제에서 두 경로를 단순화한 것입니다.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;Dirty와 트랜잭션 상태는 서로 다른 축이다 · 표 1, 가로 스크롤&quot;&gt;


































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;트랜잭션&lt;/th&gt;&lt;th&gt;Data File의 값&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;COMMITTED&lt;/td&gt;&lt;td&gt;&lt;code&gt;BOOKED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;필요한 Redo를 적용합니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;COMMITTED&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Page가 이미 최신이면 오래된 Redo를 다시 적용할 필요가 없습니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;ACTIVE&lt;/td&gt;&lt;td&gt;&lt;code&gt;BOOKED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;BOOKED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Redo로 미완료 변경이 복원됐다면 Undo합니다. 그렇지 않으면 기존 값이 남습니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;ACTIVE&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;BOOKED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;먼저 저장된 미완료 변경을 Undo합니다.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Dirty는 Buffer Pool의 Page가 대상 Data File보다 최신인지 나타내는 상태입니다. &lt;code&gt;ACTIVE&lt;/code&gt;, &lt;code&gt;COMMITTED&lt;/code&gt; 같은 트랜잭션 상태와는 다른 정보입니다.&lt;/p&gt;
&lt;p&gt;복구할 때도 행별 COMMIT 여부를 먼저 가려 Redo를 적용하지는 않습니다. 영속화된 Redo로 필요한 Page 변경을 복원한 뒤, 끝나지 않은 트랜잭션을 Rollback합니다. &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-recovery.html&quot;&gt;InnoDB Recovery&lt;/a&gt;도 두 작업을 별도 단계로 나눕니다.&lt;/p&gt;
&lt;h2 id=&quot;steal과-no-force는-mysql-설정명이-아니다&quot;&gt;STEAL과 NO-FORCE는 MySQL 설정명이 아니다&lt;/h2&gt;
&lt;p&gt;STEAL과 NO-FORCE는 &lt;code&gt;my.cnf&lt;/code&gt;에 넣는 옵션 이름이 아닙니다. Buffer Manager가 Page를 언제 저장할 수 있는지 설명하는 복구 이론의 분류입니다.&lt;/p&gt;</content:encoded></item><item><title>COMMIT은 Redo Log의 어디까지 기다리는가</title><link>https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/commit-lsn-group-commit/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/commit-lsn-group-commit/</guid><description>Redo Log의 write와 durable flush를 구분하고, COMMIT이 기다리는 LSN을 살펴봅니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;기본 내구성 설정에서 COMMIT이 기다리는 대상은 Data Page가 아니라 필요한 Redo 범위입니다.&lt;/p&gt;
&lt;p&gt;COMMIT이 기다리는 범위를 알려면 Redo의 위치를 나타내는 LSN을 이해하고, write와 flush를 구분해야 합니다.&lt;/p&gt;
&lt;h2 id=&quot;lsn은-redo-스트림의-논리적-위치다&quot;&gt;LSN은 Redo 스트림의 논리적 위치다&lt;/h2&gt;
&lt;p&gt;LSN은 Log Sequence Number의 약자입니다. InnoDB가 Redo를 추가할 때 계속 증가하는 논리적 위치입니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/glossary.html#glos_lsn&quot;&gt;MySQL 용어집&lt;/a&gt;은 LSN을 Redo Log 안의 위치로 정의합니다. LSN은 트랜잭션 번호가 아니고, 순환해서 사용하는 Redo File 하나의 고정된 물리 offset도 아닙니다. 여러 User Thread와 내부 작업이 하나의 전역 Redo 스트림을 함께 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;LSN 100        120        140        160        180&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    |-----------|----------|----------|----------|&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;       Group A     Group B     Group C     Group D&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mini-Transaction이 만든 Redo Group은 &lt;code&gt;[start_lsn, end_lsn)&lt;/code&gt;과 같은 범위를 차지합니다. 한 SQL Transaction이 여러 mtr를 사용한다면 여러 Redo Group이 전역 스트림에 섞여 들어갈 수 있습니다. 특정 트랜잭션의 Redo가 하나의 큰 덩어리로만 저장된다고 볼 수는 없습니다.&lt;/p&gt;
&lt;h2 id=&quot;redo가-만들어진-것과-파일에-쓰인-것은-다르다&quot;&gt;Redo가 만들어진 것과 파일에 쓰인 것은 다르다&lt;/h2&gt;
&lt;p&gt;전용 Log Writer와 Log Flusher를 사용하는 일반 경로를 단순화하면 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;User Thread가 Log Buffer에 Redo Group을 준비&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Log Writer가 연속된 구간을 Redo File로 write&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Log Flusher가 fsync 등에 해당하는 flush 수행&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;저장 계층이 요청을 정상적으로 처리하면 영속화&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여러 Thread가 LSN 범위를 예약하고 기록하므로 뒤쪽 범위의 작성이 먼저 끝날 수도 있습니다. Log Writer는 중간에 준비되지 않은 구간을 건너뛰어 파일로 보낼 수 없습니다. 빈틈없이 이어진 범위까지만 파일에 씁니다.&lt;/p&gt;
&lt;p&gt;개념적으로는 다음 순서가 유지됩니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;flushed_to_disk_lsn&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    ≤ write_lsn&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    ≤ Log Buffer에서 연속해서 준비된 LSN&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    ≤ current_lsn&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 위치가 항상 같지는 않습니다. Redo 생성이 빠르고 저장 장치가 느리면 차이가 커질 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;write와-durable-flush를-구분한다&quot;&gt;write와 durable flush를 구분한다&lt;/h2&gt;
&lt;p&gt;기본적인 &lt;code&gt;fsync&lt;/code&gt; 경로에서 write는 Log Buffer의 내용을 운영체제 I/O 경로로 넘깁니다. write가 완료돼도 내용이 OS Cache나 저장 장치의 휘발성 Cache에 남아 있을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;mysqld의 Log Buffer&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓ write()/pwrite()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;OS I/O 경로와 Redo File&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓ fsync()/fdatasync() 등에 해당하는 flush&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;비휘발성 저장 영역&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;write가 끝났다고 해서 전원 장애 뒤에도 데이터가 남는 것은 아닙니다. 실제 경로는 &lt;code&gt;innodb_flush_method&lt;/code&gt;, 운영체제, File System과 저장 장치에 따라 달라집니다. 여기서는 저장 장치가 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit&quot;&gt;Flush 완료를 올바르게 보고한다&lt;/a&gt;고 가정합니다.&lt;/p&gt;
&lt;h2 id=&quot;일반적인-update-commit의-목표-lsn&quot;&gt;일반적인 UPDATE COMMIT의 목표 LSN&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;id=1&lt;/code&gt;을 &lt;code&gt;BOOKED&lt;/code&gt;에서 &lt;code&gt;CANCELLED&lt;/code&gt;로 바꾼 트랜잭션을 다시 보겠습니다.&lt;/p&gt;
&lt;p&gt;일반 테이블을 UPDATE한 트랜잭션은 COMMIT 과정에서 Undo 상태를 바꾸고, Update Undo를 Rollback Segment의 History List에 연결합니다. 이 변경을 묶는 serialization mtr도 Redo로 보호됩니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;UPDATE 과정&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;mtr A: Undo Page 변경&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;mtr B: Clustered Index Page 변경&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;COMMIT 과정&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;serialization mtr:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Undo Log Segment 상태 전환&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Update Undo를 RSEG History List에 연결&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;이 mtr의 end LSN 획득&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Binary Log와 Server 2PC를 제외한 InnoDB 내부 경로에서 &lt;code&gt;innodb_flush_log_at_trx_commit=1&lt;/code&gt;이면, 이 end LSN이 Redo Flush를 기다릴 목표가 됩니다. 전역 Redo 스트림이 그 위치까지 이어져 있으므로 목표 LSN까지 영속화되면 앞서 만들어진 Page 변경 Redo도 포함됩니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;flushed_to_disk_lsn ≥ commit target LSN&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;commit target&lt;/code&gt;은 실행 중인 Thread가 기다릴 위치일 뿐, 복구 때 읽는 별도의 영속 COMMIT 표식은 아닙니다. 서버가 비정상 종료되면 메모리의 target 값은 사라지고, Recovery는 영속화된 Redo와 Undo Segment 상태, Rollback Segment 메타데이터로 트랜잭션 상태를 다시 구성합니다.&lt;/p&gt;
&lt;h2 id=&quot;메모리의-commit과-성공-응답은-같은-시점이-아니다&quot;&gt;메모리의 COMMIT과 성공 응답은 같은 시점이 아니다&lt;/h2&gt;
&lt;p&gt;InnoDB 내부에서는 트랜잭션 상태를 메모리에서 완료 상태로 바꾸고 Lock을 정리하는 작업과 Redo의 영속화를 기다리는 작업이 구분됩니다. 아래는 Binary Log와 Server 2PC를 제외한 InnoDB 내부의 Redo 대기 흐름입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;COMMIT용 Redo Group 완료&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;메모리의 Transaction 상태 전환과 Lock 정리&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;설정에 따른 target LSN write·flush 대기&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;InnoDB COMMIT 처리 완료&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;MySQL Server가 클라이언트에 성공 응답&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;내부 상태가 &lt;code&gt;COMMITTED_IN_MEMORY&lt;/code&gt;로 바뀐 것과 클라이언트가 COMMIT 성공을 받은 것은 같은 시점이 아닙니다. 설정값이 &lt;code&gt;1&lt;/code&gt;이면 필요한 Redo가 영속화되는 조건까지 만족한 뒤 COMMIT 처리가 서버로 돌아갑니다.&lt;/p&gt;
&lt;p&gt;반대로 클라이언트가 성공 응답을 받은 뒤에도 Data Page는 Dirty 상태로 남아 있을 수 있습니다. COMMIT이 확인한 것은 Page Flush가 아니라 Redo의 내구성입니다.&lt;/p&gt;
&lt;h2 id=&quot;한-번의-flush가-여러-commit을-끝낼-수-있다&quot;&gt;한 번의 flush가 여러 COMMIT을 끝낼 수 있다&lt;/h2&gt;
&lt;p&gt;트랜잭션마다 &lt;code&gt;fsync&lt;/code&gt;를 한 번씩 호출한다면 동시 요청이 많을수록 저장 장치 대기가 반복됩니다. InnoDB는 Redo의 진행 위치를 공유해 여러 대기 조건을 한 번에 만족시킬 수 있습니다.&lt;/p&gt;
&lt;figure class=&quot;reading-figure&quot; data-reading-figure=&quot;&quot;&gt;&lt;div id=&quot;reading-figure-1-image&quot; class=&quot;figure-image&quot;&gt;&lt;a href=&quot;https://dongkey.tech/_astro/redo-group-commit.D6kLM5Il_6yTcP.svg&quot; data-figure-open=&quot;&quot; aria-label=&quot;크게 보기: 한 번의 Redo flush가 서로 다른 목표 LSN을 기다리는 세 트랜잭션을 깨우는 과정&quot;&gt;&lt;img alt=&quot;한 번의 Redo flush가 서로 다른 목표 LSN을 기다리는 세 트랜잭션을 깨우는 과정&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1600&quot; height=&quot;900&quot; src=&quot;https://dongkey.tech/_astro/redo-group-commit.D6kLM5Il_6yTcP.svg&quot;&gt;&lt;/a&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;세 트랜잭션의 목표가 다음과 같다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;T1 target LSN = 150&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;T2 target LSN = 190&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;T3 target LSN = 230&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Log Flusher가 LSN 230까지 한 번에 영속화하면 세 조건이 모두 충족됩니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;flushed_to_disk_lsn = 230&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;230 ≥ 150  → T1 대기 완료&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;230 ≥ 190  → T2 대기 완료&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;230 ≥ 230  → T3 대기 완료&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 Thread는 통지를 받은 뒤 자기 조건을 다시 확인하고, 목표가 충족됐다면 남은 COMMIT 처리를 이어갑니다. 이미 &lt;code&gt;flushed_to_disk_lsn&lt;/code&gt;이 target보다 앞서 있다면 새 I/O 없이 조건을 통과할 수도 있습니다.&lt;/p&gt;
&lt;p&gt;이것이 InnoDB Redo 경로의 Group Commit입니다. Binary Log Group Commit과 MySQL Server의 2PC는 별도의 계층입니다.&lt;/p&gt;
&lt;h2 id=&quot;innodb_flush_log_at_trx_commit이-대기-지점을-바꾼다&quot;&gt;&lt;code&gt;innodb_flush_log_at_trx_commit&lt;/code&gt;이 대기 지점을 바꾼다&lt;/h2&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;innodb_flush_log_at_trx_commit이 대기 지점을 바꾼다 · 표 1, 가로 스크롤&quot;&gt;




























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;설정&lt;/th&gt;&lt;th&gt;COMMIT 시 Redo write&lt;/th&gt;&lt;th&gt;COMMIT 시 durable flush&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;1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;기다림&lt;/td&gt;&lt;td&gt;기다림&lt;/td&gt;&lt;td&gt;정상적인 저장 계층을 전제로 COMMIT된 Redo의 내구성을 보장합니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;&lt;td&gt;기다림&lt;/td&gt;&lt;td&gt;기다리지 않음&lt;/td&gt;&lt;td&gt;Redo가 durable flush되기 전에 장애가 나면 최근 COMMIT을 잃을 수 있습니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;&lt;td&gt;기다리지 않음&lt;/td&gt;&lt;td&gt;기다리지 않음&lt;/td&gt;&lt;td&gt;Redo가 durable flush되기 전에 장애가 나면 최근 COMMIT을 잃을 수 있습니다.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;1&lt;/code&gt;이 기본값입니다. &lt;code&gt;2&lt;/code&gt;는 COMMIT마다 write를 기다리지만 &lt;code&gt;0&lt;/code&gt;은 COMMIT에서 write도 기다리지 않습니다. 두 설정의 durable flush 주기는 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_timeout&quot;&gt;&lt;code&gt;innodb_flush_log_at_timeout&lt;/code&gt;&lt;/a&gt;이 정하며 기본값은 1초입니다.&lt;/p&gt;
&lt;p&gt;이 주기는 정확히 지켜진다는 보장이 없습니다. 스케줄링 때문에 늦어질 수 있고, DDL이나 일부 InnoDB 내부 작업이 별도로 Flush하면 더 빨라질 수도 있습니다. 따라서 장애 때 잃는 범위를 정확히 1초라고 단정할 수 없습니다.&lt;/p&gt;</content:encoded></item><item><title>Checkpoint는 복구 시작점을 어떻게 옮기는가</title><link>https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/checkpoint-page-lsn-doublewrite/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/checkpoint-page-lsn-doublewrite/</guid><description>Checkpoint, Page LSN, Doublewrite가 복구 과정에서 맡는 역할을 정리합니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;Redo Log의 저장 용량은 한정되어 있습니다. Checkpoint가 전진하면 장애 복구에 더는 필요하지 않은 오래된 Redo부터 정리해 공간을 다시 사용합니다.&lt;/p&gt;
&lt;h2 id=&quot;checkpoint는-redo를-재사용할-수-있는-경계를-만든다&quot;&gt;Checkpoint는 Redo를 재사용할 수 있는 경계를 만든다&lt;/h2&gt;
&lt;p&gt;Redo의 진행 상태를 단순하게 그리면 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;last_checkpoint_lsn                         current_lsn&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        |-----------------------------------------|&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;                   checkpoint age&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;checkpoint age = current_lsn - last_checkpoint_lsn&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;current_lsn&lt;/code&gt;은 새로운 Redo가 만들어지는 끝을 나타냅니다. &lt;code&gt;last_checkpoint_lsn&lt;/code&gt;은 장애 복구가 시작할 수 있는 최근 경계입니다. 둘 사이의 범위는 아직 복구에 필요할 수 있으므로 보존해야 합니다.&lt;/p&gt;
&lt;p&gt;오래된 변경을 담은 Dirty Page가 Data File에 안전하게 반영되지 않았다면 Checkpoint는 그 변경을 지나 전진할 수 없습니다. Checkpoint가 뒤처지면 재사용할 수 있는 Redo 공간이 줄고, InnoDB는 Page Flush를 더 적극적으로 진행할 수 있습니다. 공간이 부족하면 새로운 쓰기가 기다릴 수도 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;fuzzy-checkpoint는-buffer-pool-전체를-비우지-않는다&quot;&gt;Fuzzy Checkpoint는 Buffer Pool 전체를 비우지 않는다&lt;/h2&gt;
&lt;p&gt;Checkpoint 때마다 Buffer Pool의 모든 Dirty Page를 한 번에 저장한다면 쓰기가 오래 멈출 수 있습니다. InnoDB는 Fuzzy Checkpoint 방식으로 Dirty Page를 작은 묶음으로 계속 Flush합니다.&lt;/p&gt;
&lt;figure class=&quot;reading-figure&quot; data-reading-figure=&quot;&quot;&gt;&lt;div id=&quot;reading-figure-1-image&quot; class=&quot;figure-image&quot;&gt;&lt;a href=&quot;https://dongkey.tech/_astro/fuzzy-checkpoint.FZliUhcE_6yTcP.svg&quot; data-figure-open=&quot;&quot; aria-label=&quot;크게 보기: Fuzzy Checkpoint가 오래된 Redo를 재사용 가능한 범위로 만들고 Recovery 시작점을 옮기는 과정&quot;&gt;&lt;img alt=&quot;Fuzzy Checkpoint가 오래된 Redo를 재사용 가능한 범위로 만들고 Recovery 시작점을 옮기는 과정&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1600&quot; height=&quot;900&quot; src=&quot;https://dongkey.tech/_astro/fuzzy-checkpoint.FZliUhcE_6yTcP.svg&quot;&gt;&lt;/a&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-checkpoints.html&quot;&gt;Checkpoint&lt;/a&gt;는 작은 Dirty Page 묶음을 Flush하면서 전진합니다. Crash Recovery는 최신 Checkpoint부터 Redo를 순방향으로 읽습니다.&lt;/p&gt;
&lt;p&gt;Checkpoint가 &lt;code&gt;LSN 100&lt;/code&gt;까지 전진했다면 그 이전 변경을 복구하려고 앞쪽 Redo를 다시 읽을 필요가 없다는 뜻입니다. 해당 구간은 순환 로그에서 재사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;Checkpoint는 Buffer Pool 전체를 비우거나 특정 트랜잭션의 COMMIT을 표시하는 작업이 아닙니다. &lt;code&gt;LSN 100&lt;/code&gt; 이후에 다시 변경된 Page는 여전히 Dirty일 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;page-lsn은-page가-어디까지-반영됐는지-나타낸다&quot;&gt;Page LSN은 Page가 어디까지 반영됐는지 나타낸다&lt;/h2&gt;
&lt;p&gt;Redo를 읽더라도 모든 기록을 Data Page에 다시 적용할 필요는 없습니다. 장애 전에 Data File에 이미 저장된 변경도 있기 때문입니다.&lt;/p&gt;
&lt;p&gt;InnoDB Page Header에는 &lt;code&gt;FIL_PAGE_LSN&lt;/code&gt;이 있습니다. 해당 Page에 반영된 가장 최근 Redo Record가 끝나는 LSN을 담으므로 보통 Page LSN이라고 부릅니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Data Page A&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;FIL_PAGE_LSN = 160&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Redo Group [120, 140)  → 이미 반영된 범위&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Redo Group [140, 160)  → 이미 반영된 범위&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Redo Group [160, 180)  → 적용 후보&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제로는 Redo Record의 범위와 Page 초기화 여부, Tablespace 상태도 함께 확인합니다(&lt;a href=&quot;https://github.com/mysql/mysql-server/blob/mysql-8.4.10/storage/innobase/include/fil0types.h#L62-L68&quot;&gt;&lt;code&gt;FIL_PAGE_LSN&lt;/code&gt; 정의&lt;/a&gt;, &lt;a href=&quot;https://github.com/mysql/mysql-server/blob/mysql-8.4.10/storage/innobase/log/log0recv.cc#L2548-L2787&quot;&gt;Recovery 구현&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Page LSN을 COMMIT 여부로 해석해서도 안 됩니다. Page LSN은 Page 단위의 물리 변경 위치입니다. 미커밋 변경을 담은 Page도 Data File에 저장될 수 있으므로 LSN이 높다고 그 안의 모든 변경이 COMMIT됐다는 뜻은 아닙니다.&lt;/p&gt;
&lt;h2 id=&quot;wal만으로-torn-page를-복구할-수는-없다&quot;&gt;WAL만으로 Torn Page를 복구할 수는 없다&lt;/h2&gt;
&lt;p&gt;WAL은 Data Page를 쓰기 전에 관련 Redo가 먼저 영속화되도록 순서를 지킵니다. Page 자체가 온전하게 기록됐는지까지 보장하지는 않습니다.&lt;/p&gt;
&lt;p&gt;기본 16KiB InnoDB Page를 Data File에 쓰는 동안 장애가 발생하면 저장 계층에 따라 일부만 새 값으로 바뀐 Page가 남을 수 있습니다. 이를 Torn Page 또는 Partial Page Write라고 합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;정상 Page&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[ 새 Header ][ 새 Record ][ 새 나머지 영역 ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;쓰는 도중 장애가 난 Page&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[ 새 Header ][ 이전 Record ][ 이전 나머지 영역 ]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redo는 기준 Page에 어떤 변경을 적용할지 기록합니다. 기준이 될 Page 자체가 중간 상태로 깨져 있다면 Redo만으로 항상 정상 Page를 만들 수 있다고 볼 수 없습니다.&lt;/p&gt;
&lt;figure class=&quot;reading-figure&quot; data-reading-figure=&quot;&quot;&gt;&lt;div id=&quot;reading-figure-2-image&quot; class=&quot;figure-image&quot;&gt;&lt;a href=&quot;https://dongkey.tech/_astro/doublewrite-torn-page.FdOxKQL8_6yTcP.svg&quot; data-figure-open=&quot;&quot; aria-label=&quot;크게 보기: Doublewrite에 Page 사본을 먼저 기록하고 Data File의 최종 위치를 쓰는 과정&quot;&gt;&lt;img alt=&quot;Doublewrite에 Page 사본을 먼저 기록하고 Data File의 최종 위치를 쓰는 과정&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1600&quot; height=&quot;900&quot; src=&quot;https://dongkey.tech/_astro/doublewrite-torn-page.FdOxKQL8_6yTcP.svg&quot;&gt;&lt;/a&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;일반적인 &lt;code&gt;innodb_doublewrite=ON&lt;/code&gt; 또는 &lt;code&gt;DETECT_AND_RECOVER&lt;/code&gt; 경로에서는 Page를 최종 위치에 쓰기 전에 Doublewrite 영역에 사본을 쓰고 Flush합니다. 최종 위치 기록이 중간에 끊기면 Recovery에서 온전한 사본을 이용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-doublewrite-buffer.html&quot;&gt;Doublewrite&lt;/a&gt;와 Redo의 역할은 다음처럼 나뉩니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Doublewrite&lt;/strong&gt;는 불완전한 최종 Page Write를 복구할 수 있는 사본을 마련합니다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redo&lt;/strong&gt;는 복구한 Page 이후의 변경을 필요한 지점까지 다시 적용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;cancelled-page를-다시-따라가-보기&quot;&gt;&lt;code&gt;CANCELLED&lt;/code&gt; Page를 다시 따라가 보기&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;id=1&lt;/code&gt;을 &lt;code&gt;BOOKED&lt;/code&gt;에서 &lt;code&gt;CANCELLED&lt;/code&gt;로 바꾸고 COMMIT했다고 가정하겠습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Redo Log&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  BOOKED → CANCELLED 변경에 필요한 Redo가 영속화됨&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Buffer Pool&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  Dirty Page: CANCELLED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Data File&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  Page: BOOKED&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Data File의 Page가 아직 &lt;code&gt;BOOKED&lt;/code&gt;라면 Page LSN도 관련 Redo보다 뒤처져 있을 수 있습니다. 재시작한 InnoDB는 최신 Checkpoint부터 Redo를 읽고, Page가 오래된 경우 &lt;code&gt;CANCELLED&lt;/code&gt; 변경을 적용합니다.&lt;/p&gt;
&lt;p&gt;반대로 Data File의 Page에 이미 &lt;code&gt;CANCELLED&lt;/code&gt;가 반영됐다면 오래된 Redo를 다시 적용할 필요가 없습니다. Page Write가 중간에 끊겼다면 Doublewrite 사본으로 기준 Page를 복구한 뒤 필요한 변경을 Redo로 다시 적용합니다.&lt;/p&gt;</content:encoded></item><item><title>InnoDB는 왜 Redo한 뒤 Undo하는가</title><link>https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/crash-recovery-redo-undo/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/crash-recovery-redo-undo/</guid><description>장애 뒤 Redo를 적용하고, 미완료 트랜잭션을 Undo로 되돌리는 순서를 따라갑니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;Redo는 커밋된 변경을 살리고 Undo는 미커밋 변경을 지운다고만 구분하면 Crash Recovery의 순서가 잘 설명되지 않습니다. InnoDB는 먼저 Redo를 적용하고, 그 뒤에 미완료 트랜잭션을 Undo합니다.&lt;/p&gt;
&lt;p&gt;SQL Transaction이 끝나기 전에도 UPDATE 과정에서 Redo가 만들어지고, Background Log Flush나 WAL 조건에 따라 파일까지 영속화될 수 있습니다. 따라서 Redo에는 COMMIT되지 않은 변경도 포함될 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;장애-시점은-commit-호출보다-내구성-경계로-나눈다&quot;&gt;장애 시점은 COMMIT 호출보다 내구성 경계로 나눈다&lt;/h2&gt;
&lt;p&gt;아래 표는 Binary Log를 사용하지 않는 단일 InnoDB 트랜잭션의 내부 경로만 다룹니다. Binary Log가 켜져 있으면 MySQL Server의 2PC와 &lt;code&gt;sync_binlog&lt;/code&gt;까지 함께 봐야 합니다. 여기서는 일반 영구 테이블 UPDATE와 &lt;code&gt;innodb_flush_log_at_trx_commit=1&lt;/code&gt;을 기준으로 COMMIT 전후 장애를 나눠 보겠습니다.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;장애 시점은 COMMIT 호출보다 내구성 경계로 나눈다 · 표 1, 가로 스크롤&quot;&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;COMMIT 요청 뒤, 목표 LSN의 durable flush 완료를 확인하기 전&lt;/td&gt;&lt;td&gt;&lt;code&gt;BOOKED&lt;/code&gt; 또는 &lt;code&gt;CANCELLED&lt;/code&gt;가 될 수 있습니다. COMMIT 관련 Redo가 장애 전에 어디까지 영속화됐는지에 따라 달라집니다.&lt;/td&gt;&lt;td&gt;성공 여부를 알 수 없습니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;목표 LSN까지 flush된 뒤, 응답 전&lt;/td&gt;&lt;td&gt;COMMIT 상태를 복원할 Redo가 남아 있으므로 &lt;code&gt;CANCELLED&lt;/code&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;code&gt;CANCELLED&lt;/code&gt;가 돼야 합니다.&lt;/td&gt;&lt;td&gt;성공을 확인했습니다.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;응답 전에 연결이 끊겼다면 애플리케이션은 COMMIT 여부를 단정할 수 없습니다. 결과를 조회하거나 중복 실행을 막을 기준이 필요합니다. 이 문제는 InnoDB 내부 복구와 별개로 애플리케이션에서 처리해야 합니다.&lt;/p&gt;
&lt;h2 id=&quot;recovery는-page-상태를-먼저-복원한다&quot;&gt;Recovery는 Page 상태를 먼저 복원한다&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-recovery.html&quot;&gt;InnoDB Recovery&lt;/a&gt;의 주요 순서는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Tablespace Discovery&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Redo Log Application&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Rollback of Incomplete Transactions&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redo Log Application은 서버가 연결을 받기 전에 수행됩니다. 이후 미완료 트랜잭션의 Rollback은 백그라운드에서 새 작업과 병행될 수 있으며, 끝날 때까지 잠금 충돌이 생길 수 있습니다.&lt;/p&gt;
&lt;p&gt;InnoDB는 Redo가 가리키는 Tablespace를 찾고 최신 Checkpoint 이후의 유효한 Redo를 읽습니다. 이어 Data/Index Page와 Undo 관련 Page에 필요한 변경을 적용합니다.&lt;/p&gt;
&lt;figure class=&quot;reading-figure&quot; data-reading-figure=&quot;&quot;&gt;&lt;div id=&quot;reading-figure-1-image&quot; class=&quot;figure-image&quot;&gt;&lt;a href=&quot;https://dongkey.tech/_astro/recovery-redo.D6nCXRmM_6yTcP.svg&quot; data-figure-open=&quot;&quot; aria-label=&quot;크게 보기: Checkpoint 이후 완결된 Redo Group을 확인하고 Page에 적용하는 과정&quot;&gt;&lt;img alt=&quot;Checkpoint 이후 완결된 Redo Group을 확인하고 Page에 적용하는 과정&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1600&quot; height=&quot;900&quot; src=&quot;https://dongkey.tech/_astro/recovery-redo.D6nCXRmM_6yTcP.svg&quot;&gt;&lt;/a&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;Redo Application은 커밋된 SQL만 골라 다시 실행하는 과정이 아닙니다. 이 글의 UPDATE 예시에서는 영속 Redo의 완결된 기록을 읽고, 필요한 Data/Index Page와 Undo Page 변경을 복원합니다.&lt;/p&gt;
&lt;p&gt;Checkpoint가 Redo Group의 중간을 가리킬 수 있으므로 실제 Recovery는 Mini-Transaction Group의 경계도 확인합니다. 완결되지 않은 Group을 절반만 적용하지 않기 위해서입니다. Page가 이미 더 최신이라면 같은 변경을 다시 적용할 필요도 없습니다.&lt;/p&gt;
&lt;h2 id=&quot;커밋하지-않은-변경도-redo-대상이-될-수-있다&quot;&gt;커밋하지 않은 변경도 Redo 대상이 될 수 있다&lt;/h2&gt;
&lt;p&gt;첫 번째 COMMIT으로 &lt;code&gt;CANCELLED&lt;/code&gt;가 된 뒤 다음 UPDATE를 실행했다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;START TRANSACTION&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;UPDATE&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; reservations&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;SET&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; status&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &apos;REFUNDING&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; id &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;-- COMMIT하지 않은 상태에서 장애 발생&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;COMMIT하지 않은 UPDATE의 Redo도 Log File에 기록될 수 있습니다. 미완료 변경이 들어간 Page를 파일에 쓰려면 WAL 조건에 따라 관련 Redo가 먼저 영속화돼야 하고, Background Log Flush가 그보다 앞서 진행될 수도 있습니다.&lt;/p&gt;
&lt;p&gt;따라서 Recovery가 읽는 Redo에는 커밋한 &lt;code&gt;CANCELLED&lt;/code&gt;와 미완료 &lt;code&gt;REFUNDING&lt;/code&gt;의 변경이 함께 들어 있을 수 있습니다. Undo Page와 Transaction 상태도 유효한 지점까지 복원해야 하므로 미커밋 변경을 처음부터 제외할 수는 없습니다.&lt;/p&gt;
&lt;p&gt;Redo 단계에서는 Page의 물리 상태를 먼저 복원합니다. 어떤 변경을 최종적으로 남길지는 다음 Undo 단계에서 결정합니다.&lt;/p&gt;
&lt;h2 id=&quot;undo하려면-undo-정보도-먼저-복원해야-한다&quot;&gt;Undo하려면 Undo 정보도 먼저 복원해야 한다&lt;/h2&gt;
&lt;p&gt;Undo Log는 InnoDB가 관리하는 영속 Page에 저장됩니다. 일반 영구 테이블의 Undo Page와 Rollback Segment 관련 변경도 Redo의 보호를 받습니다.&lt;/p&gt;
&lt;p&gt;Redo를 적용한 뒤 InnoDB는 Rollback Segment와 Undo Log Header를 읽어 장애 전에 존재하던 Transaction을 다시 구성합니다.&lt;/p&gt;
&lt;figure class=&quot;reading-figure&quot; data-reading-figure=&quot;&quot;&gt;&lt;div id=&quot;reading-figure-2-image&quot; class=&quot;figure-image&quot;&gt;&lt;a href=&quot;https://dongkey.tech/_astro/recovery-undo.CGTiFUj4_6yTcP.svg&quot; data-figure-open=&quot;&quot; aria-label=&quot;크게 보기: Redo 적용 뒤 Undo 메타데이터로 완료된 트랜잭션과 ACTIVE, XA PREPARED를 나누는 과정&quot;&gt;&lt;img alt=&quot;Redo 적용 뒤 Undo 메타데이터로 완료된 트랜잭션과 ACTIVE, XA PREPARED를 나누는 과정&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1600&quot; height=&quot;900&quot; src=&quot;https://dongkey.tech/_astro/recovery-undo.CGTiFUj4_6yTcP.svg&quot;&gt;&lt;/a&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Redo Application 완료&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Rollback Segment와 Undo Log 확인&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        ├─ 완료된 UPDATE Undo → History List와 Purge 대상&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        ├─ 일반 ACTIVE Transaction → 자동 Rollback&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        └─ 외부 XA PREPARED → XA RECOVER 후 COMMIT/ROLLBACK 결정 대기&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;COMMIT된 UPDATE의 Undo가 곧바로 사라지는 것은 아닙니다. 이전 버전을 읽는 Transaction이 있을 수 있으므로 History List에 남았다가 더는 필요하지 않을 때 Purge됩니다.&lt;/p&gt;
&lt;p&gt;장애 당시 끝나지 않은 일반 Transaction은 Undo Records를 역방향으로 따라 Rollback합니다. 이번 예시에서는 &lt;code&gt;REFUNDING&lt;/code&gt;이 사라지고 마지막 COMMIT 값인 &lt;code&gt;CANCELLED&lt;/code&gt;가 남습니다.&lt;/p&gt;
&lt;h2 id=&quot;data-page와-transaction-상태를-따로-본다&quot;&gt;Data Page와 Transaction 상태를 따로 본다&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;BOOKED → CANCELLED&lt;/code&gt;는 COMMIT했고, &lt;code&gt;CANCELLED → REFUNDING&lt;/code&gt;은 COMMIT하지 않은 채 장애가 났다고 가정하겠습니다.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;Data Page와 Transaction 상태를 따로 본다 · 표 2, 가로 스크롤&quot;&gt;


































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Transaction 상태&lt;/th&gt;&lt;th&gt;Data File의 값&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;CANCELLED&lt;/code&gt;까지 COMMITTED&lt;/td&gt;&lt;td&gt;&lt;code&gt;BOOKED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;필요한 Redo로 &lt;code&gt;CANCELLED&lt;/code&gt;를 복원합니다.&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;까지 COMMITTED&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;이미 반영된 오래된 Redo를 건너뜁니다.&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;REFUNDING&lt;/code&gt;이 ACTIVE&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;td&gt;필요한 Page와 Undo 정보를 복원한 뒤 Rollback합니다.&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;REFUNDING&lt;/code&gt;이 ACTIVE&lt;/td&gt;&lt;td&gt;&lt;code&gt;REFUNDING&lt;/code&gt;&lt;/td&gt;&lt;td&gt;먼저 저장된 미완료 변경을 Undo합니다.&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Recovery는 Data File에 남은 Page LSN을 기준으로 이미 반영된 변경은 건너뛰고 필요한 Redo를 적용합니다. 최종 논리 상태는 복원된 Transaction 상태와 Undo 처리가 결정합니다.&lt;/p&gt;
&lt;p&gt;다만 외부 XA 트랜잭션이 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/xa-states.html&quot;&gt;&lt;code&gt;XA PREPARE&lt;/code&gt;&lt;/a&gt;까지 끝난 상태라면 일반 ACTIVE 트랜잭션처럼 자동 Rollback하지 않습니다. 재시작 뒤 &lt;code&gt;XA RECOVER&lt;/code&gt;로 확인한 다음 Transaction Manager가 &lt;code&gt;XA COMMIT&lt;/code&gt; 또는 &lt;code&gt;XA ROLLBACK&lt;/code&gt;을 내려야 끝납니다.&lt;/p&gt;</content:encoded></item><item><title>SIGKILL로 확인한 커밋과 롤백</title><link>https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/sigkill-crash-recovery-lab/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/innodb-wal-crash-recovery/sigkill-crash-recovery-lab/</guid><description>MySQL을 강제로 종료한 뒤 커밋과 롤백을 확인하고, 실험으로 단정할 수 없는 범위를 구분합니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;첫 번째 실험에서는 &lt;code&gt;BOOKED → CANCELLED&lt;/code&gt;를 COMMIT한 뒤 &lt;code&gt;mysqld&lt;/code&gt;를 종료했습니다. 두 번째 실험에서는 &lt;code&gt;CANCELLED → REFUNDING&lt;/code&gt;을 COMMIT하지 않은 상태로 종료했습니다.&lt;/p&gt;
&lt;h2 id=&quot;실험-환경&quot;&gt;실험 환경&lt;/h2&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;실험 환경 · 표 1, 가로 스크롤&quot;&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;MySQL&lt;/td&gt;&lt;td&gt;8.4.10&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Container Image&lt;/td&gt;&lt;td&gt;&lt;code&gt;mysql:8.4&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;저장 공간&lt;/td&gt;&lt;td&gt;Docker named volume&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Buffer Pool&lt;/td&gt;&lt;td&gt;128MiB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;innodb_flush_log_at_trx_commit&lt;/code&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;&lt;code&gt;innodb_log_writer_threads&lt;/code&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;종료 방식&lt;/td&gt;&lt;td&gt;&lt;code&gt;SIGKILL&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;종료 코드&lt;/td&gt;&lt;td&gt;&lt;code&gt;137&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;테이블은 다음처럼 만들었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;CREATE&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; TABLE&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; reservations&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  id &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;BIGINT&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; NOT NULL&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; PRIMARY KEY&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  status&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; VARCHAR&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;20&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;NOT NULL&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  notes BLOB &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;NOT NULL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) ENGINE&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;InnoDB;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;INSERT INTO&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; reservations (id, &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;status&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, notes)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;VALUES&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&apos;BOOKED&apos;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;REPEAT&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&apos;B&apos;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;7000&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;LSN 변화를 관찰하기 위해 &lt;code&gt;status&lt;/code&gt;와 함께 7,000바이트의 &lt;code&gt;notes&lt;/code&gt;도 바꿨습니다.&lt;/p&gt;
&lt;h2 id=&quot;기록한-값&quot;&gt;기록한 값&lt;/h2&gt;
&lt;p&gt;각 시점에 다음 전역 상태값을 기록했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Innodb_redo_log_current_lsn&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Innodb_redo_log_flushed_to_disk_lsn&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Innodb_redo_log_checkpoint_lsn&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;LSN은 트랜잭션이나 행의 번호가 아니라 서버 전체 Redo Stream의 위치입니다. &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/server-status-variables.html#statvar_Innodb_redo_log_current_lsn&quot;&gt;Server Status Variables&lt;/a&gt;도 Current LSN, Flushed-to-disk LSN과 Checkpoint LSN을 구분합니다.&lt;/p&gt;
&lt;p&gt;따라서 실험 전후 LSN 차이를 &lt;code&gt;id=1&lt;/code&gt; 한 행이 만든 Redo 크기로 해석할 수 없습니다. 같은 시간에 발생한 InnoDB 내부 작업도 전역 LSN을 전진시킬 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;실험-a-commit한-cancelled&quot;&gt;실험 A: COMMIT한 &lt;code&gt;CANCELLED&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;먼저 &lt;code&gt;id=1&lt;/code&gt;을 &lt;code&gt;CANCELLED&lt;/code&gt;로 바꾸고 COMMIT했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;START TRANSACTION&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;UPDATE&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; reservations&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;SET&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; status&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &apos;CANCELLED&apos;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    notes &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; REPEAT&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&apos;C&apos;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;7000&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; id &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;COMMIT&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;변경 전과 COMMIT 뒤에 관찰한 값입니다.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;실험 A: COMMIT한 CANCELLED · 표 2, 가로 스크롤&quot;&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;시점&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Current LSN&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Flushed-to-disk LSN&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Checkpoint LSN&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 align=&quot;right&quot;&gt;29,593,263&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;29,593,263&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;29,523,001&lt;/td&gt;&lt;td&gt;&lt;code&gt;BOOKED&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;COMMIT 뒤&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;29,610,304&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;29,610,304&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;29,523,001&lt;/td&gt;&lt;td&gt;&lt;code&gt;CANCELLED&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Current LSN과 Flushed-to-disk LSN은 17,041 증가했고, 관찰 시점에는 두 값이 같았습니다. Checkpoint LSN은 그대로였습니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit&quot;&gt;&lt;code&gt;innodb_flush_log_at_trx_commit=1&lt;/code&gt;&lt;/a&gt;이면 COMMIT에서 Redo write와 durable flush를 기다립니다. 저장 계층이 flush 요청을 정상적으로 처리한다는 전제입니다.&lt;/p&gt;
&lt;p&gt;Checkpoint가 전진하지 않은 상태에서도 COMMIT은 끝났습니다. 이는 Data Page Flush가 COMMIT 완료 조건이 아니라는 설명과 맞습니다. 하지만 이 표만으로 &lt;code&gt;id=1&lt;/code&gt;의 Data Page가 아직 Flush되지 않았다고 증명할 수는 없습니다. Page Cleaner가 종료 전에 해당 Page를 저장했을 가능성도 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;첫-번째-sigkill-뒤-redo-recovery&quot;&gt;첫 번째 SIGKILL 뒤 Redo Recovery&lt;/h2&gt;
&lt;p&gt;COMMIT 뒤 Container에 &lt;code&gt;SIGKILL&lt;/code&gt;을 보냈고 같은 named volume으로 다시 시작했습니다. 재시작 로그에는 다음 내용이 남았습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Database was not shutdown normally!&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Starting crash recovery.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Doing recovery: scanned up to log sequence number 29610314&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Applying a batch of 73 redo log records ...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Apply batch completed!&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;재시작 뒤 행을 조회한 결과입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;id  status       note_marker&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;1   CANCELLED    C&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 &lt;code&gt;73&lt;/code&gt;은 행이나 개별 Redo Record 수가 아닙니다. MySQL 8.4.10 소스상 해당 Recovery Batch에 등록된 Page address entry 수입니다. &lt;a href=&quot;https://github.com/mysql/mysql-server/blob/mysql-8.4.10/storage/innobase/log/log0recv.cc#L1138-L1153&quot;&gt;출력 코드&lt;/a&gt;는 &lt;code&gt;recv_sys-&amp;gt;n_addrs&lt;/code&gt;를 사용합니다. 이 로그만으로 &lt;code&gt;id=1&lt;/code&gt;의 Redo가 실제 적용됐다고 볼 수는 없습니다.&lt;/p&gt;
&lt;h2 id=&quot;실험-b-commit하지-않은-refunding&quot;&gt;실험 B: COMMIT하지 않은 &lt;code&gt;REFUNDING&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;첫 번째 재시작 뒤 값은 &lt;code&gt;CANCELLED&lt;/code&gt;였습니다. 이번에는 새 Transaction을 열고 &lt;code&gt;REFUNDING&lt;/code&gt;으로 바꾼 뒤 COMMIT하지 않았습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;START TRANSACTION&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;UPDATE&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; reservations&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;SET&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; status&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &apos;REFUNDING&apos;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    notes &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; REPEAT&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&apos;U&apos;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;7000&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; id &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; status&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; reservations&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; id &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; SLEEP(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;600&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;강제 종료 전에는 다음 상태를 확인했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;변경한 세션&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  status = REFUNDING&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;다른 세션&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  status = CANCELLED&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;information_schema.innodb_trx&lt;/code&gt;에서는 수정 중인 Transaction이 보였습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;trx_state  trx_rows_modified&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RUNNING    1&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;InnoDB Monitor에도 Undo Entry가 남았습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;---TRANSACTION 2319, ACTIVE 3 sec&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;2 lock struct(s), heap size 1128, 1 row lock(s), undo log entries 1&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 시점의 전역 표본과 InnoDB Monitor 기록은 다음과 같았습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Current LSN          = 29,672,903&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Flushed-to-disk LSN  = 29,672,903&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Checkpoint LSN       = 29,672,903&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Pages flushed up to  = 29,672,903&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Modified db pages    = 0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;COMMIT하지 않았는데도 Flushed-to-disk LSN이 Current LSN까지 진행했습니다. 미완료 트랜잭션의 Redo도 영속화될 수 있다는 설명과 맞습니다.&lt;/p&gt;
&lt;p&gt;Checkpoint LSN은 Current LSN까지 전진했고, 같은 표본에서 &lt;code&gt;Modified db pages&lt;/code&gt;는 0이었습니다. 관찰 시점에 Buffer Pool이 추적하던 Dirty Page가 없었다는 뜻이며, 미커밋 변경도 COMMIT 전에 Flush될 수 있다는 STEAL 흐름과 맞습니다. 다만 &lt;code&gt;REFUNDING&lt;/code&gt;을 담은 개별 Page ID나 Data File의 바이트를 대조하지 않았으므로, 그 Page가 실제로 저장됐다고 이 표본만으로 단정할 수는 없습니다.&lt;/p&gt;
&lt;h2 id=&quot;두-번째-sigkill-뒤-transaction-rollback&quot;&gt;두 번째 SIGKILL 뒤 Transaction Rollback&lt;/h2&gt;
&lt;p&gt;열린 Transaction을 그대로 둔 채 다시 &lt;code&gt;SIGKILL&lt;/code&gt;을 보냈습니다. 두 번째 재시작의 Redo Application Batch는 0이었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Database was not shutdown normally!&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Starting crash recovery.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Doing recovery: scanned up to log sequence number 29672903&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Applying a batch of 0 redo log records ...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Apply batch completed!&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이어 미완료 Transaction을 복원하고 Rollback한 로그가 남았습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Transaction ID: 2319 found for resurrecting updates&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Total records resurrected: 1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Resurrected 1 transactions doing updates.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;1 transaction(s) which must be rolled back or cleaned up&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;in total 1 row operations to undo&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Starting in background the rollback of uncommitted transactions&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Rolling back trx with id 2319, 1 rows to undo&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Rollback of trx with id 2319 completed&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Rollback of non-prepared transactions completed&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;재시작 뒤 결과입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;id  status&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;1   CANCELLED&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;open_transactions&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Redo Batch가 0이라는 로그는 해당 Batch에서 처리할 Page address가 없었다는 뜻입니다. 미완료 트랜잭션의 Rollback은 그 뒤 별도 단계로 진행됐습니다. 로그에는 Transaction 2319를 복원하고 백그라운드에서 한 행을 되돌린 과정이 남았습니다.&lt;/p&gt;
&lt;h2 id=&quot;실험에서-확인한-범위&quot;&gt;실험에서 확인한 범위&lt;/h2&gt;
&lt;p&gt;두 번 모두 InnoDB가 비정상 종료를 감지하고 Crash Recovery를 시작했습니다. 첫 재시작 뒤에는 COMMIT한 &lt;code&gt;CANCELLED&lt;/code&gt;가 남았습니다. 두 번째 종료 전에는 미완료 Transaction과 Undo Entry가 있었고, 재시작 과정에서 한 행을 Rollback한 뒤 &lt;code&gt;CANCELLED&lt;/code&gt;로 돌아왔습니다. 두 번째 종료 직전 표본에서는 Checkpoint LSN이 Current LSN까지 전진했고 &lt;code&gt;Modified db pages&lt;/code&gt;가 0이었습니다.&lt;/p&gt;
&lt;p&gt;이 실험은 &lt;code&gt;mysqld&lt;/code&gt; Process Crash만 재현했습니다. 첫 번째 종료 직전 &lt;code&gt;id=1&lt;/code&gt;의 Data Page가 &lt;code&gt;BOOKED&lt;/code&gt;였는지, 첫 복구에서 특정 Redo가 실제로 적용됐는지, &lt;code&gt;REFUNDING&lt;/code&gt;을 담은 개별 Page의 바이트가 어떻게 바뀌었는지는 확인하지 못했습니다. Docker Host와 저장 장치의 전원은 유지됐으므로 전원 장애 실험으로도 볼 수 없습니다.&lt;/p&gt;</content:encoded></item><item><title>TCP 바이트 스트림과 Seq/ACK</title><link>https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/byte-stream-seq-ack/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/byte-stream-seq-ack/</guid><description>TCP가 Seq와 ACK로 받은 바이트와 빈 구간을 확인하는 방식을 살펴봅니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9293#section-2.2&quot;&gt;RFC 9293&lt;/a&gt;은 TCP를 신뢰할 수 있고 순서가 보장되는 바이트 스트림 서비스로 정의합니다. 여기서 기준은 메시지나 파일이 아니라 바이트입니다.&lt;/p&gt;
&lt;p&gt;애플리케이션이 소켓에 데이터를 두 번 나눠 썼다고 해보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;write(&quot;ABC&quot;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;write(&quot;DEF&quot;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;수신 애플리케이션이 같은 경계로 &lt;code&gt;ABC&lt;/code&gt;, &lt;code&gt;DEF&lt;/code&gt;를 읽는다는 보장은 없습니다. 한 번에 &lt;code&gt;ABCDEF&lt;/code&gt;를 읽을 수도 있고, &lt;code&gt;AB&lt;/code&gt;와 &lt;code&gt;CDEF&lt;/code&gt;로 나눠 읽을 수도 있습니다. TCP는 두 번의 &lt;code&gt;write()&lt;/code&gt; 호출을 두 개의 메시지로 보존하지 않습니다. 바이트의 내용과 순서를 보존할 뿐입니다. 메시지 경계가 필요하면 애플리케이션 프로토콜에서 길이 필드나 구분자 등으로 정해야 합니다.&lt;/p&gt;
&lt;h2 id=&quot;연결마다-두-개의-시퀀스-공간이-있다&quot;&gt;연결마다 두 개의 시퀀스 공간이 있다&lt;/h2&gt;
&lt;p&gt;TCP 연결은 양방향입니다. 클라이언트가 서버로 보내는 바이트와 서버가 클라이언트로 보내는 바이트는 서로 다른 시퀀스 공간을 사용합니다. 연결을 시작할 때 주고받는 SYN에는 각 방향의 초기 시퀀스 번호가 담깁니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;클라이언트                              서버&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   | -------- SYN, Seq=x ------------&amp;gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   | &amp;lt;--- SYN+ACK, Seq=y, Ack=x+1 ----- |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   | -------- ACK, Ack=y+1 -----------&amp;gt; |&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SYN은 데이터가 없어도 시퀀스 번호 하나를 사용합니다. 일반적인 3-way handshake가 끝난 뒤 클라이언트가 보내는 첫 데이터 바이트의 번호는 &lt;code&gt;x+1&lt;/code&gt;, 서버가 보내는 첫 데이터 바이트의 번호는 &lt;code&gt;y+1&lt;/code&gt;부터 시작합니다. 연결을 닫을 때 사용하는 FIN도 시퀀스 번호 하나를 사용합니다.&lt;/p&gt;
&lt;h2 id=&quot;tcp-헤더에서-먼저-볼-값&quot;&gt;TCP 헤더에서 먼저 볼 값&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9293#section-3.1&quot;&gt;RFC 9293의 TCP 헤더 형식&lt;/a&gt;에는 여러 필드가 있습니다. 손실 감지 흐름을 읽을 때는 다음 값을 먼저 보면 됩니다.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;TCP 헤더에서 먼저 볼 값 · 표 1, 가로 스크롤&quot;&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;Sequence Number&lt;/td&gt;&lt;td&gt;SYN이 없는 데이터 세그먼트에 실린 첫 바이트의 번호입니다. 일반적인 handshake 뒤 첫 데이터 바이트는 &lt;code&gt;ISN+1&lt;/code&gt;부터 시작합니다.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Acknowledgment Number&lt;/td&gt;&lt;td&gt;ACK 플래그가 설정됐을 때 유효하며, 수신자가 다음으로 기대하는 시퀀스 번호입니다.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;Sequence Number와 Acknowledgment Number는 모두 32비트이며 계산은 &lt;code&gt;2^32&lt;/code&gt;를 기준으로 순환합니다. 실제 캡처에서는 값이 크게 보이기 때문에 Wireshark가 연결 시작점을 0으로 맞춘 상대 시퀀스 번호를 보여주기도 합니다. 어떤 표시 방식을 사용하든 두 필드가 가리키는 것은 시퀀스 공간의 위치입니다.&lt;/p&gt;
&lt;h3 id=&quot;데이터-구간에서-seq는-첫-바이트의-번호다&quot;&gt;데이터 구간에서 Seq는 첫 바이트의 번호다&lt;/h3&gt;
&lt;p&gt;TCP 세그먼트가 &lt;code&gt;Seq=1000&lt;/code&gt;, &lt;code&gt;Len=400&lt;/code&gt;이라고 해보겠습니다. 이 세그먼트가 담은 바이트 범위는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;[1000, 1400)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;대괄호 쪽의 1000은 포함하고, 소괄호 쪽의 1400은 포함하지 않습니다. 실제로 담긴 마지막 바이트 번호는 1399입니다. 이렇게 반열린 구간으로 적으면 길이는 &lt;code&gt;1400 - 1000 = 400&lt;/code&gt;으로 바로 계산할 수 있고, 다음 구간도 &lt;code&gt;[1400, 1800)&lt;/code&gt;처럼 빈틈없이 이어집니다.&lt;/p&gt;
&lt;p&gt;SYN이 없는 데이터 세그먼트에서 Seq는 세그먼트의 순번이 아니라 첫 데이터 바이트의 번호입니다. &lt;code&gt;Seq=1000&lt;/code&gt;인 세그먼트를 잃었다고 해서 다음 세그먼트의 Seq가 1001이 되는 것은 아닙니다. 첫 세그먼트가 400바이트였다면 다음 세그먼트는 보통 1400에서 시작합니다. 세그먼트 크기가 달라져도 바이트 범위를 기준으로 보면 어느 구간이 이어지고 비었는지 확인할 수 있습니다.&lt;/p&gt;
&lt;h3 id=&quot;ack는-다음에-받을-바이트를-가리킨다&quot;&gt;ACK는 다음에 받을 바이트를 가리킨다&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Ack=1400&lt;/code&gt;은 1400번 바이트를 받았다는 뜻이 아닙니다. 1400 바로 전까지 연속해서 받았고, 다음에는 1400번 바이트를 기대한다는 뜻입니다. 따라서 &lt;code&gt;[1000, 1400)&lt;/code&gt;을 정상적으로 받았다면 수신자는 &lt;code&gt;Ack=1400&lt;/code&gt;을 보낼 수 있습니다.&lt;/p&gt;
&lt;p&gt;이 ACK는 누적 방식입니다. &lt;code&gt;Ack=2200&lt;/code&gt;으로 값이 진행됐다면 수신 TCP가 2200 미만의 바이트를 연속된 구간으로 받았다는 뜻입니다. 앞서 보낸 세그먼트마다 ACK 번호를 따로 나열할 필요가 없습니다. ACK 번호 하나로 그보다 앞선 연속 구간 전체를 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;ACK가 왔다고 애플리케이션 처리까지 끝난 것은 아닙니다. ACK는 상대 TCP가 바이트를 받았다는 뜻입니다. 상대 애플리케이션이 데이터를 읽었는지, 데이터베이스에 반영했는지는 알 수 없습니다.&lt;/p&gt;
&lt;h3 id=&quot;중간-구간이-비면-ack가-앞으로-가지-못한다&quot;&gt;중간 구간이 비면 ACK가 앞으로 가지 못한다&lt;/h3&gt;
&lt;p&gt;누적 ACK는 연속된 바이트 범위만 표현합니다. 중간 구간이 비어 있으면 그 뒤의 데이터를 먼저 받더라도 ACK 번호를 빈 구간 너머로 진행할 수 없습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;송신자                                              수신자&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  | -- Seq=1000, Len=400  [1000, 1400) ----------&amp;gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  | &amp;lt;------------------------------- Ack=1400 ----- |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  | -- Seq=1400, Len=400  [1400, 1800) --X          |  전달되지 않음&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  | -- Seq=1800, Len=400  [1800, 2200) ----------&amp;gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  | &amp;lt;------------------------------- Ack=1400 ----- |  1400부터 비어 있음&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  | -- Seq=1400, Len=400  [1400, 1800) ----------&amp;gt; |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  | &amp;lt;------------------------------- Ack=2200 ----- |  구간이 연결됨&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;수신자는 &lt;code&gt;[1800, 2200)&lt;/code&gt;을 보관하고 있더라도 &lt;code&gt;[1400, 1800)&lt;/code&gt;을 받기 전에는 다음 기대값을 2200이라고 말할 수 없습니다. 빠져 있던 구간이 도착하면 보관 중이던 뒤쪽 구간과 이어지고, ACK가 한 번에 2200으로 진행합니다.&lt;/p&gt;
&lt;p&gt;실제로는 지연 ACK 때문에 모든 데이터 세그먼트에 ACK가 하나씩 돌아오지 않을 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;같은-ack가-반복돼도-손실이-확정된-것은-아니다&quot;&gt;같은 ACK가 반복돼도 손실이 확정된 것은 아니다&lt;/h2&gt;
&lt;p&gt;같은 ACK가 반복되면 수신자가 아직 연속해서 받지 못한 구간이 있다는 뜻입니다. 다만 세그먼트의 순서가 바뀌거나 데이터가 중복된 경우에도 같은 현상이 나타날 수 있으므로, 이것만으로 손실을 확정할 수는 없습니다.&lt;/p&gt;</content:encoded></item><item><title>RTO로 손실을 감지하는 과정</title><link>https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/rto-rtt-karn-backoff/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/rto-rtt-karn-backoff/</guid><description>RTT로 타임아웃을 계산하고, 재전송과 연속 타임아웃에 대응하는 과정을 정리합니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;ACK가 오지 않았다고 세그먼트 손실을 바로 확정할 수는 없습니다. 데이터나 ACK가 늦어진 것일 수도 있습니다. TCP는 가장 오래 확인되지 않은 데이터가 일정 시간 남아 있으면 재전송합니다. 이때 기다리는 시간이 **RTO(Retransmission Timeout)**입니다. 계산과 타이머 관리는 &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6298.html&quot;&gt;RFC 6298&lt;/a&gt;에 정의되어 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;고정된-시간으로-기다리지-않는-이유&quot;&gt;고정된 시간으로 기다리지 않는 이유&lt;/h2&gt;
&lt;p&gt;RTO를 모든 연결에서 같은 값으로 정하면 경로의 차이를 반영할 수 없습니다. 같은 서버에 접속하더라도 유선망과 무선망의 왕복 시간은 다를 수 있고, 하나의 연결 안에서도 지연은 계속 변합니다.&lt;/p&gt;
&lt;p&gt;기다리는 시간이 실제 왕복 시간보다 지나치게 짧으면 아직 이동 중인 세그먼트를 손실로 잘못 판단해 같은 데이터를 다시 보낼 수 있습니다. 반대로 너무 길면 실제 손실이 생겼을 때 복구가 늦어집니다. TCP는 최근에 측정한 RTT뿐 아니라 RTT가 얼마나 흔들렸는지도 함께 사용해 RTO를 계산합니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6298.html#section-2&quot;&gt;RFC 6298 §2&lt;/a&gt;는 다음 세 값을 사용합니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SRTT&lt;/code&gt;: 측정한 RTT를 평활한 값&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RTTVAR&lt;/code&gt;: RTT 변화 폭을 평활한 값&lt;/li&gt;
&lt;li&gt;&lt;code&gt;G&lt;/code&gt;: 타이머가 시간을 구분할 수 있는 단위&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;첫 RTT 표본을 &lt;code&gt;R&lt;/code&gt;이라고 하면 초기값은 다음과 같습니다. &lt;code&gt;K&lt;/code&gt;는 4입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;SRTT   = R&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RTTVAR = R / 2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RTO    = SRTT + max(G, K × RTTVAR)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RTT를 한 번 측정했다고 해서 그 값을 그대로 RTO로 사용하지 않습니다. 첫 표본의 절반을 편차의 초기값으로 잡고, 그 편차의 네 배를 더해 여유를 둡니다.&lt;/p&gt;
&lt;p&gt;두 번째부터는 새 표본 &lt;code&gt;R&apos;&lt;/code&gt;을 기존 값에 섞습니다. &lt;code&gt;RTTVAR&lt;/code&gt;를 먼저 계산할 때는 갱신 전의 &lt;code&gt;SRTT&lt;/code&gt;를 사용해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RTTVAR = (1 - 1/4) × RTTVAR + 1/4 × |SRTT - R&apos;|&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;SRTT   = (1 - 1/8) × SRTT   + 1/8 × R&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RTO    = SRTT + max(G, 4 × RTTVAR)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SRTT&lt;/code&gt;는 새 표본을 1/8만 반영하므로 순간적인 변화에 바로 끌려가지 않습니다. &lt;code&gt;RTTVAR&lt;/code&gt;는 기존 평균과 새 표본의 차이를 반영합니다. 왕복 시간이 불안정할수록 편차가 커지고 RTO도 늘어납니다.&lt;/p&gt;
&lt;h2 id=&quot;100ms-다음에-140ms가-측정된-경우&quot;&gt;100ms 다음에 140ms가 측정된 경우&lt;/h2&gt;
&lt;p&gt;타이머 단위 &lt;code&gt;G&lt;/code&gt;가 계산에 영향을 주지 않을 만큼 작다고 두고 직접 계산해보겠습니다.&lt;/p&gt;
&lt;p&gt;첫 RTT가 100ms라면 다음과 같이 초기화됩니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;SRTT   = 100ms&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RTTVAR = 50ms&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;계산된 RTO = 100 + 4 × 50 = 300ms&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그다음 RTT가 140ms로 측정되면 &lt;code&gt;RTTVAR&lt;/code&gt;를 먼저 갱신합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RTTVAR = 3/4 × 50 + 1/4 × |100 - 140|&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;       = 37.5 + 10&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;       = 47.5ms&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;SRTT   = 7/8 × 100 + 1/8 × 140&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;       = 87.5 + 17.5&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;       = 105ms&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;계산된 RTO = 105 + 4 × 47.5&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;           = 295ms&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;새 RTT는 40ms 늘었지만 SRTT는 5ms만 늘었습니다. 한 표본만으로 경로의 평균이 크게 바뀌지 않게 만든 결과입니다. 편차도 함께 반영되므로 &lt;code&gt;RTO = RTT의 두 배&lt;/code&gt;처럼 고정된 비율로 설명할 수 없습니다.&lt;/p&gt;
&lt;p&gt;RFC 6298은 계산 결과가 1초보다 작으면 1초로 올리고, RTT 표본을 얻기 전의 초기 RTO도 1초로 둘 것을 권고합니다. 실제 최솟값과 초기값은 구현에 따라 다를 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;재전송한-세그먼트로-rtt를-재기-어려운-이유&quot;&gt;재전송한 세그먼트로 RTT를 재기 어려운 이유&lt;/h2&gt;
&lt;p&gt;RTT는 데이터를 보낸 시점부터 그 데이터를 확인하는 ACK가 돌아온 시점까지 측정할 수 있습니다. 하지만 같은 데이터를 재전송한 뒤 받은 ACK에는 어느 전송에 대한 응답인지 표시되어 있지 않을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;t0  원본 세그먼트 전송&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;t1  RTO 만료, 같은 바이트 범위 재전송&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;t2  ACK 수신&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;t2 - t0&lt;/code&gt;를 사용하면 원본이 늦게 도착한 경우에는 맞지만, 재전송본에 대한 ACK였다면 RTT를 너무 길게 계산합니다. &lt;code&gt;t2 - t1&lt;/code&gt;을 사용하면 그 반대의 문제가 생깁니다. ACK만 보고 둘 중 하나를 고를 근거가 없습니다.&lt;/p&gt;
&lt;p&gt;이 문제를 다루는 규칙이 &lt;strong&gt;Karn 알고리즘&lt;/strong&gt;입니다. &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6298.html#section-3&quot;&gt;RFC 6298 §3&lt;/a&gt;는 재전송된 세그먼트에서 RTT 표본을 만들지 않도록 규정합니다. 모호한 값을 억지로 평균에 넣지 않고, 재전송되지 않은 새로운 데이터가 확인될 때 다시 RTT를 측정합니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc7323.html#section-4.1&quot;&gt;TCP Timestamp&lt;/a&gt;를 협상한 연결에서는 &lt;code&gt;TSval&lt;/code&gt;과 &lt;code&gt;TSecr&lt;/code&gt;로 전송을 구분해 재전송 모호성을 줄일 수 있습니다. 이때도 &lt;code&gt;SND.UNA&lt;/code&gt;를 전진시키는 ACK에서 RTT 표본을 얻습니다.&lt;/p&gt;
&lt;h2 id=&quot;타임아웃이-반복되면-기다리는-시간도-늘어난다&quot;&gt;타임아웃이 반복되면 기다리는 시간도 늘어난다&lt;/h2&gt;
&lt;p&gt;RTO가 만료되면 TCP는 확인되지 않은 세그먼트를 전부 한꺼번에 보내지 않습니다. &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6298.html#section-5&quot;&gt;RFC 6298 §5&lt;/a&gt;의 권고 절차에서는 &lt;strong&gt;아직 확인되지 않은 세그먼트 중 가장 앞선 것&lt;/strong&gt;을 재전송합니다. 그리고 현재 RTO를 두 배로 늘린 뒤 타이머를 다시 시작합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;RTO 만료 → 가장 앞선 미확인 세그먼트 재전송&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;         → RTO = RTO × 2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;         → 늘어난 RTO로 타이머 재시작&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 과정을 지수 백오프라고 합니다. 예를 들어 현재 RTO가 1초이고 새로운 RTT 표본을 얻지 못한 채 타임아웃이 반복되면 대기 시간은 2초, 4초, 8초처럼 늘어납니다. 구현은 상한을 둘 수 있지만 RFC 6298은 그 상한을 적어도 60초로 두도록 합니다.&lt;/p&gt;
&lt;p&gt;피드백이 없는 동안 같은 간격으로 재전송하면 불안정한 경로에 부담을 더할 수 있습니다. 유효한 RTT 표본을 다시 얻으면 SRTT와 RTTVAR를 갱신해 RTO를 조정합니다.&lt;/p&gt;
&lt;p&gt;RTO 타이머는 세그먼트마다 독립된 타이머가 하나씩 붙는 형태로 이해할 필요는 없습니다. 새 데이터를 확인하는 ACK가 오면 타이머를 다시 시작하고, 보낸 데이터가 모두 확인되면 끕니다. 만료 시점에는 누적 ACK 기준으로 가장 오래 확인되지 않은 세그먼트부터 처리합니다.&lt;/p&gt;</content:encoded></item><item><title>중복 ACK를 이용한 빠른 재전송과 SACK</title><link>https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/dupack-fast-retransmit-sack/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/dupack-fast-retransmit-sack/</guid><description>중복 ACK와 SACK으로 손실 구간을 찾고 재전송하는 과정을 따라갑니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;뒤쪽 데이터가 먼저 도착하면 수신자는 같은 ACK를 반복해서 보냅니다. 송신자는 이 중복 ACK를 손실 신호로 삼아 RTO가 만료되기 전에 Fast Retransmit를 시작할 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;누적-ack는-빈-구간을-넘지-않는다&quot;&gt;누적 ACK는 빈 구간을 넘지 않는다&lt;/h2&gt;
&lt;p&gt;TCP의 ACK 번호는 수신자가 다음에 받기를 기대하는 바이트의 시퀀스 번호입니다. &lt;code&gt;ACK=1001&lt;/code&gt;이라면 1000번 바이트까지 연속해서 받았다는 뜻입니다.&lt;/p&gt;
&lt;p&gt;이 상태에서 &lt;code&gt;[1001, 2001)&lt;/code&gt; 구간이 손실되고 &lt;code&gt;[2001, 3001)&lt;/code&gt; 구간이 먼저 도착했다고 가정해보겠습니다. 수신자는 2001번 이후의 데이터를 받았지만 1001번부터 시작하는 빈 구간이 남아 있으므로 ACK 번호를 3001로 올릴 수 없습니다. 대신 기존과 같은 &lt;code&gt;ACK=1001&lt;/code&gt;을 보냅니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc5681.html#section-3.2&quot;&gt;RFC 5681 3.2절&lt;/a&gt;은 순서가 어긋난 세그먼트가 도착하면 수신자가 중복 ACK를 바로 보내도록 권고합니다. 일반적인 지연 ACK처럼 잠시 기다리지 않고, 현재 어느 시퀀스 번호를 기대하는지 송신자에게 알립니다. 뒤의 데이터가 계속 도착하는 동안 빈 구간이 채워지지 않으면 같은 ACK 번호가 반복됩니다.&lt;/p&gt;
&lt;p&gt;아래 예시는 미확인 데이터가 남아 있는 동안 ACK 번호와 광고 윈도우가 변하지 않은 순수 ACK가 세 번 도착하는 경우입니다. 각 데이터 세그먼트는 1000바이트라고 가정했습니다.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;누적 ACK는 빈 구간을 넘지 않는다 · 표 1, 가로 스크롤&quot;&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th align=&quot;right&quot;&gt;순서&lt;/th&gt;&lt;th&gt;수신자에게 도착한 범위&lt;/th&gt;&lt;th&gt;수신자의 응답&lt;/th&gt;&lt;th&gt;송신자가 확인한 상태&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;1&lt;/td&gt;&lt;td&gt;&lt;code&gt;[1001, 2001)&lt;/code&gt; 손실&lt;/td&gt;&lt;td&gt;응답 없음&lt;/td&gt;&lt;td&gt;아직 손실 여부를 알 수 없음&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;2&lt;/td&gt;&lt;td&gt;&lt;code&gt;[2001, 3001)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=1001&lt;/code&gt;, &lt;code&gt;SACK=[2001, 3001)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;첫 번째 중복 ACK&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;3&lt;/td&gt;&lt;td&gt;&lt;code&gt;[3001, 4001)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=1001&lt;/code&gt;, &lt;code&gt;SACK=[2001, 4001)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;두 번째 중복 ACK&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;4&lt;/td&gt;&lt;td&gt;&lt;code&gt;[4001, 5001)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=1001&lt;/code&gt;, &lt;code&gt;SACK=[2001, 5001)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;세 번째 중복 ACK, &lt;code&gt;[1001, 2001)&lt;/code&gt; 재전송&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;5&lt;/td&gt;&lt;td&gt;재전송한 &lt;code&gt;[1001, 2001)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=5001&lt;/code&gt;&lt;/td&gt;&lt;td&gt;빈 구간이 채워져 누적 ACK가 전진&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;SACK을 사용하지 않아도 위 조건을 만족하는 중복 ACK가 세 번 도착하면 Fast Retransmit가 시작됩니다. 다만 송신자가 알 수 있는 정보가 &lt;code&gt;ACK=1001&lt;/code&gt;이라는 경계에 한정됩니다.&lt;/p&gt;
&lt;h2 id=&quot;중복-ack-세-개는-손실의-증명이-아니다&quot;&gt;중복 ACK 세 개는 손실의 증명이 아니다&lt;/h2&gt;
&lt;p&gt;같은 ACK 번호가 반복됐다는 사실만으로 특정 세그먼트가 사라졌다고 단정할 수는 없습니다. &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc5681.html#section-3.2&quot;&gt;RFC 5681&lt;/a&gt;은 중복 ACK가 생기는 원인으로 세그먼트 손실 외에도 세그먼트 순서가 바뀌거나 ACK·데이터가 복제되는 경우를 듭니다.&lt;/p&gt;
&lt;p&gt;예를 들어 &lt;code&gt;[1001, 2001)&lt;/code&gt;이 느린 경로를 지나고 뒤의 세그먼트 세 개가 먼저 도착하면 수신자는 같은 ACK를 세 번 보냅니다. 송신자는 이를 손실로 판단해 &lt;code&gt;[1001, 2001)&lt;/code&gt;을 다시 보낼 수 있지만, 원본 세그먼트가 조금 늦게 도착하면 결과적으로 불필요한 재전송이 됩니다.&lt;/p&gt;
&lt;p&gt;중복 ACK 세 개는 손실을 증명하는 조건이 아니라 RTO보다 일찍 복구를 시작하기 위한 기준입니다. 패킷 캡처에서는 시퀀스 번호와 도착 시간을 함께 봐야 합니다.&lt;/p&gt;
&lt;h2 id=&quot;fast-retransmit가-동작하지-않는-경우&quot;&gt;Fast Retransmit가 동작하지 않는 경우&lt;/h2&gt;
&lt;p&gt;일반적인 손실 상황에서 Fast Retransmit를 시작하려면 손실된 구간 뒤의 데이터가 수신자에게 도착해야 합니다. 뒤의 세그먼트가 도착할 때마다 중복 ACK를 만들 수 있기 때문입니다.&lt;/p&gt;
&lt;p&gt;전송 중인 데이터가 적어서 손실 지점 뒤에 세그먼트가 하나나 둘뿐이면 중복 ACK 세 개를 만들지 못할 수 있습니다. RFC 5681의 Limited Transmit는 첫 번째와 두 번째 중복 ACK에서 조건이 맞으면 새 데이터를 보내 ACK를 더 받을 기회를 만듭니다. 이때 보낼 데이터가 남아 있고 수신 윈도우가 허용해야 하며, 전송 중인 데이터는 &lt;code&gt;cwnd + 2 × SMSS&lt;/code&gt;를 넘지 않아야 합니다. SACK을 사용한다면 해당 중복 ACK에 새 SACK 정보도 있어야 합니다.&lt;/p&gt;
&lt;p&gt;마지막 세그먼트가 손실되면 뒤이어 도착할 데이터가 없어 수신자가 중복 ACK를 만들 수 없습니다. 이때 기본적인 복구 경로는 RTO입니다.&lt;/p&gt;
&lt;h2 id=&quot;fast-retransmit와-fast-recovery는-역할이-다르다&quot;&gt;Fast Retransmit와 Fast Recovery는 역할이 다르다&lt;/h2&gt;
&lt;p&gt;Fast Retransmit는 중복 ACK를 손실 신호로 보고 누락된 세그먼트를 다시 보내는 동작입니다. Fast Recovery는 그 뒤 혼잡 윈도우를 조정하며 전송을 이어가는 절차입니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc5681.html#section-3.2&quot;&gt;RFC 5681&lt;/a&gt;의 기본 절차에서는 세 번째 중복 ACK에서 &lt;code&gt;ssthresh&lt;/code&gt;를 줄이고 &lt;code&gt;cwnd&lt;/code&gt;를 일시적으로 늘립니다. 재전송을 시작할 때 미확인이던 데이터가 모두 확인되면 Fast Recovery를 끝내고 &lt;code&gt;cwnd&lt;/code&gt;를 &lt;code&gt;ssthresh&lt;/code&gt;로 낮춥니다.&lt;/p&gt;
&lt;h2 id=&quot;sack은-받은-범위를-알려준다&quot;&gt;SACK은 받은 범위를 알려준다&lt;/h2&gt;
&lt;p&gt;누적 ACK만으로도 첫 번째 빈 구간의 시작은 알 수 있습니다. 하지만 한 번에 여러 세그먼트가 손실되면 그 뒤의 상태는 분명하지 않습니다. ACK 번호가 첫 번째 빈 구간에 머물러 있기 때문입니다.&lt;/p&gt;
&lt;p&gt;SACK(Selective Acknowledgment)은 수신자가 순서와 상관없이 이미 받은 바이트 범위를 함께 알려주는 TCP 옵션입니다. SACK 옵션을 처리할 수 있는 TCP는 SYN에 &lt;code&gt;SACK-Permitted&lt;/code&gt;를 넣어 이 사실을 알립니다. 이 옵션을 받은 쪽은 이후 해당 상대에게 보내는 ACK에 하나 이상의 SACK 블록을 담을 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc2018.html#section-3&quot;&gt;RFC 2018&lt;/a&gt;의 SACK 블록은 &lt;code&gt;[Left Edge, Right Edge)&lt;/code&gt; 형식입니다. 왼쪽 값은 받은 범위의 첫 시퀀스 번호이고, 오른쪽 값은 마지막으로 받은 바이트의 다음 시퀀스 번호입니다. 무엇이 손실됐는지가 아니라 무엇을 받았는지를 나타냅니다. SACK을 사용해도 TCP 헤더의 ACK 번호는 누적 ACK를 나타냅니다.&lt;/p&gt;
&lt;p&gt;다음 상태를 예로 들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;누적 ACK       : 1001&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;전송한 범위    : [1001, 6001)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;SACK 블록      : [2001, 3001), [4001, 6001)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;송신자가 보는 빈 구간: [1001, 2001), [3001, 4001)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;수신자가 보낸 것은 현재 받았다고 알린 두 범위입니다. 송신자는 자신이 전송한 범위, 누적 ACK, SACK 블록을 비교해 그 사이의 빈 구간을 손실 후보로 추정합니다. SACK 옵션의 크기는 TCP 옵션 공간에 제한되므로 모든 수신 범위가 매 ACK에 담긴다는 보장도 없습니다.&lt;/p&gt;
&lt;h2 id=&quot;sack을-바탕으로-재전송-구간을-고른다&quot;&gt;SACK을 바탕으로 재전송 구간을 고른다&lt;/h2&gt;
&lt;p&gt;SACK은 받은 범위를 알릴 뿐 재전송 대상을 정하지는 않습니다. 송신자는 누적 ACK와 SACK 정보를 scoreboard에 기록하고, 아직 네트워크에 남아 있다고 추정되는 데이터 양을 계산해 보낼 구간을 고릅니다. SACK 블록 사이에 빈 구간이 보인다고 즉시 손실로 확정하는 것은 아닙니다. 이 송신자 측 복구 절차는 &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6675.html&quot;&gt;RFC 6675&lt;/a&gt;에 정의되어 있습니다.&lt;/p&gt;
&lt;p&gt;수신자는 한 번 SACK으로 알린 데이터를 나중에 버릴 수도 있습니다. 따라서 해당 범위가 누적 ACK에 포함되기 전까지는 송신 버퍼에서 제거하면 안 됩니다.&lt;/p&gt;</content:encoded></item><item><title>마지막 세그먼트가 손실됐을 때 - TLP와 RACK</title><link>https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/tail-loss-tlp-rack/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/tail-loss-tlp-rack/</guid><description>마지막 세그먼트가 손실됐을 때 TLP와 RACK이 복구를 돕는 방식을 살펴봅니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;h2 id=&quot;tail-loss에서는-중복-ack를-기대하기-어렵다&quot;&gt;Tail Loss에서는 중복 ACK를 기대하기 어렵다&lt;/h2&gt;
&lt;p&gt;송신자가 &lt;code&gt;P0&lt;/code&gt;, &lt;code&gt;P1&lt;/code&gt;, &lt;code&gt;P2&lt;/code&gt;, &lt;code&gt;P3&lt;/code&gt;을 차례로 보냈고 &lt;code&gt;P1&lt;/code&gt;부터 &lt;code&gt;P3&lt;/code&gt;까지 손실됐다고 가정해보겠습니다. 수신자는 &lt;code&gt;P0&lt;/code&gt;을 받고 누적 ACK를 보냅니다. 그 뒤에는 수신할 세그먼트가 없으므로 ACK도 더 오지 않습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;시간 ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;송신자                                      수신자&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |---- P0 -------------------------------&amp;gt;|  P0 수신&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |---- P1 ---- X                          |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |---- P2 ---- X                          |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |---- P3 ---- X                          |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |&amp;lt;-------------------- P0까지 누적 ACK ---|  이후 피드백 없음&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |                                        |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |       loss probe timeout(PTO) 만료      |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |---- P3 재전송(TLP) -------------------&amp;gt;|  P3 수신, P1·P2는 비어 있음&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |&amp;lt;-------------------------- SACK P3 ----|&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |  RACK이 P1·P2를 손실로 표시             |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |  혼잡 제어가 허용한 범위에서 재전송       |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |---- P1 재전송 ------------------------&amp;gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |---- P2 재전송 ------------------------&amp;gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  |&amp;lt;-------------------- P3까지 누적 ACK ---|  연속 구간 복구&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기존 방식만 사용하면 마지막으로 확인되지 않은 데이터는 RTO가 만료될 때까지 기다릴 가능성이 큽니다. 복구가 늦어질 뿐 아니라 RTO가 만료되면 혼잡 윈도우도 크게 줄어듭니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8985&quot;&gt;RFC 8985&lt;/a&gt;는 이 구간을 RACK-TLP로 다룹니다. TLP는 ACK 피드백을 얻기 위해 프로브(probe) 세그먼트 하나를 보내고, RACK은 돌아온 ACK나 SACK을 바탕으로 앞서 보낸 세그먼트의 손실을 판단합니다.&lt;/p&gt;
&lt;h2 id=&quot;tlp는-ack를-받기-위해-프로브를-보낸다&quot;&gt;TLP는 ACK를 받기 위해 프로브를 보낸다&lt;/h2&gt;
&lt;p&gt;TLP(Tail Loss Probe)는 손실된 세그먼트를 모두 찾아 한꺼번에 재전송하는 방식이 아닙니다. ACK가 끊긴 상태에서 프로브 하나를 보내 수신자의 응답을 이끌어냅니다.&lt;/p&gt;
&lt;p&gt;TLP는 **loss probe timeout(PTO)**이 만료되면 프로브를 보냅니다. SRTT가 있으면 기본 PTO는 &lt;code&gt;2 × SRTT&lt;/code&gt;, 없으면 1초입니다. 전송 중인 세그먼트가 하나라면 지연 ACK를 고려한 시간을 더할 수 있고, RTO 만료 시점보다 늦게 잡지는 않습니다.&lt;/p&gt;
&lt;p&gt;PTO가 만료되면 송신자는 다음 순서로 프로브를 고릅니다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;아직 보내지 않은 데이터가 있고 수신 윈도우가 허용하면 새 데이터 한 세그먼트를 보냅니다.&lt;/li&gt;
&lt;li&gt;보낼 새 데이터가 없다면 지금까지 보낸 세그먼트 중 Sequence Number가 가장 높은 세그먼트를 재전송합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;새 데이터를 보내면 수신자는 누적 ACK나 SACK을 돌려줍니다. 가장 높은 세그먼트를 재전송했을 때도 수신 결과가 ACK 피드백으로 돌아옵니다. 위의 예에서는 &lt;code&gt;P3&lt;/code&gt;을 다시 보냈고 수신자가 &lt;code&gt;P3&lt;/code&gt; 구간을 SACK으로 알렸습니다. 송신자는 이 피드백을 통해 &lt;code&gt;P1&lt;/code&gt;, &lt;code&gt;P2&lt;/code&gt;가 여전히 확인되지 않았다는 사실을 알 수 있습니다.&lt;/p&gt;
&lt;p&gt;동시에 여러 프로브를 보내지 않으며, 혼잡 윈도우를 넘어 추가로 보낼 수 있는 프로브도 하나뿐입니다. 프로브나 그 ACK도 손실될 수 있으므로 RTO는 마지막 복구 수단으로 남습니다.&lt;/p&gt;
&lt;h2 id=&quot;프로브가-손실을-바로-복구하는-경우&quot;&gt;프로브가 손실을 바로 복구하는 경우&lt;/h2&gt;
&lt;p&gt;마지막 세그먼트 하나만 손실됐다면 가장 높은 세그먼트를 재전송한 프로브가 곧 복구 패킷이 됩니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;P0 수신 → P1 수신 → P2 수신 → P3 손실&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;                              ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;                       TLP가 P3 재전송&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;                              ↓&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;                 수신 구간이 이어지고 누적 ACK 진행&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 경우에는 SACK으로 다른 빈 구간을 찾고 추가 재전송을 할 필요가 없습니다. TLP가 다시 보낸 &lt;code&gt;P3&lt;/code&gt;이 유일한 빈 구간을 채웁니다. 다만 ACK 모양만 보면 일반적인 누적 ACK처럼 보일 수 있으므로, 송신자는 TLP 재전송 상태를 따로 기록해 손실에 맞는 혼잡 제어가 적용되도록 처리합니다.&lt;/p&gt;
&lt;h2 id=&quot;rack은-전송-시각으로-손실을-판단한다&quot;&gt;RACK은 전송 시각으로 손실을 판단한다&lt;/h2&gt;
&lt;p&gt;RACK(Recent ACKnowledgment)은 ACK와 SACK 피드백으로 손실을 판단합니다. 기존 방식이 중복 ACK의 수나 SACK된 시퀀스·바이트 양을 기준으로 삼았다면, RACK은 어느 세그먼트가 언제 전송됐고 그중 무엇이 도착했는지를 함께 봅니다.&lt;/p&gt;
&lt;p&gt;송신자는 각 데이터 세그먼트의 가장 최근 전송 시각을 기록합니다. 재전송했다면 처음 보낸 시각이 아니라 가장 최근에 다시 보낸 시각으로 갱신합니다. ACK나 SACK이 도착하면 새로 전달됐다고 확인된 세그먼트 중 최근에 전송된 세그먼트를 기준으로 RTT를 구하고, 그보다 앞서 전송됐지만 아직 확인되지 않은 세그먼트를 확인합니다.&lt;/p&gt;
&lt;p&gt;RACK의 판단 흐름은 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;더 늦게 보낸 세그먼트가 먼저 전달됐고,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;이전 세그먼트가 전송 시각 + 최근 RTT + 재정렬 여유를 지나도록 확인되지 않았다면&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;이전 세그먼트를 손실로 판단한다.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 재정렬 여유가 &lt;code&gt;reordering window&lt;/code&gt;입니다. 뒤에 보낸 패킷이 먼저 도착했다고 해서 앞의 패킷이 반드시 손실된 것은 아닙니다. 서로 다른 경로를 지나거나 네트워크 내부에서 순서가 바뀌었을 수도 있습니다. 이 값은 연결 상태에 따라 0일 수도 있고, 재정렬이 관찰되면 일정 범위 안에서 늘어날 수도 있습니다. RACK은 이 여유를 반영해 재정렬과 손실을 구분합니다.&lt;/p&gt;
&lt;p&gt;RACK은 세그먼트마다 별도 타이머를 두는 방식이 아닙니다. 최근 전송 시각을 기록하고 연결 단위 reordering timer를 사용합니다. 전송 시각은 송신자가 내부에 기록하므로 TCP Timestamp 옵션은 필수 조건이 아닙니다.&lt;/p&gt;
&lt;p&gt;RACK-TLP를 쓰는 연결에서는 SACK을 사용하고, 송신자는 연결마다 SACK scoreboard를 유지합니다. RACK은 ACK와 SACK으로 확인한 범위에 최근 전송 시각을 더해, 먼저 보냈지만 아직 전달되지 않은 범위를 찾습니다.&lt;/p&gt;</content:encoded></item><item><title>PCAP으로 TCP 손실 복구 관찰하기</title><link>https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/pcap-loss-recovery-lab/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/pcap-loss-recovery-lab/</guid><description>패킷 캡처로 TCP의 복구 흐름을 읽고, 캡처만으로 확정할 수 없는 동작을 구분합니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;발표 때 사용한 두 PCAP과 &lt;code&gt;rack-experiment.txt&lt;/code&gt;를 다시 확인했습니다. 첫 번째 캡처에서는 중복 ACK가 세 번 쌓인 뒤 빈 구간이 채워지고, 두 번째 캡처에서는 RACK을 켠 환경에서 중복 ACK 한 번 뒤 빈 구간이 채워집니다. 원본과 대조해 발표 표와 생략된 프레임을 다시 확인했습니다. 다만 전체 실행 명령과 정확한 캡처 지점·방향은 남아 있지 않아 실험 전체를 그대로 재현할 수는 없습니다.&lt;/p&gt;
&lt;h2 id=&quot;데이터-경로와-캡처-경로&quot;&gt;데이터 경로와 캡처 경로&lt;/h2&gt;
&lt;p&gt;실험 구성은 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;chunked_server 애플리케이션&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  → 서버 OS TCP 스택&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  → netem egress 손실&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  → Docker 네트워크&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  → 클라이언트&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;관찰 지점&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  └─ tcpdump가 인터페이스에서 복사본을 수집&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;       → PCAP&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;       → Wireshark 또는 tshark로 확인&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;chunked_server&lt;/code&gt;가 쓴 바이트는 TCP 스택과 &lt;code&gt;netem&lt;/code&gt;이 적용된 egress, Docker 네트워크를 지나 클라이언트로 전달됩니다. &lt;code&gt;tcpdump&lt;/code&gt;는 지정한 인터페이스에서 보이는 패킷의 복사본을 저장하므로, 캡처 위치를 모르면 &lt;code&gt;netem&lt;/code&gt; 처리 전후 중 어느 쪽인지 구분할 수 없습니다.&lt;/p&gt;
&lt;p&gt;오프로딩도 캡처 모양에 영향을 줍니다. TSO·GSO·GRO가 켜진 지점에서는 운영체제나 장치가 세그먼트를 합치거나 나누기 전의 형태가 보일 수 있습니다. 관련 동작은 &lt;a href=&quot;https://docs.kernel.org/networking/segmentation-offloads.html&quot;&gt;Linux 커널의 Segmentation Offloads 문서&lt;/a&gt;에 정리돼 있습니다. 패킷 수나 &lt;code&gt;Len&lt;/code&gt;만 비교하려면 캡처 지점과 오프로딩 상태를 함께 남겨야 합니다.&lt;/p&gt;
&lt;p&gt;Sequence Number와 ACK, SACK은 패킷에 기록된 값입니다. 반면 프레임 번호와 상대 시각은 캡처 기록에서 가져오고, &lt;code&gt;TCP Fast Retransmission&lt;/code&gt;이나 &lt;code&gt;Out-Of-Order&lt;/code&gt; 같은 이름은 Wireshark가 앞뒤 패킷을 보고 붙입니다. RACK 내부 상태와 실제 커널 판정 분기는 PCAP에 나타나지 않습니다. 아래 표는 원본 PCAP에서 &lt;code&gt;tshark&lt;/code&gt;로 다시 추출한 값입니다.&lt;/p&gt;
&lt;h2 id=&quot;중복-ack-세-번-뒤에-빈-구간이-채워진-흐름&quot;&gt;중복 ACK 세 번 뒤에 빈 구간이 채워진 흐름&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;loss-classic-3dupack.pcap&lt;/code&gt;에서 손실 전후 프레임을 추리면 다음과 같습니다. 시간은 캡처의 상대 시각입니다.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;중복 ACK 세 번 뒤에 빈 구간이 채워진 흐름 · 표 1, 가로 스크롤&quot;&gt;






































































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th align=&quot;right&quot;&gt;프레임&lt;/th&gt;&lt;th align=&quot;right&quot;&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 align=&quot;right&quot;&gt;8&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;5.795ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;Seq=102 Len=1000&lt;/code&gt;&lt;/td&gt;&lt;td&gt;정상 데이터&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;9&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;5.824ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=1102&lt;/code&gt;&lt;/td&gt;&lt;td&gt;누적 ACK&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;10&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;17.918ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;Seq=2102 Len=1000&lt;/code&gt;&lt;/td&gt;&lt;td&gt;빈 구간 뒤 데이터&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;11&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;18.326ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=1102 SACK=[2102,3102)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;중복 ACK 1&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;12&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;24.504ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;Seq=3102 Len=1000&lt;/code&gt;&lt;/td&gt;&lt;td&gt;뒤쪽 데이터&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;13&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;24.518ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=1102 SACK=[2102,4102)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;중복 ACK 2&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;14&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30.621ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;Seq=4102 Len=1000&lt;/code&gt;&lt;/td&gt;&lt;td&gt;뒤쪽 데이터&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;15&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30.642ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=1102 SACK=[2102,5102)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;중복 ACK 3&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;16&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30.730ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;Seq=1102 Len=1000&lt;/code&gt;&lt;/td&gt;&lt;td&gt;빠른 재전송&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;17&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30.813ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=5102&lt;/code&gt;&lt;/td&gt;&lt;td&gt;빈 구간 복구, ACK 전진&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;9번 프레임의 &lt;code&gt;ACK=1102&lt;/code&gt;는 1102 앞까지 연속해서 받았다는 뜻입니다. 이후 &lt;code&gt;[2102, 3102)&lt;/code&gt;부터 뒤쪽 범위가 도착하지만 ACK는 1102에 머뭅니다. SACK 블록만 &lt;code&gt;[2102, 3102)&lt;/code&gt;, &lt;code&gt;[2102, 4102)&lt;/code&gt;, &lt;code&gt;[2102, 5102)&lt;/code&gt;로 늘어납니다. 이 필드만으로도 수신자가 뒤쪽 바이트는 받았지만 &lt;code&gt;[1102, 2102)&lt;/code&gt;는 아직 연속된 범위에 포함하지 못했다는 사실을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;발표 표에 없던 12번과 14번 프레임도 확인했습니다. 세 번째 중복 ACK 직후 16번 프레임에 &lt;code&gt;Seq=1102 Len=1000&lt;/code&gt;이 나타나고, 17번 프레임의 ACK는 5102로 진행합니다. Wireshark도 16번 프레임을 &lt;code&gt;TCP Fast Retransmission&lt;/code&gt;으로 표시합니다.&lt;/p&gt;
&lt;p&gt;다만 최초의 &lt;code&gt;Seq=1102&lt;/code&gt; 전송은 이 PCAP에 보이지 않습니다. 10번 프레임에 붙은 &lt;code&gt;Previous segment not captured&lt;/code&gt;도 Wireshark의 분석 표시입니다. 해당 패킷이 캡처 지점보다 뒤에서 손실됐는지, 캡처 과정에서 빠졌는지는 이 파일만으로 구분할 수 없습니다. 표와 원본에서는 중복 ACK 세 번 뒤 같은 시퀀스 구간이 나타나고 누적 ACK가 전진한 것까지만 확인할 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;중복-ack-한-번-뒤-빈-구간이-채워진-흐름&quot;&gt;중복 ACK 한 번 뒤 빈 구간이 채워진 흐름&lt;/h2&gt;
&lt;p&gt;두 번째 파일은 &lt;code&gt;loss-rack-consistent.pcap&lt;/code&gt;입니다. 함께 남아 있던 설정 메모에는 Linux &lt;code&gt;6.12.54-linuxkit&lt;/code&gt;, &lt;code&gt;tcp_recovery=1&lt;/code&gt;, &lt;code&gt;tcp_early_retrans=0&lt;/code&gt;, &lt;code&gt;tcp_sack=1&lt;/code&gt;이 적혀 있습니다. 양쪽 엔드포인트의 TSO·GSO·GRO를 끄고 &lt;code&gt;netem&lt;/code&gt;에 무작위 손실 4%를 적용했으며, 21개 패킷 가운데 1개가 드롭됐다는 기록도 있습니다. PCAP의 SYN과 SYN-ACK에는 모두 &lt;code&gt;SACK-Permitted&lt;/code&gt;가 있고, 13번 ACK에는 실제 SACK 블록이 들어 있습니다.&lt;/p&gt;
&lt;div class=&quot;table-scroll&quot; tabindex=&quot;0&quot; role=&quot;region&quot; aria-label=&quot;중복 ACK 한 번 뒤 빈 구간이 채워진 흐름 · 표 2, 가로 스크롤&quot;&gt;














































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th align=&quot;right&quot;&gt;프레임&lt;/th&gt;&lt;th align=&quot;right&quot;&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 align=&quot;right&quot;&gt;10&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;20.570ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;Seq=1102 Len=1000&lt;/code&gt;&lt;/td&gt;&lt;td&gt;정상 데이터&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;11&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;20.602ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=2102&lt;/code&gt;&lt;/td&gt;&lt;td&gt;ACK 전진&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;12&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30.475ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;Seq=3102 Len=1000&lt;/code&gt;&lt;/td&gt;&lt;td&gt;빈 구간 뒤 데이터&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;13&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30.493ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=2102 SACK=[3102,4102)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;중복 ACK 1&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;14&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30.515ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;Seq=2102 Len=1000&lt;/code&gt;&lt;/td&gt;&lt;td&gt;순서 바뀜&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;15&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;30.531ms&lt;/td&gt;&lt;td&gt;&lt;code&gt;ACK=4102&lt;/code&gt;&lt;/td&gt;&lt;td&gt;빈 구간 복구&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;13번 프레임에서 ACK가 2102에 머물렀고, 캡처 시각을 기준으로 0.022ms 뒤 14번 프레임에 &lt;code&gt;Seq=2102 Len=1000&lt;/code&gt;이 나타납니다. 이어지는 15번 프레임에서 ACK는 4102로 진행합니다.&lt;/p&gt;
&lt;p&gt;이 흐름은 RACK 재전송과 일치하지만 PCAP만으로 확정할 수는 없습니다. 최초의 &lt;code&gt;Seq=2102&lt;/code&gt;가 캡처에 없어 14번 프레임이 재전송인지 늦게 도착한 원본인지 구분할 수 없고, &lt;code&gt;Out-of-Order&lt;/code&gt;도 Wireshark의 분석 표시입니다. 0.022ms 역시 두 프레임 사이의 캡처 간격일 뿐, 3-DupACK 방식과 RACK의 성능 차이를 뜻하지 않습니다.&lt;/p&gt;
&lt;p&gt;같은 실험을 다시 한다면 커널과 TCP 설정, &lt;code&gt;netem&lt;/code&gt; 명령, 캡처 지점·방향과 offload 상태, 트래픽 조건, 양쪽 PCAP과 분석 명령을 함께 남겨야 합니다. RACK의 &lt;code&gt;reo_wnd&lt;/code&gt;와 내부 판정까지 확인하려면 커널 추적도 필요합니다. 설정값은 &lt;a href=&quot;https://docs.kernel.org/networking/ip-sysctl.html#tcp-variables&quot;&gt;IP sysctl 문서&lt;/a&gt;와 &lt;a href=&quot;https://man7.org/linux/man-pages/man8/tc-netem.8.html&quot;&gt;&lt;code&gt;tc-netem(8)&lt;/code&gt; 매뉴얼&lt;/a&gt;을 참고했습니다.&lt;/p&gt;</content:encoded></item><item><title>TCP가 복구해도 서비스는 느려질 수 있다</title><link>https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/service-latency-timeout-retry/</link><guid isPermaLink="true">https://dongkey.tech/projects/gibuseu/tcp-loss-recovery/service-latency-timeout-retry/</guid><description>TCP 복구 지연이 소켓 읽기, HTTP 응답, 애플리케이션 재시도에 미치는 영향을 살펴봅니다.</description><content:encoded>&lt;p&gt;수정일: 2026-07-22&lt;/p&gt;&lt;p&gt;TCP가 손실된 바이트를 다시 보냈다고 해서 기다린 시간까지 사라지지는 않습니다. 애플리케이션에서는 이 시간이 소켓 읽기와 HTTP 응답 지연으로 나타날 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;복구-중에도-전송량은-제한된다&quot;&gt;복구 중에도 전송량은 제한된다&lt;/h2&gt;
&lt;p&gt;손실 구간을 찾았다고 모두 한꺼번에 재전송하는 것은 아닙니다. &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9937&quot;&gt;RFC 9937&lt;/a&gt;의 PRR(Proportional Rate Reduction)은 ACK로 전달이 확인된 양과 혼잡 제어가 정한 &lt;code&gt;ssthresh&lt;/code&gt;를 기준으로 빠른 복구 중 전송량을 조절합니다. 손실 감지와 전송량 조절은 서로 다른 역할입니다.&lt;/p&gt;
&lt;h2 id=&quot;빈-구간은-애플리케이션의-대기-시간이-된다&quot;&gt;빈 구간은 애플리케이션의 대기 시간이 된다&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9293&quot;&gt;RFC 9293&lt;/a&gt;은 TCP가 애플리케이션에 신뢰할 수 있고 순서가 보장되는 바이트 스트림을 제공한다고 설명합니다. 수신 측 TCP는 순서가 뒤집혀 도착한 세그먼트를 버퍼에 보관할 수 있지만, 앞에 비어 있는 바이트 구간을 건너뛰어 일반 TCP 스트림으로 전달할 수는 없습니다.&lt;/p&gt;
&lt;p&gt;예를 들어 다음과 같이 세 구간을 보냈다고 가정하겠습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;송신: [0..999] [1000..1999] [2000..2999]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;수신: [0..999]               [2000..2999]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;                    ↑ 아직 도착하지 않은 구간&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;마지막 구간이 먼저 도착해도 애플리케이션은 &lt;code&gt;1000..1999&lt;/code&gt;가 복구될 때까지 그 뒤의 바이트를 순서대로 읽을 수 없습니다. 같은 TCP 스트림에서는 이처럼 앞의 빈 구간이 뒤의 데이터 전달을 막습니다.&lt;/p&gt;
&lt;p&gt;소켓 API에 따라 블로킹 &lt;code&gt;read&lt;/code&gt;는 데이터나 타임아웃을 기다리고, 논블로킹·비동기 API는 준비 상태나 이벤트를 돌려줍니다. 이미 읽을 수 있는 바이트가 있으면 일부만 반환할 수 있고, TCP에는 메시지 경계가 없어 HTTP 응답 하나가 여러 &lt;code&gt;read&lt;/code&gt;로 나뉠 수 있습니다.&lt;/p&gt;
&lt;p&gt;HTTP 응답 시간에서도 손실 복구 지연은 여러 요소 중 하나입니다. 요청을 보내는 구간에서 손실이 났다면 서버가 요청을 늦게 받습니다. 응답 구간의 손실이라면 클라이언트가 본문을 완성해서 읽는 시점이 늦어질 수 있습니다. HTTP 응답 시간에는 서버 처리 시간, 연결 수립, TLS, 큐 대기 시간도 포함됩니다. 응답 시간이 길다는 사실만으로 TCP 손실을 원인으로 정할 수는 없습니다.&lt;/p&gt;
&lt;h2 id=&quot;tcp-재전송과-애플리케이션-재시도&quot;&gt;TCP 재전송과 애플리케이션 재시도&lt;/h2&gt;
&lt;p&gt;TCP 재전송은 같은 연결 안에서 확인되지 않은 바이트 구간을 다시 보내는 동작입니다. 애플리케이션은 대개 이 과정을 직접 알지 못합니다.&lt;/p&gt;
&lt;p&gt;애플리케이션 재시도는 타임아웃 뒤 HTTP 요청 같은 논리적인 작업을 다시 수행합니다. 타임아웃이 끝나도 이미 보낸 요청이나 서버 처리가 자동으로 취소되지는 않으므로 첫 요청과 재시도가 겹칠 수 있습니다. HTTP/2 스트림을 취소하더라도 이미 TCP에 전달되어 송신 버퍼에 남은 미확인 바이트의 재전송이 자동으로 중단되는 것은 아닙니다. TCP는 HTTP/2의 스트림 경계를 알지 못합니다.&lt;/p&gt;
&lt;p&gt;쓰기 요청을 재시도할 때는 특히 이 차이를 봐야 합니다. 클라이언트가 응답을 받지 못했더라도 서버는 요청을 처리했을 수 있습니다. 같은 결제나 주문 생성 요청을 그대로 다시 보내면 작업이 두 번 실행될 가능성이 있습니다. &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2&quot;&gt;RFC 9110의 멱등성 규칙&lt;/a&gt;은 요청 자체가 멱등하게 처리된다는 근거가 있거나 원래 요청이 적용되지 않았음을 확인한 경우가 아니라면, 비멱등 메서드를 자동으로 재시도하지 말라고 권고합니다. 필요한 경우 요청마다 멱등성 키를 보내고 서버가 처리 결과를 보관하도록 설계할 수 있습니다.&lt;/p&gt;
&lt;p&gt;애플리케이션 타임아웃과 TCP의 RTO는 별개입니다. 타임아웃을 너무 짧게 두면 복구될 요청까지 재시도해 서버 부하와 중복 처리 가능성이 커집니다.&lt;/p&gt;
&lt;h2 id=&quot;p99를-해석할-때-확인할-것&quot;&gt;p99를 해석할 때 확인할 것&lt;/h2&gt;
&lt;p&gt;손실 복구가 오래 걸린 일부 요청은 p99를 밀어 올릴 수 있지만, 손실률만으로 증가 폭을 계산할 수는 없습니다. RTT, 손실 위치, 전송량, 복구 방식, 애플리케이션 타임아웃이 함께 영향을 줍니다.&lt;/p&gt;
&lt;p&gt;원인을 확인하려면 요청 ID나 트레이스와 TCP 연결의 4-tuple을 연결하고, 응답 시간과 재전송, RTO, &lt;code&gt;Seq&lt;/code&gt;·&lt;code&gt;ACK&lt;/code&gt;를 함께 봐야 합니다.&lt;/p&gt;</content:encoded></item><item><title>오픈미션 주제 선정기</title><link>https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/%EC%98%A4%ED%94%88%EB%AF%B8%EC%85%98-%EC%A3%BC%EC%A0%9C-%EC%84%A0%EC%A0%95%EA%B8%B0/</link><guid isPermaLink="true">https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/%EC%98%A4%ED%94%88%EB%AF%B8%EC%85%98-%EC%A3%BC%EC%A0%9C-%EC%84%A0%EC%A0%95%EA%B8%B0/</guid><description>혼자, 프레임워크 없이 멀티플레이 게임을 만들기로 한 이유와 계획을 기록했습니다.</description><content:encoded>&lt;p&gt;수정일: 2025-11-24&lt;/p&gt;&lt;p&gt;우아한테크코스 8기 오픈미션을 시작하면서 가장 먼저 마주한 질문은 &quot;무엇을 만들 것인가&quot;가 아니라 &quot;어떻게 만들 것인가&quot;였습니다.&lt;/p&gt;
&lt;p&gt;포비님께서 제안하신 여러 방향 중 하나는 &quot;낯선 도구 해커톤&quot;이었습니다. 디스코드를 보니 많은 분들이 팀을 구성하고 계셨고, 익숙한 기술 스택으로 완성도 높은 프로젝트를 만들겠다는 분들도 많았습니다.&lt;/p&gt;
&lt;p&gt;하지만 저는 고민 끝에 혼자 프로젝트를 진행하기로 했습니다.&lt;/p&gt;
&lt;h2 id=&quot;혼자-진행하기로-한-이유&quot;&gt;혼자 진행하기로 한 이유&lt;/h2&gt;
&lt;p&gt;그동안은 늘 팀으로 프로젝트를 진행했지만, 끝나고 나면 찝찝함이 남았습니다.&lt;/p&gt;
&lt;p&gt;팀 프로젝트에서는 역할이 나뉘다 보니 제가 맡은 부분 외에는 깊이 고민할 기회가 적었고, 주제 선정부터 설계, 배포까지 전체 과정을 스스로 경험해보지 못했다는 아쉬움이 있었습니다.&lt;/p&gt;
&lt;p&gt;이번 기회에 처음부터 끝까지 모두 혼자 해보면, 나중에 협업할 때 더 도움이 되는 팀원이 될 수 있지 않을까 생각했습니다.&lt;/p&gt;
&lt;p&gt;배운 내용을 팀원들과 나눌 수 있는 개발자가 되고 싶었습니다.&lt;/p&gt;
&lt;h2 id=&quot;프레임워크-없이-도전하기로-한-이유&quot;&gt;프레임워크 없이 도전하기로 한 이유&lt;/h2&gt;
&lt;p&gt;프리코스를 진행하면서 제가 원리를 모른 채 도구를 사용하고 있다는 생각이 자주 들었습니다.&lt;/p&gt;
&lt;p&gt;Spring Boot를 사용하면 &lt;code&gt;@RestController&lt;/code&gt;와 &lt;code&gt;@GetMapping&lt;/code&gt;만 써도 HTTP 통신이 되지만, HTTP 요청이 어떻게 들어오는지, 응답은 어떻게 나가는지, 소켓이 무엇인지 정확히 이해하지 못하고 있었습니다.&lt;/p&gt;
&lt;p&gt;프리코스에서 Value Object, 일급 컬렉션, 레이어 분리 같은 개념들을 배우며 &lt;strong&gt;추상화 아래에 무엇이 있는지 알아야 제대로 사용할 수 있다&lt;/strong&gt;고 생각하게 됐습니다.&lt;/p&gt;
&lt;p&gt;예를 들어 프리코스 자동차 경주에서 &lt;code&gt;CarName&lt;/code&gt;, &lt;code&gt;GameCount&lt;/code&gt; 같은 Value Object와 &lt;code&gt;Cars&lt;/code&gt; 같은 일급 컬렉션을 다뤘습니다. 원시값과 컬렉션을 감싸면서 검증과 규칙이 어디에 있어야 하는지 조금씩 이해할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;하지만 Spring Boot는 달랐습니다. 여전히 &lt;code&gt;@RestController&lt;/code&gt;와 &lt;code&gt;@GetMapping&lt;/code&gt;이 내부에서 어떻게 동작하는지 알지 못했습니다.&lt;/p&gt;
&lt;p&gt;그래서 이번에는 Spring Boot 없이, ServerSocket으로 직접 HTTP 서버를 만들기로 했습니다.&lt;/p&gt;
&lt;h2 id=&quot;프로젝트-선정&quot;&gt;프로젝트 선정&lt;/h2&gt;
&lt;p&gt;프리코스에서 만들었던 자동차 경주 게임을 멀티플레이 게임으로 만들기로 했습니다. 4명이 모이면 1초 간격으로 5라운드를 진행하고, 마지막 라운드 뒤 가장 멀리 이동한 플레이어가 이깁니다. 같은 위치라면 공동 우승입니다.&lt;/p&gt;
&lt;p&gt;목표를 정리하면 이렇습니다.&lt;/p&gt;
&lt;p&gt;첫째, ServerSocket으로 프로젝트에 필요한 GET 요청과 정적 파일 응답 범위의 학습용 HTTP 서버를 구현합니다.&lt;/p&gt;
&lt;p&gt;둘째, WebSocket으로 실시간 통신을 구현해서 4명이 동시에 접속하고 게임 상태를 공유합니다.&lt;/p&gt;
&lt;p&gt;셋째, 프레임워크 없이 끝까지 완성합니다.&lt;/p&gt;
&lt;h2 id=&quot;예상되는-어려움&quot;&gt;예상되는 어려움&lt;/h2&gt;
&lt;p&gt;솔직히 어디서부터 시작해야 할지 막막했습니다.&lt;/p&gt;
&lt;p&gt;HTTP 프로토콜을 파싱하는 방법도, WebSocket Handshake가 무엇인지도, 동시 접속을 어떻게 처리하는지도 전혀 알지 못했습니다. RFC 문서를 읽어야 한다는 것조차 이번에 처음 알았습니다.&lt;/p&gt;
&lt;p&gt;주변을 둘러보니 디스코드에서 다른 분들은 익숙한 기술로 완성도 높은 프로젝트를 만들고 계셨고, 저는 Socket이 정확히 무엇인지도 모르는 상태였습니다.&lt;/p&gt;
&lt;p&gt;그럼에도 포비님 말씀처럼 성장을 하드 스킬에만 한정할 필요는 없다고 생각해 시작했습니다. &lt;strong&gt;결과물의 완성도보다 과정에서 배우는 것이 더 중요하다&lt;/strong&gt;고 믿었습니다.&lt;/p&gt;
&lt;h2 id=&quot;단계적-확장-전략&quot;&gt;단계적 확장 전략&lt;/h2&gt;
&lt;p&gt;막막함을 줄이기 위해 단계적으로 접근하기로 했습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1주차: HTTP 서버 구현&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ServerSocket으로 요청 받기&lt;/li&gt;
&lt;li&gt;정적 파일 제공&lt;/li&gt;
&lt;li&gt;멀티스레드 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;2주차: WebSocket + 게임 로직&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;WebSocket Handshake 구현&lt;/li&gt;
&lt;li&gt;Frame 파싱&lt;/li&gt;
&lt;li&gt;세션 관리 및 브로드캐스트&lt;/li&gt;
&lt;li&gt;멀티플레이 레이싱 게임 구현&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;3주차: 아키텍처 개선 + 배포&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;버그 수정&lt;/li&gt;
&lt;li&gt;레이어 분리 및 리팩토링&lt;/li&gt;
&lt;li&gt;AWS EC2 배포&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이렇게 Multi Racing Car 프로젝트를 시작했습니다.&lt;/p&gt;</content:encoded></item><item><title>WebSocket을 바닥부터 구현하기</title><link>https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/WebSocket%EC%9D%84-%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0/</link><guid isPermaLink="true">https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/WebSocket%EC%9D%84-%EB%B0%94%EB%8B%A5%EB%B6%80%ED%84%B0-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0/</guid><description>RFC 6455를 읽으며 핸드셰이크와 텍스트 프레임을 구현한 과정입니다. 지원하지 못한 범위도 함께 정리했습니다.</description><content:encoded>&lt;p&gt;수정일: 2025-11-24&lt;/p&gt;&lt;p&gt;HTTP 서버를 만들고 정적 파일을 제공하는 데까지 성공했습니다. 이제 멀티플레이 게임을 만들려면 실시간 통신이 필요했습니다.&lt;/p&gt;
&lt;p&gt;문제는 HTTP가 기본적으로 요청-응답 구조라는 것이었습니다. HTTP/1.1의 keep-alive로 TCP 연결을 재사용할 수는 있지만, 일반적인 요청-응답만으로는 서버가 원하는 시점에 &quot;게임이 시작됐어요&quot; 같은 메시지를 지속적으로 보낼 수 없었습니다.&lt;/p&gt;
&lt;h2 id=&quot;polling을-시도하다&quot;&gt;Polling을 시도하다&lt;/h2&gt;
&lt;p&gt;처음에는 Polling 방식을 생각했습니다. 클라이언트가 0.5초마다 서버에 &quot;혹시 업데이트 있어요?&quot; 하고 물어보는 방식입니다.&lt;/p&gt;
&lt;p&gt;4명이 동시에 접속해 0.5초마다 요청을 보내면 초당 8번의 요청이 발생합니다. 당시에는 주기적으로 요청을 보내는 방식이 비효율적이라고 생각했고, 구현도 복잡해 보였습니다.&lt;/p&gt;
&lt;p&gt;다른 방법을 찾다가 실시간 양방향 통신에 WebSocket을 쓰기로 했습니다.&lt;/p&gt;
&lt;h2 id=&quot;websocket을-직접-구현하기로&quot;&gt;WebSocket을 직접 구현하기로&lt;/h2&gt;
&lt;p&gt;WebSocket은 한 번 연결되면 계속 연결을 유지하고 양방향으로 메시지를 주고받을 수 있습니다. Spring Boot에서 WebSocket을 사용해본 적은 있었지만 직접 구현해본 적은 없었습니다.&lt;/p&gt;
&lt;p&gt;&quot;이거다&quot; 싶었지만, 프레임워크 없이 직접 구현해야 한다는 것을 깨닫고 막막해졌습니다. 프레임워크에서는 이미 구현된 기능을 사용하기만 하면 됐는데 지금은 그런 게 없었습니다.&lt;/p&gt;
&lt;p&gt;여러 블로그를 읽다가 RFC 6455 문서를 참고해야 한다는 것을 알게 되었습니다. RFC 문서를 읽는 것은 이번이 처음이었습니다.&lt;/p&gt;
&lt;h2 id=&quot;handshake-구현하기&quot;&gt;Handshake 구현하기&lt;/h2&gt;
&lt;p&gt;RFC 6455 문서를 열었는데 영어로 가득했지만, 다행히 한국어 블로그 글들이 많아서 참고할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;WebSocket 연결은 HTTP로 시작한다고 했습니다. 클라이언트가 &quot;Upgrade: websocket&quot; 헤더를 보내면 서버가 101 Switching Protocols 응답을 보내서 연결을 업그레이드합니다.&lt;/p&gt;
&lt;figure class=&quot;reading-figure&quot; data-reading-figure=&quot;&quot;&gt;&lt;div id=&quot;reading-figure-1-image&quot; class=&quot;figure-image&quot;&gt;&lt;a href=&quot;https://dongkey.tech/img/websocket-handshake-process.png&quot; data-figure-open=&quot;&quot; aria-label=&quot;크게 보기: WebSocket Handshake 과정&quot;&gt;&lt;img src=&quot;https://dongkey.tech/img/websocket-handshake-process.png&quot; alt=&quot;WebSocket Handshake 과정&quot; width=&quot;1148&quot; height=&quot;866&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;/div&gt;&lt;/figure&gt;
&lt;p&gt;서버는 &lt;code&gt;Sec-WebSocket-Key&lt;/code&gt;에 Magic String이라는 특정 문자열을 붙인 뒤 SHA-1 해싱과 Base64 인코딩을 거쳐 응답 값으로 돌려줘야 했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;String key &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; request.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getHeader&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;Sec-WebSocket-Key&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;String magic &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &quot;258EAFA5-E914-47DA-95CA-C5AB0DC85B11&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;String concatenated &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; key &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; magic;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;MessageDigest digest &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; MessageDigest.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getInstance&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;SHA-1&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;byte&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[] hash &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; digest.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;digest&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(concatenated.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getBytes&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(StandardCharsets.UTF_8));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;String accept &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Base64.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getEncoder&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;encodeToString&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(hash);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처음에는 &quot;이게 왜 필요하지?&quot; 싶어 RFC 문서와 여러 자료에서 이유를 찾아봤습니다.&lt;/p&gt;
&lt;h3 id=&quot;왜-이런-과정이-필요할까&quot;&gt;왜 이런 과정이 필요할까?&lt;/h3&gt;
&lt;p&gt;단순히 &lt;code&gt;Upgrade: websocket&lt;/code&gt;만 주고받으면 현재 요청에 대응하는 WebSocket 서버의 응답인지 확인하기 어렵습니다. RFC 6455의 opening handshake는 다음 두 가지를 확인하도록 설계됐습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫째, 양쪽이 WebSocket opening handshake를 이해하는지 확인&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;클라이언트는 요청마다 무작위 nonce인 &lt;code&gt;Sec-WebSocket-Key&lt;/code&gt;를 보냅니다. 서버는 이 값에 RFC가 정한 GUID(&lt;code&gt;258EAFA5-E914-47DA-95CA-C5AB0DC85B11&lt;/code&gt;)를 이어 붙인 뒤 SHA-1과 Base64를 적용해 &lt;code&gt;Sec-WebSocket-Accept&lt;/code&gt;를 만듭니다. 클라이언트가 같은 계산으로 응답을 검증하므로, 서버가 단순히 HTTP &lt;code&gt;101&lt;/code&gt;을 반환한 것이 아니라 현재 WebSocket 요청을 처리했다는 사실을 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;둘째, 오래된 응답의 재사용 방지&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;요청마다 Key가 달라지면 Accept 값도 달라집니다. 따라서 중간 프록시 등에 남아 있던 과거 handshake 응답을 현재 연결의 정상 응답으로 받아들이지 않습니다.&lt;/p&gt;
&lt;p&gt;다만 이 challenge-response는 &lt;strong&gt;서버 인증이나 암호화가 아니며 중간자 공격을 막지 않습니다.&lt;/strong&gt; Key, GUID, 계산 방식은 모두 통신 당사자에게 공개되어 있고, 여기서 SHA-1은 RFC가 정한 값 변환에 사용될 뿐입니다. 전송 내용의 기밀성·무결성과 서버 인증이 필요하면 인증서를 검증하는 TLS 기반의 &lt;code&gt;wss://&lt;/code&gt;를 사용해야 합니다. 자세한 계산과 검증 절차는 &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6455#section-4.2.2&quot;&gt;RFC 6455 §4.2.2&lt;/a&gt;에서 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;코드를 작성하고 Handshake 응답을 보냈더니, 드디어 브라우저 콘솔에서 WebSocket 연결이 성공했다는 메시지가 나타났습니다.&lt;/p&gt;
&lt;p&gt;이 과정을 이해하고 나니 handshake가 담당하는 프로토콜 검증과 TLS가 담당하는 보안의 경계를 구분할 수 있었습니다.&lt;/p&gt;
&lt;h2 id=&quot;frame-파싱-고통스러웠던-순간&quot;&gt;Frame 파싱, 고통스러웠던 순간&lt;/h2&gt;
&lt;p&gt;Handshake는 성공했지만 메시지를 읽을 수 없었습니다. JavaScript에서 &lt;code&gt;ws.send(&apos;Hello&apos;)&lt;/code&gt;를 보냈는데 서버에서는 이상한 바이트만 찍혔습니다.&lt;/p&gt;
&lt;p&gt;RFC 6455 문서의 &quot;Data Framing&quot; 섹션을 다시 봤습니다. 엄청난 그림이 나왔습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt; 0                   1                   2                   3&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt; 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;|F|R|R|R| opcode|M| Payload len |    Extended payload length    |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;|N|V|V|V|       |S|             |   (if payload len==126/127)   |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;| |1|2|3|       |K|             |                               |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;비트 단위로 파싱해야 한다&lt;/strong&gt;는 걸 이때 알았습니다.&lt;/p&gt;
&lt;p&gt;RFC의 frame을 일반적으로 처리하려면 첫 번째 바이트에서 &lt;code&gt;FIN&lt;/code&gt;과 opcode를 확인하고, 두 번째 바이트에서 &lt;code&gt;MASK&lt;/code&gt;와 payload length를 분리해야 합니다. 당시 구현은 브라우저가 보내는 짧은 단일 텍스트 frame을 전제로 두 번째 바이트에 &lt;code&gt;&amp;amp; 0x7F&lt;/code&gt;를 적용하는 수준부터 시작했습니다.&lt;/p&gt;
&lt;p&gt;비트 연산을 해본 적이 거의 없어서 막막했지만, 블로그의 예제 코드를 참고하면서 하나씩 구현했습니다.&lt;/p&gt;
&lt;p&gt;4바이트의 Masking Key를 읽고 Payload의 각 바이트와 XOR 연산을 해야 원래 메시지를 얻을 수 있었습니다. &lt;code&gt;i % 4&lt;/code&gt;로 Masking Key를 순환하면서 사용해야 했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;byte&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[] maskingKey &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; byte&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;4&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;in.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;read&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(maskingKey);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;byte&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[] payload &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; byte&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[payloadLength];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;in.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;read&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(payload);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;for&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;int&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; i &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; i &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; payloadLength; i&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;++&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    payload[i] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;^=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; maskingKey[i &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 4&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;String message &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; String&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(payload, StandardCharsets.UTF_8);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 코드는 학습 당시의 핵심 흐름을 보여주지만 범용 parser는 아닙니다. &lt;code&gt;InputStream.read(byte[])&lt;/code&gt;가 배열을 항상 한 번에 채운다고 가정하고, &lt;code&gt;FIN&lt;/code&gt;·opcode·&lt;code&gt;MASK&lt;/code&gt;를 검증하지 않으며, payload 126/127의 확장 길이와 fragmentation도 처리하지 않습니다. 실제 프로젝트의 범위는 &lt;a href=&quot;https://github.com/DongKey777/multi-racingcar/blob/6083960f5fbc2f934f00d91bd36289d0dc69399d/src/main/java/infrastructure/websocket/protocol/WebSocketFrame.java&quot;&gt;&lt;code&gt;WebSocketFrame&lt;/code&gt;&lt;/a&gt;에서 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;3일 정도 고통을 받았던 것 같습니다. 이상한 문자가 나오거나 Exception이 발생하는 일이 반복되었고, 여러 블로그 글을 읽고 RFC 문서를 확인하며 디버깅했습니다.&lt;/p&gt;
&lt;p&gt;그러다 어느 순간 서버 콘솔에 &lt;strong&gt;&quot;Hello&quot;가 찍혔습니다.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&quot;서버에서-클라이언트로-메시지-보내기&quot;&gt;서버에서 클라이언트로 메시지 보내기&lt;/h2&gt;
&lt;p&gt;클라이언트에서 서버로 메시지를 받는 데 성공하고 나니, 이제 서버에서 클라이언트로 메시지를 보낼 차례였습니다.&lt;/p&gt;
&lt;p&gt;서버가 클라이언트로 보내는 frame은 RFC 6455에 따라 masking하면 안 됩니다. 이 방향은 masking key가 없어서 조금 더 단순했지만, 당시 코드는 여전히 payload 125바이트 이하만 처리했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; sendTextFrame&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(OutputStream out, String message) throws IOException {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    byte&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[] payload &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; message.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getBytes&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(StandardCharsets.UTF_8);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    out.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;write&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0x81&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// FIN=1, Opcode=1 (Text Frame)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    out.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;write&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(payload.length);  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// Payload Length (125 이하만 처리)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    out.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;write&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(payload);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    out.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;flush&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;브라우저에서 메시지를 받았습니다. 양방향 통신이 드디어 작동했습니다.&lt;/p&gt;
&lt;p&gt;정확히는 &lt;strong&gt;RFC 6455의 opening handshake와 payload 125바이트 이하인 단일 텍스트 frame의 happy path&lt;/strong&gt;를 학습 목적으로 구현한 결과입니다. 클라이언트→서버 방향은 &lt;code&gt;MASK=1&lt;/code&gt;, 서버→클라이언트 방향은 &lt;code&gt;MASK=0&lt;/code&gt;을 전제로 합니다. ping/pong/close 제어 frame, closing handshake, 확장 길이, 분할 메시지, strict UTF-8 검증까지 지원하는 완전한 WebSocket 구현은 아닙니다.&lt;/p&gt;
&lt;h2 id=&quot;세션-관리와-브로드캐스트&quot;&gt;세션 관리와 브로드캐스트&lt;/h2&gt;
&lt;p&gt;여러 클라이언트를 동시에 관리하려고 각 WebSocket 연결을 세션으로 만들어 &lt;code&gt;Map&lt;/code&gt;에 저장하기로 했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; SessionManager&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Map&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;WebSocketSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;gt; sessions &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ConcurrentHashMap&amp;lt;&amp;gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; add&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, WebSocketSession &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;session&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        sessions.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;put&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname, session);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; sendTo&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        WebSocketSession session &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; sessions.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;get&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (session &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; session.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;isConnected&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;            session.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;send&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(message);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;닉네임을 세션의 식별자로 사용했습니다. UUID를 써야 하나 고민했지만, 게임 특성상 닉네임이 고유하니 이를 키로 사용하기로 했습니다.&lt;/p&gt;
&lt;h2 id=&quot;게임룸-격리-문제&quot;&gt;게임룸 격리 문제&lt;/h2&gt;
&lt;p&gt;세션 관리를 구현한 뒤 테스트하려고 게임룸 2개를 동시에 실행했더니 문제가 생겼습니다.&lt;/p&gt;
&lt;p&gt;A 게임룸의 라운드 메시지가 B 게임룸 플레이어에게도 전송됐습니다. 모든 세션에 브로드캐스트하는 &lt;code&gt;broadcast()&lt;/code&gt; 메서드를 썼기 때문이었습니다.&lt;/p&gt;
&lt;p&gt;각 게임룸이 자신의 플레이어에게만 메시지를 보내도록 수정했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// GameRoom&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; broadcastToPlayers&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String message) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    for&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (Player player &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; players.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getPlayers&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        String nickname &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; player.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getNickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        sessionManager.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;sendTo&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname, message);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 각 게임룸이 독립적으로 작동했습니다.&lt;/p&gt;
&lt;p&gt;비트 연산으로 Frame을 파싱하는 부분에서는 며칠 동안 진도를 나가지 못했습니다.&lt;/p&gt;
&lt;p&gt;RFC 6455 문서와 여러 블로그 글을 참고했고, 특히 한국어 자료가 이해하는 데 도움이 됐습니다. opening handshake와 제한된 frame 처리 경로를 직접 구현하면서 프로토콜의 구조를 구체적으로 볼 수 있었고, 동시에 실제 라이브러리가 처리해야 할 예외와 제어 frame이 얼마나 많은지도 뒤늦게 확인했습니다.&lt;/p&gt;
&lt;p&gt;실시간 양방향 통신을 구현한 뒤에는 멀티플레이 게임을 만들면서 멀티스레드 환경의 문제를 다뤄야 했습니다.&lt;/p&gt;</content:encoded></item><item><title>멀티스레드 환경에서 살아남기</title><link>https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/%EB%A9%80%ED%8B%B0%EC%8A%A4%EB%A0%88%EB%93%9C-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%82%B4%EC%95%84%EB%82%A8%EA%B8%B0/</link><guid isPermaLink="true">https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/%EB%A9%80%ED%8B%B0%EC%8A%A4%EB%A0%88%EB%93%9C-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EC%82%B4%EC%95%84%EB%82%A8%EA%B8%B0/</guid><description>동시 접속에서 생긴 경쟁 조건과 객체 참조 누적을 분석하고, 현재 구현에 남은 문제를 살펴봅니다.</description><content:encoded>&lt;p&gt;수정일: 2025-11-24&lt;/p&gt;&lt;p&gt;HTTP 서버와 WebSocket을 구현한 뒤 멀티플레이 게임을 만들기 시작했습니다.&lt;/p&gt;
&lt;p&gt;실제로 여러 명이 동시에 접속할 수 있는지 테스트하기 위해 브라우저 창 4개를 열었는데, 예상치 못한 문제가 발생했습니다. 첫 번째 창은 잘 열리는데, 두 번째 창부터는 계속 로딩 중이었습니다.&lt;/p&gt;
&lt;h2 id=&quot;한-명씩만-처리되는-서버&quot;&gt;한 명씩만 처리되는 서버&lt;/h2&gt;
&lt;p&gt;코드를 다시 보니 원인을 찾았습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;while&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;Socket clientSocket &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; serverSocket.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;accept&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// 요청 처리&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;handleRequest&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(clientSocket);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    clientSocket.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;close&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;accept()&lt;/code&gt;로 연결을 받은 뒤 요청 처리, 응답 전송, 소켓 종료를 순서대로 실행하고 있었습니다.&lt;/p&gt;
&lt;p&gt;첫 번째 클라이언트의 요청을 처리하는 동안 두 번째 클라이언트는 대기열에서 기다려야 했습니다. &lt;strong&gt;한 명씩만 서빙할 수 있는 식당&lt;/strong&gt;과 같았습니다.&lt;/p&gt;
&lt;h2 id=&quot;thread-사용&quot;&gt;Thread 사용&lt;/h2&gt;
&lt;p&gt;&quot;각 연결을 별도 스레드에서 처리하면 되지 않을까?&quot; 생각했습니다.&lt;/p&gt;
&lt;p&gt;아래는 예외 처리를 생략하고 연결당 Thread라는 핵심 흐름만 나타낸 의사 코드입니다. 실제 구현에서는 &lt;code&gt;IOException&lt;/code&gt;과 소켓 종료를 명시적으로 처리해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;while&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;Socket clientSocket &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; serverSocket.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;accept&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    new&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Thread&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(() &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        try&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;handleRequest&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(clientSocket);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        } &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;finally&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;                clientSocket.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;close&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;                }).&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;start&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;브라우저 창 4개를 열어보니 모두 동시에 로드되었습니다.&lt;/p&gt;
&lt;h2 id=&quot;동시성-제어&quot;&gt;동시성 제어&lt;/h2&gt;
&lt;p&gt;동시 접속을 처리한 뒤에는 플레이어 정보를 저장하는 방식이 고민이었습니다. 여러 스레드가 동시에 같은 자료구조에 접근하면 문제가 생길 것 같았습니다.&lt;/p&gt;
&lt;p&gt;Java의 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;은 여러 스레드가 동시에 접근할 때 개별 연산의 안전성을 보장합니다. 다만 &lt;code&gt;containsKey()&lt;/code&gt;로 확인한 뒤 &lt;code&gt;put()&lt;/code&gt;하는 것처럼 여러 연산을 조합하면 그 전체가 원자적이 되는 것은 아닙니다. 이런 check-then-act 로직에는 &lt;code&gt;putIfAbsent()&lt;/code&gt;나 &lt;code&gt;compute()&lt;/code&gt; 같은 원자적 API가 필요합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Map&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;WebSocketSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;gt; sessions &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ConcurrentHashMap&amp;lt;&amp;gt;();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;당시에는 SessionManager의 저장소를 &lt;code&gt;ConcurrentHashMap&lt;/code&gt;으로 바꾼 뒤 여러 창에서 정상 동작하는 것을 보고 문제가 해결됐다고 판단했습니다. 하지만 글을 다시 검토하면서 남은 경쟁 조건을 발견했습니다. &lt;a href=&quot;https://github.com/DongKey777/multi-racingcar/blob/6083960f5fbc2f934f00d91bd36289d0dc69399d/src/main/java/service/PlayerSessionService.java#L14-L29&quot;&gt;현재 소스의 &lt;code&gt;PlayerSessionService&lt;/code&gt;&lt;/a&gt;는 &lt;code&gt;exists()&lt;/code&gt;로 검사한 뒤 별도의 &lt;code&gt;add()&lt;/code&gt;를 호출합니다. 두 스레드가 검사와 등록 사이에 끼어들 수 있으므로 닉네임 중복을 완전히 막지는 못합니다.&lt;/p&gt;
&lt;p&gt;등록 자체를 하나의 원자적 연산으로 만들어야 합니다. 예를 들면 SessionManager가 &lt;code&gt;putIfAbsent()&lt;/code&gt;의 결과로 등록 성공 여부를 반환하고, 실패한 세션을 닫는 식입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; boolean&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; addIfAbsent&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String nickname, WebSocketSession session) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; sessions.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;putIfAbsent&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname, session) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ConcurrentHashMap&lt;/code&gt;을 사용했다는 사실과 비즈니스 불변식까지 원자적으로 지켰다는 사실은 다릅니다. 이 경계 조건은 프로젝트 소스에서 후속 수정해야 할 항목으로 남겨두었습니다.&lt;/p&gt;
&lt;p&gt;세션 제거도 같은 원칙이 필요합니다. 오래된 연결이 늦게 종료되면서 같은 닉네임의 새 연결을 지우지 않게 하려면 &lt;code&gt;remove(nickname, expectedSession)&lt;/code&gt;처럼 키와 기존 값을 함께 비교해야 합니다. &lt;code&gt;WaitingQueue&lt;/code&gt;의 &lt;code&gt;ArrayList&lt;/code&gt; 접근과 같은 세션의 &lt;code&gt;OutputStream&lt;/code&gt;에 대한 동시 전송도 별도의 동기화 검토가 필요한 상태입니다.&lt;/p&gt;
&lt;h2 id=&quot;repository의-참조-누적&quot;&gt;Repository의 참조 누적&lt;/h2&gt;
&lt;p&gt;정리 로직이 없던 코드를 검토하니 &lt;code&gt;GameRoomRepository&lt;/code&gt;에서 종료된 게임룸을 제거하지 않고 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Map&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;RoomId&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;gt; multiRooms &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ConcurrentHashMap&amp;lt;&amp;gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Map&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;RoomId&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;SingleGameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;gt; singleRooms &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ConcurrentHashMap&amp;lt;&amp;gt;();&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조에서는 게임이 끝나도 GameRoom이 Map에 남기 때문에, 정리하지 않으면 게임 횟수만큼 참조가 누적될 수 있습니다. 별도의 heap 측정 수치를 보존한 것은 아니므로 여기서는 코드의 참조 관계만 다룹니다.&lt;/p&gt;
&lt;h2 id=&quot;가비지-컬렉션의-한계&quot;&gt;가비지 컬렉션의 한계&lt;/h2&gt;
&lt;p&gt;&quot;Java는 가비지 컬렉션이 있는데 왜 자동으로 안 없어지지?&quot;라는 의문이 들었습니다. 찾아보니 살아있는 Map에서 GameRoom에 도달할 수 있으면 가비지 컬렉션 대상이 아니었습니다.&lt;/p&gt;
&lt;h3 id=&quot;gc는-도달-가능성으로-판단한다&quot;&gt;GC는 도달 가능성으로 판단한다&lt;/h3&gt;
&lt;p&gt;Java의 가비지 컬렉터는 &quot;도달 가능성(Reachability)&quot;을 기준으로 객체를 판단합니다. 살아있는 스레드, 스택의 지역 변수, 정적 필드 같은 &quot;GC Root&quot;로부터 참조 체인을 따라 도달할 수 있다면, 그 객체는 살아있는 것으로 간주됩니다.&lt;/p&gt;
&lt;p&gt;도달 가능성을 설명하려고 실제 구조를 단순화한 예시입니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoomRepository&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;    // 애플리케이션이 서버 수명 동안 보유하는 Repository의 인스턴스 필드&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Map&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;RoomId&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;gt; multiRooms &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ConcurrentHashMap&amp;lt;&amp;gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; finishGame&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(RoomId &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;roomId&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        GameRoom room &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; multiRooms.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;get&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(roomId);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        room.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;announceWinner&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;        // multiRooms.remove(roomId)를 호출하지 않음&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;        // → room은 여전히 Map에서 참조되고 있음&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;        // → 살아있는 애플리케이션 객체에서 Repository와 Map을 거쳐 도달 가능&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;        // → 가비지 컬렉션 대상이 아님!&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 &lt;code&gt;multiRooms&lt;/code&gt;는 &lt;code&gt;static&lt;/code&gt; 필드도, 그 자체가 GC Root인 것도 아닙니다. 실제 참조 체인은 대략 &lt;code&gt;GC Root → 서버 수명 객체 → GameRoomRepository → multiRooms → GameRoom&lt;/code&gt;입니다. 서버가 Repository를 계속 보유하는 동안 Map의 값도 강한 참조로 도달 가능하므로 수집되지 않습니다. 반대로 Repository 자체가 도달 불가능해지면 Map에 항목이 남아 있어도 Map과 GameRoom을 함께 수집할 수 있습니다.&lt;/p&gt;
&lt;!-- 이미지 위치: GC Root와 참조 체인 다이어그램 --&gt;
&lt;p&gt;이전에 Spring 기반 라이브러리와 컨테이너가 관리하는 연결 생명주기를 사용할 때는 이런 문제를 직접 마주할 일이 적었습니다. 그렇다고 프레임워크가 애플리케이션이 만든 Map과 게임룸을 자동으로 정리해주는 것은 아닙니다. 직접 만든 저장소에서는 &quot;언제 객체를 제거해야 하는가&quot;라는 정책과 코드를 개발자가 명시해야 합니다.&lt;/p&gt;
&lt;p&gt;서버 수명 동안 유지되는 Repository에서는 &lt;code&gt;remove()&lt;/code&gt;로 참조를 끊어야 한다는 점을 이해했습니다. Repository가 살아있는 한 Map을 거쳐 도달 가능한 GameRoom은 수집 대상이 아니기 때문입니다.&lt;/p&gt;
&lt;h2 id=&quot;실제-정리-구현과-남은-타이밍-문제&quot;&gt;실제 정리 구현과 남은 타이밍 문제&lt;/h2&gt;
&lt;p&gt;게임룸과 WebSocket 세션은 생명주기가 다릅니다. 게임룸은 게임이 끝나면 저장소에서 제거할 수 있지만, 세션은 실제 연결이 끝날 때 제거해야 합니다. 현재 소스도 룸 정리에서는 Repository의 방만 제거하고, 세션은 연결 종료 흐름에서 별도로 닫습니다.&lt;/p&gt;
&lt;p&gt;다만 현재 구현을 다시 확인하면서 정리 시점이 글의 원래 설명과 다르다는 것을 발견했습니다. &lt;code&gt;GameRoomService&lt;/code&gt;는 게임 종료 이벤트가 아니라 &lt;strong&gt;방을 만들고 게임 loop를 시작한 직후&lt;/strong&gt; 10초 타이머를 예약합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;room.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;start&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;startMultiGameLoop&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(room);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;scheduleRoomCleanup&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(roomId, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;false&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;); &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// 방 생성 시점부터 10초&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;따라서 지금 코드는 &quot;게임 종료 10초 후&quot;를 보장하지 않습니다. 보통 5라운드가 먼저 끝나더라도, 라운드 처리 지연이나 규칙 변경이 생기면 실행 중인 방이 Repository에서 먼저 제거될 수 있습니다. 현재 클라이언트에는 Restart 흐름도 없으므로 Restart를 근거로 정리 시간을 설명하는 것은 맞지 않았습니다.&lt;/p&gt;
&lt;p&gt;의도대로 만들려면 &lt;code&gt;GameRoom&lt;/code&gt;의 종료를 서비스에 알리는 이벤트나 완료 신호를 두고, 그 시점에 정리를 예약해야 합니다. 라운드 반복은 &lt;code&gt;scheduleAtFixedRate()&lt;/code&gt;를 사용하고 있으며, 단발 정리도 매번 새 Thread에서 &lt;code&gt;sleep()&lt;/code&gt;하기보다 같은 생명주기를 관리하는 &lt;code&gt;ScheduledExecutorService.schedule()&lt;/code&gt;로 표현하는 편이 취소·종료·테스트를 관리하기 쉽습니다.&lt;/p&gt;
&lt;p&gt;이 검토를 통해 &quot;정리 코드를 넣었다&quot;와 &quot;올바른 생명주기 시점에 정리된다&quot;는 서로 다른 검증 항목이라는 점을 배웠습니다.&lt;/p&gt;
&lt;p&gt;Thread로 동시 접속을 처리한 뒤에도 살펴볼 문제가 남았습니다. &lt;strong&gt;ConcurrentHashMap&lt;/strong&gt;의 개별 연산과 애플리케이션 불변식은 구분해야 했고, &lt;strong&gt;가비지 컬렉션&lt;/strong&gt;에 맡길 수 없는 참조도 직접 정리해야 했습니다. 자료구조를 고르는 일에 더해 여러 연산의 경계와 세션·게임룸의 생명주기를 함께 설계해야 한다는 것을 배웠습니다.&lt;/p&gt;</content:encoded></item><item><title>God Object를 해체하고 설계 개선하기</title><link>https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/God-Object%EB%A5%BC-%ED%95%B4%EC%B2%B4%ED%95%98%EA%B3%A0-%EC%84%A4%EA%B3%84-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0/</link><guid isPermaLink="true">https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/God-Object%EB%A5%BC-%ED%95%B4%EC%B2%B4%ED%95%98%EA%B3%A0-%EC%84%A4%EA%B3%84-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0/</guid><description>GameService에 모인 책임을 나누고 의존 관계를 정리한 과정과 남은 설계 과제를 돌아봅니다.</description><content:encoded>&lt;p&gt;수정일: 2025-11-24&lt;/p&gt;&lt;p&gt;게임이 작동하고 버그도 대부분 고친 뒤, 여러 책임이 섞인 코드를 정리하기 시작했습니다.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;이 글은 커밋 시간순이 아니라 설계 주제별로 재구성했습니다. 실제 순서는 Singleton 제거·생성자 주입, GameEventPublisher 도입, 3개 서비스 분리 순이었습니다. 아래 코드는 책임과 의존 관계를 설명하기 위해 부수 로직을 줄인 예시이며, 최종 구현은 &lt;a href=&quot;https://github.com/DongKey777/multi-racingcar/tree/6083960f5fbc2f934f00d91bd36289d0dc69399d/src/main/java&quot;&gt;Multi Racing Car 저장소&lt;/a&gt;에서 확인할 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;분리 직전 &lt;code&gt;GameService.java&lt;/code&gt;는 약 185줄이었습니다. 절대적인 줄 수보다 세션, 매칭, 게임룸 생성과 스케줄링이라는 서로 다른 변경 이유가 한 클래스에 모여 있다는 점이 문제였습니다.&lt;/p&gt;
&lt;h2 id=&quot;gameservice의-문제&quot;&gt;GameService의 문제&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;GameService&lt;/code&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;4명이 모이면 게임룸 생성&lt;/li&gt;
&lt;li&gt;게임 시작 및 라운드 진행&lt;/li&gt;
&lt;li&gt;게임 종료 후 정리 스케줄링&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;하나의 클래스에 너무 많은 책임이 모여 있었습니다. 세션 관리, 매칭, 게임룸 생성 등 변경 이유가 여러 개였고, 프리코스에서 익힌 단일 책임 원칙(SRP)을 적용할 시점이었습니다.&lt;/p&gt;
&lt;p&gt;변경 이유를 기준으로 3개 서비스로 분리했습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PlayerSessionService&lt;/strong&gt;: 세션 생성 및 관리&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; PlayerSessionService&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; SessionManager sessionManager;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; createSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, Socket &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;socket&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;throws&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Exception {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;        // 닉네임을 검증하고 세션 생성&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; closeSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;        // 세션 제거&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;MatchingService&lt;/strong&gt;: 대기열 및 매칭&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; MatchingService&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; WaitingQueue waitingQueue;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; MatchResult &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;joinQueue&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; waitingQueue.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;addPlayer&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; leaveQueue&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        waitingQueue.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;removePlayer&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;GameRoomService&lt;/strong&gt;: 게임룸 생성 및 스케줄링&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoomService&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; GameRoomRepository repository;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; GameEventPublisher eventPublisher;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; createAndStartMultiRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(Players &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;players&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        String&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[] nicknames &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; players.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getPlayers&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;stream&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;                .&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;map&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(Player&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;::&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;getNickname)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;                .&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;toArray&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[]&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;::new&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        GameRoom room &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nicknames, eventPublisher);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        repository.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;saveMultiRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;generateRoomId&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(), room);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        room.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;start&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;        startMultiGameLoop&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(room);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;controller의-역할&quot;&gt;Controller의 역할&lt;/h2&gt;
&lt;p&gt;3개 서비스로 나누고 나니 서비스들 사이의 협력을 누가 관리할지 결정해야 했습니다. Controller가 흐름을 제어하도록 설계했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameController&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; MatchingService matchingService;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; GameRoomService roomService;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; PlayerJoinResult &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;joinMultiplayerGame&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        MatchResult result &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; matchingService.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;joinQueue&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (result.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;isMatched&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;            roomService.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;createAndStartMultiRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(result.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getPlayers&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;());&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;            return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; PlayerJoinResult.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;success&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(Players.MAX_PLAYERS, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;true&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; PlayerJoinResult.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;success&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(result.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getWaitingCount&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(), &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;false&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Controller는 개별 서비스의 내부 구현을 알 필요 없이 필요한 동작을 요청합니다.&lt;/p&gt;
&lt;h2 id=&quot;도메인과-인프라-의존성&quot;&gt;도메인과 인프라 의존성&lt;/h2&gt;
&lt;p&gt;서비스를 분리하고 나니 또 다른 문제가 보였습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;GameRoom&lt;/code&gt; 도메인 클래스가 &lt;code&gt;SessionManager&lt;/code&gt;에 직접 의존하고 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; SessionManager sessionManager;  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// 인프라 계층&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; broadcastToPlayers&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        for&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (Player player &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; players.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getPlayers&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;            sessionManager.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;sendTo&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(player.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getNickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(), message);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;도메인은 비즈니스 로직만 담당해야 하는데, &quot;어떻게 메시지를 전송하는가&quot;는 인프라의 관심사입니다. WebSocket 대신 다른 프로토콜을 사용하게 되면 도메인도 수정해야 하는 구조였습니다.&lt;/p&gt;
&lt;p&gt;의존성 역전 원칙(DIP)을 적용해 인터페이스로 추상화했습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;GameEventPublisher&lt;/code&gt; 인터페이스를 만들었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; interface&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameEventPublisher&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; publish&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; publishToAll&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(List&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;gt; &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nicknames&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    boolean&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; hasActiveSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    boolean&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; hasSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GameRoom은 이제 인터페이스만 의존합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; GameEventPublisher eventPublisher;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; broadcastToPlayers&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        for&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (Player player &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; players.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getPlayers&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;            String nickname &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; player.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getNickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;            if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (eventPublisher.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;hasActiveSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname)) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;                eventPublisher.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;publish&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname, message);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;인터페이스의 구현체는 인프라 계층에 둡니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; WebSocketGameEventPublisher&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; implements&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameEventPublisher&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; SessionManager sessionManager;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    @&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;Override&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; publish&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        sessionManager.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;sendTo&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname, message);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    @&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;Override&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; publishToAll&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(List&amp;lt;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;gt; &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nicknames&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        for&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (String nickname &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; nicknames) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;            sessionManager.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;sendTo&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname, message);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    @&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;Override&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; boolean&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; hasActiveSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; sessionManager.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;hasActiveSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    @&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;Override&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; boolean&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; hasSession&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nickname&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; sessionManager.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;exists&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 GameRoom은 구체적인 WebSocket이나 SessionManager 대신 &lt;code&gt;GameEventPublisher&lt;/code&gt; 포트에 의존합니다. 다만 연결 상태를 조회하고 &lt;code&gt;System.out&lt;/code&gt;을 사용하는 책임은 여전히 남아 있으므로 완전히 순수한 도메인 모델이라고 부르지는 않겠습니다.&lt;/p&gt;
&lt;h2 id=&quot;singleton-제거&quot;&gt;Singleton 제거&lt;/h2&gt;
&lt;p&gt;서비스를 분리하면서 SessionManager를 어떻게 관리할지도 고민했습니다.&lt;/p&gt;
&lt;p&gt;초기에는 SessionManager를 Singleton 패턴으로 만들었습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; SessionManager&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; static&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; SessionManager instance;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; SessionManager&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() {}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; static&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; SessionManager &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getInstance&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (instance &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;            instance &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; SessionManager&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; instance;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;어디서든 &lt;code&gt;SessionManager.getInstance()&lt;/code&gt;만 호출하면 사용할 수 있어서 편했습니다.&lt;/p&gt;
&lt;h2 id=&quot;테스트의-어려움&quot;&gt;테스트의 어려움&lt;/h2&gt;
&lt;p&gt;Singleton의 문제는 테스트를 작성할 때 드러났습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;@&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;Test&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; testGameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    SessionManager manager &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; SessionManager.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getInstance&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;    // 이전 테스트의 세션이 남아있음!&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;    // manager에는 다른 테스트의 데이터가 섞여있음&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Singleton은 전역 상태이기 때문에 한 번 생성되면 프로그램이 종료될 때까지 살아있습니다. 별도의 초기화 장치가 없으면 테스트 실행 순서에 따라 상태가 섞여 격리가 어려워집니다.&lt;/p&gt;
&lt;p&gt;예를 들어 테스트 A에서 세션을 추가하면 테스트 B에도 남아 서로 영향을 줍니다. GameRoom이 SessionManager에 의존한다는 사실도 내부 코드에 숨어 있어 생성자나 메서드 파라미터만으로는 알 수 없었습니다.&lt;/p&gt;
&lt;h2 id=&quot;생성자-주입으로-변경&quot;&gt;생성자 주입으로 변경&lt;/h2&gt;
&lt;p&gt;Singleton을 제거하고 생성자 주입 방식으로 바꿨습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; broadcastMessage&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(String &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;message&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        SessionManager.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;getInstance&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;().&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;broadcast&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(message);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; GameEventPublisher eventPublisher;  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// 의존성 명시!&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;String&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;[] &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;nicknames&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, GameEventPublisher &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;eventPublisher&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        this&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;.players &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; Players&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nicknames);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;        this&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;.eventPublisher &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; eventPublisher;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 GameRoom을 만들 때 의존성을 넘겨줘야 합니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;GameEventPublisher eventPublisher &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; WebSocketGameEventPublisher&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(sessionManager);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;GameRoom room &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nicknames, eventPublisher);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;생성자만 봐도 이 클래스에 필요한 의존성을 알 수 있습니다. 테스트 시에는 Mock 객체를 주입할 수 있습니다.&lt;/p&gt;
&lt;h2 id=&quot;스케줄링-책임-분리&quot;&gt;스케줄링 책임 분리&lt;/h2&gt;
&lt;p&gt;게임 라운드를 1초마다 자동으로 진행하는 기능을 만들면서 새로운 고민이 생겼습니다. &quot;ScheduledExecutorService를 도메인에 넣어야 할까, 서비스에 넣어야 할까?&quot;&lt;/p&gt;
&lt;p&gt;처음에는 게임룸이 게임을 진행하니까 당연히 GameRoom이 스케줄링도 담당해야 한다고 생각했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; final&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ScheduledExecutorService scheduler;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; start&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        scheduler.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;scheduleAtFixedRate&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(() &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; playOneRound&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(), &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, TimeUnit.SECONDS);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;코드를 작성하고 실행해보니 문제없이 작동했습니다. 하지만 &quot;ScheduledExecutorService가 정말 도메인의 일부일까?&quot;라는 의문이 들었습니다.&lt;/p&gt;
&lt;p&gt;&quot;1초마다 실행&quot;은 도메인이 아닌 인프라의 관심사였습니다. ScheduledExecutorService를 GameRoomService로 옮겼습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GameRoom - 도메인&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoom&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; boolean&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; playNextRound&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (round.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;isLast&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;()) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;            endGame&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;            return&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; false&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// 게임 종료&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        round &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; round.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;next&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        players.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;moveAll&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;        printResult&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; true&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// 게임 계속&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;GameRoomService - 서비스&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;public&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; class&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; GameRoomService&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    private&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; void&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; startGameLoop&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(GameRoom &lt;/span&gt;&lt;span style=&quot;color:#A54B00;--shiki-dark:#FFAB70&quot;&gt;room&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        ScheduledExecutorService executor &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Executors.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;newScheduledThreadPool&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        executor.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;scheduleAtFixedRate&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(() &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;            boolean&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; continueGame &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; room.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;playNextRound&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;            if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;continueGame) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;                executor.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;shutdown&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;            }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        }, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, TimeUnit.SECONDS);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GameRoom은 &quot;다음 라운드를 진행한다&quot;만 제공합니다. 서비스가 &quot;1초마다 호출한다&quot;를 결정합니다.&lt;/p&gt;
&lt;p&gt;테스트에서는 &lt;code&gt;playNextRound()&lt;/code&gt;를 직접 호출해 스레드 없이 동기적으로 확인할 수 있었습니다.&lt;/p&gt;</content:encoded></item><item><title>프레임워크 없이 개발하며 배운 것들</title><link>https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC-%EC%97%86%EC%9D%B4-%EA%B0%9C%EB%B0%9C%ED%95%98%EB%A9%B0-%EB%B0%B0%EC%9A%B4-%EA%B2%83%EB%93%A4/</link><guid isPermaLink="true">https://dongkey.tech/projects/woowacourse-precourse/multi-racingcar/%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC-%EC%97%86%EC%9D%B4-%EA%B0%9C%EB%B0%9C%ED%95%98%EB%A9%B0-%EB%B0%B0%EC%9A%B4-%EA%B2%83%EB%93%A4/</guid><description>서버를 직접 만들며 알게 된 프레임워크의 역할과, 프로토콜·동시성·설계에서 놓친 점을 돌아봅니다.</description><content:encoded>&lt;p&gt;수정일: 2025-11-24&lt;/p&gt;&lt;p&gt;프레임워크 없이 멀티플레이 레이싱 게임을 만들었습니다.&lt;/p&gt;
&lt;p&gt;3주 동안 ServerSocket으로 학습용 HTTP 서버를 구현하고, RFC 6455 문서를 읽으며 WebSocket의 제한된 경로를 직접 다루고, Race Condition과 객체 생명주기를 분석하고, 여러 책임이 섞인 서비스를 분리하는 과정을 거쳤습니다.&lt;/p&gt;
&lt;p&gt;특히 프로토콜의 지원 범위와 동시성 제어에서 놓친 점을 돌아봅니다.&lt;/p&gt;
&lt;h2 id=&quot;프레임워크의-가치&quot;&gt;프레임워크의 가치&lt;/h2&gt;
&lt;p&gt;프레임워크를 사용할 때는 이런 것들이 얼마나 복잡한지 전혀 몰랐습니다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@RestController&lt;/code&gt;로 HTTP 요청을 받고, 간단한 설정으로 WebSocket을 연결하고, &lt;code&gt;@Transactional&lt;/code&gt;로 트랜잭션을 관리하는 일이 간단하게 느껴졌습니다.&lt;/p&gt;
&lt;p&gt;하지만 직접 구현하고 나니 프레임워크가 얼마나 많은 것을 해주고 있는지 체감했습니다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HTTP 요청 파싱 (프로젝트에 필요한 GET 요청 라인과 헤더 범위)&lt;/li&gt;
&lt;li&gt;MIME Type 자동 설정 (.html, .css, .js 확장자별 처리)&lt;/li&gt;
&lt;li&gt;WebSocket Handshake (Sec-WebSocket-Key 해싱)&lt;/li&gt;
&lt;li&gt;WebSocket Frame 처리 (짧은 단일 텍스트 frame과 masking)&lt;/li&gt;
&lt;li&gt;세션 관리에 필요한 생성·저장·정리 흐름&lt;/li&gt;
&lt;li&gt;동시성 제어에 필요한 executor와 원자 연산&lt;/li&gt;
&lt;li&gt;애플리케이션 객체의 생명주기 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;특히 WebSocket frame을 읽으려면 payload length와 masking key를 해석해야 해서 오랜 시간 공부했습니다. 다시 소스를 검토해보니 당시 코드는 opcode를 실제로 추출하거나 검증하지 않았고, 확장 길이와 제어 frame도 지원하지 않았습니다.&lt;/p&gt;
&lt;p&gt;직접 구현해 보니 프레임워크가 복잡한 문제를 검증된 방식으로 다루는 도구라는 점을 체감했습니다.&lt;/p&gt;
&lt;h2 id=&quot;rfc-6455와-websocket-프로토콜&quot;&gt;RFC 6455와 WebSocket 프로토콜&lt;/h2&gt;
&lt;p&gt;RFC 6455 문서를 처음 열었을 때는 막막했습니다. 영어로 빽빽하게 적혀있고 비트 단위 다이어그램이 가득했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt; 0                   1                   2                   3&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt; 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;|F|R|R|R| opcode|M| Payload len |    Extended payload length    |&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&quot;이걸 어떻게 읽지?&quot; 싶었지만 한국어 블로그 글을 참고하며 한 섹션씩 읽어나갔습니다.&lt;/p&gt;
&lt;p&gt;opening handshake와 짧은 단일 텍스트 frame의 송수신 경로를 구현하면서 WebSocket의 기본 구조를 알게 되었습니다. closing handshake와 ping/pong, fragmentation, 확장 payload 길이는 구현 범위에 포함되지 않았습니다.&lt;/p&gt;
&lt;h2 id=&quot;race-condition과-concurrenthashmap&quot;&gt;Race Condition과 ConcurrentHashMap&lt;/h2&gt;
&lt;p&gt;닉네임 중복 버그를 추적하면서 Race Condition을 처음 경험했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (nicknames.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;contains&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname)) {  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// 검증&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    throw&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; new&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; IllegalArgumentException&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;nicknames.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;add&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(nickname);             &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// 실행&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;싱글스레드 환경에서는 문제없던 코드가 멀티스레드 환경에서는 버그가 되었습니다.&lt;/p&gt;
&lt;p&gt;두 스레드가 동시에 &lt;code&gt;contains()&lt;/code&gt;를 호출하면 둘 다 false를 받고 둘 다 &lt;code&gt;add()&lt;/code&gt;를 호출해서 중복이 발생했는데, &quot;검증과 실행 사이의 갭&quot;이 문제였습니다.&lt;/p&gt;
&lt;p&gt;이 경험을 통해 &lt;code&gt;ConcurrentHashMap&lt;/code&gt; 같은 thread-safe한 자료구조가 왜 필요한지 체감했습니다. 동시에 자료구조를 바꾸는 것만으로 여러 단계의 로직까지 원자적이 되는 것은 아니라는 점도 배웠습니다. 현재 구현의 &lt;code&gt;containsKey()&lt;/code&gt; 이후 &lt;code&gt;put()&lt;/code&gt; 구조에는 여전히 경쟁 조건이 남아 있어, &lt;code&gt;putIfAbsent()&lt;/code&gt; 같은 단일 원자 연산으로 보완해야 합니다.&lt;/p&gt;
&lt;h2 id=&quot;repository-참조와-가비지-컬렉션&quot;&gt;Repository 참조와 가비지 컬렉션&lt;/h2&gt;
&lt;p&gt;정리 로직이 없던 코드에서는 게임이 끝난 뒤에도 GameRoom이 Repository의 Map에 남아 참조가 누적될 수 있었습니다. 가비지 컬렉터는 살아있는 Map에서 도달 가능한 객체를 자동으로 제거하지 않습니다.&lt;/p&gt;
&lt;p&gt;그래서 서버 수명 동안 유지되는 Repository에서 참조를 명시적으로 제거해야 했습니다.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;java&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;room.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;start&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;startMultiGameLoop&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(room);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;scheduleRoomCleanup&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(roomId, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;false&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;); &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#A8B1BC&quot;&gt;// 현재는 방 생성 시점부터 10초&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;현재 구현은 방이 만들어진 시점부터 10초 뒤 Repository에서 제거합니다. 영구히 남기는 문제는 줄였지만 &quot;게임 종료 10초 후&quot;를 보장하지는 않습니다. 종료 이벤트를 기준으로 &lt;code&gt;ScheduledExecutorService.schedule()&lt;/code&gt;을 호출하도록 개선해야 생명주기 의도가 정확해집니다.&lt;/p&gt;
&lt;p&gt;프레임워크와 서버 라이브러리는 연결 수명주기와 executor 같은 검증된 도구를 제공하지만, 애플리케이션이 만든 게임룸 Map의 정리 시점까지 자동으로 결정해주지는 않습니다.&lt;/p&gt;
&lt;p&gt;저는 객체를 언제 제거하고 얼마나 기다릴지를 직접 정해야 했습니다.&lt;/p&gt;
&lt;h2 id=&quot;srp-dip-그리고-책임-분리&quot;&gt;SRP, DIP, 그리고 책임 분리&lt;/h2&gt;
&lt;p&gt;프리코스에서 배운 원칙들을 실제 프로젝트에 적용했습니다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;단일 책임 원칙 (SRP)&lt;/strong&gt;: God Object였던 GameService를 3개로 분리&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PlayerSessionService: 세션 관리&lt;/li&gt;
&lt;li&gt;MatchingService: 대기열 및 매칭&lt;/li&gt;
&lt;li&gt;GameRoomService: 게임룸 생성 및 스케줄링&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;의존성 역전 원칙 (DIP)&lt;/strong&gt;: GameEventPublisher 인터페이스로 추상화&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;도메인(GameRoom)이 인프라(SessionManager)를 직접 의존하지 않음&lt;/li&gt;
&lt;li&gt;구체적인 WebSocket·SessionManager 의존을 포트 뒤로 역전&lt;/li&gt;
&lt;li&gt;연결 상태 조회와 출력 책임은 남아 있어 완전히 순수한 도메인은 아님&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Value Object&lt;/strong&gt;: Nickname, Position, Round, RoomId&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;원시값을 포장해서 의미를 명확히 하고 검증 로직을 한 곳에 모음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;일급 컬렉션&lt;/strong&gt;: Players&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;플레이어 목록을 관리하는 로직을 한 곳에 모음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;초기 개발 단계에서는 빠른 구현을 우선했습니다. 일단 동작하는 게임을 만드는 것이 목표였고, 하나의 Service에 모든 로직을 넣는 방식으로 빠르게 기능을 완성했습니다.&lt;/p&gt;
&lt;p&gt;3주차 리팩토링 단계에서 설계를 개선했습니다. SRP에 따라 3개 서비스로 분리하고, DIP로 구체적인 WebSocket 의존을 포트 뒤로 옮겼습니다. 빠른 구현과 설계 개선, 두 단계를 모두 경험하면서 각각의 가치를 이해할 수 있었습니다.&lt;/p&gt;
&lt;p&gt;Singleton의 전역 상태 문제를 경험하고 생성자 주입으로 바꾼 일도 있었습니다.&lt;/p&gt;
&lt;h2 id=&quot;마치며&quot;&gt;마치며&lt;/h2&gt;
&lt;p&gt;막힐 때마다 검색하고, 블로그 글을 읽고, 공식 문서를 찾아보고, 코드를 고쳤습니다.&lt;/p&gt;
&lt;p&gt;프레임워크를 썼다면 훨씬 빠르게 끝났을 것입니다. 하지만 직접 구현하면서 프레임워크가 해주는 것들을 하나하나 이해하게 되었습니다.&lt;/p&gt;
&lt;p&gt;오픈미션에서는 멀티플레이 게임을 만들며 모르는 것을 스스로 찾아 배우는 법을 익혔습니다.&lt;/p&gt;</content:encoded></item></channel></rss>