<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Flrefly</title>
    <link>https://flrefly.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sat, 26 Sep 2026 00:46:00 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Flrefly</managingEditor>
    <item>
      <title>프로세스가 죽어도 FIN이 전송된다</title>
      <link>https://flrefly.tistory.com/74</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;운영하다 만난 세 가지 의문&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;RabbitMQ 컨슈머가 죽으면 메시지가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;즉시&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;재큐잉됩니다. 브로커는 어떻게 즉시 알았을까요?&lt;/li&gt;
&lt;li&gt;좀비가 된 컨슈머는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;30분&lt;/b&gt;(ack timeout)이나 기다립니다. 왜 얘는 즉시 알 수 없었을까요?&lt;/li&gt;
&lt;li&gt;업로드가 중간에 끊기면 폴백이 발동합니다. &quot;끊김&quot;은 누가, 언제 판정한 걸까요?&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;셋 다 답이 한 가지 사실에서 나옵니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;전제: TCP에서 침묵은 정상이다&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;TCP 연결은 케이블 어딘가에 실체로 존재하는 게 아닙니다. 양쪽 컴퓨터의 커널이 각자 메모리에 적어둔 상태(어디까지 보냈나, 어디까지 받았나, 버퍼)일 뿐입니다. 그리고 주고받을 데이터가 없으면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;아무 패킷도 흐르지 않습니다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;조용한 연결은 0바이트이고, 그게 TCP의 정상 상태입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 문제가 생깁니다. 상대가 조용할 때, 그 침묵이 &quot;보낼 게 없어서&quot;인지 &quot;죽어서&quot;인지 패킷 수준에서는 완전히 똑같이 보입니다. 즉&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;상대의 죽음은 저절로 알게 되는 게 아니라, 누군가 종료 통지(FIN/RST)를 보내줘야만 즉시 알 수 있습니다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;통지가 없는 죽음은, 뭔가 보내보고 응답이 없다는 걸 겪은 뒤에야 압니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;죽음이 알려지는 세 가지 방식&lt;/h2&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;시나리오&lt;/td&gt;
&lt;td&gt;무슨 일이 벌어지나&lt;/td&gt;
&lt;td&gt;상대가 아는 시점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;앱이 close() 호출&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;FIN을 주고받으며 양쪽 합의로 종료&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;즉시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;height: 37px;&quot;&gt;프로세스만 급사 (OOM kill, kill -9)&lt;/td&gt;
&lt;td style=&quot;height: 37px;&quot;&gt;커널이 남은 소켓을 정리하며 통지(FIN/RST)를 대신 보냄&lt;/td&gt;
&lt;td style=&quot;height: 37px;&quot;&gt;즉시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;height: 37px;&quot;&gt;머신&amp;middot;네트워크 급사 (전원, 케이블, 커널 패닉)&lt;/td&gt;
&lt;td style=&quot;height: 37px;&quot;&gt;아무 패킷도 안 나감&lt;/td&gt;
&lt;td style=&quot;height: 37px;&quot;&gt;보내던 중이면 재전송이 계속 실패한 끝에(수 분 뒤), 조용한 중이면 영영 모름&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;세 번째 줄이 도입부 3번 의문의 답입니다. 업로드 끊김을 판정한 건 보내는 쪽 TCP입니다. 보낼 게 있는데 응답 없는 재전송이 반복되다 한도가 차면 &quot;끊겼다&quot;고 판정합니다. 반대로 아무것도 안 보내는 중이었다면 판정할 재료 자체가 없습니다. 그래서 RabbitMQ 같은 시스템은 하트비트를 씁니다. 주기적으로 신호를 주고받게 해서, 응답이 끊기면 죽은 것으로 판정하는 겁니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;죽지 않은 네 번째 경우: 좀비&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 표의 세 줄 어디에도 안 들어가는 경우가 있습니다. 프로세스가 살아있고, 연결도 진짜로 멀쩡한데,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;일만 안 하는&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;경우입니다. 무한루프, 데드락, 응답 없는 외부 호출에 걸려 멈춘 스레드가 그렇습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;머신이 죽은 경우와 겉보기는 같지만, 결정적 차이가 있습니다. 머신 급사는 하트비트를 보내면 응답이 없어서 알수있지만, 좀비는 하트비트 응답을 통신 담당(IO 스레드)이 대신 잘 해주기 때문에 연결 감시로는 구분할 수 없습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 컨슈머는 죽은 게 아니니 FIN이 나갈 리 없고, 하트비트에도 정상적으로 응답합니다. 연결을 감시하는 어떤 수단으로도 잡히지 않습니다. 연결이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;작업&lt;/b&gt;이 멈춘 거라서, &quot;받아간 메시지를 30분 안에 처리 완료(ack)하지 않으면 회수한다&quot;는 작업 단위의 시간제한(ack timeout)으로만 잡을 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;도입부 2번 의문의 답이 이겁니다. 좀비가 30분을 기다리는 건 감지가 느려서가 아니라, ack timeout 말고는 이 경우를 잡을 수단이 없어서입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;정리하면 감지 수단은 세 가지입니다.&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;수단&lt;/td&gt;
&lt;td&gt;잡는것&lt;/td&gt;
&lt;td&gt;시점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;종료 통지 (FIN/RST)&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;프로세스의 죽음&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;즉시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;하트비트&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;머신&amp;middot;네트워크의 죽음&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;분 단위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;ack timeout&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;살아있는데 일 안 하는 좀비&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;30분&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;프로세스가 죽었는데 FIN은 누가 보냈나&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;kill -9는 프로세스에게 정리할 기회를 주지 않는 신호입니다. close()를 호출할 틈도 없이 죽습니다. 그런데도 상대는 즉시 압니다. 누가 FIN을 보낸 걸까요?&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 이게 이상했습니다. 프로세스가 죽으면 그 자원은 전부 회수되고, 소켓 정리도 프로세스 몫이라고 생각했기 때문입니다. 알고 보니 소유 방향을 반대로 알고 있었습니다. &lt;b&gt;소켓은 프로세스의 메모리에 없습니다. 커널 메모리에 있습니다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;프로세스가 가진 건 파일 디스크립터라는 참조값(정수 하나)뿐입니다. 그리고 프로세스를 만들고, 메모리를 나눠주고, 죽이는 것까지 전부 커널이 하는 일이라, 커널이 프로세스의 죽음을 모를 수가 없습니다. 그래서 프로세스가 죽을 때 실제로 벌어지는 일은 이렇습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;커널이 프로세스의 메모리를 회수합니다.&lt;/li&gt;
&lt;li&gt;커널 자신의 메모리에는 그 프로세스가 들고 있던 소켓 참조 목록이 남아 있습니다.&lt;/li&gt;
&lt;li&gt;커널은 그 참조마다 소켓을 닫으며 상대에게 FIN/RST를 보냅니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;앱이 close()를 부른 경우와 kill -9로 죽은 경우의 차이는 &quot;누가 시켰나&quot;뿐이고, 실제로 FIN을 보내는 건 언제나 커널입니다. 직접 확인해봤습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1784817890501&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// server.java
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(&quot;localhost&quot;, 9999));

Socket socket = serverSocket.accept();
System.out.println(&quot;대기 시작!!!&quot;);

int data = socket.getInputStream().read();
System.out.println(&quot;data=&quot; + data + &quot; 도착!&quot;);

// client.java
Socket clientSocket = new Socket();
clientSocket.connect(new InetSocketAddress(&quot;localhost&quot;, 9999));

System.out.println(ProcessHandle.current().pid());
Thread.sleep(1000000);&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;read()로 대기 중인 서버를 두고, 붙어있던 클라이언트 프로세스를 kill -9로 죽인 순간의 패킷입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1784817929437&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;19:52:40.904725 IP 127.0.0.1.51278 &amp;gt; 127.0.0.1.9999:
    Flags [F.], seq 1, ack 1, length 0

19:52:40.904800 IP 127.0.0.1.9999 &amp;gt; 127.0.0.1.51278:
    Flags [.], ack 2, length 0

19:52:40.912424 IP 127.0.0.1.9999 &amp;gt; 127.0.0.1.51278:
    Flags [F.], seq 1, ack 2, length 0&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;앱은 아무 코드도 실행하지 못하고 죽었는데 FIN이 나갑니다. 소켓의 주인이 커널이라는 증거입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;왜 이런 구조일까요. 제 나름대로 이유를 꼽아보면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네트워크 카드는 한 장인데 통신하려는 프로세스는 수백 개입니다. 도착한 패킷이 누구 것인지 배분하려면 단일 관리자가 필요합니다.&lt;/li&gt;
&lt;li&gt;프로세스가 하드웨어를 직접 만질 수 있으면 남의 포트인 척 패킷을 위조할 수 있습니다. 커널이 유일한 접점이면 주인을 확인하고 막을 수 있습니다.&lt;/li&gt;
&lt;li&gt;재전송이나 ACK 응답은 앱이 GC로 잠깐 멈춰 있는 동안에도 지켜져야 하고, 앱이 급사했을 때 종료를 통지할 누군가가 남아 있어야 합니다. TCP는 앱보다 오래 살아있는 쪽이 맡아야 하는 일입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;사고실험: 네트워크 카드를 프로세스마다 주면 안 될까&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;카드가 한 장이라 관리자가 필요하다&quot;는 말이 맞는지 뒤집어서 확인해봤습니다. 프로세스마다 카드를 1:1로 준다면?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;IP는 카드마다 붙기 때문에 프로세스마다 IP가 따로 생깁니다. 외부 입장에선 &quot;서버 하나의 8080&quot;이 아니라 프로세스별 IP를 알아야 접속할 수 있게 됩니다. 상대가 내 서버의 내부 배치까지 알아야 하는 구조가 됩니다.&lt;/li&gt;
&lt;li&gt;프로세스가 죽었을 때 FIN을 보내줄 주체 문제도 그대로 남습니다. 카드는 받은 바이트를 쏘기만 할 뿐 연결 상태를 모르기 때문에 못 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;어떤 방식으로 바꿔봐도 장부(포트&amp;rarr;프로세스 대응)와 뒷정리(종료 통지)를 맡을 관리자가 필요했습니다. 커널을 빼면 그 일이 사라지는 게 아니라 전부 앱이 직접 떠안게 됩니다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;소켓이 프로세스 소유였다면, 프로세스가 죽는 순간 소켓도 같이 사라지고 FIN을 보낼 주체도 없어집니다. 즉시 알 수 있던 죽음이 전부 한참 뒤에야 알게 되는 죽음으로 바뀝니다. 컨슈머가 죽었을 때 메시지가 즉시 재큐잉되는 건 이 설계가 주는 이점입니다.&lt;/li&gt;
&lt;li&gt;하지만 커널이 잡아주는 건 딱 거기까지입니다. 머신째 죽으면 통지할 커널이 없고, 좀비는 애초에 죽지도 않았습니다. 그래서 시스템들은 하트비트로 침묵을 정보로 바꾸고, ack timeout으로 작업을 감시합니다.&lt;/li&gt;
&lt;li&gt;한 줄로:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;통지받으면 즉시, 하트비트로 알면 분 단위, 일이 안 끝나는 건 30분.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;침묵은 정보가 아니라서, 시스템은 메시지를 만들어내고 타임아웃으로 포기 시점을 정합니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>네트워크</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/74</guid>
      <comments>https://flrefly.tistory.com/74#entry74comment</comments>
      <pubDate>Thu, 16 Jul 2026 19:36:36 +0900</pubDate>
    </item>
    <item>
      <title>로컬 개발 환경이 부팅이후 느려지는 이유 파악하기</title>
      <link>https://flrefly.tistory.com/70</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경: 며칠 지나면 맥이 느려졌다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로컬에서 개발하다 보면 부팅 직후에는 빨랐습니다.&lt;/li&gt;
&lt;li&gt;그런데 며칠이 지나면 점점 느려졌습니다.&lt;/li&gt;
&lt;li&gt;재부팅하면 다시 빨라졌습니다.&lt;/li&gt;
&lt;li&gt;이 패턴이 계속 반복됐습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;처음에는 그냥 넘겼다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;뭔가 쌓여서 그렇겠지&quot; 하고 대수롭지 않게 봤습니다.&lt;/li&gt;
&lt;li&gt;재부팅하면 해결되니까요.&lt;/li&gt;
&lt;li&gt;그런데 너무 자주 반복되니 원인이 궁금해졌습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫 시도: 도커 컨테이너를 줄여봤다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;처음에는 도커가 메모리를 많이 먹는다고 생각했습니다.&lt;/li&gt;
&lt;li&gt;그래서 Kafka 컨테이너를 3개에서 1개로 줄였습니다.&lt;/li&gt;
&lt;li&gt;큰 효과가 없었습니다.&lt;/li&gt;
&lt;li&gt;컨테이너 개수가 문제가 아니라는 뜻이었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;메모리를 직접 들여다봤다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;먼저 무엇이 병목인지 봤습니다. CPU는 한가한데 메모리만 압박이었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;top -l 1 | grep PhysMem
PhysMem: 35G used (4937M wired, 8579M compressor), 98M unused.&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;unused&lt;/code&gt;(노는 메모리)가 거의 0입니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;compressor&lt;/code&gt;(압축된 메모리)가 8GB가 넘습니다.&lt;/li&gt;
&lt;li&gt;메모리가 부족해서 맥이 쥐어짜고 있는 상태입니다.&lt;/li&gt;
&lt;li&gt;그다음 누가 제일 많이 쓰는지 봤습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;top -l 1 -o mem -n 5 -stats pid,command,mem
PID    COMMAND          MEM
70446  com.apple.Virtua 8024M
42420  idea             4830M
59383  java             1331M
33428  java             821M&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;com.apple.Virtua...&lt;/code&gt; 라는 게 8GB를 쓰고 있었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 프로세스가 colima인지 확인했다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이름이 &lt;code&gt;com.apple...&lt;/code&gt; 이라 애플 기본 프로세스처럼 보였습니다.&lt;/li&gt;
&lt;li&gt;colima는 맥의 가상화 기능(Virtualization.framework) 위에서 돌기 때문에 이렇게 표시됩니다.&lt;/li&gt;
&lt;li&gt;정말 colima인지, 이 프로세스가 열고 있는 파일로 확인했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;lsof -p 70446 | grep colima
com.apple 70446 miridih 4u REG ... /Users/miridih/.colima/_lima/colima/disk
com.apple 70446 miridih 5u REG ... /Users/miridih/.colima/_lima/_disks/colima/datadisk&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;colima의 디스크 파일을 붙들고 있었습니다. colima가 맞았습니다.&lt;/li&gt;
&lt;li&gt;설정도 확인했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;colima list
PROFILE   STATUS    ARCH      CPUS   MEMORY   DISK    RUNTIME
default   Running   aarch64   4      8GiB     40GiB   docker&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;colima에 메모리 8GB를 주도록 설정돼 있었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;VM 안을 보니 대부분이 캐시였다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;colima는 맥 안에서 도는 작은 리눅스입니다. 도커 컨테이너는 그 안에서 돕니다.&lt;/li&gt;
&lt;li&gt;그래서 그 리눅스 안의 메모리를 봤습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;colima ssh -- free -h
               total   used   free   shared  buff/cache   available
Mem:           7.7Gi   2.5Gi  3.1Gi  79Mi    2.4Gi        5.2Gi&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;used&lt;/code&gt;(실제 사용 중): 2.5GB&lt;/li&gt;
&lt;li&gt;&lt;code&gt;buff/cache&lt;/code&gt;(캐시): 2.4GB&lt;/li&gt;
&lt;li&gt;캐시는 한 번 읽은 파일을 다음에 빨리 쓰려고 메모리에 둔 복사본입니다.&lt;/li&gt;
&lt;li&gt;없어도 동작에는 지장이 없고, 메모리가 필요하면 알아서 버려집니다.&lt;/li&gt;
&lt;li&gt;여기서 의문이 생겼습니다.&lt;/li&gt;
&lt;li&gt;맥에서는 colima가 8GB라는데, 정작 VM 안에서 실제로 쓰는 건 2.5GB뿐이었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;의문이 두 개였다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하나. 무엇이 이 메모리를 채우는가?&lt;/li&gt;
&lt;li&gt;둘. 맥이 본 8GB와 내부의 2.5GB는 왜 다른가?&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;무엇이 메모리를 채우는지 실험했다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;free -h&lt;/code&gt;를 계속 띄워두고, 도커가 메모리를 쓸 만한 작업을 하나씩 해봤습니다.&lt;/li&gt;
&lt;li&gt;첫째, 여러 API를 계속 호출해서 DB를 조회했습니다.&lt;/li&gt;
&lt;li&gt;그래도 캐시는 거의 안 늘었습니다.&lt;/li&gt;
&lt;li&gt;로컬 DB에 데이터가 많지 않아서, 읽어들일 양 자체가 적었기 때문입니다.&lt;/li&gt;
&lt;li&gt;둘째, 테스트를 돌렸습니다. 테스트는 실제 컨테이너를 띄우고 데이터를 씁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[18:35] used=2.4Gi cache=2.1Gi
[18:51] used=3.3Gi cache=4.2Gi   &amp;lt;- 캐시가 차오름&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시가 올라갔습니다.&lt;/li&gt;
&lt;li&gt;테스트가 끝나 컨테이너가 사라지면 캐시도 같이 빠졌습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[18:51] used=2.5Gi cache=2.3Gi   &amp;lt;- 테스트 종료, 캐시 빠짐&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;즉 조회만으로는 잘 안 찼고(로컬 데이터가 적어서), 실제 컨테이너 활동이 메모리를 채웠습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;더 이상한 점: VM은 비워도 맥은 안 돌려줬다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트가 끝나 VM 내부 캐시는 빠졌는데, 맥이 본 점유량은 그대로였습니다.&lt;/li&gt;
&lt;li&gt;&quot;한 번 잡은 메모리를 호스트(맥)에 안 돌려주는 건가?&quot; 가설을 세우고 실험했습니다.&lt;/li&gt;
&lt;li&gt;일부러 캐시를 채웠다가 비우고, 맥이 따라 내려가는지 봤습니다.&lt;/li&gt;
&lt;li&gt;기준선&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;[VM 내부] used 2.5G  cache 2.4G  free 3.1G
[맥] colima 점유 1.72GB&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;디스크를 읽어 캐시를 채움&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;colima ssh -- sudo sh -c 'cat /dev/vda &amp;gt; /dev/null'
[VM 내부] used 2.5G  cache 5.3G  free 0.2G
[맥] colima 점유 6.19GB&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;채우니까 맥도 같이 올라갔습니다.&lt;/li&gt;
&lt;li&gt;캐시를 강제로 비움&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;colima ssh -- sudo sh -c 'sync; echo 3 &amp;gt; /proc/sys/vm/drop_caches'
[VM 내부] used 2.4G  cache 0.38G  free 5.2G&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;VM 안에서는 캐시가 거의 다 빠졌습니다.&lt;/li&gt;
&lt;li&gt;그럼 맥은?&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;[맥] colima 점유 5.82GB&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;VM은 약 5GB를 비웠는데, 맥은 6.19 &amp;rarr; 5.82로 거의 그대로였습니다.&lt;/li&gt;
&lt;li&gt;colima는 한 번 잡은 메모리를 맥에 잘 돌려주지 않는다는 뜻입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;같은 VM인데 숫자가 셋으로 갈렸다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;같은 colima인데 보는 도구마다 숫자가 달랐습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;도구&lt;/th&gt;
&lt;th&gt;숫자&lt;/th&gt;
&lt;th&gt;뜻&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;활성 상태 보기&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;colima가 차지한 전체(footprint)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ps (rss)&lt;/td&gt;
&lt;td&gt;4GB&lt;/td&gt;
&lt;td&gt;지금 물리 RAM에 올라와 있는 양&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;free -h (내부)&lt;/td&gt;
&lt;td&gt;약 7.8GB 만짐&lt;/td&gt;
&lt;td&gt;VM이 건드린 양&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;활성 상태 보기 8GB는 차지한 전체입니다. 압축돼 치워진 것까지 포함합니다.&lt;/li&gt;
&lt;li&gt;ps 4GB는 그중 실제 물리 RAM에 있는 양입니다.&lt;/li&gt;
&lt;li&gt;둘의 차이(약 4GB)는 맥이 압축/스왑으로 밀어낸 양입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 왜 느려졌나&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;colima가 메모리 8GB를 차지합니다.&lt;/li&gt;
&lt;li&gt;맥은 그 8GB를 물리 RAM에 다 못 올려서 일부를 압축하고 스왑으로 밀어냅니다.&lt;/li&gt;
&lt;li&gt;그 압축/해제와 스왑 작업이 CPU와 디스크를 갉아먹습니다.&lt;/li&gt;
&lt;li&gt;그래서 전체가 느려집니다.&lt;/li&gt;
&lt;li&gt;재부팅하면 colima가 잡았던 메모리가 풀려서 다시 빨라졌던 것입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결: 메모리 천장을 낮췄다&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시는 회수 가능한 여분입니다. 로컬은 데이터가 작아 캐시 이득도 작습니다.&lt;/li&gt;
&lt;li&gt;반면 맥이 압박받는 손해는 확실합니다.&lt;/li&gt;
&lt;li&gt;그래서 colima 메모리 천장을 낮추기로 했습니다.&lt;/li&gt;
&lt;li&gt;얼마로 낮출지는 평소가 아니라 최대 사용량으로 정했습니다.&lt;/li&gt;
&lt;li&gt;평소에는 2.5GB지만, 테스트를 돌리면 3.5GB까지 올라갔습니다.&lt;/li&gt;
&lt;li&gt;그래서 최대 3.5GB에 여유를 더해 5GB로 잡았습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;colima stop &amp;amp;&amp;amp; colima start --memory 5&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;줄인 뒤, 맥의 압박이 줄었는지 같은 명령으로 확인합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# 줄이기 전
PhysMem: ... 8579M compressor ...
vm.swapusage: ... used = 3733M ...

# 줄인 뒤 (예시 - 며칠 관찰 후 실제값으로 교체 예정)
PhysMem: 35G used (... 4200M compressor ...), ... unused.
vm.swapusage: ... used = 900M ...&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;compressor와 swap 사용량이 내려가면, colima가 압박의 주범이었다는 게 증명됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;증상: 며칠 지나면 느려지고, 재부팅하면 회복.&lt;/li&gt;
&lt;li&gt;원인: 맥 메모리 압박(압축/스왑).&lt;/li&gt;
&lt;li&gt;범인: colima가 메모리 8GB 차지.&lt;/li&gt;
&lt;li&gt;진짜 원인: VM이 빈 메모리를 캐시로 채우고, 비워도 맥에 돌려주지 않음. 천장이 8GB라 거기까지 차오름.&lt;/li&gt;
&lt;li&gt;해결: 최대 사용량을 기준으로 천장을 5GB로 낮춤.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;알아보니 lima(colima의 기반)에서도&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://github.com/lima-vm/lima/discussions/2720&quot;&gt;알려진 한계&lt;/a&gt;였습니다. 비운 메모리를 맥에 돌려주는 게 사실상 안 되기 때문에, 처음부터 천장을 낮게 잡아서 해결할수있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;후기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한달 쯤 지났는데, colima 점유는 천장인 5GB를 넘지 못했습니다(top 실측 5.1GB).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 달간 같은 이유로 느려지는 일이 재발하진 않았습니다.&lt;/p&gt;</description>
      <category>문제해결</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/70</guid>
      <comments>https://flrefly.tistory.com/70#entry70comment</comments>
      <pubDate>Fri, 26 Jun 2026 08:54:51 +0900</pubDate>
    </item>
    <item>
      <title>Spring 의 ObjectMapper 가 Hibernate JSONB 에 안 먹는 이유</title>
      <link>https://flrefly.tistory.com/68</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제: 반복되는 방어 코드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어느 날 DTO 에 필드 하나를 추가하려다 팀 코드를 살펴보니, 데이터 클래스 거의 모든 곳에 같은 어노테이션이 붙어 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@JsonIgnoreProperties(ignoreUnknown = true)
public class ResponseDto { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색해 보니 200 여 개 파일에 걸쳐 227 번 등장했습니다. 새 DTO 를 만들 때마다 누군가 이걸 붙이는 게 팀의 암묵적 관례가 되어 있었습니다. 이상한 점은 이미 프로젝트에는 전역 설정이 있었다는 점입니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Bean
public ObjectMapper objectMapper() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    objectMapper.registerModule(new JavaTimeModule());
    return objectMapper;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;FAIL_ON_UNKNOWN_PROPERTIES&lt;/code&gt; 를 disable 해놓았는데 왜 각 DTO 마다 또 &lt;code&gt;@JsonIgnoreProperties&lt;/code&gt; 를 붙여야 할까요?&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디버깅: 문제가 터지는 지점 찾기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;전역 설정이 이미 있으니 &lt;code&gt;@JsonIgnoreProperties&lt;/code&gt; 를 안 붙여도 되겠지&quot; 라고 생각하고 새 DTO 에 어노테이션 없이 필드를 추가했습니다. 그리곤 로컬 테스트 중에 예외가 터졌습니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException:
Unrecognized field &quot;...&quot; (class ...), not marked as ignorable&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스택 트레이스를 따라가 보니 Hibernate 가 JSONB 컬럼을 Java 객체로 역직렬화하는 과정에서 터진 예외였습니다. 해당 DTO 는 JPA 엔티티의 JSONB 컬럼 내부에 들어가는 필드였습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Entity
public class Upscale {

    @Type(JsonBinaryType.class)
    @Column(columnDefinition = &quot;jsonb&quot;)
    private UpscaleInfo info;

    @Data
    public static class UpscaleInfo {
        private CreditResponseDto credit;  // 여기!
        // ...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSONB 컬럼을 Java 객체로 매핑해 주는 건 &lt;code&gt;hypersistence-utils&lt;/code&gt; 라이브러리의 &lt;code&gt;JsonBinaryType&lt;/code&gt; 이었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;라이브러리 코드 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리가 ObjectMapper 를 어떻게 들고 있는지 봤더니 &lt;code&gt;ObjectMapperWrapper&lt;/code&gt; 라는 클래스 안에 &lt;code&gt;static&lt;/code&gt; 으로 ObjectMapper 싱글톤을 들고 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class ObjectMapperWrapper {
    private static final ObjectMapper OBJECT_MAPPER;
    public static final ObjectMapperWrapper INSTANCE;

    static {
        OBJECT_MAPPER = new ObjectMapper().findAndRegisterModules();
        // ...
        INSTANCE = new ObjectMapperWrapper(OBJECT_MAPPER);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션이 이미 가지고 있는 ObjectMapper 인스턴스와는 완전히 별개로, 라이브러리가 자기만의 ObjectMapper 를 하나 더 만들어 들고 있는 셈이었습니다.&amp;nbsp;결론은, 애플리케이션 쪽에서 &lt;code&gt;ObjectMapper&lt;/code&gt; 에 건 전역 설정이 이 라이브러리 내부에는 닿지 않는다는 것이었습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;경로&lt;/th&gt;
&lt;th&gt;사용하는 ObjectMapper&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spring MVC (Controller)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ObjectMapperConfig&lt;/code&gt; 의 Bean &amp;rarr; 전역 설정 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hibernate JSONB&lt;/td&gt;
&lt;td&gt;라이브러리의 static 싱글톤 &amp;rarr; 전역 설정 &lt;b&gt;무시&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 팀은 JSONB 로 내려갔다 올라오는 DTO 마다 &lt;code&gt;@JsonIgnoreProperties&lt;/code&gt; 를 수동으로 붙일 수밖에 없었던 것입니다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결: 라이브러리가 제공하는 확장 포인트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리가 외부에서 ObjectMapper 를 주입할 방법을 아예 막아놓은 것은 아니었습니다. &lt;code&gt;ObjectMapperSupplier&lt;/code&gt; 라는 인터페이스를 두고 &lt;code&gt;hypersistence-utils.properties&lt;/code&gt; 에 구현체 클래스명 을 등록하면 라이브러리 내부 Wrapper 가 그 Supplier 로 교체되도록 해놨습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현 시 고민: 버전 업 해도 따라가는 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Supplier 안에서 ObjectMapper 를 직접 만들어 돌려주면 동작은 하지만, 라이브러리의 기본 설정을 수동으로 재현하는 구조가 되어 버전 업 시 새로 추가된 모듈이나 설정을 놓칠 수 있다고 생각했습니다. 그래서 라이브러리가 이미 구성해 둔 기본 ObjectMapper 를 그대로 들고 와서 플래그만 얹는 방식을 택했습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;public ObjectMapper get() {
    return new ObjectMapperWrapper()
            .getObjectMapper()
            .copy()
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;new ObjectMapperWrapper()&lt;/code&gt; 의 no-arg 생성자는 라이브러리가 자체적으로 구성한 기본 ObjectMapper 를 그대로 돌려줍니다. 이 원본을 직접 수정하면 라이브러리 다른 경로에 영향을 줄 수 있으므로 &lt;code&gt;copy()&lt;/code&gt; 로 독립 인스턴스를 만들고 거기에만 플래그를 얹었습니다. 이 방식은 라이브러리가 버전 업 되며 기본 설정이 바뀌어도 자동으로 따라옵니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 코드&lt;/h2&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;// src/main/java/.../HypersistenceObjectMapperSupplier.java
public class HypersistenceObjectMapperSupplier implements ObjectMapperSupplier {

    @Override
    public ObjectMapper get() {
        return new ObjectMapperWrapper()
                .getObjectMapper()
                .copy()
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# src/main/resources/hypersistence-utils.properties
hypersistence.utils.jackson.object.mapper=com.example.HypersistenceObjectMapperSupplier&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;회고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 크게 남은 건 관례에 의문을 던지는 것의 가치였습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@JsonIgnoreProperties&lt;/code&gt; 는 애초에 개별 DTO 가 붙일 문제가 아니었고, 전역 설정이 있었는데도 작동하지 않는 건 원인이 다른 곳에 있다는 신호였습니다. 라이브러리의 초기화 순서와 static 싱글톤이라는 설계를 모르면 &quot;그냥 안 되는 거&quot; 로 끝났을 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문서에 적혀 있는 한 줄(&lt;code&gt;ObjectMapperSupplier&lt;/code&gt;) 을 찾는 데 필요한 건 라이브러리의 특별한 기능이 아니라 &quot;왜 안 될까&quot; 를 끝까지 파고 드는 태도였다고 생각합니다. 새 DTO 에 더 이상 방어 어노테이션이 필요하지 않은 것보다, 팀이 반복해 온 수동 작업의 근본 원인이 어디에 있었는지를 문서화했다는 점이 더 중요한 결과라고 느꼈습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/vladmihalcea/hypersistence-utils#hibernatetypesjacksonobjectmappersupplier&quot;&gt;hypersistence-utils &amp;mdash; Jackson ObjectMapperSupplier&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>문제해결</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/68</guid>
      <comments>https://flrefly.tistory.com/68#entry68comment</comments>
      <pubDate>Mon, 20 Apr 2026 19:44:20 +0900</pubDate>
    </item>
    <item>
      <title>S3 다운로드 최적화 시도기 &amp;mdash; Mountpoint를 도입하지 않은 이유</title>
      <link>https://flrefly.tistory.com/67</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;다운로드 시간의 중요성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 중인 서비스는 Spot 인스턴스 기반이라 매일 100~200번 인스턴스가 교체됩니다. 재부팅할 때마다 S3에서 수 GB의 모델 파일을 받아야 하기 때문에 아래와 같은 관점에서 다운로드 시간을 줄이는 것이 중요합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;비용&lt;/b&gt;: 기동 시간 1분 단축 = 월 수십 시간 절약 = 월 수십 달러 절감&lt;/li&gt;
&lt;li&gt;&lt;b&gt;장애 대응&lt;/b&gt;: 트래픽 스파이크 때 새 인스턴스가 준비되는 시간 = 사용자가 기다리는 시간&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1단계: 네트워크 시간이 느린걸까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시간을 줄여보기 위해. 먼저 현재 속도가 정상적인지를 검토해보았습니다.&lt;br /&gt;실측 속도가 ~300 MB/s였고, 스펙상으로는 더 빨라야했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여러 시도&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;병렬 다운로드&lt;/li&gt;
&lt;li&gt;multipart chunk 크기 조정 (8MB &amp;rarr; 64MB)&lt;/li&gt;
&lt;li&gt;S3 Transfer Acceleration 활성화&lt;/li&gt;
&lt;li&gt;CRT로 전송 엔진 변경&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;4번에서만 2~3배로 네트워크 속도가 빨라졌습니다 (300 MB/s &amp;rarr; 765 MB/s.)&lt;/li&gt;
&lt;li&gt;하지만 운영 환경에 적용했을 때 기동 시간이 거의 줄지 않았습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2단계: 디스크 병목&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크는 765 MB/s로 충분히 빨라졌지만, 그 뒤에 데이터를 써야 하는 디스크가 느려서 네트워크속도가 빨라져도 전체 처리 속도가 향상되지않았습니다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;S3 (빠름) 
  &amp;rarr; 네트워크 (765 MB/s, 빠름)
  &amp;rarr; 디스크 쓰기 (210 MB/s) &amp;larr; 여기서 막힘&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3단계: 디스크가 정말 느린지 확인하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;fio&lt;/code&gt;로 디스크 쓰기 성능을 쟀고. &lt;b&gt;~210 MB/s&lt;/b&gt; 정도의 성능이 나왔습니다.&lt;br /&gt;NVMe는 보통 1GB/s가 넘는다고 알고있었고, 공식 스펙에는 IOPS만 공개되고 MB/s는 안 나왔기 때문에 정말 디스크가 병목이 맞는지에 대해 추가 검토를 하였습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;파일시스템을 ext4에서 xfs로 바꿔봤다 &amp;rarr; 여전히 210 MB/s&lt;/li&gt;
&lt;li&gt;4개 프로세스로 병렬 쓰기를 해봤다 &amp;rarr; 총합도 210 MB/s&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 근거를 토대로 디스크에 상한이 걸려 있다고 판단하였습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4단계: 생각전환: 디스크에 꼭 써야할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;디스크를 빠르게 쓸 수 없으면, 애초에 디스크에 꼭 써야 할까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 워크로드를 다시 보면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모델을 한 번 GPU 메모리에 로드하면 거기서만 쓴다&lt;/li&gt;
&lt;li&gt;디스크에서 반복적으로 읽지 않는다&lt;/li&gt;
&lt;li&gt;디스크는 경유지의 역할을 한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면, AWS Mountpoint for S3를 활용해봐야겠다는 생각이 들었습니다.&lt;br /&gt;S3를 파일시스템처럼 마운트해서, 읽기 요청이 올 때 S3 GET API를 부르고 디스크를 거치지 않는 방법입니다.&lt;br /&gt;첫 로드만 조금 시간이 걸려도 GPU메모리에 로드만 시켜놓는다면, 디스크I/O가 필요없게 됩니다.&lt;br /&gt;첫로드자체도 메모리에 로드하게 된다면 900MB/s정도의 속도가 나옵니다&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5단계: 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리가 부족했습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인스턴스 메모리 구성:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모델 파일: 32GB&lt;/li&gt;
&lt;li&gt;GPU VRAM: 32GB&lt;/li&gt;
&lt;li&gt;RAM: 32GB&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리 부족 &amp;rarr; RAM에서 밀림 &amp;rarr; 네트워크 I/O 증가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 우리 워크로드는 랜덤 읽기 패턴이라 더 비효율적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과: 현재 인스턴스로는 불가능합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론상 Mountpoint는 좋은 솔루션이지만 도입하기에 아래 조건이 문제가 되었습니다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가용 메모리 양&lt;/li&gt;
&lt;li&gt;순차 읽기 패턴 유무&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 이 조건을 못 맞추므로 결과적으로 도입하지 않았습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 것들&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;클라우드 로컬 디스크는 물리 스펙이 아니다&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인스턴스 타입별로 성능이 제한되어 있다&lt;/li&gt;
&lt;li&gt;네트워크를 아무리 올려도 디스크 상한은 못 넘는다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기동 시간이 돈이라는 점&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하루 100~200번 기동 = 월 단위 비용 차이&lt;/li&gt;
&lt;li&gt;스파이크 상황에서 기동 지연 = 사용자 대기 시간&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;문제를 다시 정의하는 것도 중요함&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;어떻게 더 빨리 쓸 수 있나?&quot; vs &quot;꼭 써야 하나?&quot;&lt;/li&gt;
&lt;li&gt;질문이 다르면 해결책이 완전 달라짐&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;향후 가능성&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메모리 넉넉한 인스턴스 타입으로 가게 되면 Mountpoint를 다시 봐볼 가치가 있다&lt;/li&gt;
&lt;li&gt;그때의 의사결정에 이번 분석 결과가 쓰일 것&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>문제해결</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/67</guid>
      <comments>https://flrefly.tistory.com/67#entry67comment</comments>
      <pubDate>Thu, 16 Apr 2026 02:38:43 +0900</pubDate>
    </item>
    <item>
      <title>커밋이 완료되었다고 정말 디스크에 적혔을까? (2) 강제 kill에 의한 로그</title>
      <link>https://flrefly.tistory.com/66</link>
      <description>&lt;blockquote data-path-to-node=&quot;2&quot; data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이전 글에서 synchronous_commit 설정을 통해 데이터 안전과 쓰기 성능 사이의 트레이드오프를 확인해 보았습니다. 이론적으로 커밋된 데이터는 WAL(Write-Ahead Log) 덕분에 안전하게 보장된다고 배웠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-path-to-node=&quot;1&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;하지만 문득 이런 의문이 들었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;2,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&quot;이론적으로 복구된다는 건 알겠는데, 실제로 데이터가 막 쏟아지고 있는 찰나에 서버 전원이 나가버려도 정말 데이터가 하나도 안 날아가고 다 복구될까?&quot; &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;이 궁금증을 해결하기 위해, 직접 PostgreSQL 컨테이너의 프로세스를 &lt;/span&gt;&lt;b data-index-in-node=&quot;42&quot; data-path-to-node=&quot;3&quot;&gt;강제로 Kill&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;하는 실험을 진행했습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-path-to-node=&quot;4&quot; data-ke-size=&quot;size26&quot;&gt;synchronous_commit의 정확한 작동 방식&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size23&quot;&gt;트랜잭션 처리 과정&lt;/h3&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7&quot;&gt;1. 데이터 변경 발생 (INSERT, UPDATE 등)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;8&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메모리에 데이터 기록&lt;/li&gt;
&lt;li&gt;데이터를 WAL 버퍼(메모리)에 기록&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9&quot;&gt;2. COMMIT 실행 - 여기서 차이 발생!&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;10&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,0&quot;&gt;synchronous_commit = on (기본값)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WAL 버퍼(메모리)&lt;/li&gt;
&lt;li&gt;&amp;rarr; WAL 파일(디스크)에 물리적으로 기록(fsync) 대기&lt;/li&gt;
&lt;li&gt;&amp;rarr; 디스크 기록 완료 확인&lt;/li&gt;
&lt;li&gt;&amp;rarr; &quot;커밋 성공&quot; 응답 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;synchronous_commit = off&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WAL 버퍼(메모리)에만 기록&lt;/li&gt;
&lt;li&gt;&amp;rarr; 바로 &quot;커밋 성공&quot; 응답 반환&lt;/li&gt;
&lt;li&gt;&amp;rarr; 실제 디스크 기록은 백그라운드의 별도 프로세스가 나중에 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11&quot;&gt;3. 만약 이 시점에 docker kill 당하면?&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;on&lt;/b&gt;: WAL 파일(디스크)에 이미 있음 &amp;rarr; 복구 가능&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;off&lt;/b&gt;: WAL 버퍼(메모리)만 있던 데이터 &amp;rarr; 증발&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size26&quot;&gt;실험 환경 및 설정&lt;/h2&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;16&quot;&gt;테스트 환경&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;17&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL 13.22 (Docker)&lt;/li&gt;
&lt;li&gt;플랫폼: Docker on macOS (aarch64)&lt;/li&gt;
&lt;li&gt;설정: synchronous_commit = on (안전 최우선 모드)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18&quot;&gt;테스트용 테이블 생성&lt;/b&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;CREATE TABLE crash_test (
    id SERIAL PRIMARY KEY,
    created_at TIMESTAMP DEFAULT NOW()
);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;20&quot;&gt;데이터 삽입 프로시저&lt;/b&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE OR REPLACE PROCEDURE insert_commit_loop()
LANGUAGE plpgsql
AS $$
BEGIN
    FOR i IN 1..100000 LOOP
        INSERT INTO crash_test DEFAULT VALUES;
        COMMIT;  -- 핵심! 한 건 넣을 때마다 바로바로 커밋
    END LOOP;
END;
$$;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size23&quot;&gt;실험 진행: docker kill&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;23&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,0,0&quot;&gt;1단계: 프로시저 실행&lt;/b&gt; CALL insert_commit_loop();&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,1,0&quot;&gt;2단계: 진행 중 강제 종료&lt;/b&gt; docker kill postgres-container
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;docker stop은 DB에 정리할 시간을 주지만, docker kill은 프로세스를 즉시 kill합니다.&lt;/li&gt;
&lt;li&gt;(전원을 뽑는 것과 동일한 상황을 재현하기위해 kill을 사용했습니다)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,2,0&quot;&gt;3단계: 컨테이너 재시작&lt;/b&gt; docker start postgres-container&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23,3,0&quot;&gt;결과: 26,319건 복구&lt;/b&gt; SELECT count(*) FROM crash_test;&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;프로시저가 100,000번 루프를 다 돌기 전에 강제로 종료되었으므로, 실제로는 약 26,000번째 INSERT를 처리하던 중 전원이 나간 셈입니다. 하지만 단 한 건도 유실되지 않고 모두 복구되었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;25&quot;&gt;왜 유실이 없었을까?&lt;/b&gt; synchronous_commit = on 설정 덕분입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;26&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;26,319번째까지: WAL 디스크 기록 완료 &amp;rarr; 커밋 성공 응답&lt;/li&gt;
&lt;li&gt;26,320번째부터: WAL 디스크 기록 진행 중 &amp;rarr; 응답 못 보낸 상태에서 kill&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;사실 이 데이터들 중 상당수는 아직 실제 데이터 파일에는 적히지 않고 메모리에만 있었을 가능성이 큽니다. DB는 성능을 위해 데이터 파일 업데이트를 미루기 때문입니다. 그런데도 완벽하게 복구된 이유는 바로 디스크에 안전하게 기록된 WAL 덕분입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size26&quot;&gt;로그로 읽는 DB의 복원 과정&lt;/h2&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;다음은 재부팅 시 출력된 PostgreSQL의 로그입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1771763351192&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 강제 종료 직후: 모든 클라이언트 연결이 비명을 지름 
2026-02-22 20:06:14.553 KST [36] FATAL: terminating connection due to unexpected postmaster exit 
2026-02-22 20:06:14.553 KST [47] FATAL: terminating connection due to unexpected postmaster exit

# 재시작: 기존 데이터베이스 디렉토리 발견, 초기화 스킵 
PostgreSQL Database directory appears to contain a database; Skipping initialization

# 부팅 시작 
2026-02-22 20:06:18.723 KST [1] LOG: starting PostgreSQL 13.22 (Debian 13.22-1.pgdg13+1) 
2026-02-22 20:06:18.723 KST [1] LOG: listening on IPv4 address &quot;0.0.0.0&quot;, port 5432

# 마지막 정상 작동 시점 확인 (약 3분 26초 전) 
2026-02-22 20:06:18.732 KST [28] LOG: database system was interrupted; last known up at 2026-02-22 20:02:48 KST

# 비정상 종료 감지 &amp;rarr; 자동 복구 시작 
2026-02-22 20:06:19.676 KST [28] LOG: database system was not properly shut down; automatic recovery in progress

# Redo 시작: LSN 23/EF2940C0 위치부터 WAL 재생 
2026-02-22 20:06:19.681 KST [28] LOG: redo starts at 23/EF2940C0

# WAL이 끊긴 지점 발견: kill 당한 순간 메모리 데이터가 증발한 증거 
# &quot;24바이트 기대했는데 0바이트&quot; = WAL 레코드가 쓰이다가 중간에 잘림 
2026-02-22 20:06:19.710 KST [28] LOG: invalid record length at 23/EF72FD00: wanted 24, got 0

# 유효한 마지막 WAL 레코드까지 복구 완료 (약 4.2MB 재생) 
2026-02-22 20:06:19.710 KST [28] LOG: redo done at 23/EF72FCD8

# 복구 완료 및 서비스 재개
2026-02-22 20:06:19.753 KST [1] LOG: database system is ready to accept connections&lt;/code&gt;&lt;/pre&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;40&quot;&gt;핵심 포인트&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;41&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;23/EF2940C0 ~ 23/EF72FCD8: WAL의 시작과 끝 위치&lt;/li&gt;
&lt;li&gt;약 4.2MB의 WAL을 읽어서 26,319건의 INSERT를 재실행(Redo)&lt;/li&gt;
&lt;li&gt;3분 26초치 작업을 단 1초 만에 복구 (WAL이 순차IO라 빠름)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-path-to-node=&quot;43&quot; data-ke-size=&quot;size26&quot;&gt;번외: Checkpoint 폭풍과 WAL 크기 설정&lt;/h2&gt;
&lt;p data-path-to-node=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;복구 과정을 이해했으니, 이제 WAL 크기 설정의 중요성을 체험해 봅시다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;45&quot; data-ke-size=&quot;size23&quot;&gt;Checkpoint란?&lt;/h3&gt;
&lt;p data-path-to-node=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;메모리에 쌓인 변경된 데이터를 디스크의 실제 데이터 파일로 밀어내는 작업을 Checkpoint라고 합니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;47&quot; data-ke-size=&quot;size23&quot;&gt;실험: WAL 공간을 극단적으로 줄이기&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;sql&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;ALTER SYSTEM SET max_wal_size = '100MB';
ALTER SYSTEM SET min_wal_size = '20MB';
ALTER SYSTEM SET checkpoint_timeout = '30s';

CREATE TABLE checkpoint_test (
    id SERIAL PRIMARY KEY,
    data TEXT DEFAULT md5(random()::text)
);

INSERT INTO checkpoint_test
SELECT FROM generate_series(1, 1000000);  -- 100만 건&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;52&quot;&gt;결과: DB가 비명을 지름&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1771763474769&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# WAL이 100MB를 금방 채움
2026-02-22 21:06:23.864 KST [29] LOG: checkpoints are occurring too frequently (13 seconds apart) 
2026-02-22 21:06:23.864 KST [29] HINT: Consider increasing the configuration parameter &quot;max_wal_size&quot;.
2026-02-22 21:06:25.784 KST [29] LOG: checkpoints are occurring too frequently (2 seconds apart) 
2026-02-22 21:06:25.784 KST [29] HINT: Consider increasing the configuration parameter &quot;max_wal_size&quot;.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-path-to-node=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;54&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;54&quot;&gt;이 로그의 의미&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;55&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설정한 checkpoint_timeout(30초)보다 훨씬 빠르게 WAL이 100MB를 초과&lt;/li&gt;
&lt;li&gt;DB가 할 수 없이 체크포인트를 계속 수행 &amp;rarr; 디스크 I/O 폭증&lt;/li&gt;
&lt;li&gt;INSERT 속도가 급격히 느려지고, 특정 순간 DB가 일시적으로 멈춤&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;56&quot; data-ke-size=&quot;size23&quot;&gt;max_wal_size 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;57,0,0&quot;&gt;너무 작게 설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;57,0,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;57,0,1,0,0&quot;&gt;장점&lt;/b&gt;: 디스크 공간 절약, 장애 발생 시 복구 시간이 짧음&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;57,0,1,1,0&quot;&gt;단점&lt;/b&gt;: Checkpoint가 빈번하게 발생하여 디스크 I/O 폭증, 대량 작업 시 성능 저하&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;57&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;57,1,0&quot;&gt;너무 크게 설정&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;57,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;57,1,1,0,0&quot;&gt;장점&lt;/b&gt;: 대량 배치 작업 시 유리&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;57,1,1,1,0&quot;&gt;단점&lt;/b&gt;: 장애 복구 시간 증가, 디스크 공간이 부족할 수 있음, 한번 체크포인트 발생시 처리해야할 데이터양이 너무많음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-path-to-node=&quot;59&quot; data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-path-to-node=&quot;60&quot; data-ke-size=&quot;size16&quot;&gt;이번 실험을 통해 알게 된 것들을 정리하면 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;61&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;61,0,0&quot;&gt;&quot;커밋 성공&quot; 응답의 의미&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;61,0,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;synchronous_commit = on: WAL이 디스크에 기록 완료됨. 전원 차단 시에도 100% 복구 가능.&lt;/li&gt;
&lt;li&gt;synchronous_commit = off: WAL이 메모리에만 있을 수 있음. 마지막 1~2초치 데이터 유실 위험.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;61,1,0&quot;&gt;WAL의 역할&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;61,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데이터 파일의 랜덤 I/O 대신 순차 I/O를 사용하여 성능 향상.&lt;/li&gt;
&lt;li&gt;디스크에 안전하게 기록되어 장애 시 재실행(Redo) 보장.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;61,2,0&quot;&gt;Checkpoint와 WAL 크기의 트레이드오프&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;61,2,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WAL 공간 부족 &amp;rarr; Checkpoint 폭풍 &amp;rarr; 전체적인 시스템 성능 저하.&lt;/li&gt;
&lt;li&gt;배치 작업 전에는 max_wal_size를 늘리고 checkpoint_timeout을 조절하는 것이 좋음.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;이론만으로는 와닿지 않던 &quot;디스크 I/O 병목&quot;과 &quot;데이터 안전&quot;의 트레이드오프를, 직접 서버를 kill 해보며 체감할 수 있었습니다.&lt;/p&gt;</description>
      <category>데이터베이스</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/66</guid>
      <comments>https://flrefly.tistory.com/66#entry66comment</comments>
      <pubDate>Sun, 22 Feb 2026 20:43:02 +0900</pubDate>
    </item>
    <item>
      <title>커밋이 완료되었다고 정말 디스크에 적혔을까? (1)</title>
      <link>https://flrefly.tistory.com/65</link>
      <description>&lt;blockquote data-ke-style=&quot;style3&quot;&gt;WAL/Redo 쓰기 지연에 대해 공부하면서 안전과 속도의 트레이드오프가 실무에서 비용과 직결된다는 글을 읽었습니다. &lt;br /&gt;그런데 문득 이론적으로 느리다는 건 알겠는데, 실제로 디스크 I/O 병목이 얼마나 심하길래 데이터 유실의 위험을 감수하면서까지 속도를 올리려고 하는 걸까? 하는 생각이 들었습니다. 더 깊이 이해하고 싶어서 PostgreSQL의 synchronous_commit 설정을 이용해 직접 테스트를 진행하고 관련 내용을 찾아보았습니다&lt;/blockquote&gt;
&lt;h3 data-path-to-node=&quot;3&quot; data-ke-size=&quot;size23&quot;&gt;synchronous_commit 설정이란?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;3&quot;&gt;PostgreSQL에서 트랜잭션이 커밋될 때, 해당 변경 사항을 디스크의 WAL(Write-Ahead Log) 파일에 언제 물리적으로 기록할지 결정하는 설정입니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;3&quot;&gt;on (기본값): 커밋 시 데이터가 디스크에 완전히 기록될 때까지 기다립니다. (안전 최우선)&lt;/li&gt;
&lt;li data-path-to-node=&quot;3&quot;&gt;off: 커밋 시 메모리(WAL 버퍼)에만 데이터를 쓰고, 디스크 기록은 백그라운드로 미루며 클라이언트에게 바로 완료 응답을 보냅니다. (속도 최우선)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트전 두가지 의문&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;4&quot;&gt;어차피 WAL 파일에서 불러오면 되는 거 아닌가? 테스트를 진행하기 전, 저는 의문이 있었습니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;4&quot;&gt;off로 설정하더라도 서버가 꺼졌다가 켜지면 어차피 WAL에서 데이터를 다시 불러오면 되는 것 아닌가? 하는 생각이었습니다. 왜 데이터 유실이 발생한다고 하는 걸까요?
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;5&quot;&gt;synchronous_commit = off 상태일 때 DB는 데이터를 디스크가 아닌 메모리(RAM)에 있는 WAL 버퍼에만 기록해 둡니다. 그리고 디스크에 쓰기를 기다리지 않고 클라이언트에게 커밋 성공 응답을 보냅니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;6&quot;&gt;만약 이 찰나의 순간에 서버 전원이 나가버린다면, 휘발성인 메모리 안의 WAL 버퍼 데이터는 그대로 증발합니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;6&quot;&gt;디스크에 있는 실제 WAL 파일에는 아직 데이터가 적히지 않았기 때문에 재부팅 후 복구를 하려고 해도 방금 전 커밋된 1~2초가량의 데이터는 존재하지 않는 데이터가 됩니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;6&quot;&gt;반면 on 설정은 디스크 파일에 완벽히 기록됨을 확인받고 나서야 성공 응답을 주기 때문에, DB가 성공했다고 응답한 데이터는 날아가지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;두번째 의문은 &quot;&lt;/b&gt;WAL도 결국 디스크에 저장되는 파일인데, 왜 굳이 WAL에 먼저 쓰고 나중에 또 데이터 파일에 옮겨 적는 번거로운 짓을 할까? 그냥 처음부터 데이터 파일에 저장하면 되잖아!&quot;입니다. 이에 대한 답은 '디스크가 일하는 방식' 때문입니다.
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;랜덤 I/O는 너무 느리다&lt;/b&gt;: 우리가 INSERT나 UPDATE를 할 때, 데이터는 테이블의 여기저기, 인덱스의 구석구석에 흩어져 저장됩니다. 만약 커밋할 때마다 실제 데이터 파일을 찾아가서 저장하려면 디스크 헤더가 꽤 왔다 갔다 해야 합니다(랜덤 I/O). 이건 물리적으로 시간이 많이 걸리는 작업입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WAL의 순차 I/O:&lt;/b&gt; 반면 WAL(Write-Ahead Log)은 말 그대로 로그입니다. 기존 내용을 수정하는 게 아니라, 파일 끝에 &quot;누가 무엇을 했다&quot;라고 순서대로 적기만 하면 됩니다(순차 I/O). 디스크 헤더가 움직일 필요 없이 쭉 적기만 하면 되니, 데이터 파일에 직접 쓰는 것보다 수십 배는 빠릅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일단 메모리에 적고, 나중에 모아서 던지기:&lt;/b&gt; 그래서 DB는 일단 성능이 좋은 메모리에 데이터를 적고, 동시에 아주 빠른 WAL에만 기록을 남긴 뒤 사용자에게 &quot;성공!&quot;이라고 알려줍니다. 실제 무거운 데이터 파일 업데이트는 나중에 한가할 때몰아서 처리하는 것이죠.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;사용예시&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;DB라면 무조건 데이터를 지켜야 하는 것 아닌가? 왜 위험하게 off를 쓸까?&quot; 라는 의문이 들 수 있습니다. 하지만 실무에서는 모든 데이터의 가치가 같지 않다는 점을 이용해 전략적인 선택을 합니다.&lt;/li&gt;
&lt;li&gt;어떤 서비스에서 주로 사용?
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;휘발성 강한 실시간 데이터: SNS의 '좋아요' 개수, 라이브 방송의 실시간 시청자 수, 게시글의 조회수 등은 1초 전의 데이터 일부가 유실되어도 서비스 운영에 치명적이지 않습니다.&lt;/li&gt;
&lt;li&gt;방대한 양의 로그 수집: 서비스 개선을 위한 사용자 행동 로그나 시스템 모니터링 데이터는 초당 수만 건씩 발생합니다. 이를 건건이 디스크에 적으면 DB가 비명을 지르지만, off 설정을 통해 처리량을 늘릴 수 있습니다.&lt;/li&gt;
&lt;li&gt;게임 서버의 일부 데이터: 캐릭터의 현재 좌표 정보나 단순 아이템 획득 로그처럼, 매우 빈번하게 업데이트되면서도 최악의 경우 약간의 '롤백'이 허용되는 데이터에 사용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;벤치마킹 테스트&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;9&quot;&gt;디스크 I/O 대기 시간에 따른 성능 차이를 보다 명확하게 이해하기 위해, 직접 프로시저를 만들어 100만 번의 반복적인 INSERT와 COMMIT을 수행하는 벤치마킹 테스트를 수행해보았습니다. 테스트에 사용된 쿼리는 다음과 같습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1771734160912&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 테스트용 테이블 생성
CREATE TABLE wal_test (
    id SERIAL PRIMARY KEY, 
    created_at TIMESTAMP DEFAULT NOW()
);

-- N번 INSERT하고 매번 COMMIT하는 프로시저 생성
CREATE OR REPLACE PROCEDURE test_wal_speed(iterations INT)
LANGUAGE plpgsql
AS $$
BEGIN
    FOR i IN 1..iterations LOOP
        INSERT INTO wal_test DEFAULT VALUES;
        COMMIT; -- 매 건마다 강제로 디스크(또는 메모리)에 쓰도록 강제함
    END LOOP;
END;
$$;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-path-to-node=&quot;12&quot; data-ke-size=&quot;size23&quot;&gt;테스트 A: 안전 최우선 모드&lt;/h3&gt;
&lt;pre id=&quot;code_1771734258825&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 1) 안전 모드 켜기 (기본값)
SET synchronous_commit = on;

-- 2) 데이터 다 비우기
TRUNCATE TABLE wal_test;

-- 3) 100만 번 INSERT 및 COMMIT 실행 (실행된 총 시간을 확인 필요)
CALL test_wal_speed(1000000);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;14&quot;&gt;(4개의 세션을 연 상태로, 100만건을 동시에 수행하는 경우엔&lt;span&gt;&amp;nbsp;&lt;/span&gt;Group Commit에 의해 60초만에 작업이 끝났습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;테스트 B: 속도 최우선 모드 (synchronous_commit = off)&lt;/h3&gt;
&lt;pre id=&quot;code_1771734286660&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 1) 속도 모드 켜기 (비동기 커밋)
SET synchronous_commit = off;

-- 2) 데이터 다시 비우기
TRUNCATE TABLE wal_test;

-- 3) 똑같이 100만 번 INSERT 및 COMMIT 실행 (실행된 총 시간을 비교하세요!)
CALL test_wal_speed(1000000);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;14&quot;&gt;실제로 100만 건의 데이터를 넣으며 테스트해 본 결과, 안전 최우선 모드(on)인 테스트 A는 105초가 소요되었습니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;14&quot;&gt;반면 속도 최우선 모드(off)인 테스트 B는 5초 만에 작업이 완료되었습니다. (21배의 속도 차이)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결론: 지연시간 vs 데이터보존의 트레이드 오프&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;16&quot;&gt;&lt;b&gt;디스크 I/O 병목의 체감&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;16&quot;&gt;(단일세션기준) 105초와 5초라는 시간 차이는, DB가 스토리지와 동기화하며 기다리는 대기 시간(I/O Wait)이 전체 처리 성능에 얼마나 영향을 미치는가를 알 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-path-to-node=&quot;19,1,0&quot; data-index-in-node=&quot;0&quot;&gt;Group Commit:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;on 설정이라도 여러 사용자가 동시에 접속하는 환경(Multi-session)에서는 DB가 커밋을 묶어서 처리하므로, 단일 테스트 때보다 성능이 약 38% 향상(65초)됨을 확인했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-path-to-node=&quot;16&quot;&gt;&lt;b&gt;트레이드오프&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-path-to-node=&quot;16&quot;&gt;서비스 장애 시 1~2초 정도의 최신 데이터 유실을 감수할 수 있다면, 하드웨어 업그레이드 없이도 현재 장비 그대로 데이터베이스의 처리량(TPS)을 끌어올릴 수 있습니다.&lt;/li&gt;
&lt;li data-path-to-node=&quot;16&quot;&gt;결제나 금융 데이터처럼 유실이 절대 용납되지 않는 도메인이라면 synchronous_commit설정을 on으로 두고 (만약 느리다면) 비싼 SSD를 도입해야 하며, 단순 로그나 조회수 통계 데이터라면 off로 설정하여 서버 리소스와 비용을 아끼는 아키텍처 의사결정을 내릴 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>데이터베이스</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/65</guid>
      <comments>https://flrefly.tistory.com/65#entry65comment</comments>
      <pubDate>Sun, 22 Feb 2026 13:28:24 +0900</pubDate>
    </item>
    <item>
      <title>PostgreSQL MVCC와 VACUUM: Index Bloat</title>
      <link>https://flrefly.tistory.com/64</link>
      <description>&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;PostgreSQL을&amp;nbsp;운영하다&amp;nbsp;보면&amp;nbsp;데이터&amp;nbsp;건수는&amp;nbsp;그대로인데&amp;nbsp;디스크&amp;nbsp;사용량만&amp;nbsp;기하급수적으로&amp;nbsp;늘어나는&amp;nbsp;현상을&amp;nbsp;마주하게&amp;nbsp;됩니다.&amp;nbsp;이는&amp;nbsp;PostgreSQL의&amp;nbsp;동시성&amp;nbsp;제어&amp;nbsp;아키텍처인&amp;nbsp;MVCC(Multi-Version&amp;nbsp;Concurrency&amp;nbsp;Control)의&amp;nbsp;특징&amp;nbsp;때문이라는&amp;nbsp;글을&amp;nbsp;읽었습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;그런데&amp;nbsp;문득&amp;nbsp;기존&amp;nbsp;데이터를&amp;nbsp;제자리에&amp;nbsp;덮어쓰지&amp;nbsp;않고&amp;nbsp;'죽은&amp;nbsp;튜플(Dead&amp;nbsp;Tuple)'로&amp;nbsp;남겨두는&amp;nbsp;방식이&amp;nbsp;실제로&amp;nbsp;디스크&amp;nbsp;용량을&amp;nbsp;얼마나&amp;nbsp;부풀게(Bloat)&amp;nbsp;하는지,&amp;nbsp;그리고&amp;nbsp;이를&amp;nbsp;제어하는&amp;nbsp;VACUUM이&amp;nbsp;내부적으로&amp;nbsp;어떻게&amp;nbsp;동작하는지&amp;nbsp;궁금해졌습니다.&amp;nbsp;더&amp;nbsp;깊이&amp;nbsp;이해하고&amp;nbsp;싶어서&amp;nbsp;직접&amp;nbsp;10만&amp;nbsp;건의&amp;nbsp;데이터를&amp;nbsp;넣고&amp;nbsp;테스트를&amp;nbsp;진행하며&amp;nbsp;관련&amp;nbsp;내용을&amp;nbsp;찾아보았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;인덱스&amp;nbsp;부풀기(Index&amp;nbsp;Bloat)란?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL에서 UPDATE가 발생하면 기존 행을 죽은 튜플로 남기고 새로운 행을 물리적인 디스크 공간에 새로 삽입합니다.&lt;/li&gt;
&lt;li&gt;이때 테이블 크기만 커지는 것이 아니라, 해당 테이블에 걸려있는 인덱스는 &lt;b data-index-in-node=&quot;10&quot; data-path-to-node=&quot;8,1,0&quot;&gt;B-Tree의 정렬 구조를 유지&lt;/b&gt;해야 합니다. 업데이트 시 기존 인덱스 엔트리는 Dead로 마킹되지만, 그 공간은 &lt;b data-index-in-node=&quot;73&quot; data-path-to-node=&quot;8,1,0&quot;&gt;정렬 순서가 정확히 일치하는 데이터&lt;/b&gt;가 들어오기 전까지 재사용되지 못하고 자리를 차지합니다. 이것이 테이블보다 인덱스가 훨씬 가파르게 부풀어 오르는 원인입니다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 실험 환경 준비&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정확한 관찰을 위해 10만 건의 데이터를 삽입하고, Autovacuum을 잠시 끕니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 1. 기존 테이블 삭제 및 재생성  
DROP TABLE IF EXISTS vacuum_demo;  

CREATE TABLE vacuum_demo (  
    id INT,  
    indexed_col TEXT,     -- 인덱스를 걸 컬럼  
    non_indexed_col TEXT  -- 인덱스가 없는 컬럼  
);  

-- 2. Autovacuum 비활성화 (실험 목적)  
ALTER TABLE vacuum_demo SET (autovacuum_enabled = false);  

-- 3. 데이터 10만 건 삽입  
INSERT INTO vacuum_demo (id, indexed_col, non_indexed_col)  
SELECT i, 'idx_data_' || i, 'non_idx_data_' || i  
FROM generate_series(1, 100000) s(i);  

-- 4. 인덱스 생성  
CREATE INDEX idx_vacuum_demo_col ON vacuum_demo(indexed_col);  

-- 5. 초기 상태(Baseline) 확인  
SELECT   
    pg_size_pretty(pg_table_size('vacuum_demo')) AS table_size,  
    pg_size_pretty(pg_indexes_size('vacuum_demo')) AS index_size;  

-- [실행 결과]  
-- table_size: 6704 kB   
-- index_size: 3104 kB  &lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Index Bloat (인덱스 부풀기) 유도&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스가 걸려 있는 컬럼을 업데이트해 봅니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 6. 인덱스가 걸린 컬럼을 3번 연속 업데이트 (총 30만 번의 UPDATE 발생)  
UPDATE vacuum_demo SET indexed_col = 'updated_1_' || id;  
UPDATE vacuum_demo SET indexed_col = 'updated_2_' || id;  
UPDATE vacuum_demo SET indexed_col = 'updated_3_' || id;  

-- 7. 부풀기(Bloat) 결과 확인  
SELECT   
    pg_size_pretty(pg_table_size('vacuum_demo')) AS bloated_table,  
    pg_size_pretty(pg_indexes_size('vacuum_demo')) AS bloated_index,  
    (SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'vacuum_demo') AS dead_tuples;  

-- [실행 결과]  
-- bloated_table: 26 MB (약 4배 증가)  
-- bloated_index: 18 MB (약 6배 증가)  
-- dead_tuples: 300,000  &lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테이블 크기뿐만 아니라 인덱스 크기도 크게 증가(Index Bloat)했습니다.&lt;/li&gt;
&lt;li&gt;인덱스 역시 이전 버전의 튜플을 가리키는 물리적 포인터를 유지해야 하므로, 테이블 데이터가 갱신될 때마다 인덱스 파일에도 새로운 엔트리가 추가되어 쓰기 I/O가 대량으로 발생합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. PostgreSQL 성능 최적화의 핵심: HOT 업데이트&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이번에는 인덱스가 없는 컬럼을 업데이트해 보겠습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 8. 인덱스가 '없는' 컬럼을 3번 연속 업데이트  
UPDATE vacuum_demo SET non_indexed_col = 'hot_1_' || id;  
UPDATE vacuum_demo SET non_indexed_col = 'hot_2_' || id;  
UPDATE vacuum_demo SET non_indexed_col = 'hot_3_' || id;  

-- 9. HOT 업데이트 결과 확인  
SELECT   
    pg_size_pretty(pg_table_size('vacuum_demo')) AS table_after_hot,  
    pg_size_pretty(pg_indexes_size('vacuum_demo')) AS index_after_hot;  

-- [실행 결과]  
-- table_after_hot: 43 MB (계속 증가함)  
-- index_after_hot: 19 MB (거의 증가하지 않음!) &lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테이블 크기는 이전과 마찬가지로 늘어났지만, 인덱스 크기는 거의 변동이 없습니다. 이것은 PostgreSQL의 HOT 업데이트때문입니다.&lt;/li&gt;
&lt;li&gt;일반적인 UPDATE는 새로운 튜플이 생성되어 물리적 위치가 변경되므로, 해당 테이블에 연결된 모든 인덱스 파일을 수정하는 쓰기 I/O가 발생합니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;하지만 HOT 업데이트는 수정하려는 컬럼에 인덱스가 없고, 데이터가 저장된 동일한 물리적 페이지(Block) 내에 여유 공간(fillfactor)이 있을 때 작동합니다. 이 경우 인덱스 파일은 전혀 수정하지 않고, 데이터 페이지 내부에서만 기존의 오래된 튜플이 새 튜플의 물리적 위치를 가리키도록 체인 형태로 포인터를 연결합니다.&lt;/li&gt;
&lt;li&gt;결과적으로 데이터 파일 I/O만 발생할 뿐 인덱스 파일 수정 I/O는 생략되므로 성능이 향상됩니다. DB 설계 시 &quot;자주 업데이트되는 컬럼에는 가급적 인덱스를 걸지 말라&quot;라는 규칙이 바로 이 원리 때문입니다&lt;/li&gt;
&lt;li&gt;fillfactor
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;낮추면 ⬇️:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;장점) hot 업데이트 성공률이 높아짐&lt;/li&gt;
&lt;li&gt;단점) 저장공간낭비&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;높이면 ⬆️&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;장점)데이터를꾺꾺눌러담앗으니까 한번의 io로 더많은 데이터를 읽을수잇음&lt;/li&gt;
&lt;li&gt;장점)저장 공간 절약&lt;/li&gt;
&lt;li&gt;단점) 데이터 수정되는순간 들어갈자리가 없어서 다른 페이지로 이사가야함. not-hot업데이트발생&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. VACUUM과 물리적 이동&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;VACUUM&lt;/h4&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 10. 일반 VACUUM 실행  
VACUUM vacuum_demo;  

-- 11. 상태 확인 (크기와 물리적 파일 이름)  
SELECT   
    pg_size_pretty(pg_table_size('vacuum_demo')) AS size_after_vacuum,  
    relfilenode   
FROM pg_class WHERE relname = 'vacuum_demo';  

-- [실행 결과]  
-- size_after_vacuum: 43 MB (용량 감소 없음)  
-- relfilenode: 5253114  &lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;물리적인 파일 크기와 파일 이름(relfilenode)이 그대로입니다.&amp;nbsp;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;VACUUM이 파일 크기를 줄이는 것이 아니라 내부의 빈 공간을 찾아 '재사용 가능' 상태로만 마킹하기 때문입니다.&lt;/li&gt;
&lt;li&gt;(relfilenode는 해당 테이블이 디스크 상에 저장될 때 사용하는 실제 파일의 이름(번호)입니다)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;추가로, 앞서 60만 번의 UPDATE를 수행하는 동안, 내부적으로 60만 개의 트랜잭션 ID(XID)가 소모되었습니다.&lt;/li&gt;
&lt;li&gt;이 XID는 약 42억 개를 한도로 순환하는데, 한계를 넘으면 과거데이터와 미래 데이터를 구분하지 못해 DB가 멈추는 Transaction ID Wraparound 현상이 발생합니다.&lt;/li&gt;
&lt;li&gt;일반 VACUUM은 아주 오래된 데이터들을 찾아 더 이상 XID 비교 대상이 되지 않도록 &lt;b&gt;냉동 처리&lt;/b&gt;하여, DB의 셧다운 타이머를 초기화하는 생명 연장의 역할을 합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;냉동처리&lt;/b&gt;: 이 데이터는 아주 옛날데이터니, 앞으로는 어떤 트랜잭션 번호와 비교해도 항상 과거의 데이터로 간주하도록 표식해놓는것&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;일반 VACUUM이 인덱스의 물리적 크기를 줄이지 못하는 이유:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;'정렬 제약' 때문입니다.&lt;/li&gt;
&lt;li&gt;인덱스 페이지 전체가 비지 않는 한 OS에 공간을 반환할 수 없으며, 중간중간 뚫린 구멍은 순서가 맞는 데이터만 채울 수 있어 재사용 효율이 매우 떨어집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;VACUUM FULL&lt;/h4&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 12. VACUUM FULL 실행 (강력한 Lock 발생, 운영 중 주의!)  
VACUUM FULL vacuum_demo;  

-- 13. 최종 결과 및 '이사'의 증거 확인  
SELECT   
    pg_size_pretty(pg_table_size('vacuum_demo')) AS final_table_size,  
    pg_size_pretty(pg_indexes_size('vacuum_demo')) AS final_index_size,  
    relfilenode   
FROM pg_class WHERE relname = 'vacuum_demo';  

-- [실행 결과]  
-- final_table_size: 5896 kB (초기 상태로 복구)  
-- final_index_size: 3104 kB (초기 상태로 복구)  
-- relfilenode: 5253121 (번호가 변경됨!!)  &lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;용량이 초기 상태로 줄어들었으며, relfilenode값이 변경되었습니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;즉, VACUUM FULL은 활성 상태인 데이터만을 추출해 디스크상에 완전히 새로운 파일을 생성하는 방식입니다.&lt;/li&gt;
&lt;li&gt;이사를 가는 과정이기 때문에 작업 중 해당 테이블에 대한 모든 접근이 차단(Access Exclusive Lock)되며, 원본 테이블 크기만큼의 추가적인 디스크 여유 공간이 반드시 필요합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실무대안&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 중에 VACUUM FULL의 Lock을 감당할 수 없다면 다음 대안을 고려할 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;14,0,1&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,0,1,0,0&quot;&gt;REINDEX CONCURRENTLY:&lt;/b&gt; 서비스 중단 없이 &lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,1,2,0&quot;&gt;낡은 인덱스 새 인덱스로 교체 &lt;/b&gt;(PG 12+).
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,1,2,0&quot;&gt;단점) &lt;/b&gt;새로운 인덱스를 만드는 동안 기존 인덱스도 유지해야해서 &lt;b data-path-to-node=&quot;4,1,2,0&quot; data-index-in-node=&quot;0&quot;&gt;I/O 및 CPU 부하 2배, &lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b data-path-to-node=&quot;4,1,2,0&quot; data-index-in-node=&quot;0&quot;&gt;실패 시 'Invalid' 인덱스 남음&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-path-to-node=&quot;4,1,2,0&quot; data-index-in-node=&quot;0&quot;&gt;1. 테이블을 한번훑고 그사이에 들어온 변화를 훑으며 계속확인하면서 인덱스를 만듦&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b data-path-to-node=&quot;4,1,2,0&quot; data-index-in-node=&quot;0&quot;&gt;2. 이중간에 네트워크 장애 및 유니크제약위배 및 쿼리취소하는 등의 사고가 발생하면 인덱스 만들기는 실패함. &lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b data-path-to-node=&quot;4,1,2,0&quot; data-index-in-node=&quot;0&quot;&gt;3. PostgreSQL은 이 실패한 인덱스를 자동으로 지우지 않고 INVALID라는 낙인만 찍은 채 그대로 둠 (용량차지, 사용도 안되는 인덱스가 됨. 시스템 카탈로그를 조회해봐야 존재를 알게됨)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,0,1,1,0&quot;&gt;pg_repack :&lt;/b&gt; 서비스 영향 없이 테이블/인덱스 Bloat을 물리적으로 제거하는 툴.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,2,2,0&quot;&gt;단점) &lt;/b&gt;테이블을 통째로 새로 복사하는 방식이라 &lt;b data-path-to-node=&quot;4,2,2,0&quot; data-index-in-node=&quot;0&quot;&gt;추가 디스크 공간 필요 (테이블 크기만큼)&lt;/b&gt;, 외부 툴 의존&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,0,1,2,0&quot;&gt;Autovacuum 튜닝:&lt;/b&gt; autovacuum_vacuum_scale_factor를 기본값(0.2)보다 낮게(0.01~0.05) 설정하여 Bloat이 쌓이기 전에 청소하게 유도.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,3,2,0&quot;&gt;단점) (다른 중요한 비즈니스 쿼리가써야할 자원을 &lt;b data-path-to-node=&quot;14,0,1,2,0&quot; data-index-in-node=&quot;0&quot;&gt;Autovacuum이 사용할수도?)&lt;/b&gt;&amp;nbsp;상시적인 I/O 부하&lt;/b&gt;, 이미 커진 파일은 줄이지 못함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;첫째, 인덱스 부풀기(Bloat)의 실체
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MVCC 특성상 UPDATE가 발생하면 테이블뿐만 아니라 인덱스까지 기하급수적으로 비대해지며, 30만 개의 죽은 튜플이 생성될 때 인덱스 파일에도 새로운 엔트리가 계속 추가되어 막대한 쓰기 I/O를 초래한다는 것을 알 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;둘째, HOT 업데이트를 활용한 최적화 전략
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;수정하려는 컬럼에 인덱스가 없다면, 인덱스 파일은 전혀 건드리지 않고 데이터 페이지 내부에서만 체인 형태로 포인터를 연결하는 HOT(Heap Only Tuple) 업데이트가 작동합니다. 값이 자주 변경되는 컬럼(상태값, 조회수 등)에는 가급적 인덱스를 걸지 않아야 DB 성능을 올릴수있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;셋째, VACUUM의 진짜 목적과 한계
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일반 VACUUM 명령어를 친다고 해서 디스크 용량이 반환되지 않습니다.&lt;/li&gt;
&lt;li&gt;일반 VACUUM은 빈 공간을 재사용 가능 상태로 마킹하고 트랜잭션 ID 고갈로 인한 DB 셧다운을 방지합니다.&lt;/li&gt;
&lt;li&gt;물리적인 용량 확보를 위해서는 VACUUM FULL이 필요하며, 이는 내부적으로 아예 새로운 파일을 생성해 유효한 데이터만 이사시키는 무거운 작업이므로 디스크 여유 공간 확보와 Lock에 대한 주의가 필요합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고&lt;br /&gt;&lt;a href=&quot;https://www.postgresql.org/docs/9.4/functions-admin.html&quot;&gt;물리크기측정함수&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://www.postgresql.org/docs/current/catalog-pg-class.html&quot;&gt;relfilenode&lt;/a&gt;&lt;/p&gt;
&lt;div id=&quot;gtx-trans&quot; style=&quot;position: absolute; left: 242px; top: 4577.67px;&quot;&gt;
&lt;div class=&quot;gtx-trans-icon&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;</description>
      <category>데이터베이스</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/64</guid>
      <comments>https://flrefly.tistory.com/64#entry64comment</comments>
      <pubDate>Sat, 21 Feb 2026 17:02:40 +0900</pubDate>
    </item>
    <item>
      <title>HTTP/1.1 에선 멀티플렉싱을 처리하지 못하는걸까?</title>
      <link>https://flrefly.tistory.com/63</link>
      <description>&lt;blockquote data-ke-style=&quot;style3&quot;&gt;HTTP/1.1에서 헤드오브라인 블로킹 문제가 있었고, HTTP/2에서는 이를 해결했다는 글을 읽었습니다. 그런데 문득 HTTP/1.1에선 멀티플렉싱을 쓰고 싶어도 못쓰는걸까? 하는 생각이 들었습니다. 더 깊이 이해하고 싶어서 관련 내용을 찾아보았습니다&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;멀티플렉싱이란?&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하나의 connection 으로 동시에 여러개의 메세지 스트림을 주고 받는 것&lt;/li&gt;
&lt;li&gt;http 2에서 도입됨&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프레임, 메세지, 스트림&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Frame&lt;/b&gt; : 요청1은 여러 프레임으로 쪼개질 수 있으며, 메타정보를 담는 헤더프레임, 실제 컨텐츠를 담는 데이터프레임 이렇게 두종류의 프레임이 존재합니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Message&lt;/b&gt; : 요청1,응답1 각각을 메세지라는 단위로 봅니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Stream&lt;/b&gt; : 요청1, 응답1 쌍을 하나의 스트림으로 봅니다&lt;/li&gt;
&lt;li&gt;Connection: 하나의 커넥션에 여러 스트림이 속합니다&lt;/li&gt;
&lt;li&gt;-&amp;gt; 요청 하나에 여러 Frame이 속하고 각각의 요청-응답은 Message가 되고, 요청-응답쌍을 하나의 Stream이라고 칭하며 여러 스트림이 하나의 커넥션에 속합니다&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTTP/1.1에서의 처리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;http 1.1에서는 하나의 연결만 사용해서 여러 리소스를 보내야할때, 어떻게 처리했을까요?&lt;/li&gt;
&lt;li&gt;http1.1에서 connection이 한개고 요청을 3개 처리해야하면&lt;/li&gt;
&lt;li&gt;a요청 &amp;rarr; a응답 &amp;rarr; b 요청 &amp;rarr; b응답 &amp;rarr; c요청 &amp;rarr; c응답&lt;/li&gt;
&lt;li&gt;과같은 순서대로 도착 해야합니다. (즉, 먼저 보낸 요청은 먼저 응답이 와야한다)&lt;/li&gt;
&lt;li&gt;그래서 중간에 있는 요청 b의응답이 느려지면 그 다음 c요청이 지연됩니다 (&lt;span style=&quot;background-color: #fcfcfc; color: #666666; text-align: left;&quot;&gt;헤드오브라인 블로킹&lt;/span&gt;)&lt;/li&gt;
&lt;li&gt;그래서 http2에선 요청 순서에 상관없이 처리가능하게 바뀌었습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;HTTP/1.1에선&amp;nbsp;멀티플렉싱을&amp;nbsp;쓰고&amp;nbsp;싶어도&amp;nbsp;못쓰는걸까?&amp;nbsp;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;stream id가 http1.1에 없기 때문에 멀티플렉싱을 쓰고싶어도 사용할 수 없습니다. (어떤 요청에 대한 데이터인지 구분을 못하기 때문이다)&lt;/li&gt;
&lt;li&gt;예를들어 같은 커넥션인데, 요청 a, b, c를 섞어서 나눠서 보내면 어떤데이터가 a인지 b인지를 알 수 없습니다. 이를 구분하는 데이터가 stream id입니다. 하지만, 네트워크 탭에서 stream id라는 데이터를 실제로 본적이 없었습니다. 왜냐하면 http 일반 헤더가 아니라 프레임 헤더에 정보가 존재하기 때문입니다. 따라서 네트워크 탭이 아닌 wireshark를 통해 패킷을 분석해봤습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;752&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dOcULC/btsMLv0yOD9/GxxeD1Ogqe5elbkqdhHpbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dOcULC/btsMLv0yOD9/GxxeD1Ogqe5elbkqdhHpbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dOcULC/btsMLv0yOD9/GxxeD1Ogqe5elbkqdhHpbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdOcULC%2FbtsMLv0yOD9%2FGxxeD1Ogqe5elbkqdhHpbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;541&quot; height=&quot;351&quot; data-origin-width=&quot;1160&quot; data-origin-height=&quot;752&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;http2만으로 wireshark필터링을 하려는데 잘 안돼서, &lt;a href=&quot;https://http2.tistory.com/6&quot;&gt;링크&lt;/a&gt; 참고&lt;/li&gt;
&lt;li&gt;http2.streamid==21 와 같이 특정 stream id 로 필터링을 해보면 같은 stream id를 가진 여러 요청들을 필터링해서 볼 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프레임헤더와 일반 HTTP 헤더의 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP/2는 기존의 텍스트 기반 HTTP와 달리 바이너리 프로토콜로 동작합니다. 즉, 데이터가 사람이 읽기 쉬운 텍스트가 아니라, 여러 개의 프레임(frame)으로 분할되어 전송됩니다. 이 프레임들은 모두 자체 헤더를 가지고 있으며, 그 중 하나가 stream id입니다&lt;/p&gt;
&lt;h2 data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;HTTP/1.1의 청크 인코딩과 HTTP/2의 프레임 기반 전송의 차이&lt;/span&gt;&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&quot;HTTP/1.1은 서버가 전체 응답의 크기를 미리 알 수 없을 때, 응답을 일정 크기의 청크 단위로 나눠 전송합니다. 그런데 청크 전송 인코딩은 텍스트 기반이라 파싱에 추가적인 부담(파싱 오버헤드)이 있습니다.&quot; 라는 문장을 읽고 두가지 의문이 들었습니다.&lt;/blockquote&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;텍스트 기반 (사람이 읽기쉬운 데이터)면 왜 파싱 오버헤드가 있는것인가?&lt;/li&gt;
&lt;li&gt;파싱 오버헤드가 어떤 문제이길래 사람이 읽기 쉽다는 장점을 버릴 정도인 것인가?&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;true&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;텍스트 기반&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: &quot;apple,banana,orange&quot;라는 데이터를 보낸다고 가정하고, 컴퓨터는 데이터를 특정 문자열로 나눈다고 가정합니다. 하지만 항상 이런 방법에는 특정문자열이 데이터내에 포함되는 경우를 생각해야만합니다. 즉, 텍스트 기반에서는 항상 예상하지 못한 경우를 처리하는 추가 로직을 만들어야 하고, 오류가 발생할 가능성이 높아집니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;바이너리 기반 포맷&lt;/b&gt;&lt;/span&gt;&lt;span&gt;에서는 데이터의 각 부분이 정확한 위치와 길이를 가집니다. 예를 들어, 첫 번째 4바이트는 무조건 데이터 길이, 그 다음 1바이트는 타입이라고 정해져 있다면, 컴퓨터는 데이터를 읽을 때 &quot;정확히 4바이트 읽고, 다음 1바이트 읽는다&quot;는 방식으로 데이터를 읽을 수 있기 때문에 텍스트기반에서의 문제가 자연스럽게 사라집니다.&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;벤치마킹 테스트&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파싱 오버헤드에 따른 성능 차이를 보다 명확하게 이해하기 위해 HTTP/1.1의 청크 인코딩과 HTTP/2의 바이너리 프레임 기반 전송 방식을 비교하는 벤치마킹 테스트를 수행해보았습니다. 각각의 방식을 자바로 구현하여 실행 시간을 측정하였으며, 벤치마크에 사용된 코드는 다음과 같습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1742043876645&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.benchmark;

import org.openjdk.jmh.annotations.*;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)       // 평균 실행 시간을 측정
@OutputTimeUnit(TimeUnit.NANOSECONDS)    // 결과를 나노초 단위로 출력
@State(Scope.Benchmark)                  // 벤치마크 전역 상태로 사용
public class Test1 {

    // HTTP/1.1의 청크 인코딩 응답 예제:
    // HTTP/1.1은 텍스트 기반 프로토콜이며, 데이터를 청크 단위로 전송합니다.
    // 예제 문자열은 각 청크의 크기를 16진수로 표현한 후, CRLF로 구분되고, 0 청크로 종료됩니다.
    // 예: &quot;4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n&quot;
    private byte[] http1ChunkedResponse;

    // HTTP/2 프레임 응답 예제:
    // HTTP/2는 바이너리 프로토콜로, 데이터를 '프레임' 단위로 전송합니다.
    // 프레임 헤더는 9바이트로 구성되며, 그 뒤에 실제 데이터(payload)가 이어집니다.
    // 아래 예제에서는 9바이트 헤더와 10바이트의 payload를 포함하는 배열을 만듭니다.
    private byte[] http2FrameResponse;

    @Setup(Level.Trial)
    public void setup() {
        // --- HTTP/1.1 청크 인코딩 데이터 준비 ---
        // 이 문자열은 두 개의 청크(&quot;Wiki&quot;, &quot;pedia&quot;)와 0 크기의 청크로 종료됨을 나타냅니다.
        // 특정 문자열 (\r\n)을 기준으로 데이터를 나눌수있고 첫번째는 크기 두번쨰는 데이터를 의미합니다
        // &quot;4&quot; -&amp;gt; 첫 청크의 크기(4바이트), &quot;Wiki&quot; -&amp;gt; 첫 번째 청크 데이터
        // &quot;5&quot; -&amp;gt; 두 번째 청크의 크기(5바이트), &quot;pedia&quot; -&amp;gt; 두 번째 청크 데이터
        // &quot;0&quot; -&amp;gt; 청크 종료를 의미
        http1ChunkedResponse = &quot;4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n&quot;.getBytes(StandardCharsets.US_ASCII);

        // --- HTTP/2 프레임 데이터 준비 ---
        // HTTP/2 프레임 구조:
        // - 3바이트: Payload의 길이 (big-endian)
        // - 1바이트: 프레임 타입 (예: 0x01 = HEADERS 프레임)
        // - 1바이트: 플래그 (예: 0x04 = END_HEADERS)
        // - 4바이트: 스트림 식별자 (최상위 비트는 예약되어 있으므로 마스킹)
        // - 그 뒤: Payload 데이터 (여기서는 10바이트의 임의 값)
        ByteBuffer buffer = ByteBuffer.allocate(9 + 10); // 헤더 9바이트 + payload 10바이트
        // 3바이트 길이: 10을 16진수 0x00000A로 표현 (payload 길이)
        buffer.put((byte) 0x00);
        buffer.put((byte) 0x00);
        buffer.put((byte) 0x0A);
        // 1바이트 타입: 0x01 (예시로 HEADERS 프레임 사용)
        buffer.put((byte) 0x01);
        // 1바이트 플래그: 0x04 (예시로 END_HEADERS 플래그)
        buffer.put((byte) 0x04);
        // 4바이트 스트림 식별자: 여기서는 스트림 번호 1 (예약 비트를 제거하기 위해 &amp;amp; 0x7FFFFFFF)
        buffer.putInt(1);
        // Payload: 10바이트의 임의 값 (여기서는 1부터 10까지의 바이트)
        for (int i = 0; i &amp;lt; 10; i++) {
            buffer.put((byte) (i + 1));
        }
        http2FrameResponse = buffer.array();
    }

    /**
     * HTTP/1.1 청크 인코딩 응답을 파싱하는 벤치마크 메서드.
     *
     * 이 메서드는 HTTP/1.1에서 청크 전송 인코딩된 응답을 읽어서,
     * 각 청크의 크기를 파싱한 후 해당 크기만큼 데이터를 읽어 누적합니다.
     * 최종적으로 누적한 문자열의 길이를 반환합니다.
     *
     * 네트워크 지연은 없으며, 단순히 CPU가 텍스트를 파싱하는 오버헤드를 측정합니다.
     */
    @Benchmark
    public int parseHttp1Chunked() {
        // ByteArrayInputStream을 이용하여 바이트 배열을 InputStream으로 변환합니다.
        ByteArrayInputStream bis = new ByteArrayInputStream(http1ChunkedResponse);
        // BufferedReader를 통해 텍스트 데이터를 읽습니다.
        BufferedReader reader = new BufferedReader(new InputStreamReader(bis, StandardCharsets.US_ASCII));
        StringBuilder sb = new StringBuilder();
        try {
            String line;
            // 응답의 각 줄을 순차적으로 읽습니다.
            while ((line = reader.readLine()) != null) {
                // 각 청크의 시작 줄은 청크의 크기를 16진수 문자열로 나타냅니다.
                int chunkSize;
                try {
                    chunkSize = Integer.parseInt(line.trim(), 16);
                } catch (NumberFormatException e) {
                    break;  // 파싱 실패 시 종료
                }
                // 청크 크기가 0이면 응답이 종료됨을 의미합니다.
                if (chunkSize == 0) {
                    break;
                }
                // 지정된 크기만큼의 문자 데이터를 읽어 청크 데이터를 가져옵니다.
                char[] chunk = new char[chunkSize];
                int read = reader.read(chunk, 0, chunkSize);
                sb.append(chunk, 0, read);
                // 각 청크 뒤에는 CRLF (줄바꿈)이 있으므로, 한 줄을 추가로 읽어 버립니다.
                reader.readLine();
            }
        } catch (IOException e) {
            // 실제 벤치마크 환경에서는 예외가 발생하지 않는다고 가정합니다.
            e.printStackTrace();
        }
        // 최종 누적한 문자열의 길이를 반환합니다.
        return sb.length();
    }

    /**
     * HTTP/2 프레임 응답을 파싱하는 벤치마크 메서드.
     *
     * 이 메서드는 HTTP/2의 프레임 구조를 모방한 바이트 배열을
     * ByteBuffer로 감싸고, 프레임 헤더(길이, 타입, 플래그, 스트림 id)를 파싱한 후,
     * payload 부분의 바이트들을 단순히 합산(sum)을 계산합니다.
     *
     * 이를 통해 HTTP/2의 이진 프레이밍 방식으로 데이터를 파싱할 때의 CPU 소비를 측정합니다.
     */
    @Benchmark
    public int parseHttp2Frame() {
        // ByteBuffer를 이용하여 HTTP/2 프레임 응답을 읽습니다.
        ByteBuffer buffer = ByteBuffer.wrap(http2FrameResponse);
        // --- 프레임 헤더 파싱 ---
        // 3바이트: payload 길이 (big-endian)
        // buffer.get()은 1바이트(8비트)를 읽습니다.
        // 1. Java의 byte는 -128 ~ 127 범위를 가짐 (8비트 signed integer)
        // 2. 128(0x80) 이상 값이 들어오면 음수로 변환됨 (2의 보수 방식)
        // 3. 129 이상의 값이 원래 무엇이었는지 알고 싶다면 &amp;amp; 0xFF 사용
        // 4. buffer.get() &amp;amp; 0xFF를 하면 원래 부호 없는 값(0~255)을 얻을 수 있음
        int length = ((buffer.get() &amp;amp; 0xFF) &amp;lt;&amp;lt; 16)
                | ((buffer.get() &amp;amp; 0xFF) &amp;lt;&amp;lt; 8)
                | (buffer.get() &amp;amp; 0xFF);
        // 1바이트: 프레임 타입 (여기서는 단순 예제이므로 사용하지 않음)
        byte type = buffer.get();
        // 1바이트: 플래그 (여기도 예제용)
        byte flags = buffer.get();
        // 4바이트: 스트림 식별자 (최상위 예약 비트 제거)
        int streamId = buffer.getInt() &amp;amp; 0x7FFFFFFF;
        // --- Payload 파싱 ---
        // payload의 각 바이트를 읽어 단순 합(sum)을 계산합니다.
        int sum = 0;
        for (int i = 0; i &amp;lt; length; i++) {
            sum += (buffer.get() &amp;amp; 0xFF);
        }
        return sum;
    }

    // main 메서드를 통해 JMH 벤치마크 실행기를 호출할 수 있습니다.
    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;6057&quot; data-start=&quot;5868&quot;&gt;&lt;b&gt;parseHttp1Chunked():&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;6057&quot; data-start=&quot;5899&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;5949&quot; data-start=&quot;5899&quot;&gt;HTTP/1.1의 텍스트 기반 청크 인코딩을 모방하여, 입력 스트림에서 각 줄을 읽고,&lt;/li&gt;
&lt;li data-end=&quot;6006&quot; data-start=&quot;5953&quot;&gt;첫 줄에서 청크 크기를 파싱한 후 해당 크기만큼 데이터를 읽어 누적하는 방식으로 파싱합니다.&lt;/li&gt;
&lt;li data-end=&quot;6057&quot; data-start=&quot;6010&quot;&gt;최종적으로 누적된 문자열의 길이를 반환하여 파싱 작업의 CPU 소비를 측정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;6261&quot; data-start=&quot;6059&quot;&gt;&lt;b&gt;parseHttp2Frame():&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;6261&quot; data-start=&quot;6088&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;6145&quot; data-start=&quot;6088&quot;&gt;HTTP/2의 바이너리 프레임 구조를 모방하여, ByteBuffer를 통해 9바이트의 헤더를 읽고,&lt;/li&gt;
&lt;li data-end=&quot;6211&quot; data-start=&quot;6149&quot;&gt;헤더에서 payload 길이를 구한 뒤, 그 길이만큼의 데이터를 순차적으로 읽어 바이트들의 합을 계산합니다.&lt;/li&gt;
&lt;li data-end=&quot;6261&quot; data-start=&quot;6215&quot;&gt;이 역시 네트워크 지연 없이 파싱에 소요되는 CPU 시간만 측정하는 예제입니다&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1742044917831&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Benchmark                Mode  Cnt    Score    Error  Units
Test1.parseHttp1Chunked  avgt   25  725.497 &amp;plusmn; 12.011  ns/op
Test1.parseHttp2Frame    avgt   25    3.358 &amp;plusmn;  0.045  ns/op&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;378&quot; data-start=&quot;323&quot;&gt;&lt;b&gt;Score:&lt;/b&gt; 한 번의 메서드 실행(또는 &quot;operation&quot;)에 걸리는 평균 시간입니다. (&lt;b&gt;CPU에서 이 작업을 처리하는 데 소요된 시간)&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;417&quot; data-start=&quot;379&quot;&gt;&lt;b&gt;Mode (avgt):&lt;/b&gt; 평균 시간을 측정한다는 의미입니다.&lt;/li&gt;
&lt;li data-end=&quot;442&quot; data-start=&quot;418&quot;&gt;&lt;b&gt;ns/op:&lt;/b&gt; 단위가 나노초입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파싱 오버헤드에 따른 벤치마크 결과를 비교하면, HTTP/1.1의 청크 인코딩 방식은 평균 실행시간이 725.497ns인 반면, HTTP/2의 바이너리 프레임 기반 방식은 약 3.358ns로 약 250배 정도의 차이가 났습니다. 이는 텍스트 기반으로 데이터를 주고받는 HTTP/1.1이 바이너리 데이터를 사용하는 HTTP/2에 비해 파싱할 때 더 많은 CPU 자원을 소비하기 때문입니다. 텍스트 기반 방식에서는 문자열 해석 과정에서 추가적인 연산이 필요하지만, 바이너리 방식은 데이터의 구조가 미리 정의되어 있어 바로 파싱할 수 있기 때문입니다. 즉, HTTP/2는 데이터 처리 과정에서의 파싱 오버헤드를 최소화하여 효율적으로 멀티플렉싱을 지원할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>네트워크</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/63</guid>
      <comments>https://flrefly.tistory.com/63#entry63comment</comments>
      <pubDate>Sat, 15 Mar 2025 18:15:19 +0900</pubDate>
    </item>
    <item>
      <title>트레이싱 어노테이션 140개를 지우며 들었던 생각</title>
      <link>https://flrefly.tistory.com/61</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경: 트레이싱을 도입하던 날의 원칙&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서비스에 분산 트레이싱을 넣기로 했을 때, 가장 중요하게 생각한 부분 중 하나는, 인프라 코드이기때문에 비즈니스 로직과 섞이면 안된다는 생각이었습니다.&lt;/li&gt;
&lt;li&gt;트레이싱은 요청이 어디를 거쳐 얼마나 걸렸는지 기록하는 일입니다. 이미지를 생성하는 로직 입장에서는 자기가 추적당하는지 아닌지 알 필요가 없습니다. 그래서 어노테이션과 AOP 를 선택했습니다. 메서드에 어노테이션 하나만 붙이면 스팬 생성과 종료, 예외 기록은 전부 Aspect 가 처리하는 구조였습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;verilog&quot;&gt;&lt;code&gt;@ImageGenerateTraceable
public ImageResponse generate(ImageRequest request) {
    // 본문에는 트레이싱 코드가 한 줄도 없다
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메서드 본문은 깨끗했고, 트레이싱 로직은 Aspect 파일 안에만 있었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제: 어노테이션을 걷어내야 하는 날이 옴&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;시간이 지나 트레이싱을 라이브러리 방식에서 에이전트 방식으로 바꾸자는 이야기가 나왔습니다. 에이전트가 계측을 대신해 주니, 코드에 심어둔 수동 계측은 걷어내야 했습니다. 걷어내려고 검색해 보니, 어노테이션이 140 개가 넘는 파일에 흩어져 있었습니다.&lt;/li&gt;
&lt;li&gt;컨트롤러, 서비스, 이벤트 리스너.. 트레이싱 대상이었던 모든 파일을 하나하나 열어 어노테이션을 지우고, 임포트를 지우고, 테스트를 고쳤습니다. 최종적으로 정리 커밋은 147 개 파일, 1,099 줄 삭제였습니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;그동안 문제가 없었던 이유가 분리가 잘되어있어서가 아니라, 어노테이션명 및 패키지이름 등을 바꾸는 변경사항이 한 번도 오지않았기 때문이라는 것을 알게되었습니다.&lt;/li&gt;
&lt;li&gt;(에이전트 방식은 결과적으론 도입하지 않기로 결정이 났습니다. 다만 어노테이션을 걷어내는 과정에서 산재된 선언 자체의 문제를 이미 체감한 뒤였고, 계측을 다시 수동으로 넣어야 하는 상황이 된 김에 이번에는 같은 방식으로 되돌리지 않기로 했습니다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;당시의 해결: 어노테이션 대신 이름으로 일괄 처리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다시 트레이싱을 정리하면서는 조금 생각을 해본 뒤, 포인트컷이 클래스 이름 규칙으로 대상을 잡도록 했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;ceylon&quot;&gt;&lt;code&gt;@Pointcut(&quot;&quot;&quot;
        execution(public * com.mirid.api..*ClientApi.*(..))
        &quot;&quot;&quot;)
private void traceTargetMethods() {
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;참고로, 당시 가능했던 건 이 코드베이스가 이미 이름에 역할을 담고 있었기 때문입니다. 이름이 곧 역할이라면, 역할 전체에 걸리는 관심사는 이름으로 붙잡을 수 있습니다.&lt;/li&gt;
&lt;li&gt;이제 트레이싱이라는 관심사는 물리적으로 한 곳에 있습니다. 대상을 넓히든 좁히든, 스팬 이름 규칙을 바꾸든, 방식을 통째로 갈아엎든 열어야 하는 파일은 하나입니다. 비즈니스 파일은 트레이싱의 존재 자체를 모릅니다. 이번에는 흔적까지 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;장점만 있는 해결책이 아님&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;어노테이션&lt;/th&gt;
&lt;th&gt;네이밍 포인트컷&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;파일만 보고 트레이싱 여부를 알 수 있는가&lt;/td&gt;
&lt;td&gt;있다&lt;/td&gt;
&lt;td&gt;없다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;트레이싱 정책 변경 시 건드리는 파일&lt;/td&gt;
&lt;td&gt;대상 전부&lt;/td&gt;
&lt;td&gt;하나&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;대상마다 다른 옵션 주기&lt;/td&gt;
&lt;td&gt;쉽다&lt;/td&gt;
&lt;td&gt;어렵다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;새 클래스가 조용히 누락될 위험&lt;/td&gt;
&lt;td&gt;없다 (안 붙이면 그게 의도)&lt;/td&gt;
&lt;td&gt;있다 (이름을 잘못 지으면)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가장 신경쓰이는 부분은 암묵성입니다. 서비스 파일을 열어봐도 이 메서드가 트레이싱되는지 알 수 없습니다. 이름을 규칙에서 벗어나게 지으면 에러 없이 트레이싱에서 빠집니다. 이름이 동작을 결정하는 계약이 된 셈인데, 이 계약은 컴파일러가 강제하지 않습니다.&lt;/li&gt;
&lt;li&gt;그럼에도 이 방식을 선택한 근거는 두 가지였습니다.&lt;/li&gt;
&lt;li&gt;첫째, 어느 쪽 변경이 더 자주 오는가입니다. 이미 한 번 겪었듯이 트레이싱은 정책 전체가 통째로 바뀌는 종류의 관심사입니다. 방식 교체, 일괄 적용, 일괄 제거. 반면 &quot;이 메서드 하나만 트레이싱에서 빼고 싶다&quot; 같은 개별 단위의 변경은 지금까지 없었고, 필요해지면 포인트컷에 예외 한 줄을 추가하면 됩니다. 변경이 정책 단위로 온다면 코드도 정책 단위로 모여 있는 편이 낫다고 판단했습니다.&lt;/li&gt;
&lt;li&gt;둘째, 실패했을 때 무슨 일이 생기는가입니다. 이름을 잘못 지어 트레이싱이 누락되면 잃는 것은 스팬 하나이고, 비즈니스 동작에는 영향이 없습니다. 또한 이런 누락은 트레이싱 대시보드에서 스팬 수가 줄어드는 형태로 바로 드러납니다. 실패 비용이 이 정도라면 암묵성은 감수할 수 있다고 봤습니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;(전환 시점에는 기존에 어노테이션이 붙어 있던 파일 목록과 네이밍 규칙에 걸리는 파일 목록을 비교해 누락된 대상이 없는지 확인했고, 전환 전후로 트레이싱 대시보드에 잡히는 스팬 수가 유지되는지도 함께 봤습니다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;회고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;비즈니스 로직과 인프라 코드를 분리한다&quot; 는 생각 자체가 틀렸다기보단, 분리를 확인하는 부분에서 아쉬움이 있었습니다. 코드가 어느 파일에 있는지를 봤고, 변경이 어느 파일에 닿는지를 보지 않았습니다. 변경이 오지 않는 동안에는 이 둘의 차이가 드러나지 않기 때문에, 어떤 결합은 꽤 오랫동안 분리처럼 보일 수 있겠다 라는 생각이 들었습니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>문제해결</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/61</guid>
      <comments>https://flrefly.tistory.com/61#entry61comment</comments>
      <pubDate>Wed, 26 Feb 2025 15:30:44 +0900</pubDate>
    </item>
    <item>
      <title>자바 메모리 관리와 참조 유형</title>
      <link>https://flrefly.tistory.com/60</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;자바는 가비지 컬렉션(GC)을 통해 더 이상 사용되지 않는 객체를 자동으로 메모리에서 회수합니다.&lt;br /&gt;그러나, 애플리케이션이 특정 객체의 회수 시점이나 자원 정리에 관여할 필요가 있을 때, 단순한 강한 참조 외에 &lt;b&gt;소프트&lt;/b&gt;, &lt;b&gt;약한&lt;/b&gt;, &lt;b&gt;팬텀&lt;/b&gt; 참조를 활용할 수 있습니다.&lt;br /&gt;예전부터 사용되어 온 &lt;a title=&quot;finalize()&quot; href=&quot;https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;finalize()&lt;/a&gt; 메소드도 있지만, 여러 문제로 인해 Cleaner(phantom reachable 상태가 되었을 때 미리 등록한 작업 실행하는 매커니즘), PhantomReference, AutoCloseable과 같은 대체 메커니즘이 권장됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 먼저 각 참조 유형의 기본 개념과 특징을 살펴보고, 실제 코드 예제를 통해 어떻게 활용되는지 알아보겠습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. finalize()와 그 한계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 finalize()의 역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;finalize() 메소드는 GC가 객체를 회수하기 전에 정리 작업(예: 파일 핸들, 데이터베이스 연결 해제 등)을 수행하기 위해 호출됩니다.&lt;br /&gt;예를 들어, 시스템 자원을 가진 객체라면 finalize()에서 해당 자원을 해제할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 finalize()의 호출 특성&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;호출 시점 불확실: &lt;/b&gt;JVM이 객체를 더 이상 참조하지 않는다고 판단했을 때 호출되지만, 언제 호출될지는 보장되지 않으며 지연될 수 있습니다. 즉, gc의 동작에 의존하고 있다는거고, 이로인해 문제가 발생할 수 있습니다. 자원은 한정되어있고, 객체가 그 자원을 점유한 채로 오래 남아있게 되면, 정작 필요할떄 사용가능한 자원이 부족해지는 거죠.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;한 번만 호출: &lt;/b&gt;한 객체에 대해 finalize()는 단 한 번만 호출되고, 재시도할 기회가 없습니다. 즉, 한 번 실패하면 정리되지 않은 채로 남게 됩니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;예외 처리 문제: &lt;/b&gt;finalize()에서 발생한 예외는 무시되므로, 문제가 발생해도 쉽게 감지하기 어렵습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;객체 부활 위험: &lt;/b&gt;finalize() 내에서 객체를 다시 강한 참조로 만들면, 객체가 부활하여 메모리 누수 등의 문제가 발생할 수 있습니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;(finalize method에서 예 를들어 static 변수에 자기자신의 인스턴스(this)를 대입해버린다고 가정. 그러면 애플리케이션이 종료되지 않는 한 계속해서 도달 가능한 영역에 저장돼버리고 그럼 그 객체에 대해 강하게 참조가 되며.. gc는 그 객체를 아직사용중이라고 간주하고 회수대상에서 제외)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 finalize()의 문제점과 대안&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 이유로 자바에서는 finalize() 대신 Cleaner, PhantomReference, 또는 AutoCloseable(try-with-resources)을 사용하여 보다 결정적이고 안전하게 자원을 관리하도록 권장합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 자바의 참조 유형과 도달 가능성(Reachability)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바에서는 객체를 참조하는 방식에 따라 GC가 객체를 회수하는 기준이 달라집니다.&lt;br /&gt;GC는 지역 변수, static 변수, 네이티브 코드 등부터 시작하여 도달할 수 있는 객체는 살아있다고 판단합니다.&lt;br /&gt;객체의 도달 가능성은 다음과 같이 분류됩니다:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Strongly reachable:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아무런 특별한 참조 없이, 일반 변수 등으로 직접 참조되는 상태&lt;/li&gt;
&lt;li&gt;강한 참조가 존재하는 한 GC는 해당 객체를 회수하지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Softly reachable:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;강한 참조는 없지만, &lt;b&gt;SoftReference&lt;/b&gt;를 통해 도달 가능한 상태&lt;/li&gt;
&lt;li&gt;메모리가 부족할 때 GC에 의해 회수될 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Weakly reachable:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;강한 참조와 소프트 참조가 모두 없고, 오직 &lt;b&gt;WeakReference&lt;/b&gt;를 통해서만 도달 가능한 상태&lt;/li&gt;
&lt;li&gt;GC가 실행되면 바로 회수 대상이 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Phantomly reachable:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;객체가 강한, 소프트, 약한 참조 없이 오직 &lt;b&gt;PhantomReference&lt;/b&gt;에 의해서만 도달 가능한 상태&lt;/li&gt;
&lt;li&gt;이 상태의 객체는 이미 finalize()가 호출되었거나 호출될 준비가 되어 있으며, 실제 메모리 회수 직전에 ReferenceQueue에 등록되어 후속 정리 작업을 수행할 수 있습니다.&lt;/li&gt;
&lt;li&gt;PhantomReference의 get() 메서드는 항상 null을 반환합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Unreachable (도달할 수 없는 상태):&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;루트 집합에서 어떠한 경로로도 도달할 수 없는 객체&lt;/li&gt;
&lt;li&gt;GC에 의해 회수됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 각 참조 유형의 특징 및 활용 예제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 Strong Reference&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;개념:&lt;/b&gt;&lt;br /&gt;우리가 일반적으로 변수를 선언하고 객체를 할당할 때 사용하는 참조 방식입니다.&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1739164641773&quot; class=&quot;java&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;MyObject obj = new MyObject(&quot;TestObject&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;강한 참조가 존재하는 한, GC는 해당 객체를 회수하지 않습니다.&lt;/li&gt;
&lt;li&gt;즉, obj가 살아있는 동안 MyObject 인스턴스는 메모리에서 제거되지 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 Weak Reference&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;객체를 참조하지만, 이 참조만으로는 해당 객체를 GC로부터 보호하지 않는 참조입니다.&lt;/li&gt;
&lt;li&gt;예를 들어, 캐시를 구현할 때 객체에 대한 부가 정보를 저장하고, 객체가 더 이상 사용되지 않으면 캐시에서도 자동으로 제거하고 싶을 때 사용합니다.&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1739164689707&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.util.Map;
import java.util.WeakHashMap;

class MyKey {
    private String id;
    public MyKey(String id) { this.id = id; }
    @Override 
    public String toString() { return id; }
}

public class WeakReferenceExample {
    public static void main(String[] args) throws InterruptedException {
        // WeakHashMap은 내부적으로 키를 약한 참조로 관리합니다.
        Map&amp;lt;MyKey, String&amp;gt; cache = new WeakHashMap&amp;lt;&amp;gt;();

        MyKey key = new MyKey(&quot;key1&quot;);
        cache.put(key, &quot;Cached Data&quot;);

        System.out.println(&quot;캐시 저장 후: &quot; + cache);

        // 강한 참조인 key를 null로 만들어 더 이상 사용되지 않게 합니다.
        key = null;

        // GC를 강제로 요청하여, 더 이상 참조되지 않는 객체들을 회수합니다.
        System.gc();
        Thread.sleep(1000);

        // 키가 GC되었으므로 캐시에서 자동 제거됩니다.
        System.out.println(&quot;GC 후 캐시: &quot; + cache);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 Soft Reference&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메모리가 충분할 때는 객체를 유지하지만, 메모리가 부족할 때는 GC에 의해 회수될 수 있도록 하는 참조입니다.&lt;/li&gt;
&lt;li&gt;예를 들어, 웹 브라우저나 이미지 뷰어에서 이미지 캐시를 구현할 때 사용합니다.&lt;br /&gt;메모리가 넉넉하면 이미지를 캐시에 보관하고, 메모리가 부족해지면 GC가 이를 회수하여 메모리 부족 문제를 예방합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1739164715762&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.lang.ref.SoftReference;

class Image {
    private String name;
    public Image(String name) { this.name = name; }
    @Override public String toString() { return &quot;Image: &quot; + name; }
}

public class SoftReferenceExample {
    public static void main(String[] args) {
        // 이미지 객체 생성 후 소프트 참조로 감싼다.
        Image img = new Image(&quot;Landscape&quot;);
        SoftReference&amp;lt;Image&amp;gt; softRef = new SoftReference&amp;lt;&amp;gt;(img);

        // 강한 참조가 있으므로, 소프트 참조의 get()는 객체를 반환합니다.
        System.out.println(&quot;소프트 참조 사용 전: &quot; + softRef.get());

        // 강한 참조 제거: img에 대한 강한 참조를 없앱니다.
        img = null;

        // 여기서 메모리 상황에 따라, GC가 소프트 참조 대상 이미지를 회수할 수 있습니다.
        System.gc();

        // GC 후, 메모리가 부족한 상황이라면 softRef.get()는 null을 반환할 수 있습니다.
        System.out.println(&quot;GC 후, 소프트 참조: &quot; + softRef.get());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.4 Phantom Reference&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;객체가 강한, 소프트, 약한 참조로는 도달할 수 없고, 오직 팬텀 참조에 의해서만 도달 가능한 상태를 의미합니다.&lt;br /&gt;팬텀 참조는 get() 메서드가 항상 null을 반환하며, 객체에 직접 접근할 수 없고 &quot;회수 직전&quot; 신호로만 사용됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;/b&gt;네이티브 리소스(예: 직접 할당한 메모리, 파일 핸들 등)를 사용하는 객체의 경우, finalize()에 의존하면 호출 시점이 불확실한 문제가 있습니다.&lt;/li&gt;
&lt;li&gt;대신 팬텀 참조와 ReferenceQueue를 사용하면, 객체가 GC에 의해 회수되기 직전에 정리 작업(자원 해제 등)을 실행할 수 있습니다.&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1739164748691&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

class ResourceHolder {
    private String resource;

    public ResourceHolder(String resource) {
        this.resource = resource;
    }

    @Override
    public String toString() {
        return &quot;ResourceHolder{&quot; + &quot;resource='&quot; + resource + '\'' + '}';
    }

    // 실제 자원 해제 메소드 (예: 파일 핸들 닫기, 네이티브 메모리 해제 등)
    public void cleanup() {
        System.out.println(&quot;자원을 해제합니다: &quot; + resource);
    }
}

public class PhantomReferenceExample {
    public static void main(String[] args) throws InterruptedException {
        // 1. ReferenceQueue 생성
        ReferenceQueue&amp;lt;ResourceHolder&amp;gt; refQueue = new ReferenceQueue&amp;lt;&amp;gt;();

        // 2. 객체 생성 및 팬텀 참조 등록
        ResourceHolder holder = new ResourceHolder(&quot;NativeResource&quot;);
        PhantomReference&amp;lt;ResourceHolder&amp;gt; phantomRef = new PhantomReference&amp;lt;&amp;gt;(holder, refQueue);

        System.out.println(&quot;팬텀 참조 상태: &quot; + phantomRef.get());

        // 3. 강한 참조 제거
        holder = null;

        // 4. GC 요청
        System.gc();
        Thread.sleep(1000);

        // 5. ReferenceQueue에서 팬텀 참조가 들어왔는지 확인
        PhantomReference&amp;lt;ResourceHolder&amp;gt; refFromQueue = (PhantomReference&amp;lt;ResourceHolder&amp;gt;) refQueue.poll();
        if (refFromQueue != null) {
            // 팬텀 참조가 큐에 들어왔다는 것은 객체가 GC에 의해 회수될 준비가 되었다는 신호입니다.
            System.out.println(&quot;팬텀 참조가 큐에 들어갔습니다. 정리 작업을 수행할 수 있습니다.&quot;);

            // 여기서 cleanup() 등의 메소드를 호출해, 네이티브 리소스나 기타 자원을 해제할 수 있습니다.
            // 단, 팬텀 참조 자체로는 객체에 접근할 수 없으므로, 정리 로직은 별도의 구조로 관리해야 합니다.
        } else {
            System.out.println(&quot;팬텀 참조가 아직 큐에 들어오지 않았습니다.&quot;);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;객체가 회수될 준비가 되었음을 정확히 감지할 수 있어, 정리 작업을 적절한 시점에 실행할 수 있습니다.&lt;/li&gt;
&lt;li&gt;팬텀 참조 자체로는 객체에 접근할 수 없으므로, 주로 ReferenceQueue와 함께 자원 해제 후처리 로직을 구현하는 데 사용됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. ReferenceQueue란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ReferenceQueue&amp;lt;T&amp;gt;는 GC가 특정 객체를 회수할 때, 해당 객체를 참조하던 &lt;b&gt;약한, 소프트, 또는 팬텀 참조&lt;/b&gt;를 자동으로 큐에 넣어줍니다.&lt;br /&gt;이를 통해 애플리케이션은 GC가 객체를 회수했다는 사실을 감지하고, 캐시에서 해당 항목을 제거하거나 추가 자원 정리 작업을 수행할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 위의 팬텀 참조 예제에서는 GC가 회수할 준비가 된 객체에 대해 팬텀 참조가 ReferenceQueue에 등록되면, 애플리케이션은 이를 감지하여 cleanup() 같은 정리 작업을 실행할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바에서는 객체를 관리할 때 &lt;b&gt;참조의 종류&lt;/b&gt;에 따라 GC가 객체를 회수하는 기준과 시점이 달라집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;강한 참조&lt;/b&gt;는 기본적인 참조 방식으로, 참조가 있는 한 객체가 회수되지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;약한 참조&lt;/b&gt;는 GC가 강한 참조가 없는 객체를 바로 회수하도록 하여, 캐시 등에서 불필요한 객체가 자동으로 제거되도록 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;소프트 참조&lt;/b&gt;는 메모리 상황에 따라 GC가 객체를 회수할 수 있도록 하여, 메모리 민감 캐시에 유용합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;팬텀 참조&lt;/b&gt;는 객체가 회수되기 직전에 후속 정리 작업을 안전하게 수행할 수 있는 신호 역할을 하며, ReferenceQueue와 함께 사용됩니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>자바</category>
      <author>Flrefly</author>
      <guid isPermaLink="true">https://flrefly.tistory.com/60</guid>
      <comments>https://flrefly.tistory.com/60#entry60comment</comments>
      <pubDate>Mon, 10 Feb 2025 14:21:41 +0900</pubDate>
    </item>
  </channel>
</rss>