<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>공부노트</title>
    <link>https://jaehee329.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 2 Jul 2026 17:56:43 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Jaehee Jeon</managingEditor>
    <item>
      <title>OpenSSH의 ControlMaster, ControlPersist를 통한 연결 비용 최소화</title>
      <link>https://jaehee329.tistory.com/63</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;ssh 커넥션을 자주 열고 닫는 환경에서는 매번 TCP 연결, 키 교환, 인증, 세션 초기화를 반복하는 비용이 꽤 크다.&lt;br /&gt;이 글에서는 OpenSSH portable의 실제 구현을 참고하여 TCP 커넥션 위의 논리적인 SSH session과 channel을 OpenSSH가 어떻게 관리하고 재사용하는지 정리해 본다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;1. TCP connection, SSH session, channel의 관계&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ssh를 쓰다 보면 &amp;ldquo;세션을 연다&amp;rdquo;, &amp;ldquo;커넥션을 유지한다&amp;rdquo;, &amp;ldquo;터널을 만든다&amp;rdquo; 같은 말을 섞어 쓰게 된다. 실제 구현을 볼 때는 이 단어들을 조금 분리해서 보는 편이 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 전체 구조를 보면 대략 이렇다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;TCP connection
└─ SSH transport: 암호화, MAC, key exchange, rekey
   └─ SSH connection protocol
      ├─ channel 0: session:shell
      ├─ channel 1: direct-tcpip 또는 forwarded-tcpip
      ├─ channel 2: x11-connection
      └─ ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 위에서부터 내려오며 정리하면 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;TCP connection은 가장 바깥의 실제 네트워크 연결&lt;/b&gt;&lt;br /&gt;&lt;code&gt;ssh user@host&lt;/code&gt;를 실행하면 클라이언트는 서버의 22번 포트, 또는 설정된 포트로 TCP 연결을 만든다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SSH transport는 TCP connection 위에 올라가는 암호화된 전송 계층&lt;/b&gt;&lt;br /&gt;TCP 연결이 만들어진 뒤에는 SSH 버전 교환, key exchange, host key 검증, 사용자 인증이 진행된다. 여기까지 끝나면 &amp;ldquo;암호화되고 인증된 SSH 연결&amp;rdquo;이 준비된다. 이 글에서 이후 재사용된다고 말하는 핵심 대상도 바로 이 SSH transport connection이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SSH connection protocol은 그 위에 여러 channel을 올림&lt;/b&gt;&lt;br /&gt;이 레이어에서 등장하는 핵심 추상화가 &lt;b&gt;channel&lt;/b&gt;이다. OpenSSH 구현에서 channel은 로컬 fd, 원격 channel id, window size, max packet size, close 상태 등을 가진 하나의 논리 스트림으로 관리된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;session은 channel의 한 종류&lt;/b&gt;&lt;br /&gt;우리가 흔히 &amp;ldquo;터미널 세션&amp;rdquo;이라고 부르는 것은 보통 &lt;code&gt;session&lt;/code&gt; 타입 channel이다. 즉 SSH 프로토콜 관점에서 session은 TCP connection이나 SSH transport와 같은 별도 계층이 아니라, &lt;b&gt;channel type 중 하나&lt;/b&gt;로 보는 편이 정확하다. OpenSSH 코드에서 &lt;code&gt;channel_new(..., &quot;session&quot;, ...)&lt;/code&gt; 같은 호출을 볼 때도 &amp;ldquo;새로운 SSH 연결 전체를 만든다&amp;rdquo;기보다 &amp;ldquo;이미 만들어진 SSH connection 위에 session 타입 channel 하나를 연다&amp;rdquo;라고 읽는 것이 자연스럽다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;channel은 session만 있는 게 아님&lt;/b&gt;&lt;br /&gt;일반적인 터미널 접속이나 원격 명령 실행은 &lt;code&gt;session&lt;/code&gt; channel을 쓰지만, port forwarding은 &lt;code&gt;direct-tcpip&lt;/code&gt;/&lt;code&gt;forwarded-tcpip&lt;/code&gt; channel을 쓰고, X11 forwarding은 &lt;code&gt;x11&lt;/code&gt; 계열 channel을 쓴다. 즉 하나의 SSH connection 위에는 목적이 다른 여러 논리 스트림이 함께 올라갈 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;session&lt;/code&gt; channel은 우리가 가장 자주 보는 형태다. 일반적인 &lt;code&gt;ssh user@host&lt;/code&gt; 접속, &lt;code&gt;ssh host command&lt;/code&gt; 실행, &lt;code&gt;sftp&lt;/code&gt; 같은 subsystem 실행은 모두 이 session channel 위에서 &lt;code&gt;shell&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;subsystem&lt;/code&gt;, &lt;code&gt;pty-req&lt;/code&gt; 같은 channel request를 보내는 식으로 구성된다. 다만 대부분의 사용자는 터미널 접속이나 원격 명령 실행을 통해 session 타입 channel을 가장 먼저 접하므로 기본 흐름은 session channel을 중심으로 설명한다.&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;이 구조를 이해하면 OpenSSH multiplexing이 무엇을 재사용할지가 명확해진다. multiplexing은 &lt;b&gt;이미 인증까지 끝난 하나의 SSH transport connection을 마스터 프로세스가 유지하고, 이후의 ssh 실행은 그 위에 새 channel만 추가로 요청하는 방식&lt;/b&gt;이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;2. 새 session channel 생성과 연결 비용의 반복&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 옵션으로 다음 명령을 실행한다고 해보자.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;ssh user@example.com&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우에는 보통 하나의 ssh 프로세스가 하나의 TCP connection과 하나의 대표 &lt;code&gt;session&lt;/code&gt; channel을 가진다. OpenSSH 클라이언트 쪽 흐름을 구현 관점에서 단순화하면 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;ssh main()
  └─ 설정 파일/CLI 옵션 파싱
  └─ TCP 연결 생성
  └─ SSH transport 초기화: 버전 교환, KEX, 인증
  └─ ssh_session2()
     └─ ssh_session2_open(): 논리적 채널 생성
        └─ channel_new(&quot;session&quot;, SSH_CHANNEL_OPENING, ...)
        └─ channel_send_open()
     └─ open confirm 이후 client_session2_setup(): 채널을 어떤 용도로 쓸지 협상
        └─ pty 할당 요청 / 환경 변수 세팅 / 단순 명령 exec / shell 할당 요청
  └─ client_loop()
     ├─ network socket
     ├─ channel fd
     ├─ window adjust
     ├─ channel data/eof/close
     └─ server alive / rekey / signal 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 흐름에서 짧은 요청 하나를 보내기 위해 매번 &lt;code&gt;TCP 연결 생성&lt;/code&gt;부터 &lt;code&gt;SSH transport 초기화&lt;/code&gt;, &lt;code&gt;KEX&lt;/code&gt;, &lt;code&gt;인증&lt;/code&gt;까지 반복하는 것은 꽤 비싸다. 이미 인증까지 끝난 SSH transport connection을 유지할 수 있다면, 이후 실행에서는 이 앞단을 생략하고 기존 connection 위에 새로운 &lt;code&gt;session&lt;/code&gt; channel만 열면 된다.&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;ldquo;세션 채널 캐싱&amp;rdquo;은 개별 &lt;code&gt;session&lt;/code&gt; channel 자체를 저장해 두고 재사용한다는 뜻은 아니다. OpenSSH가 실제로 재사용하는 것은 더 비싼 단위인 &lt;b&gt;SSH transport connection&lt;/b&gt;이고, 필요할 때마다 그 위에 새 &lt;code&gt;session&lt;/code&gt; channel을 빠르게 추가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenSSH의 multiplexing은 이 지점에서 등장한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;3. Multiplexing의 핵심 아이디어&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenSSH의 multiplexing은 여러 ssh 프로세스가 하나의 TCP socket을 직접 나눠 쓰는 구조가 아니다. 더 정확히는 &lt;b&gt;마스터 프로세스가 서버와의 SSH 연결을 독점하고, 슬레이브 프로세스들은 마스터에게 작업을 위임하는 구조&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마스터가 필요한 이유는 SSH transport가 단순한 byte stream이 아니기 때문이다. 하나의 SSH connection에서는 packet sequence, MAC/cipher 상태, rekey 타이밍, channel id 공간이 모두 하나의 순서 안에서 관리된다. 여러 프로세스가 같은 TCP socket에 직접 SSH 패킷을 쓰면 이 상태를 일관되게 직렬화하기 어렵다. 그래서 OpenSSH는 TCP socket과 SSH transport 상태를 마스터 하나만 소유하게 만든다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;master ssh process
  ├─ 서버와의 TCP socket 보유
  ├─ SSH transport 암호화 상태 보유
  ├─ channel id / window / rekey 상태 관리
  └─ 기존 SSH connection 위에 새 session channel 생성

slave ssh process
  └─ ControlPath의 Unix domain socket으로 master에 접속
      └─ MUX_C_NEW_SESSION 요청 `이 command로 session channel 하나 열어줘`&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unix domain socket은 multiplexing을 자동으로 해주는 소켓이 아니라, 같은 머신 안의 slave와 master가 대화하기 위한 로컬 통로다. 이 소켓은 master가 &lt;code&gt;ControlPath&lt;/code&gt; 경로에 listener로 만들어두고, slave는 같은 경로로 connect 해 master를 찾는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;mux.c&lt;/code&gt;의 클라이언트 경로는 이 소켓으로 master에 접속한 뒤 메시지를 보낸다. 일반적인 새 명령 실행이라면 이 메시지는 &lt;code&gt;MUX_C_NEW_SESSION&lt;/code&gt;이고, master는 이벤트 루프에서 control socket 이벤트를 받아 요청을 해석한 뒤 자신이 가진 SSH connection 위에 새 &lt;code&gt;session&lt;/code&gt; channel을 연다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;4. 슬레이브가 붙는 과정: muxclient()&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 두 번째 ssh를 실행했다고 해보자.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;ssh user@example.com hostname&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ControlMaster auto&lt;/code&gt;이고 같은 &lt;code&gt;ControlPath&lt;/code&gt;에 이미 마스터가 있으면, 새 ssh 프로세스는 TCP 연결을 만들기 전에 &lt;code&gt;mux.c&lt;/code&gt;의 &lt;code&gt;muxclient()&lt;/code&gt; 경로를 시도한다. 여기서 슬레이브가 붙는 대상은 원격 서버가 아니라, &lt;b&gt;이미 떠 있는 로컬 마스터 프로세스&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;muxclient(ControlPath)
  ├─ AF_UNIX socket 생성
  ├─ ControlPath로 로컬 master에 connect()
  ├─ 실패하면:
  │   ├─ auto 계열이면 일반 SSH 연결로 fallback
  │   └─ stale socket처럼 보이면 정리
  └─ 성공하면:
      ├─ master와 mux protocol HELLO 교환
      └─ 요청 종류에 따라 session / forward / check / exit / proxy 수행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HELLO&lt;/b&gt; 교환은 슬레이브가 &lt;code&gt;ControlPath&lt;/code&gt;의 Unix domain socket으로 마스터에 붙은 뒤, &lt;b&gt;로컬 마스터와 mux protocol 버전이 맞는지 확인하는 단계&lt;/b&gt;다. 이 확인이 끝나야 슬레이브는 &amp;ldquo;이 마스터에게 세션 생성을 위임해도 된다&amp;rdquo;라고 판단하고 다음 요청으로 넘어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적인 새 쉘/명령 실행은 &lt;code&gt;MUX_C_NEW_SESSION&lt;/code&gt; 요청으로 이어진다. 여기서 중요한 점은 슬레이브가 단순히 명령어 문자열만 보내는 것이 아니라, &lt;b&gt;자신이 가진 세션 제어권을 마스터에게 넘긴다&lt;/b&gt;는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 fd 전달이 핵심이다. Unix domain socket의 프로세스 간 file descriptor 전달 기능을 이용, 슬레이브의 &lt;code&gt;stdin&lt;/code&gt;, &lt;code&gt;stdout&lt;/code&gt;, &lt;code&gt;stderr&lt;/code&gt; fd를 마스터에게 보낸다. 그래서 마스터는 슬레이브의 터미널 입출력 통로를 직접 가진 상태로 새 session channel을 만들 수 있다.&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;이후 슬레이브는 네트워크 패킷을 직접 만들지 않는다. 마스터가 받은 fd를 session channel의 입출력에 연결하고, 서버와의 실제 SSH protocol exchange를 대신 수행한다. 슬레이브 프로세스는 foreground에 남아 있다가 마스터가 보내주는 session open 결과, 종료 상태, 에러 메시지를 기다린다.&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;그래서 사용자는 두 번째 ssh도 독립 프로세스처럼 느낀다. 터미널도 있고, 종료 코드도 받고, 표준 입/출력도 분리되어 있다. 하지만 실제 TCP 연결, 암호화, channel 생성과 관리는 모두 마스터가 이미 가지고 있던 하나의 SSH connection 위에서 처리된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;5. 종료 과정: 세션 종료와 마스터 종료는 다르다&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;multiplexing을 이해할 때 종료 과정을 분리해서 봐야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는 &lt;b&gt;개별 session channel의 종료&lt;/b&gt;다. 예를 들어 슬레이브로 실행한 &lt;code&gt;ssh host hostname&lt;/code&gt; 명령이 끝나면, 서버는 session channel에 exit status를 보낸다. 마스터는 이 정보를 control socket을 통해 슬레이브에게 알려주고, 슬레이브는 그 exit status로 종료한다. &lt;code&gt;mux_client_request_session()&lt;/code&gt;은 &lt;code&gt;MUX_S_SESSION_OPENED&lt;/code&gt;를 받은 뒤 계속 control socket을 읽다가 &lt;code&gt;MUX_S_EXIT_MESSAGE&lt;/code&gt; 등을 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 &lt;b&gt;마스터의 종료&lt;/b&gt;다. 마스터는 여러 이유로 끝날 수 있다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;1. 일반적인 ssh session이 끝나고 더 유지할 이유가 없을 때
2. ControlPersist timeout이 지났을 때
3. 사용자가 `ssh -O exit host`를 보냈을 때
4. 네트워크 연결이 끊겼을 때
5. 마스터 프로세스가 죽었을 때&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ssh -O exit&lt;/code&gt;는 mux protocol의 &lt;code&gt;MUX_C_TERMINATE&lt;/code&gt; 요청으로 간다. 마스터는 이를 받으면 종료 플래그를 세우고 loop를 빠져나간다. 반면 &lt;code&gt;ssh -O stop&lt;/code&gt;은 조금 다르다. 이것은 새 multiplexing 요청을 더 받지 않도록 control socket을 닫는 쪽에 가깝다. &lt;code&gt;client_stop_mux()&lt;/code&gt;는 &lt;code&gt;ControlPath&lt;/code&gt;를 unlink 하고, persist 모드이거나 shell이 없는 경우 모든 active channel이 닫힌 뒤 종료되도록 표시한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;6. ControlPersist: 연결을 언제까지 들고 있을까&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ControlMaster&lt;/code&gt;는 이미 만들어진 SSH transport connection을 마스터 프로세스가 들고 있게 만든다. 그렇다고 해서 마스터가 자신의 원래 session이 끝나는 즉시 항상 죽는 것은 아니다. &lt;code&gt;clientloop.c&lt;/code&gt;의 메인 루프는 &lt;code&gt;session_closed&lt;/code&gt;만 보지 않고 &lt;code&gt;channel_still_open(ssh)&lt;/code&gt;도 함께 본다. 즉 마스터가 실행한 원래 명령이 끝났더라도 아직 슬레이브가 만든 channel이 하나라도 열려 있으면 마스터는 계속 살아서 대행을 이어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;ControlMaster만 있는 경우
  ├─ 마스터의 원래 session 종료
  ├─ 그래도 열린 slave channel이 있으면 유지
  └─ 모든 channel이 닫히면 즉시 종료&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ControlPersist&lt;/code&gt;는 여기서 한 걸음 더 나간다. 모든 session channel이 닫혀 더 이상 처리할 슬레이브가 없어도, 마스터를 일정 시간 더 살려둔다. 이 로직은 &lt;code&gt;clientloop.c&lt;/code&gt;의 &lt;code&gt;set_control_persist_exit_time()&lt;/code&gt;에 드러난다. 열린 channel이 있으면 종료 예약을 취소하고, 열린 channel이 없으면 &lt;code&gt;ControlPersist&lt;/code&gt; 설정값에 따라 종료 시각을 잡는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;ControlPersist가 있는 경우
  ├─ 열린 channel이 있으면 계속 유지
  ├─ 열린 channel이 없어도 바로 종료하지 않음
  ├─ ControlPersist yes / 0 이면 계속 대기
  └─ ControlPersist 10m 이면 마지막 channel 종료 후 10분 뒤 종료&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 ControlMaster만으로도 &amp;ldquo;이미 붙어 있는 슬레이브&amp;rdquo;는 보호된다. 하지만 모든 슬레이브가 잠깐이라도 0개가 되는 순간 마스터가 사라지기 때문에, 다음 짧은 요청은 다시 TCP 연결과 인증부터 시작해야 한다. ControlPersist는 바로 이 공백을 메우기 위한 옵션이다. Ansible, 배포 스크립트, 짧은 ssh host command를 반복하는 환경에서 효과가 큰 이유도 여기에 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;7. 왜 이렇게 설계했을까?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenSSH multiplexing의 핵심은 &lt;b&gt;공유할 것과 위임할 것을 분리한 것&lt;/b&gt;이다. TCP socket과 SSH transport 상태는 마스터가 계속 들고 있고, 슬레이브는 매번 새로 생기는 사용자 입출력과 명령 실행 요청만 마스터에게 넘긴다.&lt;/p&gt;
&lt;pre class=&quot;elm&quot;&gt;&lt;code&gt;마스터가 관리하는 범위
  ├─ TCP socket
  ├─ SSH transport 암호화 상태
  ├─ rekey 상태
  ├─ channel id / window 상태
  └─ 서버와의 실제 protocol exchange

슬레이브가 관리하는 범위
  ├─ 사용자 터미널/stdio
  ├─ 실행하고 싶은 command
  ├─ 이번 실행의 옵션
  └─ 종료 상태를 기다리는 foreground 프로세스&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나누면 SSH transport의 복잡한 상태는 한 프로세스 안에서만 직렬화할 수 있다. packet sequence, MAC/cipher 상태, rekey, channel window 같은 값은 마스터가 일관되게 관리하고, 슬레이브는 Unix domain socket을 통해 &amp;ldquo;새 session channel을 열어달라&amp;rdquo;는 고수준 요청만 보낸다.&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;동시에 사용자 경험은 기존 ssh와 크게 달라지지 않는다. 슬레이브는 자기 &lt;code&gt;stdin&lt;/code&gt;, &lt;code&gt;stdout&lt;/code&gt;, &lt;code&gt;stderr&lt;/code&gt; fd를 마스터에게 넘기고 foreground에서 종료 상태를 기다린다. 사용자는 독립적인 ssh 명령을 실행한 것처럼 느끼지만, 실제로는 마스터가 기존 SSH connection 위에 새 channel을 열어 대신 처리한다.&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;결국 ControlMaster는 &amp;ldquo;TCP socket을 여러 프로세스가 같이 만지는 기능&amp;rdquo;이 아니라, &lt;b&gt;마스터가 연결 상태를 중앙에서 관리하고 슬레이브의 요청을 channel 단위로 대행하는 구조&lt;/b&gt;다. 마스터가 소유한 암호화나 시퀀스 상태를 자신의 명령이 끝날 때 살아 있는 슬레이브에게 원자적으로 넘겨주는 까다로운 방식을 택하지도 않는다. 이 덕분에 TCP handshake, key exchange, 인증 같은 비싼 단계를 반복하지 않으면서도 SSH transport의 상태를 안전하게 유지할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;Reference&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RFC 4254: &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc4254&quot;&gt;The Secure Shell (SSH) Connection Protocol&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;OpenSSH portable GitHub Repository: &lt;a href=&quot;https://github.com/openssh/openssh-portable&quot;&gt;https://github.com/openssh/openssh-portable&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>기타</category>
      <category>ControlMaster</category>
      <category>ControlPersist</category>
      <category>OpenSSH</category>
      <category>ssh</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/63</guid>
      <comments>https://jaehee329.tistory.com/63#entry63comment</comments>
      <pubDate>Sun, 26 Apr 2026 16:50:18 +0900</pubDate>
    </item>
    <item>
      <title>ForkJoinPool 아키텍처와 경합 최소화</title>
      <link>https://jaehee329.tistory.com/62</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Java 7부터 도입된 스레드 풀 &lt;code&gt;ForkJoinPool&lt;/code&gt;은 Java 5의 전통적인 &lt;code&gt;ThreadPoolExecutor&lt;/code&gt;와는 구조와 동작 방식이 특이하다. 직접 &lt;code&gt;ForkJoinPool&lt;/code&gt;을 사용한 적은 없더라도 병렬 스트림, &lt;code&gt;CompletableFuture&lt;/code&gt;, 가상 스레드 풀 등 다양한 자바 기능들이 내부적으로 &lt;code&gt;ForkJoinPool&lt;/code&gt;를 의존하여 나도 모르게 사용하고 있었을 가능성이 크다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ForkJoinPool&lt;/code&gt;은 멀티 코어 CPU를 최대한 효율적으로 활용하는 것을 목표한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;코어 수와 유사한 개수의 스레드를 만들고 이것들의 가동률을 100%로 유지한다.&lt;/li&gt;
&lt;li&gt;병렬도와 무관하게 경합을 고려해야 하는 로직을 최소화한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ForkJoinPool&lt;/code&gt;구현의 주요한 아이디어들을 살펴보자.&lt;/p&gt;
&lt;h1&gt;1. &lt;code&gt;ForkJoinPool&lt;/code&gt;내의 경합을 최소화환 WorkQueue Array 관리&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ForkJoinPool&lt;/code&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;code&gt;ForkJoinWorkerThread&lt;/code&gt;)가 관리하는 Local Queue&lt;/li&gt;
&lt;li&gt;외부 스레드에서 제출한 작업이 쌓이는 Submission Queue&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3520&quot; data-origin-height=&quot;1026&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b05uYn/dJMcaaqdNmw/lauqyTO3LfkCKsTRQGPpE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b05uYn/dJMcaaqdNmw/lauqyTO3LfkCKsTRQGPpE0/img.png&quot; data-alt=&quot;이러한 하나의 Submission Queue를 떠올리기 쉽지만 실은 아니다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b05uYn/dJMcaaqdNmw/lauqyTO3LfkCKsTRQGPpE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb05uYn%2FdJMcaaqdNmw%2FlauqyTO3LfkCKsTRQGPpE0%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;3520&quot; height=&quot;1026&quot; data-origin-width=&quot;3520&quot; data-origin-height=&quot;1026&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이러한 하나의 Submission Queue를 떠올리기 쉽지만 실은 아니다&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ForkJoinPool&lt;/code&gt;은 워커 스레드가 Submission Queue에서 작업을 꺼내가는 pull 방식으로 설계되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 여러 워커 스레드가 하나의 바라보고 작업을 가져가는 방식을 떠올리기 쉽지만 이런 모델은 여러 워커 스레드가 Submission Queue에 접근할 때 경합이 빈번히 발생하며, 워커 스레드가 늘어날수록 경합을 기하급수적으로 늘린다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해소하고자 &lt;code&gt;ForkJoinPool&lt;/code&gt;의 실제 구현에서는 Submission Queue를 Local Queue와 같은 수로 유지하고, 두 종류의 Queue를 array 형태로 묶은&amp;nbsp;&lt;code&gt;WorkQueue[]&lt;/code&gt;로 관리한다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WorkQueue[]&lt;/code&gt;는 지정한 병렬성의 2배 이상의 가장 작은 2의 거듭제곱 크기로 생성되고 &lt;b&gt;짝수 인덱스는 Submission Queue로 활용, 홀수 인덱스는 개별 워커 스레드의 Local Queue로 활용&lt;/b&gt;한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eH6WVw/dJMcaiPhomy/Ix78w4UBLZYWQub1M6CE8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eH6WVw/dJMcaiPhomy/Ix78w4UBLZYWQub1M6CE8K/img.png&quot; data-alt=&quot;워커 스레드 각각의 큐와 외부에서 작업을 제출하는 큐의 개수가 같다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eH6WVw/dJMcaiPhomy/Ix78w4UBLZYWQub1M6CE8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeH6WVw%2FdJMcaiPhomy%2FIx78w4UBLZYWQub1M6CE8K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1024&quot; height=&quot;559&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;워커 스레드 각각의 큐와 외부에서 작업을 제출하는 큐의 개수가 같다&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ForkJoinPool&lt;/code&gt;내에 작업을 추가 시 작업을 추가하는 스레드가 어떤 스레드인지 검사 되며, 워커 스레드(&lt;code&gt;ForkJoinWorkerThread&lt;/code&gt;)가 아닌 외부 스레드라면 Submission Queue에 작업이 추가된다.&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;Submission Queue가 전체의 반이나 되는데 어떤 큐를 어떤 순서로 사용할까?&lt;br /&gt;&lt;code&gt;ForkJoinPool&lt;/code&gt;내부에선 언제나 수많은 Submission Queue중 랜덤한 하나를 골라 접근, 고른 분배를 꾀한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;629&quot; data-origin-height=&quot;813&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bblhdw/dJMcacVR8Wv/NqqH7IwMzMS7vOczWGiZOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bblhdw/dJMcacVR8Wv/NqqH7IwMzMS7vOczWGiZOK/img.png&quot; data-alt=&quot;스레드 고유의 랜덤 시드(r)를 shifting(&amp;amp;lt;&amp;amp;lt; 1)하며 짝수 인덱스만 스캔한다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bblhdw/dJMcacVR8Wv/NqqH7IwMzMS7vOczWGiZOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbblhdw%2FdJMcacVR8Wv%2FNqqH7IwMzMS7vOczWGiZOK%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;629&quot; height=&quot;813&quot; data-origin-width=&quot;629&quot; data-origin-height=&quot;813&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;스레드 고유의 랜덤 시드(r)를 shifting(&amp;lt;&amp;lt; 1)하며 짝수 인덱스만 스캔한다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드 각각이 가진 랜덤 시드(&lt;code&gt;r&lt;/code&gt;)의 bit shifting으로 빠르게 짝수 인덱스(&lt;code&gt;id&lt;/code&gt;)를 하나 선택, 큐 크기와 나머지 연산(&lt;code&gt;(queueSize - 1) &amp;amp; (id = r &amp;lt;&amp;lt; 1)&lt;/code&gt;)하여 접근할 임의의 Submission Queue를 선정하는 것을 볼 수 있다. 순차 탐색이 목표가 아니며 모든 큐를 완전 탐색하는 게 목표가 아니므로 유효하고 빠른 방법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;워커 스레드들이 쉬다가 깨어나서 다른 큐에서 작업을 훔칠 큐를 선정하는 흐름도 유사하다. 마찬가지로 의사 난수를 통해 &lt;code&gt;WorkQueue&lt;/code&gt;의 인덱스를 선정, 랜덤한 큐에서 훔쳐갈 것이 있나 살펴본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부에서 작업을 넣을 때는 최대한 흩뿌려서 큐의 크기를 고르게 유지하고, 워커 스레드가 훔쳐갈 남의 큐를 랜덤하게 선택하도록 하여 스레드 간 경합이 발생하는 상황 자체를 최소화한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 스레드 풀 외부의 스레드가 작업을 제출할 때부터 Local Queue를 임의 선택하여 추가하는 Push 모델이 아닐까?&lt;br /&gt;워커 스레드들이 Pull하는 방식으로 설계하지 않았다면 여러 개의 Submission Queue도 필요하지 않을 것이다.&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;Pull 모델을 선택한 이유는 후술 할 &lt;b&gt;Work-Stealing&lt;/b&gt; 방식을 채택한 이유와도 이어지는데, 1. Local Queue에 작업을 추가하는 주체를 시스템 전역에서 Local Queue를 소유한 하나의 워커 스레드로 한정하여 경합을 줄이고, 2. 외부 스레드 입장에선 어떤 워커 스레드가 여유로운지 알 수 없으므로 워커 스레드가 직접 작업을 선택하게 하는 방식이 낫다고 판단한 것으로 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;3. Work-Stealing Deque 기반의 경합을 최소화하는 &lt;code&gt;WorkQueue&lt;/code&gt;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 &lt;code&gt;WorkQueue&lt;/code&gt;를 동시 접근할 때의 경합을 Lock-free 방식으로 최소화하는 것도 핵심이다.&lt;br /&gt;클래스의 이름은 &lt;code&gt;WorkQueue&lt;/code&gt;이지만 실질적인 구현은 Deque이며, 양 끝단을 접근하는 주체는 철저히 분리된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WorkQueue&lt;/code&gt;는 Chase-Lev의 'Dynamic Circular Work-Stealing Deque'을 최적화하여 구현해놓았는데 핵심은 &lt;b&gt;소유자와 도둑의 역할 및 접근 지점 분리&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 간단한 형태로 논문의 Work-Stealing 방식만 설명하면 아래와 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;락 사용 없이 top과 bottom 위치를 &lt;code&gt;volatile&lt;/code&gt;로 선언하여 메모리 가시성을 확보하고 최소한의 CAS 연산을 사용한다.&lt;/li&gt;
&lt;li&gt;bottom 인덱스는 단조 증가하며, &lt;b&gt;작업을 추가하는 주인에 의해서만 변경&lt;/b&gt;한다(&lt;code&gt;pushBottom&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;도둑은 작업 훔치기만 가능하며, &lt;b&gt;CAS 연산을 통한 top을 증가시키는 방식으로 한정&lt;/b&gt;된다.&lt;/li&gt;
&lt;li&gt;이렇게 구현 시 &lt;b&gt;주인이 작업을 가져가는 위치인 bottom에서는 마지막 작업이 아닌 이상 CAS 가 불필요&lt;/b&gt;해진다.&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;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WGjEt/dJMcadHcB91/l3nrMkeRfNPwyMeSjpIack/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WGjEt/dJMcadHcB91/l3nrMkeRfNPwyMeSjpIack/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WGjEt/dJMcadHcB91/l3nrMkeRfNPwyMeSjpIack/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWGjEt%2FdJMcadHcB91%2Fl3nrMkeRfNPwyMeSjpIack%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1024&quot; height=&quot;559&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;class WorkQueue {
    Task[] tasks = new Task[CAPACITY];

    // volatile을 통한 메모리 가시성 확보
    volatile int top = 0;    // 도둑이 훔쳐가는 위치
    volatile int bottom = 0; // 주인이 작업을 넣고 빼는 위치

    // 도둑 스레드가 CAS 기반의 top에서 작업을 훔치는 메서드
    public Task steal() {
        ...
    }

    // 주인이 bottom에 작업을 추가하는 메서드
    public void pushBottom(Task task) {
        ...
    }

    // 주인이 bottom에서 작업을 꺼내가는 메서드. 작업이 하나 남은게 아니라면 CAS가 불필요하다
    public Task popBottom() {
        int b = this.bottom - 1;
        this.bottom = b;      // 일단 인덱스를 하나 줄여 예약

        int t = this.top;     // top을 읽어와서 경합 여부 확인
        int size = b - t;

        if (size &amp;lt; 0) {       // 1) 큐가 비어있음
            this.bottom = t;  // 인덱스 복구
            return null;
        }

        Task task = tasks[b]; // 작업 가져오기

        if (size &amp;gt; 0) {       // 2) 작업이 넉넉함 = 경합 없음
            return task;      // CAS 없이 바로 리턴
        }

        // 3) size == 0, 마지막 아이템이므로 도둑과 경합 발생 가능
        // CAS로 top을 증가시켜서 도둑이 못 가져가게 막아야
        if (!compareAndSwapTop(t, t + 1)) {
            this.bottom = t + 1; // 도둑이 먼저 가져가 실패한 경우 인덱스 복구
            return null; 
        }

        this.bottom = t + 1; // 성공 시 상태 업데이트
        return task;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 구현에선 WorkQueue에 쓰기 작업을 수행하는 스레드는 &lt;code&gt;WorkQueue&lt;/code&gt;의 주인으로 한정되므로 &lt;code&gt;top&lt;/code&gt;과 &lt;code&gt;bottom&lt;/code&gt; 각각을 volatile로 지정하는 대신 별도의 메모리 동기화용 필드 &lt;code&gt;access&lt;/code&gt;를 활용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 큐에서도 도둑과 주인의 접근 방향을 구분하는 아이디어로 주인이 자기 큐의 작업을 집중적으로 처리할 때 대부분의 상황에서 &lt;b&gt;동기화 로직 없이&lt;/b&gt; 작업을 손쉽게 할당한다. (메모리 가시성 정도만 고려)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;3. ManagedBlocker를 통한 I/O 작업 최적화&lt;/h1&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;ThreadPoolExecutor&lt;/code&gt;와 달리 &lt;code&gt;ForkJoinPool&lt;/code&gt;은 '병렬도'만을 지정하는 생성자들이 존재한다. &lt;b&gt;스레드 수가 아닌 병렬도를 지정&lt;/b&gt;한다는 의미가 무슨 의미일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 &lt;code&gt;ForkJoinPool&lt;/code&gt;이 &lt;b&gt;I/O 작업을 수행하는 워커 스레드&lt;/b&gt;가 있으면 해당 스레드는 커널에 의해 sleep되는 것으로 간주하고 &lt;b&gt;동적으로 신규 스레드를 할당하여 유저가 원하는 수의 실질적 병렬도를 유지시킨다&lt;/b&gt;는 의미이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Project Loom을 들어보지 못했거나 JVM 스레드와 커널 스레드 간의 1:1 관계가 I/O 작업 과정에서 어떻게 동작하는지 이해하지 못하면 이 필요성을 전혀 알 수 없으므로 동작 방식을 간단히 설명한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Q7LKs/dJMb995Vqu9/0ZFssJcx4RhGWkvvPxEtt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Q7LKs/dJMb995Vqu9/0ZFssJcx4RhGWkvvPxEtt1/img.png&quot; data-alt=&quot;JVM 위에선 C 코드 수행 경과를 알기 어렵다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Q7LKs/dJMb995Vqu9/0ZFssJcx4RhGWkvvPxEtt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQ7LKs%2FdJMb995Vqu9%2F0ZFssJcx4RhGWkvvPxEtt1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1024&quot; height=&quot;559&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JVM 위에선 C 코드 수행 경과를 알기 어렵다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;전통적인 자바의 소켓 라이브러리 등을 사용할 때 I/O를 수행하는 Native 코드들은 C 기반의 JNI 구현체를 호출한다.&lt;/li&gt;
&lt;li&gt;JNI 구현체에서 &lt;code&gt;socket.read()&lt;/code&gt;와 같은 코드가 실행되었으나 소켓 버퍼에 읽을 내용이 없다면 커널에 의해 pthread는 &lt;code&gt;Sleep&lt;/code&gt;된다.&lt;/li&gt;
&lt;li&gt;JVM 스레드는 여전히 JNI 구현체 코드가 끝나지 않은 것으로 인식, jstack 등으로 JVM 스레드의 상태를 출력해 보면 실행 중임을 의미하는 &lt;code&gt;RUNNABLE&lt;/code&gt;로 표기된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 자바의 특성상 JVM 위에서는 Native 코드 내에서 이뤄지는 I/O과정의 무한 대기 여부를 판별할 수 없으므로 프로그래머가 &lt;b&gt;'이 코드의 내용엔 커널이 pthread를 sleep 시킬 만한 내용이 있어'를&lt;/b&gt; 알려야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ForkJoinPool&lt;/code&gt;에선 별도의 &lt;code&gt;ManagedBlocker&lt;/code&gt;라는 인터페이스의 &lt;code&gt;block()&lt;/code&gt;을 구현하여 풀에 '작업 과정의 I/O 블록이 발생할 수 있는 영역'을 개발자가 정의하게 한다.&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;ManagedBlocker 사용 예시&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;public static void main(String[] args) {  
    ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();  
    forkJoinPool.submit(new MyTask());  
}

// RecursiveAction은 ForkJoinPool이 허용하는 ForkJoinTask의 자식 클래스.
private static class MyTask extends RecursiveAction {  

    @Override  
    protected void compute() {  
       // CPU bound job 1
       // ...

       // I/O 작업 부분은 ManagedBlocker 구현체를 실행하는 것으로 대체한다.
       FileReadBlocker blocker = new FileReadBlocker();  
       try {  
          ForkJoinPool.managedBlock(blocker);  
       } catch (InterruptedException e) {  
          e.printStackTrace();  
       }

       // CPU bound job 2...  
       String blockerResult = blocker.getResult();  

    }  
}

// I/O 작업 부분만 모아두는 ManagedBlocker 구현체
private static class FileReadBlocker implements ForkJoinPool.ManagedBlocker {  

    private String result;  
    private boolean finished = false;  

    @Override  
    public boolean block() throws InterruptedException {  
       try (BufferedReader br = new BufferedReader(new FileReader(&quot;/myPath&quot;))) { 
          this.result = br.readLine();  
       } catch (IOException e) {  
          throw new RuntimeException(e);  
       }  
       this.finished = true;  
       return true;  
    }  

    @Override  
    public boolean isReleasable() {
        //...
       return false;
    }  

    public String getResult() {  
       return result;  
    }  
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 구현 시 &lt;code&gt;MyTask&lt;/code&gt; 내의 File I/O를 실행하는 코드를 진입할 때는 &lt;code&gt;ForkJoinPool&lt;/code&gt;이 'I/O가 발생했으니 커널에 의해 pthread는 &lt;code&gt;Sleep&lt;/code&gt; 되고 JVM 스레드는 &lt;code&gt;RUNNABLE&lt;/code&gt;인,&lt;b&gt; 실질적 동시 CPU 작업을 수행하는 스레드가 하나 줄어들겠군'이라는&lt;/b&gt; 판단을 하고 동적으로 풀에 추가 스레드를 할당하여 병렬도를 유지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 &lt;code&gt;ForkJoinPool&lt;/code&gt;에 타 스레드 풀과 달리 생성자에서 '스레드 수'를 지정하지 않고 '원하는 병렬도'를 지정하는 이유이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;내부적으로 &lt;code&gt;ForkJoinPool&lt;/code&gt;이 사용되는 경우를 알아두자&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;code&gt;CompletableFuture&lt;/code&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도 스레드에서 작업을 처리하는 &lt;code&gt;CompletableFuture.runAsync()&lt;/code&gt;, &lt;code&gt;supplyAsync()&lt;/code&gt;, &lt;code&gt;thenApplyAsync()&lt;/code&gt;등은 실행할 스레드 풀을 지정하지 않으면 &lt;code&gt;실행 환경의 코어 수 - 1&lt;/code&gt; 크기의 워커 스레드를 가지는 &lt;code&gt;ForkJoinPool.commonPool()&lt;/code&gt;에서 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여기에 쿼리나 HTTP 통신, 파일 I/O등 I/O 작업을 전달하면 전체 시스템 병목의 원인이 된다.&lt;/b&gt;&lt;br /&gt;&lt;code&gt;ForkJoinPool&lt;/code&gt;에 직접 작업을 추가할 땐 &lt;code&gt;ManagedBlocker&lt;/code&gt;를 구현해서 동적으로 추가 스레드 생성 힌트라도 줄 수 있었는데 &lt;code&gt;CompletableFuture&lt;/code&gt;에는 &lt;code&gt;Runnable&lt;/code&gt;, &lt;code&gt;Supplier&lt;/code&gt; 등만 등록 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부에선 &lt;code&gt;ForkJoinPool&lt;/code&gt;이 &lt;code&gt;Runnable&lt;/code&gt;과 같은 인터페이스의 인스턴스를 전달받으면 분할은 불가하나 어댑터를 씌워 &lt;code&gt;ForkJoinTask&lt;/code&gt;의 모습으로 바꾸어 실행한다. 껍데기만 씌웠으므로 워커 스레드가 실행하는 과정에서 작은 CPU bound 작업들로 분할하여 여러 워커 스레드가 분할 정복하는 &lt;code&gt;ForkJoinPool&lt;/code&gt;의 이점은 하나도 누리지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Parallel Stream, &lt;code&gt;Arrays.parallelSort()&lt;/code&gt;, 일부 &lt;code&gt;Collections&lt;/code&gt; API&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마찬가지로 특별한 꼼수를 쓰지 않는 이상 &lt;code&gt;ForkJoinPool.commonPool()&lt;/code&gt;에서 실행된다.&lt;br /&gt;&lt;code&gt;Arrays.parallelSort()&lt;/code&gt;나 &lt;code&gt;reduce()&lt;/code&gt;, &lt;code&gt;ConcurrentHashMap.search()&lt;/code&gt;등은 순수한 CPU 작업이므로 문제 될 게 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;병렬 스트림을 사용하는 경우 한정, &lt;code&gt;CompletableFuture&lt;/code&gt;와 마찬가지로 I/O 작업을 넣으면 시스템 병목을 일으킨다. 인메모리 CPU 계산에만 사용하자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모두 같은 &lt;code&gt;ForkJoinPool.commonPool()&lt;/code&gt;을 사용하므로 한 곳에서만 잘못 써도 풀을 공유하는 모든 로직에서 병목이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Virtual Thread&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞선 두 가지와 다르게 전용 &lt;code&gt;ForkJoinPool&lt;/code&gt;을 만들어 사용한다. &lt;code&gt;ForkJoinPool&lt;/code&gt;의 워커 스레드들이 가상 스레드에서 이야기하는 캐리어 스레드 역할을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상 스레드와 &lt;code&gt;ForkJoinPool&lt;/code&gt; 모두 커널 스레드의 컨텍스트 스위칭을 최소화하며 CPU를 남김없이 쓰자는 것은 동일하나, I/O를 감지(&lt;code&gt;ManagedBlocker&lt;/code&gt;)하면 JVM 스레드와 커널 스레드를 추가 할당하는 &lt;code&gt;ForkJoinPool&lt;/code&gt;과 달리 가상 스레드는 I/O를 미리 감지하고 가상 스레드만 교체한다는 측면에서 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;ForkJoinPool&lt;/code&gt;은 임의의 사용자 환경에서 범용적인 병렬도를 최대한 유지하며 CPU 집약적인 작업들을 처리하기 위해 설계되었다. &lt;code&gt;ManagedBlocker&lt;/code&gt; 또한 CPU 집약적 작업 과정에서 드물게 발생하는 I/O 로직을 구제하는 장치일 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ForkJoinPool&lt;/code&gt;을 사용하는 몇몇 라이브러리들을 사용하기보다 내부 코드를 열어본다면 성능을 위한 경합 최소화부터 Temporal Locality를 위한 한 번 고른 큐를 집중 공략하는 로직, &lt;code&gt;Unsafe&lt;/code&gt;를 통한 최소한의 메모리 가시성 확보나 하나의 &lt;code&gt;ctl&lt;/code&gt; 변수에 최대한 정보를 몰아두고 bitwise 연산을 활용하는 등의 마이크로 최적화까지 다양한 고민들을 공감할 수 있을 것이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;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;a href=&quot;https://www.dre.vanderbilt.edu/~schmidt/PDF/work-stealing-dequeue.pdf&quot;&gt;Chase and Lev. (2005). Dynamic Circular Work-Stealing Deque, SPAA 2005&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Amazon Corretto 21.0.9&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Java</category>
      <category>ForkJoinPool</category>
      <category>Java</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/62</guid>
      <comments>https://jaehee329.tistory.com/62#entry62comment</comments>
      <pubDate>Fri, 19 Dec 2025 13:58:24 +0900</pubDate>
    </item>
    <item>
      <title>WebDAV와 NGINX의 ngx_http_dav_module</title>
      <link>https://jaehee329.tistory.com/61</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;WebDAV&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;WebDAV는 HTTP 1.1 프로토콜의 확장 프로토콜이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DAV는 Distributed Authoring and Versiong의 약자로, HTTP보다 세부적인 웹 기반의 자원 관리와 관련된 표준이다.&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;아래의 예시처럼 HTTP에서는 볼 수 없는 특별한 메서드들이 존재한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;LOCK / UNLOCK: 자원에 대한 락 취득 / 해제&lt;/li&gt;
&lt;li&gt;MKCOL: 디렉터리 생성&lt;/li&gt;
&lt;li&gt;MOVE / COPY: 자원 이동 / 복사&lt;/li&gt;
&lt;li&gt;&lt;span&gt;PROPFIND / PROPPATCH: 자원에 정의된 프로퍼티 조회 / 수정&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;WebDAV의 장점&lt;/h3&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;HTTP 1.1과 호환되므로 익숙한 HTTP 관련 구현 기술들을 그대로 사용 가능&lt;/li&gt;
&lt;li&gt;보통 구현 기술들이 웹 서버와 같은 포트(80, 443)를 사용하여 웹 서버에 적용한 보안 등의 인프라를 그대로 적용 가능&lt;/li&gt;
&lt;li&gt;특정 자원에 대한 잠금이 가능하여 여러 사용자에 대한 동시 접근 관리 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;익숙한 HTTP로 서버 자원을 세밀하게 관리하는 게 목적에 맞게 사용이 간단하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 서버에서 자원의 위치를 변경하는 MOVE 메서드는&amp;nbsp;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;curl 명령에 단순히 HTTP Method만 수정하고 요청하면 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1738571370219&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# /old/file.txt를 /new/file.txt로 옮기는 예시
curl -X MOVE --header &quot;Destination: https://www.test.com/new/file.txt&quot; https://www.test.com/old/file.txt&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현 기술&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Microsoft Office나 오픈소스인 Libere Office 등 문서에 대한 동시 편집 기능이 필요한 기술에 기능이 구현되어 있으며, Apache Web Server, NGINX나 시놀로지 NAS 등 웹 요청을 통해 파일을 관리하는 기술들에도 구현이 존재한다.&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NGINX의 ngx_http_dav_module&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글의 목적인 NGINX에서의 WebDAV 관련 기능을 확인해보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;NGINX 공식 이미지에는 이미 Web DAV 모듈이 존재&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글 작성 시점의 NGINX &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;최신 안정화 버전인&lt;/span&gt; 1.26.2에는 이미 Web DAV 구현 모듈인 ngx_http_dav_module이 포함되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;881&quot; data-origin-height=&quot;366&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/B0fz9/btsL4L4eUzf/myAtwb0OjavFvUTMlSCeIk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/B0fz9/btsL4L4eUzf/myAtwb0OjavFvUTMlSCeIk/img.png&quot; data-alt=&quot;NGINX 1.26.2(stable) 이미지는 http_dav_module을 내장 중이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/B0fz9/btsL4L4eUzf/myAtwb0OjavFvUTMlSCeIk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FB0fz9%2FbtsL4L4eUzf%2FmyAtwb0OjavFvUTMlSCeIk%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;617&quot; height=&quot;256&quot; data-origin-width=&quot;881&quot; data-origin-height=&quot;366&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;NGINX 1.26.2(stable) 이미지는 http_dav_module을 내장 중이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적절히 NGINX 설정 파일을 활성화하기만 하면 WebDAV 기능을 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 공식 이미지를 통한 컨테이너가 아닌 직접 바이너리를 설치하는 경우, 바이너리에 ngx_http_dav_module이 포함되었는지 확인해야 하며 없다면 바이너리를 직접 빌드해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;사용 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시로, 설정 파일인 /etc/nginx/nginx.conf 또는 /etc/nginx/conf.d/*. conf의 server 블록을 아래와 같이 정의해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1738572898932&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location ~ &quot;/storage/([0-9a-zA-Z-.]*)$&quot; {
        dav_methods            PUT DELETE MKCOL COPY MOVE;
        client_body_temp_path  /storage/tmp/;
        alias                  /local/$1;
        create_full_put_path   on;
        dav_access             group:rw  all:r;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정에 대한 설명은 아래와 같다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;location: 정규식을 사용하여 HTTP URL이 /storage 하위인 경우 매칭&lt;/li&gt;
&lt;li&gt;dav_methods: WebDAV의 메서드 중 허용할 메서드를 선언. (default: off =&amp;gt; 모듈이 내장되어 있으나 기본 미사용되는 이유)&lt;/li&gt;
&lt;li&gt;client_body_temp_path: 전송 중 임시 파일이 잔류할 위치. 이 위치에 임시 파일이 생성된 이후 옮기는 방식으로 동작.&lt;/li&gt;
&lt;li&gt;alias: 실제 자원이 관리될 로컬 파일시스템 경로로 치환. URL이 /storage/text.txt 라면 바디의 내용이 /local/text.txt에 저장됨&lt;/li&gt;
&lt;li&gt;create_full_put_path: URL과 동일한 폴더 구조를 자동 생성할지 여부. (default: off)&lt;/li&gt;
&lt;li&gt;dav_access: 자원 생성 시 초기 부여되는 파일 권한. 위의 경우 그룹 권한으로는 읽기 / 쓰기, 전체로는 읽기 권한 부여&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 설정으로 띄운 NGINX에 curl을 사용해 아래처럼 요청을 보내면 /local 하위에 123이란 내용의 text.txt가 생성된다.&lt;/p&gt;
&lt;pre id=&quot;code_1738573442942&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;curl -X PUT -d &quot;123\n&quot; http://localhost/storage/text.txt&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;유의사항&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. PUT으로 파일 업로드 시 Content-Type을 Plain Text로 지정해야 한다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특별한 웹앱 서버처럼 동작하는 방식이 아니다 보니 Content-Type을 다르게 지정하는 경우 정상 저장 응답이 오나 실제 저장된 파일에 불필요한 데이터가 추가되는 경우가 존재했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;일반적인 웹앱처럼 multipart/form-data로 하나의 HTTP 요청으로 여러 파일들을 한 번에 저장하는 동작 등이 불가능한 것도 단점.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 가장 눈여겨보았던 &lt;b&gt;LOCK 관련 메서드나 자원의 프로퍼티 관리 관련 메서드는 지원하지 않는다.&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;56&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/R4i4y/btsL5QQ7DrI/42d8MIbDERlCYgZaA11VSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/R4i4y/btsL5QQ7DrI/42d8MIbDERlCYgZaA11VSK/img.png&quot; data-alt=&quot;ngx_http_dav_module docs&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/R4i4y/btsL5QQ7DrI/42d8MIbDERlCYgZaA11VSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FR4i4y%2FbtsL5QQ7DrI%2F42d8MIbDERlCYgZaA11VSK%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;792&quot; height=&quot;56&quot; data-origin-width=&quot;792&quot; data-origin-height=&quot;56&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ngx_http_dav_module docs&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebDAV 프로토콜의 DA(Distributed Authoring)을 위한 핵심 기능이라 생각해서 아쉬움에 찾아보니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애초에 WebDAV 표준인 RFC 2518, 4918에서 LOCK 등 일부 메서드에 대한 지원까지는 강제하지 않고 있었다.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;482&quot; data-origin-height=&quot;270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mUJf0/btsL47lNPrx/rFQGzZPC6pHOgR2xEhCCc1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mUJf0/btsL47lNPrx/rFQGzZPC6pHOgR2xEhCCc1/img.png&quot; data-alt=&quot;RFC 2518 - 6.2 Lock 메서드 지원은 필수가 아니다 (최신 표준인 RFC 4918에서도 동일)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mUJf0/btsL47lNPrx/rFQGzZPC6pHOgR2xEhCCc1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmUJf0%2FbtsL47lNPrx%2FrFQGzZPC6pHOgR2xEhCCc1%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;482&quot; height=&quot;270&quot; data-origin-width=&quot;482&quot; data-origin-height=&quot;270&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;RFC 2518 - 6.2 Lock 메서드 지원은 필수가 아니다 (최신 표준인 RFC 4918에서도 동일)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NGINX는 많은 사용자에 대한 빠른 정적 콘텐츠 서빙을 목표로 하는데, 스펙에 맞게 파일 별 S락, X락이나 자원의 프로퍼티들을 관리하려면 이 모듈을 위한 영속성 데이터를 관리해야 하므로 지원 범위를 제한한 것으로 보인다.&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 Apache의 WebDAV 모듈은 Lock과 관련한 고급 기능도 제공하는데, 이 기능을 위해 간단한 key-value 기반의 파일 DB(SDBM)를 설정해야 한다. 혹여 제대로 쓰려면 Apache 한계와 SDBM 관련 모듈을 원하는 DB로 커스텀할 수 있는지 등을 모두 고려해야 할 듯.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1087&quot; data-origin-height=&quot;186&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ctdvDd/btsL5l5jSlD/ElG9u7Ch8gdPzl4oYmch90/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ctdvDd/btsL5l5jSlD/ElG9u7Ch8gdPzl4oYmch90/img.png&quot; data-alt=&quot;https://httpd.apache.org/docs/2.4/ko/mod/mod_dav.html&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ctdvDd/btsL5l5jSlD/ElG9u7Ch8gdPzl4oYmch90/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FctdvDd%2FbtsL5l5jSlD%2FElG9u7Ch8gdPzl4oYmch90%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;1087&quot; height=&quot;186&quot; data-origin-width=&quot;1087&quot; data-origin-height=&quot;186&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://httpd.apache.org/docs/2.4/ko/mod/mod_dav.html&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NGINX 쪽에서는 비공식 모듈인 nginx-dav-ext-module에서 프로퍼티, Lock 관련 기능을 지원하긴 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 리스트로 정보를 관리해 락 점유 여부를 확인할 때 O(n)이 소요된다 하니.. 조금이라도 규모 있는 환경에서는 쓰면 안 되겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/arut/nginx-dav-ext-module&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/arut/nginx-dav-ext-module&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;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;아쉽게도 내장된 WebDAV 모듈은 이미 NGINX로 정적 파일 서빙을 하던 서버에 설정만 조금 바꿔서 정말 간단한 업로드 기능을 구현하는 정도로밖에 못 쓸 듯 하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 PUT 동작 방식으로 인해 파일 덮어쓰는 것을 방지하는 등의 로직을 커스텀하거나, Lock 기능이 본격적으로 필요하다면 별도의 솔루션 사용이나 웹앱 개발이 불가피해 보인다.&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;조금 더 찾아보고자 한다면 아래의 WebDAV 구현체 소개를 모아둔 프로젝트가 있으니 참고하면 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/fstanis/awesome-webdav?tab=readme-ov-file&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/fstanis/awesome-webdav?tab=readme-ov-file&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Reference&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://www.ietf.org/rfc/rfc2518.txt?number=2518&quot;&gt;&lt;br /&gt;&lt;/a&gt;&lt;a href=&quot;https://www.ietf.org/rfc/rfc2518.txt?number=2518&quot;&gt;https://www.ietf.org/rfc/rfc2518.txt?number=2518&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc4918&quot;&gt;https://datatracker.ietf.org/doc/html/rfc4918&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nginx.org/en/docs/http/ngx_http_dav_module.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nginx.org/en/docs/http/ngx_http_dav_module.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Web</category>
      <category>nginx</category>
      <category>webDAV</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/61</guid>
      <comments>https://jaehee329.tistory.com/61#entry61comment</comments>
      <pubDate>Wed, 5 Feb 2025 23:33:09 +0900</pubDate>
    </item>
    <item>
      <title>List 초기화와 MultipleBagFetchException 발생 원인</title>
      <link>https://jaehee329.tistory.com/58</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;일을 하면서 OneToMany 관계를 여럿 가지는 부모 엔티티를 자식과 함께 가져와야만 하는 경우들을 마주친다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;때로는 비즈니스 제약으로 규모는 크지 않으나 부모 - 자식1 - 자식2 등의 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;왜 hibernate는 단일 쿼리로의 다중 List 초기화를 방지하며,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 부모는 자식을 항상 Set이 아닌 List로 초기화할까?&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;환경: hibernate-core 6.5.3 Final&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;MultipleBagFetchException&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;하나의 쿼리에서 둘 이상의 List를 Fetch Join으로 초기화하는 것은 불가하다.&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1644&quot; data-origin-height=&quot;1284&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJEz2e/btsKKH3ksFq/7AcOeupPoCVfquSxZJxyyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJEz2e/btsKKH3ksFq/7AcOeupPoCVfquSxZJxyyk/img.png&quot; data-alt=&quot;설명에 사용할 객체의 연관 관계&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJEz2e/btsKKH3ksFq/7AcOeupPoCVfquSxZJxyyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJEz2e%2FbtsKKH3ksFq%2F7AcOeupPoCVfquSxZJxyyk%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;411&quot; height=&quot;321&quot; data-origin-width=&quot;1644&quot; data-origin-height=&quot;1284&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;설명에 사용할 객체의 연관 관계&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lazy Loading으로 Parent의 두 Child 컬렉션을 설정해둔 뒤 두 개의 Fetch Join으로 한 번에 연관된 데이터를 가져오려 하면 아래처럼 예외가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1731219963588&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;TypedQuery&amp;lt;Parent&amp;gt; query = entityManager.createQuery(
    &quot;select p from Parent p join fetch p.child1s c1 join fetch p.child2s c2&quot;, Parent.class);
try {
    Parent findParent = query.getSingleResult();
    ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1976&quot; data-origin-height=&quot;70&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RMtYR/btsKEnaWKKm/1jKzMz8M39VINHUaijP2Zk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RMtYR/btsKEnaWKKm/1jKzMz8M39VINHUaijP2Zk/img.png&quot; data-alt=&quot;MultipleBagFetchException 발생&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RMtYR/btsKEnaWKKm/1jKzMz8M39VINHUaijP2Zk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRMtYR%2FbtsKEnaWKKm%2F1jKzMz8M39VINHUaijP2Zk%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;1976&quot; height=&quot;70&quot; data-origin-width=&quot;1976&quot; data-origin-height=&quot;70&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;MultipleBagFetchException 발생&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 입장에서는 아래와 같은 쿼리를 예상했겠지만, multiple bags에 대한 조회는 불가능하다는 응답만이 반환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1731220282460&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;select
    ...
from
    parent p1_0 
join
    child1 c1_0 
        on p1_0.id=c1_0.parent_id 
join
    child2 c2_0 
        on p1_0.id=c2_0.parent_id&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hibernate는 런타임에 List를 PersistentBag라는 프록시로 대체하고 지연 로딩 시 사용 시점에 초기화하는데, Fetch Join을 사용하여 명시적으로 하나의 쿼리에서 초기화를 시도하면 MultipleBagsFetchException이 발생한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인: Cartesian Product를 인스턴스화하는 과정이 지나치게 비싸다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 예상되는 쿼리로 두 개의 컬렉션을 한 번에 가져왔다면 hibernate가 처리해야 할 ResultSet을 상상해 보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 개의 컬렉션에 각각 두 개의 자식이 포함된 한 부모를 가정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 85px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent_id&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent_name&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_id&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_name&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_id&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 컬렉션의 크기가 각각 n, m라면, Cartesian Product로 인해 총 n*m개의 row를 가진 Result Set을 가져올 것이고 Hibernate는 이를 인스턴스로 만들어 컬렉션을 채워나가야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 다중 컬렉션 초기화가 컬렉션의 개수에 따라 공간 복잡도가 선형적으로 증가하고, &lt;b&gt;OOM(Out Of Memory) 발생 가능성이 있어서 예외를 발생시킨다 말하면 맞는 설명일까?&lt;/b&gt; 만약 데이터의 크기가 크지 않다면 &lt;b&gt;사용자 판단 하에 사용해도 될 듯한데 원천 차단하는 이유는&lt;/b&gt; 무엇일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;여러 개의 Set은 한 번에 초기화해도 MultipleBagsFetchException이 발생하지 않는다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 상황에서 컬렉션의 자료 구조를 Set으로 변경하면 문제없이 원하는 쿼리가 수행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1731221449282&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
public class Parent {

    ...

    @OneToMany(mappedBy = &quot;parent&quot;)
    private Set&amp;lt;Child1&amp;gt; child1s = new HashSet&amp;lt;&amp;gt;();
//    private List&amp;lt;Child1&amp;gt; child1s = new ArrayList&amp;lt;&amp;gt;();

    @OneToMany(mappedBy = &quot;parent&quot;)
    private Set&amp;lt;Child2&amp;gt; child2s = new HashSet&amp;lt;&amp;gt;();
//    private List&amp;lt;Child2&amp;gt; child2s = new ArrayList&amp;lt;&amp;gt;();

    ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;594&quot; data-origin-height=&quot;750&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wGrDx/btsKDFKan6x/F6ni2Eofi6QxKrwdMcnVzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wGrDx/btsKDFKan6x/F6ni2Eofi6QxKrwdMcnVzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wGrDx/btsKDFKan6x/F6ni2Eofi6QxKrwdMcnVzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwGrDx%2FbtsKDFKan6x%2FF6ni2Eofi6QxKrwdMcnVzK%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;322&quot; height=&quot;407&quot; data-origin-width=&quot;594&quot; data-origin-height=&quot;750&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자료 구조만 바꾸었는데 원했던 데이터가 가져와진다는 것은 &lt;b&gt;Result Set의 크기로 인한 OOM이 문제가 아니라는 뜻&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자료 구조에 의한 객체 초기화의 시간 복잡도가 원인이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 예시에서의 Result Set의 자식 인스턴스들이 처리되는 흐름을 고민해 보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 85px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent_id&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent_name&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_id&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_name&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_id&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;parent&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child1_2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; height: 17px;&quot;&gt;child2_1&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;hibernate는 parent_id = 1인 인스턴스에 List 또는 Set을 프록시로 대체한 두 컬렉션을 넣어둔다.&lt;/li&gt;
&lt;li&gt;row 1: child1s 컬렉션에 child1_1이 &lt;b&gt;있는지 확인한다.&lt;/b&gt; 없으므로 생성한다.&lt;/li&gt;
&lt;li&gt;row 1: child2s 컬렉션에 child2_1이 &lt;b&gt;있는지 확인한다.&lt;/b&gt; 없으므로 생성한다.&lt;/li&gt;
&lt;li&gt;row 2: child1s 컬렉션에 child1_1이 &lt;b&gt;있는지 확인한다.&lt;/b&gt; 있으므로 넘어간다.&lt;/li&gt;
&lt;li&gt;row 2: child2s 컬렉션에 child2_2이 &lt;b&gt;있는지 확인한다.&lt;/b&gt; 없으므로 생성한다.&lt;/li&gt;
&lt;li&gt;...&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 눈치챘겠지만, 컬렉션을 채워 넣는 과정에서 있는지 확인하는 프로세스가 List(PersistentBag)와 Set(PersistentSet) 자료구조의 특성으로 인해 다르다.&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;List를 사용하는 경우, 두 컬렉션의 크기 곱(n * m)만큼의 Result Set을 처리하는 과정에서 기존에 존재하던 컬렉션 내의 값들을 매번 비교해야 하므로 &lt;b&gt;시간 복잡도는 2차 이상&lt;/b&gt;이 된다. 이는 컬렉션이 두 개일 때이며 컬렉션 수에 따라 지수 항 또한 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hibernate는 대규모 데이터로 인한 &lt;b&gt;메모리 문제를 우려한다기보다, 여러 List 초기화에 시간 복잡도가 엄청나 막는다&lt;/b&gt;고 보는 게 맞겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Set은 어떤 문제를 가지나?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터의 중복과 무관하게 일반적으로 List를 선택하는 이유는 무엇일까? Set은 값을 추가하는 경우 등에서 지연 로딩의 이점을 살리지 못하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 존재하던 부모를 조회하고, 자식을 하나 더 추가해 보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1731224234387&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;TransactionStatus tx2 = platformTransactionManager.getTransaction(
        TransactionDefinition.withDefaults());
try {
	// 부모를 조회하고,
    Parent findParent = entityManager.find(Parent.class, 1L);
    Child1 child1_2 = new Child1();
    child1_2.setName(&quot;child1_2&quot;);
    // 자식을 추가한다. (cascade를 추가하지 않아 직접 영속화한다)
    findParent.addChild1(child1_2);
    entityManager.persist(child1_2);
    entityManager.flush();
    entityManager.clear();
    ...&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;List&lt;br /&gt;&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;482&quot; data-origin-height=&quot;660&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Tyf7z/btsKDCzUELr/k0j4FKdhl2kPgYp7UsHBm1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Tyf7z/btsKDCzUELr/k0j4FKdhl2kPgYp7UsHBm1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Tyf7z/btsKDCzUELr/k0j4FKdhl2kPgYp7UsHBm1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTyf7z%2FbtsKDCzUELr%2Fk0j4FKdhl2kPgYp7UsHBm1%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;242&quot; height=&quot;331&quot; data-origin-width=&quot;482&quot; data-origin-height=&quot;660&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;Set&lt;br /&gt;&lt;br /&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;482&quot; data-origin-height=&quot;1058&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FA3Gj/btsKCdH7ehH/pKulWpa4wedMP6kaVvvzik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FA3Gj/btsKCdH7ehH/pKulWpa4wedMP6kaVvvzik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FA3Gj/btsKCdH7ehH/pKulWpa4wedMP6kaVvvzik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFA3Gj%2FbtsKCdH7ehH%2FpKulWpa4wedMP6kaVvvzik%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;221&quot; height=&quot;485&quot; data-origin-width=&quot;482&quot; data-origin-height=&quot;1058&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자식을 추가하는 과정에서 &lt;b&gt;List의 경우 중복을 고려할 필요가 없으므로 바로 데이터를 삽입&lt;/b&gt;하는 반면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Set의 경우 자료 구조의 특성상 &lt;b&gt;삽입 시점에 해싱을 통한 데이터 중복을 검사&lt;/b&gt;해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;List와 달리 지연 로딩&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;으로 값이&lt;span&gt; 비어있던 Set(PersistentSet)을 초기화하기 위해 모든 자식을 가져오는 조회 쿼리가 추가로 발생한다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;즉, &lt;b&gt;Set은 데이터 삽입 시 지연 로딩의 이점을 살리지 못한다&lt;/b&gt;.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;즉시 로딩으로 여러 컬렉션을 가진 부모를 조회하면 어떨까?&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스 특성 상 부모 - 자식이 언제나 함께 조회되어야 하는 상황이라면 즉시 로딩을 고려해 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 hibernate는 여러 List를 하나의 조회 쿼리로의 초기화를 방지하는 것을 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지연 로딩이었던 설정을 즉시 로딩으로 바꾸고 부모를 가져오면 어떤 쿼리가 발생할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1731226361342&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
public class Parent {

    ...

    @OneToMany(mappedBy = &quot;parent&quot;, fetch = FetchType.EAGER)
    private List&amp;lt;Child1&amp;gt; child1s = new ArrayList&amp;lt;&amp;gt;();

    @OneToMany(mappedBy = &quot;parent&quot;, fetch = FetchType.EAGER)
    private List&amp;lt;Child2&amp;gt; child2s = new ArrayList&amp;lt;&amp;gt;();

    ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;1016&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SpJu5/btsKDbW4MUk/Xbe5kq1o9EgTYukivVU7P1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SpJu5/btsKDbW4MUk/Xbe5kq1o9EgTYukivVU7P1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SpJu5/btsKDbW4MUk/Xbe5kq1o9EgTYukivVU7P1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSpJu5%2FbtsKDbW4MUk%2FXbe5kq1o9EgTYukivVU7P1%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;289&quot; height=&quot;489&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;1016&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 말했듯 여러 List에 대한 동시 조회는 비용이 너무 비싸므로, &lt;b&gt;알아서 하나의 List(PersistentBag)에 대해서만 초기화를 하도록 child1을 join 하여 가져오고, 별개의 쿼리로 child2를 가져온다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;하나는 List, 나머지는 Set인 경우에도 컬렉션 별로 다른 쿼리를 통해 초기화한다&lt;/li&gt;
&lt;li&gt;두 계층(Parent - Child1)이 아닌 더 많은 계층 (Parent - Child1 - ChildX..)의 경우에도 알아서 첫 Child1까지만 join으로 가져오고, 나머지 계층은 개별적인 쿼리로 가져온다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 &lt;b&gt;hibernate가 List 초기화 과정을 최대한 간소화하는 방향을 선택&lt;/b&gt;함을 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;b&gt;즉시 로딩은 구현체(hibernate)의 판단에 따라 하나의 쿼리로 묶이지 않는 경우도 있음&lt;/b&gt;을 알았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>JPA</category>
      <category>hibernate</category>
      <category>JPA</category>
      <category>multiplebagsfetchexception</category>
      <category>OneToMany</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/58</guid>
      <comments>https://jaehee329.tistory.com/58#entry58comment</comments>
      <pubDate>Sun, 10 Nov 2024 17:52:08 +0900</pubDate>
    </item>
    <item>
      <title>[InnoDB] 인덱싱되지 않은 컬럼으로 UPDATE 시 데드락이 발생한다</title>
      <link>https://jaehee329.tistory.com/57</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;결제 서비스를 만들고 테스트하던 중 인덱싱 되지 않은 컬럼을 검색 조건으로 활용한 Update에서, 단건의 요청 처리에 대해 문제가 없었으나 다수의 요청 동시 처리 시 데드락이 발생하는 상황을 경험했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;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;h2 data-ke-size=&quot;size26&quot;&gt;1. 데드락 확인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1720340675674&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;org.springframework.dao.TransientDataAccessResourceException: execute; SQL [UPDATE payment_orders
SET payment_order_status = :status, updated_at = CURRENT_TIMESTAMP
WHERE order_id = :orderId]; Deadlock found when trying to get lock; try restarting transaction&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링을 통해 어떤 로직을 테스트하던 중, &lt;b&gt;인덱싱되지 않은 컬럼을 검색 조건으로 UPDATE 쿼리가 동시에 다수 발생할 때 데드락&lt;/b&gt;으로 인해 실패하는 것을 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1720340622192&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;mysql&amp;gt; show engine innodb status;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데드락이 발생한 트랜잭션과 쿼리의 상세 정보를 확인하기 위해 DB에 접속하여 InnoDB의 상태를 출력하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1720340832990&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-07-07 07:18:17 281472619503424
*** (1) TRANSACTION:
TRANSACTION 5431, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 2
MySQL thread id 22, OS thread handle 281472562806592, query id 123 192.168.65.1 root updating
UPDATE payment_orders
SET payment_order_status = 'UNKNOWN', updated_at = CURRENT_TIMESTAMP
WHERE order_id = 'ce6d18a4-5ac0-4c27-b032-aba1c6225695'

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 3 page no 4 n bits 80 index PRIMARY of table `test`.`payment_orders` trx id 5431 lock mode S locks rec but not gap
Record lock, heap no 12 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
 0: len 8; hex 80000000000000bf; asc         ;;
...
 14: len 5; hex 99b3ce7490; asc    t ;;

Record lock, heap no 13 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
 0: len 8; hex 80000000000000c0; asc         ;;
...
 14: len 5; hex 99b3ce7490; asc    t ;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 3 page no 4 n bits 80 index PRIMARY of table `test`.`payment_orders` trx id 5431 lock_mode X waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
 0: len 8; hex 80000000000000b5; asc         ;;
...
 14: len 5; hex 99b3ce6ddc; asc    m ;;


*** (2) TRANSACTION:
TRANSACTION 5427, ACTIVE 0 sec fetching rows
mysql tables in use 1, locked 1
LOCK WAIT 8 lock struct(s), heap size 1128, 13 row lock(s), undo log entries 4
MySQL thread id 21, OS thread handle 281472567033664, query id 122 192.168.65.1 root updating
UPDATE payment_orders
SET payment_order_status = 'UNKNOWN', updated_at = CURRENT_TIMESTAMP
WHERE order_id = 'f4f0c58e-dfad-4b03-89f1-2114f3e4e9b7'

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 3 page no 4 n bits 80 index PRIMARY of table `test`.`payment_orders` trx id 5427 lock_mode X
Record lock, heap no 2 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
 0: len 8; hex 80000000000000b5; asc         ;;
...
 14: len 5; hex 99b3ce6ddc; asc    m ;;

Record lock, heap no 3 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
 0: len 8; hex 80000000000000b6; asc         ;;
...
 14: len 5; hex 99b3ce6ddc; asc    m ;;

Record lock, heap no 4 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
 0: len 8; hex 80000000000000b7; asc         ;;
...
 14: len 5; hex 99b3ce6ea9; asc    n ;;

Record lock, heap no 5 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
 0: len 8; hex 80000000000000b8; asc         ;;
...
 14: len 5; hex 99b3ce6ea9; asc    n ;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 3 page no 4 n bits 80 index PRIMARY of table `test`.`payment_orders` trx id 5427 lock_mode X waiting
Record lock, heap no 12 PHYSICAL RECORD: n_fields 15; compact format; info bits 0
 0: len 8; hex 80000000000000bf; asc         ;;
...
 14: len 5; hex 99b3ce7490; asc    t ;;

*** WE ROLL BACK TRANSACTION (1)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Transaction 5427과 Transaction 5431에서 각각 아래 쿼리를 실행하는 도중 데드락이 발생했다는 것을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1720341398845&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- TX 5427
SET payment_order_status = 'UNKNOWN', updated_at = CURRENT_TIMESTAMP
WHERE order_id = 'f4f0c58e-dfad-4b03-89f1-2114f3e4e9b7'

-- TX 5431
SET payment_order_status = 'UNKNOWN', updated_at = CURRENT_TIMESTAMP
WHERE order_id = 'ce6d18a4-5ac0-4c27-b032-aba1c6225695'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그 중 아래와 같은 부분들을 통해 실제 어떤 row에 대한 record lock인지도 특정할 수 있다. 각 줄은 TX 정보부터 해당 row의 각 컬럼이 무엇인지를 내포한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1720346386001&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; 0: len 8; hex 80000000000000b6; asc         ;;
...
 6: len 30; hex 64336433323034342d653432622d343833342d396338392d666662633562; asc d3d32044-e42b-4834-9c89-ffbc5b; (total 36 bytes);
...
 14: len 5; hex 99b3ce6ddc; asc    m ;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 db에 저장된 값들(UUID d3d32044-e42b-4834-9c89-ffbc5b) 등이 보인다.&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;b&gt;show processlist&lt;/b&gt; 명령으로 프로세스 및 스레드를 확인하면 이미 트랜잭션을 처리하는 스레드는 보이지 않음도 확인하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2.&amp;nbsp; 원인 분석&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;TX 5427은 heap no 2, 3, 4, 5에 대한 X lock을 가지고 heap no 12에 대한 X lock을 얻지 못해 대기,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TX 5431은 heap no 12, 13에 대한 S lock을 가지고 heap no 2에 대한 X lock을 얻지 못해 대기하는 중이다.&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;1. 같은 컬럼에 대한 검색 조건이 다른데 Record 락 경합이 발생한다는 점,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;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;두 가지 근거로 고민해보니 원인은 너무 단순했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;아직 테스트 중인 DB로 검색 조건인 order_id에 대한 인덱싱을 하지 않아 Full Scan을 수행&lt;/b&gt;, 다른 TX가 겹치며 문제가 발생한 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://medium.com/@im_zero/non-unique-index%EC%99%80-next-key-lock-b314379d9b0f&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/@im_zero/non-unique-index%EC%99%80-next-key-lock-b314379d9b0f&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 실험해보신 분이 있었는데, &lt;b&gt;non-indexed column에 대한 업데이트 쿼리 시 풀 스캔 후 수정 과정에서 모든 레코드에 X lock이 걸리게 된다&lt;/b&gt;. &lt;b&gt;X lock 취득은 레코드 단위로 순차적으로 일어나는데&lt;/b&gt;, 먼저 시작된 TX 5427이 레코드에 대한 X lock을 거는 도중 TX 5431가 이미 S lock을 잡고 있어 수행되지 못했고, 마찬가지로 TX 5431도 쓰기 작업을 위해 X lock을 취득을 시도할 때 데드락이 발생한 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(공식 문서에서 찾진 못했으나 UPDATE 쿼리의 경우 읽기 단계에서 S lock을, 이후 X lock으로 재취득하는 과정이 이뤄지는 듯)&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;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.4/en/innodb-locks-set.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dev.mysql.com/doc/refman/8.4/en/innodb-locks-set.html&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관련 근거는 위의 공식 문서에서 일부 확인 가능했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;A&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a style=&quot;background-color: #ffffff; color: #0074a3; text-align: start;&quot; href=&quot;https://dev.mysql.com/doc/refman/8.4/en/glossary.html#glos_locking_read&quot;&gt;locking read&lt;/a&gt;&lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;, an&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a style=&quot;background-color: #ffffff; color: #0074a3; text-align: start;&quot; href=&quot;https://dev.mysql.com/doc/refman/8.4/en/update.html&quot;&gt;UPDATE&lt;/a&gt;&lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;, or a&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a style=&quot;background-color: #ffffff; color: #0074a3; text-align: start;&quot; href=&quot;https://dev.mysql.com/doc/refman/8.4/en/delete.html&quot;&gt;DELETE&lt;/a&gt;&lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;generally set record locks on every index record that is scanned in the processing of an SQL statement.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;If you have no indexes suitable for your statement and MySQL must scan the entire table to process the statement, every row of the table becomes locked, which in turn blocks all inserts by other users to the table.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot;&gt;locking read, update, delete는 &lt;b&gt;SQL에 의해 스캔되는 모든 index record에 record lock을 건다&lt;/b&gt;. 구문에 사용될 &lt;b&gt;적절한 index가 없다면, MySQL은 테이블의 모든 row에 대한 lock (record)를 걸어&lt;/b&gt; 다른 사용자의 insert를 금지한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해소&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데드락 자체는 MySQL에서 알아서 감지하고 해소하여 수동으로 프로세스를 처리할 필요는 없었고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스를 추가하여 문제가 해소됨을 확인하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 조건에 인덱싱을 하는 건 너무 당연한 일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 인덱싱의 부재가 성능적 문제 외에 InnoDB의 특성으로 데드락에 걸릴 수 있다는 사실이 제법 흥미로웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거에 인덱싱되지 않은 컬럼으로 검색 시 모든 레코드에 락이 걸린다는 내용을 어렴풋이 본 것 같은데.. 실제로 테스트하며 마주친 덕분에 innodb의 동작도 다시 살펴볼 수 있었다.&lt;/p&gt;</description>
      <category>DB</category>
      <category>deadlock</category>
      <category>InnoDB</category>
      <category>MySQL</category>
      <category>record lock</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/57</guid>
      <comments>https://jaehee329.tistory.com/57#entry57comment</comments>
      <pubDate>Mon, 8 Jul 2024 00:58:52 +0900</pubDate>
    </item>
    <item>
      <title>@Async의 ThreadPoolTaskExecutor 설정하기</title>
      <link>https://jaehee329.tistory.com/56</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최신의 스프링 부트 애플리케이션에서 Async, Scheduled 등을 사용하면 가상 스레딩을 활용하지 않는 이상 ThreadPoolTaskExecutor가 구현체로 선택된다. 기본적인 설정, 종료 시점의 예약 작업 핸들링 설정 등을 설명한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Task Executor&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;스프링 어플리케이션에서 @EnableAsync 설정을 추가하고 @Async가 붙은 메서드를 런타임에 호출 시 Runnable 혹은 Callable의 형태로 스레드 풀의 Blocking Queue에 작업을 등록한 뒤 비동기로 처리된다.&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;AsyncExecutionInterceptor에 의해 요청마다 스레드를 새로 생성하는 SimpleAsyncTaskExecutor를 Executor로 등록한다.&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;스프링 부트에서는 Java 21 이상을 사용해 가상 스레드를 사용하도록 설정하는 경우에만(spring.threads.virtual.enabled = true) 스레드의 생성 작업이 비교적 가벼워 SimpleAsyncTaskExecutor를 기본 Task Executor로 등록, 이외에는 설정에 따라 풀링을 하는 ThreadPoolTaskExecutor를 등록한다.&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;h2 data-ke-size=&quot;size26&quot;&gt;ThreadPoolTaskExecutor&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;스프링에서 제공하는 Task Executor 클래스이나 내부적으로는 JDK의 스레드 풀인 &lt;b&gt;ThreadPoolExecutor&lt;/b&gt;를 사용하며 부가 기능을 일부 포함한다. 실제 풀링 기능의 동작 방식은 JDK의 ThreadPoolExecutor를 따른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1718440976049&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;protected ExecutorService initializeExecutor(ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler) {
    ...
    executor = new ThreadPoolExecutor(this.corePoolSize, this.maxPoolSize, (long)this.keepAliveSeconds, TimeUnit.SECONDS, queue, threadFactory, rejectedExecutionHandler);
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기본 설정과 동작 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot를 통해 기본 생성되는 ThreadPoolTaskExecutor의 주요 풀링 관련 설정(=ThreadPoolExecutor 설정)은 아래와 같다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;corePoolSize: 8&lt;/li&gt;
&lt;li&gt;maxPoolSize: Integer.MAX_VALUE&lt;/li&gt;
&lt;li&gt;queueCapacity: Integer.MAX_VALUE&lt;/li&gt;
&lt;li&gt;keepAliveSeconds: 60&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 환경에서의 동작 방식은 ThreadPoolExecutor의 JavaDoc을 보면 자세히 알 수 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;런타임에서는 최초 크기 0의 스레드 풀로 시작한다.&lt;/li&gt;
&lt;li&gt;스레드 풀에 작업이 등록되면 corePoolSize까지의 요청은 바로 신규 스레드가 생성되어 처리된다.&lt;/li&gt;
&lt;li&gt;corePoolSize 이상의 요청이 동시 등록되면 Blocking Queue에 우선 작업이 쌓인다.&lt;/li&gt;
&lt;li&gt;Blocking Queue 크기 이상의 작업이 요청되었다면 순차적으로 maxPoolSize 크기 이하의 스레드를 생성하여 작업들을 할당한다.&lt;/li&gt;
&lt;li&gt;설정된 한도만큼의 스레드를 사용 중이며 Blocking Queue도 가득 찼으나 새로운 작업이 등록되면 RejectedExecutionHandler에 의해 처리된다(기본: RejectedExecutionException 발생)&lt;/li&gt;
&lt;li&gt;corePoolSize 이상으로 늘어난 스레드는 이후 요청이 감소하면 keepAliveSeconds만큼 대기했다가 정리된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유의할 점은 corePoolSize만큼의 스레드가 동시 처리를 하는 중 신규 작업이 추가되었을 때 maxPoolSize까지 신규 스레드를 생성하는 것이 아닌, Blocking Queue에 작업을 우선적으로 등록한다는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만&lt;b&gt; queueCapacity가 기본 Integer.MAX_VALUE이므로 기본 설정에서는 corePoolSize 크기 이상의 스레드가 할당될 일이 사실상 없고 모두 Blocking Queue에서 대기&lt;/b&gt;하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로 인해 기본 설정에서는 동시 요청이 많은 순간 @Async 메서드 콜이 몰려도 8개의 스레드에서만 작업을 처리해 비동기 작업이 지연될 수 있다.&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;h3 data-ke-size=&quot;size23&quot;&gt;설정 변경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 가지 방법이 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yaml 혹은 application.properties의 spring.task.execution.pool 설정을 바꿔 spring boot가 지정한 설정의 pool을 생성하도록 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1718442548228&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# application.yaml
spring:
  task:
    execution:
      pool:
        max-size: 16
        queue-capacity: 100
        keep-alive: &quot;10s&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;혹은 설정 파일에서 Executor 관련 Bean을 직접 주입하면 @Async 처리 과정에서 이 스레드 풀을 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래의 방법으로는 스레드 풀을 여럿 설정하여 @Async 호출 시 원하는 스레드 풀을 사용하도록 선택할 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1718442488454&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Config
public class BeanConfig {

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
        taskExecutor.setCorePoolSize(8);
        taskExecutor.setQueueCapacity(Integer.MAX_VALUE);
        taskExecutor.setMaxPoolSize(Integer.MAX_VALUE);
        taskExecutor.initialize();
        return taskExecutor;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 요청이 급증할 때 Blocking Queue를 거치지 않고 바로 신규 스레드를 생성하여 비동기 작업들을 빠르게 처리해야 한다면 queueCapacity를 0으로 설정하면 된다.&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;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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완전하진 않지만 아래의 두 설정을 통해 이미 예약된 작업들의 처리를 어느 정도 수행할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;setWaitForTasksToCompleteOnShutDown()&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;true로 설정 시  스레드 풀을 종료하는 과정에서 ExecutorService의 shutdown()을 수행, 기본 값 false로 설정 시 shutdownNow()를 수행한다.&lt;/p&gt;
&lt;pre id=&quot;code_1733036278590&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ExecutorConfigurationSupport.class
public void shutdown() {
	...
    if (this.executor != null) {
        if (this.waitForTasksToCompleteOnShutdown) {
            this.executor.shutdown();
        } else {
            Iterator var1 = this.executor.shutdownNow().iterator();
            ...
        }
        this.awaitTerminationIfNecessary(this.executor);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1260&quot; data-origin-height=&quot;446&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dMioqW/btsK3wsfyPg/9lkAQKScHTeazJHbUmRUy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dMioqW/btsK3wsfyPg/9lkAQKScHTeazJHbUmRUy0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dMioqW/btsK3wsfyPg/9lkAQKScHTeazJHbUmRUy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdMioqW%2FbtsK3wsfyPg%2F9lkAQKScHTeazJHbUmRUy0%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;704&quot; height=&quot;249&quot; data-origin-width=&quot;1260&quot; data-origin-height=&quot;446&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ExecutorService의 shutdown()은 &lt;b&gt;예약된 작업들의 완료를 보장하지는 않으며&lt;/b&gt;, 신규 작업의 예약만 추가적으로 받아들이지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 ExecutorService의 shutdownNow()는 처리 중 및 &lt;b&gt;대기 중인 작업 모두를 중단 시도하고 중단된 작업들을 반환&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;setAwaitTerminationMillis(), setAwaitTerminationSeconds()&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 ExecutorConfigurationSupport 클래스에서 볼 수 있듯, 스레드 풀을 종료하는 과정에서는 awaitTerminationIfNecessary가 호출된다. 이 곳이 지정된 시간만큼 처리 중인 작업들이 완료되기를 기다리는 블로킹 구간이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 메서드로 설정한 시간만큼 작업들의 완료를 기다리며, 시간이 초과될 시 할당되었던 스레드는 Interrupt 시킨다.&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;스레드 풀을 닫을 때 &lt;b&gt;이미 등록된 작업의 무조건적 완료를 보장하는 방법은 제공하지 않으며&lt;/b&gt;, 위의 두 가지 설정을 조합하여 지정된 최대 시간 동안의 대기까지는 허용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;추가적인 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이외에도 부가적인 ThreadPoolTaskExecutor 설정 몇 개를 소개한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;setTaskDecorator()&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TaskDecorator 설정을 추가하면 스레드 풀을 통해 작업이 처리될 때 decorator의 runnable이 추가적으로 수행된다. (기본 null)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1718443175951&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;protected ExecutorService initializeExecutor(ThreadFactory threadFactory, RejectedExecutionHandler rejectedExecutionHandler) {
    ...
    if (this.taskDecorator != null) {
        executor = new ThreadPoolExecutor(this.corePoolSize, this.maxPoolSize, (long)this.keepAliveSeconds, TimeUnit.SECONDS, queue, threadFactory, rejectedExecutionHandler) {
            public void execute(Runnable command) {
                Runnable decorated = ThreadPoolTaskExecutor.this.taskDecorator.decorate(command);
                if (decorated != command) {
                    ThreadPoolTaskExecutor.this.decoratedTaskMap.put(decorated, command);
                }

                super.execute(decorated);
            }
        };
    }
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일례로, 아래와 같이 사용 시 스레드 풀의 작업 처리 과정에서 스레드 별 로그가 출력된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1718443366931&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;TaskDecorator taskDecorator = runnable -&amp;gt; () -&amp;gt; log.debug(&quot;decorator&quot;);
taskExecutor.setTaskDecorator(taskDecorator);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;setAllowCoreThreadTimeOut()&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 옵션 false에서 corePool 크기 이상으로 만들어진 스레드는 유휴 상태에서 keepAliveSeconds(기본 60초) 이후 자동으로 정리되나,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;corePool 크기 만큼의 스레드들(Core Thread)은 생성 이후 정리되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 옵션을 true로 설정 시 Core Thread 또한 유휴 상태가 지속되면 정리된다.&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;h4 data-ke-size=&quot;size20&quot;&gt;setPrestartAllCoreThreads()&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 설명했듯, 기본 옵션 false에서는 최초 런타임에서는 비동기 요청이 시작되어야 Core Thread들이 생성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;true로 설정 시 마치 HikariCP와 같은 Connection Pool이 미리 pool size만큼의 커넥션을 최초 런타임에 한 번에 맺듯,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;런타임 초기에 Core Thread들을 corePoolSize만큼 바로 생성해 서버의 warm up 영향을 줄일 수 있다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Reference&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-framework/blob/5e808ad0183a63944ab08017a13cbc7ed75bc581/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionInterceptor.java#L156&quot;&gt;https://github.com/spring-projects/spring-framework/blob/5e808ad0183a63944ab08017a13cbc7ed75bc581/spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionInterceptor.java#L156&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/reference/integration/scheduling.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-framework/reference/integration/scheduling.html&lt;/a&gt;&lt;/p&gt;</description>
      <category>Web/Spring</category>
      <category>async</category>
      <category>Spring</category>
      <category>ThreadPoolExecutor</category>
      <category>ThreadPoolTaskExecutor</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/56</guid>
      <comments>https://jaehee329.tistory.com/56#entry56comment</comments>
      <pubDate>Sat, 15 Jun 2024 19:47:04 +0900</pubDate>
    </item>
    <item>
      <title>컨테이너에서의 사용자 프로세스 제한과 UID</title>
      <link>https://jaehee329.tistory.com/55</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;회사에서 한 장비에 수십 개의 도커 컨테이너를 동시에 운용할 상황이 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너 각각에서 작업이 이루어지고 ssh를 통해 접속해서 확인할 수 있도록 구성하였는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너가 많아지자 모든 컨테이너로의 ssh 접속이 불가해졌다는 연락을 받았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ssh -vv로 로그를 확인하니 접속 시 인증 단계는 정상적으로 넘어가나, 셸 할당이 실패하는 상황이었다.&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;결론적으로는 ssh 접속 시의 사용자에 대한 프로세스 제한을 늘려주어 임시로 해소하였고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 컨테이너에서의 사용자 UID를 구분해 주어 근본적인 문제를 해결하였다.&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;컨테이너에서의 ulimit -a로 프로세스 제한을 확인했을 때는 분명 unlimited로 표시되었고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 자원은 충분하며 각 컨테이너에서의 프로세스/스레드는 수백 개 수준이었는데도 프로세스 생성이 불가하여 원인을 찾는데 시간이 꽤 걸렸다. 문제를 해결하며 덕분에 컨테이너에서의 사용자 UID를 구분해야 함을 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다른 컨테이너에서 같은 UID의 사용자 중 하나로 스레드 초과 실행하기&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;상황 재현을 위해 외부에서 ssh로 접속 가능한 컨테이너 두 개를 띄우고, 한 쪽에서만 스레드를 많이 띄워보자.&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;아래와 같이 `start.sh`와 Dockerfile을 만들고 컨테이너를 띄운다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711264335481&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#!/bin/bash

# start.sh

/usr/sbin/sshd

tail -f /dev/null&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSH 데몬을 직접 실행시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711264400614&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# Dockerfile
FROM centos:7

COPY start.sh /start.sh
RUN chmod +x /start.sh

RUN yum -y install openssh-server &amp;amp;&amp;amp; \
      yum clean all

RUN ssh-keygen -A

RUN useradd appuser &amp;amp;&amp;amp; \
      echo &quot;appuser:1234&quot; | chpasswd

ENTRYPOINT [&quot;/start.sh&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 appuser라는 사용자를 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711265007056&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;docker build -t test .
docker run -d -p 10001:22 --name cont1 test
docker run -d -p 10002:22 --name cont2 test

ssh appuser@localhost -p10001 # 1234
# [appuser@server ~]$&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 도커파일을 통해 cont1, cont2라는 컨테이너를 생성하였고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 ssh 접속도 가능함을 확인 가능하다.&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;이제 &lt;b&gt;두 번째 컨테이너&lt;/b&gt;에 아래와 같이 더미 스레드를 생성하는 스크립트를 만들고 실행하자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(리눅스에서 &lt;a href=&quot;https://stackoverflow.com/questions/344203/maximum-number-of-threads-per-process-in-linux/344292#344292&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;프로세스와 스레드는 동일하게 취급&lt;/a&gt;된다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711265731573&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#!/bin/bash

count=&quot;$1&quot;

process_count=0

for ((i = 0; i &amp;lt; count; i++)); do
    sleep 3600 &amp;amp;

    process_count=$((process_count + 1))
done&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711265816440&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# docker exec로 접속하여 실행
docker exec -u appuser -it cont2 /bin/bash
[appuser@server /]$ vi limit.sh
[appuser@server /]$ ./limit.sh 5000 # 5000개의 스레드 생성
[appuser@server /]$ ps aux | wc -l
# &amp;gt;&amp;gt; 5007&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;더미 스레드는 두 번째 컨테이너에 생성&lt;/b&gt;했지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 컨테이너 뿐만 아니라 &lt;b&gt;첫 번째 컨테이너에서도 ssh 접속이 셸 할당 단계에서 불가능&lt;/b&gt;해진다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711266007081&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ssh -vv appuser@localhost -p10001 # 프로세스가 몇 없는 1번 컨테이너로 접속

# &amp;gt;&amp;gt; ...
# &amp;gt;&amp;gt; debug2: PTY allocation request accepted on channel 0
# &amp;gt;&amp;gt; debug2: channel_input_status_confirm: type 100 id 0
# &amp;gt;&amp;gt; shell request failed on channel 0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 Dockerfile에서 생성한 사용자의 UID가 동일한 것이 원인이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 컨테이너 모두 `appuser` 일반 사용자로 접속해 UID를 확인해 보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711266145865&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[appuser@server /]$ id
# &amp;gt;&amp;gt; uid=1000(appuser) gid=1000(appuser) groups=1000(appuser)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도의 설정을 하지 않는다면 같은 UID를 가진 사용자가 만들어질 수 있다.&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;SSH 접속 과정에서 PAM(&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;Pluggable Authentication Modules) 인증을 거치는데 이 과정에서 각 사용자가 생성 가능한 프로세스/스레드 수를 검사하게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;제한 관련 설정은 리눅스 배포판마다 위치가 조금씩 다른데, centos7의 경우 /etc/security/limits.d/ 의 20-proc.conf에 존재한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0d0d0d;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;redhat 계열의 linux는 이름이 다르지만 대부분 이 폴더에 존재하며, 보통 /etc/security에서의 설정들이 영향을 미친다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711266545319&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 20-nproc.conf
# Default limit for number of user's processes to prevent
# accidental fork bombs.
# See rhbz #432903 for reasoning.

*          soft    nproc     4096
root       soft    nproc     unlimited&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;root 이외의 사용자(*)는 프로세스 수 제한이 4096개로 기본 설정되어 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;&lt;b&gt;root 계정으로만 설정 가능한 hard 옵션에서 지정한 범위 이하로&amp;nbsp;일반 사용자도 자신의 최대 프로세스 수를 soft옵션으로 제한&lt;/b&gt; 가능하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;기본 수치는 리눅스 배포판마다 다르며 aws와 같은 서비스를 이용하는 경우 인스턴스 사양에 따라 적절히 조절된 채로 제공되니 확인이 필요하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;기본 사용자의 상한이 4096이었으므로 스레드를 5000개 이상 실행해 버린 2번 컨테이너에는&lt;b&gt; ssh 접속 시 새로운 셸, 즉 새로운 프로세스가 appuser라는 일반 사용자 이름으로 할당되는 시점에 제한에 막혀&lt;/b&gt; 접속 불가 처리된 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;같은 UID의 일반 사용자를 통해 1번 컨테이너에 접속이 불가능한 이유는 뭘까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너는 다르나 UID가 같으면 사용자에 대한 프로세스/스레드 수가 검사될 때 이 수가 합산되기 때문이다.&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;컨테이너를 사용하면 호스트의 커널을 공유하게 되며 UID, GID 풀은 전역으로 단 하나만 공유, 관리된다.&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;`appuser` 사용자로 컨테이너 1에 ssh 접속을 시도하면 아래의 과정을 거쳐 실패하는 듯하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 호스트에서 ssh 요청으로 인해 새로운 셸 프로세스를 생성할 때 컨테이너 내의 사용자 `appuser`의 UID 1000으로 프로세스 수를 검색,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 글로벌하게 UID 1000으로 생성된 프로세스가 5000개 이상임이 확인,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 컨테이너 1의 `&lt;span style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: start;&quot;&gt;20-proc.conf&lt;/span&gt;`를 참조, root 이외 사용자는 4096개의 프로세스만 생성 가능하므로 셸 프로세스 생성 실패&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉, 단 하나의 프로세스를 같은 UID의 일반 사용자로 실행하는 컨테이너를 4096개 생성하면,&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;모든 컨테이너에서 해당 사용자로의 프로세스 생성이 불가능&lt;/b&gt;해지는 것이다.&lt;/p&gt;
&lt;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;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 2번 컨테이너에만 `appuser`의 프로세스 제한을 해제하면 어떻게 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711268024032&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# container2 20-nproc.conf

# Default limit for number of user's processes to prevent
# accidental fork bombs.
# See rhbz #432903 for reasoning.

*          soft    nproc     4096
root       soft    nproc     unlimited
appuser    soft    nproc     unlimited # appuser 사용자에 대한 process soft limit 해제&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1711268208580&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 2번 컨테이너에 일반 사용자로 접속 시도 (성공)
ssh appuser@localhost -p10002
# &amp;gt;&amp;gt; Last login: ...
[appuser@server ~]$ ps aux | wc -l
# &amp;gt;&amp;gt; 5009

# 1번 컨테이너에 일반 사용자로 접속 시도 (실패)
ssh appuser@localhost -p10001
# &amp;gt;&amp;gt; shell request failed on channel 0

# 1번 컨테이너에 루트 사용자로 접속 시도 (성공)
ssh root@localhost -p10001
[root@server ~]$ ps aux | wc -l
# &amp;gt;&amp;gt; 7&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5000개 이상의 프로세스를 실행 중인 2번 컨테이너에 `appuser`로는 접속이 가능해지고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단 7개의 프로세스를 실행 중인 1번 컨테이너에는 억울하게도 여전히 `appuser`로 접근이 불가하다.&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;root 사용자의 경우 프로세스 수의 제한이 없으므로 1번 컨테이너에도 접속이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;일반 사용자들의 UID 자체를 다르게 가져가거나, 컨테이너마다 이 제한을 매번 해제해주면 문제가 해결됨을 알 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로, docker exec로 /bin/bash에 진입해 스크립트를 돌리는 상황에서는 PAM 인증을 거치지 않아 일반 사용자로도 제한 이상의 프로세스를 생성할 수 있다.&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;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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시는 SSH로의 접속을 들었으나, 같은 UID의 일반 사용자로 새로운 프로세스를 생성하는 모든 작업에선 컨테이너가 많아진다면 문제가 발생할 수 있다.&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;동일한 커널을 공유하는 컨테이너 기술에서는 같은 UID를 가지는 사용자를 활용하는 것은 위험하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너들에서 &lt;b&gt;일반 사용자를 추가할 때 동적으로 다른 UID를 가지도록 관리해야 안전&lt;/b&gt;하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Reference&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://access.redhat.com/solutions/30316&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://access.redhat.com/solutions/30316&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://medium.com/@mccode/understanding-how-uid-and-gid-work-in-docker-containers-c37a01d01cf&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/@mccode/understanding-how-uid-and-gid-work-in-docker-containers-c37a01d01cf&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://techblog.woowahan.com/2569/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://techblog.woowahan.com/2569/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>UID</category>
      <category>ulimit</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/55</guid>
      <comments>https://jaehee329.tistory.com/55#entry55comment</comments>
      <pubDate>Sun, 24 Mar 2024 17:50:02 +0900</pubDate>
    </item>
    <item>
      <title>git diff, patch 사용법</title>
      <link>https://jaehee329.tistory.com/54</link>
      <description>&lt;p&gt;사내에서 diff와 patch를 다룰 일이 잦아 관련 내용을 정리해보고자 한다.&lt;br&gt;&lt;code&gt;git version 2.39.2 (Apple Git-143)&lt;/code&gt;, &lt;code&gt;patch 2.0-12u11-Apple&lt;/code&gt;에서 확인한 내용이며 버전에 따라 출력 형식은 조금 변할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;diff?&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;git diff&lt;/code&gt;라는 명령으로 사용 가능하다.&lt;/p&gt;
&lt;p&gt;깃의 워킹 디렉토리와 스테이징 영역을 비교하거나, 특정 커밋과 비교하거나, 로컬과 원격 깃 레포지토리를 비교하는 명령이다.  &lt;/p&gt;
&lt;h2&gt;diff 사용법&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;diff&lt;/code&gt;라는 폴더에 &lt;code&gt;first.txt&lt;/code&gt;라는 파일을 생성해 임의의 최초 커밋을 생성한 다음,&lt;/p&gt;
&lt;p&gt;&lt;code&gt;second.txt&lt;/code&gt;라는 파일을 생성한 뒤 &lt;code&gt;git log&lt;/code&gt;를 확인한 결과는 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# git log
commit 8d475c23f8ac7f857ca6f1ab7790f034467e8e55 (HEAD -&amp;gt; main)
Author: ... &amp;lt;...@gmail.com&amp;gt;
Date:   Sun Feb 4 15:51:24 2024 +0900

    second.txt

commit a9720f836dd6385594dd0e3ac53537c3f63acf03
Author: ... &amp;lt;...@gmail.com&amp;gt;
Date:   Sun Feb 4 15:47:16 2024 +0900

    create first.txt&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;git diff HEAD~1..HEAD&lt;/code&gt; 로 &lt;code&gt;HEAD&lt;/code&gt;에 위치한 가장 최근 커밋과 첫 번째 커밋을 비교할 수 있다.&lt;br&gt;&lt;code&gt;HEAD&lt;/code&gt;대신 원하는 커밋의 커밋 해시(&lt;code&gt;SHA&lt;/code&gt;)를 지정하여 비교할 수도 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# git diff HEAD~1..HEAD, HEAD의 1개 이전 커밋과 HEAD를 비교, 커밋 해시로도 비교 가능
diff --git a/second.txt b/second.txt
new file mode 100644
index 0000000..86a2422
--- /dev/null
+++ b/second.txt
@@ -0,0 +1 @@
+create second.txt&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;어떤 디렉토리에 어떤 내용의 파일이 변경되었는지 알려준다.  &lt;/p&gt;
&lt;h3&gt;&lt;code&gt;a/&lt;/code&gt;, &lt;code&gt;b/&lt;/code&gt; ??&lt;/h3&gt;
&lt;p&gt;실제 파일 디렉토리와 무관하게 디렉토리 앞에 &lt;code&gt;a/&lt;/code&gt;, &lt;code&gt;b/&lt;/code&gt;가 붙는 것을 볼 수 있다.&lt;/p&gt;
&lt;p&gt;이는 git이 변경 사항을 관리하기 위해 임의로 붙이는 prefix로, 변경 전 디렉토리에는 &lt;code&gt;a/&lt;/code&gt;를, 변경 후 디렉토리에는 &lt;code&gt;b/&lt;/code&gt;를 자동으로 붙여 표현하게 된다.&lt;/p&gt;
&lt;p&gt;이러한 prefix 없이 실제 디렉토리만을 표현하고자 한다면 &lt;code&gt;--no-prefix&lt;/code&gt; 옵션을 붙이면 된다  &lt;/p&gt;
&lt;h3&gt;&lt;code&gt;--no-prefix&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;git diff시 출력에 디렉토리에 자동으로 변경 전, 후를 나타내기 위해 붙는 &lt;code&gt;a/&lt;/code&gt;, &lt;code&gt;b/&lt;/code&gt;와 같은 prefix를 제거할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--no-prefix&lt;/code&gt;를 추가해 diff를 출력한 결과는 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# git diff HEAD~1..HEAD --no-prefix
diff --git second.txt second.txt # 옵션 없이는 diff --git a/second.txt b/second.txt 로 출력되었음
new file mode 100644
index 0000000..86a2422
--- /dev/null
+++ second.txt # 옵션 없이는 b/second.txt로 출력되었음.
@@ -0,0 +1 @@
+create second.txt&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;file mode?&lt;/h3&gt;
&lt;p&gt;새로운 변경에 대한 종류 및 권한 또한 표시되는 것을 볼 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;new file mode 100644&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;100644&lt;/code&gt;는 파일의 유형 및 권한을 8진수로 표현한 것이다.&lt;/p&gt;
&lt;p&gt;앞의 &lt;code&gt;100&lt;/code&gt;은 변경의 유형, 뒤의 &lt;code&gt;644&lt;/code&gt;는 해당 파일의 권한을 나타낸다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;100&lt;/code&gt;은 일반 파일, &lt;code&gt;644&lt;/code&gt;는 &lt;code&gt;-rw-r--r--&lt;/code&gt;로 소유자의 읽기 쓰기 권한, 그룹의 읽기 권한, 다른 사용자는 읽기 권한을 가진 파일임을 나타낸다.  &lt;/p&gt;
&lt;h3&gt;index?&lt;/h3&gt;
&lt;p&gt;변경되는 부분이 &lt;strong&gt;로컬에서&lt;/strong&gt; 어떤 해시를 부여받아 추적되는지 나타낸다.&lt;/p&gt;
&lt;p&gt;위에서는 새로운 파일을 생성하는 경우이므로 기존 해시가 &lt;code&gt;0000000&lt;/code&gt;였고 새로운 해시가 부여되는 것을 볼 수 있다.&lt;/p&gt;
&lt;p&gt;git 버전에 따라 file mode와 index가 같은 행에 표시되는 경우가 존재한다.  &lt;/p&gt;
&lt;h3&gt;&lt;code&gt;/dev/null&lt;/code&gt;?&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;git&lt;/code&gt;으로 관리되는 프로젝트의 루트 디렉토리에 바로 파일을 생성했음에도 &lt;code&gt;/dev/null&lt;/code&gt;이라는 파일이 사라지고 생성한 파일이 대체한 것으로 표현된다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/dev/null&lt;/code&gt;은 유닉스 시스템에서 특별하게 다뤄지는 파일로 데이터를 버리고 아무 동작도 하지 않는 파일을 의미한다.&lt;/p&gt;
&lt;p&gt;실제로 우리가 생성한 파일과 관계가 없다.  &lt;/p&gt;
&lt;h2&gt;파일 생성, 수정, 삭제, 이름 변경 시의 diff&lt;/h2&gt;
&lt;p&gt;파일 생성, 수정, 삭제 시의 diff 형태를 한눈에 보면 아래와 같다.&lt;/p&gt;
&lt;p&gt;기존의 &lt;code&gt;first.txt&lt;/code&gt;는 삭제, &lt;code&gt;second.txt&lt;/code&gt;는 수정, &lt;code&gt;third.txt&lt;/code&gt;라는 파일은 새로 생성했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 생성, 수정, 삭제 시의 diff
diff --git a/first.txt b/first.txt
deleted file mode 100644
index cbd2c53..0000000
--- a/first.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-create first file
-
diff --git a/second.txt b/second.txt
index 86a2422..3dcf956 100644
--- a/second.txt
+++ b/second.txt
@@ -1 +1,5 @@
+prefix
+
 create second.txt
+
+postfix
diff --git a/third.txt b/third.txt
new file mode 100644
index 0000000..a1c5d2c
--- /dev/null
+++ b/third.txt
@@ -0,0 +1,2 @@
+create third.txt
+&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code&gt;# 이름 변경 시의 diff
diff --git a/second.txt b/second_new_name.txt
similarity index 100%
rename from second.txt
rename to second_new_name.txt&lt;/code&gt;&lt;/pre&gt;&lt;br&gt;

&lt;hr&gt;
&lt;h2&gt;patch&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;git diff&lt;/code&gt;를 통해 얻어낸 변경 사항을 &lt;code&gt;patch&lt;/code&gt; 라는 별도의 프로그램을 통해 &lt;code&gt;git&lt;/code&gt;과 무관하게 적용할 수 있다.&lt;/p&gt;
&lt;p&gt;patch를 사용하면 &lt;code&gt;변경 내역&lt;/code&gt;을 &lt;code&gt;파일로 관리&lt;/code&gt;, 손쉽게 변경을 적용할 수 있다.&lt;/p&gt;
&lt;p&gt;두 사람의 &lt;code&gt;git&lt;/code&gt;디렉토리에 &lt;code&gt;first.txt&lt;/code&gt;만 있는 상황을 가정,&lt;/p&gt;
&lt;p&gt;나의 로컬에서의 변경점을 원격 git 레포지토리 활용 없이 타인의 로컬에 반영하려고 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#ls
first.txt&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;어떠한 작업을 수행하여 결과로 &lt;code&gt;second.txt&lt;/code&gt;가 생성되었고 그 내용을 커밋하였다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;diff&lt;/code&gt;를 출력하면 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# git diff HEAD~1..HEAD
diff --git a/second.txt b/second.txt
new file mode 100644
index 0000000..86a2422
--- /dev/null
+++ b/second.txt
@@ -0,0 +1 @@
+create second.txt&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이 &lt;code&gt;diff&lt;/code&gt;를 단순히 콘솔에 출력하지 않고 파일의 형태로 만들어내면 &lt;code&gt;patch&lt;/code&gt;프로그램에 이를 그대로 적용 가능하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;diff&lt;/code&gt;가 곧 &lt;code&gt;patch file&lt;/code&gt;이 된다.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 패치 파일 생성
git diff HEAD~1 HEAD &amp;gt; ../patch_file # 생성을 원하는 디렉토리 및 파일 명 입력&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;당연히 파일의 내용은 &lt;code&gt;diff&lt;/code&gt;로 출력한 내용과 동일하다.&lt;/p&gt;
&lt;p&gt;이제 이 패치 파일을 타인의 로컬에 그대로 전달하고,&lt;br&gt;아래와 같이 패치 파일 표준 입력으로 넣어주기만 하면 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# patch 적용 전 ls
first.txt&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code&gt;# patch 적용
# patch -p1 &amp;lt; ../patch_file
patching file second.txt&lt;/code&gt;&lt;/pre&gt;&lt;pre&gt;&lt;code&gt;# patch 적용 결과 ls
first.txt  second.txt&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;파일의 형태로 변경 사항을 가볍게 주고받고 적용할 수 있다.  &lt;/p&gt;
&lt;h2&gt;&lt;code&gt;-p1&lt;/code&gt;?&lt;/h2&gt;
&lt;p&gt;왜 굳이 p1 옵션을 붙이는 것일까?&lt;br&gt;이것은 패치 파일(diff)에 존재하는 디렉토리 정보에서 &lt;strong&gt;경로 정보의 상위 몇 번째 디렉토리까지 무시하고&lt;/strong&gt; 적용할지 알리는 옵션이다.&lt;/p&gt;
&lt;p&gt;위에서 설명했듯 &lt;code&gt;--no-prefix&lt;/code&gt; 옵션 없이 패치 파일을 생성(&lt;code&gt;diff&lt;/code&gt;)하면&lt;/p&gt;
&lt;p&gt;&lt;code&gt;a/&lt;/code&gt;, &lt;code&gt;b/&lt;/code&gt;와 같이 git이 prefix로 별도 디렉토리 정보를 추가한 결과가 출력되므로,&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;diff --git a/second.txt b/second.txt # 실제 디렉토리 정보가 아닌 `a/`, `b/`가 포함됨.
new file mode 100644
index 0000000..86a2422
--- /dev/null
+++ b/second.txt
@@ -0,0 +1 @@
+create second.txt&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;patch 프로그램이 이를 실제로 적용할 때 디렉토리에 붙은 접두어 한 단계를 무시하고 적용하도록 하는 것이다.&lt;/p&gt;
&lt;p&gt;당연하게도 &lt;strong&gt;만약 &lt;code&gt;--no-prefix&lt;/code&gt;옵션을 주어 패치 파일을 생성했다면&lt;/strong&gt;,&lt;br&gt;패치 &lt;strong&gt;적용 시에는 &lt;code&gt;patch -p0 &amp;lt; ${patch_file}&lt;/code&gt;로 &lt;code&gt;p0&lt;/code&gt;옵션을 주어야&lt;/strong&gt; 한다.&lt;/p&gt;
&lt;p&gt;패치 파일과 패치 적용 시의 디렉토리 옵션이 맞지 않는 경우 사용자가 직접 이 충돌을 해소해주어야 하며 실패하는 경우 &lt;code&gt;Oops.rej&lt;/code&gt;가 남는다.  &lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;참고&lt;/h3&gt;
&lt;p&gt;단순 파일에 대한 변경의 경우 패치 파일에 권한 및 로컬 깃 인덱스 정보가 누락되어도 적용은 가능하다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 파일 권한 및 인덱스 정보가 없는 아래의 약식 패치 파일도 적용이 가능하다.
--- /dev/null
+++ b/second.txt
@@ -0,0 +1 @@
+create second.txt&lt;/code&gt;&lt;/pre&gt;</description>
      <category>기타</category>
      <category>diff</category>
      <category>git diff</category>
      <category>patch</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/54</guid>
      <comments>https://jaehee329.tistory.com/54#entry54comment</comments>
      <pubDate>Sun, 4 Feb 2024 17:02:15 +0900</pubDate>
    </item>
    <item>
      <title>[Java] Lambda는 JVM에서 어떻게 다뤄지는가? (invokedynamic)</title>
      <link>https://jaehee329.tistory.com/53</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JVM이 Lambda를 어떤 방식으로 다루는지 궁금해서 찾아보던 중 Oracle 블로그에서 Red Hat의 시니어 엔지니어인 Ben Evans가 쓴 글을 찾을 수 있었다. 이를 이해하며 Lambda가 JVM에서 어떻게 다뤄지는지 살펴보려 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&quot;&gt;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1699536237041&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&quot; data-og-description=&quot;&quot; data-og-host=&quot;blogs.oracle.com&quot; data-og-source-url=&quot;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&quot; data-og-url=&quot;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;blogs.oracle.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;javac, javap 모두 19.0.1을 사용하였다.&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;Bytecode 레벨에서 살펴보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;람다는 단순히 익명(내부) 클래스 구현을 대신하는 눈속임일까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 람다를 활용하지 않는 경우를 보자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;람다를 활용하지 않는 다음의 코드를 컴파일한 후 디컴파일해서 어떻게 바이트코드가 구성되는지 살펴보자.&lt;/p&gt;
&lt;pre id=&quot;code_1699536664660&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class NonLambda {
    private static final String HELLO = &quot;Hello World!&quot;;

    public static void main(String[] args) throws Exception {
        Runnable r = new Runnable() {
            @Override
            public void run() {
                System.out.println(HELLO);
            }
        };
    
        Thread t = new Thread(r);
        t.start();
        t.join();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;372&quot; data-origin-height=&quot;136&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPTY0V/btsz50daMNj/J9LDsQ7MIyN1SY0yFR3q71/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPTY0V/btsz50daMNj/J9LDsQ7MIyN1SY0yFR3q71/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPTY0V/btsz50daMNj/J9LDsQ7MIyN1SY0yFR3q71/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPTY0V%2Fbtsz50daMNj%2FJ9LDsQ7MIyN1SY0yFR3q71%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;372&quot; height=&quot;136&quot; data-origin-width=&quot;372&quot; data-origin-height=&quot;136&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;javac로 컴파일한 결과 두 개의 클래스로 컴파일되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각을 디컴파일한 결과는 아래와 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;974&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxfLTk/btsz1S1OU7m/5zXDkBYDshrw6A9mXHGeaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxfLTk/btsz1S1OU7m/5zXDkBYDshrw6A9mXHGeaK/img.png&quot; data-alt=&quot;javap -c -p NonLambda.class&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxfLTk/btsz1S1OU7m/5zXDkBYDshrw6A9mXHGeaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxfLTk%2Fbtsz1S1OU7m%2F5zXDkBYDshrw6A9mXHGeaK%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;634&quot; height=&quot;395&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;974&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;javap -c -p NonLambda.class&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;550&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PlHa7/btsz2lbBG1v/ggBgkiXBpxO3OCMO4y83t1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PlHa7/btsz2lbBG1v/ggBgkiXBpxO3OCMO4y83t1/img.png&quot; data-alt=&quot;javap -c -p NonLambda.class$1.class&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PlHa7/btsz2lbBG1v/ggBgkiXBpxO3OCMO4y83t1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPlHa7%2Fbtsz2lbBG1v%2FggBgkiXBpxO3OCMO4y83t1%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;705&quot; height=&quot;250&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;550&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;javap -c -p NonLambda.class$1.class&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;익명 클래스는 내부 클래스를 컴파일할 때와 같이 두 개의 클래스 파일로 나눠지고, 각각을 디컴파일한 결과는 당연히 각자가 수행할 내용만을 포함한다.&lt;/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;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 람다를 활용하는 다음의 코드를 컴파일한 후 디컴파일해서 어떻게 바이트코드가 구성되는지 살펴보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1699536292646&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Lambda {
    private static final String HELLO = &quot;Hello World!&quot;;

    public static void main(String[] args) throws Exception {
        Runnable r = () -&amp;gt; System.out.println(HELLO);
        Thread t = new Thread(r);
        t.start();
        t.join();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;1152&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b16H6b/btsz6xaIlE5/hl9VsvYbNqfKKen2iSvHHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b16H6b/btsz6xaIlE5/hl9VsvYbNqfKKen2iSvHHK/img.png&quot; data-alt=&quot;javap -c -p&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b16H6b/btsz6xaIlE5/hl9VsvYbNqfKKen2iSvHHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb16H6b%2Fbtsz6xaIlE5%2Fhl9VsvYbNqfKKen2iSvHHK%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;628&quot; height=&quot;463&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;1152&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;javap -c -p&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금은 다른 결과가 나왔다.&lt;/p&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;private static&lt;/b&gt; &lt;b&gt;메서드&lt;/b&gt;로 만들어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;반환형은 람다가 구현한 인터페이스 메서드의 반환형&lt;/b&gt;을 따른다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;익명 객체와는 다른 스타일로 처리됨을 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;invokedynamic&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바이트코드는 다르다는 것을 알았다. 하지만 그것만이 궁금한 것은 아닐 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;익명 클래스와 처리 과정의 차이는 어디에서 기인하는가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 클래스 파일이 나눠지냐 합쳐지냐만 다른 것이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;람다 사용 시의 메인 메서드의 바이트코드를 자세히 보면 invokedynamic라는 opcode로 시작하는 것을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1356&quot; data-origin-height=&quot;166&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/C2uo2/btsz1Ru6s6m/QlVRIQLdtE7hC2Y8Hfg5XK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/C2uo2/btsz1Ru6s6m/QlVRIQLdtE7hC2Y8Hfg5XK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/C2uo2/btsz1Ru6s6m/QlVRIQLdtE7hC2Y8Hfg5XK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FC2uo2%2Fbtsz1Ru6s6m%2FQlVRIQLdtE7hC2Y8Hfg5XK%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;1356&quot; height=&quot;166&quot; data-origin-width=&quot;1356&quot; data-origin-height=&quot;166&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;invokedynamic은 어떠한 팩토리 메서드를 실행하는 것으로 이해하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 메서드가 main 메서드가 실행될 때 가장 먼저 실행되고 그 결과가 지역 변수에 저장되어(astore_1) 스택에 가장 먼저 쌓인다.&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;특이한 점은 컴파일 시점에는 이 opcode가 어떤 메서드를 호출하는지는 여기에서 결정되지 않는다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 몇 가지의 추가 정보를 포함하여 런타임에 실행된다. &lt;b&gt;리플렉션과 유사&lt;/b&gt;하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;invokedynamic 옆에 써 있는 '#7, 0'을 참고하여 Constant Pool(상수 풀)과 Bootstrap Method 정보를 살펴보자.&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;h4 data-ke-size=&quot;size20&quot;&gt;Constant Pool&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 #7을 Constant Pool에서 찾아보면 다음의 정보가 나타난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1150&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSJNV0/btsz2WWY3bN/FCdZzybkaK4EtxTmvXodXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSJNV0/btsz2WWY3bN/FCdZzybkaK4EtxTmvXodXk/img.png&quot; data-alt=&quot;javap -v -p&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSJNV0/btsz2WWY3bN/FCdZzybkaK4EtxTmvXodXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSJNV0%2Fbtsz2WWY3bN%2FFCdZzybkaK4EtxTmvXodXk%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;653&quot; height=&quot;227&quot; data-origin-width=&quot;1150&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;javap -v -p&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상수 풀에 #7 InvokeDynamic과 관련된 정보가 존재하는 것을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InvokeDynamic은 다시 0번과 8번을 가리키는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상수 풀은 1번부터 시작하므로 0번에 대한 정보는 상수 풀에 존재하지 않으며,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8번은 NameAndType, 이름과 타입 정보가 #9, #10에 포함됨을 알린다.&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;9번의 이름은 임의로 결정되는 것이며(run)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10번에는 유의미한 정보인 &lt;b&gt;람다의 타입 정보인 Runnable&lt;/b&gt;이 적혀있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 타입 정보는 런타임에 invokedynamic factory가 반환할 정보가 된다.&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;h4 data-ke-size=&quot;size20&quot;&gt;Bootstrap Method&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InvokeDynamic이 가리켰던 0번에 대한 정보는 Bootstrap Method에서 찾을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2278&quot; data-origin-height=&quot;288&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6RWPR/btsz12Xt2z6/Jcz7C1xazKHfncZYJWhhxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6RWPR/btsz12Xt2z6/Jcz7C1xazKHfncZYJWhhxk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6RWPR/btsz12Xt2z6/Jcz7C1xazKHfncZYJWhhxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6RWPR%2Fbtsz12Xt2z6%2FJcz7C1xazKHfncZYJWhhxk%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;2278&quot; height=&quot;288&quot; data-origin-width=&quot;2278&quot; data-origin-height=&quot;288&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내용이 많아보이지만 천천히 살펴보면 복잡하지 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시그니처를 보면 LambdaMetafactory 클래스의 metafactory라는 정적 메서드를 호출하는 것을 알 수 있다.&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;또한 이 정적 메서드의 argument로 세 개의 정보가 넘겨지는 것을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이들은 람다에서 사용되는 시그니처에 대한 정보이다. #59는 람다의 입력, #60은 람다 바디 정보이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에서는 Void를 의미하는 ()V가 들어가 있다.&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;Runnable 구현은 너무 간단한 람다이니 비교를 위해 Function을 디컴파일하여 BSM(Bootstrap Method) 예시도 한번 보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1699548739219&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Function&amp;lt;Integer, Integer&amp;gt; f = a -&amp;gt; a + 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;110&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GEtzu/btsz1QJHztO/PXfWeLQkCIACDzZSOWch3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GEtzu/btsz1QJHztO/PXfWeLQkCIACDzZSOWch3k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GEtzu/btsz1QJHztO/PXfWeLQkCIACDzZSOWch3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGEtzu%2Fbtsz1QJHztO%2FPXfWeLQkCIACDzZSOWch3k%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;1202&quot; height=&quot;110&quot; data-origin-width=&quot;1202&quot; data-origin-height=&quot;110&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;람다의 입력은 타입을 명시하지 않았으므로 Object, 출력은 Integer이며 바디에서 사용되는 Integer를 포함한다.&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;
&lt;p data-ke-size=&quot;size16&quot;&gt;bootstrap method에서 호출하는 LambdaMetaFactory의 metafactory로 넘어가 보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1699550130228&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    public static CallSite metafactory(MethodHandles.Lookup caller,
                                       String interfaceMethodName,
                                       MethodType factoryType,
                                       MethodType interfaceMethodType,
                                       MethodHandle implementation,
                                       MethodType dynamicMethodType)
            throws LambdaConversionException {
        AbstractValidatingLambdaMetafactory mf;
        mf = new InnerClassLambdaMetafactory(Objects.requireNonNull(caller),
                                             Objects.requireNonNull(factoryType),
                                             Objects.requireNonNull(interfaceMethodName),
                                             Objects.requireNonNull(interfaceMethodType),
                                             Objects.requireNonNull(implementation),
                                             Objects.requireNonNull(dynamicMethodType),
                                             false,
                                             EMPTY_CLASS_ARRAY,
                                             EMPTY_MT_ARRAY);
        mf.validateMetafactoryArgs();
        return mf.buildCallSite();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바 버전에 따라 파라미터의 이름이 조금 다를 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MethodType 클래스는 리턴 타입과 파라미터 타입 배열을 가지고 있다.&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;파라미터 중 위의 세 정보는 JVM에 의해 입력되고 마지막 세 개의 정보들은...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;interfaceMethodType은 구현해야 할 메서드의 리턴 타입과 파라미터들의 타입을, (runnable은 리턴이 void)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MethodHandle은 실제 람다 내에서 실행하고자 하는 내용이 들어간다(System.out.pritnln(HELLO))&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;dynamicMethodtype에는 interfaceMethodType에서 제네릭 메서드를 사용했다면 제외했을 실제 타입을 넣는다.&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;이 메서드를 통하여 CallSite 객체를 얻어낼 수 있다.&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;h4 data-ke-size=&quot;size20&quot;&gt;CallSite&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CallSite는 MethodHandle을 들고 있는 Holder 클래스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;javadoc을 보면 MethodHandle은 실행 가능한 람다 인스턴스의 참조이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1699553054170&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/** MethodHandle
 * A method handle is a typed, directly executable reference to an underlying method,
 * constructor, field, or similar low-level operation, with optional
 * transformations of arguments or return values.
 * ...
 **/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2146&quot; data-origin-height=&quot;728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qkeaW/btsz3NyHNHw/7ywKIqJoAAIms4GJQBErU1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qkeaW/btsz3NyHNHw/7ywKIqJoAAIms4GJQBErU1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qkeaW/btsz3NyHNHw/7ywKIqJoAAIms4GJQBErU1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqkeaW%2Fbtsz3NyHNHw%2F7ywKIqJoAAIms4GJQBErU1%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;2146&quot; height=&quot;728&quot; data-origin-width=&quot;2146&quot; data-origin-height=&quot;728&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;dynamicInvoker()를 통해 MethodHandle을 얻어낼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스를 알고 있으므로 JVM은 람다를 실체화하여 실행할 수 있다.&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;매 람다 호출마다 전 과정이 반복되는 것은 아니며, 추상 클래스인 CallSite의 구현 중 ConstantCallSite의 형태로 람다 인스턴스를 저장하고 &lt;b&gt;캐싱하여 사용&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&amp;nbsp;Some invokedynamic call sites are effectively just lazily computed, and the method they target will never change after they have been executed the first time. This is a very common use case for&amp;nbsp;ConstantCallSite, and this includes lambda expressions.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 이렇게 복잡한 과정을 거쳐야만 했을까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 런타임에 동작하는 리플렉션과 같은 로직은 유용하나 JIT 컴파일러에 의해 최적화되기 어렵다는 점이 치명적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;런타임에 매번 Method.invoke()와 같은 로직을 동적으로 수행해야 하기 때문이다.&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;invokedynamic을 지원함으로써 람다에 대한 인스턴스화를 런타임에 1회 진행, 상수화하고 캐싱하여 JIT 컴파일러가 최적화할 수 있는 형태로 만들게 되었다.&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;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고 자료&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&quot;&gt;https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.oracle.com/a/ocom/docs/corporate/java-magazine-nov-dec-2017.pdf#page=67&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.oracle.com/a/ocom/docs/corporate/java-magazine-nov-dec-2017.pdf#page=67&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Java</category>
      <category>invokedynamic</category>
      <category>Java</category>
      <category>Lambda</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/53</guid>
      <comments>https://jaehee329.tistory.com/53#entry53comment</comments>
      <pubDate>Fri, 10 Nov 2023 03:44:04 +0900</pubDate>
    </item>
    <item>
      <title>정적 파일, 웹 서버, DB 스키마까지 무중단 배포 시도하기(2) - Trigger로 무중단 DB 마이그레이션 진행하기</title>
      <link>https://jaehee329.tistory.com/52</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전이 올라가며 이전, 이후 버전의 DB 스키마가 크게 바뀌었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 테이블의 네이밍이 수정되었고 서비스 로직이 변경되며 컬럼의 위치도 일부 수정되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인 별로 저장되던 스터디 참여자들의 진행 정보가 기획이 수정되며 함께 관리되도록 바뀌었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;959&quot; data-origin-height=&quot;578&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beTuZw/btszltN7QyX/aDgZEF2FXNKpi6Rz8rS8Y1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beTuZw/btszltN7QyX/aDgZEF2FXNKpi6Rz8rS8Y1/img.png&quot; data-alt=&quot;이전 버전의 스키마&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beTuZw/btszltN7QyX/aDgZEF2FXNKpi6Rz8rS8Y1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeTuZw%2FbtszltN7QyX%2FaDgZEF2FXNKpi6Rz8rS8Y1%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;723&quot; height=&quot;436&quot; data-origin-width=&quot;959&quot; data-origin-height=&quot;578&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이전 버전의 스키마&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1916&quot; data-origin-height=&quot;1310&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oLBkT/btsznAeUUyH/2gxhgeAztUBvyrLz8KX0p1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oLBkT/btsznAeUUyH/2gxhgeAztUBvyrLz8KX0p1/img.png&quot; data-alt=&quot;목표하는 스키마&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oLBkT/btsznAeUUyH/2gxhgeAztUBvyrLz8KX0p1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoLBkT%2FbtsznAeUUyH%2F2gxhgeAztUBvyrLz8KX0p1%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;712&quot; height=&quot;487&quot; data-origin-width=&quot;1916&quot; data-origin-height=&quot;1310&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;목표하는 스키마&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스키마의 변경 자체는 크진 않지만 신규 배포를 무중단으로 진행해야 한다는 점이 가장 큰 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스에는 다음의 세 주요 기능이 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 스터디 함께 진행하기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 혼자 공부하기(다인 스터디를 혼자 진행하는 형태)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 자신의 스터디 기록 조회하기&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;이번 배포에서는 기능이 빈약한 '1. 스터디 함께 진행하기'에 실시간 동기화 기능을 추가하는 것이 가장 큰 변경이었고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능이 변경되면 이전 버전과 아예 호환이 되지 않기에 구버전에서 기능을 사용하지 못하도록 선제조치를 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 과정에서도 '2. 혼자 공부하기'와 '3. 자신의 스터디 기록 조회하기'는 중단이 없어야 하므로 다음의 제약사항을 해결해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 이전, 이후 버전이 다른 WAS로 요청을 보내지만 &lt;b&gt;사용자가 생성하는 기록은 각자의 버전에 맞춰 DB에 생성되거나 변경&lt;/b&gt;되는 동시에,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. &lt;b&gt;신규 버전에선 이전 버전까지의 기록을 통합하여 확인&lt;/b&gt;할 수 있어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1848&quot; data-origin-height=&quot;1207&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pQvpn/btszjD5hR89/iZpmK2kZM5AXE5upQN1Qnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pQvpn/btszjD5hR89/iZpmK2kZM5AXE5upQN1Qnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pQvpn/btszjD5hR89/iZpmK2kZM5AXE5upQN1Qnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpQvpn%2FbtszjD5hR89%2FiZpmK2kZM5AXE5upQN1Qnk%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;788&quot; height=&quot;515&quot; data-origin-width=&quot;1848&quot; data-origin-height=&quot;1207&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;이전 버전에서 쓰인 데이터는 1. 이전 버전대로 저장도 되며 2. 실시간으로 신버전에 맞게도 저장&lt;/b&gt;되어야&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;배포 직후 신버전을 내려받아 사용하는 사람이 구버전에서 완료한 스터디 기록을 바로 조회&lt;/b&gt;할 수 있다.&lt;/p&gt;
&lt;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;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;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실패한 시도: 짧은 간격의 Schedule을 돌려 새로 추가된 내용을 신규 테이블에 복제하자&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;DB를 구버전, 신버전으로 나누어 해결하는 방법도 있을지 모르겠으나 이는 시간이 부족한 상황에서 선택하기 어려운 방법이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불행인지 다행인지 테이블의 이름이 바꼈으므로 신버전에서 쓸 새로운 테이블을 만들고 구버전 테이블에 새로 쓰이는 데이터를 추가로 복사해서 넘겨주는 방식을 고려했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옛 Schema에 맞춰 삽입된 데이터를 어떻게 새로운 table에도 저장할 수 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 익숙한 MySQL Schedule을 사용해 해결하는 방법을 구상했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1684&quot; data-origin-height=&quot;1065&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxCXsh/btszlY1rC7R/GK8OiK8ZEGPk9MkOmPvSP0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxCXsh/btszlY1rC7R/GK8OiK8ZEGPk9MkOmPvSP0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxCXsh/btszlY1rC7R/GK8OiK8ZEGPk9MkOmPvSP0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxCXsh%2FbtszlY1rC7R%2FGK8OiK8ZEGPk9MkOmPvSP0%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;762&quot; height=&quot;482&quot; data-origin-width=&quot;1684&quot; data-origin-height=&quot;1065&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'lastly_updated_pks'는 마지막으로 복제 작업을 완료한 컬럼의 PK를 기억하기 위함이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'mapping table'은 다른 테이블의 데이터 복제 시 FK를 참조해야 하는 경우가 있어 필요하다.&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;LAST_INSERT_ID() 함수를 통해 신규 테이블에 insert하며 생성된 auto_increment_id를 얻을 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이를 바탕으로 fk가 필요한 다른 테이블에도 삽입 작업을 한다. (LAST_INSERT_ID()와 관련해선 유의해야 할 점들이 있다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번의 스케줄 내에서는 아래의 동작들을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0. 짧은 간격의 스케줄이 돌기 전 구버전 사용자는 old_table에 기록을, 신규 사용자는 new_table에 기록을 남긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 스케줄 시작 시 mapping table에서 마지막으로 복사 작업을 한 데이터의 pk를 가져온다. (old table id = 7)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 범위 검색을 통해 마지막 스케줄 이후 old_table에 새로 삽입된 row들을 가져온다(old table id = 8, 9)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. new_table에 스키마에 맞게 insert문을 날린다. (new_table id = 12)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 기존 테이블 - 복제된 데이터의 pk 정보를 mapping table에 기록한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 6. 추가적인 데이터에 대해서도 insert 및 mapping table의 기록을 진행한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7. 복제를 완료한 old table의 id를 갱신한다(7 -&amp;gt; 9)&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;총 테이블이 네 개가 변경되므로 네 테이블 모두에 대한 schedule이 필요하고, 이때 신규 테이블에서 사용 중인 FK를 참조하기 위해 각 테이블의 mapping table을 별도로 관리한다. (실제로 FK를 사용하고 있진 않으나 연관성을 나타내기 적절해 표현상으로만 쓴다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방법이 조금 복잡하고 일시적으로 성능은 감수해야겠지만 특정 시점부터 구 테이블에 삽입된 데이터는 신규 테이블에도 맞게 삽입된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적절히 짧은 시간마다 schedule이 돌게 하면 추가된 사항만 점검하므로 사용자가 체감하지 못할 수준으로 복제된다.&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;
&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;p data-ke-size=&quot;size16&quot;&gt;아....&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;새로운 시도: Trigger를 사용하자&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;짧은 간격의 스케줄로 Table Full Scan을 하며 변경을 찾아내는 건 말이 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잠시, 아니 좀 길게 낙담했다가...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문득 MySQL에서는 바이너리 로그로 Master-Slave 간 변경을 반영하는데 이처럼&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용자 수준에서 변경(INSERT, UPDATE, DELETE)에 대한 처리를 명시적으로 해 줄 방법&lt;/b&gt;은 없을까? 하는 생각이 났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 뒤져보니 너무 다행히도 MySQL에는 &lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/triggers.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Trigger&lt;/a&gt;라는 기능이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일종의 Stored Procedure이며 &lt;b&gt;테이블 단위로 INSERT, UPDATE, DELETE에 대한 이벤트 발생 시 사용자가 지정한 행위를 하도록&lt;/b&gt; 만들 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래처럼 Trigger를 만들어주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1698567045399&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;DELIMITER $$
CREATE TRIGGER pomodoro_study_insert_trigger
AFTER INSERT
ON pomodoro_study
FOR EACH ROW

BEGIN
	INSERT INTO study(name, total_cycle, time_per_cycle, current_cycle, step, created_date, last_modified_date) VALUES (NEW.name, NEW.total_cycle, NEW.time_per_cycle, 1, 'PLANNING', NEW.created_date, NEW.last_modified_date);
	INSERT INTO pomodoro_study_to_study(pomodoro_study_id, study_id) VALUES
	(NEW.id, LAST_INSERT_ID());

END $$
DELIMITER ;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 적용하면 'pomodoro_study'라는 테이블에 INSERT가 발생했을 때 내가 지정한 작업,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'study'테이블에 INSERT를 수행하고, 위에서 언급한 mapping table에도 해당 정보를 기록한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(모든 테이블의 레코드 생성 시점이 같은 것이 아니어서 여전히 FK 참조를 위해 mapping table은 필요했다)&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;이와 같은 형태로 다른 테이블에도 필요한 내용의 INSERT, UPDATE Trigger를 모두 준비해 두었다.&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;이제 남은 고민은 예상처럼 Transaction 처리가 깔끔하게 되나? 였다.&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;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;WAS 사용 시점은 NGINX를 통해 제어가 가능하므로 '신규 테이블을 생성하는 DDL'은 먼저 입력해 두어도 문제가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. '기존 데이터 복제', 2. 'Trigger를 통한 신규 데이터 복제' 두 가지만 같은 시점에 명령하면 되었다.&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;스케줄을 통해 기존 데이터 복제와 Trigger 생성을 동시에 하려고 하였으나...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trigger는 Schedule을 통한 생성이 불가능했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;821&quot; data-origin-height=&quot;481&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPtj0N/btszpztjson/T9x56bdkK7GWrgN869WFbK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPtj0N/btszpztjson/T9x56bdkK7GWrgN869WFbK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPtj0N/btszpztjson/T9x56bdkK7GWrgN869WFbK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPtj0N%2Fbtszpztjson%2FT9x56bdkK7GWrgN869WFbK%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;294&quot; height=&quot;172&quot; data-origin-width=&quot;821&quot; data-origin-height=&quot;481&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불가피하게 데이터 복제와 Trigger 생성이 한 트랜잭션 내에서 처리가 불가능한 상황이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로 인해 1번과 2번 사이에서 사용자가 구버전의 테이블에 삽입/수정을 진행하면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trigger가 생성되기 이전이므로 신규 테이블에 반영되지 않을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CJULi/btszkyblgta/vu5WzTaSOxSz7L3BbeeyN0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CJULi/btszkyblgta/vu5WzTaSOxSz7L3BbeeyN0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CJULi/btszkyblgta/vu5WzTaSOxSz7L3BbeeyN0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCJULi%2Fbtszkyblgta%2Fvu5WzTaSOxSz7L3BbeeyN0%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;298&quot; height=&quot;288&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 &lt;b&gt;데이터 복제가 끝난 시점의 시간과 Trigger를 선언하기 직전의 시간을 기록&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막에 'last_modified_date'을 참조하여 이 사이에 변경이 있었던 기록을 다시 추적하여 직접 반영하는 스크립트를 추가로 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAS를 통해 DB를 사용할 땐 Locking Read를 사용하므로 복제 시점에 일시적인 성능 저하가 있을 순 있지만 현 서비스 규모에서는 크게 문제 되지 않는다 판단하였고 실제로도 그랬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 서버에서 환경을 동일하게 구성하여 수 차례 스크립트들을 순차적으로 실행시키며 점검하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;꼬박 하루를 소요했지만 다행히 문제없이 DB의 무중단 마이그레이션을 진행했다.&lt;/p&gt;</description>
      <category>하루스터디</category>
      <category>MySQL</category>
      <category>mysql trigger</category>
      <author>Jaehee Jeon</author>
      <guid isPermaLink="true">https://jaehee329.tistory.com/52</guid>
      <comments>https://jaehee329.tistory.com/52#entry52comment</comments>
      <pubDate>Sun, 29 Oct 2023 17:58:17 +0900</pubDate>
    </item>
  </channel>
</rss>