<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>리오의 개발일지</title>
    <link>https://codediary21.tistory.com/</link>
    <description>좋은 영향력을 전파하기 위해 노력하는 엔지니어 리오입니다.</description>
    <language>ko</language>
    <pubDate>Mon, 17 Aug 2026 14:00:24 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>ri5</managingEditor>
    <item>
      <title>MQTT로 기기를 제어하고 있지만, 사실 잘 몰랐습니다</title>
      <link>https://codediary21.tistory.com/200</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;들어가며&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;769&quot; data-origin-height=&quot;418&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDAsPO/dJMcaf8naqY/ZIKmXmgtkpXeHADyI2uhS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDAsPO/dJMcaf8naqY/ZIKmXmgtkpXeHADyI2uhS0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDAsPO/dJMcaf8naqY/ZIKmXmgtkpXeHADyI2uhS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDAsPO%2FdJMcaf8naqY%2FZIKmXmgtkpXeHADyI2uhS0%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;552&quot; height=&quot;300&quot; data-origin-width=&quot;769&quot; data-origin-height=&quot;418&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 운영 중인 서비스에서 AWS IoT Core를 통해 MQTT로 기기를 제어하고 있다. 명령을 publish 하면 기기의 내판과 외판이 동작하고, 기기의 상태가 변경되면 서버가 받아서 반영한다. 외적으로는 잘 돌아가지만 문제는 &lt;b&gt;왜 잘 돌아가는지 깊게 설명할 수 없었다&lt;/b&gt;는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QoS란 무엇인지, 기기가 오프라인일 때 보낸 명령은 어떻게 되는지, 연결이 끊긴 기기를 서버는 어떻게 알아채는지 잘 설명할 수 없었다. 장애가 나기 전에 이해해두자는 마음으로, MQTT의 내부 동작을 표준 스펙과 AWS IoT Core의 실제 구현을 오가며 정리해봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 말해두면, AWS IoT Core는 표준 MQTT 브로커가 아니다. 표준과 다르게 동작하는 지점이 꽤 많고, 그 차이를 제대로 이해하지 못하면 실제 사용되는 기기가 의도하지 않은 채로 동작할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 각 절마다 표준과 AWS IoT Core의 차이점에 대해 정리하였따.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 왜 기기 - 서버 통신은 HTTP가 아니라 MQTT인가&lt;/b&gt;&lt;/h2&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;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WINPT/dJMcaalHuix/MRvzguDKWcgWfTlLYiz8kk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WINPT/dJMcaalHuix/MRvzguDKWcgWfTlLYiz8kk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WINPT/dJMcaalHuix/MRvzguDKWcgWfTlLYiz8kk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWINPT%2FdJMcaalHuix%2FMRvzguDKWcgWfTlLYiz8kk%2Fimg.jpg&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;483&quot; height=&quot;483&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본론에 들어가기 전에, 애초에 왜 기기 통신에서 다들 MQTT를 쓰는지부터 짚고 가자. 웹 개발자의 기본기인 HTTP로 시작해보면 이유가 선명해진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;첫 번째 문제: 서버가 기기를 부를 수 없다.&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTTP는 요청-응답 모델이다. 항상 클라이언트가 먼저 요청을 해야 응답을 받는 구조이다.&lt;/li&gt;
&lt;li&gt;그런데 기기 제어의 본질은 반대다. 서버가 기기에게 &quot;이렇게 동작해&quot;라고 말을 걸어야 한다.&lt;/li&gt;
&lt;li&gt;기기는 보통 공유기 뒤(NAT)나 이동통신망 안에 있기 때문에 서버가 기기의 IP로 접속해 들어갈 방법이 없다.&lt;/li&gt;
&lt;li&gt;HTTP로 풀려면 기기가 폴링(polling)을 해야 하는데, 명령이 없는 99%의 시간에도 요청이 오가고, 즉시 처리가 어렵고 폴링 주기만큼 기다려야한다. 그리고 기기가 늘어갈수록 서버에게도 부하를 주게 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MQTT의 해법은 요청 방향을 뒤집는 것이다. &lt;b&gt;기기가 먼저 브로커에 TCP 연결을 걸고, 그 연결을 계속 열어둔다.&lt;/b&gt; 서버에서 기기로 못 들어가니, 기기에서 서버로 뚫어놓은 통로를 유지하는 것이다. 이제 서버가 보낸 명령을 브로커가 이 열린 통로로 즉시 밀어넣어줄(push) 수 있다. 폴링도, 공인 IP도, 포트포워딩도 필요 없다.&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;두 번째 문제: HTTP는 무겁다.&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTTP 요청 하나에는 매번 수백 바이트의 헤더가 따라붙고, 연결을 새로 맺는다면 TCP/TLS 핸드셰이크 비용까지 든다.&lt;/li&gt;
&lt;li&gt;반면 MQTT는 연결을 한 번 맺어두고 재사용하며, 고정 헤더가 최소 2바이트다. 센서 값 하나 보내는 데 드는 비용이 자릿수부터 다르다.&lt;/li&gt;
&lt;li&gt;실제로 MQTT는 1999년 IBM 엔지니어들이 위성 회선으로 송유관 센서 데이터를 보내기 위해 만든 프로토콜이다. 대역폭이 비싸고, 회선이 수시로 끊기고, 기기 전력과 네트워크가 불안정한 환경에서 문제를 해결하기 위해 만들어졌다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;세 번째 문제: 기기와 서버를 1:1로 묶으면 확장이 안 된다.&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기기가 수천 대가 되고, 상태를 받아볼 소비자가 여럿이 되면(제어 서버, 모니터링, 데이터 파이프라인 등) 서로의 주소를 알고 직접 통신하는 구조는 금방 유지보수가 어려워진다.&lt;/li&gt;
&lt;li&gt;MQTT는 pub/sub 모델로 이 결합을 끊는다. 클라이언트끼리는 서로를 모르고, 모든 메시지는 &lt;b&gt;브로커(broker)&lt;/b&gt;를 거친다&lt;/li&gt;
&lt;li&gt;AWS IoT Core에서는 메세지 브로커가 그 역할이다. 메시지는 devices/door-01/command 같은 &lt;b&gt;계층형 토픽(topic)&lt;/b&gt;으로 라우팅되고, 구독할 때 +(한 레벨), #(하위 전체) 와일드카드를 쓸 수도 있다.&lt;/li&gt;
&lt;li&gt;발행자는 토픽에 던질 뿐이고, 그걸 누가 몇 명이 받는지는 브로커의 일이다. 기기 한 대의 상태를 서비스 셋이 나눠 받는 구조가 설정 없이 그냥 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 MQTT가 기기-서버 통신의 표준처럼 쓰이는 이유는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;NAT 뒤의 기기에게 서버가 실시간으로 push할 수 있고&lt;/li&gt;
&lt;li&gt;저전력&amp;middot;저대역폭 환경을 버티며&lt;/li&gt;
&lt;li&gt;기기가 늘어나도 구조가 유지되기 때문(pub/sub 디커플링)이다.&lt;/li&gt;
&lt;li&gt;연결이 &quot;끊기는 것&quot;을 예외가 아니라 일상으로 전제하고, 그 상황을 다루는 도구들을 QoS, 세션, keep-alive, LWT을 프로토콜 안에 내장되어 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. QoS - &quot;정확히 한 번&quot;이란 무엇일까&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;861&quot; data-origin-height=&quot;770&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXz5lx/dJMcaf1C23l/kmkLurIyRv73VyuximqnPK/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXz5lx/dJMcaf1C23l/kmkLurIyRv73VyuximqnPK/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXz5lx/dJMcaf1C23l/kmkLurIyRv73VyuximqnPK/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXz5lx%2FdJMcaf1C23l%2FkmkLurIyRv73VyuximqnPK%2Fimg.webp&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;344&quot; height=&quot;308&quot; data-origin-width=&quot;861&quot; data-origin-height=&quot;770&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MQTT 표준의 QoS(Quality of Service)는 세 단계로 이뤄진다. 각 단계가 실제로 어떤 패킷을 주고받는지 자세히 살펴보면 메세지 전송의 보장이 무슨 뜻인지 알 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;QoS 0 - At most once: 던지고 잊는다&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;Sender ── PUBLISH ──▶ Receiver
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패킷 하나로 끝. 응답도, 재전송도 없다. 연결이 끊기는 타이밍에 걸리면 메시지는 사라진다. 유실돼도 다음 값으로 금방 대체되는 데이터(주기적 센서 값 등)에 적합하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AWS IoT Core에서는:&lt;/b&gt; 놀랍게도 &lt;b&gt;QoS 0 메시지도 중복 전달될 수 있다&lt;/b&gt;고 공식 문서에 명시되어 있다. 표준의 &quot;at most once&quot;가 IoT Core에서는 &quot;보통 한 번, 가끔 여러 번&quot;이 되는 셈이다. 게다가 중복 전달 시 DUP 플래그(&lt;span style=&quot;color: #666666; text-align: start;&quot; data-sfc-cp=&quot;&quot; data-sfc-root=&quot;ep&quot; data-sfc-cb=&quot;&quot; data-copy-service-computed-style=&quot;font-family: Arial, sans-serif; font-size: 16px; font-weight: 500; margin: 0px; text-decoration: none; border-bottom: 0px rgb(238, 240, 255);&quot;&gt;메시지 발행(PUBLISH) 시 해당 메시지가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;이전에 전송된 메시지의 재전송본&lt;/b&gt;&lt;span style=&quot;color: #666666; text-align: start;&quot;&gt;인지, 아니면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;첫 번째 전송 시도&lt;/b&gt;&lt;span style=&quot;color: #666666; text-align: start;&quot;&gt;인지를 나타내는 1비트 식별자&lt;/span&gt;)도 붙지 않는다&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;QoS 1 - At least once: 도착은 보장, 중복은 감수&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;Sender ── PUBLISH (packet id 부여) ──▶ Receiver
Sender ◀─ PUBACK ──────────────────── Receiver
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발신 측은 메세지(PUBLISH)를 보낸 뒤 응답(&lt;b&gt;PUBACK)을 받을 때까지 메시지를 저장&lt;/b&gt;하고, 안 오면 재전송한다. 여기서 본질적인 문제가 있다. 수신 측이 메시지를 잘 받아 처리까지 했는데 응답(&lt;b&gt;PUBACK)이 유실&lt;/b&gt;되면, 발신 측은 같은 메시지를 다시 보낸다. 수신 측에는 같은 메시지가 두 번 도착하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기기 제어에서 이 문제는 심각하게 이어질 수 있다. 예를들어 &quot;문 열어&quot; 명령이 두 번 도착하는 건 괜찮을 수 있다. 하지만 &quot;열려있으면 닫고 닫혀있으면 열어&quot;가 두 번 도착하면 문이 열렸다가 다시 닫히면서 고객이 문사이에 끼어 피해를 입을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그래서 결론은 QoS 1 위에서의 제어 명령은 반드시 멱등(idempotent)해야 한다는 것이다.&lt;/b&gt; &quot;토글&quot;처럼 누를 때마다 동작이 변화하는 것이 아니라 &quot;문을 열어라&quot;처럼, 같은 명령이 두 번 와도 결과가 같도록 설계하는 것. 이건 선택이 아니라 QoS1을 사용하기 위해 필수로 적용해야하는 개념이다. 명령에 고유 ID를 넣어 기기 쪽에서 중복을 걸러내는 방법도 함께 쓰이고는 한다.&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;b&gt;QoS 2 - Exactly once: 중복없이 정확히 한번&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표준 MQTT의 QoS 2는 4-way handshake(PUBLISH &amp;rarr; PUBREC &amp;rarr; PUBREL &amp;rarr; PUBCOMP)를 통해 중복까지 제거해 &quot;정확히 한 번&quot;을 제공한다. 대신 메시지 하나에 패킷 4개와 서버, 클라이언트 모두 상태 유지라는 비용이 필요하다.&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;1단계&amp;nbsp;(PUBLISH):&lt;/b&gt;&amp;nbsp;송신자가&amp;nbsp;Packet&amp;nbsp;ID와&amp;nbsp;함께&amp;nbsp;메시지를&amp;nbsp;전송합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2단계&amp;nbsp;(PUBREC):&lt;/b&gt;&amp;nbsp;수신자가&amp;nbsp;메시지를&amp;nbsp;수신/저장하고,&amp;nbsp;송신자에게&amp;nbsp;수신&amp;nbsp;완료(PUBREC)를&amp;nbsp;알립니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;3단계&amp;nbsp;(PUBREL):&lt;/b&gt;&amp;nbsp;송신자가&amp;nbsp;PUBREC을&amp;nbsp;확인&amp;nbsp;후,&amp;nbsp;수신자에게&amp;nbsp;메시지를&amp;nbsp;최종&amp;nbsp;처리하라는&amp;nbsp;해제&amp;nbsp;신호(PUBREL)를&amp;nbsp;보냅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;4단계&amp;nbsp;(PUBCOMP):&lt;/b&gt;&amp;nbsp;수신자가&amp;nbsp;메시지를&amp;nbsp;처리하고,&amp;nbsp;송신자에게&amp;nbsp;모든&amp;nbsp;과정이&amp;nbsp;완료(PUBCOMP)되었음을&amp;nbsp;알립니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AWS IoT Core&lt;/b&gt;에서는 &lt;b&gt;QoS 2&lt;/b&gt;를 지원하지 않는다. 표준 브로커를 쓰다가 넘어와 QoS 2를 설정하면, &lt;b&gt;PUBREC/PUBREL/PUBCOMP 패킷&lt;/b&gt;을 지원하지 않기 때문에 에러 없이 그냥 조용히 실패해버린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니 IoT Core에서 신뢰성의 상한선은 QoS 1이고, &quot;정확히 한 번&quot;은 프로토콜이 아니라 &lt;b&gt;애플리케이션으로 멱등성을 보장&lt;/b&gt;해야한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;놓치기 쉬운 것: 서버와 클라이언트의 QoS는 별개다&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QoS는 발행자&amp;rarr;브로커, 브로커&amp;rarr;구독자 &lt;b&gt;두 구간에서 따로&lt;/b&gt; 적용되고, 실제 적용값은 &lt;b&gt;둘 중 낮은 쪽&lt;/b&gt;이다. 발행자가 QoS 1로 보내도 구독자가 QoS 0으로 구독했다면 브로커&amp;rarr;구독자 구간은 QoS 0이다. 분명 QoS 1로 보냈는데 유실되는 문제가 생길 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 &lt;b&gt;IoT Core는 메시지 순서를 보장하지 않는다&lt;/b&gt;고 문서에 명시되어 있다. 명령의 순서가 중요하다면 메시지에 시퀀스 번호나 타임스탬프를 넣고 수신 측에서 처리해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 세션 - 오프라인 기기에 보낸 명령은 어디로 가는가&lt;/b&gt;&lt;/h2&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;299&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CikSG/dJMcahkXHGS/wcgYnJJqUmkc9xTsJ1yiG0/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CikSG/dJMcahkXHGS/wcgYnJJqUmkc9xTsJ1yiG0/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CikSG/dJMcahkXHGS/wcgYnJJqUmkc9xTsJ1yiG0/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCikSG%2FdJMcahkXHGS%2FwcgYnJJqUmkc9xTsJ1yiG0%2Fimg.webp&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;600&quot; height=&quot;299&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;299&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기기가 오프라인일 때 보낸 명령은 어떻게 될까?&lt;/b&gt; 답은 세션 설정에 따라 다르다. 클라이언트가 연결할 때 &lt;b&gt;persistent session&lt;/b&gt;이면 브로커는 기기가 끊긴 동안에도 구독 목록을 기억하고, &lt;b&gt;QoS 1 메시지를 큐잉해뒀다가 재연결 시 전달&lt;/b&gt;한다. clean session이면 끊기는 순간 모든 것이 증발하고, 오프라인 동안의 명령은 그냥 사라진다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AWS IoT Core에서는:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;persistent session의 기본 만료 시간이 &lt;b&gt;1시간&lt;/b&gt;이다(계정 단위 설정, 연장 신청 가능). 기기가 1시간 넘게 오프라인이면 세션과 큐잉된 명령이 함께 사라진다.&lt;/li&gt;
&lt;li&gt;MQTT 5에서는 Session Expiry Interval을 연결별로 지정할 수 있고 최대 &lt;b&gt;7일&lt;/b&gt;까지 가능하다. 단, CONNECT에서 지정하지 않으면 &lt;b&gt;기본값이 0이다.&lt;/b&gt;&amp;nbsp;즉 끊기는 순간 세션 종료다.&lt;/li&gt;
&lt;li&gt;재연결 시 큐잉된 메시지는 &lt;b&gt;초당 최대 10개&lt;/b&gt;로 흘려보내진다. 오래 끊겼던 기기에 명령이 산더미처럼 쌓여 있었다면 도착이 순차적으로 지연된다는 뜻이다.&lt;/li&gt;
&lt;li&gt;CONNACK의 sessionPresent 플래그로 세션이 살아 있는지 확인할 수 있다. 0이면 재구독부터 해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 질문을 하나 던져보자. &lt;b&gt;오래된 명령이 뒤늦게 실행되는 게 맞는가?&lt;/b&gt; 새벽에 보낸 &quot;문 열어&quot;를 보냈는데 기기 복구 후 오전에 실행된다면 그건 기능이 아니라 장애다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령에 유효기간을 넣거나(MQTT 5의 Message Expiry Interval을 쓰면 프로토콜 레벨에서 가능, IoT Core는 최대 7일), 기기가 수신할 때 타임스탬프를 검사해서 특정 시간이 지나면 동작하지 않도록 하는 것이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 세션의 키는 &lt;b&gt;Client ID&lt;/b&gt;다. 그리고 두 클라이언트가 같은 Client ID로 접속하면 IoT Core는 &lt;b&gt;기존 연결을 즉시 끊어버린다.&lt;/b&gt; 실수로 기기 두 대에 같은 ID를 넣으면 둘이 서로를 밀어내며 무한 재연결하는 루프에 빠져 버린다. 접속이 이상하게 뚝뚝 끊긴다면 Client ID 중복부터 체크해보는 것이 중요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. Keep-alive - 죽은 기기를 탐지하는 기술&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ekpU4C/dJMb99UFT96/jLfZOr2xulZUCJwRMfwyKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ekpU4C/dJMb99UFT96/jLfZOr2xulZUCJwRMfwyKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ekpU4C/dJMb99UFT96/jLfZOr2xulZUCJwRMfwyKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FekpU4C%2FdJMb99UFT96%2FjLfZOr2xulZUCJwRMfwyKk%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;731&quot; height=&quot;487&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TCP의 불편한 진실: &lt;b&gt;상대가 죽어도 아무 일도 일어나지 않는다.&lt;/b&gt; 패킷을 보내보기 전까지는 연결이 죽었는지 알 수 없다(half-open connection). 마지막으로 보낸 신호가 정상이고 그 뒤에 신호를 보내지 않는다면 기기가 네트워크 문제가 있어도 계속 서버에서는 정상으로 보일 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 MQTT의 해법이 keep-alive다. 클라이언트는 CONNECT에서 keep-alive 간격(초)을 선언하고, 그 간격 안에 보낼 패킷이 없으면 &lt;b&gt;PINGREQ&lt;/b&gt;를 보낸다. 브로커는 &lt;b&gt;PINGRESP&lt;/b&gt;로 답한다. 브로커는 keep-alive를 설정한 시간의 &lt;b&gt;1.5배&lt;/b&gt; 동안 클라이언트로부터 아무 패킷이 없으면 연결이 죽었다고 판단하고 정리한다.&amp;nbsp; IoT Core도 동일하게 1.5배 규칙을 쓴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값 선택은 트레이드오프다. 짧으면 죽음 감지가 빠르지만 트래픽과 전기를 많이 먹고, 길면 반대다. 그리고 이 &quot;죽었다고 판단하는 순간&quot;이 중요한 이유가 다음 절에 나온다. 바로 이때 LWT가 발동한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 기기의 생사 알림 - LWT, 그리고 retained의 함정&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfm48t/dJMcaiYjsEu/PTaLMwQqfTgb72nOIr8rBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfm48t/dJMcaiYjsEu/PTaLMwQqfTgb72nOIr8rBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfm48t/dJMcaiYjsEu/PTaLMwQqfTgb72nOIr8rBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbfm48t%2FdJMcaiYjsEu%2FPTaLMwQqfTgb72nOIr8rBk%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;482&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;LWT - 클라이언트의 유언장&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트는 CONNECT 시점에 브로커에게 LWT(Last Will and Testament)를 맡길 수 있다. 내가 &lt;b&gt;비정상적으로&lt;/b&gt; 죽으면 이 토픽에 이 메시지를 대신 발행한다는 뜻이다. 브로커는 네트워크 오류로 연결이 끊기거나, keep-alive 1.5배 시간 내에 패킷이 없으면 LWT를 발행한다. 정상적으로 DISCONNECT를 보내고 떠나면 발행하지 않는다. 즉 LWT는 정확히 &lt;b&gt;&quot;예고 없는 죽음&quot;의 감지하는 기술이&lt;/b&gt;다. IoT Core도 LWT를 지원한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;retained message - 마지막 값의 스냅샷&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 메시지는 발행 순간 구독 중인 클라이언트에게만 전달된다. 1초 늦게 구독한 클라이언트는 그 메시지를 영영 모른다. &lt;b&gt;retained flag&lt;/b&gt;를 켜고 발행하면 브로커가 그 메시지를 토픽당 1개 저장해두고, 새 구독자에게 즉시 전달할 수 있다. &quot;이 토픽의 마지막 값&quot;이다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;연결 시 LWT 등록: devices/{id}/status에 offline, retained&lt;/li&gt;
&lt;li&gt;연결 직후 발행: 같은 토픽에 online, retained&lt;/li&gt;
&lt;li&gt;누가 언제 구독하든 현재 상태를 즉시 알 수 있다. 기기가 소리 없이 죽으면 브로커가 offline을 대신 발행한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AWS IoT Core에서는:&lt;/b&gt; retained message를 지원하지만(2021년부터) 치명적인 예외가 있다 - &lt;b&gt;와일드카드(+, #) 구독으로는 retained 메시지를 받을 수 없다.&lt;/b&gt; &lt;br /&gt;토픽을 정확히 지정해 구독해야만 구독 시점에 retained 값이 온다. devices/+/status로 전체 기기 상태를 한 번에 받아오는 표준식 패턴이 IoT Core에서는 동작하지 않는다는 뜻이다. (이후의 실시간 발행분은 와일드카드로도 받는다. 안 오는 건 &quot;구독 시점의 저장된 값&quot;이다.)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;그래서 AWS가 미는 Device Shadow&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지점에서 IoT Core의 답은 사실 retained가 아니라 &lt;b&gt;Device Shadow&lt;/b&gt;다. Shadow는 기기별로 브로커 옆에 붙는 JSON 상태 문서로, desired(원하는 상태)와 reported(기기가 보고한 상태)를 나눠 저장한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcgryr/dJMcaftPh21/fMJWnAOzyP1GpxDt8LfkWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcgryr/dJMcaftPh21/fMJWnAOzyP1GpxDt8LfkWk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcgryr/dJMcaftPh21/fMJWnAOzyP1GpxDt8LfkWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbcgryr%2FdJMcaftPh21%2FfMJWnAOzyP1GpxDt8LfkWk%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;765&quot; height=&quot;510&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&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;동작 흐름:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;서버가 desired: {door: &quot;open&quot;}을 Shadow에 쓴다.&lt;/li&gt;
&lt;li&gt;기기가 온라인이 되면 Shadow가 delta(desired와 reported의 차이)를 기기에 알려준다&lt;/li&gt;
&lt;li&gt;기기가 실행 후 reported: {door: &quot;open&quot;}을 보고하면 delta가 사라진다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 장점은 &lt;b&gt;&quot;명령을 보낸다&quot;에서 &quot;원하는 상태를 선언한다&quot;로 관점을 바꿔주는 것&lt;/b&gt;이다. 명령의 중복도, 유실도, 오프라인 큐잉도 상태 수렴이라는 모델 안에서 자연스럽게 해소된다. 앞에서 말한 멱등 설계를 AWS가 인프라 레벨로 만들어둔 셈이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 우리 인프라를 확인해봤다. 그냥 기기별 커스텀 토픽에 QoS 0으로 publish하고 있었다. Shadow가 해주는 일들을 우리는 직접 하고 있던 셈이다. 오프라인 기기 문제는 못 풀었고(명령이 그냥 사라진다), 중복 방지는 DB 락으로, 실행 확인은 폴링으로 때우고 있다. 이 사실을 Shadow를 공부하고 나서야 알았다.&lt;br /&gt;그나마 다행인 건 명령 페이로드가 전부 멱등성 있게 설계되었다는 점이다. Shadow의 desired가 요구하는 게 정확히 이 형태라, 옮기기로 하면 페이로드 설계는 다시 할 필요가 없다. 토픽 구조와 기기 쪽 구독 코드를 바꾸는 일이 새롭게 추가되었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 그 외 IoT Core에서 알아두면 좋은 제약 (짧게)&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;메시지 하나의 최대 크기는 128KB다. 펌웨어나 이미지처럼 큰 데이터를 보내려면 나눠서 보내는 수밖에 없다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;MQTT 5도 쓸 수 있다. reason code, shared subscription, topic alias 같은 기능이 들어온다. 다만 MQTT 3에서 만든 persistent session은 MQTT 5로 이어지지 않으니 버전을 올릴 때 주의해야 한다.&lt;/li&gt;
&lt;li&gt;와일드카드 동작에 애매한 구석이 하나 있는데, sensor/#를 구독하면 sensor/temperature는 받지만 sensor 토픽으로 오는 메시지는 못 받는다.&lt;/li&gt;
&lt;li&gt;$로 시작하는 토픽은 AWS가 예약해서 쓴다. Shadow, lifecycle 이벤트 같은 것들이 $aws/... 토픽으로 온다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;IoT Core에서 신뢰성의 상한은 QoS 1이고, &quot;정확히 한 번&quot;은 어플리케이션으로 멱득성을 설계해야된다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;제어 명령은 중복된 명령에도 멱득성을 보장하도록 &quot;토글&quot;이 아니라 &quot;이렇게 동작해라&quot;로 구현해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;오프라인 기기에 보낸 명령의 운명은 세션 설정이 결정한다.&lt;/b&gt; clean session 여부, 세션 만료 1시간, Client ID 전략 등 셋 다 개발자가 직접 결정해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기기 상태 추적은 LWT+retained 조합이 자주 사용되던 방식이지만, AWS IoT Core는 와일드카드 구독에 retained Message를 주지 않는다.&lt;/b&gt; 서버가 재시작하면 전체 기기의 마지막 상태를 받아올 방법이 없다는 뜻이다. 대신 상태를 문서로 저장해두고 아무 때나 API로 조회할 수 있는 Device Shadow를 제공해준다.,&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글을 쓰기 전의 나는 &quot;MQTT로 기기를 제어한다&quot; 수준까지 이해하고 있었지만 지금은 MQTT 뒤에 숨어 있던 기술이 나오게 된 배경 그리고 문제를 해결하기 위해 나온 기술들(QoS, 세션, Keep Alive) 을 하나씩 설명할 수 있게 됐다. 그 과정속에 우리가 가진 기술부채까지 알게 되면서 기기의 상태와 서버의 상태가 불일치하는 이유까지 파악할 수 있었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;참고: &lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MQTT 3.1.1&lt;/a&gt; / &lt;a href=&quot;https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;5.0 OASIS&lt;/a&gt; 스펙&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/iot/latest/developerguide/mqtt.html&quot;&gt;AWS IoT Core 개발자 가이드 - MQTT&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://aws.amazon.com/blogs/iot/getting-started-mqtt-retained-messages-aws-iot-core/&quot;&gt;AWS IoT Core retained messages 소개 블로그&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>CS/네트워크</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/200</guid>
      <comments>https://codediary21.tistory.com/200#entry200comment</comments>
      <pubDate>Sun, 19 Jul 2026 22:59:22 +0900</pubDate>
    </item>
    <item>
      <title>3년 만에 다시 돌아온 스타트업, 생존형 엔지니어링을 넘어</title>
      <link>https://codediary21.tistory.com/199</link>
      <description>&lt;h2 data-sourcepos=&quot;1:1-1:17;0-16&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3년 만에 다시, 스타트업&lt;/b&gt;&lt;/h2&gt;
&lt;p data-sourcepos=&quot;5:1-5:84;122-205&quot; data-ke-size=&quot;size16&quot;&gt;3년 전, 나는 &quot;그래도 또 스타트업에 갈 거냐&quot;는 질문에 망설임 없이 &quot;예스&quot;라고 적으며 글을 맺었다. 그리고 정말로, 그때의 다짐대로 다시 스타트업에 들어와 개발자로 일하고 있다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;5:1-5:84;122-205&quot; data-ke-size=&quot;size16&quot;&gt;그때의 나와 지금의 나는 무엇이 달라졌을까. 3년 전 써 둔 글을 다시 펼쳐 읽으며, 그동안 겪은 이야기와 조금은 달라진 마음가짐을 정리해보려 한다.&lt;/p&gt;
&lt;h2 data-sourcepos=&quot;7:1-7:17;207-223&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;정신없이 시작된 인수인계&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1086&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ctkz1o/dJMcageR9Is/gXur3kQRFhkZxkHOmvM6u0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ctkz1o/dJMcageR9Is/gXur3kQRFhkZxkHOmvM6u0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ctkz1o/dJMcageR9Is/gXur3kQRFhkZxkHOmvM6u0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fctkz1o%2FdJMcageR9Is%2FgXur3kQRFhkZxkHOmvM6u0%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;558&quot; height=&quot;419&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1086&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-sourcepos=&quot;13:1-13:195;444-638&quot; data-ke-size=&quot;size16&quot;&gt;인원이 적은 스타트업에서 새로운 사람을 뽑는 경우는 대체로 둘 중 하나다. 기존 인력이 빠져나가 그 빈자리를 메워야 하거나, 사업이 커지면서 손이 더 필요해지거나. 나는 전자였다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;13:1-13:195;444-638&quot; data-ke-size=&quot;size16&quot;&gt;전임 개발자가 건강 문제로 급하게 자리를 비우게 되면서 채용이 진행됐고, 마침 일자리를 찾고 있던 나에게 오퍼가 왔다. 양쪽 모두 급했던 만큼 절차도 빨랐다. 오퍼를 수락하고 3~4일 만에 곧바로 출근했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;13:1-13:195;444-638&quot; data-ke-size=&quot;size16&quot;&gt;전임자는 인수인계를 위해 본인의 업무를 최대한 문서로 정리해 두었다. 다만 그 문서는 '업무 기록'이었지, '신규 입사자를 위한 온보딩 가이드'는 아니었다. 그사이에도 마감이 박힌 정부 프로젝트는 굴러가야 했고, 이미 운영 중인 서비스의 이슈 대응도 멈출 수 없었다. 모든 걸 한꺼번에 붙잡을 수는 없으니, 무엇에 먼저 손을 댈지부터 정해야 했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;13:1-13:195;444-638&quot; data-ke-size=&quot;size16&quot;&gt;사실 새 조직에 들어간다는 건, 단순히 새로운 코드를 익히는 일만은 아니었다. 이미 한 번 조직에서 밀려나 본 적이 있는 사람에게는 '내가 이곳에서 필요한 사람인지'를 다시 증명해야 하는 일에 가까웠다. 그 걱정이 마냥 나쁘지만은 않았다. 덕분에 나는 첫날부터 손을 놓고 있을 수 없었고, 무엇부터 붙잡아야 할지 빠르게 살펴볼 수 있었다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;한마리의 토끼를 잡아라&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mw720/dJMcadvALQ5/0yrKo05C7AJPemYm7Py1k1/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mw720/dJMcadvALQ5/0yrKo05C7AJPemYm7Py1k1/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mw720/dJMcadvALQ5/0yrKo05C7AJPemYm7Py1k1/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fmw720%2FdJMcadvALQ5%2F0yrKo05C7AJPemYm7Py1k1%2Fimg.webp&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;579&quot; height=&quot;362&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-sourcepos=&quot;32:1-32:43;1309-1351&quot; data-ke-size=&quot;size16&quot;&gt;엔지니어링 관련 책을 읽다 보면 자주 등장하는 단어가 있다. 바로 '트레이드오프(trade-off)'다. 하나를 얻으려면 다른 하나를 내려놓아야 한다는 뜻이다. 빠른 개발 속도를 원한다면, 그만큼 높은 품질과 안정성은 어느 정도 양보해야 한다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;32:1-32:43;1309-1351&quot; data-ke-size=&quot;size16&quot;&gt;내 앞에 놓인 일은 한두개가 아니었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;23:1-26:22;1010-1088&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;23:1-23:18;1010-1027&quot;&gt;마감이 정해진 정부 프로젝트&lt;/li&gt;
&lt;li data-sourcepos=&quot;24:1-24:19;1028-1046&quot;&gt;운영 중인 서비스의 CS 대응&lt;/li&gt;
&lt;li data-sourcepos=&quot;25:1-25:20;1047-1066&quot;&gt;온보딩을 통한 사업&amp;middot;도메인 이해&lt;/li&gt;
&lt;li data-sourcepos=&quot;26:1-26:22;1067-1088&quot;&gt;기존에 구축된 인프라 아키텍처 분석&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;28:1-28:98;1090-1187&quot; data-ke-size=&quot;size16&quot;&gt;당장 급해 보이는 일은 많았지만, 이 모두를 동시에 끌고 갈 수는 없었다. 그래서 아이젠하워 매트릭스를 빌려 '급한 일'과 '중요한 일'의 축으로 해야 할 일들을 정리해봤다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;28:1-28:98;1090-1187&quot; data-ke-size=&quot;size16&quot;&gt;가장 먼저 잡아야 할 토끼는 분명했다. 전임자가 떠난 뒤에도 운영 중인 시스템의 이슈는 계속 터질 것이고, 그걸 감당하려면 시스템과 도메인을 이해하는 일이 무엇보다 급했다. 그래서 전임자가 자리를 비우기 전에, 사용 중인 AWS 서비스와 접근 방법을 프로젝트별로 정리해 직접 설명해달라고 부탁드렸다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;28:1-28:98;1090-1187&quot; data-ke-size=&quot;size16&quot;&gt;반복되는 루틴 업무는 페어로 함께 진행하며 조금씩 넘겨받았고, 덕분에 운영 업무는 빠르게 손에 익힐 수 있었다. 마지막으로 정부 프로젝트는 외주 인력에게 맡기는 방향으로 설득한 뒤, 그들이 처리해야 할 업무 목록을 빠르게 정리해 넘기며 온전히 위임했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;28:1-28:98;1090-1187&quot; 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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;첫 PM으로써의 역할, 미숙해도 괜찮아&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;700&quot; data-origin-height=&quot;997&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WlGQq/dJMcacXP2RG/GZk8EQX9kpPhYEdVqruk2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WlGQq/dJMcacXP2RG/GZk8EQX9kpPhYEdVqruk2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WlGQq/dJMcacXP2RG/GZk8EQX9kpPhYEdVqruk2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWlGQq%2FdJMcacXP2RG%2FGZk8EQX9kpPhYEdVqruk2K%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;360&quot; height=&quot;513&quot; data-origin-width=&quot;700&quot; data-origin-height=&quot;997&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정부 프로젝트를 진행하다 보니, 백엔드가 관여해야 할 부분이 생각보다 많았다. 네트워크와 보안, 데이터베이스, 서버 스펙처럼 먼저 협의해 결정해야 할 사항들이 아직 정해지지 않은 채 남아 있었기 때문이다. 그러다 보니 자연스럽게 내가 PM 역할까지 맡게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PM은 처음이라 서툰 부분도 있었다. 하지만 직접 워커로 부딪혀 본 경험이 의외의 무기가 됐다. 일이 어디서 막히고 무엇 때문에 늘어지는지를 몸으로 알고 있었던 덕에, 대부분의 협의와 소통은 생각보다 어렵지 않게 풀렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 솔직히, PM 역할 자체는 생각보다 까다로웠다. 협의나 소통이 어려웠다기보다, 코드를 깊게 파고들고 싶을 때마다 사람과 일정, 요구사항을 다시 정리하느라 손을 멈춰야 했기 때문이다. 코드만 붙잡고 있고 싶은 마음이 굴뚝같을 때가 많았다. 그래도 그 조율을 누군가 하지 않으면 결국 개발도 앞으로 나아가지 못한다는 걸 알았기에, 손을 놓을 수는 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 PM으로서 가장 먼저 한 일은 세 가지였다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;46:1-46:127;2074-2200&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째, 업무를 눈에 보이게 만들었다.&lt;/b&gt; 진행 상황을 한눈에 볼 수 있는 대시보드를 빠르게 도입하고, 그동안 메신저로 흩어져 오가던 업무 흐름을 하나의 프로세스로 정리했다. 소통 비용과 미스커뮤니케이션이 눈에 띄게 줄었다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;46:1-46:127;2074-2200&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째, 병목을 걷어냈다.&lt;/b&gt; 대표적인 게 API 명세였다. 스프레드시트에 일일이 손으로 정리하던 작업을, 팀원의 피드백을 받아 Swagger로 대체했다. 반복되는 일은 자동화로 넘기니, 같은 시간에 훨씬 많은 일이 돌아갔다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;46:1-46:127;2074-2200&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;셋째, 요구사항과 일정을 명확히 했다.&lt;/b&gt; 무엇을 언제까지 할지 구체적으로 정리한 뒤, 그 일정에 맞춰 외부 업체와 내부 인력이 협의할 사항들을 빠르게 확정 지었다. 덕분에 데드라인에 맞춰 기능 구현과 테스트까지 마칠 수 있었다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;기술 부채와 신뢰의 상관 관계&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1086&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0zKCP/dJMcaalnFG7/LhxkvVk4gvszdxB8rNhSHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0zKCP/dJMcaalnFG7/LhxkvVk4gvszdxB8rNhSHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0zKCP/dJMcaalnFG7/LhxkvVk4gvszdxB8rNhSHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0zKCP%2FdJMcaalnFG7%2FLhxkvVk4gvszdxB8rNhSHK%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;459&quot; height=&quot;344&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1086&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-sourcepos=&quot;56:1-56:85;2526-2610&quot; data-ke-size=&quot;size16&quot;&gt;정부 프로젝트가 후반부에 접어들 무렵, 또 하나의 큰 숙제가 기다리고 있었다. 클라우드에서 운영하던 인프라를 온프레미스 환경으로 옮기는 일이었다. 다행히 네트워킹을 통해 알게 된 개발자분과 함께 계약하여 지방을 오가며 작업했고, 순탄하진 않았지만 AWS에 의존하던 서비스와 기능을 하나씩 분석해 점진적으로 걷어낼 수 있었다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;56:1-56:85;2526-2610&quot; data-ke-size=&quot;size16&quot;&gt;문제는, 앞으로 한 발 내딛는 동안 뒤에서 발목을 잡는 것들이 있었다는 점이다. 기존에 개발해 둔 시스템들이 하나둘 말썽을 부리기 시작했다. 원인은 스타트업의 고질병, 기술 부채였다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;56:1-56:85;2526-2610&quot; data-ke-size=&quot;size16&quot;&gt;'일단 동작하게만' 만들어 둔 기능들은, 사용자가 늘고 유즈케이스가 많아질수록 숨어 있던 문제를 수면 위로 드러냈다. 그리고 그 문제들은 고스란히 고객의 불편으로 이어졌다. 정부 프로젝트에 매달리는 동안 이런 신호들이 방치됐고, 제품에 대한 신뢰도 조금씩 깎여 나가고 있었다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;56:1-56:85;2526-2610&quot; data-ke-size=&quot;size16&quot;&gt;처음&amp;nbsp;이&amp;nbsp;레거시를&amp;nbsp;마주했을&amp;nbsp;때는&amp;nbsp;솔직히&amp;nbsp;막막했다.&amp;nbsp;하지만&amp;nbsp;코드를&amp;nbsp;들여다볼수록,&amp;nbsp;만든&amp;nbsp;사람을&amp;nbsp;탓하는&amp;nbsp;마음보다&amp;nbsp;전임자&amp;nbsp;분이&amp;nbsp;먼저&amp;nbsp;떠올랐다.&amp;nbsp;이걸&amp;nbsp;대체&amp;nbsp;어떻게&amp;nbsp;혼자서&amp;nbsp;다&amp;nbsp;감당했을까.&amp;nbsp;주니어&amp;nbsp;백엔드&amp;nbsp;개발자&amp;nbsp;혼자서&amp;nbsp;3년간&amp;nbsp;이만한&amp;nbsp;규모를&amp;nbsp;끌고&amp;nbsp;오느라&amp;nbsp;얼마나&amp;nbsp;막막하고&amp;nbsp;고생스러웠을지,&amp;nbsp;그제야&amp;nbsp;눈에&amp;nbsp;들어왔다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;56:1-56:85;2526-2610&quot; data-ke-size=&quot;size16&quot;&gt;백오피스 없이 수작업으로 버티던 운영 업무도 한계에 다다랐다. 신규 프로젝트에 인력이 쏠리자 대응이 늦어졌고, 그 여파는 제품 설치 일정에까지 번졌다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;56:1-56:85;2526-2610&quot; data-ke-size=&quot;size16&quot;&gt;결국 필요한 건 단순히 '시간을 더 갈아 넣는' 대응이 아니었다. 이 문제들이 왜 발생하는지 그 뿌리를 먼저 짚고, 가장 빠르게 끊어낼 해법이 필요했다.&lt;/p&gt;
&lt;h2 data-sourcepos=&quot;60:1-60:16;2699-2714&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;AI와 함께 부채 갚기&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1252&quot; data-origin-height=&quot;1486&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sCi8D/dJMcaaeGBaH/dboV95xliJi8fXAzjklgkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sCi8D/dJMcaaeGBaH/dboV95xliJi8fXAzjklgkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sCi8D/dJMcaaeGBaH/dboV95xliJi8fXAzjklgkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsCi8D%2FdJMcaaeGBaH%2FdboV95xliJi8fXAzjklgkk%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;386&quot; height=&quot;458&quot; data-origin-width=&quot;1252&quot; data-origin-height=&quot;1486&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-sourcepos=&quot;75:1-75:199;3377-3575&quot; data-ke-size=&quot;size16&quot;&gt;이전 회사에서 AI에 의존해 업무를 진행하다, 팀장님에게 코드 품질에 대한 피드백을 받은 적이 있다. 그때는 '왜 이런 결과가 나왔을까'를 한참 곱씹었다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;75:1-75:199;3377-3575&quot; data-ke-size=&quot;size16&quot;&gt;이유는 명확했다. 당시 나는 프론트엔드와 타입스크립트 경험이 깊지 않았다. 그러다 보니 무엇이 안티패턴이고 무엇이 좋은 구조인지 판단할 기준이 없는 채로, 일단 '돌아가게 만드는 것'에만 집중했다. 게다가 참고할 수 있는 기존 코드마저 같은 문제를 안고 있었다. 결국 AI가 그 좋지 않은 코드를 컨텍스트로 참조하면서, 비슷하게 좋지 않은 결과물을 다시 만들어내는 악순환이 반복된 것이다.&lt;/p&gt;
&lt;blockquote data-sourcepos=&quot;72:1-72:49;3596-3644&quot; data-ke-style=&quot;style3&quot;&gt;
&lt;p data-sourcepos=&quot;72:3-72:49;3598-3644&quot; data-ke-size=&quot;size16&quot;&gt;AI는 좋은 컨텍스트를 주면 좋은 답을, 나쁜 컨텍스트를 주면 나쁜 답을 돌려준다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-sourcepos=&quot;74:1-74:188;3646-3833&quot; data-ke-size=&quot;size16&quot;&gt;이번 백오피스를 개발할 때는 같은 실수를 반복할 수 없었다. 그래서 역할을 나눴다. 클로드와 ChatGPT에게는 유지보수에 유리한 프론트 구조를 계속 리서치하고 정리하도록 맡겼다. 그동안 나는 실제로 이 서비스를 쓰는 사람들, 즉 CS 대응과 설치를 담당하는 직원분들을 직접 인터뷰하며 지금 필요한 기능과 그들이 겪는 문제를 파악했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;74:1-74:188;3646-3833&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-sourcepos=&quot;74:1-74:188;3646-3833&quot; data-ke-size=&quot;size16&quot;&gt;인터뷰에서 드러난 핵심 요구사항은 두 가지였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;78:1-79:40;3863-3948&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;78:1-78:46;3863-3908&quot;&gt;담당자 한 명을 거치지 않고도, 현장에서 직접 기기 세팅을 끝낼 수 있는 환경&lt;/li&gt;
&lt;li data-sourcepos=&quot;79:1-79:40;3909-3948&quot;&gt;문제가 생겼을 때 실시간으로 상황을 파악하고 대응할 수 있는 시스템&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;81:1-81:199;3950-4148&quot; data-ke-size=&quot;size16&quot;&gt;기존에 만들어 둔 원격 관리 시스템은 모니터링과 간단한 대응까지는 가능했지만, 기기 설치 프로세스 자체를 체계적으로 효율화하진 못했다. 그래서 검증된 백엔드는 그대로 살리되 낡은 부분만 걷어내고, 최소한의 리소스로 새 솔루션을 올리기로 했다. 클로드 디자인으로 새로 구축한 디자인 시스템과 대중적인 프론트엔드 컨벤션에 맞춰, 빠르게 기능을 구현해 나갔다.&lt;/p&gt;
&lt;h2 data-sourcepos=&quot;77:1-77:19;3577-3595&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;분산된 인프라가 치르는 비용&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;1254&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ARSbi/dJMcafNRLCv/iEED6pq1uxNXbO7QAANZlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ARSbi/dJMcafNRLCv/iEED6pq1uxNXbO7QAANZlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ARSbi/dJMcafNRLCv/iEED6pq1uxNXbO7QAANZlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FARSbi%2FdJMcafNRLCv%2FiEED6pq1uxNXbO7QAANZlK%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;432&quot; height=&quot;432&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;1254&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-sourcepos=&quot;92:1-92:241;4320-4560&quot; data-ke-size=&quot;size16&quot;&gt;한때 MSA가 유행하면서, 많은 기업이 모놀리식 시스템을 잘게 쪼개기 시작했다. 분산 시스템의 매력은 분명하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;87:1-88:36;4234-4310&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;87:1-87:41;4234-4274&quot;&gt;도메인끼리 영향을 주지 않고, 독립적으로 또 빠르게 배포할 수 있다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;88:1-88:36;4275-4310&quot;&gt;장애가 나도 그 영향 범위를 한 도메인 안에 가둘 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;90:1-90:117;4312-4428&quot; data-ke-size=&quot;size16&quot;&gt;하지만 그 반대편의 비용도 만만치 않다. 데이터가 흩어지고 트랜잭션이 쪼개지면서 복잡도가 올라가고, 안정적으로 운영하려면 추가적인 인프라와 설계가 따라붙는다. 결국 유지보수 비용과 운영 비용이 함께 불어난다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;90:1-90:117;4312-4428&quot; data-ke-size=&quot;size16&quot;&gt;우리 회사가 안고 있던 부채도 바로 여기에 있었다. 기존 레거시와 새로 개발한 시스템이 전부 분산된 채 돌아가고 있었고, 데이터 동기화마저 서버가 아니라 클라이언트 중심으로 이뤄지다 보니 데이터 손실로 인한 CS가 적지 않게 발생했다. 기기 하나를 등록하려면 두 개의 DB에 각각 데이터를 넣고, 애플리케이션 레벨에서 조인까지 해야 했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;90:1-90:117;4312-4428&quot; data-ke-size=&quot;size16&quot;&gt;이대로 두면 계속 발목이 잡힐 게 눈에 보였다. 그래서 데이터베이스를 점진적으로 통합하고, 모놀리식으로 운영할 수 있도록 시스템을 다시 설계하기로 했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;90:1-90:117;4312-4428&quot; data-ke-size=&quot;size16&quot;&gt;다행히 기기를 제어&amp;middot;관리하는 시스템의 데이터는 내부 인원만 사용&amp;middot;관리하는 데이터베이스라, 마이그레이션 자체는 크게 어렵지 않았다. 진짜 까다로운 건 중복된 테이블을 새로 모델링하는 일이었다. 결국 주 데이터베이스의 모델링을 기준으로 삼아, 중복된 컬럼과 데이터는 걷어내고 새로 필요한 컬럼과 테이블만 추가하는 방향으로 정리했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;90:1-90:117;4312-4428&quot; data-ke-size=&quot;size16&quot;&gt;마이그레이션은 이렇게 진행했다. 먼저 AI에게 전체 스키마와 새로 모델링한 테이블 구조를 참고해 마이그레이션 SQL을 만들어 달라고 요청한 뒤, 그 파일을 밀어넣고 애플리케이션 서버가 바라보는 엔드포인트를 새 DB로 바꿨다. 배포가 진행되는 동안 정합성이 깨진 데이터는 별도 검증 쿼리로 하나씩 수작업으로 맞췄다. 그렇게 두 개의 DB는 하나로 합쳐졌고, 기기 등록과 관리를 한곳에서 처리할 수 있는 백오피스를 완성할 수 있었다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;92:1-92:241;4320-4560&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-sourcepos=&quot;113:1-113:20;5333-5352&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;생존형 엔지니어링을 넘어, 엔지니어링의 개인화 &lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-sourcepos=&quot;96:1-96:19;4592-4610&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;나무만 보지는 않게 되었다&lt;/b&gt;&lt;/h3&gt;
&lt;p data-sourcepos=&quot;98:1-98:164;4612-4775&quot; data-ke-size=&quot;size16&quot;&gt;이전 스타트업에서 처음 의사결정을 내려야 했을 때는, 사수의 피드백도 없고 경험도 많지 않은 주니어 시절이었다. 그래서 내가 제대로 설계한 게 맞는지, 이 방향을 제시하는 것이 정말 도움이 될지 확신이 서지 않았다. 늘 불안했고, 그 갈증을 채우려 무리해서라도 스터디와 업무를 병행하곤 했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;98:1-98:164;4612-4775&quot; data-ke-size=&quot;size16&quot;&gt;그렇게 여러 번 실패도 겪고, 내가 입사하기 전 다른 엔지니어들이 내렸던 선택이 지금에 어떤 영향과 문제로 이어지는지를 직접 마주하고 나니, '이 선택을 하면 이런 결과로 이어지겠구나'가 점점 보이기 시작했다. 한때 나무가 아니라 숲을 보는 엔지니어가 되는 게 꿈이었던 나는, 아직 숲 전체를 본다고 확신하진 못한다. 다만 적어도 이제는 나무만 바라보지 않는다는 것은 느낄 수 있었다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;98:1-98:164;4612-4775&quot; data-ke-size=&quot;size16&quot;&gt;다만 나무만 보지 않게 되었다는 건, 생각보다 낭만적인 변화는 아니었다. 더 많은 것이 보인다는 건 더 많은 것을 신경 써야 한다는 뜻이기도 했으니까. 운영, 고객, 일정, 기술 부채, 그리고 이 코드를 언젠가 이어받을 다음 사람까지. 솔직히 짊어질 게 늘어 버거울 때도 있다. 그래도 이제는 그 무게를, 피하기보다 감당하는 자리로 조금씩 옮겨가고 있다는 생각이 든다.&lt;/p&gt;
&lt;h3 data-sourcepos=&quot;102:1-102:23;4994-5016&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;AI가 바꾼 것, 그리고 그 이면&lt;/b&gt;&lt;/h3&gt;
&lt;p data-sourcepos=&quot;104:1-104:106;5018-5123&quot; data-ke-size=&quot;size16&quot;&gt;AI의 발전으로 여러 직무의 역할 경계가 희미해지고, 전문 지식이라는 진입 장벽도 많이 낮아졌다. 덕분에 기존에 안고 있던 여러 문제가 풀리기도 했지만, 그에 따른 부작용도 함께 따라왔다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;114:1-117:17;5906-5948&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;114:1-114:8;5906-5913&quot;&gt;인지 부채&lt;/li&gt;
&lt;li data-sourcepos=&quot;115:1-115:9;5914-5922&quot;&gt;할루시네이션&lt;/li&gt;
&lt;li data-sourcepos=&quot;116:1-116:9;5923-5931&quot;&gt;편향된 관점&lt;/li&gt;
&lt;li data-sourcepos=&quot;117:1-117:17;5932-5948&quot;&gt;생각의 깊이가 얕아지는 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;119:1-119:163;5950-6112&quot; data-ke-size=&quot;size16&quot;&gt;AI로 모든 업무와 작업이 빨라졌다기보다는, 개인이 더 많은 것을 할 수 있게 되었다는 쪽에 더 가깝다는 생각이 든다. 더 빠르게 만드는 것은 결국 인간의 선택과 의지에 달려 있다고 생각한다. 맞는 방향으로 이끈다면 더 빨라질 것이고, 틀린 방향으로 간다면 오히려 전보다 느려질 수도 있다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;119:1-119:163;5950-6112&quot; data-ke-size=&quot;size16&quot;&gt;사실 이건 남의 이야기가 아니다. 이번 백오피스와 마이그레이션 작업에서도 큰 방향과 청사진은 내가 잡았지만, 세부 구현의 상당 부분은 AI가 메웠다. 덕분에 빨리 구현할 수 있었지만, 그만큼 '내가 이걸 전부 설명할 수 있나' 싶은 인지 부채도 남았다. 그래도 이제는 그걸 굳이 숨기기보다, AI 시대의 엔지니어가 새로 안고 가야 할 몫으로 받아들이고 해결 방법을 고민하고 있다.&lt;/p&gt;
&lt;h3 data-sourcepos=&quot;113:1-113:20;5333-5352&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;엔지니어링이 개인화되는 시대&lt;/b&gt;&lt;/h3&gt;
&lt;p data-sourcepos=&quot;121:1-121:122;5693-5814&quot; data-ke-size=&quot;size16&quot;&gt;지금 우리 회사도 비개발자분이 회사에 필요한 CRM 시스템을 직접 개발하고 구축하고 있다. 설계나 운영에 나는 관여하지 않는다. 그분들이 선택하고, 결정하고, 실행한다. 나는 그렇게 만들어진 시스템에 맞춰 업무를 진행하기도 하고, 필요에 따라 API를 개발하거나 좀 더 쉽게 풀 수 있는 방법을 제시하는 정도로만 거든다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;121:1-121:122;5693-5814&quot; data-ke-size=&quot;size16&quot;&gt;TDD와 익스트림 프로그래밍(XP)의 창시자로 잘 알려진 소프트웨어 엔지니어 켄트 벡(Kent Beck)은, ChatGPT를 처음 써본 뒤 이런 말을 남겼다.&lt;/p&gt;
&lt;blockquote data-sourcepos=&quot;129:1-129:67;6600-6666&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-sourcepos=&quot;129:3-129:67;6602-6666&quot; data-ke-size=&quot;size16&quot;&gt;내 기술의 90% 가치는 0달러가 되었고, 남은 10%의 레버리지는 1000배가 되었다. 나는 다시 조정해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-sourcepos=&quot;131:1-131:122;6668-6789&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 다소 과장된 표현처럼 들렸지만, 시간이 지날수록 그 말의 의미를 조금씩 이해하게 되는 것 같다. 단순히 많이 아는 것, 빠르게 구현하는 것, 익숙한 도구를 잘 다루는 것만으로는 더 이상 충분하지 않을 것이다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;131:1-131:122;6668-6789&quot; data-ke-size=&quot;size16&quot;&gt;오히려 중요한 것은 무엇을 만들지 결정하는 힘, 문제를 정확히 바라보는 힘, 복잡도를 감당할 수 있는 수준으로 유지하는 힘, 그리고 결과물이 정말 옳은 방향으로 가고 있는지 판단하는 힘이다. 사라진 것은 기술의 전부가 아니라, 기술을 바라보던 예전의 기준이었는지도 모른다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;131:1-131:122;6668-6789&quot; data-ke-size=&quot;size16&quot;&gt;정말 높은 수준의 지식을 요구하는 전문적인 엔지니어링의 영역은 소수에게 남을 것이다. 그 외의 많은 일은, 전문성과 엔지니어에 의존하지 않고 개인과 조직, 회사가 스스로 필요한 것을 만들어 쓰게 될 것이다. 내가 원하는 것을 손쉽게 구현하는 편리함을 한번 맛본 사람들은, 다시 이전으로 돌아가지 않을 테니까.&lt;/p&gt;
&lt;p data-sourcepos=&quot;131:1-131:122;6668-6789&quot; data-ke-size=&quot;size16&quot;&gt;예전의 나는 좋은 설계와 깊은 기술이 더 큰 가치를 만든다고 믿었고, 능력 있고 존경받는 엔지니어가 되고 싶었다. 지금도 그 마음이 완전히 사라진 건 아니다. 다만 시대가 바뀌고 일하는 방식이 바뀌는 만큼, 나도 '엔지니어'라는 이름 안에만 머물기보다 문제를 푸는 사람 쪽으로 무게를 조금씩 옮기고 있는지도 모르겠다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;131:1-131:122;6668-6789&quot; data-ke-size=&quot;size16&quot;&gt;그래서 앞으로 내가 어떤 엔지니어가 될 것이라고 지금 당장 확실히 말하긴 어렵다. 다만 한동안은 CS나 아키텍처 설계 책을 공부하기보다는, 비즈니스와 사람을 더 이해하는 데 시간을 쓸 것 같다. 그렇다고 엔지니어링을 완전히 놓진 않겠지만, 예전처럼 각 잡고 하기보다는 필요와 상황에 따라 하게 되지 않을까. 어쩌면 지금의 변화는 더 뛰어난 엔지니어가 되기 위한 다음 단계라기보다, 엔지니어라는 이름 바깥에서도 나를 증명해보려는 새로운 스텝에 가까운지도 모른다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;131:1-131:122;6668-6789&quot; data-ke-size=&quot;size16&quot;&gt;일단 회사 업무적으로 확실한 건, 옵저버빌리티를 더 확보하고 그동안 쌓인 레거시 부채를 어느 정도 정리하고 나면 앞으로 비즈니스는 더 쉽고 빠르게 실행할 수 있을 것이고, 의사결정의 기준도 전보다 한층 명확해질 것이다. 제품의 품질 또한 높아질 것이라 생각한다. 이건 확실하게 말할 수 있다.&lt;/p&gt;
&lt;h2 data-sourcepos=&quot;121:1-121:122;5693-5814&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;부록&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://codediary21.tistory.com/133&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://codediary21.tistory.com/133&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1781945634287&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;스타트업을 다니면서 느꼈던 현실과 생각들&quot; data-og-description=&quot;스타트업을 선택한 이유 일단 스타트업을 다닌 이유는 SI에서 클라이언트 개발자에서 백엔드로 이직을 하면서 네카라쿠배라는 대기업에서 일하는 경험을 하고 싶었지만 지금 당장 현실적으로&quot; data-og-host=&quot;codediary21.tistory.com&quot; data-og-source-url=&quot;https://codediary21.tistory.com/133&quot; data-og-url=&quot;https://codediary21.tistory.com/133&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/m9fOV/dJMb9aKOriJ/tJ33F6hNKFKjUI6PoV3tY0/img.jpg?width=720&amp;amp;height=1038&amp;amp;face=38_319_679_470,https://scrap.kakaocdn.net/dn/AcGAd/dJMb9b31oUr/qhksISUA4y5VcDpMFLssxK/img.jpg?width=720&amp;amp;height=1038&amp;amp;face=38_319_679_470,https://scrap.kakaocdn.net/dn/g96Rh/dJMb9hDaxaz/OKf0Hyg7PvFckwKreV9SB1/img.jpg?width=2048&amp;amp;height=1280&amp;amp;face=1088_185_1379_501&quot;&gt;&lt;a href=&quot;https://codediary21.tistory.com/133&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://codediary21.tistory.com/133&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/m9fOV/dJMb9aKOriJ/tJ33F6hNKFKjUI6PoV3tY0/img.jpg?width=720&amp;amp;height=1038&amp;amp;face=38_319_679_470,https://scrap.kakaocdn.net/dn/AcGAd/dJMb9b31oUr/qhksISUA4y5VcDpMFLssxK/img.jpg?width=720&amp;amp;height=1038&amp;amp;face=38_319_679_470,https://scrap.kakaocdn.net/dn/g96Rh/dJMb9hDaxaz/OKf0Hyg7PvFckwKreV9SB1/img.jpg?width=2048&amp;amp;height=1280&amp;amp;face=1088_185_1379_501');&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;스타트업을 다니면서 느꼈던 현실과 생각들&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;스타트업을 선택한 이유 일단 스타트업을 다닌 이유는 SI에서 클라이언트 개발자에서 백엔드로 이직을 하면서 네카라쿠배라는 대기업에서 일하는 경험을 하고 싶었지만 지금 당장 현실적으로&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;codediary21.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>커리어 &amp;middot; 일상/이직 &amp;middot; 커리어</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/199</guid>
      <comments>https://codediary21.tistory.com/199#entry199comment</comments>
      <pubDate>Sat, 20 Jun 2026 17:54:39 +0900</pubDate>
    </item>
    <item>
      <title>사가패턴을 통해 데이터 정합성을 보장하기</title>
      <link>https://codediary21.tistory.com/198</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;잘못된 설계가 만드는 오차&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1086&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/boaAMA/dJMcabxFu36/S3GncUrzkQKvyBsQq87Ue0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/boaAMA/dJMcabxFu36/S3GncUrzkQKvyBsQq87Ue0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/boaAMA/dJMcabxFu36/S3GncUrzkQKvyBsQq87Ue0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FboaAMA%2FdJMcabxFu36%2FS3GncUrzkQKvyBsQq87Ue0%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;611&quot; height=&quot;458&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1086&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 환경에서 잘못된 설계 탓에 어긋난 데이터는 원인을 추적하기가 쉽지 않다. 실제 사용자가 서비스를 쓰고 여러 시스템이 맞물려 돌아가다 보니 버그를 재현하기 어렵고, 문제 자체도 불규칙하고 간헐적으로 나타나기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 이렇게 어긋난 데이터를 막는 것은 불가능할까? 그렇지 않다. 로그를 심어 사후에 추적하는 방법도 있지만, 더 근본적인 해결은 설계 단계에서부터 트랜잭션의 경계와 예외 발생 시 로직의 흐름을 명확히 정의하는 데서 출발한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;실제 주문 로직을 통해 알아보기&lt;/b&gt;&lt;/h2&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@Transactional
public PaymentResponse requestPayment(Long memberId, Long orderId) {
    Order order = orderRepository.findByIdAndMemberId(orderId, memberId)
            .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.ORDER_NOT_FOUND));

    if (order.getStatus() != OrderStatus.PENDING) {
        throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS, &quot;결제 대기 상태의 주문만 결제할 수 있습니다.&quot;);
    }

    if (paymentRepository.findByOrderIdAndStatusNot(orderId, PaymentStatus.FAILED).isPresent()) {
        throw new BusinessException(ErrorCode.DUPLICATE_REQUEST, &quot;이미 결제가 진행된 주문입니다.&quot;);
    }

    Map&amp;lt;String, Object&amp;gt; paymentResult;
    try {
        paymentResult = externalPaymentClient.requestPayment(order.getOrderNumber(), order.getTotalAmount());
    } catch (BusinessException e) {
        for (var item : order.getItems()) {
            Product product = productRepository.findByIdWithLock(item.getProduct().getId())
                    .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
            product.restoreStock(item.getQuantity());
        }

        Payment failedPayment = Payment.builder()
                .order(order)
                .paymentKey(&quot;FAILED-&quot; + java.util.UUID.randomUUID().toString().substring(0, 8))
                .amount(order.getTotalAmount())
                .build();
        failedPayment.fail();
        paymentRepository.save(failedPayment);
        order.changeStatus(OrderStatus.CANCELLED);

        applicationEventPublisher.publishEvent(OrderCancelledEvent.builder()
                .data(OrderCancelledEvent.OrderCancelledData.builder()
                        .orderId(order.getId())
                        .orderNumber(order.getOrderNumber())
                        .reason(&quot;결제 실패로 인한 자동 취소&quot;)
                        .refundAmount(java.math.BigDecimal.ZERO)
                        .build())
                .build());

        throw e;
    }

    String paymentKey = paymentResult.get(&quot;paymentKey&quot;).toString();

    Payment payment = Payment.builder()
            .order(order)
            .paymentKey(paymentKey)
            .amount(order.getTotalAmount())
            .build();
    payment.approve(LocalDateTime.now(clock));
    paymentRepository.save(payment);

    order.changeStatus(OrderStatus.PAID);

    OrderPaidEvent event = OrderPaidEvent.builder()
            .data(OrderPaidEvent.OrderPaidData.builder()
                    .orderId(order.getId())
                    .orderNumber(order.getOrderNumber())
                    .paymentKey(paymentKey)
                    .amount(order.getTotalAmount())
                    .paidAt(payment.getPaidAt())
                    .build())
            .build();
    applicationEventPublisher.publishEvent(event);

    return PaymentResponse.from(payment);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드의 문제는 트랜잭션 경계에 있다. 핵심은 catch 블록이다. 결제 실패 시 재고를 복원하고, 실패 기록을 남기고, 주문을 취소 상태로 바꾸고, 취소 이벤트까지 발행하는 보상 로직을 작성해두었다. 그러나 마지막 throw e가 같은 트랜잭션 안에서 예외를 다시 던지면서, 방금 수행한 보상 네 줄이 전부 롤백된다. 결국 재고는 복원되지 않고 주문은 PENDING 상태에 영구히 남는다. 보상하려고 짠 코드가 보상을 무효화하는 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 더해, 외부 결제 API 호출이 트랜잭션 경계 안에 있다는 점도 문제다. 외부 시스템은 우리 DB가 롤백된다고 함께 되돌아가지 않으므로, 결제는 성공했는데 로컬 트랜잭션은 롤백되는 순간 &quot;돈은 빠져나갔지만 주문에는 반영되지 않은&quot; 상태가 만들어진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;952&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHbDUs/dJMcafGTDSi/3PYUKkPhiEreEipClRPDV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHbDUs/dJMcafGTDSi/3PYUKkPhiEreEipClRPDV1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHbDUs/dJMcafGTDSi/3PYUKkPhiEreEipClRPDV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHbDUs%2FdJMcafGTDSi%2F3PYUKkPhiEreEipClRPDV1%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;399&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;952&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;트랜잭션 경계 개선하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 경계는 단순히 throw를 빼는 식으로 손대기보다는, 각 메서드의 책임을 잘게 나누고 상위의 오케스트레이션 레이어가 전체 흐름을 조율하도록 하는 편이 유지보수에 유리하다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class PaymentOrchestrator {

    private final PaymentService paymentService;
    private final PaymentCompensationService paymentCompensationService;
    private final ExternalPaymentClient externalPaymentClient;

    public PaymentResponse requestPayment(Long memberId, Long orderId) {
        // 1단계: 검증 &amp;mdash; 짧은 읽기 전용 트랜잭션
        PaymentTarget target = paymentService.validatePaymentTarget(memberId, orderId);

        // 2단계: 외부 결제 호출 &amp;mdash; DB 트랜잭션 밖.
        // 외부 I/O 동안 DB 커넥션과 락을 점유하지 않는다(기존에는 호출 내내 트랜잭션이 열려 있었다).
        Map&amp;lt;String, Object&amp;gt; paymentResult;
        try {
            paymentResult = externalPaymentClient.requestPayment(target.orderNumber(), target.totalAmount());
        } catch (BusinessException e) {
            // 3a단계: 보상 트랜잭션 &amp;mdash; 별도 빈의 독립 트랜잭션에서 커밋된다.
            // 보상이 &quot;커밋된 뒤에&quot; 예외를 다시 던지므로, 기존처럼 재throw가 보상을 롤백시키지 않는다.
            paymentCompensationService.compensatePaymentFailure(orderId, &quot;결제 실패로 인한 자동 취소&quot;);
            throw e;
        }

        // 3b단계: 승인 트랜잭션 &amp;mdash; 외부 호출 동안 주문 상태가 바뀌었을 수 있으므로 내부에서 재검증한다.
        return paymentService.approvePayment(orderId, paymentResult.get(&quot;paymentKey&quot;).toString());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final ExternalPaymentClient externalPaymentClient;
    private final ApplicationEventPublisher applicationEventPublisher;
    private final Clock clock;

    /**
     * 결제 가능 여부 검증 &amp;mdash; 짧은 읽기 전용 트랜잭션.
     * 여기서 통과해도 외부 호출 동안 상태가 바뀔 수 있으므로(TOCTOU),
     * 최종 검증은 approvePayment의 상태 전이 가드가 다시 수행한다.
     */
    @Transactional(readOnly = true)
    public PaymentTarget validatePaymentTarget(Long memberId, Long orderId) {
        Order order = orderRepository.findByIdAndMemberId(orderId, memberId)
                .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.ORDER_NOT_FOUND));

        if (order.getStatus() != OrderStatus.PENDING) {
            throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS, &quot;결제 대기 상태의 주문만 결제할 수 있습니다.&quot;);
        }

        if (paymentRepository.findByOrderIdAndStatusNot(orderId, PaymentStatus.FAILED).isPresent()) {
            throw new BusinessException(ErrorCode.DUPLICATE_REQUEST, &quot;이미 결제가 진행된 주문입니다.&quot;);
        }

        return new PaymentTarget(order.getId(), order.getOrderNumber(), order.getTotalAmount());
    }

    /**
     * 결제 승인 반영 &amp;mdash; 독립 로컬 트랜잭션.
     * changeStatus(PAID)가 상태 머신 가드 역할을 한다: 외부 호출 동안 주문이
     * 취소됐다면(PENDING이 아니면) 여기서 예외가 나고 승인은 기록되지 않는다.
     * 이때 외부 결제는 이미 승인된 상태로 남으므로, 다음 단계의 사가에서는
     * &quot;외부 결제 취소&quot;라는 보상 트랜잭션이 추가로 필요하다.
     */
    @Transactional
    public PaymentResponse approvePayment(Long orderId, String paymentKey) {
        Order order = orderRepository.findByIdForUpdate(orderId)
                .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.ORDER_NOT_FOUND));

        // 상태 머신 가드: PENDING이 아니면(예: 외부 호출 동안 취소됨) 여기서 예외 발생
        order.changeStatus(OrderStatus.PAID);

        Payment payment = Payment.builder()
                .order(order)
                .paymentKey(paymentKey)
                .amount(order.getTotalAmount())
                .build();
        payment.approve(LocalDateTime.now(clock));
        paymentRepository.save(payment);

        OrderPaidEvent event = OrderPaidEvent.builder()
                .data(OrderPaidEvent.OrderPaidData.builder()
                        .orderId(order.getId())
                        .orderNumber(order.getOrderNumber())
                        .paymentKey(paymentKey)
                        .amount(order.getTotalAmount())
                        .paidAt(payment.getPaidAt())
                        .build())
                .build();
        applicationEventPublisher.publishEvent(event);

        return PaymentResponse.from(payment);
    }

    @Transactional
    public PaymentCancelResponse cancelPayment(Long memberId, String paymentKey, String reason) {
        Payment payment = paymentRepository.findByPaymentKey(paymentKey)
                .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.PAYMENT_NOT_FOUND));

        Order order = orderRepository.findByIdForUpdate(payment.getOrder().getId())
                .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.ORDER_NOT_FOUND));

        if (!order.getMember().getId().equals(memberId)) {
            throw new BusinessException(ErrorCode.ORDER_NOT_FOUND);
        }

        if (payment.getStatus() != PaymentStatus.APPROVED) {
            throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS, &quot;승인된 결제만 취소할 수 있습니다.&quot;);
        }

        order.changeStatus(OrderStatus.REFUND_REQUESTED);
        externalPaymentClient.cancelPayment(paymentKey);
        payment.cancel();
        order.changeStatus(OrderStatus.REFUNDED);

        for (var item : order.getItems()) {
            int remaining = item.getActiveQuantity();
            if (remaining &amp;lt;= 0) continue;
            Product product = productRepository.findByIdWithLock(item.getProduct().getId())
                    .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
            product.restoreStock(remaining);
            item.cancelQuantity(remaining);
        }

        return PaymentCancelResponse.builder()
                .paymentKey(paymentKey)
                .status(payment.getStatus().name())
                .cancelledAt(LocalDateTime.now(clock))
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentCompensationService {

    private final OrderRepository orderRepository;
    private final PaymentRepository paymentRepository;
    private final ProductRepository productRepository;
    private final ApplicationEventPublisher applicationEventPublisher;

    /**
     * 결제 실패 보상: 재고 복원 &amp;rarr; 실패 결제 기록 &amp;rarr; 주문 취소 &amp;rarr; 취소 이벤트 발행.
     *
     * 호출자(PaymentOrchestrator)에 트랜잭션이 없으므로 plain @Transactional로도
     * 항상 새 트랜잭션이 열린다. 이미 트랜잭션이 열린 컨텍스트에서 호출될 가능성이
     * 생기면 propagation = REQUIRES_NEW를 명시해 독립 커밋을 보장해야 한다.
     */
    @Transactional
    public void compensatePaymentFailure(Long orderId, String reason) {
        Order order = orderRepository.findByIdForUpdate(orderId)
                .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.ORDER_NOT_FOUND));

        // 멱등 가드: 이미 보상됐거나(CANCELLED) 다른 흐름이 선점했다면(PAID 등) 재실행하지 않는다.
        // 보상이 중복 실행되면 재고가 두 번 복원되는 정합성 위반이 생긴다.
        if (order.getStatus() != OrderStatus.PENDING) {
            log.info(&quot;결제 실패 보상 스킵 - orderId: {}, status: {}&quot;, orderId, order.getStatus());
            return;
        }

        for (var item : order.getItems()) {
            Product product = productRepository.findByIdWithLock(item.getProduct().getId())
                    .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
            product.restoreStock(item.getQuantity());
        }

        Payment failedPayment = Payment.builder()
                .order(order)
                .paymentKey(&quot;FAILED-&quot; + UUID.randomUUID().toString().substring(0, 8))
                .amount(order.getTotalAmount())
                .build();
        failedPayment.fail();
        paymentRepository.save(failedPayment);

        order.changeStatus(OrderStatus.CANCELLED);

        // 이 트랜잭션은 예외 없이 정상 커밋되므로 AFTER_COMMIT 리스너가 실제로 발행한다.
        // (기존 코드는 같은 트랜잭션에서 재throw &amp;rarr; 전체 롤백 &amp;rarr; 보상&amp;middot;이벤트 모두 무위)
        applicationEventPublisher.publishEvent(OrderCancelledEvent.builder()
                .data(OrderCancelledEvent.OrderCancelledData.builder()
                        .orderId(order.getId())
                        .orderNumber(order.getOrderNumber())
                        .reason(reason)
                        .refundAmount(BigDecimal.ZERO)
                        .build())
                .build());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 코드를 분리해 각 트랜잭션의 경계를 명확히 하면, 단계별 처리가 독립적인 트랜잭션으로 커밋되어 보상이 본래 의도대로 동작한다. 또한 각 단계가 실패하면 그 지점에서 예외가 드러나므로 에러 트레이스를 따라 디버깅하기도 수월하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이렇게 트랜잭션을 쪼갠 대가로, 단계와 단계 사이에는 정합성이 일시적으로 깨질 수 있는 구간이 생긴다. 가령 외부 결제는 이미 승인됐는데 로컬 승인 트랜잭션이 실패하면, &quot;결제는 됐지만 주문에 반영되지 않은&quot; 상태가 남는다. 이 빈틈을 메우는 것이 다음 절에서 다룰 보상 트랜잭션, 곧 사가 패턴이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;사가 패턴을 설계해보기&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;580&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OuBKP/dJMcacKbEEp/IrxJNRjmJ0xknPzcmr94z1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OuBKP/dJMcacKbEEp/IrxJNRjmJ0xknPzcmr94z1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OuBKP/dJMcacKbEEp/IrxJNRjmJ0xknPzcmr94z1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOuBKP%2FdJMcacKbEEp%2FIrxJNRjmJ0xknPzcmr94z1%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;736&quot; height=&quot;290&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;580&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;1472&quot; data-origin-height=&quot;500&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sXXTC/dJMcag6PtI6/lZxIYVESmXqvCfbcE0QLHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sXXTC/dJMcag6PtI6/lZxIYVESmXqvCfbcE0QLHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sXXTC/dJMcag6PtI6/lZxIYVESmXqvCfbcE0QLHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsXXTC%2FdJMcag6PtI6%2FlZxIYVESmXqvCfbcE0QLHk%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;745&quot; height=&quot;253&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;500&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두번째는 코레오그래피 방식인데 이 방식은 오케스트레이션 방식과 다르게 이벤트를 기반으로 상호작용하는 형태로 구현되어 서로 간 결합도가 낮다. 예를 들어 정상 흐름에서는 주문 생성 &amp;rarr; (주문 생성됨 이벤트) &amp;rarr; 결제 &amp;rarr; (결제 완료 이벤트) &amp;rarr; 배송처럼 각 단계가 앞 단계의 이벤트를 듣고 이어진다. 실패 시에는 반대 방향으로 결제 실패 &amp;rarr; (취소 이벤트) &amp;rarr; 재고 복원 &amp;rarr; 환불처럼 보상 이벤트가 연쇄된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;코레오그래피 사가 구현하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞 절에서 결제 흐름을 PaymentOrchestrator로 분리하면서 하나의 긴 트랜잭션을 단계별 독립 트랜잭션으로 쪼갰다. 덕분에 외부 결제 I/O가 진행되는 동안 DB 커넥션과 락을 붙잡고 있지 않아도 되니 주문 처리를 더 늘리기 좋아졌고, 결제가 실패하더라도 보상이 끝날 때까지 기다리지 않고 바로 응답하므로 사용자 입장에서도 더 빠른 결과를 받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 트랜잭션을 쪼갠 데에는 대가가 따른다. 각 단계 사이에 데이터 정합성이 일시적으로 깨지는 구간이 생긴 것이다. 결제는 FAILED로 커밋됐는데 재고는 아직 차감된 채 남아 있는 순간이 있고, 같은 이벤트가 두 번 전달되면 보상이 두 번 실행되어 재고가 실제보다 늘어날 수도 있다. 이 빈틈을 메우는 것이 사가다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 현재의 발행&amp;middot;소비 골격&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 이벤트는 트랜잭션이 커밋된 뒤에 발행된다. PaymentService.recordPaymentFailure가 실패 결제(FAILED)를 커밋하면서 ApplicationEventPublisher로 도메인 이벤트를 던지면, AFTER_COMMIT 단계의 리스너가 그것을 받아 브로커로 내보낸다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// OrderEventListener &amp;mdash; AFTER_COMMIT에서 브로커 발행으로 릴레이
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handlePaymentFailed(PaymentFailedEvent event) {
    orderEventPublisher.publishPaymentFailed(event);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// RabbitMQEventPublisher &amp;mdash; 실제 발행 지점
public void publishPaymentFailed(PaymentFailedEvent event) {
    try {
        rabbitTemplate.convertAndSend(RabbitMQConfig.ORDER_EXCHANGE, RabbitMQConfig.PAYMENT_FAILED_KEY, event);
        log.info(&quot;Published PaymentFailedEvent: orderId={}&quot;, event.getData().getOrderId());
    } catch (Exception e) {
        // AFTER_COMMIT 직접 발행이라 브로커 장애 시 조용히 유실된다(측정 포인트).
        log.error(&quot;Failed to publish PaymentFailedEvent: orderId={}&quot;, event.getData().getOrderId(), e);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발행된 payment.failed는 exchange에서 도메인별 큐 2개로 팬아웃된다. 한쪽은 재고 복원을 맡는 PaymentFailedStockHandler, 다른 한쪽은 주문 취소를 맡는 PaymentFailedOrderHandler다. 두 핸들러는 서로를 모른 채, 각자 독립된 트랜잭션&amp;middot;재시도&amp;middot;DLQ 단위로 같은 이벤트에 반응한다. 누가 시켜서가 아니라 스스로 구독해 처리한다는 것, 이것이 코레오그래피다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발행을 AFTER_COMMIT에 거는 이유는 트랜잭션이 롤백되면 이벤트도 나가면 안 되기 때문이다. 이것이 첫 절의 버그, 즉 같은 트랜잭션에서 재throw하는 바람에 보상과 이벤트가 모두 함께 롤백되던 문제를 구조적으로 피하는 방법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 여기에 또 다른 함정이 있다. 커밋은 이미 끝났는데 convertAndSend가 실패하면 &amp;mdash; 브로커가 다운됐거나 네트워크가 순간 끊기면 &amp;mdash; 그 이벤트는 그냥 증발한다. 위 코드의 catch 블록이 로그만 남기고 삼켜버리기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 전제 ① &amp;mdash; 신뢰성 있는 발행 (현재는 일부러 비워둔 자리)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 솔직하게 짚고 가자. 사가를 아무리 잘 구현해도 이벤트 유실 자체는 줄지 않는다. 유실은 발행 경로의 문제이지 사가의 문제가 아니기 때문이다. 그래서 사가는 발행 신뢰성 보강과 한 세트로 가야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 가벼운 보강은 RabbitMQ의 publisher confirm을 켜는 것이다. 브로커가 메시지를 받았는지 확인(ack)받고, 못 받았으면(nack) 다시 발행한다. 라우팅 자체가 실패한 경우는 mandatory로 잡는다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  rabbitmq:
    publisher-confirm-type: correlated   # 비동기 confirm
    publisher-returns: true              # 라우팅 실패 반환
    template:
      mandatory: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것만으로도 &quot;브로커는 살아 있는데 메시지를 놓치는&quot; 경우는 막힌다. 하지만 커밋과 발행 사이에 프로세스가 죽으면 여전히 유실된다. AFTER_COMMIT은 트랜잭션 바깥이라, 커밋은 끝났는데 발행 코드에 도달하기 전에 인스턴스가 내려가면 그 이벤트는 영영 나가지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 마지막 빈틈까지 막는 근본적인 해법이 Outbox 패턴이다. 이벤트를 브로커로 바로 보내는 대신, 결제를 FAILED로 기록하는 그 트랜잭션 안에서 outbox 테이블에 함께 INSERT한다. 둘이 같은 트랜잭션이라 함께 커밋되거나 함께 롤백되므로, &quot;커밋은 됐는데 이벤트가 없는&quot; 상태가 원천적으로 생기지 않는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 전제 ② &amp;mdash; 멱등한 소비&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 브로커는 보통 at-least-once로 전달한다. 즉 같은 이벤트가 두 번 이상 도착할 수 있다. 재시도든 재배달이든 컨슈머 재기동이든, 중복은 언제든 일어난다. 멱등 처리가 없으면 보상이 두 번 실행되어 재고가 실제보다 많아지는 정합성 위반이 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 두 보상 핸들러 모두에 방어를 두 겹으로 깔았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 겹은 명시적인 멱등 키다. 이벤트마다 고유한 event_id를 부여하고, (event_id, handler)에 UNIQUE 제약을 건 processed_events 테이블에 선점 INSERT를 시도한다. 핸들러별로 키를 잡는 이유는, 같은 이벤트라도 재고 핸들러와 주문 핸들러가 각각 한 번씩은 처리해야 하기 때문이다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// PaymentFailedOrderHandler.cancelOrder &amp;mdash; 비즈니스 트랜잭션 안에서 선점
if (!idempotentEventProcessor.tryProcess(event.getEventId(), HANDLER_NAME)) {
    log.info(&quot;주문 취소 스킵(중복 이벤트) - ...&quot;);
    return;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// IdempotentEventProcessor &amp;mdash; MANDATORY: 반드시 핸들러의 트랜잭션 안에서 호출된다
@Transactional(propagation = Propagation.MANDATORY)
public boolean tryProcess(String eventId, String handler) {
    if (processedEventRepository.existsByEventIdAndHandler(eventId, handler)) {
        return false;
    }
    processedEventRepository.save(/* eventId, handler, processedAt */);
    return true;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에는 설계 의도가 두 가지 담겨 있다. 하나는 전파 속성을 MANDATORY로 둔 것이다. 처리 기록과 비즈니스 부작용이 반드시 같은 트랜잭션에 묶이도록 강제해서, &quot;기록은 됐는데 처리가 안 됨&quot;이나 그 반대가 생기지 않게 한다. 다른 하나는 exists 체크를 어디까지나 빠른 경로로만 본 것이다. 동시에 들어온 두 트랜잭션이 둘 다 체크를 통과하더라도, 커밋 시점에 UNIQUE 제약 위반으로 늦은 쪽 트랜잭션 전체가 롤백되므로 부작용은 결국 한 번만 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 겹은 도메인 상태 가드다. 멱등 키는 &quot;같은 이벤트가 다시 온 경우&quot;만 막는다. 사용자 취소 같은 다른 흐름과 겹치는 중복은 도메인의 상태가 막아야 한다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// 주문 쪽: PENDING이 아니면(이미 취소됐거나 다른 흐름이 선점) 전이하지 않는다
if (order.getStatus() != OrderStatus.PENDING) {
    log.info(&quot;주문 취소 스킵(상태 가드) - orderId: {}, status: {}&quot;, ...);
    return;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 재고 쪽: 이미 복원된 수량은 다시 복원하지 않고,
// 복원분은 cancelledQuantity로 마킹해 이중 복원을 구조적으로 차단한다
int remaining = item.getActiveQuantity();
if (remaining &amp;lt;= 0) continue;
product.restoreStock(remaining);
item.cancelQuantity(remaining);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;findByIdForUpdate로 비관적 락을 잡고 상태를 확인하므로, 두 보상이 동시에 들어와도 한쪽이 먼저 커밋하면 다른 쪽은 가드에 걸려 스킵된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 주의할 점이 있다. 관찰용으로 두는 consumed_events 테이블은 멱등 키로 쓸 수 없다. 이 테이블은 재전달된 중복까지 그대로 쌓아 중복률을 측정하는 것이 목적이라, event_id에 일부러 UNIQUE 제약을 걸지 않았다. 멱등을 책임지는 것은 UNIQUE 제약이 있는 processed_events이고, 둘은 역할이 전혀 다르다. 하나는 방어를 위한 자물쇠이고, 하나는 그저 무슨 일이 있었는지 비추는 거울이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. 순서는 보장되지 않는다&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요약:&lt;/b&gt; 사가는 순서를 맞춰주지 않는다. 순서가 뒤바뀌어도 사고가 안 나게 막아줄 뿐이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스너 동시성이 1보다 크고(concurrency: 2, max-concurrency: 8) 큐가 여러 개면, 이벤트는 발행 순서대로 도착하지 않는다. 장애가 없어도 그렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 두 문제를 구분하자. &lt;b&gt;순서 보장&lt;/b&gt;은 파티션 키로 같은 엔티티를 같은 컨슈머에 몰아주는, 코레오그래피 바깥의 일이다. 사가가 맡는 건 &lt;b&gt;순서가 꼬여도 부작용이 안 생기게 막는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방법은 상태 머신이다. 이미 CANCELLED된 주문에 취소 이벤트가 늦게 도착해도, 허용 안 되는 전이를 거부하면 끝이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// Order.changeStatus &amp;mdash; 전이 가드
public void changeStatus(OrderStatus nextStatus) {
    this.status.validateTransitionTo(nextStatus);  // 허용되지 않는 전이면 BusinessException
    this.status = nextStatus;
}

// OrderStatus &amp;mdash; 허용 전이를 명시한 상태 머신
PENDING, Set.of(PAID, CANCELLED),
PAID,    Set.of(PREPARING, REFUND_REQUESTED, CANCELLED, PARTIALLY_CANCELLED),
...&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5. 흐름이 흩어져 멈춤(stuck)을 잡기 어렵다&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요약:&lt;/b&gt; &quot;처리 실패&quot;는 DLQ로 잡는다. &quot;트리거 유실&quot;은 아무도 못 잡으니 스위퍼가 필요하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코레오그래피는 흐름이 한곳에 모여 있지 않다. 어딘가 멈춰 있어도 알아챌 주체가 없다. 멈춤은 두 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;① 이벤트는 도착했는데 처리가 실패한 경우.&lt;/b&gt; DLQ로 격리했다. 큐가 도메인별로 나뉘어 있어 재고 보상이 DLQ로 가도 주문 보상은 멀쩡히 돈다. 대신 재고는 복원됐는데 주문은 PENDING인, 보상의 부분 실패가 생길 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;② 트리거 이벤트 자체가 유실된 경우.&lt;/b&gt; 이쪽이 더 고약하다. DLQ에도 흔적이 없어 주문이 PENDING에 영원히 남는데 아무도 모른다. 발행 신뢰성(2절)을 보강해도 100%는 아니라, PENDING에 오래 머문 주문을 주기적으로 훑어 보상을 다시 트리거하는 스위퍼가 마지막 안전망으로 필요하다. 아직 코드엔 없는 다음 과제다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;// 후속 과제 스케치 &amp;mdash; 아직 코드에 없다
@Scheduled(fixedDelay = 60_000)
public void sweep() {
    LocalDateTime deadline = LocalDateTime.now().minusMinutes(5);
    for (Long orderId : orderRepository.findStuckPendingOrderIds(deadline)) {
        paymentService.recordPaymentFailure(orderId, &quot;타임아웃으로 인한 자동 취소&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스위퍼는 보상 로직을 직접 부르면 안 된다. &lt;b&gt;사가 트리거를 다시 쏘는 방식&lt;/b&gt;이어야 멱등 가드가 이벤트 체인을 타고 일관되게 동작한다. 그런데 recordPaymentFailure엔 &quot;이미 결제 행이 있으면 발행 안 함&quot; 가드가 있어서, 기록은 됐는데 발행만 유실된 케이스는 이 경로로 못 잡는다. 그래서 이 가드를 우회하는 재발행 경로가 함께 필요하다 &amp;mdash; Outbox와 스위퍼가 한 세트인 이유다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6. 사가는 은탄환이 아니다&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;요약:&lt;/b&gt; 사가는 유실&amp;middot;stuck은 못 줄인다. 정합성(중복 보상)만 잡는다. 숫자로도 그렇다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 강도의 장애를 주입하는 카오스 테스트를 Before/After로 돌려 비교했다. &lt;b&gt;무엇을 지표로 보느냐&lt;/b&gt;가 핵심이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;유실률&lt;/b&gt; &amp;mdash; 사가는 발행 경로를 안 건드리니 안 움직인다. Before/After가 같게 나오는 게 정상이고, 그게 &quot;사가와 발행 신뢰성은 별개&quot;라는 증거다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정합성&lt;/b&gt; &amp;mdash; 사가가 실제로 잡는 곳. 중복 보상으로 인한 invariant 위반은 이번 구현이 직접 책임진다. (stuck은 트리거 유실분만큼 스위퍼의 몫으로 남는다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;측정 조건은 양쪽 똑같다. k6로 VUS 30&amp;middot;60초 부하, 시작 20초에 RabbitMQ를 10초간 내렸다 올렸다. Before는 멱등 2중 방어(processed_events 키 + activeQuantity/cancelQuantity 가드)를 꺼둔 무방비 빌드, After는 현재 코드다.&lt;/p&gt;
&lt;div&gt;지표Before (멱등 OFF)After (멱등 ON)
&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&gt;이벤트 유실률&lt;/td&gt;
&lt;td&gt;18.6%&lt;/td&gt;
&lt;td&gt;17.0% &amp;mdash; &lt;b&gt;변화 없음&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재고 invariant 위반&lt;/td&gt;
&lt;td&gt;15건 (재고 +15)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;0건&lt;/b&gt; (재고 +0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stuck 주문 수&lt;/td&gt;
&lt;td&gt;23건&lt;/td&gt;
&lt;td&gt;21건 &amp;mdash; &lt;b&gt;변화 없음&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표가 말하는 건 하나다. 유실과 stuck은 그대로, &lt;b&gt;재고 invariant 위반만 15 &amp;rarr; 0으로 떨어졌다.&lt;/b&gt; 멱등 2중 방어가 유일하게 움직인 지표다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(보조 지표, After 기준: 순서 꼬임 93.6%로 구조적이라 줄지 않음. E2E p99 약 28초, lag 회복 16초 &amp;mdash; 장애 중 큐 적체의 후행 효과로 Before도 동급.)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;div&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&gt;멱등한 소비 (전제)&lt;/td&gt;
&lt;td&gt;멱등 키 + 상태 가드 &lt;b&gt;2중 방어&lt;/b&gt; ✅ 완료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;신뢰성 있는 발행 (전제)&lt;/td&gt;
&lt;td&gt;confirms &amp;rarr; Outbox 경로만 확정, 측정 위해 비워둠&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;순서 (한계)&lt;/td&gt;
&lt;td&gt;상태 머신 가드로 부작용 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;부분 실패 (한계)&lt;/td&gt;
&lt;td&gt;도메인별 DLQ로 격리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;트리거 유실 stuck (한계)&lt;/td&gt;
&lt;td&gt;타임아웃 스위퍼 &amp;mdash; 후속 과제&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 솔직한 전제. 이 앱은 모놀리스에 단일 DB라, 원래는 한 트랜잭션으로 끝낼 수 있다. 그런데도 트랜잭션을 쪼갠 건 분산 트랜잭션 상황을 재현해 정합성 패턴을 검증하기 위해서였다. 사가는 그 분리의 대가를 치르는 방법이지, 분리가 필요 없는 곳에 꺼내 쓸 은탄환이 아니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;부록 &amp;mdash; 흐름 다이어그램&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;8192&quot; data-origin-height=&quot;7945&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcKrOu/dJMcadPK37t/5vxyKbaoit8QzwQirDUmC1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcKrOu/dJMcadPK37t/5vxyKbaoit8QzwQirDUmC1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcKrOu/dJMcadPK37t/5vxyKbaoit8QzwQirDUmC1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcKrOu%2FdJMcadPK37t%2F5vxyKbaoit8QzwQirDUmC1%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;8192&quot; height=&quot;7945&quot; data-origin-width=&quot;8192&quot; data-origin-height=&quot;7945&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>CS/설계 &amp;middot; 아키텍처</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/198</guid>
      <comments>https://codediary21.tistory.com/198#entry198comment</comments>
      <pubDate>Tue, 16 Jun 2026 17:10:21 +0900</pubDate>
    </item>
    <item>
      <title>백엔드에서 동시성 이슈를 제어하는 6가지 방법</title>
      <link>https://codediary21.tistory.com/197</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;2&quot;&gt;트래픽이 별로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;10&quot;&gt;없는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;13&quot;&gt;서비스에서는 동시성 &lt;/span&gt;&lt;span data-newtext-seq=&quot;24&quot;&gt;문제를 마주칠 &lt;/span&gt;&lt;span data-newtext-seq=&quot;32&quot;&gt;일이 거의 &lt;/span&gt;&lt;span data-newtext-seq=&quot;38&quot;&gt;없다. 그래서 &lt;/span&gt;&lt;span data-newtext-seq=&quot;46&quot;&gt;몇 년을 &lt;/span&gt;&lt;span data-newtext-seq=&quot;51&quot;&gt;개발하면서 한 번도 &lt;/span&gt;&lt;span data-newtext-seq=&quot;62&quot;&gt;안 터뜨려본 &lt;/span&gt;&lt;span data-newtext-seq=&quot;69&quot;&gt;사람도 많다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;77&quot;&gt;나도 동시성 이슈를 해결한 경험이 많지는 않다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;86&quot;&gt; &lt;/span&gt;&lt;span data-newtext-seq=&quot;89&quot;&gt;하지만 트래픽이 &lt;/span&gt;&lt;span data-newtext-seq=&quot;98&quot;&gt;적다고 안 터지는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;108&quot;&gt;건 아니다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;115&quot;&gt;클라이언트의 더블 &lt;/span&gt;&lt;span data-newtext-seq=&quot;125&quot;&gt;클릭, 네트워크 &lt;/span&gt;&lt;span data-newtext-seq=&quot;134&quot;&gt;재시도, 비동기로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;144&quot;&gt;동작하는 컴포넌트. &lt;/span&gt;&lt;span data-newtext-seq=&quot;155&quot;&gt;이런 &lt;/span&gt;&lt;span data-newtext-seq=&quot;158&quot;&gt;것만으로도 같은 &lt;/span&gt;&lt;span data-newtext-seq=&quot;167&quot;&gt;요청이 거의 &lt;/span&gt;&lt;span data-newtext-seq=&quot;174&quot;&gt;동시에 &lt;/span&gt;&lt;span data-newtext-seq=&quot;178&quot;&gt;도착한다. 그 순간 &lt;/span&gt;&lt;span data-newtext-seq=&quot;189&quot;&gt;데이터 정합성은 &lt;/span&gt;&lt;span data-newtext-seq=&quot;198&quot;&gt;깨진다.&lt;/span&gt;&lt;span data-newtext-seq=&quot;198&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;207&quot;&gt;그래서 결국 &lt;/span&gt;&lt;span data-newtext-seq=&quot;214&quot;&gt;알아야 한다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;222&quot;&gt;언젠가는 마주칠 &lt;/span&gt;&lt;span data-newtext-seq=&quot;231&quot;&gt;문제이고 이런 설계 미스로 인해 서비스 신뢰도와 매출의 하락이 발생할 수 있기 때문이다.&lt;/span&gt;&lt;span data-newtext-seq=&quot;231&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;241&quot;&gt;이 글에서는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;248&quot;&gt;가장 흔한 &lt;/span&gt;&lt;span data-newtext-seq=&quot;254&quot;&gt;동시성 문제인 &lt;/span&gt;&lt;span data-newtext-seq=&quot;262&quot;&gt;갱신 &lt;/span&gt;&lt;span data-newtext-seq=&quot;265&quot;&gt;분실(lost &lt;/span&gt;&lt;span data-newtext-seq=&quot;273&quot;&gt;update)을 재고 &lt;/span&gt;&lt;span data-newtext-seq=&quot;285&quot;&gt;차감 시나리오로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;294&quot;&gt;재현하고, 이걸 &lt;/span&gt;&lt;span data-newtext-seq=&quot;303&quot;&gt;막는 다섯 가지 &lt;/span&gt;&lt;span data-newtext-seq=&quot;312&quot;&gt;방법을 &lt;/span&gt;&lt;span data-newtext-seq=&quot;316&quot;&gt;비교해보려고 한다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;327&quot;&gt;Redis 분산락, &lt;/span&gt;&lt;span data-newtext-seq=&quot;338&quot;&gt;DB 격리수준 &lt;/span&gt;&lt;span data-newtext-seq=&quot;346&quot;&gt;+ &lt;/span&gt;&lt;span data-newtext-seq=&quot;348&quot;&gt;Unique &lt;/span&gt;&lt;span data-newtext-seq=&quot;355&quot;&gt;Constraint, 메시지 큐 &lt;/span&gt;&lt;span data-newtext-seq=&quot;373&quot;&gt;직렬화, &lt;/span&gt;&lt;span data-newtext-seq=&quot;378&quot;&gt;PostgreSQL &lt;/span&gt;&lt;span data-newtext-seq=&quot;389&quot;&gt;Advisory &lt;/span&gt;&lt;span data-newtext-seq=&quot;398&quot;&gt;Lock, 낙관적 &lt;/span&gt;&lt;span data-newtext-seq=&quot;408&quot;&gt;락. 다 알고 &lt;/span&gt;&lt;span data-newtext-seq=&quot;416&quot;&gt;있다고 &lt;/span&gt;&lt;span data-newtext-seq=&quot;420&quot;&gt;생각했는데, 막상 &lt;/span&gt;&lt;span data-newtext-seq=&quot;430&quot;&gt;&quot;어떤 걸 &lt;/span&gt;&lt;span data-newtext-seq=&quot;436&quot;&gt;쓸까&quot; 고르려니 잘 대답하기 어려웠다&lt;/span&gt;&lt;span data-newtext-seq=&quot;459&quot;&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span data-newtext-seq=&quot;459&quot;&gt;비관적락을 통한 동시성 제어&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;624&quot; data-origin-height=&quot;517&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beBb2n/dJMcaaS13GD/Iq2FSkk968sqdzYNapWkbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beBb2n/dJMcaaS13GD/Iq2FSkk968sqdzYNapWkbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beBb2n/dJMcaaS13GD/Iq2FSkk968sqdzYNapWkbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeBb2n%2FdJMcaaS13GD%2FIq2FSkk968sqdzYNapWkbk%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;624&quot; height=&quot;517&quot; data-origin-width=&quot;624&quot; data-origin-height=&quot;517&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;2&quot;&gt;단일 &lt;/span&gt;&lt;span data-newtext-seq=&quot;5&quot;&gt;데이터베이스 환경이라면 &lt;/span&gt;&lt;span data-newtext-seq=&quot;18&quot;&gt;비관적 락이 가장 &lt;/span&gt;&lt;span data-newtext-seq=&quot;28&quot;&gt;많이 쓰인다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;36&quot;&gt;구현이 쉽고 &lt;/span&gt;&lt;span data-newtext-seq=&quot;43&quot;&gt;롤백도 깔끔해서 &lt;/span&gt;&lt;span data-newtext-seq=&quot;52&quot;&gt;많은 개발자가 &lt;/span&gt;&lt;span data-newtext-seq=&quot;60&quot;&gt;우선 선택하는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;68&quot;&gt;방법이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;78&quot;&gt;가장 큰 단점은 &lt;/span&gt;&lt;span data-newtext-seq=&quot;87&quot;&gt;성능이다. 한 &lt;/span&gt;&lt;span data-newtext-seq=&quot;95&quot;&gt;트랜잭션이 레코드를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;106&quot;&gt;잡고 있으면 &lt;/span&gt;&lt;span data-newtext-seq=&quot;113&quot;&gt;다른 트랜잭션은 &lt;/span&gt;&lt;span data-newtext-seq=&quot;122&quot;&gt;무조건 기다려야 &lt;/span&gt;&lt;span data-newtext-seq=&quot;131&quot;&gt;한다. 대기하는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;140&quot;&gt;동안 요청이 계속 &lt;/span&gt;&lt;span data-newtext-seq=&quot;150&quot;&gt;쌓이면 병목이 &lt;/span&gt;&lt;span data-newtext-seq=&quot;158&quot;&gt;생기고, 심하면 시스템 &lt;/span&gt;&lt;span data-newtext-seq=&quot;171&quot;&gt;장애로까지 이어진다.&lt;/span&gt;&lt;span data-newtext-seq=&quot;171&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;187&quot;&gt;트래픽이 &lt;/span&gt;&lt;span data-newtext-seq=&quot;192&quot;&gt;적을 때는 보이지 &lt;/span&gt;&lt;span data-newtext-seq=&quot;202&quot;&gt;않는다. 하지만 동시 &lt;/span&gt;&lt;span data-newtext-seq=&quot;214&quot;&gt;요청이 한 지점에 &lt;/span&gt;&lt;span data-newtext-seq=&quot;224&quot;&gt;몰리는 순간, 가장 &lt;/span&gt;&lt;span data-newtext-seq=&quot;235&quot;&gt;먼저 무너지는 게 &lt;/span&gt;&lt;span data-newtext-seq=&quot;245&quot;&gt;비관적 락이다.&lt;/span&gt;&lt;span data-newtext-seq=&quot;459&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;2&quot;&gt;비관적 락을 쓸 때 &lt;/span&gt;&lt;span data-newtext-seq=&quot;13&quot;&gt;한 가지 주의할 &lt;/span&gt;&lt;span data-newtext-seq=&quot;22&quot;&gt;게 있다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;28&quot;&gt;여러 레코드에 &lt;/span&gt;&lt;span data-newtext-seq=&quot;36&quot;&gt;락을 걸 때는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;44&quot;&gt;항상 같은 &lt;/span&gt;&lt;span data-newtext-seq=&quot;50&quot;&gt;순서로 걸어야 &lt;/span&gt;&lt;span data-newtext-seq=&quot;58&quot;&gt;한다.&lt;/span&gt;&lt;span data-newtext-seq=&quot;58&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;66&quot;&gt;트랜잭션 A가 &lt;/span&gt;&lt;span data-newtext-seq=&quot;74&quot;&gt;레코드 1을 잡고 &lt;/span&gt;&lt;span data-newtext-seq=&quot;84&quot;&gt;레코드 2를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;91&quot;&gt;기다리는데, 트랜잭션 &lt;/span&gt;&lt;span data-newtext-seq=&quot;103&quot;&gt;B가 반대로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;110&quot;&gt;레코드 2를 잡고 &lt;/span&gt;&lt;span data-newtext-seq=&quot;120&quot;&gt;레코드 1을 &lt;/span&gt;&lt;span data-newtext-seq=&quot;127&quot;&gt;기다리면 어떻게 &lt;/span&gt;&lt;span data-newtext-seq=&quot;136&quot;&gt;될까. 서로가 &lt;/span&gt;&lt;span data-newtext-seq=&quot;144&quot;&gt;서로의 락을 &lt;/span&gt;&lt;span data-newtext-seq=&quot;151&quot;&gt;기다리면서 영원히 안 &lt;/span&gt;&lt;span data-newtext-seq=&quot;163&quot;&gt;풀린다. 데드락이다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;176&quot;&gt; 그래서 &lt;/span&gt;&lt;span data-newtext-seq=&quot;183&quot;&gt;다중 레코드에 &lt;/span&gt;&lt;span data-newtext-seq=&quot;191&quot;&gt;락을 걸 일이 &lt;/span&gt;&lt;span data-newtext-seq=&quot;199&quot;&gt;있다면, ID &lt;/span&gt;&lt;span data-newtext-seq=&quot;207&quot;&gt;기준이든 뭐든 정렬 &lt;/span&gt;&lt;span data-newtext-seq=&quot;218&quot;&gt;기준을 정해두고 모든 &lt;/span&gt;&lt;span data-newtext-seq=&quot;230&quot;&gt;트랜잭션이 같은 순서로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;243&quot;&gt;접근하도록 만들어야 &lt;/span&gt;&lt;span data-newtext-seq=&quot;254&quot;&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;b&gt;&lt;span data-newtext-seq=&quot;459&quot;&gt;낙관적 락을 통한 제어&lt;/span&gt;&lt;/b&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;동작 방식은 단순하다. 테이블에 version이라는 컬럼을 두고, 업데이트할 때마다 +1 한다. 그리고 업데이트 시점에 &quot;내가 읽었을 때의 버전&quot;과 &quot;지금 DB의 버전&quot;이 같은지 확인한다. 다르면 누군가 먼저 수정했다는 뜻이니까 에러를 던지고 롤백한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락을 안 걸기 때문에 비관적 락처럼 다른 트랜잭션을 막지 않는다. 그래서 처리량이 좋다. 하지만 충돌이 자주 일어나는 환경이면 얘기가 달라진다. 매번 재시도해야 하니까 오히려 비관적 락보다 느려질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 낙관적 락은 &quot;충돌이 거의 없을 거다&quot;라는 전제 위에서 동작하는 방법이다. 이름 그대로 낙관적인 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;b&gt;Postgres Advisory Lock&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;920&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgWve2/dJMcaiKeHDc/zKsqSG2WbLOvLGxnbqlDn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgWve2/dJMcaiKeHDc/zKsqSG2WbLOvLGxnbqlDn0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgWve2/dJMcaiKeHDc/zKsqSG2WbLOvLGxnbqlDn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcgWve2%2FdJMcaiKeHDc%2FzKsqSG2WbLOvLGxnbqlDn0%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;657&quot; height=&quot;411&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;920&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;분산 환경에서 락을 DB 바깥으로 빼는 방법으로 Redis를 많이 떠올린다. 하지만 PostgreSQL을 쓰고 있다면 굳이 Redis를 붙이지 않아도 된다. PostgreSQL이 자체적으로 분산락 비슷한 걸 제공하기 때문이다. 바로 Advisory Lock이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비관적 락은 실제 레코드를 잠근다. 반면 Advisory Lock은 레코드가 아니라 내가 정한 숫자(키)에 락을 건다. &quot;상품 ID 1번 재고 작업&quot;이라는 논리적인 개념에 락을 거는 셈이다. 그래서 잠글 행이 아직 없어도, 여러 테이블에 걸친 작업이어도 상관없다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;sql&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;-- 트랜잭션 단위 락, COMMIT 시 자동 해제
SELECT pg_advisory_xact_lock(1);  -- 상품 ID 1을 키로 사용

-- 재고 차감 로직...
-- COMMIT 하면 알아서 풀린다&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락의 종류는 두 가지다. 세션 단위(pg_advisory_lock)는 직접 unlock을 호출하거나 세션이 끊겨야 풀린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션 단위(pg_advisory_xact_lock)는 트랜잭션이 끝나면 알아서 풀린다. 실무에서는 트랜잭션 단위를 쓰는 게 안전하다. 이유는 잠시 뒤에 나온다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;784&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjLsjd/dJMcabLdVEr/JBm6rT9rQsGYls050jtMnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjLsjd/dJMcabLdVEr/JBm6rT9rQsGYls050jtMnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjLsjd/dJMcabLdVEr/JBm6rT9rQsGYls050jtMnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjLsjd%2FdJMcabLdVEr%2FJBm6rT9rQsGYls050jtMnk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1472&quot; height=&quot;784&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;784&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Advisory Lock을 쓸 때 반드시 알아야 할 게 있다. 이름 그대로 &quot;권고적(advisory)&quot; 락이라는 점이다. DB가 실제 데이터를 막아주지 않는다. 누군가 락을 안 걸고 그냥 재고를 수정하면 DB는 그걸 막지 않는다. 모두가 같은 키로 락을 거는 약속을 지켜야만 동작한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비관적 락이 한 명이 쓰는 동안 문을 잠가서 다른 사람이 아예 못 들어오게 막는 거라면, advisory lock은 문에 자물쇠가 없고 &quot;다들 같은 규칙으로 줄 서자&quot;는 약속에 가깝다. 약속을 안 지키는 사람이 있으면 그냥 들어가 버린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 세션 단위 락은 커넥션 풀과 만나면 골치 아파진다. unlock을 빼먹은 채 커넥션이 풀로 반납되면, 다음에 그 커넥션을 빌려간 요청이 락을 들고 있는 상태가 된다. 앞에서 트랜잭션 단위를 쓰라고 한 게 이 이유다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, 키는 그냥 숫자(bigint)다. 문자열로 잠그고 싶으면 해시해서 숫자로 바꿔야 하는데, 이때 해시 충돌이 생길 수 있다. 서로 다른 두 대상이 같은 키로 매핑되면 엉뚱하게 직렬화된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 Advisory Lock은 &quot;추가 인프라 없이 단일 PostgreSQL 안에서 분산락이 필요할 때&quot; 쓰는 방법이다. Redis를 붙이는 부담은 없지만, 결국 락 부하는 DB가 떠안는다. 그리고 DB를 여러 대로 분산하는 순간 무력화된다. 각 DB가 자기만의 락 공간을 가지기 때문이다. 거기까지 가면 그때는 정말 Redis가 필요해진다.&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;b&gt;Redis를 활용한 분산락 제어하기&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;880&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n9jnx/dJMcafz2Em2/78W1LIczYAmeIw1MofNaSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n9jnx/dJMcafz2Em2/78W1LIczYAmeIw1MofNaSk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n9jnx/dJMcafz2Em2/78W1LIczYAmeIw1MofNaSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn9jnx%2FdJMcafz2Em2%2F78W1LIczYAmeIw1MofNaSk%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;666&quot; height=&quot;398&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;880&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 본 락들은 전부 DB에 묶여 있었다. 비관적 락도, advisory lock도 결국 &quot;하나의 DB&quot;라는 울타리 안에서만 동작한다. DB를 여러 대로 나누는 순간 그 울타리는 사라진다. 락을 DB 바깥, 모든 서버가 공유하는 한 곳으로 빼야 한다. 그 한 곳으로 가장 많이 쓰이는 게 Redis다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작 원리는 단순하다. Redis에 특정 키를 하나 만든다. 락을 잡고 싶은 서버는 그 키를 선점하려 하고, 성공한 서버만 임계 영역에 들어간다. 나머지는 키가 풀릴 때까지 기다린다. 모든 서버가 같은 Redis를 보고 있으니, 서버가 몇 대로 늘어나든 락은 한 곳에서 관리된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에서는 보통 Redisson의 RLock을 쓴다. 직접 SETNX로 구현할 수도 있지만, 그러면 만료 처리, 재시도, 락 해제 같은 걸 전부 손으로 짜야 한다. Redisson은 이걸 다 감싸준다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;RLock lock = redissonClient.getLock(&quot;stock:&quot; + productId);
try {
    // 최대 3초 대기, 점유 후 5초 뒤 자동 해제
    boolean acquired = lock.tryLock(3, 5, TimeUnit.SECONDS);
    if (!acquired) {
        throw new IllegalStateException(&quot;락 획득 실패&quot;);
    }
    // 재고 차감 로직...
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 눈여겨볼 게 두 번째 인자, &lt;b&gt;만료 시간(lease time)&lt;/b&gt;이다. Redis 분산락에는 advisory lock에 없던 문제가 하나 있다. 락을 잡은 서버가 죽어버리면? DB 락은 커넥션이 끊기면 알아서 풀린다. 하지만 Redis는 그 서버가 죽었는지 모른다. 그래서 만료 시간을 걸어둔다. 일정 시간이 지나면 강제로 풀리도록.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이 만료 시간이 또 다른 함정을 만든다. 작업이 만료 시간보다 오래 걸리면, 아직 일하고 있는데 락이 풀려버린다. 그 틈에 다른 서버가 락을 잡으면 두 서버가 동시에 임계 영역에 들어간다. 막으려던 동시성 문제가 그대로 터지는 것이다. Redisson은 이걸 막으려고 워치독(watchdog)이라는 장치로 락을 자동 연장해주지만, leaseTime을 직접 지정하면 워치독이 동작하지 않는다. 이런 디테일을 모르고 쓰면 오히려 독이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 Redis 분산락은 &quot;여러 서버, 여러 DB로 흩어진 환경에서 하나의 공유된 락이 필요할 때&quot; 쓰는 방법이다. 가장 강력하고 가장 범용적이다. 대신 Redis라는 인프라가 하나 더 늘고, 그 Redis가 죽으면 락 전체가 흔들린다. 단일 장애점이 하나 생기는 셈이다. 공짜가 아니다.&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;b&gt;큐 직렬화를 통해 동시성 문제 해결하기&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/963z2/dJMcah5Iwib/6xPdO4gauVg86KjWCFt2EK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/963z2/dJMcah5Iwib/6xPdO4gauVg86KjWCFt2EK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/963z2/dJMcah5Iwib/6xPdO4gauVg86KjWCFt2EK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F963z2%2FdJMcah5Iwib%2F6xPdO4gauVg86KjWCFt2EK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1472&quot; height=&quot;410&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;410&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;2&quot;&gt;지금까지의 &lt;/span&gt;&lt;span data-newtext-seq=&quot;8&quot;&gt;방법은 전부 &lt;/span&gt;&lt;span data-newtext-seq=&quot;15&quot;&gt;&quot;동시에 들어오는 걸 &lt;/span&gt;&lt;span data-newtext-seq=&quot;27&quot;&gt;어떻게 막을까&quot;였다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;39&quot;&gt;비관적 락도, &lt;/span&gt;&lt;span data-newtext-seq=&quot;47&quot;&gt;advisory lock도, &lt;/span&gt;&lt;span data-newtext-seq=&quot;63&quot;&gt;Redis 분산락도 &lt;/span&gt;&lt;span data-newtext-seq=&quot;74&quot;&gt;결국 줄을 세우는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;84&quot;&gt;락이었다. 큐 &lt;/span&gt;&lt;span data-newtext-seq=&quot;92&quot;&gt;직렬화는 질문 자체를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;104&quot;&gt;바꾼다. &lt;/span&gt;&lt;b&gt;&lt;span data-newtext-seq=&quot;111&quot;&gt;막지 말고, 애초에 동시가 아니게 만들면 되지 않나?&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;147&quot;&gt;발상은 이렇다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;156&quot;&gt;주문 요청이 들어오면 &lt;/span&gt;&lt;span data-newtext-seq=&quot;168&quot;&gt;바로 재고를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;175&quot;&gt;차감하지 않는다. 일단 &lt;/span&gt;&lt;span data-newtext-seq=&quot;188&quot;&gt;메시지 큐에 넣는다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;200&quot;&gt;그리고 그 큐를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;209&quot;&gt;하나의 &lt;/span&gt;&lt;span data-newtext-seq=&quot;213&quot;&gt;소비자(consumer)가 순서대로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;233&quot;&gt;꺼내서 처리한다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;243&quot;&gt;50명이 동시에 &lt;/span&gt;&lt;span data-newtext-seq=&quot;252&quot;&gt;주문해도, 큐에서 꺼내는 건 &lt;/span&gt;&lt;span data-newtext-seq=&quot;268&quot;&gt;한 번에 하나다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;278&quot;&gt;동시성 자체가 사라진다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;292&quot;&gt;경쟁할 일이 없으니 &lt;/span&gt;&lt;span data-newtext-seq=&quot;303&quot;&gt;락도 필요 없다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;314&quot;&gt; &lt;/span&gt;&lt;span data-newtext-seq=&quot;317&quot;&gt;RabbitMQ나 Kafka를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;334&quot;&gt;많이 쓴다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;341&quot;&gt;Kafka라면 같은 상품 &lt;/span&gt;&lt;span data-newtext-seq=&quot;355&quot;&gt;ID를 같은 파티션으로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;368&quot;&gt;보내는 게 핵심이다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;380&quot;&gt;파티션 하나는 컨슈머 &lt;/span&gt;&lt;span data-newtext-seq=&quot;392&quot;&gt;하나가 순서를 보장하며 &lt;/span&gt;&lt;span data-newtext-seq=&quot;405&quot;&gt;처리하니까, &quot;상품 &lt;/span&gt;&lt;span data-newtext-seq=&quot;416&quot;&gt;1번에 대한 주문&quot;은 &lt;/span&gt;&lt;span data-newtext-seq=&quot;428&quot;&gt;항상 한 줄로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;436&quot;&gt;직렬화된다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 상품 ID를 파티션 키로 &amp;rarr; 같은 상품은 같은 파티션 &amp;rarr; 순서 보장
kafkaTemplate.send(&quot;order-topic&quot;, String.valueOf(productId), orderEvent);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;564&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zzSDb/dJMcahkmhnV/YQpWJ9kFgf74HtSI1QEjp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zzSDb/dJMcahkmhnV/YQpWJ9kFgf74HtSI1QEjp0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zzSDb/dJMcahkmhnV/YQpWJ9kFgf74HtSI1QEjp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzzSDb%2FdJMcahkmhnV%2FYQpWJ9kFgf74HtSI1QEjp0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1472&quot; height=&quot;564&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;564&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;561&quot;&gt;락이 &lt;/span&gt;&lt;span data-newtext-seq=&quot;587&quot;&gt;없으니 락 때문에 생기던 &lt;/span&gt;&lt;span data-newtext-seq=&quot;601&quot;&gt;문제도 없다. 데드락도, &lt;/span&gt;&lt;span data-newtext-seq=&quot;615&quot;&gt;락 대기로 인한 병목도, &lt;/span&gt;&lt;span data-newtext-seq=&quot;629&quot;&gt;만료 시간 같은 고민도 &lt;/span&gt;&lt;span data-newtext-seq=&quot;642&quot;&gt;사라진다. 트래픽이 아무리 &lt;/span&gt;&lt;span data-newtext-seq=&quot;657&quot;&gt;몰려도 큐가 받아주고, &lt;/span&gt;&lt;span data-newtext-seq=&quot;670&quot;&gt;처리는 뒤에서 차분히 &lt;/span&gt;&lt;span data-newtext-seq=&quot;682&quot;&gt;진행된다. 스파이크를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;694&quot;&gt;버텨내는 힘이 강하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;711&quot;&gt;대신 큐 &lt;/span&gt;&lt;span data-newtext-seq=&quot;716&quot;&gt;직렬화는 가장 큰 걸 &lt;/span&gt;&lt;span data-newtext-seq=&quot;728&quot;&gt;하나 내준다. &lt;/span&gt;&lt;b&gt;&lt;span data-newtext-seq=&quot;738&quot;&gt;즉시성이다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;751&quot;&gt;앞의 락 방식들은 &lt;/span&gt;&lt;span data-newtext-seq=&quot;761&quot;&gt;동기적이었다. 주문 요청이 &lt;/span&gt;&lt;span data-newtext-seq=&quot;776&quot;&gt;오면 그 자리에서 &lt;/span&gt;&lt;span data-newtext-seq=&quot;786&quot;&gt;처리하고 &quot;성공&quot;이나 &lt;/span&gt;&lt;span data-newtext-seq=&quot;798&quot;&gt;&quot;재고 부족&quot;을 &lt;/span&gt;&lt;span data-newtext-seq=&quot;807&quot;&gt;바로 응답했다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;816&quot;&gt;큐 직렬화는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;823&quot;&gt;다르다. 요청은 &lt;/span&gt;&lt;span data-newtext-seq=&quot;832&quot;&gt;&quot;접수됐다&quot;까지만 답하고, &lt;/span&gt;&lt;span data-newtext-seq=&quot;847&quot;&gt;실제 재고 &lt;/span&gt;&lt;span data-newtext-seq=&quot;853&quot;&gt;차감은 뒤에서 &lt;/span&gt;&lt;span data-newtext-seq=&quot;861&quot;&gt;일어난다. 사용자는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;872&quot;&gt;자기 주문이 &lt;/span&gt;&lt;span data-newtext-seq=&quot;879&quot;&gt;성공했는지 그 순간 &lt;/span&gt;&lt;span data-newtext-seq=&quot;890&quot;&gt;알 수 없다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;898&quot;&gt;비동기가 된다는 건 &lt;/span&gt;&lt;span data-newtext-seq=&quot;909&quot;&gt;시스템 전체의 &lt;/span&gt;&lt;span data-newtext-seq=&quot;917&quot;&gt;설계가 바뀐다는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;926&quot;&gt;뜻이다. 결과를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;935&quot;&gt;어떻게 다시 &lt;/span&gt;&lt;span data-newtext-seq=&quot;942&quot;&gt;알려줄지, 실패하면 &lt;/span&gt;&lt;span data-newtext-seq=&quot;953&quot;&gt;어떻게 보상할지, &lt;/span&gt;&lt;span data-newtext-seq=&quot;963&quot;&gt;전부 새로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;969&quot;&gt;설계해야 한다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;979&quot;&gt; 그리고 &lt;/span&gt;&lt;span data-newtext-seq=&quot;986&quot;&gt;처리량의 천장이 &lt;/span&gt;&lt;span data-newtext-seq=&quot;995&quot;&gt;생긴다. 순서를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1004&quot;&gt;보장하려면 결국 한 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1015&quot;&gt;줄로 처리해야 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1023&quot;&gt;하는데, 이건 곧 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1033&quot;&gt;병렬 처리를 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1040&quot;&gt;포기한다는 뜻이다. &lt;/span&gt;&lt;span data-newtext-seq=&quot;1051&quot;&gt;상품별로 파티션을 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1061&quot;&gt;나눠 어느 정도 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1070&quot;&gt;분산할 수는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1077&quot;&gt;있어도, &quot;같은 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1086&quot;&gt;상품&quot;에 대해서는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1096&quot;&gt;영원히 한 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1102&quot;&gt;줄이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-newtext-seq=&quot;1111&quot;&gt;정리하면 큐 직렬화는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1123&quot;&gt;&quot;락으로 막기엔 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1132&quot;&gt;트래픽이 너무 크고, &lt;/span&gt;&lt;span data-newtext-seq=&quot;1144&quot;&gt;비동기를 감당할 수 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1155&quot;&gt;있을 때&quot; 쓰는 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1164&quot;&gt;방법이다. 순간적인 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1175&quot;&gt;폭주를 가장 잘 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1184&quot;&gt;버티지만, 그 대가로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1196&quot;&gt;즉시 응답을 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1203&quot;&gt;포기하고 시스템을 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1213&quot;&gt;비동기로 다시 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1221&quot;&gt;설계해야 한다. 가장 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1233&quot;&gt;규모가 큰 해법이자, &lt;/span&gt;&lt;span data-newtext-seq=&quot;1245&quot;&gt;가장 무거운 &lt;/span&gt;&lt;span data-newtext-seq=&quot;1252&quot;&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span data-newtext-seq=&quot;1252&quot;&gt;정리&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든&amp;nbsp;쓰기&amp;nbsp;작업에&amp;nbsp;동시성&amp;nbsp;제어를&amp;nbsp;거는&amp;nbsp;건&amp;nbsp;과한&amp;nbsp;비용이다.&amp;nbsp;대부분의&amp;nbsp;요청은&amp;nbsp;부딪칠&amp;nbsp;일이&amp;nbsp;없으니까.&lt;br /&gt;정말 필요한 곳은 따로 있다. 선착순 이벤트, 재고 차감, 좌석 예매처럼 동시 요청이 겹쳤을 때 데이터가 깨지면 손해가 큰 로직. 동시성 제어는 거기에 집중하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 방법을 고를 때는 지금의 트래픽과 인프라를 봐야 한다. 단일 DB로 충분한데 Redis를 붙일 필요는 없고, 비동기를 감당 못 하는데 큐를 끌어올 이유도 없다. 처음에 말했듯이 &quot;가장 좋은 방법&quot;은 없다. &quot;지금 우리 시스템에 맞는 방법&quot;이 있을 뿐이다.&lt;/p&gt;</description>
      <category>CS/설계 &amp;middot; 아키텍처</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/197</guid>
      <comments>https://codediary21.tistory.com/197#entry197comment</comments>
      <pubDate>Sun, 31 May 2026 14:33:43 +0900</pubDate>
    </item>
    <item>
      <title>인프라 공방전 2주차 IaC - 코드로 CI/CD 파이프라인을 구축해보자</title>
      <link>https://codediary21.tistory.com/196</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;배경&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mPVkF/dJMcagrPctX/MLCY5O8QwCEqGNIN7szcPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mPVkF/dJMcagrPctX/MLCY5O8QwCEqGNIN7szcPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mPVkF/dJMcagrPctX/MLCY5O8QwCEqGNIN7szcPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmPVkF%2FdJMcagrPctX%2FMLCY5O8QwCEqGNIN7szcPk%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;1672&quot; height=&quot;941&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;/&gt;&lt;/span&gt;&lt;/figure&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;인프라 공방전 스터디를 진행하며 모든 과정을 코드와 문서로 꼼꼼히 기록하던 중, 유독 인프라 영역만 빈 페이지로 남아 있다는 사실을 깨달았습니다. AWS 콘솔에서 마우스로 뚝딱뚝딱 만든 리소스들은, 스터디가 끝나거나 계정이 삭제되면 함께 사라지는 &lt;b&gt;휘발성 작품&lt;/b&gt;이었거든요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이번엔 다르게 해보자.&quot; 그렇게 미뤄왔던 &lt;b&gt;Terraform&lt;/b&gt;을 꺼내 들었습니다. 인프라를 코드로 적어두면 언제든 똑같이 재현할 수 있고, 누구나 읽고 리뷰할 수 있으니까요. 마침 Jenkins 파이프라인 구축을 앞둔 시점이라, 이보다 더 좋은 실험 무대는 없어 보였습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;테라폼이란?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테라폼 공식 사이트에서는 아래와 같이 테라폼을 설명하고 있습니다. 요약하자면, Terraform은 &lt;b&gt;인프라를 코드로 관리할 수 있게 해주는 IaC(Infrastructure as Code) 도구&lt;/b&gt;입니다. 컴퓨팅 인스턴스, 스토리지, 네트워킹 같은 &lt;b&gt;저수준 리소스&lt;/b&gt;부터 DNS 레코드, SaaS 기능 같은 &lt;b&gt;고수준 리소스&lt;/b&gt;까지 폭넓게 다룰 수 있다는 점이 특징입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Terraform is an infrastructure as code tool that lets you build, change, and version infrastructure safely and efficiently. This includes low-level components like compute instances, storage, and networking; and high-level components like DNS entries and SaaS features.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;테라폼 다운로드 받기(macOS)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맥에서 가장 대중적으로 사용되는 패키지 매니저인 brew를 통해 쉽게 다운로드를 할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;brew tap hashicorp/tap
brew install hashicorp/tap/terraform&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;젠킨스도 코드로 구축해보자&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;990&quot; data-origin-height=&quot;495&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQf2Hd/dJMcagrPcDK/AfqqAStyTdXPm5mKTT6dk0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQf2Hd/dJMcagrPcDK/AfqqAStyTdXPm5mKTT6dk0/img.jpg&quot; data-alt=&quot;젠킨스 JCasC&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQf2Hd/dJMcagrPcDK/AfqqAStyTdXPm5mKTT6dk0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQf2Hd%2FdJMcagrPcDK%2FAfqqAStyTdXPm5mKTT6dk0%2Fimg.jpg&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;990&quot; height=&quot;495&quot; data-origin-width=&quot;990&quot; data-origin-height=&quot;495&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;젠킨스 JCasC&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;젠킨스를 사용하다보면 정말 많은 설정과 많은 플러그인들을 마주하게 됩니다. 여기서 만약 실수로 서버를 종료 시키거나 인프라 이전을 했을 때 기존의 젠킨스 설정이 전부 날아가버리게 됩니다. 그렇게 힘들게 설정한 젠킨스의 구성이 날아가지 않고 코드로 관리할 수 있도록 도와주는 오픈소스가 존재합니다. 그것이 바로 JCasC입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Jenkins 초기 설정 (JCasC)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정 파일은 &lt;b&gt;JCasC(Jenkins Configuration as Code)&lt;/b&gt; 형식으로, Jenkins UI에서 클릭으로 설정해야 할 항목들을 YAML 코드로 정의해둔 것입니다. 컨테이너가 시작되면 이 파일을 읽어 자동으로 Jenkins를 구성하기 때문에, 매번 동일한 환경을 재현할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;jenkins:
  systemMessage: &quot;Managed by JCasC. Do not edit via UI.&quot;
  numExecutors: 2
  mode: NORMAL
  # 단일 컨트롤러 구성에서는 별도 inbound agent를 쓰지 않으므로 JNLP TCP 리스너 비활성.
  # -1 = disable (Jenkins core: Jenkins.java#setSlaveAgentPort 참조)
  slaveAgentPort: -1
  securityRealm:
    local:
      allowsSignup: false
      users:
        - id: &quot;admin&quot;
          password: &quot;${JENKINS_ADMIN_PASSWORD}&quot;
  authorizationStrategy:
    loggedInUsersCanDoAnything:
      allowAnonymousRead: false

credentials:
  system:
    domainCredentials:
      - credentials:
          - usernamePassword:
              scope: GLOBAL
              id: &quot;github-pat&quot;
              username: &quot;x-access-token&quot;
              password: &quot;${GITHUB_PAT:-}&quot;
              description: &quot;GitHub PAT for SCM&quot;
          - string:
              scope: GLOBAL
              id: &quot;slack-token&quot;
              secret: &quot;${SLACK_TOKEN:-}&quot;
              description: &quot;Slack bot token&quot;

unclassified:
  location:
    url: &quot;${JENKINS_URL:-http://localhost:8080/}&quot;
    adminAddress: &quot;dldydtjs8124@gmail.com&quot;
  # slackNotifier:
  #   teamDomain: &quot;&amp;lt;TBD&amp;gt;&quot;
  #   tokenCredentialId: &quot;slack-token&quot;

tool:
  git:
    installations:
      - name: Default
        home: &quot;/usr/bin/git&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;보안 설정 (securityRealm &amp;amp; authorizationStrategy)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins에 로그인할 관리자 계정을 정의했습니다. `local` 방식으로 admin 계정을 생성하고, 비밀번호는 환경변수(`JENKINS_ADMIN_PASSWORD`)로 주입받도록 하여 코드에 평문으로 노출되지 않도록 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 `loggedInUsersCanDoAnything` 전략을 사용해 &lt;b&gt;로그인한 사용자만&lt;/b&gt; Jenkins에 접근할 수 있도록 제한했고, 익명 읽기는 차단했습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;자격 증명 (credentials)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파이프라인에서 사용할 외부 서비스 인증 정보를 미리 등록해두었습니다. 두 값 모두 환경변수로 주입받아 민감 정보가 코드에 직접 노출되지 않도록 처리했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;github-pat:&lt;/b&gt; GitHub Personal Access Token (SCM 연동용)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;slack-token:&lt;/b&gt; Slack Bot Token (빌드 알림용)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;시스템 설정&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;location.url:&lt;/b&gt; 젠킨스에서 외부로 접근하는 url&lt;/li&gt;
&lt;li&gt;&lt;b&gt;adminAddress:&lt;/b&gt; 시스템 알림이 발송되는 어드민 계정&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Slack Notifier 설정은 워크스페이스 도메인이 아직 확정되지 않아 주석 처리해두었으며, 추후 활성화할 예정입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;도구 설정 (tool)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Git 실행 파일의 경로를 컨테이너 내부 경로(`/usr/bin/git`)로 명시했습니다. 베이스 이미지에 Git이 사전 설치되어 있다는 전제 하에, Jenkins가 SCM 작업을 수행할 때 이 경로를 사용합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Plugin.txt&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins에서 사용할 플러그인 목록을 선언한 파일입니다. `name:version` 형식으로 플러그인 이름과 버전을 명시하며, &lt;b&gt;Docker 이미지를 빌드하는 시점에&lt;/b&gt; `jenkins-plugin-cli` 도구가 이 파일을 읽어 모든 플러그인을 한꺼번에 설치합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 이미지에 플러그인을 미리 포함시켜두면, 컨테이너가 실행될 때 별도의 다운로드 없이 곧바로 Jenkins가 기동됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# jenkins-plugin-cli manifest. Format: name:version (#은 주석)
# Jenkins 2.555.1-lts-jdk21 호환 검증됨. 업데이트: plugins.jenkins.io/api/plugin/&amp;lt;name&amp;gt;

configuration-as-code:2077.v41f1011a_5110
credentials:1502.v5c95e620ddfe
credentials-binding:719.v80e905ef14eb_
plain-credentials:199.v9f8e1f741799
ssh-credentials:372.va_250881b_08cd

workflow-aggregator:608.v67378e9d3db_1
pipeline-stage-view:2.41
pipeline-utility-steps:2.20.0

git:5.10.1
github:1.46.0
github-branch-source:1967.vdea_d580c1a_b_a_
workflow-multibranch:821.vc3b_4ea_780798

docker-workflow:634.vedc7242b_eda_7
ssh-agent:396.vcc7d84e622ec
timestamper:1.30
ws-cleanup:0.49
build-timeout:1.40
ansicolor:536.v13fa_b_860c267

slack:795.v4b_9705b_e6d47&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Dockerfile&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 `jenkins/jenkins:2.555.1-lts-jdk21` 이미지에는 Docker CLI와 AWS CLI가 포함되어 있지 않습니다. 하지만 이번 파이프라인에서는 컨테이너 내부에서 직접 Docker 명령과 AWS 리소스 조작이 필요하기 때문에, 두 도구를 추가로 설치하도록 Dockerfile을 작성했습니다. 전체 흐름은 다음과 같습니다.&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;Docker CLI 설치:&lt;/b&gt; Docker 공식 apt 저장소를 추가하고 `docker-ce-cli` 패키지를 설치합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AWS CLI v2 설치:&lt;/b&gt; 공식 zip 인스톨러를 다운로드해 설치하며, IMDSv2를 통해 EC2 인스턴스 프로파일의 자격 증명을 자동으로&lt;br /&gt;획득하도록 구성됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Docker 그룹 권한 부여:&lt;/b&gt; 호스트의 Docker 소켓을 사용할 수 있도록 `jenkins` 사용자를 `docker` 그룹에 추가합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;플러그인 설치:&lt;/b&gt; `plugins.txt` 파일을 이미지 안으로 복사한 뒤, `jenkins-plugin-cli`로 명시된 플러그인들을 일괄 설치합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 작업이 이미지 빌드 시점에 완료되기 때문에, 컨테이너 실행 시에는 별도의 추가 설치 없이 바로 Jenkins가 기동됩니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;FROM jenkins/jenkins:2.555.1-lts-jdk21

ARG DOCKER_GID=999

USER root

# Docker CLI: Debian 기본 저장소에 없어 공식 apt repo 추가 필요. unzip은 awscli v2 zip installer용.
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y --no-install-recommends \
      ca-certificates curl gnupg lsb-release unzip &amp;amp;&amp;amp; \
    install -m 0755 -d /etc/apt/keyrings &amp;amp;&amp;amp; \
    curl -fsSL https://download.docker.com/linux/debian/gpg \
      | gpg --dearmor -o /etc/apt/keyrings/docker.gpg &amp;amp;&amp;amp; \
    chmod a+r /etc/apt/keyrings/docker.gpg &amp;amp;&amp;amp; \
    echo &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/debian $(. /etc/os-release &amp;amp;&amp;amp; echo $VERSION_CODENAME) stable&quot; \
      &amp;gt; /etc/apt/sources.list.d/docker.list &amp;amp;&amp;amp; \
    apt-get update &amp;amp;&amp;amp; \
    apt-get install -y --no-install-recommends docker-ce-cli &amp;amp;&amp;amp; \
    rm -rf /var/lib/apt/lists/*

# awscli v2: 파이프라인이 SSM Run Command / ELBv2 / ECR API를 컨테이너에서 직접 호출.
# 컨트롤러 EC2의 instance profile credentials를 IMDSv2 경유로 자동 획득.
# 플러그인 install 레이어보다 위에 두어 plugins.txt 변경 시 awscli 레이어 재사용.
RUN ARCH=$(uname -m) &amp;amp;&amp;amp; \
    curl -fsSL &quot;https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip&quot; -o /tmp/awscliv2.zip &amp;amp;&amp;amp; \
    unzip -q /tmp/awscliv2.zip -d /tmp &amp;amp;&amp;amp; \
    /tmp/aws/install &amp;amp;&amp;amp; \
    rm -rf /tmp/aws /tmp/awscliv2.zip &amp;amp;&amp;amp; \
    aws --version

RUN (getent group docker &amp;amp;&amp;amp; groupmod -g ${DOCKER_GID} docker) \
      || groupadd -g ${DOCKER_GID} docker &amp;amp;&amp;amp; \
    usermod -aG docker jenkins

USER jenkins

COPY plugins.txt /usr/share/jenkins/ref/plugins.txt
RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txt --verbose&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;DockerCompose.yml&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 작성한 파일들을 정리해보면 다음과 같이 설명할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Dockerfile&lt;/b&gt;: Jenkins + Docker CLI + AWS CLI가 포함된 이미지 정의&lt;/li&gt;
&lt;li&gt;&lt;b&gt;plugins.txt&lt;/b&gt;: 이미지에 설치할 플러그인 목록&lt;/li&gt;
&lt;li&gt;&lt;b&gt;jcasc 설정&lt;/b&gt;: Jenkins의 보안&amp;middot;자격 증명&amp;middot;시스템 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 이 파일들을 하나로 묶어 &lt;b&gt;실제로 동작하는 컨테이너로 실행&lt;/b&gt;하는 단계가 남았습니다. 그 역할을 하는 것이 바로&lt;br /&gt;docker-compose.yml입니다. 이 파일에서는 다음과 같은 항목들을 정의했습니다.&lt;/p&gt;
&lt;table style=&quot;height: 145px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;build&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Dockerfile을 기반으로 이미지를 빌드하고 &lt;code&gt;DOCKER_GID&lt;/code&gt;를 주입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;ports&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;8080 포트로 Jenkins 웹 UI 노출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;volumes&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;데이터 영속화(&lt;code&gt;jenkins_home&lt;/code&gt;), Docker 소켓 공유, JCasC 설정 마운트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;environment&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Setup Wizard 비활성화, JCasC 경로, 자격 증명 환경변수 주입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;healthcheck&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;/login&lt;/code&gt; 엔드포인트로 컨테이너 상태 주기적 점검&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;code&gt;logging&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;로그 파일 크기&amp;middot;개수 제한으로 디스크 보호&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 볼륨 설정에서 세 가지 마운트가 각각 다른 목적을 가진다는 점입니다. 요약하여 설명하면 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;jenkins_home&lt;/code&gt; (Named Volume) &amp;mdash; Jenkins 데이터 영속성&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/var/run/docker.sock&lt;/code&gt; (Bind Mount) &amp;mdash; 호스트 Docker 데몬 공유&lt;/li&gt;
&lt;li&gt;&lt;code&gt;./jcasc&lt;/code&gt; (Bind Mount, 읽기 전용) &amp;mdash; JCasC 설정 주입&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 설정이 코드로 관리되기 때문에, 누구든 저장소를 클론한 뒤&lt;code&gt;docker compose up -d&lt;/code&gt; 한 줄이면 동일한 Jenkins 환경을 띄울 수 있습니다. 이제 이 코드로 정의된 젠킨스를 테라폼을 통해 인터페이스를 사용하지 않고 코드로 인프라를 띄울 것입니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;services:
  jenkins:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        DOCKER_GID: &quot;${DOCKER_GID:-999}&quot;
    image: pms-order-jenkins:2.555.1
    container_name: pms-order-jenkins
    ports:
      - &quot;8080:8080&quot;
      # 단일 컨트롤러 구성에서는 별도의 JNLP TCP 포트(50000)를 열지 않는다.
    volumes:
      - jenkins_home:/var/jenkins_home
      - /var/run/docker.sock:/var/run/docker.sock
      - ./jcasc:/var/jenkins_home/casc_configs:ro
    environment:
      - JAVA_OPTS=-Djenkins.install.runSetupWizard=false -Xmx1536m
      - CASC_JENKINS_CONFIG=/var/jenkins_home/casc_configs
      - TZ=Asia/Seoul
      - JENKINS_ADMIN_PASSWORD=${JENKINS_ADMIN_PASSWORD}
      - GITHUB_PAT=${GITHUB_PAT:-}
      - SLACK_TOKEN=${SLACK_TOKEN:-}
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:8080/login&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 120s
    logging:
      driver: json-file
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;5&quot;

volumes:
  jenkins_home:
    driver: local&lt;/code&gt;&lt;/pre&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;b&gt;테라폼으로 젠킨스 서버 띄우기&lt;/b&gt;&lt;/h2&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div data-composer-surface=&quot;true&quot;&gt;
&lt;div&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dm62Bj/dJMcaf0JzwR/xkOIt3ojVxAs6sDTojNeX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dm62Bj/dJMcaf0JzwR/xkOIt3ojVxAs6sDTojNeX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dm62Bj/dJMcaf0JzwR/xkOIt3ojVxAs6sDTojNeX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdm62Bj%2FdJMcaf0JzwR%2FxkOIt3ojVxAs6sDTojNeX0%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;789&quot; height=&quot;444&quot; data-origin-width=&quot;1672&quot; data-origin-height=&quot;941&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VPC와 ALB는 다른 팀원들이 직접 구축하기로 역할이 분담되었기&amp;nbsp;때문에, 기존에 구성된 인프라 위에 Terraform을 활용해 Jenkins&amp;nbsp;&lt;br /&gt;서버를 어떻게 올릴 수 있는지에 초점을 맞춰 정리해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 이번 스터디에서 단일 컨트롤러 노드 한 대로 구성했지만, 실제 운영 환경에서는 하나의 컨트롤러 노드와 여러 에이전트 노드를 두는 분산 구조도 많이 사용됩니다. 이렇게 분리하면 빌드(CPU&amp;middot;메모리 집약적)와 배포(네트워크 I/O 집약적) 작업을&amp;nbsp;각각의 워크로드에 특화된 인스턴스에 할당할 수 있어, 자원을&amp;nbsp;훨씬&amp;nbsp;효율적으로&amp;nbsp;사용할&amp;nbsp;수&amp;nbsp;있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;서버 컴퓨팅 자원 할당&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 compute.tf는 Jenkins 컨트롤러를 실행할 단일 EC2 인스턴스를 정의하고 있습니다. 내용을 요약하자면 최신 Amazon Linux 2023 AMI를 SSM Parameter Store에서 조회하고, 지정된 서브넷과 보안 그룹 안에 EC2를 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스에는 IAM Instance Profile, 보안 메타데이터 설정, 암호화된 gp3 루트 볼륨, 그리고 Jenkins 초기 설치과 세팅을 위한 user data를 적용 하고 있습니다. Jenkins 데이터는 별도 EBS 볼륨을 참조하도록 구성되어 있어, EC2 자체와 Jenkins 데이터 저장소의 역활을 분리함으로써 새로운 EC2로 이전하더라도 이전 젠킨스 세팅을 그대로 운용가능하도록 구성할 수 있었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1777968471864&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# Jenkins 컨트롤러 단일 EC2. 학습 목적에서는 ASG/Spot/별도 agent 없이 이 구성이
# 가장 이해하기 쉽고 운영 포인트가 적다.
data &quot;aws_ssm_parameter&quot; &quot;al2023_ami&quot; {
  name = &quot;/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64&quot;
}

resource &quot;aws_instance&quot; &quot;controller&quot; {
  ami                         = data.aws_ssm_parameter.al2023_ami.value
  instance_type               = var.controller_instance_type
  subnet_id                   = var.controller_subnet_id
  associate_public_ip_address = var.associate_public_ip_address
  vpc_security_group_ids      = [aws_security_group.controller.id]

  iam_instance_profile = aws_iam_instance_profile.controller.name

  metadata_options {
    http_endpoint               = &quot;enabled&quot;
    http_tokens                 = &quot;required&quot;
    http_put_response_hop_limit = 2
  }

  root_block_device {
    volume_size           = 20
    volume_type           = &quot;gp3&quot;
    delete_on_termination = true
    encrypted             = true
  }

  tags = {
    Name = &quot;jenkins-controller&quot;
    Role = &quot;controller&quot;
  }

  user_data = templatefile(&quot;${path.module}/templates/userdata-controller.sh.tftpl&quot;, {
    jenkins_repo_url     = var.jenkins_repo_url
    jenkins_repo_ref     = var.jenkins_repo_ref
    jenkins_repo_raw_url = var.jenkins_repo_raw_url
    jenkins_data_volume  = aws_ebs_volume.jenkins_data.id
    aws_region           = var.aws_region
  })

  user_data_replace_on_change = true
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;EBS 설정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EBS는 EC2 인스턴스에 연결해서 사용하는 네트워크 기반 블록 스토리지입니다. 쉽게 말하면 서버에 붙이는 디스크라고 볼 수 있습니다. EC2의 루트 디스크와 별도로 EBS 볼륨을 만들어 Jenkins 데이터를 저장하면, EC2 인스턴스가 교체되더라도 해당 볼륨을 다시 연결해 데이터를 유지할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 스냅샷을 통해 백업하거나 복구할 수 있습니다. 즉, 물리적인 하드디스크를 직접 다루는 대신 AWS에서 추상화된 디스크 자원을 생성하고 EC2에 붙여 사용하는 구조입니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777968986265&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# ─────────────────────────────────────────────────────────────────────────────
# 영속 EBS - controller subnet과 같은 AZ에 생성. EC2 user-data에서 attach.
# prevent_destroy로 실수로 인한 데이터 손실 방지 (변경하려면 lifecycle 블록 수정).
# ─────────────────────────────────────────────────────────────────────────────

resource &quot;aws_ebs_volume&quot; &quot;jenkins_data&quot; {
  availability_zone = data.aws_subnet.controller.availability_zone
  size              = var.data_volume_size_gb
  type              = &quot;gp3&quot;
  encrypted         = true

  tags = {
    Name     = &quot;jenkins-data&quot;
    Snapshot = &quot;true&quot;
  }

  lifecycle {
    prevent_destroy = true
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;IAM 권한 설정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS의 인프라 뿐만 아니라 코드를 통해 IAM 권한 또한 다음과 같이 정의할 수 있습니다. 아래의 스크립트는 제가 젠킨스를 SSM을 활용하여 서버를 제어하기 위해 설정을 하였고 ELB를 사용하기 위한 설정과 실제 ECR을 통해 이미지를 올리고 서버 인스턴스에 배포를 하기위한 권한들을 지정해두었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778159041271&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;data &quot;aws_caller_identity&quot; &quot;current&quot; {}

data &quot;aws_iam_policy_document&quot; &quot;ec2_assume_role&quot; {
  statement {
    effect = &quot;Allow&quot;
    principals {
      type        = &quot;Service&quot;
      identifiers = [&quot;ec2.amazonaws.com&quot;]
    }
    actions = [&quot;sts:AssumeRole&quot;]
  }
}

resource &quot;aws_iam_role&quot; &quot;controller&quot; {
  name               = &quot;jenkins-controller-role&quot;
  assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
}

resource &quot;aws_iam_role_policy_attachment&quot; &quot;controller_ssm_core&quot; {
  role       = aws_iam_role.controller.name
  policy_arn = &quot;arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore&quot;
}

data &quot;aws_iam_policy_document&quot; &quot;controller_inline&quot; {
  statement {
    effect    = &quot;Allow&quot;
    actions   = [&quot;ssm:GetParameter&quot;, &quot;ssm:GetParameters&quot;]
    resources = [&quot;arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/jenkins/*&quot;]
  }

  statement {
    effect    = &quot;Allow&quot;
    actions   = [&quot;ec2:AttachVolume&quot;, &quot;ec2:DetachVolume&quot;, &quot;ec2:DescribeVolumes&quot;]
    resources = [&quot;*&quot;]
  }

  # ── pms-order 롤링 배포 파이프라인용 ────────────────────────────────────────
  # 파이프라인이 컨트롤러 IAM으로 다음 작업을 수행:
  #   - SSM Parameter Store에서 앱 메타데이터(INSTANCE_IDS, TG_ARN, ECR_REPO 등) 조회
  #   - SSM Run Command로 앱 EC2에서 deploy.sh / stop-old-color.sh / nginx 롤백 실행
  #   - ALB 타겟 deregister/register + healthy 폴링
  #   - ECR에 새 이미지 push
  #   - SSM 명령 출력 &amp;rarr; CloudWatch Logs

  statement {
    effect = &quot;Allow&quot;
    actions = [
      &quot;ssm:GetParameter&quot;,
      &quot;ssm:GetParameters&quot;,
      &quot;ssm:GetParametersByPath&quot;,
    ]
    resources = [&quot;arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/pms-order/*&quot;]
  }

  # SendCommand: instance와 document를 분리한다.
  # condition은 statement 전체에 적용되므로, AWS 관리 문서(AWS-RunShellScript)에까지
  # ssm:resourceTag/Project 태그를 요구하면 condition fail로 전체가 deny된다.
  # &amp;rarr; instance 리소스에만 Project=pms-order 태그 조건을 걸고, 문서는 무조건 허용.
  statement {
    effect    = &quot;Allow&quot;
    actions   = [&quot;ssm:SendCommand&quot;]
    resources = [&quot;arn:aws:ssm:${var.aws_region}::document/AWS-RunShellScript&quot;]
  }

  statement {
    effect    = &quot;Allow&quot;
    actions   = [&quot;ssm:SendCommand&quot;]
    resources = [&quot;arn:aws:ec2:${var.aws_region}:${data.aws_caller_identity.current.account_id}:instance/*&quot;]
    # 인스턴스는 태그 Project=pms-order 가 붙은 것에만 명령 가능. 운영 안전장치.
    condition {
      test     = &quot;StringEquals&quot;
      variable = &quot;ssm:resourceTag/Project&quot;
      values   = [&quot;pms-order&quot;]
    }
  }

  statement {
    effect = &quot;Allow&quot;
    # 리소스 레벨 스코프 미지원 &amp;rarr; wildcard 강제
    actions = [
      &quot;ssm:GetCommandInvocation&quot;,
      &quot;ssm:ListCommandInvocations&quot;,
      &quot;ssm:DescribeInstanceInformation&quot;,
    ]
    resources = [&quot;*&quot;]
  }

  statement {
    effect = &quot;Allow&quot;
    # TG ARN은 SSM에서 런타임 조회 &amp;rarr; 학습용 wildcard.
    # 운영 시 SSM 키가 안정화되면 특정 TG ARN 으로 좁힐 것.
    actions = [
      &quot;elasticloadbalancing:DescribeTargetHealth&quot;,
      &quot;elasticloadbalancing:DescribeTargetGroups&quot;,
      &quot;elasticloadbalancing:RegisterTargets&quot;,
      &quot;elasticloadbalancing:DeregisterTargets&quot;,
    ]
    resources = [&quot;*&quot;]
  }

  statement {
    effect    = &quot;Allow&quot;
    actions   = [&quot;ec2:DescribeInstances&quot;]
    resources = [&quot;*&quot;]
  }

  statement {
    effect    = &quot;Allow&quot;
    actions   = [&quot;ecr:GetAuthorizationToken&quot;]
    resources = [&quot;*&quot;]
  }

  statement {
    effect = &quot;Allow&quot;
    actions = [
      &quot;ecr:BatchCheckLayerAvailability&quot;,
      &quot;ecr:PutImage&quot;,
      &quot;ecr:InitiateLayerUpload&quot;,
      &quot;ecr:UploadLayerPart&quot;,
      &quot;ecr:CompleteLayerUpload&quot;,
      &quot;ecr:BatchGetImage&quot;,
      &quot;ecr:DescribeRepositories&quot;,
    ]
    # 실제 ECR repo 명: b-team/pms-order. b-team 네임스페이스 하위 레포 전체 허용.
    resources = [&quot;arn:aws:ecr:${var.aws_region}:${data.aws_caller_identity.current.account_id}:repository/b-team/*&quot;]
  }

  statement {
    effect = &quot;Allow&quot;
    actions = [
      &quot;logs:CreateLogGroup&quot;,
      &quot;logs:CreateLogStream&quot;,
      &quot;logs:PutLogEvents&quot;,
      &quot;logs:DescribeLogStreams&quot;,
    ]
    resources = [&quot;arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/ssm/pms-order-deploy*&quot;]
  }
}

resource &quot;aws_iam_role_policy&quot; &quot;controller_inline&quot; {
  name   = &quot;jenkins-controller-inline&quot;
  role   = aws_iam_role.controller.id
  policy = data.aws_iam_policy_document.controller_inline.json
}

resource &quot;aws_iam_instance_profile&quot; &quot;controller&quot; {
  name = &quot;jenkins-controller-profile&quot;
  role = aws_iam_role.controller.name
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Network&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;젠킨스 서버를 띄우기 위한 권한, 저장 공간, 인스턴스 스펙을 정의하였으니 이제는 네트워크를 설정을 해야합니다. 저는 이미 구축되어있는 가상 네트워크 망과 서브넷이 있어서 기존 VPC를 테라폼 변수로 지정하여 주입하도록 구현하였습니다. 그리고 젠킨스가 뜨는 8080 포트를 인바운드 규칙을 지정해줌으로써 이제 젠킨스를 코드로 띄울 준비가 완료되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;b&gt;terraform&amp;nbsp;apply&lt;/b&gt;를 입력하면 테라폼이 내부적으로 AWS의 인프라를 구성하여 생성 및 수정해주고 AWS 콘솔을 통해 확인하면 Jenkins-Controller라는 인스턴스가 떠있는 것을 확인할 수 있었습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778159291937&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 기존 VPC 안에 Jenkins 컨트롤러 1대만 배치한다.
data &quot;aws_vpc&quot; &quot;selected&quot; {
  id = var.vpc_id
}

data &quot;aws_subnet&quot; &quot;controller&quot; {
  id = var.controller_subnet_id
}

resource &quot;aws_security_group&quot; &quot;controller&quot; {
  name        = &quot;jenkins-controller&quot;
  description = &quot;Jenkins controller - 8080 from existing VPC only&quot;
  vpc_id      = data.aws_vpc.selected.id
}

resource &quot;aws_security_group_rule&quot; &quot;controller_8080_from_vpc&quot; {
  type              = &quot;ingress&quot;
  security_group_id = aws_security_group.controller.id
  from_port         = 8080
  to_port           = 8080
  protocol          = &quot;tcp&quot;
  cidr_blocks       = [data.aws_vpc.selected.cidr_block]
  description       = &quot;HTTP from existing ALB or VPC clients&quot;
}

resource &quot;aws_security_group_rule&quot; &quot;controller_egress_all&quot; {
  type              = &quot;egress&quot;
  security_group_id = aws_security_group.controller.id
  from_port         = 0
  to_port           = 0
  protocol          = &quot;-1&quot;
  cidr_blocks       = [&quot;0.0.0.0/0&quot;]
  description       = &quot;All egress&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1778159608861&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 예시 변수값. 실제 사용 시 terraform.tfvars(gitignore됨)로 복사 후 수정.

aws_region  = &quot;ap-northeast-2&quot;
aws_profile = &quot;my-profile&quot;

# 기존 AWS 인프라
vpc_id               = &quot;vpc-xxxxxxxxxxxxxxxxx&quot;
controller_subnet_id = &quot;subnet-xxxxxxxxxxxxxxxxx&quot;

# ALB는 별도로 연결한다. ALB DNS가 정해졌다면 여기에 넣는다.
# 비워두면 Jenkins 내부 location.url은 http://localhost:8080/ 이다.
# jenkins_url = &quot;http://existing-alb-123456789.ap-northeast-2.elb.amazonaws.com/&quot;

# 리포 URL - user-data가 git clone / curl 로 받아옴
jenkins_repo_url     = &quot;https://github.com/protect-my-service/bteam-jenkins.git&quot;
jenkins_repo_ref     = &quot;main&quot;
jenkins_repo_raw_url = &quot;https://raw.githubusercontent.com/protect-my-service/bteam-jenkins/main&quot;

# 단일 컨트롤러 EC2
# controller_instance_type    = &quot;t3.medium&quot;
# associate_public_ip_address = true

# Jenkins home EBS
# data_volume_size_gb = 30&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;관련 코드&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/protect-my-service/bteam-jenkins&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/protect-my-service/bteam-jenkins&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778159831269&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - protect-my-service/bteam-jenkins: pms-order 서비스 배포 파이프라인을 위한 Jenkins 인프라 레포&quot; data-og-description=&quot;pms-order 서비스 배포 파이프라인을 위한 Jenkins 인프라 레포. Contribute to protect-my-service/bteam-jenkins development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/protect-my-service/bteam-jenkins&quot; data-og-url=&quot;https://github.com/protect-my-service/bteam-jenkins&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/protect-my-service/bteam-jenkins&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/protect-my-service/bteam-jenkins&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;GitHub - protect-my-service/bteam-jenkins: pms-order 서비스 배포 파이프라인을 위한 Jenkins 인프라 레포&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;pms-order 서비스 배포 파이프라인을 위한 Jenkins 인프라 레포. Contribute to protect-my-service/bteam-jenkins development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;CI/CD 파이프라인 구성하기&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1086&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/clGvMq/dJMcaiQKapY/A1qwuwY2k8WQBJPCAXFIbK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/clGvMq/dJMcaiQKapY/A1qwuwY2k8WQBJPCAXFIbK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/clGvMq/dJMcaiQKapY/A1qwuwY2k8WQBJPCAXFIbK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FclGvMq%2FdJMcaiQKapY%2FA1qwuwY2k8WQBJPCAXFIbK%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;614&quot; height=&quot;461&quot; data-origin-width=&quot;1448&quot; data-origin-height=&quot;1086&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 CI/CD 파이프라인은 컨테이너 단위의 Blue-Green 배포와 인스턴스 단위의 Rolling 배포를 조합한 하이브리드 방식으로 구성했습니다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;각 서버 앞단에는 Nginx 리버스 프록시를 두고, 하나의 인스턴스 내부에서는 기존 컨테이너와 신규 컨테이너를 분리하여 실행합니다. 신규 컨테이너가 정상적으로 기동되고 헬스 체크를 통과하면 Nginx의 upstream을 신규 컨테이너로 전환합니다. 이처럼 인스턴스 내부에서는 컨테이너 단위의 Blue-Green 배포가 이루어집니다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;전체 서버 관점에서는 두 대의 인스턴스를 동시에 교체하지 않고 한 대씩 순차적으로 배포합니다. 첫 번째 인스턴스의 신규 컨테이너가 정상적으로 배포되고 트래픽 전환까지 완료되면, 그 다음 두 번째 인스턴스에 동일한 과정을 수행합니다. 이 부분은 Rolling 배포 방식으로 구현이 되어있습니다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;이러한 구조를 선택한 이유는 배포 시간과 롤백 시간을 줄이기 위해서 선택하였습니다. 매번 새로운 서버를 프로비저닝하는 방식은 안정적일 수 있지만, 인스턴스 생성과 초기화에 시간이 필요합니다. 반면 기존 인스턴스 안에서 Docker 이미지를 기반으로 컨테이너만 교체하면 배포 속도를 높일 수 있습니다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;또한 신규 버전에 문제가 발생하더라도 이전 버전의 Docker 이미지를 다시 실행하고 Nginx 라우팅을 되돌리면 되기 때문에, 별도의 인프라 재구성 없이 빠르게 롤백할 수 있습니다.&lt;/span&gt;&lt;br /&gt;&lt;span&gt;결과적으로 이러한 구조를 설계하여 제한된 인프라 환경에서 무중단에 가까운 배포, 빠른 롤백, 점진적인 배포 전파를 모두 확보할 수 있는 형태로 배포를 진행할 수 있게 되었습니다.&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span&gt;젠킨스 파일&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;a href=&quot;https://github.com/protect-my-service/bteam-jenkins/blob/main/Jenkinsfile&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/protect-my-service/bteam-jenkins/blob/main/Jenkinsfile&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778161661090&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;bteam-jenkins/Jenkinsfile at main &amp;middot; protect-my-service/bteam-jenkins&quot; data-og-description=&quot;pms-order 서비스 배포 파이프라인을 위한 Jenkins 인프라 레포. Contribute to protect-my-service/bteam-jenkins development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/protect-my-service/bteam-jenkins/blob/main/Jenkinsfile&quot; data-og-url=&quot;https://github.com/protect-my-service/bteam-jenkins/blob/main/Jenkinsfile&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/coAHEa/dJMb9llbgQ1/fK0kRHHTTU4ENsUOAzeKHK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/brD6nB/dJMb9iIK755/P3KBe6Rsg1nYeCjd2nblTk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/protect-my-service/bteam-jenkins/blob/main/Jenkinsfile&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/protect-my-service/bteam-jenkins/blob/main/Jenkinsfile&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/coAHEa/dJMb9llbgQ1/fK0kRHHTTU4ENsUOAzeKHK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/brD6nB/dJMb9iIK755/P3KBe6Rsg1nYeCjd2nblTk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&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;bteam-jenkins/Jenkinsfile at main &amp;middot; protect-my-service/bteam-jenkins&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;pms-order 서비스 배포 파이프라인을 위한 Jenkins 인프라 레포. Contribute to protect-my-service/bteam-jenkins development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;후기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 Jenkins 인프라 구축 과정에서는 Claude Code와 같은 AI 도구를 함께 활용했습니다. 예전 같았다면 Terraform 리소스를 하나씩 찾아보고, Jenkins 설정 파일을 직접 비교하며, Dockerfile과 IAM 정책을 반복해서 수정하느라 훨씬 더 많은 시간이 걸렸을 것입니다. 하지만 이제는 어느 정도의 아키텍처 방향과 요구사항만 명확하다면, 인프라 코드와 설정 파일의 초안을 빠르게 만들어낼 수 있다는 것을 체감했습니다.&lt;br /&gt;이 경험을 통해 앞으로 개발자의 역할이 단순히 코드를 직접 작성하는 사람에서, 문제를 구조화하고 결과물을 검증하는 오케스트레이터에 가까워질 수 있겠다는 생각이 들었습니다. AI가 많은 부분을 빠르게 생성해줄 수는 있지만, 왜 이런 구조가 필요한지, 이 권한이 과도하지는 않은지, 장애가 났을 때 복구 가능한 구조인지 판단하는 일은 여전히 개발자의 몫이기 때문입니다.&lt;br /&gt;특히 인프라 영역에서는 겉으로 보기에는 동작하는 코드처럼 보여도, 작은 설정 하나가 보안 문제나 운영 장애로 이어질 수 있을 것입니다. IAM 권한 범위, EBS 영속성, User Data 실행 방식, Nginx 라우팅 전환, 롤백 전략 같은 부분은 단순히 코드가 생성되었다고 끝나는 것이 아니라 실제 운영 관점에서 생각하고 고려해야됩니다.&lt;br /&gt;이번 스터디를 통해 구현 속도는 분명히 과거보다 훨씬 빨라졌다는 것을 느꼈습니다. 동시에 그만큼 내부 동작을 깊게 이해하지 못한 채 넘어갈 위험도 커졌다고 생각합니다. 결국 중요한 것은 AI를 통해 빠르게 만드는 것이 아니라, 빠르게 만든 결과물을 내가 설명할 수 있고, 운영 중 문제가 발생했을 때 책임지고 수정할 수 있느냐? 가 중요해질 것 같다는 생각이 들었습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;</description>
      <category>devops/aws</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/196</guid>
      <comments>https://codediary21.tistory.com/196#entry196comment</comments>
      <pubDate>Thu, 7 May 2026 23:01:47 +0900</pubDate>
    </item>
    <item>
      <title>인프라 공방전 스터디 1주차 - VPC를 처음부터 구축해보기</title>
      <link>https://codediary21.tistory.com/195</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;인프라 공방전 스터디를 열게 된 이유?&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqXcYv/dJMcagkNOf1/XK9U7654rvrTLLc1q6Hl51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqXcYv/dJMcagkNOf1/XK9U7654rvrTLLc1q6Hl51/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqXcYv/dJMcagkNOf1/XK9U7654rvrTLLc1q6Hl51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqXcYv%2FdJMcagkNOf1%2FXK9U7654rvrTLLc1q6Hl51%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;538&quot; height=&quot;303&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 기술 발전으로 개발 영역이 빠르게 대체되는 시대에, 개발자는 비즈니스 성장과 안정적인 시스템 구축이라는 두 마리 토끼를 모두 잡아야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러기 위해서는 비즈니스에 적절한 시점에 적합한 기술을 도입하고 비즈니스의 변화를 염두해두고 디자인하는 사람이 되어야 되는데 이를 하기 위해서는 경험과 지식이 필요한데 안정적인 서비스 운영은 직접 경험을 통해 배울 수 있기 때문에 이 스터디를 만들게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;SSH 없이 EC2 접속하기 &amp;mdash; AWS SSM 터널링 완전 정복&lt;/b&gt;&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 실제 세팅 과정에서 마주친 에러들을 하나씩 해결하며 작성한 실전 경험 기반의 글입니다. Private Subnet EC2에 SSM으로 접속하는 전 과정을 정리해보았습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 SSM인가?&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;884&quot; data-origin-height=&quot;397&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVI5PR/dJMcahcW8eT/byPXFbNl9AKVZoPEmtbdKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVI5PR/dJMcahcW8eT/byPXFbNl9AKVZoPEmtbdKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVI5PR/dJMcahcW8eT/byPXFbNl9AKVZoPEmtbdKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVI5PR%2FdJMcahcW8eT%2FbyPXFbNl9AKVZoPEmtbdKK%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;619&quot; height=&quot;278&quot; data-origin-width=&quot;884&quot; data-origin-height=&quot;397&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전통적인 SSH 접속은 몇 가지 문제를 안고 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인바운드 22번 포트를 열어야 한다&lt;/li&gt;
&lt;li&gt;SSH 키를 관리해야 한다&lt;/li&gt;
&lt;li&gt;Bastion Host가 필요하다&lt;/li&gt;
&lt;li&gt;Private Subnet의 인스턴스에는 직접 접근이 불가능하다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS SSM(Systems Manager) Session Manager는 이 모든 문제를 해결합니다. EC2의 SSM Agent가 SSM 서비스로 먼저 아웃바운드 연결을 맺고, 로컬 클라이언트는 그 연결을 통해 세션을 시작하는 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;로컬 PC
  └─ aws ssm start-session
       └─ SSM 엔드포인트 (443 아웃바운드만 필요)
            └─ SSM Agent (EC2)
                 └─ 대상 포트 (DB, Web 등)
&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;인바운드 포트가 전혀 필요 없습니다. 보안 그룹에 아웃바운드 443만 열려 있으면 충분합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;사전 준비&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. AWS Credentials 설정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSM 명령어를 실행하기 전에 반드시 로컬에 AWS 인증 정보를 설정해야 합니다. AccessKey와 시크릿 키를 만드는 건 아래의 &lt;a title=&quot;문서&quot; href=&quot;https://docs.aws.amazon.com/ko_kr/IAM/latest/UserGuide/id_credentials_access-keys.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;문서&lt;/a&gt;를 참고해주세요.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;aws configure --profile my-profile
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;AWS Access Key ID: AKIA...
AWS Secret Access Key: xxxxxxxx
Default region name: ap-northeast-2
Default output format: json
&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;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;aws sts get-caller-identity --profile my-profile
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상 출력되면 준비 완료입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주의: region 입력 시 오타에 유의한다. ap-northeaset-2 (X) &amp;rarr; ap-northeast-2 (O) 오타가 있으면 Could not connect to the endpoint URL 에러가 발생한다.&lt;/p&gt;
&lt;/blockquote&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. Session Manager Plugin 설치&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;aws ssm start-session 명령어는 내부적으로 Session Manager Plugin을 호출합니다. AWS CLI와 별개로 로컬에 설치해야 하는 플러그인이기 때문에 cli를 설치하셨더라도 설치를 권장드립니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;# macOS
brew install --cask session-manager-plugin

# 설치 확인
session-manager-plugin --version
&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;pre class=&quot;mercury&quot;&gt;&lt;code&gt;SessionManagerPlugin is not found.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. EC2에 IAM Role 부여&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 인스턴스에 AmazonSSMManagedInstanceCore 정책이 포함된 IAM Role을 부여해야 합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 &amp;rarr; 인스턴스 선택 &amp;rarr; 작업 &amp;rarr; 보안 &amp;rarr; IAM 역할 수정&lt;/p&gt;
&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;로컬 CLI 사용자에게도 아래 권한이 필요합니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;Effect&quot;: &quot;Allow&quot;,
  &quot;Action&quot;: [
    &quot;ssm:StartSession&quot;,
    &quot;ssm:TerminateSession&quot;,
    &quot;ssm:DescribeSessions&quot;,
    &quot;ssm:DescribeInstanceInformation&quot;
  ],
  &quot;Resource&quot;: &quot;*&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;트러블슈팅 1 &amp;mdash; TargetNotConnected&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스를 ssm연결을 호출 했을 때 대상이 존재하지 않는다는 메세지가 발생하면서 접속되지 않은 현상입니다. 원인은 크게 두가지로 볼 수 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;접근하려는 EC2가 ssm 에이전트가 제대로 설치되거나 연동되지 않은 경우&lt;/li&gt;
&lt;li&gt;IAM Role이 제대로 부여되지 않은 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;An error occurred (TargetNotConnected) when calling the StartSession operation:
i-xxxxxxxxxxxxxxxxx is not connected.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;진단&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 인스턴스가 SSM에 등록됐는지 확인합니다. AWS 대시보드 화면에서 보려면 EC2 클릭 후 작업 -&amp;gt; 모니터링 및 문제 해결 -&amp;gt; 시스템 로그 조회에서 보실 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;aws ssm describe-instance-information --profile my-profile
# &amp;rarr; InstanceInformationList: []  (빈 결과면 문제)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2에 직접 접속해서 IAM 정보를 확인하면 IAM 권한이 있는지 확인할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;curl http://169.254.169.254/latest/meta-data/iam/info
# &amp;rarr; 아무것도 출력되지 않음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이게 핵심 단서였습니다.&lt;/b&gt; IAM Role이 전파 중인 것이 아니라 애초에 EC2에 IAM Role이 붙어있지 않은 상황이였습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;원인&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSM Agent가 SSM 서비스에 자신을 등록하려면 AmazonSSMManagedInstanceCore 권한이 필요한데, Role 자체가 없으니 등록이 불가능했던 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 Role 부여 후에도 바로 연결되지 않는 이유는 &lt;b&gt;SSM Agent가 SSM 서비스에 재등록하는 시간&lt;/b&gt;이 필요하기 때문에 조금 시간이 지난 뒤 재시도해보니 PingStatus: Online이 확인할 수 있습니다. 그 뒤에는 세션 연결이 가능합니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;aws ssm describe-instance-information --profile my-profile
# &amp;rarr; PingStatus: &quot;Online&quot; 이 뜨면 연결 가능
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;교훈&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TargetNotConnected는 &quot;연결이 끊겼다&quot;가 아니라 &quot;한 번도 연결된 적 없다&quot;일 수 있다. 에이전트가 연결 되었는지 확인하고 EC2 메타데이터 API를 통해 IAM Role 유무를 통해 확인할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;트러블슈팅 2 &amp;mdash; ssm-user로 접속되어 ec2-user 디렉토리 접근 불가&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSM 접속 후 /home/ec2-user로 이동하려 하면 다음과 같이 접근 권한 문제가 뜹니다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;sh-5.2$ cd ec2-user/
sh: cd: ec2-user/: Permission denied
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;원인&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSM Session Manager는 기본적으로 &lt;b&gt;ssm-user&lt;/b&gt; 계정으로 세션을 시작합니다. ssm-user는 ec2-user의 홈 디렉토리에 접근 권한이 없습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;잘못된 시도&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;sudo cd가 동작하지 않는 이유는 cd가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;shell builtin&lt;/b&gt;이기 때문이다. 만약 윈도우에서 guest의 메모장을 켰을 때 이 메모장을 root 계정의 메모장으로 만들 수 없듯이 나 자신의 상태를 바꾸는 건 불가능하다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;sudo cd ec2-user/   # 아무 반응 없음
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Shell Builtin이란?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리눅스에서 명령어는 두 종류로 나뉩니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;종류&lt;/td&gt;
&lt;td&gt;예시&lt;/td&gt;
&lt;td&gt;특징&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 명령어&lt;/td&gt;
&lt;td&gt;ls, cp, mv&lt;/td&gt;
&lt;td&gt;파일시스템에 실행 파일로 존재 (/bin/ls)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shell Builtin&lt;/td&gt;
&lt;td&gt;cd, export, alias&lt;/td&gt;
&lt;td&gt;Shell 자체에 내장, 별도 파일 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sudo는 &lt;b&gt;새로운 프로세스&lt;/b&gt;를 다른 권한으로 실행하는 명령어입니다. cd는 현재 shell 프로세스의 작업 디렉토리 상태를 바꾸는 것이므로 sudo로 실행할 수 있는 대상 자체가 아닙니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;type cd    # cd is a shell builtin
type ls    # ls is /bin/ls
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;올바른 해결&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;sudo su - ec2-user
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sudo su - ec2-user를 통해 &lt;b&gt;ec2-user 권한으로 새로운 shell 프로세스를 띄우는 것입니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;SSM 세션 시작
└── sh 프로세스 (ssm-user 권한)
      └── sudo su - ec2-user 실행
            └── bash 프로세스 (ec2-user 권한)  &amp;larr; 여기서 작업
                  └── exit &amp;rarr; 다시 ssm-user의 sh로 돌아옴
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Private Subnet EC2에 SSM 연결하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Private Subnet의 EC2는 인터넷망이 연결되어있지 않기 때문에 SSM 서비스에 접근할 수 없습니다. 해결 방법은 두 가지 입니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;방법&lt;/td&gt;
&lt;td&gt;장점&lt;/td&gt;
&lt;td&gt;단점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT Instance&lt;/td&gt;
&lt;td&gt;비용 저렴 (EC2 비용만)&lt;/td&gt;
&lt;td&gt;직접 관리 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT Gateway&lt;/td&gt;
&lt;td&gt;AWS 완전 관리, 고가용성&lt;/td&gt;
&lt;td&gt;시간당 요금 + 데이터 요금&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인프라 규모나 서비스 규모가 작다면 NAT Instance가 비용 면에서 유리합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;NAT Instance 구성&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. Public Subnet에 EC2 생성&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;956&quot; data-origin-height=&quot;502&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biES1L/dJMcahjLhxT/mEQssDiYPunIYHOHjINsek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biES1L/dJMcahjLhxT/mEQssDiYPunIYHOHjINsek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biES1L/dJMcahjLhxT/mEQssDiYPunIYHOHjINsek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiES1L%2FdJMcahjLhxT%2FmEQssDiYPunIYHOHjINsek%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;584&quot; height=&quot;307&quot; data-origin-width=&quot;956&quot; data-origin-height=&quot;502&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;서브넷: Public Subnet  &amp;larr; 반드시 퍼블릭 서브넷에 생성
퍼블릭 IP 자동 할당: 활성화
보안 그룹 인바운드:
  - All Traffic &amp;larr; Private Subnet CIDR (예: 10.0.1.0/24)
보안 그룹 아웃바운드:
  - All Traffic &amp;larr; 0.0.0.0/0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 소스/대상 확인 비활성화&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 &amp;rarr; NAT Instance &amp;rarr; 작업 &amp;rarr; 네트워킹 &amp;rarr; 소스/대상 확인 변경 &amp;rarr; 중지 체크&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NAT Instance는 트래픽을 받아 전달해주는 역활이기 때문에 자신이 출발지/목적지가 아닌 트래픽을 전달해야 합니다. 위 설정을 반드시 비활성화해야 합니다. 이걸 빠뜨리면 아무리 다른 설정을 해도 NAT가 동작하지 않습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. IP 포워딩 및 iptables 설정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;IPtable란?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iptables는 Linux 커널에서 netfilter 프레임워크를 제어하는 도구입니다. 패킷이 커널을 통과할 때 어떻게 처리할 지 정의하는 도구라고 생각하면 됩니다.&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;b&gt;4. 테이블과 체인 구조&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iptables는 &lt;b&gt;테이블 &amp;rarr; 체인 &amp;rarr; 규칙&lt;/b&gt; 3단계 구조로 이루어져 있습니다. 패킷을 택배, iptable을 물류 창고라고 생각하면 테이블은 택배의 종류입니다. 일반 택배인지 퀵 배송인지 신선 제품인지를 구분짓는 것이고 체인은 창고의 위치입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입고 전 창고와 출고 직전 창고가 다르듯이 패킷의 상태에 따라 위치하게 되는 체인이 달라집니다. 그 다음은 규칙은 택배를 반송 시킬 건지 폐기 시킬 건지에 대한 기준이라고 생각하면 편합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;테이블 종류&lt;/b&gt;&lt;/h4&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;테이블&lt;/td&gt;
&lt;td&gt;역활&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;filter&lt;/td&gt;
&lt;td&gt;패킷 허용/차단 (기본값)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nat&lt;/td&gt;
&lt;td&gt;주소 변환 (NAT)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mangle&lt;/td&gt;
&lt;td&gt;패킷 헤더 수정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;raw&lt;/td&gt;
&lt;td&gt;연결 추적 제외 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;패킷 흐름과 체인&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;858&quot; data-origin-height=&quot;1204&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKuLLb/dJMcaa5Y8fx/YmZ0iJhHbdtwdnKDVzmPTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKuLLb/dJMcaa5Y8fx/YmZ0iJhHbdtwdnKDVzmPTk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKuLLb/dJMcaa5Y8fx/YmZ0iJhHbdtwdnKDVzmPTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKuLLb%2FdJMcaa5Y8fx%2FYmZ0iJhHbdtwdnKDVzmPTk%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;467&quot; height=&quot;655&quot; data-origin-width=&quot;858&quot; data-origin-height=&quot;1204&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;체인별 역활&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;INPUT&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 프로세스로 들어오는 패킷&lt;/p&gt;
&lt;pre id=&quot;code_1775902992125&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# SSH 접속 허용
iptables -A INPUT -p tcp --dport 22 -j ACCEPT&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OUTPUT&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 프로세스에서 나가는 패킷.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# 외부로 나가는 HTTP 허용
iptables -A OUTPUT -p tcp --dport 80 -j ACCEPT&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;FORWARD&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라우터/NAT처럼 &lt;b&gt;통과시키는&lt;/b&gt; 패킷. 오늘 NAT Instance에서 이게 REJECT였기 때문에 패킷이 전달되지 않았습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;mipsasm&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;# 전달 허용
iptables -A FORWARD -j ACCEPT&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PREROUTING&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패킷이 라우팅 결정되기 전에 처리. (주로 DNAT에 사용)&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;# 외부 80포트 &amp;rarr; 내부 8080으로 전달
iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 10.0.1.100:8080&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;POSTROUTING&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패킷이 나가기 직전에 처리. (주로 SNAT/MASQUERADE에 사용)&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;# 오늘 설정한 바로 이것
iptables -t nat -A POSTROUTING -o ens5 -j MASQUERADE&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5. 실제 실행 명령어&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# ============================================
# NAT Instance 설정 스크립트
# ============================================

# [1] IP 포워딩 즉시 활성화
# - 리눅스는 기본적으로 자신이 목적지가 아닌 패킷을 버린다
# - ip_forward=1 로 설정하면 다른 호스트 간 패킷을 전달(라우팅)할 수 있게 된다
# - sysctl -w 는 커널 파라미터를 런타임에 즉시 변경 (재부팅 시 초기화됨)
sudo sysctl -w net.ipv4.ip_forward=1

# [2] IP 포워딩 영구 적용
# - /etc/sysctl.conf 에 설정을 추가해 재부팅 후에도 유지되도록 한다
# - tee -a 는 파일에 내용을 추가(append) 한다 (덮어쓰기 아님)
echo &quot;net.ipv4.ip_forward = 1&quot; | sudo tee -a /etc/sysctl.conf

# [3] NAT 규칙 추가 (MASQUERADE)
# - -t nat         : nat 테이블에 규칙 추가
# - -A POSTROUTING : 패킷이 외부로 나가기 직전 시점(POSTROUTING 체인)에 적용
# - -o ens5        : 아웃바운드 인터페이스가 ens5 인 패킷에만 적용
# - -j MASQUERADE  : 출발지 IP를 ens5 인터페이스의 현재 IP로 자동 변환
#                    (SNAT와 달리 IP를 명시하지 않아도 되므로 EC2처럼 IP가 바뀌는 환경에 적합)
sudo iptables -t nat -A POSTROUTING -o ens5 -j MASQUERADE

# [4] FORWARD 체인 허용 &amp;larr; 오늘 문제의 핵심
# - NAT Instance는 Private EC2의 패킷을 대신 전달하는 역할을 한다
# - 이때 패킷은 FORWARD 체인을 통과하는데, 기본값이 REJECT 인 경우 패킷이 버려진다
# - -I FORWARD : FORWARD 체인의 맨 앞(1번)에 규칙을 삽입 (-A 는 맨 뒤 추가)
#   맨 앞에 넣는 이유: 기존 REJECT 규칙보다 먼저 매칭되어 ACCEPT 처리되도록 하기 위함
# - -j ACCEPT  : 매칭된 패킷을 허용
sudo iptables -I FORWARD -j ACCEPT

# [5] iptables 규칙 영구 저장
# - iptables 규칙은 메모리에만 존재하므로 재부팅 시 초기화된다
# - iptables-save  : 현재 메모리의 모든 iptables 규칙을 텍스트 형식으로 출력
# - tee            : 출력 내용을 파일로 저장 (표준출력에도 동시 출력)
# - /etc/sysconfig/iptables : 부팅 시 iptables-restore 가 읽는 규칙 파일 경로
#   (systemctl enable iptables 가 활성화된 경우 부팅 시 자동 복원됨)
sudo iptables-save | sudo tee /etc/sysconfig/iptables&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주의: iptables의 FORWARD 체인이 기본적으로 REJECT로 설정된 경우가 있다. 이 경우 패킷이 NAT Instance까지 도달해도 전달되지 않는다. 반드시 FORWARD 체인 상태를 확인해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;# FORWARD 체인 확인
sudo iptables -L FORWARD -n -v

# REJECT 규칙이 있으면 삭제
sudo iptables -D FORWARD -j REJECT --reject-with icmp-host-prohibited
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6. Private Subnet 라우팅 테이블 설정&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VPC &amp;rarr; 라우팅 테이블 &amp;rarr; Private Subnet 라우팅 테이블 &amp;rarr; 라우팅 편집&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;accesslog&quot;&gt;&lt;code&gt;대상(Destination)   타깃(Target)
0.0.0.0/0          NAT Instance ID
10.0.0.0/16        local
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;인스턴스를 타깃으로 지정하면 AWS가 자동으로 ENI로 변환한다. 정상 동작이므로 걱정하지 않아도 됩니다.&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;직렬 콘솔 설정 (Private EC2 초기 접근 시)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NAT Instance 구성 전 Private EC2에 접근해야 할 때 직렬 콘솔을 사용할 수 있습니다. 직렬 콘솔은 VPC 네트워크와 독립적으로 동작하므로 네트워크 문제와 무관하게 접속 가능하기 때문에 네트워크 문제를 추적하기에 용이합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;직렬 콘솔이 안 됐던 3가지 원인&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;원인 1 &amp;mdash; AWS 계정 레벨 비활성화&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직렬 콘솔은 기본적으로 계정 레벨에서 비활성화되어 있습니다.&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;# 활성화 상태 확인
aws ec2 get-serial-console-access-status

# 활성화
aws ec2 enable-serial-console-access
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는 콘솔에서:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 &amp;rarr; 대시보드 &amp;rarr; 계정 속성 &amp;rarr; EC2 직렬 콘솔 &amp;rarr; 관리 &amp;rarr; 허용&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;원인 2 &amp;mdash; OS 비밀번호 미설정&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Amazon Linux 2023은 기본적으로 비밀번호 로그인이 비활성화되어 있다. 직렬 콘솔 화면은 뜨지만 어떤 비밀번호를 입력해도 Login incorrect가 발생합니다. User Data를 통해 비밀번호를 설정해야 한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;원인 3 &amp;mdash; cloud-init 기본 동작으로 인해 User Data가 재실행되지 않음&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2의 Linux 인스턴스에서 일반적인 User Data 스크립트는 cloud-init에 의해 기본적으로 once-per-instance로 실행됩니다. 따라서 이미 초기 실행이 끝난 인스턴스에서 User Data를 수정하더라도, 인스턴스를 다시 시작한다고 자동으로 재실행되지는 않습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결 &amp;mdash; MIME 멀티파트 방식&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MIME(Multipurpose Internet Mail Extensions)은 여러 종류의 콘텐츠를 하나의 메시지에 담기 위한 표입니다. 원래 이메일에서 본문과 첨부파일을 함께 전송하기 위해 만들어졌으며, cloud-init도 같은 방식을 차용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;always 옵션을 적용하려면 cloud-config와 shell script를 MIME 멀티파트로 분리해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;Content-Type: multipart/mixed; boundary=&quot;//&quot;
MIME-Version: 1.0

--//
Content-Type: text/cloud-config; charset=&quot;us-ascii&quot;
MIME-Version: 1.0

#cloud-config
cloud_final_modules:
- [scripts-user, always]    &amp;larr; 매번 강제 실행

--//
Content-Type: text/x-shellscript; charset=&quot;us-ascii&quot;
MIME-Version: 1.0

#!/bin/bash
echo &quot;password&quot; | passwd --stdin ec2-user
passwd -u ec2-user
echo &quot;password&quot; | passwd --stdin root
passwd -u root
sed -i 's/^auth\s*required\s*pam_securetty.so/#auth required pam_securetty.so/' /etc/pam.d/login
echo &quot;done $(date)&quot; &amp;gt;&amp;gt; /var/log/userdata-result.log
--//
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공하면 시스템 로그에서 아래 메시지를 확인할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;cloud-init: Changing password for user ec2-user.
cloud-init: passwd: all authentication tokens updated successfully.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;전체 트러블슈팅 요약&lt;/b&gt;&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;에러&lt;/td&gt;
&lt;td&gt;증상&lt;/td&gt;
&lt;td&gt;실제 원인&lt;/td&gt;
&lt;td&gt;해결 방법&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TargetNotConnected&lt;/td&gt;
&lt;td&gt;SSM 연결 안 됨&lt;/td&gt;
&lt;td&gt;IAM Role 미부여&lt;/td&gt;
&lt;td&gt;EC2에 Role 부여 후 대기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SessionManagerPlugin not found&lt;/td&gt;
&lt;td&gt;CLI 명령 실패&lt;/td&gt;
&lt;td&gt;플러그인 미설치&lt;/td&gt;
&lt;td&gt;brew install --cask session-manager-plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permission denied on ec2-user&lt;/td&gt;
&lt;td&gt;홈 디렉토리 접근 불가&lt;/td&gt;
&lt;td&gt;SSM 기본 계정이 ssm-user&lt;/td&gt;
&lt;td&gt;sudo su - ec2-user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;직렬 콘솔 화면 안 뜸&lt;/td&gt;
&lt;td&gt;콘솔 접속 불가&lt;/td&gt;
&lt;td&gt;계정 레벨 비활성화&lt;/td&gt;
&lt;td&gt;AWS 콘솔에서 직렬 콘솔 허용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login incorrect&lt;/td&gt;
&lt;td&gt;OS 로그인 실패&lt;/td&gt;
&lt;td&gt;비밀번호 미설정&lt;/td&gt;
&lt;td&gt;User Data MIME 멀티파트로 설정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl 응답 없음&lt;/td&gt;
&lt;td&gt;인터넷 연결 안 됨&lt;/td&gt;
&lt;td&gt;iptables FORWARD REJECT&lt;/td&gt;
&lt;td&gt;sudo iptables -I FORWARD -j ACCEPT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT 동작 안 함&lt;/td&gt;
&lt;td&gt;패킷 전달 안 됨&lt;/td&gt;
&lt;td&gt;소스/대상 확인 활성화&lt;/td&gt;
&lt;td&gt;소스/대상 확인 비활성화&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;빠른 진단 체크리스트&lt;/b&gt;&lt;/h2&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SSM 연결 안 될 때:

1. IAM Role 있음?
   curl http://169.254.169.254/latest/meta-data/iam/info
   &amp;rarr; 빈 값이면 Role 없음

2. SSM에 인스턴스 보임?
   aws ssm describe-instance-information
   &amp;rarr; 빈 리스트면 미등록 상태

3. PingStatus: Online?
   &amp;rarr; Online이면 start-session 가능

4. Session Manager Plugin 설치됨?
   session-manager-plugin --version

5. Private Subnet이면 NAT 있음?
   curl https://checkip.amazonaws.com
   &amp;rarr; 응답 없으면 NAT 설정 확인

NAT Instance 체크리스트:
  □ Public Subnet에 있는지
  □ 소스/대상 확인 비활성화
  □ IP 포워딩: cat /proc/sys/net/ipv4/ip_forward &amp;rarr; 1
  □ iptables FORWARD 체인: ACCEPT 상태인지
  □ iptables MASQUERADE 규칙 있는지
  □ Private Subnet 라우팅 테이블: 0.0.0.0/0 &amp;rarr; NAT Instance
&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;마치며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSM 세팅 과정은 단순해 보이지만 실제로는 여러 레이어의 설정이 맞물려 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;AWS 레벨&lt;/b&gt;: IAM Role, 계정 설정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;네트워크 레벨&lt;/b&gt;: 라우팅 테이블, 보안 그룹, NAT&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OS 레벨&lt;/b&gt;: SSM Agent, iptables, 비밀번호&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어느 하나라도 빠지면 에러가 발생하고, 에러 메시지만으로는 어느 레이어의 문제인지 바로 파악하기 어려웠던 것 같습니다. 그래서 한번에 세팅하여 에러 원인을 찾기보단 레이어 별로 설정한 뒤 테스트 해보는 것을 권장드립니다.&lt;/p&gt;</description>
      <category>devops/aws</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/195</guid>
      <comments>https://codediary21.tistory.com/195#entry195comment</comments>
      <pubDate>Sat, 18 Apr 2026 11:29:50 +0900</pubDate>
    </item>
    <item>
      <title>1년 4번의 이직, 나는 문제가 있는 걸까</title>
      <link>https://codediary21.tistory.com/194</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;이직의 시작&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCyME5/dJMcaaLA9NN/XB1JtcaX0AvvCm6PbkyfLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCyME5/dJMcaaLA9NN/XB1JtcaX0AvvCm6PbkyfLK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCyME5/dJMcaaLA9NN/XB1JtcaX0AvvCm6PbkyfLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCyME5%2FdJMcaaLA9NN%2FXB1JtcaX0AvvCm6PbkyfLK%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;581&quot; height=&quot;317&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&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;2024년, 재직하고 있던 회사의 상황이 어려워지면서 내가 속한 팀이 정리되었다. 이미 회사에 불만을 품고 있던 나는 오히려 기회라고 생각했고, 잔류 대신 퇴직을 택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입사 전 회사는 두 가지 비전을 이야기해주었다. Ruby에서 Java로 기술 스택을 전환할 것이라는 점, 그리고 커진 조직 규모에 맞춰 목적 조직 중심으로 MSA를 도입해 시스템을 운영할 것이라는 점이었다. 나는 그 미래를 믿고 DDD와 분산 시스템 아키텍처를 공부하며 나름의 준비를 해왔다.&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;실패 속에서 찾아온 기회&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;423&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FU01S/dJMcadO1vRU/4mhsIBcYBBZtxl0Kzia6iK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FU01S/dJMcadO1vRU/4mhsIBcYBBZtxl0Kzia6iK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FU01S/dJMcadO1vRU/4mhsIBcYBBZtxl0Kzia6iK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFU01S%2FdJMcadO1vRU%2F4mhsIBcYBBZtxl0Kzia6iK%2Fimg.jpg&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;586&quot; height=&quot;310&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;423&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실업급여가 나오는 상황이었고, 그동안 모아둔 자금도 어느 정도 있었기에 급하게 이직하기보다는 여유를 갖고 준비하기로 했다. 그래도 루틴은 놓치고 싶지 않아서, 함께 퇴사한 동료들과 사당역 카페에 모여 꾸준히 준비를 이어갔다. 면접, 코딩 테스트, 과제 전형까지 실전 경험을 쌓아가며 나름의 성과도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 내가 지원하는 회사들은 대부분 기존에 다뤄온 기술 스택과 달랐고, 아직 준비가 부족한 나는 떨어지기 일쑤였다. 그래도 실패에서 배운다는 마음으로 계속 도전했다. 하지만 시간이 길어질수록 자신감은 서서히 걱정으로 바뀌어갔고, 나 자신에 대한 확신도 점점 흐려졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 한 번쯤 머리를 비워야겠다는 생각에 일본으로 여행을 다녀왔고, 비영리 단체에 재능 기부를 하기도 했다. 당장 결과를 만들어내는 일보다, 에너지를 채울 수 있는 일에 시간을 쓰려고 했던 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 한동안 시간을 보내고 있던 어느 날, 한 커머스 기업에서 커피챗 요청이 들어왔다. 물론 100점 짜리 기업은 아니였지만 기술 스택도 일치하였고 내가 원하는 도메인과 기술을 쓰는 회사였기에 기쁜 마음으로 수락을 눌렀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;도망친 곳에서는 낙원은 없다.&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;320&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/w1ihJ/dJMcajhp50N/zCYQd57K1yyGfIJ32OAMOk/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/w1ihJ/dJMcajhp50N/zCYQd57K1yyGfIJ32OAMOk/img.webp&quot; data-alt=&quot;베르세르크의 한 장면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/w1ihJ/dJMcajhp50N/zCYQd57K1yyGfIJ32OAMOk/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fw1ihJ%2FdJMcajhp50N%2FzCYQd57K1yyGfIJ32OAMOk%2Fimg.webp&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;400&quot; height=&quot;320&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;320&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;베르세르크에는 이런 장면이 있다. 너무나 힘든 현실에서 벗어나고 싶었던 한 소녀가 주인공에게 자신을 데려가 달라고 말한다. 하지만 주인공은 그 부탁을 거절하며 이렇게 말한다. &quot;도망쳐서 도착한 곳에 낙원이란 있을 수 없는 거야.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 주인공이 말하고자 한 건 이런 것이다. 스스로 이 시련을 이겨내지 않으면, 다음에 같은 시련이 왔을 때, 아니 그보다 더 힘든 시련이 왔을 때에도 이겨낼 수 없다는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 도망쳤다. 반복되는 이직 전형을 처음부터 다시 준비해야 하는 현실이 지겨웠고, 눈앞에 온 기회에 쉽게 올라탔다. 그 대가는 컸다. 회사의 비전과 재무 상태를 주의 깊게 살펴보지 못했고, 어떤 팀원들과 어떤 분위기 속에서 어려움을 헤쳐나가고 있는지도 제대로 파악하지 못했다. 이것은 나의 가장 큰 패착이었다.&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;b&gt;처음부터 다시 시작하기&amp;nbsp;&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1902&quot; data-origin-height=&quot;1100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n4Opq/dJMcacbzrGw/cDgyCt8KgrXDOIj2MdWMp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n4Opq/dJMcacbzrGw/cDgyCt8KgrXDOIj2MdWMp0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n4Opq/dJMcacbzrGw/cDgyCt8KgrXDOIj2MdWMp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn4Opq%2FdJMcacbzrGw%2FcDgyCt8KgrXDOIj2MdWMp0%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;545&quot; height=&quot;315&quot; data-origin-width=&quot;1902&quot; data-origin-height=&quot;1100&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;그 회사는 이 어려운 상황을 헤쳐나갈 전략조차 없었다. 조직원들이 납득하기 어려운 선택들을 반복했고, 얼마 남지 않았던 좋은 사람들마저 하나둘 등을 돌리게 만들었다. 아무런 비전도 희망도 없이 이 상황을 버텨내는 건 사실상 불가능하다고 판단했다. 결국 자발적으로 수습 종료 의사를 전했고, 그렇게 2025년 첫 번째 회사를 떠나 다시 원점으로 돌아왔다.&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;맞지 않는 옷을 입기는 어려워.&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Gemini_Generated_Image_ovp3nvovp3nvovp3.png&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cs1HKZ/dJMcadIg00j/Mjn01XduOvzTlwSbOaO3S0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cs1HKZ/dJMcadIg00j/Mjn01XduOvzTlwSbOaO3S0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cs1HKZ/dJMcadIg00j/Mjn01XduOvzTlwSbOaO3S0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcs1HKZ%2FdJMcadIg00j%2FMjn01XduOvzTlwSbOaO3S0%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;728&quot; height=&quot;397&quot; data-filename=&quot;Gemini_Generated_Image_ovp3nvovp3nvovp3.png&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다들 어렸을 때 맞지 않는 옷을 억지로 입어본 적이 있을 것이다. 숨이 막힐 듯 답답해지고, 얼른 이 옷을 벗고 싶다는 생각밖에 들지 않는다. 나에게 두 번째 회사가 그런 곳이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;열심히 연습한 덕에 면접에서도 가끔씩 합격 소식을 들을 수 있게 되었고, 두 번째 회사에 지원했을 때도 기분 좋은 결과를 받았다. 자그마치 면접만 네 시간이 넘었다. 오후부터 저녁 늦게까지 이어진 힘든 과정이었지만, 재정 상태도 탄탄하고 비전도 좋은 회사였기에 진심을 다했다.&lt;/p&gt;
&lt;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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;다시 돌아와 시작하기&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;401&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1kBt8/dJMcagSAqAW/TmT1Lbk5JGSyyfTJwvETBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1kBt8/dJMcagSAqAW/TmT1Lbk5JGSyyfTJwvETBK/img.png&quot; data-alt=&quot;강철의 연금술사 한 장면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1kBt8/dJMcagSAqAW/TmT1Lbk5JGSyyfTJwvETBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1kBt8%2FdJMcagSAqAW%2FTmT1Lbk5JGSyyfTJwvETBK%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;527&quot; height=&quot;286&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;401&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;세 번째 회사를 떠날 때가 가장 충격이 컸다. 첫 번째와 두 번째는 내가 선택한 퇴사였지만, 세 번째는 달랐다. 극복하려고 노력했지만 끝내 해내지 못한 것이기에 더 아팠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나름대로 Node.js를 사이드 프로젝트에 적용해보며 준비했다고 생각했지만, 실무에서 능숙하게 다루는 건 그만큼의 시간과 경험 없이는 불가능했다. AI가 메워줄 수 있는 공백에는 한계가 있었다. 그래서 결심했다. 지금까지 가장 익숙하고 능숙하게 다뤄온 Java로 돌아가자.&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;/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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;직감을 믿고 일단 실행하라&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1602&quot; data-origin-height=&quot;898&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/53Wxa/dJMcacbzvlE/XkyPrH1mUEglvSTrG6rgk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/53Wxa/dJMcacbzvlE/XkyPrH1mUEglvSTrG6rgk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/53Wxa/dJMcacbzvlE/XkyPrH1mUEglvSTrG6rgk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F53Wxa%2FdJMcacbzvlE%2FXkyPrH1mUEglvSTrG6rgk1%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;548&quot; height=&quot;307&quot; data-origin-width=&quot;1602&quot; data-origin-height=&quot;898&quot;/&gt;&lt;/span&gt;&lt;/figure&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;그래서 가장 빠르게 도입할 수 있는 것부터 손을 댔다. n8n을 들여와 단순 반복 운영 업무를 자동화하기 시작했고, 덕분에 반복되는 작업을 조금이나마 줄여나갈 수 있었다. 눈에 보이는 변화가 생기자 동료들도 점점 신뢰를 보내기 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음은 지금 해야 하는 일을 정리하는 것이었다. 인수인계, AWS 인프라 이전, 데이터 센터 이전 등 여러 목표가 동시에 놓여 있었고, 고민하면 할수록 시간만 흘러가며 갈팡질팡할 수밖에 없었다. 그래서 이 모든 일을 잠시 내려놓고 대화부터 시작했다. 정부 프로젝트를 맡게 된 배경, 인수인계의 범위, 마감 시점, 현재 진행 상황까지 하나하나 깊이 이야기를 나누며 함께 정리해 나가기 시작했다.&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;b&gt;정리하고 난 뒤 다듬기&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Rnf9X/dJMcad2y7Sm/OkWiy8Jkxcjp92Bl8CmITK/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Rnf9X/dJMcad2y7Sm/OkWiy8Jkxcjp92Bl8CmITK/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Rnf9X/dJMcad2y7Sm/OkWiy8Jkxcjp92Bl8CmITK/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRnf9X%2FdJMcad2y7Sm%2FOkWiy8Jkxcjp92Bl8CmITK%2Fimg.webp&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;503&quot; height=&quot;335&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;/figure&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;그래서 먼저 노션에 Task Board를 도입해 업무 공유 방식을 바꿨다. 팀원들의 피드백을 받아 개선해 나가면서 병목 없이 협업할 수 있는 흐름이 잡혀갔고, 이후 회고를 통해 우리에게는 지라가 더 맞겠다는 판단이 서자 재빠르게 전환했다. 동료들이 적극적으로 함께 움직여준 덕에 우리는 이전보다 훨씬 신속하게 일할 수 있었다.&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;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;내가 나아갈 길&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Gemini_Generated_Image_hs4q27hs4q27hs4q.png&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPVcEV/dJMcacilvzy/KXkWdgrXB1hG3J5fqocyB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPVcEV/dJMcacilvzy/KXkWdgrXB1hG3J5fqocyB0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPVcEV/dJMcacilvzy/KXkWdgrXB1hG3J5fqocyB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPVcEV%2FdJMcacilvzy%2FKXkWdgrXB1hG3J5fqocyB0%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;717&quot; height=&quot;391&quot; data-filename=&quot;Gemini_Generated_Image_hs4q27hs4q27hs4q.png&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 사이에 기술적인 개선도 이어갔다. 인스턴스 배포 방식에서 도커 이미지 기반의 무중단 배포로 전환해 배포 속도를 높였고, 스프레드 시트에 직접 작성하던 API 문서도 Swagger를 통해 코드 기반으로 문서화하는 방식을 도입했다. 더 높은 수준의 협업이 가능해졌고, 불편한 부분이 있으면 편하게 말하고 피드백을 받아들이는, 내가 원하던 문화로 팀이 점점 물들어가기 시작했다.&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;</description>
      <category>커리어 &amp;middot; 일상/이직 &amp;middot; 커리어</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/194</guid>
      <comments>https://codediary21.tistory.com/194#entry194comment</comments>
      <pubDate>Sun, 5 Apr 2026 16:30:27 +0900</pubDate>
    </item>
    <item>
      <title>클로드 코드로 클로드 코드를 분석해보았습니다.</title>
      <link>https://codediary21.tistory.com/193</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;클로드 코드를 분석하게 된 이유&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에 AI 도구를 만들어보기도 하고 직접 활용까지 해봤지만, 어떻게 설계하는 것이 좋은지, 어떤 원리로 동작하는지는 깊이 이해하지 못한 채 사용해왔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 지금처럼 계속 사용만 하더라도 실무에서 활용하는 데 아무 문제는 없지만, 좋은 기술을 분석하는 것만큼 시야를 넓혀주는 경험은 없기에 클로드 코드를 클로드 코드로 분석해보기로 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;클로드 코드란?&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;737&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNX9g9/dJMcahp2j29/KQXWqJBerPvgnGwGQ0tfkk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNX9g9/dJMcahp2j29/KQXWqJBerPvgnGwGQ0tfkk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNX9g9/dJMcahp2j29/KQXWqJBerPvgnGwGQ0tfkk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNX9g9%2FdJMcahp2j29%2FKQXWqJBerPvgnGwGQ0tfkk%2Fimg.jpg&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;599&quot; height=&quot;345&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;737&quot;/&gt;&lt;/span&gt;&lt;/figure&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;기존의 Cursor나 Copilot은 에디터 위에 AI 기능을 얹는 방식이라, IDE의 업데이트에 따라 성능 이슈나 호환성 문제가 발생하기도 했습니다.&amp;nbsp; 반면 클로드 코드는 코어가 터미널에서 독립적으로 동작하기 때문에 특정 IDE에 종속되지 않으면서도, VS Code 확장이나 데스크톱 앱 등을 통해 GUI 환경과도 통합할 수 있습니다. 이 아키텍처 차이가 어떤 결과를 만들었는지 알아봅시다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;클로드 코드의 핵심은 플러그인 시스템&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Plugin Discovery Engine-2026-03-05-125007.png&quot; data-origin-width=&quot;4266&quot; data-origin-height=&quot;3280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCuqDe/dJMcaaqUuwm/Mz1qWKZ31x8Gk5lGfcgjZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCuqDe/dJMcaaqUuwm/Mz1qWKZ31x8Gk5lGfcgjZk/img.png&quot; data-alt=&quot;클로드 플러그인 아키텍처&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCuqDe/dJMcaaqUuwm/Mz1qWKZ31x8Gk5lGfcgjZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCuqDe%2FdJMcaaqUuwm%2FMz1qWKZ31x8Gk5lGfcgjZk%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;636&quot; height=&quot;489&quot; data-filename=&quot;Plugin Discovery Engine-2026-03-05-125007.png&quot; data-origin-width=&quot;4266&quot; data-origin-height=&quot;3280&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;클로드 코드의 핵심은 플러그인 시스템입니다. Copilot과 Cursor도 커스터마이징을 지원하지만, 기본적으로 IDE 내부의 기능에 의존하기 때문에 제약을 받아 제한적으로 사용해왔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 클로드 코드는 Anthropic이 만든 오픈 프로토콜인 MCP(Model Context Protocol)를 통해 외부 도구, API, 데이터 소스를 표준화된 인터페이스로 연결하는 데 집중했습니다. 간편하지만 제약이 많은 IDE 안에서 문제를 해결하는 것이 아니라, 어렵지만 제약이 적은 터미널을 중심으로 필요한 도구를 자유롭게 조합하는 방식을 선택한 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 클로드 코드의 공식 예제 레포지토리를 열어보면, TypeScript나 JavaScript 소스코드가 없습니다. 코어는 컴파일된 바이너리이고, 레포지토리에 공개된 것은 모두 마크다운과 JSON으로 된 플러그인 파일들 뿐입니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;plugins/
├── code-review/          # PR 코드 리뷰 자동화
├── commit-commands/      # Git 커밋 워크플로우
├── feature-dev/          # 7단계 기능 개발 프로세스
├── security-guidance/    # 보안 이슈 경고 훅
├── hookify/              # 대화 분석 기반 훅 생성
├── plugin-dev/           # 플러그인 개발 도구
├── pr-review-toolkit/    # 6개 전문 에이전트 기반 PR 리뷰
└── ... (14개 공식 플러그인)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 플러그인의 내부 구조를 보면 패턴이 보입니다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;plugin-name/
├── .claude-plugin/
│   └── plugin.json      # 메타데이터 (이름, 버전, 설명)
├── commands/            # 슬래시 커맨드 정의 (마크다운)
├── agents/              # 서브에이전트 정의 (마크다운)
├── skills/              # 전문 지식 모듈 (마크다운)
├── hooks/               # 이벤트 핸들러 (JSON + 스크립트)
└── .mcp.json            # 외부 도구 연결 설정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 플러그인이 같은 디렉토리 구조를 따르고, 마크다운 파일로 기능이 정의됩니다. 왜 이런 구조를 선택했을까요? 파일 구조만 보면 간단해보이지만 여기에는 다섯 가지의 설계 철학이 녹아 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;설계 철학 1: Convention over Configuration&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;672&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/G5bdB/dJMcab4BSip/ykWV1ZzlrarPFoUKg2N8jk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/G5bdB/dJMcab4BSip/ykWV1ZzlrarPFoUKg2N8jk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/G5bdB/dJMcab4BSip/ykWV1ZzlrarPFoUKg2N8jk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FG5bdB%2FdJMcab4BSip%2FykWV1ZzlrarPFoUKg2N8jk%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;835&quot; height=&quot;305&quot; data-origin-width=&quot;1842&quot; data-origin-height=&quot;672&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CoC(Convention over Configuration)는 &quot;설정보다는 관례&quot;라는 의미로, 개발자가 설정하지 않아도 미리 정해진 규칙에 따르면 자동으로 동작하게 하는 설계 철학입니다. 쉽게 설명하자면 규칙만 따르면 프레임워크에서 알아서 설정해주는 것을 의미합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많은 프레임워크가 CoC 기술을 기반으로 동작하고 있고 대표적으로 Ruby on Rails가 이 철학을 잘 보여줍니다. 구조만 잡아두면 프레임워크 레벨에서 알아서 연결해줍니다.&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;routeros&quot;&gt;&lt;code&gt;app/
├── models/
│   └── user.rb              &amp;rarr; User 모델 (users 테이블 자동 연결)
├── controllers/
│   └── users_controller.rb  &amp;rarr; UsersController 자동 인식
└── views/
    └── users/
        ├── index.html.erb   &amp;rarr; GET /users
        └── show.html.erb    &amp;rarr; GET /users/:id&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클로드 코드도 동일한 패턴을 따릅니다. 아래의 규칙만 따르면 프레임워크가 자동으로 인식합니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;.claude/
├── commands/
│   └── review.md        &amp;rarr; /review 슬래시 커맨드 자동 등록
├── agents/
│   └── reviewer.md      &amp;rarr; Task 에이전트로 자동 사용 가능
└── skills/
    └── code-review/
        └── SKILL.md     &amp;rarr; 트리거 조건 매칭 시 자동 활성화&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;커맨드&lt;/b&gt;는 commands/ 디렉토리에 마크다운 파일을 넣으면 자동으로 슬래시 커맨드가 됩니다. commit.md를 넣으면 /commit이 되는 식입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에이전트&lt;/b&gt;는 agents/ 디렉토리의 마크다운 파일이 서브에이전트로 자동 등록됩니다. 별도의 설정 파일 없이 파일 위치만으로 역할이 결정됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스킬&lt;/b&gt;은 skills/ 디렉토리 아래에 서브디렉토리를 만들고 SKILL.md를 넣으면, description 필드의 트리거 조건과 대화 맥락이 매칭될 때 자동으로 활성화됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;훅&lt;/b&gt;은 hooks/hooks.json에 이벤트 핸들러를 등록하면 PreToolUse, PostToolUse, SessionStart 등의 이벤트에 자동 연결됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정 파일을 따로 작성하여 지정할 필요가 없습니다. 정해진 위치에 정해진 형식의 파일을 놓으면 프레임워크가 알아서 발견하고 등록합니다. 이것이 CoC의 힘입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 갖는 진짜 힘은 &lt;b&gt;에이전트가 자가적으로 확장한다는 것&lt;/b&gt;입니다. 명시적인 등록 절차나 컴파일 과정이 없으므로, 클로드 코드 에이전트가 작업 결과를 바탕으로 새로운 스킬이나 커맨드 마크다운 파일을 &lt;code&gt;commands/&lt;/code&gt; 디렉토리에 생성하기만 하면, 다음 세션에서 해당 기능이 즉시 활성화됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 스스로 도구를 만들고 사용하는 자기 개선 루프를 가능하게 하는 설계입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;설계 철학 2: Markdown as DSL&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1834&quot; data-origin-height=&quot;844&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QwPOK/dJMcaio9dEB/cxtVycDN6bKWLj0Yqck3YK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QwPOK/dJMcaio9dEB/cxtVycDN6bKWLj0Yqck3YK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QwPOK/dJMcaio9dEB/cxtVycDN6bKWLj0Yqck3YK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQwPOK%2FdJMcaio9dEB%2FcxtVycDN6bKWLj0Yqck3YK%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;838&quot; height=&quot;386&quot; data-origin-width=&quot;1834&quot; data-origin-height=&quot;844&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클로드 코드는 마크다운을 일종의 DSL(Domain Specific Language)로 활용합니다. CLAUDE.md에 &quot;테스트는 항상 Jest로 실행할 것&quot;이라고 적으면, 클로드 코드는 그 규칙에 따라 동작합니다. 복잡한 설정 파일이나 별도의 문법 없이 자연어로 에이전트의 행동을 정의할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 단순히 마크다운으로 모든 것을 제어하는 것은 아닙니다. 실제 코드를 보면 마크다운 위에 정교한 규칙들이 잡혀 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;YAML 프론트매터: 프레임워크가 읽는 설정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마크다운 파일 맨 앞에 YAML 프론트매터가 붙습니다. 실제 &lt;code&gt;/commit&lt;/code&gt; 커맨드를 봅시다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;---
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
description: Create a git commit
---&lt;/code&gt;&lt;/pre&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;code&gt;allowed-tools&lt;/code&gt;에 &lt;code&gt;Bash(git add:*)&lt;/code&gt;, &lt;code&gt;Bash(git status:*)&lt;/code&gt;, &lt;code&gt;Bash(git commit:*)&lt;/code&gt;만 지정하면, 이 커맨드가 실행될 때 모델은 해당 도구만 사용할 수 있습니다. &lt;code&gt;rm -rf&lt;/code&gt;나 &lt;code&gt;curl&lt;/code&gt;같은 위험한 명령은 프레임워크 수준에서 차단됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트로 &quot;이 도구만 써&quot;라고 지시하는 것보다 훨씬 안정적이고 빠르게 처리할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;동적 컨텍스트 주입: !&lt;code&gt;command&lt;/code&gt; 문법&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1850&quot; data-origin-height=&quot;1022&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/p7pMw/dJMcacvIixt/4J1JBkjc0iSayT00JymztK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/p7pMw/dJMcacvIixt/4J1JBkjc0iSayT00JymztK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/p7pMw/dJMcacvIixt/4J1JBkjc0iSayT00JymztK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fp7pMw%2FdJMcacvIixt%2F4J1JBkjc0iSayT00JymztK%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;829&quot; height=&quot;458&quot; data-origin-width=&quot;1850&quot; data-origin-height=&quot;1022&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트 내용이 담긴 본문 쪽을 보면 더 흥미로운 패턴이 있습니다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;## Context

- Current git status: !`git status`
- Current git diff (staged and unstaged changes): !`git diff HEAD`
- Current branch: !`git branch --show-current`
- Recent commits: !`git log --oneline -10`&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;!`command`&lt;/code&gt; 문법은 커맨드 로드 시점에 bash 명령을 실행하고 그 결과를 프롬프트에 삽입합니다. 커밋 커맨드를 실행하면 현재 git 상태, 변경된 파일, 브랜치명, 최근 커밋 이력이 자동으로 수집되어 모델에게 전달됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이런 패턴을 사용하는 이유가 무엇일까요? 일반적인 AI 에이전트 워크플로우에서는 모델이 현재 상태를 알기 위해 다음의 단계를 거칩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Bash 도구 호출 요청 생성&lt;/li&gt;
&lt;li&gt;프레임워크가 명령 실행&lt;/li&gt;
&lt;li&gt;결과를 다시 컨텍스트에 주입하는 3단계 라운드트립을 거칩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 문법은 프롬프트 조립 단계에서 쉘 명령을 미리 실행하고 결과를 정적 텍스트로 치환합니다. 모델은 첫 턴부터 완벽한 컨텍스트를 받게 되어, 불필요한 도구 호출에 따른 토큰 낭비와 응답 지연이 사라집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;도구 제한의 세밀한 제어&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도구를 제한하는 것은 단순한 리스크를 관리하기 위함이 아닙니다. 스프링 시큐리티처럼 &lt;b&gt;접두사 매칭(Prefix Matching)&lt;/b&gt;을 사용하여&amp;nbsp;세밀하게 제어됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;와일드카드 &lt;code&gt;*&lt;/code&gt;는 패턴의 끝에서만 유효하며, 지정된 접두사로 시작하는 명령만 허용합니다. &lt;code&gt;Bash(git add:*)&lt;/code&gt;는 &lt;code&gt;git add&lt;/code&gt;로 시작하는 모든 명령을 허용하지만, &lt;code&gt;Bash(* rm)&lt;/code&gt;처럼 중간이나 앞에 와일드카드를 넣는 것은 불가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정규식이 아닌 접두사 매칭을 선택한 것은 쉘 인젝션을 원천 차단하기 위한 보안적인 설계입니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;# 모든 git 서브커맨드 허용
allowed-tools: Bash(git:*)

# git add만 허용
allowed-tools: Bash(git add:*)

# 여러 git 서브커맨드를 개별 지정
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)

# MCP 도구 지정
allowed-tools: mcp__github_inline_comment__create_inline_comment&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/commit-push-pr&lt;/code&gt; 커맨드의 실제 설정을 보면 이 세밀함이 드러납니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;allowed-tools: Bash(git checkout --branch:*), Bash(git add:*), Bash(git status:*),
               Bash(git push:*), Bash(git commit:*), Bash(gh pr create:*)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;git checkout&lt;/code&gt;은 &lt;code&gt;--branch&lt;/code&gt; 플래그를 붙인 경우만 허용합니다. 브랜치 생성은 되지만, &lt;code&gt;git checkout -- .&lt;/code&gt;으로 변경사항을 날리는 건 불가능합니다. 이런 수준의 제어가 마크다운 한 줄로 이루어집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;파일 참조와 인자 시스템&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마크다운 DSL에는 파일 참조(&lt;code&gt;@path&lt;/code&gt;)와 인자 치환(&lt;code&gt;$ARGUMENTS&lt;/code&gt;, &lt;code&gt;$1&lt;/code&gt;, &lt;code&gt;$2&lt;/code&gt;)도 제공해줍니다. 코딩처럼 파라미터를 통해 값을 전달한다고 생각하면 이해하기 쉬울 것입니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;---
description: Fix issue by number
argument-hint: [issue-number]
---

Fix issue #$ARGUMENTS following our coding standards.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 &lt;code&gt;/fix-issue 123&lt;/code&gt;을 입력하면, &lt;code&gt;$ARGUMENTS&lt;/code&gt;가 &lt;code&gt;123&lt;/code&gt;으로 치환되어 &quot;Fix issue #123 following our coding standards.&quot;가 모델에게 전달됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 클로드 코드의 마크다운은 단순한 텍스트가 아닙니다. &lt;b&gt;프론트매터로 권한을 제어&lt;/b&gt;하고, &lt;b&gt;동적 문법으로 컨텍스트를 주입&lt;/b&gt;하며, &lt;b&gt;파라미터 시스템으로 재사용성까지 확보&lt;/b&gt;하는, 에이전트 행동 정의를 위한 완전한 DSL입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;설계 철학 3: Progressive Disclosure&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1854&quot; data-origin-height=&quot;1016&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8eQI7/dJMcaio9dHq/4VnZNLKztVJxKsvEfgiST0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8eQI7/dJMcaio9dHq/4VnZNLKztVJxKsvEfgiST0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8eQI7/dJMcaio9dHq/4VnZNLKztVJxKsvEfgiST0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8eQI7%2FdJMcaio9dHq%2F4VnZNLKztVJxKsvEfgiST0%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;833&quot; height=&quot;456&quot; data-origin-width=&quot;1854&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;듀오링고를 보면 엄청나게 많은 애니메이션과 인터랙티브한 요소들이 있지만, 이 모든 것을 한번에 불러오지는 않습니다. 스크롤하면 필요한 정보만 점진적으로 로드하면서 성능을 최적화합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클로드 코드의 스킬 시스템도 같은 원리로 동작합니다. LLM 기반 도구에서 Progressive Disclosure는 단순히 UI 패턴이 아니라, &lt;b&gt;토큰 효율성&lt;/b&gt;과&amp;nbsp;&lt;b&gt;실질적인 비용&lt;/b&gt; 문제를 위해 필수적으로 사용한 기술이라는 것을 알 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3단계 정보 로딩&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클로드 코드의 스킬은 세 단계로 정보를 로딩합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Level 1: 메타데이터 (항상 컨텍스트에 존재)&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;---
name: Command Development
description: This skill should be used when the user asks to &quot;create a slash command&quot;,
             &quot;add a command&quot;, &quot;write a custom command&quot;...
version: 0.2.0
---&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스킬의 이름과 트리거 조건만 담긴 메타데이터입니다. 모든 스킬의 메타데이터는 항상 컨텍스트에 올라가 있어서 클로드가 &quot;지금 어떤 스킬을 활성화할지&quot; 판단할 수 있습니다. 수십 개의 스킬이 있어도 메타데이터만이라면 토큰 소비가 미미합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Level 2: SKILL.md 본문 (트리거 시에만 로드)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 &quot;슬래시 커맨드를 만들고 싶어&quot;라고 말하면, description의 트리거 조건과 매칭되어 &lt;code&gt;SKILL.md&lt;/code&gt; 본문이 로드됩니다. 여기에는 핵심 개념, 워크플로우 단계, 의사결정 프레임워크가 담겨 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 이 본문이 &lt;b&gt;5K 단어 이하&lt;/b&gt;로 제한된다는 점입니다. 필요한 핵심만 담고, 나머지는 다음 레벨로 미룹니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Level 3: 번들 리소스 (필요할 때만 참조)&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;skills/command-development/
├── SKILL.md              # Level 2 (~1.5K 단어)
├── references/           # Level 3 (8K+ 단어)
│   ├── frontmatter-reference.md
│   └── plugin-features-reference.md
├── scripts/              # Level 3
└── assets/               # Level 3&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트매터의 모든 필드를 상세히 설명하는 레퍼런스, 고급 패턴 예제, 검증 스크립트 등은 &lt;code&gt;references/&lt;/code&gt;와 &lt;code&gt;scripts/&lt;/code&gt;에 분리되어 있습니다. 모델이 이 파일들을 직접 읽어야 할 때만 로드됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 이런 구조가 중요한가&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 커맨드를 만들 때와 복잡한 멀티 에이전트 플러그인을 만들 때, 필요한 정보량은 전혀 다릅니다.&lt;/p&gt;
&lt;table style=&quot;height: 72px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;로딩 레벨&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;토큰 사용량&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&quot;커맨드를 만들어줘&quot;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Level 1 &amp;rarr; 2&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;~2K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&quot;프론트매터 필드를 자세히 알고 싶어&quot;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Level 1 &amp;rarr; 2 &amp;rarr; 3&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;~10K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;관련 없는 대화&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Level 1만&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;수백&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 정보를 항상 로드하면 대화마다 수만 토큰이 소비됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트가 수십 개의 플러그인에 연결될 경우, 각 도구의 정의와 스킬 본문을 모두 프롬프트에 올리면 실제 사용자의 요청을 처리하기도 전에 컨텍스트 창의 상당 부분이 소모되는 &lt;b&gt;컨텍스트 부패(Context Rot)&lt;/b&gt; 현상이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Progressive Disclosure로 필요한 시점에 필요한 만큼만 로드하니, 비용과 응답 속도 모두 최적화됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;설계 철학 4: Portability&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클로드 코드 플러그인은 &lt;b&gt;어디서든 동작&lt;/b&gt;해야 합니다. macOS에서 만든 플러그인이 Linux에서도, 다른 사용자의 환경에서도 그대로 동작해야 합니다. 이를 위해 클로드 코드는 &lt;code&gt;${CLAUDE_PLUGIN_ROOT}&lt;/code&gt;라는 환경 변수를 도입했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;하드코딩 대신 환경 변수&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플러그인은 설치 위치를 알 수 없습니다. 사용자가 어디에 설치했는지, 어떤 OS를 쓰는지, 마켓플레이스에서 설치했는지 로컬에서 설치했는지에 따라 경로가 달라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;security-guidance&lt;/code&gt; 플러그인의 훅 설정을 다시 봅시다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;command&quot;: &quot;python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/Users/yongseon/plugins/security-guidance/hooks/...&lt;/code&gt;처럼 절대 경로를 쓰면 다른 사용자의 환경에서는 동작하지 않습니다. &lt;code&gt;${CLAUDE_PLUGIN_ROOT}&lt;/code&gt;가 런타임에 실제 플러그인 경로로 치환되기 때문에, 어떤 환경에서든 동일하게 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴은 코드베이스 전반에 걸쳐 일관되게 적용됩니다. 훅 설정, MCP 서버 커맨드, 커맨드의 스크립트 실행, 스킬의 리소스 참조까지 모두 &lt;code&gt;${CLAUDE_PLUGIN_ROOT}&lt;/code&gt;를 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;nsis&quot;&gt;&lt;code&gt;# 훅에서
&quot;command&quot;: &quot;bash ${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh&quot;

# MCP 서버에서
&quot;command&quot;: &quot;${CLAUDE_PLUGIN_ROOT}/servers/db-server&quot;

# 커맨드에서
!`node ${CLAUDE_PLUGIN_ROOT}/scripts/analyze.js $1`

# 스킬에서
@${CLAUDE_PLUGIN_ROOT}/references/patterns.md&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 &lt;code&gt;plugin-dev&lt;/code&gt; 플러그인의 공식 가이드에는 이런 규칙이 명시되어 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Never use hardcoded absolute paths, relative paths from working directory, or home directory shortcuts. Always use ${CLAUDE_PLUGIN_ROOT} for portability.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 이것이 중요한가&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Portability는 단순히 &quot;다른 OS에서도 돌아간다&quot;는 의미를 넘어, 플러그인 생태계의 전제 조건입니다. 이식성이 보장되지 않으면 플러그인 공유가 불가능합니다. 마켓플레이스라는 개념 자체가 성립하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker가 &quot;어디서든 동일하게 실행된다&quot;는 약속으로 컨테이너 생태계를 만들었듯, &lt;code&gt;${CLAUDE_PLUGIN_ROOT}&lt;/code&gt; 하나의 규칙이 클로드 코드의 플러그인 생태계를 가능하게 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;현실적 한계: 크로스 플랫폼의 함정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 설계가 모든 문제를 해결하지는 못합니다. 환경 변수가 치환되더라도 OS 수준의 차이는 남아 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Windows 사용자 계정에 공백이 포함된 경로(&lt;code&gt;C:\Users\John Doe\...&lt;/code&gt;)에서 쉘 인자 분리 오류가 발생하거나, Windows에서 개발된 플러그인의 CRLF 줄바꿈이 Linux/macOS의 Bash에서 파싱 실패를 일으키는 문제가 보고되고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추상화 계층이 쉘 인터프리터의 원초적 특성과 충돌하는 현상으로, 프레임워크 레벨에서 쉘 실행 엔진을 표준화해야 할 과제가 남아 있는 상태긴 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;설계 철학 5: Composability&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1828&quot; data-origin-height=&quot;964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4qSOe/dJMcagrjrAk/BVq86waX6Iak3NIDdaHH5K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4qSOe/dJMcagrjrAk/BVq86waX6Iak3NIDdaHH5K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4qSOe/dJMcagrjrAk/BVq86waX6Iak3NIDdaHH5K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4qSOe%2FdJMcagrjrAk%2FBVq86waX6Iak3NIDdaHH5K%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;822&quot; height=&quot;433&quot; data-origin-width=&quot;1828&quot; data-origin-height=&quot;964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클로드 코드의 플러그인 시스템에서 가장 강력한 특징은 &lt;b&gt;각 구성 요소를 레고 블록처럼 자유롭게 조합&lt;/b&gt;할 수 있다는 점입니다. 커맨드, 에이전트, 스킬, 훅, MCP 서버는 독립적인 단위이면서 동시에 서로를 참조하고 연결할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;하나의 커맨드가 여러 에이전트를 조합한다&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/feature-dev&lt;/code&gt; 커맨드를 봅시다. 이 커맨드 자체는 하나의 마크다운 파일이지만, 실행 과정에서 세 종류의 에이전트를 조합합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;## Phase 2: Codebase Exploration
1. Launch 2-3 code-explorer agents in parallel.

## Phase 4: Architecture Design
1. Launch 2-3 code-architect agents in parallel with different focuses

## Phase 6: Quality Review
1. Launch 3 code-reviewer agents in parallel with different focuses&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커맨드가 에이전트를 호출하고, 에이전트가 도구를 사용하며, 스킬이 필요한 시점에 자동으로 활성화됩니다. 이 모든 조합이 마크다운 텍스트 안에서 이루어집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;커맨드가 스킬을 호출한다&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/hookify&lt;/code&gt; 커맨드의 첫 줄을 봅시다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;**FIRST: Load the hookify:writing-rules skill** using the Skill tool&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커맨드가 실행되면 먼저 &lt;code&gt;writing-rules&lt;/code&gt; 스킬을 로드하여 규칙 작성법을 학습한 뒤, 대화 분석 에이전트를 실행하고, 사용자에게 질문을 던지며, 최종적으로 훅 파일을 생성합니다. 하나의 커맨드 안에서 스킬, 에이전트, 사용자 상호작용, 파일 생성이 모두 조합됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;플러그인이 다른 플러그인의 도구를 참조한다&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/code-review&lt;/code&gt; 커맨드는 MCP 도구를 직접 참조합니다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;allowed-tools: ..., mcp__github_inline_comment__create_inline_comment&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub 인라인 코멘트 MCP 서버의 특정 함수를 도구로 사용합니다. 커맨드(마크다운) &amp;rarr; 에이전트(마크다운) &amp;rarr; MCP 도구(JSON) &amp;rarr; 외부 API(GitHub)로 이어지는 체인이, 각각 독립적인 파일에 정의되어 있으면서도 하나의 워크플로우로 동작합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;조합의 단위가 작다&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 조합의 단위가 충분히 작다는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;┌─ 커맨드: &quot;무엇을 할 것인가&quot; (워크플로우)
├─ 에이전트: &quot;누가 할 것인가&quot; (전문가)
├─ 스킬: &quot;어떤 지식이 필요한가&quot; (지식)
├─ 훅: &quot;언제 개입할 것인가&quot; (이벤트)
└─ MCP: &quot;어떤 외부 도구를 쓸 것인가&quot; (인터페이스)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;pr-review-toolkit&lt;/code&gt; 플러그인은 이 조합성을 극대화한 예시입니다. 6개의 독립적인 전문 에이전트(&lt;code&gt;code-reviewer&lt;/code&gt;, &lt;code&gt;code-simplifier&lt;/code&gt;, &lt;code&gt;comment-analyzer&lt;/code&gt;, &lt;code&gt;pr-test-analyzer&lt;/code&gt;, &lt;code&gt;silent-failure-hunter&lt;/code&gt;, &lt;code&gt;type-design-analyzer&lt;/code&gt;)를 만들어두고, &lt;code&gt;/review-pr&lt;/code&gt; 커맨드가 상황에 따라 필요한 에이전트만 골라서 조합합니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Based on changes:
- Always applicable: code-reviewer
- If test files changed: pr-test-analyzer
- If comments/docs added: comment-analyzer
- If error handling changed: silent-failure-hunter
- If types added/modified: type-design-analyzer
- After passing review: code-simplifier&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 파일이 변경되지 않았다면 &lt;code&gt;pr-test-analyzer&lt;/code&gt;는 실행되지 않습니다. 변경 내용에 따라 필요한 에이전트만 동적으로 조합됩니다. 마치 레고 블록을 필요한 것만 골라 끼우듯이.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unix 철학의 &quot;한 가지 일을 잘하는 작은 프로그램을 파이프로 연결한다&quot;는 원칙이 AI 에이전트 시스템에서 재현된 것입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;터미널을 넘어서: Channels&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Portability는 OS 간 이식성만을 의미하지 않습니다. 클로드 코드는 텔레그램, 슬랙 등과 연결하는 Channels 기능을 지원하여,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널 앞에 앉아 있지 않아도 대규모 리팩토링이나 전체 테스트 같은 장시간 작업을 메신저에서 모니터링하고 원격으로 지시할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트가 동작하는 환경뿐 아니라, 사용자가 에이전트를 제어하는 환경까지 이식 가능하게 만든 것입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;발견 1: 멀티 에이전트 오케스트레이션&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 분석하면서 가장 인상 깊었던 것은 &lt;code&gt;/code-review&lt;/code&gt; 커맨드의 설계입니다. 하나의 커맨드가 여러 에이전트를 오케스트레이션하는 9단계 파이프라인으로 구성되어 있습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;[1단계] 사전 검증 (haiku)
   PR이 닫혔는지, 드래프트인지, 이미 리뷰했는지 확인
        &amp;darr;
[2단계] CLAUDE.md 수집 (haiku)
   변경된 파일이 속한 디렉토리의 CLAUDE.md 파일 경로 수집
        &amp;darr;
[3단계] PR 요약 (sonnet)
   변경사항 전체 요약 생성
        &amp;darr;
[4단계] 병렬 리뷰 (4개 에이전트 동시 실행)
   ┌─ Agent 1: CLAUDE.md 준수 감사 (sonnet)
   ├─ Agent 2: CLAUDE.md 준수 감사 (sonnet, 다른 관점)
   ├─ Agent 3: 버그 탐지 (opus)
   └─ Agent 4: 버그 탐지 (opus, 다른 관점)
        &amp;darr;
[5단계] 병렬 검증
   각 이슈를 별도 에이전트가 재검증 (진짜 이슈인지 확인)
        &amp;darr;
[6단계] 필터링
   검증 통과한 이슈만 남김
        &amp;darr;
[7~9단계] 결과 출력 및 PR 코멘트 작성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 주목할 패턴이 몇 가지 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;모델 선택의 경제학&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;978&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ce7MtS/dJMcagrjrCM/8XMZ05R0eNdzYnzbMVhrqk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ce7MtS/dJMcagrjrCM/8XMZ05R0eNdzYnzbMVhrqk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ce7MtS/dJMcagrjrCM/8XMZ05R0eNdzYnzbMVhrqk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fce7MtS%2FdJMcagrjrCM%2F8XMZ05R0eNdzYnzbMVhrqk%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;800&quot; height=&quot;412&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;978&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사전 검증과 CLAUDE.md 수집 같은 단순 작업은 &lt;b&gt;haiku&lt;/b&gt;(가장 저렴한 모델)로 처리합니다. PR 요약은 &lt;b&gt;sonnet&lt;/b&gt;, 버그 탐지처럼 고도의 판단이 필요한 작업은 &lt;b&gt;opus&lt;/b&gt;(가장 강력한 모델)를 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 복잡도에 맞는 모델을 선택하여 비용을 최적화하면서도 품질을 유지합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;동일 관점의 이중 검증&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1826&quot; data-origin-height=&quot;960&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdAMXv/dJMcagESFrW/DRbRL5lh1SWwHU7r4EC9Lk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdAMXv/dJMcagESFrW/DRbRL5lh1SWwHU7r4EC9Lk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdAMXv/dJMcagESFrW/DRbRL5lh1SWwHU7r4EC9Lk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdAMXv%2FdJMcagESFrW%2FDRbRL5lh1SWwHU7r4EC9Lk%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;772&quot; height=&quot;406&quot; data-origin-width=&quot;1826&quot; data-origin-height=&quot;960&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Agent 1과 2가 같은 일(CLAUDE.md 준수 검사)을 합니다. Agent 3과 4도 같은 일(버그 탐지)을 합니다. 왜 중복으로 실행할까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM은 같은 입력에도 다른 관점에서 분석할 수 있습니다. 두 에이전트가 독립적으로 검토하면 한 에이전트가 놓친 이슈를 다른 에이전트가 잡아낼 수 있습니다. 이것은 소프트웨어 공학에서 말하는 N-version programming의 아이디어와 유사합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5단계의 재검증이 핵심&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4단계에서 발견된 이슈를 5단계에서 다시 검증합니다. 커맨드 파일에 이런 지시가 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;For each issue found in the previous step, launch parallel subagents to validate the issue. The agent's job is to review the issue to validate that the stated issue is truly an issue with high confidence.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 코드 리뷰의 가장 큰 문제는 &lt;b&gt;오탐(false positive)&lt;/b&gt;입니다. 존재하지 않는 버그를 지적하면 개발자의 신뢰를 잃습니다. 그래서 발견 단계와 검증 단계를 분리하고, 검증을 통과한 이슈만 보고합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 커맨드에는 오탐을 방지하기 위한 명시적 규칙도 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Do NOT flag: Code style or quality concerns, potential issues that depend on specific inputs or state, subjective suggestions or improvements. If you are not certain an issue is real, do not flag it. False positives erode trust and waste reviewer time.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;발견 2: 에이전트의 역할 분리 패턴&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;/feature-dev&lt;/code&gt; 커맨드를 분석하면 에이전트 간 역할 분리가 어떻게 설계되는지 보입니다. 세 종류의 에이전트가 각자 다른 단계에서 활약합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Code Explorer (탐색 단계)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;---
name: code-explorer
model: sonnet
color: yellow
tools: Glob, Grep, LS, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, KillShell
---&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드베이스를 탐색하고 이해하는 에이전트입니다. 주목할 점은 &lt;b&gt;Write나 Edit 도구가 없다&lt;/b&gt;는 것입니다. 이 에이전트는 코드를 읽기만 합니다. 수정 권한이 없으니 실수로 코드를 변경할 위험이 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것은 정보 보안에서 말하는 &lt;b&gt;최소 권한의 원칙(Principle of Least Privilege)&lt;/b&gt;을 에이전트 시스템에 적용한 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트가 방대한 코드를 읽고 분석하는 초기 탐색 단계는 환각(Hallucination)이 발생하기 가장 쉬운 취약 구간입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구간의 에이전트에게 읽기 전용 도구만 부여함으로써, 에이전트의 착각으로 인한 소스 코드 손상을 아키텍처 레벨에서 원천 차단합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Code Architect (설계 단계)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;탐색 결과를 기반으로 아키텍처를 설계합니다. 여러 접근법을 제시하되, 각 접근법의 트레이드오프를 비교합니다. 이 에이전트도 코드를 직접 수정하지 않고, &quot;어떤 파일을 만들고 수정해야 하는지&quot;의 블루프린트를 생성합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Code Reviewer (검증 단계)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;---
name: code-reviewer
model: opus
color: green
---&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현이 끝난 후 코드를 검토합니다. 0-100점의 신뢰도 점수를 매기고, &lt;b&gt;80점 이상인 이슈만 보고&lt;/b&gt;합니다. 이 에이전트의 원칙은 명확합니다: &quot;quality over quantity.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 에이전트가 하나의 워크플로우 안에서 순차적으로 동작합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Phase 2: code-explorer &amp;times; 2~3개 (병렬) &amp;rarr; 코드베이스 이해
Phase 4: code-architect &amp;times; 2~3개 (병렬) &amp;rarr; 설계 방안 제시
Phase 6: code-reviewer &amp;times; 3개 (병렬) &amp;rarr; 구현 결과 검증&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 에이전트는 자기 역할에 필요한 도구만 갖고, 자기 단계에서만 활동합니다. 마치 건설 현장에서 설계사, 시공사, 감리가 각자의 역할을 수행하는 것과 같습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;발견 3: Hook 시스템 - 이벤트 드리븐 안전장치&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클로드 코드에는 에이전트의 행동에 개입할 수 있는 훅(Hook) 시스템이 있습니다. &lt;code&gt;security-guidance&lt;/code&gt; 플러그인의 설정을 봅시다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;hooks&quot;: {
    &quot;PreToolUse&quot;: [
      {
        &quot;hooks&quot;: [
          {
            &quot;type&quot;: &quot;command&quot;,
            &quot;command&quot;: &quot;python3 ${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py&quot;
          }
        ],
        &quot;matcher&quot;: &quot;Edit|Write|MultiEdit&quot;
      }
    ]
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PreToolUse&lt;/code&gt; 이벤트에 &lt;code&gt;Edit|Write|MultiEdit&lt;/code&gt; 매처를 걸었습니다. 모델이 파일을 수정하려고 할 때마다, 실제 수정이 일어나기 전에 Python 스크립트가 실행되어 보안 위험을 검사합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용 가능한 이벤트 타입은 다양합니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;이벤트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;시점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;용도&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;도구 실행 전&lt;/td&gt;
&lt;td&gt;위험한 명령 차단, 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;도구 실행 후&lt;/td&gt;
&lt;td&gt;결과 검증, 로깅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;세션 시작 시&lt;/td&gt;
&lt;td&gt;환경 설정, 규칙 로드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionEnd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;세션 종료 시&lt;/td&gt;
&lt;td&gt;정리, 보고&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;사용자 입력 시&lt;/td&gt;
&lt;td&gt;입력 전처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreCompact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;컨텍스트 압축 전&lt;/td&gt;
&lt;td&gt;중요 정보 보존&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;알림 발생 시&lt;/td&gt;
&lt;td&gt;외부 연동&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;explanatory-output-style&lt;/code&gt; 플러그인은 &lt;code&gt;SessionStart&lt;/code&gt; 훅으로 세션이 시작될 때 교육적 설명 모드를 자동 활성화합니다. 훅은 프롬프트가 아닌 코드 레벨에서 동작하므로, 모델이 무시할 수 없는 강제적인 행동 규칙을 만들 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;두 가지 훅 타입: Command vs Prompt&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;훅에는 두 가지 타입이 있습니다. 앞서 본 &lt;code&gt;security-guidance&lt;/code&gt;는 &lt;code&gt;type: &quot;command&quot;&lt;/code&gt; 훅으로, bash 스크립트를 실행하는 결정론적 방식입니다. 하지만 공식 가이드에서 더 권장하는 것은 &lt;code&gt;type: &quot;prompt&quot;&lt;/code&gt; 훅입니다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;{
  &quot;type&quot;: &quot;prompt&quot;,
  &quot;prompt&quot;: &quot;Validate file write safety. Check: system paths, credentials,
             path traversal, sensitive content. Return 'approve' or 'deny'.&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prompt 훅은 도구 실행 직전에 LLM을 호출하여 &lt;b&gt;맥락을 이해한 의사결정&lt;/b&gt;을 내립니다. 정규표현식만으로는 &quot;이 파이썬 스크립트가 테스트 목적인지 악의적인지&quot; 판단하기 어렵습니다. Prompt 훅은 LLM에게 맥락을 넘겨 의미론적으로 판단하게 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결정론적 룰 기반 제어(Command 훅)와 AI 기반의 의미론적 제어(Prompt 훅)가 하나의 파이프라인 안에서 결합된 구조입니다. 단순한 패턴은 빠른 Command 훅으로, 복잡한 맥락 판단은 Prompt 훅으로 처리합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;권한 제어의 계층 구조&lt;/b&gt;&lt;/h3&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;992&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEMZab/dJMcabjgTKX/qyh2GC9I905Kl20Va9ADB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEMZab/dJMcabjgTKX/qyh2GC9I905Kl20Va9ADB1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEMZab/dJMcabjgTKX/qyh2GC9I905Kl20Va9ADB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEMZab%2FdJMcabjgTKX%2Fqyh2GC9I905Kl20Va9ADB1%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;769&quot; height=&quot;413&quot; data-origin-width=&quot;1848&quot; data-origin-height=&quot;992&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hook이 &quot;언제 개입할 것인가&quot;를 다룬다면, 권한 제어 시스템은 &quot;애초에 무엇을 허용할 것인가&quot;를 다룹니다. settings.json에서 도구 사용을 allow/ask/deny 3단계로 제어할 수 있고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정은 전역(~/.claude/settings.json) &amp;rarr; 프로젝트 공유(.claude/settings.json) &amp;rarr; 프로젝트 로컬(.claude/settings.local.json) 순으로 계층화되어 있습니다. 팀 전체에 적용할 보안 규칙은 프로젝트 공유 설정에, 개인 환경에 맞춘 예외는 로컬 설정에 두는 식입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;발견 4: 데이터 드리븐 아키텍처&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1816&quot; data-origin-height=&quot;1012&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Ab8fa/dJMcadnNbHE/6SoOgrPm7WvEjmgslIqYY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Ab8fa/dJMcadnNbHE/6SoOgrPm7WvEjmgslIqYY1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Ab8fa/dJMcadnNbHE/6SoOgrPm7WvEjmgslIqYY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAb8fa%2FdJMcadnNbHE%2F6SoOgrPm7WvEjmgslIqYY1%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;815&quot; height=&quot;454&quot; data-origin-width=&quot;1816&quot; data-origin-height=&quot;1012&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 분석한 내용을 종합하면 하나의 패턴이 보입니다. 클로드 코드는 &lt;b&gt;코어 바이너리를 수정하지 않고, 마크다운과 JSON 파일만으로 모든 동작을 정의&lt;/b&gt;합니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;확장 포인트&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;형식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commands&lt;/td&gt;
&lt;td&gt;마크다운 + YAML&lt;/td&gt;
&lt;td&gt;사용자 명령 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agents&lt;/td&gt;
&lt;td&gt;마크다운 + YAML&lt;/td&gt;
&lt;td&gt;서브에이전트 역할 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skills&lt;/td&gt;
&lt;td&gt;마크다운 + YAML&lt;/td&gt;
&lt;td&gt;자동 활성화 지식 모듈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hooks&lt;/td&gt;
&lt;td&gt;JSON + 스크립트&lt;/td&gt;
&lt;td&gt;이벤트 기반 행동 제어&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;td&gt;외부 도구 연결&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLAUDE.md&lt;/td&gt;
&lt;td&gt;마크다운&lt;/td&gt;
&lt;td&gt;프로젝트 규칙 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 IDE 플러그인 방식과의 근본적 차이입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IDE 플러그인은 호스트 애플리케이션의 API에 의존합니다. VS Code의 Extension API가 바뀌면 플러그인도 업데이트해야 합니다. Cursor의 내부 구조가 바뀌면 커스터마이징이 깨질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 클로드 코드의 플러그인은 &lt;b&gt;선언적 데이터&lt;/b&gt;입니다. &quot;이 에이전트는 이 도구를 쓸 수 있고, 이 역할을 수행한다&quot;를 마크다운에 기술하면 끝입니다. 코어 바이너리의 내부 구현이 바뀌어도, 마크다운의 문법만 유지되면 플러그인은 그대로 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 개발자가 아닌 사람도 플러그인을 만들 수 있습니다. 프로그래밍 언어를 배울 필요 없이, 마크다운으로 에이전트에게 지시를 내리면 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;b&gt;발견 5: MCP를 통한 외부 세계 연결&lt;/b&gt;&lt;/b&gt;&lt;/h2&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;1844&quot; data-origin-height=&quot;980&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3T4Ov/dJMcahDJ1Th/tJrKAVR5bySpI3VEZvvhk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3T4Ov/dJMcahDJ1Th/tJrKAVR5bySpI3VEZvvhk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3T4Ov/dJMcahDJ1Th/tJrKAVR5bySpI3VEZvvhk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3T4Ov%2FdJMcahDJ1Th%2FtJrKAVR5bySpI3VEZvvhk1%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;843&quot; height=&quot;448&quot; data-origin-width=&quot;1844&quot; data-origin-height=&quot;980&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666; text-align: start;&quot;&gt;지금까지 본 커맨드, 에이전트, 스킬, 훅은 모두 로컬 환경에서 동작합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666; text-align: start;&quot;&gt;하지만 클로드 코드의 진짜 확장성은 .mcp.json 하나로 GitHub, Jira, Slack, Sentry, 외부 DB 등 수천 개의 외부 서비스와 표준화된 방식으로 연결되는 MCP 생태계에 있습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666; text-align: start;&quot;&gt;로컬 파일 편집을 넘어 외부 클라우드 리소스와 통신하는 이 구조가, 앞서 본 Composability를 로컬에서 클라우드 규모로 확장시키는 핵심입니다.&lt;/span&gt;&lt;span style=&quot;color: #666666; text-align: start;&quot;&gt;&lt;/span&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;정리: 클로드 코드의 설계에서 배운 것&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1884&quot; data-origin-height=&quot;1018&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SSCpI/dJMcafFXiVe/3Ej0e3gJoUm92FSyjo8x6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SSCpI/dJMcafFXiVe/3Ej0e3gJoUm92FSyjo8x6K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SSCpI/dJMcafFXiVe/3Ej0e3gJoUm92FSyjo8x6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSSCpI%2FdJMcafFXiVe%2F3Ej0e3gJoUm92FSyjo8x6K%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;710&quot; height=&quot;384&quot; data-origin-width=&quot;1884&quot; data-origin-height=&quot;1018&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;table style=&quot;height: 120px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;b&gt;원칙&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;b&gt;이점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Convention over Configuration&lt;/td&gt;
&lt;td&gt;규칙만 따르면 자동으로 동작&lt;/td&gt;
&lt;td&gt;진입장벽 &amp;darr; 일관성 &amp;uarr;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;Markdown as DSL&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;자연어로 에이전트 행동 정의&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;접근성 &amp;uarr; 표현력 &amp;uarr;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;Progressive Disclosure&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;필요한 시점에 필요한 만큼만 로드&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;토큰 효율성 &amp;uarr; 비용 &amp;darr;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;Portability&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;어디서든 동작&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;이식성 &amp;uarr; 생태계 확장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;Composability&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;작은 단위를 레고처럼 조합&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;유연성 &amp;uarr; 재사용성 &amp;uarr;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Anthropic은 이 다섯 가지 철학 위에 개발자들이 자유롭게 확장할 수 있는 도구를 만들었고, 그 도구가 다시 새로운 도구를 만들어내는 생태계로 성장하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흥미로운 점은 이 원칙들이 전혀 새롭지 않다는 겁니다. CoC는 Ruby on Rails에서, Composability는 Unix 철학에서, Progressive Disclosure는 UX 디자인에서 이미 검증된 원칙들입니다. 클로드 코드가 한 것은 이 원칙들을 AI 에이전트라는 새로운 도메인에 일관되게 적용한 것뿐입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 분석을 통해 느낀 것은, 좋은 도구는 기술의 깊이나 양에서 나오는 것이 아니라 문제의 본질에 집중할 때 나온다는 사실입니다.&lt;/p&gt;</description>
      <category>인공지능/Claude</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/193</guid>
      <comments>https://codediary21.tistory.com/193#entry193comment</comments>
      <pubDate>Sun, 22 Mar 2026 16:44:50 +0900</pubDate>
    </item>
    <item>
      <title>토비의 스프링 정복하기 17편 - POJO를 DDD 관점으로 해석하기</title>
      <link>https://codediary21.tistory.com/192</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. POJO는 왜 여전히 중요한가&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토비의 스프링이 출간되던 시절, POJO는 EJB라는 무거운 컨테이너 종속 구조를 벗어나기 위한 반란이었습니다. 마틴 파울러가 &quot;Plain Old Java Object&quot;라는 이름을 붙인 이유 자체가, 개발자들이 단순한 자바 객체를 쓰는 것을 '초라하게' 느끼지 않도록 하기 위함이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2026년 현재 EJB는 사실상 사라졌고, Spring Boot가 사실상 자바 웹 프레임워크의 표준이 되었습니다. 그런데 아이러니하게도, 시간이 흘러 함수형 패러다임과 이벤트 패러다임 등 많은 이론들이 나왔지만 여전히 객체 지향은 더 중요해졌습니다. 그리고 Spring Boot의 자동 설정, 수많은 스타터 의존성, 그리고 각종 애노테이션 조합 속에서 우리의 도메인 객체가 여전히 &quot;진짜 POJO&quot;인지 돌아볼 필요가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DDD&amp;nbsp;관점에서&amp;nbsp;POJO의&amp;nbsp;핵심&amp;nbsp;가치를&amp;nbsp;한&amp;nbsp;문장으로&amp;nbsp;정리하면&amp;nbsp;이렇습니다:&amp;nbsp;도메인&amp;nbsp;모델이&amp;nbsp;정말로&amp;nbsp;순수한&amp;nbsp;POJO인가?&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 스프링 애플리케이션의 구조: 과거와 현재&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;토비의 스프링이 말한 구조&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토비의 스프링에서는 스프링 애플리케이션을 두 가지로 구분했습니다: POJO로 만든 애플리케이션 코드, 그리고 POJO 간의 관계를 정의하는 설계 정보(XML 설정, 애노테이션 등). IoC/DI, AOP, PSA(Portable Service Abstraction)는 이 POJO 기반 개발을 가능하게 하는 &quot;가능 기술(Enabling Technology)&quot;로 정의되었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;현대 스프링에서의 재해석&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현대 Spring Boot 3.x 애플리케이션에서 이 구조를 다시 그려보면, 핵심 구조는 변하지 않았지만 표현 방식이 크게 달라졌습니다.&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/bMlqgn/dJMcagSjdGB/2CKtgC7md1Z0UxOF8ngbj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMlqgn/dJMcagSjdGB/2CKtgC7md1Z0UxOF8ngbj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMlqgn/dJMcagSjdGB/2CKtgC7md1Z0UxOF8ngbj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMlqgn%2FdJMcagSjdGB%2F2CKtgC7md1Z0UxOF8ngbj1%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;p data-ke-size=&quot;size16&quot;&gt;여기서 주목할 점은 &lt;b&gt;Domain Model 계층이 순수 POJO로 유지되어야 한다&lt;/b&gt;는 것입니다. @Entity, @Service, @Repository 같은 스프링/JPA 애노테이션이 도메인 모델 내부에 침투하는 순간, 그것은 더 이상 진정한 의미의 POJO가 아니라고 볼 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. POJO의 세 가지 조건을 현대적으로 재정의하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토비의 스프링은 POJO의 조건을 세 가지로 정리했습니다. 이를 현대 스프링과 DDD 맥락에서 다시 해석해 봅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3.1 특정 규약에 종속되지 않을 것 &amp;rarr; 도메인 모델의 인프라 독립성&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;과거의 문제:&lt;/b&gt; EJB의 SessionBean 인터페이스를 구현해야 했고, 자바의 단일 상속 제약 때문에 객체지향 설계가 제한되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;현재의 문제:&lt;/b&gt; EJB는 사라졌지만, 종속성은 다른 형태로 남아 있습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ❌ JPA 규약에 종속된 &quot;도메인&quot; 객체
@Entity
@Table(name = &quot;orders&quot;)
public class Order {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = &quot;order_id&quot;)
    private List&amp;lt;OrderLine&amp;gt; lines = new ArrayList&amp;lt;&amp;gt;();

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    // JPA 요구사항: 기본 생성자
    protected Order() {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 JPA라는 특정 퍼시스턴스 규약에 완전히 종속되어 있습니다. @Entity, @Table, @OneToMany 등의 애노테이션은 도메인 로직과 무관한 인프라 관심사입니다. JPA가 요구하는 기본 생성자는 도메인 불변식(invariant)을 깨뜨릴 위험이 있습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// ✅ 순수 POJO 도메인 모델
public class Order {
    private final OrderId id;
    private final List&amp;lt;OrderLine&amp;gt; lines;
    private OrderStatus status;
    private final List&amp;lt;DomainEvent&amp;gt; domainEvents = new ArrayList&amp;lt;&amp;gt;();

    public Order(OrderId id, List&amp;lt;OrderLine&amp;gt; lines) {
        if (lines == null || lines.isEmpty()) {
            throw new IllegalArgumentException(&quot;주문에는 최소 하나의 주문 항목이 필요합니다&quot;);
        }
        this.id = id;
        this.lines = List.copyOf(lines);
        this.status = OrderStatus.CREATED;
        this.domainEvents.add(new OrderCreated(id));
    }

    public void confirm() {
        if (this.status != OrderStatus.CREATED) {
            throw new IllegalStateException(&quot;생성 상태의 주문만 확정할 수 있습니다&quot;);
        }
        this.status = OrderStatus.CONFIRMED;
        this.domainEvents.add(new OrderConfirmed(this.id));
    }

    public Money calculateTotal() {
        return lines.stream()
            .map(OrderLine::subtotal)
            .reduce(Money.ZERO, Money::add);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 객체는 JPA를 모릅니다. Spring을 모릅니다. 오직 비즈니스 규칙만 알고 있습니다. DDD에서 말하는 &lt;b&gt;Aggregate Root&lt;/b&gt;의 이상적인 형태입니다. 불변식이 생성자에서 보장되고, 상태 전이에 명확한 규칙이 있으며, 도메인 이벤트를 통해 부수 효과를 표현합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3.2 특정 환경에 종속되지 않을 것 &amp;rarr; 헥사고날 아키텍처의 Port&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;과거의 문제:&lt;/b&gt; JNDI 룩업, 서블릿 컨테이너 등 특정 런타임 환경에 종속.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;현재의 문제:&lt;/b&gt; 현대 스프링에서는 표현 계층의 DTO가 그대로 서비스 계층을 관통하거나, Spring의 ApplicationContext나 @Value 같은 인프라 관심사가 도메인 로직에 침투하는 경우입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// ❌ 표현 계층의 DTO가 도메인 서비스까지 관통하는 구조
@RestController
public class OrderController {
    private final OrderService orderService;

    @PostMapping(&quot;/orders&quot;)
    public ResponseEntity&amp;lt;OrderResponse&amp;gt; placeOrder(@RequestBody @Valid OrderRequest request) {
        // Controller의 DTO가 Service 계층까지 그대로 전달됨
        return ResponseEntity.ok(orderService.placeOrder(request));
    }
}

@Service
public class OrderService {
    // 표현 계층의 DTO에 직접 의존 &amp;mdash; Jackson 애노테이션, Validation 애노테이션이
    // 서비스 계층의 시그니처를 지배함
    public OrderResponse placeOrder(OrderRequest request) {
        // request는 @JsonProperty, @NotNull 등 HTTP 직렬화 관심사를 품고 있음
        Order order = new Order(request.getUserId(), request.getItems());
        repository.save(order);
        return new OrderResponse(order.getId(), order.getStatus());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 동작하지만, OrderService가 HTTP 표현 계층의 DTO에 종속되어 있습니다. 만약 같은 주문 로직을 메시지 큐 컨슈머나 배치 잡에서 호출해야 한다면, OrderRequest라는 HTTP용 DTO를 억지로 재사용하거나 중복 코드를 만들게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DDD와 헥사고날 아키텍처 관점에서, 이 문제의 해법은 &lt;b&gt;Port와 Adapter 패턴&lt;/b&gt;입니다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;// ✅ 환경 독립적인 Application Service (Use Case)
public class PlaceOrderUseCase {
    private final OrderRepository orderRepository;
    private final InventoryChecker inventoryChecker;
    private final DomainEventPublisher eventPublisher;

    public PlaceOrderUseCase(OrderRepository orderRepository,
                              InventoryChecker inventoryChecker,
                              DomainEventPublisher eventPublisher) {
        this.orderRepository = orderRepository;
        this.inventoryChecker = inventoryChecker;
        this.eventPublisher = eventPublisher;
    }

    public OrderId execute(PlaceOrderCommand command) {
        // 도메인 로직 수행
        if (!inventoryChecker.isAvailable(command.productId(), command.quantity())) {
            throw new InsufficientInventoryException(command.productId());
        }

        Order order = Order.create(command.customerId(), command.orderLines());
        orderRepository.save(order);
        order.domainEvents().forEach(eventPublisher::publish);

        return order.id();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// Port (인터페이스) - 도메인 계층에 위치
public interface OrderRepository {
    void save(Order order);
    Optional&amp;lt;Order&amp;gt; findById(OrderId id);
}

// Adapter (구현) - 인프라 계층에 위치
@Repository
public class JpaOrderRepository implements OrderRepository {
    private final JpaOrderEntityRepository jpaRepository;
    private final OrderMapper mapper;

    @Override
    public void save(Order order) {
        JpaOrderEntity entity = mapper.toEntity(order);
        jpaRepository.save(entity);
    }

    @Override
    public Optional&amp;lt;Order&amp;gt; findById(OrderId id) {
        return jpaRepository.findById(id.value())
            .map(mapper::toDomain);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlaceOrderUseCase는 HTTP를 모르고, JPA를 모르고, 심지어 Spring도 모릅니다. OrderRepository라는 &lt;b&gt;Port&lt;/b&gt;를 통해 인프라와 소통하며, 실제 구현인 JpaOrderRepository는 인프라 계층의 &lt;b&gt;Adapter&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3.3 객체지향 설계가 적용될 것 &amp;rarr; 전술적 DDD 패턴의 활용&lt;/b&gt;&lt;/h3&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;현대적 해석:&lt;/b&gt; DDD의 전술적 패턴(Aggregate, Entity, Value Object, Domain Service, Domain Event)이 이 원칙의 구체적 실현입니다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// ❌ 빈약한 도메인 모델 (Anemic Domain Model)
// 데이터만 들고 있고 행위는 Service에 있음
public class Order {
    private Long id;
    private String status;
    private List&amp;lt;OrderLine&amp;gt; lines;
    // getter/setter만 존재
}

@Service
public class OrderService {
    public void cancelOrder(Long orderId) {
        Order order = repository.findById(orderId);
        if (&quot;CONFIRMED&quot;.equals(order.getStatus())) {
            order.setStatus(&quot;CANCELLED&quot;);
            for (OrderLine line : order.getLines()) {
                inventoryService.restore(line.getProductId(), line.getQuantity());
            }
            repository.save(order);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 Order는 POJO처럼 보이지만, 마틴 파울러가 &lt;b&gt;빈약한 도메인 모델(Anemic Domain Model)&lt;/b&gt;이라 비판한 안티패턴입니다. 도메인 지식이 Service에 흩어져 있고, Order 객체는 단순 데이터 홀더에 불과합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// ✅ 풍부한 도메인 모델 (Rich Domain Model)
public class Order {
    private final OrderId id;
    private OrderStatus status;
    private final CustomerId customerId;
    private final List&amp;lt;OrderLine&amp;gt; lines;
    private final List&amp;lt;DomainEvent&amp;gt; events = new ArrayList&amp;lt;&amp;gt;();

    public void cancel(CancellationPolicy policy) {
        if (!policy.canCancel(this)) {
            throw new OrderCancellationDeniedException(
                &quot;취소 정책에 의해 주문 취소가 거부되었습니다: &quot; + id
            );
        }
        this.status = OrderStatus.CANCELLED;
        this.events.add(new OrderCancelled(this.id, this.lines));
    }

    public boolean isWithinCancellationWindow(Clock clock) {
        return Duration.between(createdAt, clock.instant())
            .compareTo(Duration.ofHours(24)) &amp;lt; 0;
    }

    public Money calculateTotal() {
        return lines.stream()
            .map(OrderLine::subtotal)
            .reduce(Money.ZERO, Money::add);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 CancellationPolicy는 도메인 서비스 혹은 전략 패턴으로 구현된 정책 객체입니다. 재고 복구는 OrderCancelled 이벤트를 구독하는 별도 핸들러가 담당합니다. 이것이 &lt;b&gt;단일 책임 원칙&lt;/b&gt;과 &lt;b&gt;이벤트 기반 느슨한 결합&lt;/b&gt;의 조화입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. Value Object: POJO 원칙의 가장 순수한 구현&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DDD에서 Value Object는 POJO 철학의 가장 순수한 형태입니다. Java 14 이후의 Record가 이를 언어 레벨에서 지원합니다. Value Object라고 불리는 값 객체라고 불리는 객체는 순수한 계산 로직이나 값을 처리하는 역활로 활용을 많이 합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// Value Object as Record
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, &quot;금액은 필수입니다&quot;);
        Objects.requireNonNull(currency, &quot;통화는 필수입니다&quot;);
        if (amount.scale() &amp;gt; currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException(&quot;통화의 소수점 자릿수를 초과했습니다&quot;);
        }
    }

    public static final Money ZERO = new Money(BigDecimal.ZERO, Currency.getInstance(&quot;KRW&quot;));

    public Money add(Money other) {
        validateSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
    }

    private void validateSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException(&quot;서로 다른 통화는 연산할 수 없습니다&quot;);
        }
    }
}

public record OrderId(UUID value) {
    public OrderId {
        Objects.requireNonNull(value);
    }

    public static OrderId generate() {
        return new OrderId(UUID.randomUUID());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Value Object들은 프레임워크 의존성이 전혀 없고, 불변이며, 동등성이 값으로 결정되고, 자체 유효성을 검증합니다. 테스트가 단순하고, 어떤 환경에서든 동일하게 동작합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 현대 스프링의 Enabling Technology 재정의&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토비의 스프링은 IoC/DI, AOP, PSA를 POJO 개발의 가능 기술로 정의했습니다. 현대 Spring Boot 3.x에서는 이들이 어떻게 진화했는지 살펴봅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;IoC/DI &amp;rarr; 생성자 주입과 명시적 의존성&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Framework 4.3부터 단일 생성자의 경우 @Autowired가 불필요해졌습니다. 이것은 POJO 원칙에 더 가까워진 것입니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// Spring에 대한 import가 전혀 없는 Application Service
public class PlaceOrderUseCase {
    private final OrderRepository orderRepository;
    private final DomainEventPublisher eventPublisher;

    // @Autowired 불필요 &amp;mdash; Spring이 자동으로 주입
    public PlaceOrderUseCase(OrderRepository orderRepository,
                              DomainEventPublisher eventPublisher) {
        this.orderRepository = orderRepository;
        this.eventPublisher = eventPublisher;
    }
}

// Spring 설정은 Infrastructure 계층의 @Configuration에서
@Configuration
public class OrderConfig {
    @Bean
    public PlaceOrderUseCase placeOrderUseCase(
            OrderRepository orderRepository,
            DomainEventPublisher eventPublisher) {
        return new PlaceOrderUseCase(orderRepository, eventPublisher);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PlaceOrderUseCase 클래스 자체에는 Spring 관련 import가 단 하나도 없습니다. 이것이 진정한 POJO입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;AOP &amp;rarr; 횡단 관심사의 인프라 격리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션, 로깅, 보안 같은 레이어 레벨에서 처리해야 하는 작업 들은 횡단 관심사를 도메인 밖으로 밀어냅니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// 도메인 계층은 트랜잭션을 모름
public class PlaceOrderUseCase {
    public OrderId execute(PlaceOrderCommand command) {
        Order order = Order.create(command.customerId(), command.orderLines());
        orderRepository.save(order);
        return order.id();
    }
}

// 트랜잭션은 인프라 계층의 Adapter가 처리
@Component
@Transactional
public class TransactionalPlaceOrderAdapter implements PlaceOrderPort {
    private final PlaceOrderUseCase useCase;

    @Override
    public OrderId placeOrder(PlaceOrderCommand command) {
        return useCase.execute(command);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또는 @Configuration에서 AOP를 선언적으로 적용할 수도 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;PSA &amp;rarr; 인터페이스 기반 Port 정의&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 추상화는 DDD의 Port 개념과 자연스럽게 합류합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// Port: 도메인이 필요로 하는 인터페이스
public interface NotificationSender {
    void send(Notification notification);
}

// Adapter 1: 이메일
@Component
@Profile(&quot;production&quot;)
public class EmailNotificationSender implements NotificationSender {
    private final JavaMailSender mailSender;
    // ...
}

// Adapter 2: 테스트용 인메모리
public class InMemoryNotificationSender implements NotificationSender {
    private final List&amp;lt;Notification&amp;gt; sent = new ArrayList&amp;lt;&amp;gt;();

    @Override
    public void send(Notification notification) {
        sent.add(notification);
    }

    public List&amp;lt;Notification&amp;gt; sentNotifications() {
        return Collections.unmodifiableList(sent);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경에 따라 구현체를 교체할 수 있고, 도메인 코드는 전혀 변경되지 않습니다. 이것이 토비의 스프링이 말한 PSA의 현대적 실현입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 애노테이션과 POJO의 경계&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토비의 스프링은 애노테이션이 특정 환경에 종속적 정보를 담지 않으면 POJO라 할 수 있다고 했습니다. 현대 스프링에서 이 경계를 명확히 구분해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;// POJO 허용 범위의 애노테이션: Bean Validation
public class CreateOrderCommand {
    @NotNull private final CustomerId customerId;
    @NotEmpty private final List&amp;lt;OrderLineRequest&amp;gt; lines;
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bean Validation 애노테이션(@NotNull, @NotEmpty)은 표준 스펙(Jakarta Validation)이며, 특정 프레임워크에 종속되지 않습니다. 이 정도는 허용 가능합니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ❌ POJO 위반: 도메인 객체에 인프라 애노테이션
public class Order {
    @Id @GeneratedValue
    private Long id;

    @Column(name = &quot;order_status&quot;)
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @JsonProperty(&quot;total_amount&quot;)    // 직렬화 관심사
    private BigDecimal totalAmount;

    @Cacheable(&quot;orders&quot;)             // 캐시 관심사
    public OrderSummary toSummary() { ... }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA 매핑, JSON 직렬화, 캐시 전략은 모두 인프라 관심사입니다. 이들이 도메인 객체에 직접 부착되면, 도메인 모델의 변경이 인프라 변경에 의해 강제됩니다. 가령 JPA에서 MongoDB로 전환할 때, 도메인 모델 전체를 수정해야 하는 상황이 발생합니다.&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; 현실의 많은 프로젝트에서 JPA 엔티티와 도메인 모델을 분리하는 것은 매핑 비용이 큽니다. 이 경우 모듈 경계를 명확히 하고, 최소한 핵심 Aggregate의 도메인 로직은 최소한의 연관 관계를 매핑한 구조로 활용하는 것으로 타협하는 것이 좋습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. POJO와 테스트: 비용 대비 효과의 핵심&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POJO의 가장 직접적인 실용 가치는 테스트에 있습니다. 도메인 로직이 순수 POJO로 작성되면, 인프라와 프레임워크에 종속되어 있지 않기 때문에 별도의 컨테이너 없이 밀리초 단위로 수천 개의 테스트를 실행할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;class OrderTest {
    @Test
    void 주문_생성_시_상태는_CREATED이다() {
        Order order = Order.create(
            CustomerId.of(&quot;cust-1&quot;),
            List.of(OrderLine.of(ProductId.of(&quot;prod-1&quot;), 2, Money.won(10000)))
        );

        assertThat(order.status()).isEqualTo(OrderStatus.CREATED);
    }

    @Test
    void 확정된_주문은_취소할_수_있다() {
        Order order = createConfirmedOrder();
        CancellationPolicy policy = new DefaultCancellationPolicy(Clock.fixed(...));

        order.cancel(policy);

        assertThat(order.status()).isEqualTo(OrderStatus.CANCELLED);
        assertThat(order.domainEvents())
            .hasSize(1)
            .first()
            .isInstanceOf(OrderCancelled.class);
    }

    @Test
    void 빈_주문항목으로_주문을_생성할_수_없다() {
        assertThatThrownBy(() -&amp;gt; Order.create(CustomerId.of(&quot;cust-1&quot;), List.of()))
            .isInstanceOf(IllegalArgumentException.class);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 테스트들은 Spring ApplicationContext가 필요 없고, 데이터베이스가 필요 없고, 어떤 외부 의존성도 필요 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@SpringBootTest의 수십 초 부팅 시간 없이, 순수 JUnit으로 수백 개의 도메인 규칙을 검증할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 토비의 스프링이 말한 &quot;POJO의 코드는 매우 유연한 방식으로 원하는 레벨에서 코드를 빠르고 명확하게 테스트할 수 있음&quot;의 현대적 실현입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;8. 결론: POJO는 기술이 아니라 설계 원칙이다&lt;/b&gt;&lt;/h2&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;이러한 판단은 개발자의 몫입니다. 그리고 이러한 판단을 하기 위해서는 코드와 기술로 설계를 하지 않고, 실제 비즈니스와 도메인의 현실적인 부분을 바라봐야지만 해석할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현대 스프링과 DDD 관점에서 이를 재정리하면 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;도메인 모델은 프레임워크를 모르는 순수 POJO여야 합니다. JPA, Spring, Jackson 등의 애노테이션이 도메인 객체에 침투하면, 도메인 모델의 진화가 인프라에 의해 제약됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;경계를 명확히 하는 것이 핵심입니다.&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 헥사고날 아키텍처의 Port와 Adapter, DDD의 Bounded Context와 Anti-Corruption Layer는 모두 이 경계를 정의하는 도구입니다. POJO는 이 경계 안쪽에 존재하는 것입니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 가능성은 설계 품질의 리트머스 시험지입니다.&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 도메인 로직을 테스트하기 위해 @SpringBootTest가 필요하다면, 그것은 도메인이 인프라에 종속되어 있다는 신호입니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;설계의 수준은 비즈니스 복잡도에 맞춰야 합니다.&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 모든 프로젝트에 헥사고날 아키텍처와 Rich Domain Model이 필요한 것은 아닙니다. 단순한 CRUD 서비스에 과도한 추상화를 도입하면 오히려 유지보수 비용이 증가합니다. 프레임워크는 좋은 설계를 &quot;가능하게&quot; 할 뿐이고, 어떤 수준의 설계를 적용할지는 비즈니스와 도메인의 현실을 직시한 개발자의 판단에 달려 있습니다&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>Spring/토비의 스프링 시리즈</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/192</guid>
      <comments>https://codediary21.tistory.com/192#entry192comment</comments>
      <pubDate>Sun, 15 Mar 2026 16:49:58 +0900</pubDate>
    </item>
    <item>
      <title>토비의 스프링 정복하기 16편 - 스프링 생태계의 진화</title>
      <link>https://codediary21.tistory.com/191</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;스프링이 가져온 봄&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;641&quot; data-origin-height=&quot;349&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eajjbm/dJMcadVtbqb/CSAHtZwakYcYs6IsCtpAaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eajjbm/dJMcadVtbqb/CSAHtZwakYcYs6IsCtpAaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eajjbm/dJMcadVtbqb/CSAHtZwakYcYs6IsCtpAaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Feajjbm%2FdJMcadVtbqb%2FCSAHtZwakYcYs6IsCtpAaK%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;641&quot; height=&quot;349&quot; data-origin-width=&quot;641&quot; data-origin-height=&quot;349&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링이 등장하기 전, 개발자들은 비즈니스 로직보다 인프라 문제를 해결하는 데 더 많은 시간을 써야 했습니다. 웹 서버 구축, HTTP 통신, 직렬화, DB 접근까지 모두 직접 구현해야 했기 때문입니다.&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이를 해결하기 위해 등장한 것이 EJB였습니다. EJB는 이런 인프라 문제를 프레임워크 수준에서 해결해주었지만, 오히려 새로운 부담을 안겨줬습니다. 특정 인터페이스를 강제로 구현해야 했고, 무거운 WAS가 필요했으며, 방대한 XML 설정은 유지보수를 어렵게 만들었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;스프링은 이 반성에서 출발했습니다. 특정 프레임워크에 종속되지 않는 순수한 자바 객체, 즉 POJO만으로 애플리케이션을 구성할 수 있다는 것이 핵심이었고, 개발자들이 다시 비즈니스 문제에 집중할 수 있는 환경을 만들어줬습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;봄 이후의 그늘&lt;/b&gt;&lt;/h2&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;315&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Lr20E/dJMcabDphKs/PdEu5RsBxAgKKWBcoZZKwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Lr20E/dJMcabDphKs/PdEu5RsBxAgKKWBcoZZKwk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Lr20E/dJMcabDphKs/PdEu5RsBxAgKKWBcoZZKwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLr20E%2FdJMcabDphKs%2FPdEu5RsBxAgKKWBcoZZKwk%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;537&quot; height=&quot;282&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;315&quot;/&gt;&lt;/span&gt;&lt;/figure&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;스프링 팀은 이 문제를 해결하기 위해 Spring Boot 프로젝트를 시작했습니다. 검증된 의존성 조합을 미리 제공하고, 설정을 자동화해 개발자가 프로젝트를 빠르게 시작할 수 있도록 한 것입니다. 동시에 스프링 생태계 전반도 Spring Data, Spring Security, Spring Batch 등으로 표준화되어 일관된 방식으로 사용할 수 있게 정비되었습니다.&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;b&gt;만개한 봄 꽃밭&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;948&quot; data-origin-height=&quot;266&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m8Bnk/dJMb996EBA0/Y0qrMyo0UuFWpfyLhPy2z1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m8Bnk/dJMb996EBA0/Y0qrMyo0UuFWpfyLhPy2z1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m8Bnk/dJMb996EBA0/Y0qrMyo0UuFWpfyLhPy2z1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm8Bnk%2FdJMb996EBA0%2FY0qrMyo0UuFWpfyLhPy2z1%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;948&quot; height=&quot;266&quot; data-origin-width=&quot;948&quot; data-origin-height=&quot;266&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링은 객체 지향 프로그래밍을 추구했지만, 데이터베이스 앞에서는 한계가 있었습니다. 객체는 상속과 연관관계로 풍부한 구조를 표현하지만, 관계형 데이터베이스는 테이블과 행으로 데이터를 저장합니다. 이 패러다임의 불일치를 메우기 위해 개발자는 반복적인 SQL과 매핑 코드를 직접 작성해야 했고, 시스템이 복잡해질수록 유지보수 부담도 커졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 ORM이 등장했습니다. 개빈 킹이 만든 Hibernate는 객체와 테이블을 자동으로 매핑해주는 오픈소스로 빠르게 확산됐습니다. 이후 자바 진영은 Hibernate를 비롯한 다양한 ORM 구현체를 표준화하기 위해 JPA 명세를 만들었고, Hibernate가 그 대표 구현체로 자리잡았습니다. 지금의 Spring Data JPA는 이 JPA 위에서 반복적인 코드를 더욱 줄여주는 역할을 하고 있습니다.&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;b&gt;디비를 넘어 인프라로&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링은 여기서 멈추지 않았습니다. 서비스 규모가 커지면서 개발자들이 다뤄야 하는 인프라는 데이터베이스 하나가 아니었습니다. 빠른 조회를 위한 Redis 같은 캐시 서버, 서비스 간 비동기 통신을 위한 Kafka, RabbitMQ 같은 메시지 큐, 그리고 수십 개의 서비스가 분산되어 운영되는 MSA 환경까지, 개발자가 직접 다뤄야 하는 기술 스택이 폭발적으로 늘어났습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 팀은 이 각각의 기술을 개발자가 직접 붙잡고 씨름하는 대신, 일관된 방식으로 다룰 수 있도록 추상화하는 방향을 선택했습니다. 그 결과가 spring-data-* 로 시작하는 라이브러리들입니다. Spring Data JPA, Spring Data Redis, Spring Data MongoDB는 서로 다른 저장소이지만 Repository 인터페이스라는 동일한 방식으로 접근할 수 있습니다. 저장소가 바뀌어도 개발자가 배워야 할 패턴은 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산&amp;nbsp;환경의&amp;nbsp;문제는&amp;nbsp;Spring&amp;nbsp;Cloud가&amp;nbsp;담당했습니다.&amp;nbsp;서비스&amp;nbsp;간&amp;nbsp;통신,&amp;nbsp;로드&amp;nbsp;밸런싱,&amp;nbsp;서킷&amp;nbsp;브레이커,&amp;nbsp;분산&amp;nbsp;설정&amp;nbsp;관리까지,&amp;nbsp;MSA&amp;nbsp;운영에서&amp;nbsp;반복적으로&amp;nbsp;등장하는&amp;nbsp;인프라&amp;nbsp;문제들을&amp;nbsp;프레임워크&amp;nbsp;수준에서&amp;nbsp;해결할&amp;nbsp;수&amp;nbsp;있게&amp;nbsp;되었습니다.&lt;br /&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>Spring/토비의 스프링 시리즈</category>
      <author>ri5</author>
      <guid isPermaLink="true">https://codediary21.tistory.com/191</guid>
      <comments>https://codediary21.tistory.com/191#entry191comment</comments>
      <pubDate>Tue, 10 Mar 2026 23:17:27 +0900</pubDate>
    </item>
  </channel>
</rss>