<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Archist</title>
    <description>The latest articles on DEV Community by Archist (@archist).</description>
    <link>https://dev.to/archist</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3812612%2Fea7da566-26b1-454c-8ee8-c97a88a91317.png</url>
      <title>DEV Community: Archist</title>
      <link>https://dev.to/archist</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/archist"/>
    <language>en</language>
    <item>
      <title>복붙으로 만든 벤더 파이프라인 4벌, 무엇이 추상화할 가치가 있었나</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Mon, 27 Jul 2026 09:52:15 +0000</pubDate>
      <link>https://dev.to/archist/bogbuteuro-mandeun-bendeo-paipeurain-4beol-mueosi-cusanghwahal-gaciga-isseossna-oo</link>
      <guid>https://dev.to/archist/bogbuteuro-mandeun-bendeo-paipeurain-4beol-mueosi-cusanghwahal-gaciga-isseossna-oo</guid>
      <description>&lt;p&gt;우리 백엔드는 외부 벤더 시스템 4개에서 데이터를 받아 멀티테넌트 DB로 동기화한다. 하나는 SSH 터널 너머 DB를 직접 읽는 레거시 온프레미스 시스템이고, 나머지 셋은 HTTP API인데 각자 성격이 다르다. 하나는 터널 뒤에서 EUC-KR로 응답하고, 하나는 토큰 로그인을 쓰고, 하나는 정적 토큰을 쓰는 원내 어댑터다.&lt;/p&gt;

&lt;p&gt;각 연동은 NestJS 모노레포 안에서 독립 모듈로 산다. 그리고 새 연동은 전부 직전 연동 모듈을 복사해서 시작됐다. 두 번째 벤더까지는 이게 합리적인 출하 방식이다. 네 번째 벤더쯤 되니, 체감은 되는데 측정은 안 해본 문제가 하나 생겨 있었다.&lt;/p&gt;

&lt;p&gt;그래서 손대기 전에 먼저 측정했다.&lt;/p&gt;

&lt;h2&gt;
  
  
  감사(audit)부터
&lt;/h2&gt;

&lt;p&gt;네 모듈의 파일을 전부 훑으면서 하나씩 분류했다. 이 파일은 벤더 지식인가(그쪽 API 형태, 인증, 이상한 상태 코드), 아니면 어느 벤더든 똑같이 생겼을 오케스트레이션인가.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모듈&lt;/th&gt;
&lt;th&gt;프로덕션 LOC&lt;/th&gt;
&lt;th&gt;boilerplate 추정&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;벤더 A (레거시 DB)&lt;/td&gt;
&lt;td&gt;~10,400&lt;/td&gt;
&lt;td&gt;20~30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;벤더 B (HTTP + 터널)&lt;/td&gt;
&lt;td&gt;~4,300&lt;/td&gt;
&lt;td&gt;~50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;벤더 C (REST + 토큰)&lt;/td&gt;
&lt;td&gt;~3,000&lt;/td&gt;
&lt;td&gt;60~70%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;벤더 D (원내 어댑터)&lt;/td&gt;
&lt;td&gt;~4,300&lt;/td&gt;
&lt;td&gt;~45%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;흥미로운 건 총량이 아니라 중복이 &lt;em&gt;어디에&lt;/em&gt; 사는가였다. 나는 HTTP 클라이언트와 transformer가 문제일 거라 예상했다. 틀렸다. transformer는 anti-corruption 경계라서 벤더마다 진짜로 다른 스키마를 매핑한다. 그 코드는 제 몫을 하는 코드다. 진짜 중복은 그 &lt;em&gt;주변&lt;/em&gt;에 있었다.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;벤더 쪽에서 사라진 레코드를 감지해 soft-delete하는 "reconcile" 컴포넌트가 두 모듈에 거의 글자 단위로 복사돼 있었다. 두 번째 사본의 주석에는 대놓고 "다른 벤더와 같은 패턴"이라고 적혀 있었다.&lt;/li&gt;
&lt;li&gt;스케줄러 서비스 두 개가 주석까지 동일했다. 다른 건 벤더 enum과 로그 문자열뿐.&lt;/li&gt;
&lt;li&gt;계정 연동에 쓰는 직원 조회 서비스가 세 벌, 각각 50줄 안팎, 구조는 사실상 100% 동일.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getToday&lt;/code&gt;, &lt;code&gt;addDays&lt;/code&gt;, &lt;code&gt;formatDate&lt;/code&gt;, &lt;code&gt;chunk&lt;/code&gt; 같은 잔챙이 헬퍼가 &lt;em&gt;14개 파일&lt;/em&gt;에 private 메서드로 복붙돼 있었다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;한 가지가 눈에 들어왔다. 우리는 벤더를 &lt;em&gt;빠뜨리는&lt;/em&gt; 실수에 대해서는 이미 컴파일 타임 방어가 있었다. 라우팅 지점이 전부 &lt;code&gt;Record&amp;lt;VendorType, Port&amp;gt;&lt;/code&gt; 맵이라 enum에 값을 추가하고 어댑터 등록을 빼먹으면 빌드가 깨진다. 벤더별 계약 불변식을 강제하는 테스트 게이트도 있다. 이 장치들은 잘 작동한다. 다만 이들이 강제하는 건 &lt;em&gt;존재&lt;/em&gt;이지 &lt;em&gt;유일성&lt;/em&gt;이 아니다. 파일 20개를 복붙해서 계약을 충족하는 것은 아무도 막지 않는다.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjoiZmxvd2NoYXJ0IFRCXG4gIHN1YmdyYXBoIEJFRk9SRVtcIkJlZm9yZTog66qo65OI66eI64ukIOyekOq4sCDsgqzrs7hcIl1cbiAgICBkaXJlY3Rpb24gTFJcbiAgICBBMVtcIuuypOuNlCBBPGJyLz7rgqDsp5wg7Zes7Y28IHgzIO2MjOydvFwiXVxuICAgIEIxW1wi67Kk642UIEI8YnIvPu2XrO2NvCArIHJlY29uY2lsZTxici8-KyDsiqTsvIDspITrn6wgKyDsp4Hsm5DsobDtmoxcIl1cbiAgICBDMVtcIuuypOuNlCBDPGJyLz7tl6ztjbwgKyDsiqTsvIDspITrn6wgKyDsp4Hsm5DsobDtmoxcIl1cbiAgICBEMVtcIuuypOuNlCBEPGJyLz7tl6ztjbwgKyByZWNvbmNpbGUgKyDsp4Hsm5DsobDtmoxcIl1cbiAgZW5kXG4gIEJFRk9SRSA9PT4gQUZURVJcbiAgc3ViZ3JhcGggQUZURVJbXCJBZnRlcjog6rO17Jqp7Li17J20IOqzqOqyqeydhCDshozsnKBcIl1cbiAgICBkaXJlY3Rpb24gTFJcbiAgICBTMVtcInV0aWwvPGJyLz7rgqDsp5wsIGNodW5rLCByZXRyeVwiXVxuICAgIFMyW1wicmVjb25jaWxlLzxici8-7Lu07Y-s64SM7Yq4IDHqsJxcIl1cbiAgICBTM1tcInNjaGVkdWxlci88YnIvPuy2lOyDgSDrsqDsnbTsiqRcIl1cbiAgICBTNFtcInN0YWZmLzxici8-7KCc64Sk66atIOuyoOydtOyKpFwiXVxuICBlbmRcbiAgc3R5bGUgQkVGT1JFIGZpbGw6I2ZkZjJlMCxzdHJva2U6I2IwNzgxOCxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEFGVEVSIGZpbGw6I2U0ZjBlNCxzdHJva2U6IzViOGY1Yixjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEExIGZpbGw6I2ZmZmZmZixzdHJva2U6I2IwNzgxOCxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEIxIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJiNmNiMCxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEMxIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJmODU1YSxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEQxIGZpbGw6I2ZmZmZmZixzdHJva2U6IzZiNDZjMSxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIFMxIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJjN2E2Yixjb2xvcjojMWYyOTM3XG4gIHN0eWxlIFMyIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJjN2E2Yixjb2xvcjojMWYyOTM3XG4gIHN0eWxlIFMzIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJjN2E2Yixjb2xvcjojMWYyOTM3XG4gIHN0eWxlIFM0IGZpbGw6I2ZmZmZmZixzdHJva2U6IzJjN2E2Yixjb2xvcjojMWYyOTM3IiwibWVybWFpZCI6eyJ0aGVtZSI6ImRlZmF1bHQifX0%3Ftype%3Dpng" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjoiZmxvd2NoYXJ0IFRCXG4gIHN1YmdyYXBoIEJFRk9SRVtcIkJlZm9yZTog66qo65OI66eI64ukIOyekOq4sCDsgqzrs7hcIl1cbiAgICBkaXJlY3Rpb24gTFJcbiAgICBBMVtcIuuypOuNlCBBPGJyLz7rgqDsp5wg7Zes7Y28IHgzIO2MjOydvFwiXVxuICAgIEIxW1wi67Kk642UIEI8YnIvPu2XrO2NvCArIHJlY29uY2lsZTxici8-KyDsiqTsvIDspITrn6wgKyDsp4Hsm5DsobDtmoxcIl1cbiAgICBDMVtcIuuypOuNlCBDPGJyLz7tl6ztjbwgKyDsiqTsvIDspITrn6wgKyDsp4Hsm5DsobDtmoxcIl1cbiAgICBEMVtcIuuypOuNlCBEPGJyLz7tl6ztjbwgKyByZWNvbmNpbGUgKyDsp4Hsm5DsobDtmoxcIl1cbiAgZW5kXG4gIEJFRk9SRSA9PT4gQUZURVJcbiAgc3ViZ3JhcGggQUZURVJbXCJBZnRlcjog6rO17Jqp7Li17J20IOqzqOqyqeydhCDshozsnKBcIl1cbiAgICBkaXJlY3Rpb24gTFJcbiAgICBTMVtcInV0aWwvPGJyLz7rgqDsp5wsIGNodW5rLCByZXRyeVwiXVxuICAgIFMyW1wicmVjb25jaWxlLzxici8-7Lu07Y-s64SM7Yq4IDHqsJxcIl1cbiAgICBTM1tcInNjaGVkdWxlci88YnIvPuy2lOyDgSDrsqDsnbTsiqRcIl1cbiAgICBTNFtcInN0YWZmLzxici8-7KCc64Sk66atIOuyoOydtOyKpFwiXVxuICBlbmRcbiAgc3R5bGUgQkVGT1JFIGZpbGw6I2ZkZjJlMCxzdHJva2U6I2IwNzgxOCxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEFGVEVSIGZpbGw6I2U0ZjBlNCxzdHJva2U6IzViOGY1Yixjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEExIGZpbGw6I2ZmZmZmZixzdHJva2U6I2IwNzgxOCxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEIxIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJiNmNiMCxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEMxIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJmODU1YSxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIEQxIGZpbGw6I2ZmZmZmZixzdHJva2U6IzZiNDZjMSxjb2xvcjojMWYyOTM3XG4gIHN0eWxlIFMxIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJjN2E2Yixjb2xvcjojMWYyOTM3XG4gIHN0eWxlIFMyIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJjN2E2Yixjb2xvcjojMWYyOTM3XG4gIHN0eWxlIFMzIGZpbGw6I2ZmZmZmZixzdHJva2U6IzJjN2E2Yixjb2xvcjojMWYyOTM3XG4gIHN0eWxlIFM0IGZpbGw6I2ZmZmZmZixzdHJva2U6IzJjN2E2Yixjb2xvcjojMWYyOTM3IiwibWVybWFpZCI6eyJ0aGVtZSI6ImRlZmF1bHQifX0%3Ftype%3Dpng" alt="diagram" width="348" height="1154"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  함정 1: "동일한" 헬퍼가 동일하지 않았다
&lt;/h2&gt;

&lt;p&gt;당연한 첫수는 날짜 헬퍼를 공용 util로 올리는 것이다. 쉬워 보인다.&lt;/p&gt;

&lt;p&gt;거의 쉬웠다. 14개 사본을 나란히 놓고 읽어보니 &lt;code&gt;getToday&lt;/code&gt;에 두 가지 변형이 있었다. 대부분은 "지금 시각을 비즈니스 타임존으로 시프트한 값"을 반환했는데, 두 사본은 거기에 &lt;code&gt;setHours(0,0,0,0)&lt;/code&gt;으로 자정 절삭까지 하고 있었다. 이름도 같고 주석도 같은데 값이 다르다.&lt;/p&gt;

&lt;p&gt;이걸 함수 하나로 합쳤다면 어떤 스케줄 윈도우는 조용히 어긋나고, 어떤 타임스탬프 비교는 조용히 바뀌었을 것이다. 그래서 공용 util은 차이를 실토하는 이름으로 둘 다 export한다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getKstNow&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* 타임존 시프트된 now */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getKstTodayStart&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* 시프트 + 자정 절삭 */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;formatDate&lt;/code&gt;도 같은 이야기다. 세 모듈은 &lt;code&gt;YYYYMMDD&lt;/code&gt;를, 한 모듈은 &lt;code&gt;YYYY-MM-DD&lt;/code&gt;를 만들고 있었다. 함수 두 개로 두었다. 변형 두 개를 하나로 "정리"하는 순간 그것은 더 이상 리팩토링이 아니다. 리팩토링의 탈을 쓴 동작 변경이다.&lt;/p&gt;

&lt;p&gt;같은 커밋에서 나온 작은 함정 하나 더. 언젠가 10분을 아껴줄지도 모르니 적어둔다. &lt;code&gt;this.chunk(...)&lt;/code&gt;를 import 함수로 바꾸다가 &lt;code&gt;for (const chunk of chunk(codes, SIZE))&lt;/code&gt; 형태의 호출부가 깨졌다. 루프 변수가 자기 초기화식 안에서 import를 가리는 temporal dead zone 에러다. 루프 변수 이름을 바꾸고 넘어갔다.&lt;/p&gt;

&lt;h2&gt;
  
  
  함정 2: "동일한 스케줄러 3개" 중 하나는 아니었다
&lt;/h2&gt;

&lt;p&gt;감사 노트에는 HTTP 벤더 스케줄러 3개가 전부 복붙이라고 적혀 있었다. 둘은 정말 그랬다. 같은 크론 패턴, 같은 테넌트 루프, 같은 실행 추적 전이, 주석까지. 그런데 세 번째는 멀리서 보면 비슷한데 구조가 달랐다. 동기화를 인라인으로 실행하는 게 아니라 cascade 잡을 enqueue하고 나머지는 큐 워커에게 맡긴다.&lt;/p&gt;

&lt;p&gt;일치하는 둘만 추상 베이스로 뽑았다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SyncSchedulerBase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 소유: 테넌트 루프, 실행 추적, master -&amp;gt; records 실행, 에러 삼킴&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;runScheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VendorBScheduler&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;SyncSchedulerBase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 소유: 크론 선언 (분산 락 이름이 벤더별로 유니크해야 함)&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;DistributedCron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0 6 * * *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vendor-b-morning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;morning&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runScheduled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;morning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;크론 데코레이터는 일부러 서브클래스에 남겼다. 우리 분산 크론 래퍼는 name을 Redis 락 키로 쓰기 때문에 벤더별로 유니크해야 하고, 데코레이터 메타데이터는 어차피 상속이 깔끔하지 않다.&lt;/p&gt;

&lt;p&gt;세 번째 스케줄러는? 그대로 두었다. 베이스에는 왜 제외했는지 주석으로 남겼다. 억지로 베이스에 넣으려면 "인라인 vs enqueue"를 가르는 훅이 필요해지는데, 그 순간부터 추상화 비용이 중복 비용을 넘어선다. 사전에 만든 감사 문서에 "하지 말 것" 목록이 있었는데, 이 목록이 정말 필요해지는 시점이 바로 리팩토링 도중이다. 완결주의 충동은 꼭 그때 온다.&lt;/p&gt;

&lt;h2&gt;
  
  
  재시도 코어: 베이스 클래스가 아니라 함수
&lt;/h2&gt;

&lt;p&gt;두 HTTP 클라이언트의 재시도 뼈대는 같았다. 3회 시도, &lt;code&gt;[1000, 2000, 4000]&lt;/code&gt; backoff, private &lt;code&gt;sleep&lt;/code&gt;. 상수도 루프도 동일하다. 하지만 루프를 둘러싼 &lt;em&gt;에러 처리&lt;/em&gt;는 진짜로 달랐다.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;벤더 B는 터널 레벨 연결 실패에 즉시 fail-fast한다. 죽은 터널에 재시도해봐야 호출당 9초를 태우고 상위 RPC 예산만 날리기 때문에, 즉시 경보를 보내고 도메인 에러를 던진다.&lt;/li&gt;
&lt;li&gt;벤더 C는 404를 "데이터 없음"(벤더 관례상 정상적인 빈 결과)으로 변환하고, 401에는 토큰 캐시를 폐기하며, 네트워크 에러·401·5xx만 재시도한다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;여기서 유혹은 &lt;code&gt;AbstractHttpClient&lt;/code&gt;다. 안 했다. 한쪽은 array buffer에서 EUC-KR을 디코드하고 다른 쪽은 토큰 로그인을 관리한다. 공용 베이스를 만들면 template method 훅만 가득하고 실제로 공유되는 알맹이는 없다. 대신 공용층에는 함수 하나만 두었다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;retryWithBackoff&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;delaysMs&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="c1"&gt;// false 반환: 원본 에러로 즉시 실패.&lt;/span&gt;
    &lt;span class="c1"&gt;// throw: *당신의* 에러로 즉시 실패 (터널 fail-fast 용).&lt;/span&gt;
    &lt;span class="nl"&gt;isRetryable&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;onRetry&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delayMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;두 클라이언트를 모두 수용하게 해준 계약은 &lt;code&gt;isRetryable&lt;/code&gt;이 &lt;em&gt;throw할 수 있다&lt;/em&gt;는 것이었다. 벤더 B의 터널 체크는 훅 안에서 경보를 보내고 도메인 에러를 던진다. 그리고 코어는 최종 실패를 로깅하지도, 에러를 래핑하지도 않는다. 원본 에러를 그대로 다시 던지고, 각 호출자가 기존의 "최종 실패" 로그 라인과 에러 메시지 형식을 유지하게 했다. 덕분에 diff가 읽기 좋았다. 루프는 이동했지만 동작은 안 변했다.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjoic2VxdWVuY2VEaWFncmFtXG4gICAgcGFydGljaXBhbnQgQyBhcyBIVFRQIO2BtOudvOydtOyWuO2KuFxuICAgIHBhcnRpY2lwYW50IFIgYXMgcmV0cnlXaXRoQmFja29mZlxuICAgIHBhcnRpY2lwYW50IFYgYXMg67Kk642UIEFQSVxuICAgIEMtPj5SOiBmbiwgaXNSZXRyeWFibGUsIG9uUmV0cnlcbiAgICBSLT4-VjogMeywqCDsi5zrj4RcbiAgICBWLS0-PlI6IEVDT05OUkVGVVNFRFxuICAgIFItPj5DOiBpc1JldHJ5YWJsZShlcnJvcik_XG4gICAgYWx0IO2EsOuEkCDri6TsmrQgKOuypOuNlCBCKVxuICAgICAgICBDLT4-Qzog6rK967O0IOuwnOyGoVxuICAgICAgICBDLS0-PlI6IHRocm93IFR1bm5lbFVucmVhY2hhYmxlRXJyb3JcbiAgICAgICAgUi0tPj5DOiDsponsi5wg7Iuk7YyoICjsnqzsi5zrj4Qg7JeG7J2MKVxuICAgIGVsc2Ug7J287IucIOyXkOufrFxuICAgICAgICBDLS0-PlI6IHRydWVcbiAgICAgICAgUi0-PlI6IG9uUmV0cnkg66Gc6re4LCBzbGVlcCgxMDAwKVxuICAgICAgICBSLT4-VjogMuywqCDsi5zrj4RcbiAgICAgICAgVi0tPj5SOiAyMDAgT0tcbiAgICAgICAgUi0tPj5DOiDqsrDqs7xcbiAgICBlbmQiLCJtZXJtYWlkIjp7InRoZW1lIjoiZGVmYXVsdCJ9fQ%3Ftype%3Dpng" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FeyJjb2RlIjoic2VxdWVuY2VEaWFncmFtXG4gICAgcGFydGljaXBhbnQgQyBhcyBIVFRQIO2BtOudvOydtOyWuO2KuFxuICAgIHBhcnRpY2lwYW50IFIgYXMgcmV0cnlXaXRoQmFja29mZlxuICAgIHBhcnRpY2lwYW50IFYgYXMg67Kk642UIEFQSVxuICAgIEMtPj5SOiBmbiwgaXNSZXRyeWFibGUsIG9uUmV0cnlcbiAgICBSLT4-VjogMeywqCDsi5zrj4RcbiAgICBWLS0-PlI6IEVDT05OUkVGVVNFRFxuICAgIFItPj5DOiBpc1JldHJ5YWJsZShlcnJvcik_XG4gICAgYWx0IO2EsOuEkCDri6TsmrQgKOuypOuNlCBCKVxuICAgICAgICBDLT4-Qzog6rK967O0IOuwnOyGoVxuICAgICAgICBDLS0-PlI6IHRocm93IFR1bm5lbFVucmVhY2hhYmxlRXJyb3JcbiAgICAgICAgUi0tPj5DOiDsponsi5wg7Iuk7YyoICjsnqzsi5zrj4Qg7JeG7J2MKVxuICAgIGVsc2Ug7J287IucIOyXkOufrFxuICAgICAgICBDLS0-PlI6IHRydWVcbiAgICAgICAgUi0-PlI6IG9uUmV0cnkg66Gc6re4LCBzbGVlcCgxMDAwKVxuICAgICAgICBSLT4-VjogMuywqCDsi5zrj4RcbiAgICAgICAgVi0tPj5SOiAyMDAgT0tcbiAgICAgICAgUi0tPj5DOiDqsrDqs7xcbiAgICBlbmQiLCJtZXJtYWlkIjp7InRoZW1lIjoiZGVmYXVsdCJ9fQ%3Ftype%3Dpng" alt="diagram" width="730" height="867"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  결과
&lt;/h2&gt;

&lt;p&gt;커밋 다섯 개로: 공용 util 층(날짜, 본문 정제, chunk, retry), reconcile 컴포넌트 두 벌 → 한 벌, 스케줄러 복사본 두 벌 → 베이스 하나, 직원 조회 세 벌 → 제네릭 베이스 하나, 손으로 만 재시도 루프 두 벌 → 코어 하나.&lt;/p&gt;

&lt;p&gt;코드베이스 순 효과는 약 380줄 &lt;em&gt;감소&lt;/em&gt;, 테스트 수는 오히려 증가다(공용 헬퍼마다 단위 테스트를 새로 붙였다). 호출자 변경은 0건. 클래스명, DI 토큰, public 메서드가 전부 제자리라서 리뷰도 조용히 지나갔다.&lt;/p&gt;

&lt;p&gt;내가 진짜 신경 쓰는 숫자는 온보딩 비용이다. 전에는 HTTP 벤더 하나를 추가하려면 가장 비슷한 기존 모듈에서 파일 20개쯤을 복사해야 했다. 이제 그 복붙 층은 대부분 사라졌고, 남은 작성 대상은 진짜 벤더 고유물이다. transformer, HTTP 클라이언트의 transport 특이점, 그리고 배선.&lt;/p&gt;

&lt;h2&gt;
  
  
  배운 것
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;컴파일 타임 레지스트리와 계약 테스트 게이트는 훌륭하지만, 이들이 막는 건 &lt;em&gt;누락&lt;/em&gt;이지 &lt;em&gt;중복&lt;/em&gt;이 아니다. 두 번째 문제에는 다른 도구가 필요하고, 그 도구는 감사와 의도적인 추출이다.&lt;/li&gt;
&lt;li&gt;추상화 전에 측정하라. "동일한 스케줄러 3개" 중 둘만 동일했다. 셋째는 우연히 비슷하게 들리는 다른 설계였다.&lt;/li&gt;
&lt;li&gt;헬퍼에 변형이 있으면 변형을 유지하고 이름으로 실토하라. 합치는 것은 정리가 아니라 동작 변경이다.&lt;/li&gt;
&lt;li&gt;추상화 경계는 지식 경계에 두어라. 벤더 지식(인증, 인코딩, 상태 코드 관례)은 훅과 서브클래스로, 오케스트레이션(루프, 실행 추적, backoff)은 공용층으로.&lt;/li&gt;
&lt;li&gt;HTTP 재시도에는 훅 두 개짜리 얇은 함수가 베이스 클래스를 이겼다. transport가 이만큼 다르면 상속이 공유하는 것은 로직이 아니라 격식이다.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>refactoring</category>
      <category>architecture</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>EC2 Dev 서버 운영기: PM2에서 시작해서 ECS Production까지</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Sat, 14 Mar 2026 07:19:04 +0000</pubDate>
      <link>https://dev.to/archist/ec2-dev-seobeo-unyeonggi-pm2eseo-sijaghaeseo-ecs-productionggaji-53im</link>
      <guid>https://dev.to/archist/ec2-dev-seobeo-unyeonggi-pm2eseo-sijaghaeseo-ecs-productionggaji-53im</guid>
      <description>&lt;h1&gt;
  
  
  EC2 Dev 서버 운영기 — PM2에서 시작해서 ECS Production까지
&lt;/h1&gt;

&lt;h2&gt;
  
  
  배경
&lt;/h2&gt;

&lt;p&gt;Node.js 마이크로서비스 4개를 운영하고 있다. API Gateway 1개와 백엔드 서비스 3개. Production은 AWS ECS Fargate로 돌리고, Dev 서버는 EC2 한 대에 PM2로 올려놓고 쓰고 있다.&lt;/p&gt;

&lt;p&gt;어느 날 Dev 서버 SSH 접속이 느려졌다. 들어가보니 PM2 로그가 수 GB, Docker dangling 이미지가 디스크를 채우고 있었다. 그때부터 정리한 운영 스크립트와, EC2와 ECS를 동시에 운영하면서 느낀 점을 정리한다.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dev 서버 (EC2) 구조
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EC2 (Ubuntu, t3.medium)
├── PM2 (Fork mode)
│   ├── gateway          512MB
│   ├── service-1        512MB
│   ├── service-2        512MB
│   ├── service-3-a      1GB
│   ├── service-3-b      1GB
│   ├── admin-dashboard  256MB
│   └── nginx            (sticky session)
│
├── Docker
│   ├── PostgreSQL 15    (port 5432)
│   └── Redis 7.4        (port 6379)
│
└── Nginx (ip_hash로 SSE sticky session)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PM2를 fork 모드로 쓰는 이유가 있다. SSE(Server-Sent Events) 연결이 cluster 모드에서 끊기기 때문이다. service-3은 2개 인스턴스로 띄우고, Nginx ip_hash로 sticky session을 건다.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production (ECS Fargate) 구조
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ALB (단일, 멀티 포트)
├── :443  → Gateway (Blue/Green, 2~10 tasks)
├── :444  → Service-3 (Blue/Green, Sticky, 1~6 tasks)
├── :446  → Service-2 (Blue/Green, Sticky, 1~6 tasks)
├── :448  → Service-1 (Rolling, Sticky, 1~6 tasks)
└── :3333 → Admin Dashboard (Rolling, 1 task)

Service Discovery (Cloud Map)
├── gateway.internal:4001
├── service-1.internal:4001
├── service-2.internal:4002
└── service-3.internal:4003
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;각 서비스가 독립적인 Fargate Task로 돌아간다. TCP 마이크로서비스 간 통신은 AWS Cloud Map으로 서비스 디스커버리를 하고, ALB에서 SSE가 필요한 서비스에는 sticky session 쿠키를 붙인다.&lt;/p&gt;

&lt;h3&gt;
  
  
  배포 전략도 서비스마다 다르다
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;서비스&lt;/th&gt;
&lt;th&gt;배포 방식&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gateway&lt;/td&gt;
&lt;td&gt;CodeDeploy Blue/Green&lt;/td&gt;
&lt;td&gt;진입점이라 무중단 필수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service-3&lt;/td&gt;
&lt;td&gt;CodeDeploy Blue/Green&lt;/td&gt;
&lt;td&gt;SSE 연결 graceful draining 120초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service-1&lt;/td&gt;
&lt;td&gt;ECS Rolling&lt;/td&gt;
&lt;td&gt;상태 없음, 빠른 배포 우선&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service-2&lt;/td&gt;
&lt;td&gt;ECS Rolling&lt;/td&gt;
&lt;td&gt;상태 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin Dashboard&lt;/td&gt;
&lt;td&gt;ECS Rolling&lt;/td&gt;
&lt;td&gt;내부 도구, 중단 허용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  두 환경의 현실적인 차이
&lt;/h2&gt;

&lt;h3&gt;
  
  
  EC2가 Dev에서 좋은 이유
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 코드 수정 → 반영: 30초&lt;/span&gt;
git pull &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pm2 restart all

&lt;span class="c"&gt;# 디버깅: SSH 한 방&lt;/span&gt;
ssh dev-server
pm2 logs service-1 &lt;span class="nt"&gt;--lines&lt;/span&gt; 100
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; postgres psql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ECS에서 같은 걸 하려면:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 코드 수정 → 반영: 3~5분&lt;/span&gt;
&lt;span class="c"&gt;# Docker 빌드 → ECR 푸시 → Task 재배포&lt;/span&gt;

&lt;span class="c"&gt;# 디버깅: 의식의 흐름이 필요&lt;/span&gt;
aws ecs execute-command &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster&lt;/span&gt; my-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--task&lt;/span&gt; arn:aws:ecs:... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--container&lt;/span&gt; my-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--interactive&lt;/span&gt; &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;
&lt;span class="c"&gt;# + Session Manager Plugin, IAM 권한, ECS Exec 활성화 필요&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; 하나 바꾸는 것도 EC2는 vim으로 수정 후 재시작이면 끝이지만, ECS는 Secrets Manager 수정 → 태스크 재배포다.&lt;/p&gt;

&lt;h3&gt;
  
  
  단, ALB가 붙으면 EC2 장점이 사라진다
&lt;/h3&gt;

&lt;p&gt;EC2의 가장 큰 장점은 &lt;strong&gt;Stop하면 과금이 멈추는 것&lt;/strong&gt;이다. 그런데 ALB가 붙어있으면:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ALB 고정비: ~$16/월&lt;/li&gt;
&lt;li&gt;NAT Gateway: ~$32/월 + 트래픽&lt;/li&gt;
&lt;li&gt;Stop해도 &lt;strong&gt;$48/월은 계속 나간다&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;이 정도면 ECS Fargate로 &lt;code&gt;desired_count: 0&lt;/code&gt;으로 내려놓는 것과 비용 차이가 없다. ALB가 필요한 Dev 서버라면 차라리 ECS로 통일하는 게 인프라 관리 포인트가 줄어든다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EC2 Dev 서버가 의미있으려면 ALB 없이 IP 직접 접근&lt;/strong&gt;으로 써야 한다.&lt;/p&gt;

&lt;h2&gt;
  
  
  EC2 Dev 서버 운영 스크립트
&lt;/h2&gt;

&lt;p&gt;ALB 없이 EC2를 Dev 서버로 쓰고 있다면, 이 자동화 정도는 깔아둬야 한다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cron 자동 정리
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;crontab &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# [매시] Docker 미사용 리소스 + systemd 로그 정리
0 * * * * docker system prune -f &amp;amp;&amp;amp; sudo journalctl --vacuum-size=500M &amp;amp;&amp;amp; sudo find /var/log -name "*.gz" -delete &amp;amp;&amp;amp; sudo find /tmp -atime +1 -delete 2&amp;gt;/dev/null

# [매일 03시] PM2 로그 비우기 — GB 단위로 쌓인다
0 3 * * * pm2 flush &amp;amp;&amp;amp; find ~/.pm2/logs -name "*.log" -size +500M -exec truncate -s 0 {} \;

# [매일 04시] 72시간 지난 Docker 이미지 삭제
0 4 * * * docker image prune -a -f --filter "until=72h" &amp;amp;&amp;amp; docker volume prune -f

# [매일 05시] 7일 넘은 로그 파일 삭제
0 5 * * * sudo find /var/log -name "*.log.*" -mtime +7 -delete 2&amp;gt;/dev/null

# [매주 일요일] apt 캐시 정리
0 4 * * 0 sudo apt clean &amp;amp;&amp;amp; sudo apt autoremove -y -q
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  30초 진단
&lt;/h3&gt;

&lt;p&gt;서버가 느릴 때 이것부터:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;uptime&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; free &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;세 가지를 동시에 본다:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;load average&lt;/strong&gt; — 코어 수 이상이면 CPU 과부하&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;available&lt;/strong&gt; — 전체 메모리의 10% 이하면 위험&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use%&lt;/strong&gt; — 디스크 90% 이상이면 긴급&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;더 파고 싶으면:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# CPU/메모리 Top 5&lt;/span&gt;
ps aux &lt;span class="nt"&gt;--sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;-%cpu | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
ps aux &lt;span class="nt"&gt;--sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;-%mem | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;

&lt;span class="c"&gt;# CPU steal — 높으면 t3 같은 버스터블 인스턴스 한계&lt;/span&gt;
top &lt;span class="nt"&gt;-bn1&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"%Cpu"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print "steal:", $16"%"}'&lt;/span&gt;

&lt;span class="c"&gt;# OOM Killer 발생 여부&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dmesg | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"oom&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;killed"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Docker 인프라 모니터링
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 컨테이너 리소스&lt;/span&gt;
docker stats &lt;span class="nt"&gt;--no-stream&lt;/span&gt;

&lt;span class="c"&gt;# PostgreSQL idle 연결 정리 (10분 이상)&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-qf&lt;/span&gt; &lt;span class="s2"&gt;"name=postgres"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle'
AND query_start &amp;lt; now() - interval '10 minutes';"&lt;/span&gt;

&lt;span class="c"&gt;# Redis BullMQ 잔여 데이터 정리&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-qf&lt;/span&gt; &lt;span class="s2"&gt;"name=redis"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  redis-cli &lt;span class="nt"&gt;--scan&lt;/span&gt; &lt;span class="nt"&gt;--pattern&lt;/span&gt; &lt;span class="s2"&gt;"bull:*:completed"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-qf&lt;/span&gt; &lt;span class="s2"&gt;"name=redis"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; redis-cli DEL &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  긴급 대응
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 디스크 100%: 범인 찾고 즉시 확보&lt;/span&gt;
&lt;span class="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /var/log /tmp ~/.pm2/logs /var/lib/docker 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rh&lt;/span&gt;
pm2 flush &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker system prune &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;--volumes&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;--vacuum-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100M

&lt;span class="c"&gt;# 메모리 부족: 캐시 해제 + PM2 재시작&lt;/span&gt;
&lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;3 | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /proc/sys/vm/drop_caches &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
pm2 restart all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  ECS Production에서는 이게 다 필요 없다
&lt;/h2&gt;

&lt;p&gt;위 스크립트들이 EC2에서 필요한 이유는 &lt;strong&gt;서버가 stateful&lt;/strong&gt;하기 때문이다. ECS Fargate는:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;EC2에서 수동으로 하는 것&lt;/th&gt;
&lt;th&gt;ECS에서 자동으로 되는 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PM2 로그 정리 Cron&lt;/td&gt;
&lt;td&gt;CloudWatch Logs (30일 retention 자동)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker prune Cron&lt;/td&gt;
&lt;td&gt;컨테이너 재생성 시 자동 정리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PM2 헬스체크&lt;/td&gt;
&lt;td&gt;ECS 헬스체크 + 자동 재시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ps aux&lt;/code&gt; 수동 모니터링&lt;/td&gt;
&lt;td&gt;CloudWatch 메트릭 + 알림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fail2ban SSH 방어&lt;/td&gt;
&lt;td&gt;SSH 자체가 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swap 설정&lt;/td&gt;
&lt;td&gt;Fargate가 메모리 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;디스크 용량 관리&lt;/td&gt;
&lt;td&gt;Ephemeral 스토리지, 상태 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  ECS에서 대신 필요한 것
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 서비스 상태 확인&lt;/span&gt;
aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; my-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--services&lt;/span&gt; gateway &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].{running:runningCount,desired:desiredCount,deployments:deployments[*].status}'&lt;/span&gt;

&lt;span class="c"&gt;# 실시간 로그&lt;/span&gt;
aws logs &lt;span class="nb"&gt;tail&lt;/span&gt; /ecs/my-service &lt;span class="nt"&gt;--follow&lt;/span&gt;

&lt;span class="c"&gt;# 스케일링&lt;/span&gt;
aws ecs update-service &lt;span class="nt"&gt;--cluster&lt;/span&gt; my-cluster &lt;span class="nt"&gt;--service&lt;/span&gt; gateway &lt;span class="nt"&gt;--desired-count&lt;/span&gt; 4

&lt;span class="c"&gt;# 롤백&lt;/span&gt;
aws ecs update-service &lt;span class="nt"&gt;--cluster&lt;/span&gt; my-cluster &lt;span class="nt"&gt;--service&lt;/span&gt; gateway &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--task-definition&lt;/span&gt; my-service:&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;aws ecs describe-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; my-cluster &lt;span class="nt"&gt;--services&lt;/span&gt; gateway &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[0].taskDefinition'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'[0-9]*$'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  개선 로드맵
&lt;/h2&gt;

&lt;p&gt;현재 구조에서 아직 남은 과제들:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Dev 환경도 ECS로 통합
&lt;/h3&gt;

&lt;p&gt;EC2 Dev 서버에 ALB가 붙어있다면, 비용 이점이 없다. Terraform 모듈을 환경별로 분리하면 된다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terraform/environments/
├── prod/    # 현재 구성
├── staging/ # desired_count: 1, 작은 리소스
└── dev/     # desired_count: 1, FARGATE_SPOT 100%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Auto Scaling 정책 추가
&lt;/h3&gt;

&lt;p&gt;현재 ECS에 min/max만 정의되어 있고 실제 스케일링 정책이 없다. Target Tracking 추가가 필요하다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_appautoscaling_target"&lt;/span&gt; &lt;span class="s2"&gt;"gateway"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;max_capacity&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="nx"&gt;min_capacity&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"service/my-cluster/gateway"&lt;/span&gt;
  &lt;span class="nx"&gt;scalable_dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs:service:DesiredCount"&lt;/span&gt;
  &lt;span class="nx"&gt;service_namespace&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_appautoscaling_policy"&lt;/span&gt; &lt;span class="s2"&gt;"gateway_cpu"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gateway-cpu-scaling"&lt;/span&gt;
  &lt;span class="nx"&gt;policy_type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"TargetTrackingScaling"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_id&lt;/span&gt;
  &lt;span class="nx"&gt;scalable_dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scalable_dimension&lt;/span&gt;
  &lt;span class="nx"&gt;service_namespace&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_namespace&lt;/span&gt;

  &lt;span class="nx"&gt;target_tracking_scaling_policy_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;predefined_metric_specification&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;predefined_metric_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ECSServiceAverageCPUUtilization"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;target_value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;70.0&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. FARGATE_SPOT 활용
&lt;/h3&gt;

&lt;p&gt;상태 없는 서비스(Service-1, Service-2)는 FARGATE_SPOT으로 전환하면 최대 70% 비용 절감이 가능하다. 중단 시 ECS가 자동으로 다른 인스턴스를 띄운다.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Docker 로그 제한 (EC2 운영 시)
&lt;/h3&gt;

&lt;p&gt;정리보다 &lt;strong&gt;애초에 안 쌓이게&lt;/strong&gt; 하는 게 낫다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/etc/docker/daemon.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"log-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"log-opts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"50m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;이 글의 시작은 "서버가 느린데 어떡하지"였다. Claude Code에게 물어보니 진단 → cron 자동화 → 문서화까지 한 세션에서 끝났다.&lt;/p&gt;

&lt;p&gt;특히 도움된 부분:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;코드베이스의 &lt;code&gt;ecosystem.config.js&lt;/code&gt;, &lt;code&gt;docker-compose.yml&lt;/code&gt;, Terraform 설정을 자동으로 분석해서 &lt;strong&gt;프로젝트 스택에 맞는&lt;/strong&gt; 스크립트 생성&lt;/li&gt;
&lt;li&gt;cron 표현식, &lt;code&gt;docker exec&lt;/code&gt; 파이프라인처럼 문법 실수하기 쉬운 명령어를 바로 사용 가능한 형태로 제공&lt;/li&gt;
&lt;li&gt;EC2 vs ECS 비교를 현재 인프라 비용 기준으로 구체적으로 계산&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>aws</category>
      <category>docker</category>
      <category>node</category>
    </item>
    <item>
      <title>소규모 B2B SaaS 팀을 위한 듀얼트랙 애자일 — 2주 사이클 실전 운영기</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Tue, 10 Mar 2026 15:31:28 +0000</pubDate>
      <link>https://dev.to/archist/sogyumo-b2b-saas-timeul-wihan-dyueolteuraeg-aejail-2ju-saikeul-siljeon-unyeonggi-jn3</link>
      <guid>https://dev.to/archist/sogyumo-b2b-saas-timeul-wihan-dyueolteuraeg-aejail-2ju-saikeul-siljeon-unyeonggi-jn3</guid>
      <description>&lt;h1&gt;
  
  
  소규모 B2B SaaS 팀을 위한 듀얼트랙 애자일 — 2주 사이클 실전 운영기
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;스크럼? 칸반? 어느 쪽도 우리 상황에 딱 맞지 않아서 직접 프로세스를 설계했다. 소규모 팀에서 기획과 개발이 서로를 기다리지 않는 구조를 만든 경험을 공유한다.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  왜 직접 만들었나
&lt;/h2&gt;

&lt;p&gt;일반적인 스크럼은 "기획 → 개발 → 배포"가 한 스프린트 안에 순차적으로 일어난다. 문제는 &lt;strong&gt;개발팀이 기획을 기다리는 유휴 시간&lt;/strong&gt;이 생긴다는 것이다. 소규모 팀에서 이 유휴 시간은 치명적이다.&lt;/p&gt;

&lt;p&gt;듀얼트랙 애자일의 핵심은 단순하다: &lt;strong&gt;다음 사이클의 기획(Discovery)과 현재 사이클의 개발(Delivery)을 동시에 돌린다.&lt;/strong&gt; 개발팀이 이번 차수를 만드는 동안, 기획/디자인팀은 다음 차수를 준비한다.&lt;/p&gt;

&lt;h2&gt;
  
  
  2주 사이클 전체 그림
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZ2FudHQKICAgIHRpdGxlIDItV2VlayBEdWFsLVRyYWNrIEN5Y2xlCiAgICBkYXRlRm9ybWF0IFlZWVktTU0tREQKICAgIGV4Y2x1ZGVzIHdlZWtlbmRzCgogICAgc2VjdGlvbiBUcmFjayAxOiBEaXNjb3ZlcnkKICAgIEJhY2tsb2cgR3Jvb21pbmcgICAgICAgICAgICAgICAgIDphMSwgMjAyNi0wMy0wMiwgMmQKICAgIFByaW9yaXR5IFJldmlldyBhbmQgQmx1ZXByaW50ICAgIDphMiwgMjAyNi0wMy0wMywgM2QKICAgIFBSRCBXcml0aW5nICAgICAgICAgICAgICAgICAgICAgIDphMywgMjAyNi0wMy0wNSwgNGQKICAgIERlc2lnbiBhbmQgUHJvdG90eXBpbmcgICAgICAgICAgIDphNCwgMjAyNi0wMy0wNiwgNWQKICAgIFBsYW4gQ29uZmlybWF0aW9uICAgICAgICAgICAgICAgIDptaWxlc3RvbmUsIG0xLCAyMDI2LTAzLTEzLCAwZAoKICAgIHNlY3Rpb24gVHJhY2sgMjogRGVsaXZlcnkKICAgIEtpY2tvZmYgYW5kIFJldHJvc3BlY3RpdmUgICAgICAgIDptaWxlc3RvbmUsIG0yLCAyMDI2LTAzLTAzLCAwZAogICAgRGV2ZWxvcG1lbnQgICAgICAgICAgICAgICAgICAgICAgOmIxLCAyMDI2LTAzLTAyLCA3ZAogICAgUUEgYW5kIFZlcmlmaWNhdGlvbiAgICAgICAgICAgICAgOmIyLCAyMDI2LTAzLTExLCAyZAogICAgUmVsZWFzZSAgICAgICAgICAgICAgICAgICAgICAgICAgOm1pbGVzdG9uZSwgbTMsIDIwMjYtMDMtMTMsIDBkCgogICAgc2VjdGlvbiBBbHdheXMgT24KICAgIEhvdC10cmFjayAgICAgICAgICAgICAgICAgICAgICAgIDpjcml0LCBoMSwgMjAyNi0wMy0wMiwgMTBkCg%3D%3D" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZ2FudHQKICAgIHRpdGxlIDItV2VlayBEdWFsLVRyYWNrIEN5Y2xlCiAgICBkYXRlRm9ybWF0IFlZWVktTU0tREQKICAgIGV4Y2x1ZGVzIHdlZWtlbmRzCgogICAgc2VjdGlvbiBUcmFjayAxOiBEaXNjb3ZlcnkKICAgIEJhY2tsb2cgR3Jvb21pbmcgICAgICAgICAgICAgICAgIDphMSwgMjAyNi0wMy0wMiwgMmQKICAgIFByaW9yaXR5IFJldmlldyBhbmQgQmx1ZXByaW50ICAgIDphMiwgMjAyNi0wMy0wMywgM2QKICAgIFBSRCBXcml0aW5nICAgICAgICAgICAgICAgICAgICAgIDphMywgMjAyNi0wMy0wNSwgNGQKICAgIERlc2lnbiBhbmQgUHJvdG90eXBpbmcgICAgICAgICAgIDphNCwgMjAyNi0wMy0wNiwgNWQKICAgIFBsYW4gQ29uZmlybWF0aW9uICAgICAgICAgICAgICAgIDptaWxlc3RvbmUsIG0xLCAyMDI2LTAzLTEzLCAwZAoKICAgIHNlY3Rpb24gVHJhY2sgMjogRGVsaXZlcnkKICAgIEtpY2tvZmYgYW5kIFJldHJvc3BlY3RpdmUgICAgICAgIDptaWxlc3RvbmUsIG0yLCAyMDI2LTAzLTAzLCAwZAogICAgRGV2ZWxvcG1lbnQgICAgICAgICAgICAgICAgICAgICAgOmIxLCAyMDI2LTAzLTAyLCA3ZAogICAgUUEgYW5kIFZlcmlmaWNhdGlvbiAgICAgICAgICAgICAgOmIyLCAyMDI2LTAzLTExLCAyZAogICAgUmVsZWFzZSAgICAgICAgICAgICAgICAgICAgICAgICAgOm1pbGVzdG9uZSwgbTMsIDIwMjYtMDMtMTMsIDBkCgogICAgc2VjdGlvbiBBbHdheXMgT24KICAgIEhvdC10cmFjayAgICAgICAgICAgICAgICAgICAgICAgIDpjcml0LCBoMSwgMjAyNi0wMy0wMiwgMTBkCg%3D%3D" alt="2-Week Dual-Track Cycle" width="1904" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;두 트랙이 2주 주기로 맞물려 돌아간다. Discovery가 끝나면 그 결과물이 다음 Delivery의 입력이 된다. 파이프라인처럼.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1. Discovery — 다음에 뭘 만들지
&lt;/h2&gt;

&lt;h3&gt;
  
  
  백로그 정제
&lt;/h3&gt;

&lt;p&gt;고객 피드백, 내부 아이디어를 &lt;strong&gt;유저 스토리 단위&lt;/strong&gt;로 정리한다. 이 단계에서 중요한 건 "무엇을"이지 "어떻게"가 아니다. 기술 부채도 별도 섹션으로 관리하면서 매 사이클 리소스를 &lt;strong&gt;강제 할당&lt;/strong&gt;한다. 이걸 안 하면 기능 개발에 밀려서 영원히 처리 안 된다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blueprint &amp;amp; PRD
&lt;/h3&gt;

&lt;p&gt;우선순위가 높은 항목은 아키텍처 영향도를 검토하는 &lt;strong&gt;Blueprint&lt;/strong&gt;를 먼저 만든다. 이 기능이 기존 시스템에 어떻게 붙는지, 어떤 서비스에 변경이 필요한지를 미리 파악하는 것이다. 그 위에 기능 목적과 제약사항을 담은 &lt;strong&gt;PRD&lt;/strong&gt;를 작성한다.&lt;/p&gt;

&lt;h3&gt;
  
  
  디자인 및 프로토타이핑
&lt;/h3&gt;

&lt;p&gt;개발 착수 &lt;strong&gt;전에&lt;/strong&gt; 시각적 결과물을 확정한다. "개발 중 스펙 변경"이 가장 큰 낭비라는 걸 몇 번 겪고 나서 이 단계를 반드시 거치게 만들었다.&lt;/p&gt;

&lt;h3&gt;
  
  
  경영진 컨펌
&lt;/h3&gt;

&lt;p&gt;2주치 작업 계획이 비즈니스 방향과 맞는지 검증하는 게이트. 스타트업 규모라면 이 단계가 빠르게 돌아간다.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2. Delivery — 실제로 만들기
&lt;/h2&gt;

&lt;h3&gt;
  
  
  킥오프 + 회고
&lt;/h3&gt;

&lt;p&gt;사이클 시작일에 두 가지를 동시에 한다:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;이전 사이클 회고&lt;/strong&gt;: Keep/Problem 공유 (길게 안 한다, 15분)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;이번 사이클 킥오프&lt;/strong&gt;: 확정된 스토리 브리핑, 세부 일정 확인&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  개발 → QA → 배포
&lt;/h3&gt;

&lt;p&gt;확정된 스펙에 따라 개발하고, 배포 전 기능 명세 기반 QA를 거쳐 정기 배포한다. 2주마다 예측 가능한 릴리즈가 나간다는 게 핵심이다.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hot-track — 현실은 계획대로 안 된다
&lt;/h2&gt;

&lt;p&gt;정기 사이클만으로는 부족하다. 운영 중인 서비스에서 크리티컬 버그가 터지거나, 고객사에서 긴급 요청이 들어온다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;운영 원칙:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;전체 리소스의 &lt;strong&gt;~20%를 버퍼&lt;/strong&gt;로 확보해둔다&lt;/li&gt;
&lt;li&gt;정기 사이클 배포를 기다릴 수 없는 건만 Hot-track으로 처리&lt;/li&gt;
&lt;li&gt;Hot-track 배포 건은 다음 킥오프에서 함께 리뷰&lt;/li&gt;
&lt;li&gt;발생한 기술 부채는 즉시 백로그에 등록&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;20% 버퍼가 없으면 Hot-track이 정기 사이클을 잡아먹는다. 이건 협상 불가능한 원칙이다.&lt;/p&gt;

&lt;h2&gt;
  
  
  전체 워크플로우
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZ3JhcGggVEQKICAgIHN1YmdyYXBoIFJlZ3VsYXIgMi1XZWVrIEN5Y2xlCiAgICAgICAgQVvslYTsnbTrlJTslrQg67Cc7J2YXSAtLT4gQnvsmrDshKDsiJzsnIQg7ZmV7KCVfQogICAgICAgIEIgLS0%2BIENbQmx1ZXByaW50IGFuZCBQUkQg7J6R7ISxXQogICAgICAgIEMgLS0%2BIERb65SU7J6Q7J24IOuwjyDtlITroZzthqDtg4DsnbTtlZFdCiAgICAgICAgRCAtLT4gRVvssKjsiJgg6rOE7ZqNIOyImOumvV0KICAgICAgICBFIC0tPiBGW%2BqyveyYgeynhCDsu6jtjoxdCiAgICAgICAgRiAtLT4gR1vtgqXsmKTtlIQg67CPIO2ajOqzoF0KICAgICAgICBHIC0tPiBIW%2BqwnOuwnCDrsI8gUUFdCiAgICAgICAgSCAtLT4gSVvsoJXquLAg67Cw7Y%2BsXQogICAgZW5kCgogICAgc3ViZ3JhcGggVGVjaG5pY2FsIERlYnQKICAgICAgICBUMVvsi5zsiqTthZwg7JWI7KCV7ZmUIC8g66as7Yyp7Yao66eBXSAtLS0gQgogICAgZW5kCgogICAgc3ViZ3JhcGggSG90LXRyYWNrCiAgICAgICAgSlvquLTquIkg7JqU6rWs7IKs7ZWtIC8g67KE6re4XSAtLT4gS3vquLTquIkg7Jes67aAIO2MkOuLqH0KICAgICAgICBLIC0tIFlFUyAtLT4gTFtGYXN0LXRyYWNrIOqwnOuwnCDrsI8gUUFdCiAgICAgICAgTCAtLT4gTVvsiJzsi5wg67Cw7Y%2BsXQogICAgICAgIEsgLS0gTk8gLS0%2BIEIKICAgIGVuZAo%3D" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZ3JhcGggVEQKICAgIHN1YmdyYXBoIFJlZ3VsYXIgMi1XZWVrIEN5Y2xlCiAgICAgICAgQVvslYTsnbTrlJTslrQg67Cc7J2YXSAtLT4gQnvsmrDshKDsiJzsnIQg7ZmV7KCVfQogICAgICAgIEIgLS0%2BIENbQmx1ZXByaW50IGFuZCBQUkQg7J6R7ISxXQogICAgICAgIEMgLS0%2BIERb65SU7J6Q7J24IOuwjyDtlITroZzthqDtg4DsnbTtlZFdCiAgICAgICAgRCAtLT4gRVvssKjsiJgg6rOE7ZqNIOyImOumvV0KICAgICAgICBFIC0tPiBGW%2BqyveyYgeynhCDsu6jtjoxdCiAgICAgICAgRiAtLT4gR1vtgqXsmKTtlIQg67CPIO2ajOqzoF0KICAgICAgICBHIC0tPiBIW%2BqwnOuwnCDrsI8gUUFdCiAgICAgICAgSCAtLT4gSVvsoJXquLAg67Cw7Y%2BsXQogICAgZW5kCgogICAgc3ViZ3JhcGggVGVjaG5pY2FsIERlYnQKICAgICAgICBUMVvsi5zsiqTthZwg7JWI7KCV7ZmUIC8g66as7Yyp7Yao66eBXSAtLS0gQgogICAgZW5kCgogICAgc3ViZ3JhcGggSG90LXRyYWNrCiAgICAgICAgSlvquLTquIkg7JqU6rWs7IKs7ZWtIC8g67KE6re4XSAtLT4gS3vquLTquIkg7Jes67aAIO2MkOuLqH0KICAgICAgICBLIC0tIFlFUyAtLT4gTFtGYXN0LXRyYWNrIOqwnOuwnCDrsI8gUUFdCiAgICAgICAgTCAtLT4gTVvsiJzsi5wg67Cw7Y%2BsXQogICAgICAgIEsgLS0gTk8gLS0%2BIEIKICAgIGVuZAo%3D" alt="Product Workflow" width="970" height="1360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  티켓 관리 — 보드 상태 설계
&lt;/h2&gt;

&lt;p&gt;프로세스가 있어도 티켓 상태가 이를 반영하지 않으면 무용지물이다. 듀얼트랙에 맞춰 보드 상태를 설계했다.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZ3JhcGggTFIKICAgIEFbQkFDS0xPR10gLS0%2BIEJbR1JPT01FRF0KICAgIEIgLS0%2BIENbUFJJT1JJVElaRURdCiAgICBDIC0tPiBEW1NQRUNfUkVBRFldCiAgICBEIC0tPiBFW0NPTU1JVFRFRF0KICAgIEUgLS0%2BIEZbSU5fUFJPR1JFU1NdCiAgICBGIC0tPiBHW0lOX1JFVklFV10KICAgIEcgLS0%2BIEhbUUFdCiAgICBIIC0tPiBJW0RPTkVdCg%3D%3D" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZ3JhcGggTFIKICAgIEFbQkFDS0xPR10gLS0%2BIEJbR1JPT01FRF0KICAgIEIgLS0%2BIENbUFJJT1JJVElaRURdCiAgICBDIC0tPiBEW1NQRUNfUkVBRFldCiAgICBEIC0tPiBFW0NPTU1JVFRFRF0KICAgIEUgLS0%2BIEZbSU5fUFJPR1JFU1NdCiAgICBGIC0tPiBHW0lOX1JFVklFV10KICAgIEcgLS0%2BIEhbUUFdCiAgICBIIC0tPiBJW0RPTkVdCg%3D%3D" alt="Board Status Flow" width="1697" height="70"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;진입 조건&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BACKLOG&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;티켓 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GROOMED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Background/Overview 작성 완료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PRIORITIZED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;P0~P3 우선순위 라벨 부여&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SPEC_READY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PRD + 디자인 완료 (P0/P1 대상)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commitment&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COMMITTED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;차수 계획 + 경영진 컨펌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IN_PROGRESS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;개발자 착수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IN_REVIEW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PR 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;QA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;리뷰 승인 완료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DONE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;QA 통과 + 배포&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;핵심은 &lt;code&gt;SPEC_READY&lt;/code&gt;와 &lt;code&gt;COMMITTED&lt;/code&gt; 사이의 게이트다. Discovery에서 스펙이 완성되지 않은 티켓은 Delivery에 넘어갈 수 없다. 이 경계가 "개발 중 스펙 변경"을 막아준다.&lt;/p&gt;

&lt;h3&gt;
  
  
  우선순위 기준
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;우선순위&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;기획 요구사항&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;다음 사이클 필수&lt;/td&gt;
&lt;td&gt;PRD, 디자인 필수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;다음 사이클 선택&lt;/td&gt;
&lt;td&gt;PRD, 디자인 가능한 만큼 (70% 이상 완료 지향)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;P2&lt;/strong&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;strong&gt;P3&lt;/strong&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;/div&gt;

&lt;p&gt;P0/P1은 Commitment 단계에서 실제 포함 여부가 결정된다. P2/P3는 주기적으로 재검토해서 승격하거나 폐기한다.&lt;/p&gt;

&lt;h2&gt;
  
  
  핵심 관리 지표
&lt;/h2&gt;

&lt;p&gt;모니터링하는 지표는 3개뿐이다:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;계획 준수율&lt;/strong&gt;: 이번 사이클에 계획한 스토리가 다 배포됐는가?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;기술 부채 해결 비율&lt;/strong&gt;: 안정화 작업에 리소스가 적절히 할당되고 있는가?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hot-track 빈도&lt;/strong&gt;: 긴급 배포가 정기 사이클의 예측 가능성을 해치고 있지 않은가?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;지표가 많으면 아무도 안 본다. 3개만 추적하되 매 사이클 회고에서 반드시 리뷰한다.&lt;/p&gt;

&lt;h2&gt;
  
  
  운영하면서 느낀 점
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;잘 되는 것:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;개발팀이 기획을 기다리는 유휴 시간이 거의 없다&lt;/li&gt;
&lt;li&gt;2주마다 예측 가능한 배포가 나간다는 안정감&lt;/li&gt;
&lt;li&gt;Hot-track 20% 버퍼 덕분에 긴급 대응에도 정기 사이클이 안 무너진다&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;아직 고민인 것:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discovery와 Delivery 간 핸드오프 품질이 들쑥날쑥하다. PRD의 완성도 차이가 크다&lt;/li&gt;
&lt;li&gt;기술 부채 리소스 "강제 할당"이 바쁜 시기에 지켜지기 어렵다&lt;/li&gt;
&lt;li&gt;소규모 팀이라 한 명이 빠져도 사이클 전체가 흔들린다&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;완벽한 프로세스는 없다. 중요한 건 &lt;strong&gt;2주마다 회고하면서 프로세스 자체를 개선&lt;/strong&gt;한다는 점이다. 프로세스도 제품처럼 반복 개선의 대상이다.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;이 프로세스 문서 자체를 Claude Code로 구조화했다. "우리 팀이 실제로 하고 있는 일"을 설명하면 Mermaid 다이어그램과 테이블로 시각화해주고, 빠진 단계나 모호한 기준을 지적해줘서 프로세스를 더 명확하게 다듬을 수 있었다.&lt;/p&gt;

</description>
      <category>agile</category>
      <category>startup</category>
      <category>projectmanagement</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Terraform으로 AWS Managed Grafana 모니터링 체계 구축하기: MSA 6개 서비스를 한 눈에</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Tue, 10 Mar 2026 15:26:28 +0000</pubDate>
      <link>https://dev.to/archist/terraformeuro-aws-managed-grafana-moniteoring-cegye-gucughagi-msa-6gae-seobiseureul-han-nune-gj8</link>
      <guid>https://dev.to/archist/terraformeuro-aws-managed-grafana-moniteoring-cegye-gucughagi-msa-6gae-seobiseureul-han-nune-gj8</guid>
      <description>&lt;h2&gt;
  
  
  문제: 서비스가 6개인데 모니터링이 없다
&lt;/h2&gt;

&lt;p&gt;MSA로 전환한 뒤 가장 먼저 부딪힌 현실이 있다. &lt;strong&gt;"지금 어떤 서비스가 문제인지 모른다."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gateway, AI 처리, 문서 생성, 데이터 파이프라인, 관리자 대시보드, 레거시 서비스 — 6개 ECS 서비스가 돌아가고, 각각이 Aurora PostgreSQL, Redis, ALB를 공유한다. 장애가 나면 AWS 콘솔에서 CloudWatch → ECS → RDS → ElastiCache를 돌아다니며 원인을 찾아야 했다. 서비스 하나 확인하는 데 5분, 전체 파악에 30분.&lt;/p&gt;

&lt;p&gt;"대시보드 하나에서 전부 보고 싶다." 이 단순한 요구가 Grafana 도입의 시작이었다.&lt;/p&gt;

&lt;h2&gt;
  
  
  왜 AWS Managed Grafana인가
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;선택지&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch 대시보드만&lt;/td&gt;
&lt;td&gt;추가 비용 없음&lt;/td&gt;
&lt;td&gt;커스터마이징 한계, 대시보드 간 이동 불편&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;자체 호스팅 Grafana&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;strong&gt;AWS Managed Grafana&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;관리 부담 없음 + SSO 연동&lt;/td&gt;
&lt;td&gt;월 비용 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datadog/New Relic&lt;/td&gt;
&lt;td&gt;강력한 APM&lt;/td&gt;
&lt;td&gt;비용이 서비스 수에 비례해서 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;5명 팀에서 Grafana 서버를 직접 운영하는 건 과하다. AWS Managed Grafana는 SSO 인증, 데이터 소스 연결, 업그레이드를 AWS가 처리하므로 &lt;strong&gt;대시보드 설계에만 집중&lt;/strong&gt;할 수 있다.&lt;/p&gt;

&lt;p&gt;결정적으로, CloudWatch와 X-Ray를 네이티브로 지원한다. 별도 에이전트 없이 기존 AWS 메트릭을 그대로 시각화할 수 있다.&lt;/p&gt;

&lt;h2&gt;
  
  
  Terraform으로 전체 구성하기
&lt;/h2&gt;

&lt;p&gt;모니터링 인프라도 코드로 관리한다. Grafana 워크스페이스 + IAM 역할 + 대시보드 JSON을 Terraform으로 프로비저닝한다.&lt;/p&gt;

&lt;h3&gt;
  
  
  워크스페이스 + IAM
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Grafana 워크스페이스&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_grafana_workspace"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project}-${var.environment}"&lt;/span&gt;
  &lt;span class="nx"&gt;account_access_type&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CURRENT_ACCOUNT"&lt;/span&gt;
  &lt;span class="nx"&gt;authentication_providers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"AWS_SSO"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# IAM Identity Center 연동&lt;/span&gt;
  &lt;span class="nx"&gt;permission_type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SERVICE_MANAGED"&lt;/span&gt;

  &lt;span class="nx"&gt;configuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;unifiedAlerting&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;plugins&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pluginAdminEnabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# 보안: 플러그인 설치 차단&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;data_sources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CLOUDWATCH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"XRAY"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grafana가 AWS 리소스의 메트릭을 읽으려면 IAM 역할이 필요하다. &lt;strong&gt;최소 권한 원칙&lt;/strong&gt;을 지키면서도 필요한 서비스를 모두 커버해야 한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"grafana"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project}-grafana-role"&lt;/span&gt;

  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"grafana.amazonaws.com"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;
      &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;StringEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"aws:SourceAccount"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_caller_identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# 읽기 전용 정책 — 메트릭 조회만 허용&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"grafana_permissions"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grafana&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
        &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="c1"&gt;# CloudWatch 메트릭&lt;/span&gt;
          &lt;span class="s2"&gt;"cloudwatch:DescribeAlarms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"cloudwatch:GetMetricData"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"cloudwatch:GetMetricStatistics"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"cloudwatch:ListMetrics"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="c1"&gt;# CloudWatch Logs&lt;/span&gt;
          &lt;span class="s2"&gt;"logs:DescribeLogGroups"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"logs:GetQueryResults"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"logs:StartQuery"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"logs:StopQuery"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="c1"&gt;# ECS 서비스 상태&lt;/span&gt;
          &lt;span class="s2"&gt;"ecs:ListClusters"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"ecs:DescribeServices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"ecs:ListTasks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="c1"&gt;# RDS + Redis&lt;/span&gt;
          &lt;span class="s2"&gt;"rds:DescribeDBClusters"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"elasticache:DescribeCacheClusters"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="c1"&gt;# ALB&lt;/span&gt;
          &lt;span class="s2"&gt;"elasticloadbalancing:DescribeTargetHealth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="c1"&gt;# X-Ray 트레이싱&lt;/span&gt;
          &lt;span class="s2"&gt;"xray:BatchGetTraces"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"xray:GetServiceGraph"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"xray:GetTimeSeriesServiceStatistics"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SourceAccount&lt;/code&gt; 조건으로 다른 AWS 계정에서 이 역할을 사용하는 것을 방지한다. Grafana 서비스 프린시펄만 assume 가능하다.&lt;/p&gt;

&lt;h2&gt;
  
  
  대시보드 설계 — 7개 대시보드 체계
&lt;/h2&gt;

&lt;p&gt;대시보드를 용도별로 나눴다. "하나의 대시보드에 전부 넣기" 유혹을 이기고, &lt;strong&gt;관심사 분리&lt;/strong&gt;를 적용했다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;📊 대시보드 체계
├── API Overview          # 전체 서비스 한 눈에 (첫 화면)
├── ALB Traffic           # 트래픽 패턴 + 에러율
├── ECS Metrics           # 서비스별 CPU/메모리 상세
├── Aurora DB             # 데이터베이스 성능
├── Redis Cache           # 캐시 히트율 + 메모리
├── EC2 Infrastructure    # 인스턴스 레벨 메트릭
└── ECS Logs Explorer     # 로그 검색 + 에러 추적
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;장애 시 동선: &lt;strong&gt;API Overview에서 이상 감지 → 해당 영역 대시보드로 드릴다운&lt;/strong&gt; → 5분 내 원인 파악.&lt;/p&gt;

&lt;h3&gt;
  
  
  API Overview — 운영의 첫 화면
&lt;/h3&gt;

&lt;p&gt;가장 중요한 대시보드다. 한 화면에서 전체 시스템의 건강 상태를 파악한다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;서비스&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;상태&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;카드&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;떠있는지&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;죽었는지&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;즉시&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;확인&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Gateway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fieldConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"defaults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"thresholds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"red"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;DOWN&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"green"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;UP&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"targets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ECS/ContainerInsights"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"metricName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RunningTaskCount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dimensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ClusterName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-cluster"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ServiceName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gateway"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"60"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Average"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;6개 서비스의 상태 카드가 한 줄에 나란히 표시된다. &lt;strong&gt;빨간색이 보이면 즉시 대응&lt;/strong&gt;할 수 있다.&lt;/p&gt;

&lt;p&gt;그 아래로:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;총 요청 수&lt;/strong&gt; (ALB RequestCount, 1분 합계)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;응답 시간&lt;/strong&gt; (Average + p99, 0.5초 노란색 / 1초 빨간색 임계치)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;서비스별 CPU/메모리&lt;/strong&gt; (8시간 트렌드, 70% 주의 / 90% 위험)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;인프라 요약&lt;/strong&gt; (Aurora CPU, DB 커넥션 수, Redis 메모리, 5xx 에러)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ALB Traffic — 트래픽 패턴 읽기
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;xx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;에러&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;바&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;차트로&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;스파이크&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;즉시&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;감지&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"barchart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5XX Errors"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fieldConfig"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"defaults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fixedColor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"red"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fixed"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"targets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"namespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AWS/ApplicationELB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"metricName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HTTPCode_ELB_5XX_Count"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"60"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;트래픽 대시보드에서 가장 중요한 건 &lt;strong&gt;5xx 에러 바 차트&lt;/strong&gt;다. 타임시리즈보다 바 차트가 스파이크를 잡아내기 좋다. 평소에 막대가 없다가 빨간 막대가 하나라도 나타나면 즉시 눈에 띈다.&lt;/p&gt;

&lt;p&gt;서비스별 Healthy Host Count 카드도 배치했다. 배포 중에 한 서비스의 호스트가 0이 되면 즉시 알 수 있다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Aurora DB — 데이터베이스 병목 추적
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Read/Write&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Latency&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;분리&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;어디서&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;느린지&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;파악&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Read / Write Latency"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"targets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"metricName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ReadLatency"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"stat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Average"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"60"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"metricName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WriteLatency"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"stat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Average"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"period"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"60"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DB 대시보드의 핵심은 &lt;strong&gt;Read/Write Latency 분리&lt;/strong&gt;와 &lt;strong&gt;커넥션 수 추적&lt;/strong&gt;이다. 멀티테넌트 환경에서 테넌트가 추가될 때마다 커넥션이 늘어나는데, 80개 임계치에 접근하면 알람이 울린다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redis Cache — 캐시 효율 모니터링
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;캐시&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;히트율&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;계산&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Grafana&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;표현식&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;활용&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cache Hit Rate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"targets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hits"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"metricName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CacheHits"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"stat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sum"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"misses"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"metricName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CacheMisses"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"stat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sum"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expression"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IF(hits + misses &amp;gt; 0, hits / (hits + misses) * 100, 0)"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CloudWatch에는 "캐시 히트율" 메트릭이 없다. Hits와 Misses를 가져와서 &lt;strong&gt;Grafana Math Expression으로 직접 계산&lt;/strong&gt;한다. 0 나누기 방지를 위한 &lt;code&gt;IF&lt;/code&gt; 조건도 포함.&lt;/p&gt;

&lt;p&gt;히트율이 90% 이하로 떨어지면 캐시 키 전략을 재검토해야 한다는 신호다.&lt;/p&gt;

&lt;h3&gt;
  
  
  ECS Logs Explorer — 에러 추적의 핵심
&lt;/h3&gt;

&lt;p&gt;이 대시보드가 장애 대응에서 가장 많이 쓰인다. CloudWatch Logs Insights 쿼리를 Grafana 패널에 내장했다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// 5분 단위 에러 카운트 — 스파이크 감지
fields @timestamp, @message
| filter @message like /(?i)(error|exception|fail)/
| stats count() as error_count by bin(5m)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// 최근 에러 로그 100건 — 원인 파악
fields @timestamp, @message, @logStream
| filter @message like /(?i)(error|exception|fail|critical)/
| sort @timestamp desc
| limit 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;서비스 선택 드롭다운&lt;/strong&gt;과 &lt;strong&gt;키워드 검색&lt;/strong&gt; 변수를 추가해서, 특정 서비스의 특정 에러를 바로 필터링할 수 있다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"templating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"list"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"custom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Gateway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/ecs/my-project-gateway"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Service A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/ecs/my-project-service-a"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Service B"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/ecs/my-project-service-b"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search_keyword"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"textbox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"current"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  CloudWatch 알람 + Slack 연동
&lt;/h2&gt;

&lt;p&gt;Grafana 대시보드는 &lt;strong&gt;사후 분석&lt;/strong&gt;에 강하다. &lt;strong&gt;사전 감지&lt;/strong&gt;는 CloudWatch Alarm이 담당한다. 15개 이상의 알람을 Terraform으로 관리한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ECS 서비스별 CPU 알람 (동적 생성)&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_metric_alarm"&lt;/span&gt; &lt;span class="s2"&gt;"ecs_cpu"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;  &lt;span class="c1"&gt;# 6개 서비스 자동 생성&lt;/span&gt;

  &lt;span class="nx"&gt;alarm_name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${each.key}-high-cpu"&lt;/span&gt;
  &lt;span class="nx"&gt;comparison_operator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GreaterThanThreshold"&lt;/span&gt;
  &lt;span class="nx"&gt;evaluation_periods&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="nx"&gt;metric_name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CPUUtilization"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS/ECS"&lt;/span&gt;
  &lt;span class="nx"&gt;period&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;      &lt;span class="c1"&gt;# 5분&lt;/span&gt;
  &lt;span class="nx"&gt;statistic&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Average"&lt;/span&gt;
  &lt;span class="nx"&gt;threshold&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;       &lt;span class="c1"&gt;# 75% 초과 시&lt;/span&gt;
  &lt;span class="nx"&gt;treat_missing_data&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"notBreaching"&lt;/span&gt;

  &lt;span class="nx"&gt;dimensions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ClusterName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
    &lt;span class="nx"&gt;ServiceName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;alarm_actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sns_topic_arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# Slack 알림&lt;/span&gt;
  &lt;span class="nx"&gt;ok_actions&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sns_topic_arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# 복구 알림도 전송&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# RDS 커넥션 수 알람&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_metric_alarm"&lt;/span&gt; &lt;span class="s2"&gt;"rds_connections"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rds-high-connections"&lt;/span&gt;
  &lt;span class="nx"&gt;threshold&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;  &lt;span class="c1"&gt;# 멀티테넌트 환경에서 커넥션 폭증 감지&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Redis 메모리 알람&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_metric_alarm"&lt;/span&gt; &lt;span class="s2"&gt;"redis_memory"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"redis-high-memory"&lt;/span&gt;
  &lt;span class="nx"&gt;threshold&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;  &lt;span class="c1"&gt;# 70% 초과 시 경고&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;핵심 설계 결정:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;for_each&lt;/code&gt;로 서비스 추가 시 알람 자동 생성. 수동으로 알람을 만들면 반드시 빠뜨린다.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ok_actions&lt;/code&gt;에도 SNS를 연결해서 &lt;strong&gt;복구 알림&lt;/strong&gt;도 받는다. "장애 발생" 알림만 오고 "복구됨" 알림이 없으면 불안하다.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;treat_missing_data = "notBreaching"&lt;/code&gt;으로 데이터 없음을 정상으로 취급. 배포 중 메트릭이 잠시 빈다고 알람이 울리면 피로도만 높아진다.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;알람 → SNS → Lambda → Slack Webhook 흐름으로 Slack 채널에 실시간 알림이 온다.&lt;/p&gt;

&lt;h2&gt;
  
  
  로그 분리 라우팅 — 에러만 따로 모으기
&lt;/h2&gt;

&lt;p&gt;6개 서비스의 전체 로그를 뒤지는 건 비효율적이다. &lt;strong&gt;에러 로그만 별도 그룹으로 분기&lt;/strong&gt;하는 로그 라우터를 구성했다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 서비스별 일반 로그 + 에러 전용 로그&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_log_group"&lt;/span&gt; &lt;span class="s2"&gt;"service"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/ecs/${each.key}"&lt;/span&gt;
  &lt;span class="nx"&gt;retention_in_days&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_log_group"&lt;/span&gt; &lt;span class="s2"&gt;"error"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/${var.project}/${each.key}/error"&lt;/span&gt;
  &lt;span class="nx"&gt;retention_in_days&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# error/warn 레벨만 에러 그룹으로 분기&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_log_subscription_filter"&lt;/span&gt; &lt;span class="s2"&gt;"error_filter"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${each.key}-error-filter"&lt;/span&gt;
  &lt;span class="nx"&gt;log_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/ecs/${each.key}"&lt;/span&gt;
  &lt;span class="nx"&gt;filter_pattern&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"?ERROR ?WARN ?error ?warn"&lt;/span&gt;
  &lt;span class="nx"&gt;destination_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log_router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;효과: Grafana Logs Explorer에서 에러 전용 로그 그룹을 선택하면 &lt;strong&gt;노이즈 없이 에러만&lt;/strong&gt; 볼 수 있다. 장애 시 원인 파악 시간이 체감상 절반으로 줄었다.&lt;/p&gt;

&lt;h2&gt;
  
  
  아쉬웠던 점
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;대시보드 JSON 관리가 번거롭다.&lt;/strong&gt; Grafana UI에서 대시보드를 수정한 뒤 JSON을 export해서 Terraform에 반영하는 과정이 수동이다. Grafana API + CI로 자동화하고 싶지만 아직 못 했다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudWatch 메트릭의 1분 해상도 한계.&lt;/strong&gt; 순간적인 스파이크는 1분 평균에 묻힌다. 커스텀 메트릭으로 초 단위 데이터를 보내면 비용이 급증한다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;서비스 간 호출 추적이 부족하다.&lt;/strong&gt; X-Ray 데이터 소스를 연결해놨지만, NestJS TCP 통신에 X-Ray를 제대로 붙이려면 커스텀 미들웨어가 필요하다. 현재는 서비스별 메트릭만 볼 수 있고 호출 체인을 시각화하진 못한다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;알람 피로도.&lt;/strong&gt; 초기에 임계치를 낮게 잡았더니 하루에 알람이 10개씩 왔다. 임계치를 올리니 진짜 장애를 놓칠까 불안하다. 적정선을 찾는 게 예상보다 어렵다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  향후 보완할 점
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grafana Dashboard as Code 자동화&lt;/strong&gt;: 대시보드 변경을 Grafana API로 export → Terraform JSON 자동 동기화&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry 연동&lt;/strong&gt;: AsyncLocalStorage 기반 분산 트레이싱을 OTEL로 전환, Grafana Tempo와 연결하여 서비스 간 호출 체인 시각화&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;비즈니스 메트릭 대시보드 추가&lt;/strong&gt;: 인프라 메트릭 외에 "분당 처리 건수", "AI 추론 성공률" 같은 비즈니스 KPI 패널&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;알람 정책 고도화&lt;/strong&gt;: 정적 임계치 → 이상 탐지(Anomaly Detection) 기반 동적 알람. CloudWatch Anomaly Detection을 활용하면 "평소 대비 비정상" 패턴을 자동 감지할 수 있다&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  배운 점
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;대시보드는 관심사별로 분리해야 한다.&lt;/strong&gt; 하나에 다 넣으면 스크롤 지옥이 된다. Overview → 드릴다운 구조가 장애 대응에 가장 효과적이다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform으로 알람을 관리하면 빠뜨림이 없다.&lt;/strong&gt; &lt;code&gt;for_each&lt;/code&gt;로 서비스 추가 시 알람이 자동 생성되므로 "새 서비스 올렸는데 모니터링이 없었다"는 사고가 발생하지 않는다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;에러 로그 분리는 필수다.&lt;/strong&gt; 전체 로그에서 에러를 grep하는 것과, 에러만 모인 그룹을 조회하는 것은 장애 시 체감 차이가 크다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;캐시 히트율처럼 CloudWatch에 없는 메트릭은 Grafana Expression으로 만들 수 있다.&lt;/strong&gt; Math Expression을 적극 활용하면 커스텀 메트릭 비용 없이 파생 지표를 만들 수 있다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ok_actions&lt;/code&gt; 설정을 잊지 마라.&lt;/strong&gt; 장애 알림만 오고 복구 알림이 없으면 팀의 불안감이 불필요하게 높아진다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;7개 대시보드의 JSON 정의를 Claude Code로 생성했다. "ECS 6개 서비스의 CPU/메모리를 한 패널에, 임계치는 70%/90%로"라고 요청하면 Grafana JSON 스펙에 맞는 패널 정의가 나온다. 특히 Math Expression 문법(Cache Hit Rate 계산)이나 CloudWatch Logs Insights 쿼리 작성에서 AI가 시행착오를 크게 줄여줬다.&lt;/p&gt;

</description>
      <category>grafana</category>
      <category>aws</category>
      <category>monitoring</category>
      <category>terraform</category>
    </item>
    <item>
      <title>NestJS MSA Lite 실전 아키텍처 (3/3) — 운영, 배포, 그리고 진화</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Tue, 10 Mar 2026 15:17:17 +0000</pubDate>
      <link>https://dev.to/archist/nestjs-msa-lite-siljeon-akitegceo-33-unyeong-baepo-geurigo-jinhwa-1d1l</link>
      <guid>https://dev.to/archist/nestjs-msa-lite-siljeon-akitegceo-33-unyeong-baepo-geurigo-jinhwa-1d1l</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;여러 레포에 흩어져 있던 B2B SaaS 서비스를 MSA Lite 모노레포로 통합한 경험을 정리한 시리즈의 마지막 글이다.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: 서비스 구조와 통신 설계&lt;/li&gt;
&lt;li&gt;Part 2: 데이터 레이어와 비동기 처리&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3: 운영, 배포, 그리고 진화&lt;/strong&gt; (이 글)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Terraform + ECS Fargate — 백엔드 2명의 인프라 선택
&lt;/h2&gt;

&lt;p&gt;MSA를 도입하면 배포 단위가 늘어난다. 우리는 5개 서비스(Gateway, AI 처리, 문서 생성, 데이터 파이프라인, 관리자 대시보드)를 운영하고 있다. 이걸 수동으로 관리하면 인프라 변경 하나에 반나절이 날아간다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terraform&lt;/strong&gt;: 인프라 변경 이력이 Git에 남고, &lt;code&gt;plan&lt;/code&gt; → &lt;code&gt;apply&lt;/code&gt; 2단계로 실수를 사전 차단한다.&lt;br&gt;
&lt;strong&gt;ECS Fargate&lt;/strong&gt;: EC2 인스턴스 관리 부담 없이 서비스별 CPU/메모리를 독립 설정 가능. Kubernetes보다 운영 복잡도가 낮다.&lt;/p&gt;
&lt;h3&gt;
  
  
  모듈 구조
&lt;/h3&gt;

&lt;p&gt;14개 모듈로 인프라 전체를 관리한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terraform/
├── environments/prod/
│   ├── main.tf                 # 모듈 조합 + 서비스별 설정
│   ├── variables.tf
│   ├── backend.tf              # S3 + DynamoDB 상태 관리
│   └── task-definitions/       # ECS 태스크 정의 템플릿
└── modules/
    ├── alb/                    # Application Load Balancer
    ├── alerting/               # CloudWatch 알람 + Slack 알림
    ├── cloudwatch-dashboard/   # 통합 모니터링 대시보드
    ├── codedeploy/             # Blue/Green 배포 자동화
    ├── ecs-cluster/            # ECS 클러스터 + Service Discovery
    ├── ecs-service/            # ECS Fargate 서비스 정의
    ├── grafana/                # AWS Managed Grafana
    ├── iam/                    # IAM 역할 + 정책
    ├── log-router/             # 에러 로그 분리 라우팅
    ├── network/                # 보안 그룹
    ├── secrets/                # Secrets Manager
    └── waf/                    # Web Application Firewall
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;핵심 설계 원칙: &lt;strong&gt;하나의 모듈은 하나의 인프라 관심사만 담당한다.&lt;/strong&gt; 모듈 간 의존성은 &lt;code&gt;main.tf&lt;/code&gt;에서 output → input으로 연결한다.&lt;/p&gt;

&lt;h3&gt;
  
  
  서비스별 리소스 설계
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;서비스&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;th&gt;배포 전략&lt;/th&gt;
&lt;th&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gateway&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;1024MB&lt;/td&gt;
&lt;td&gt;Blue/Green&lt;/td&gt;
&lt;td&gt;HTTP 진입점, SSE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service A (AI)&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;2048MB&lt;/td&gt;
&lt;td&gt;Rolling&lt;/td&gt;
&lt;td&gt;AI 추론 → 높은 리소스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service B (문서)&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;1024MB&lt;/td&gt;
&lt;td&gt;Rolling&lt;/td&gt;
&lt;td&gt;PDF 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service C (데이터)&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;1024MB&lt;/td&gt;
&lt;td&gt;Blue/Green&lt;/td&gt;
&lt;td&gt;ETL + MCP 서버&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin Dashboard&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;512MB&lt;/td&gt;
&lt;td&gt;Rolling&lt;/td&gt;
&lt;td&gt;React SPA&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Service A가 다른 서비스의 2배 리소스를 쓴다. 외부 AI 서버와 스트리밍 통신하면서 후처리하는 작업이 CPU/메모리를 잡아먹기 때문이다. Fargate의 장점이 여기서 빛난다 — 서비스별로 리소스를 독립 조정할 수 있다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Service Discovery — TCP 통신의 핵심
&lt;/h3&gt;

&lt;p&gt;ECS 서비스 간 TCP 통신에 &lt;strong&gt;AWS Cloud Map&lt;/strong&gt;을 사용한다. 하드코딩된 IP 대신 DNS 기반으로 동적 연결한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// NestJS TCP 클라이언트 설정&lt;/span&gt;
&lt;span class="nx"&gt;ClientsModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ServiceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SERVICE_A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TCP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;service-a.my-project.local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// Cloud Map DNS&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;배포 시 태스크가 교체되면 Cloud Map이 자동으로 DNS 레코드를 업데이트한다. TTL 10초이므로 최대 10초 내에 새 태스크로 전환된다.&lt;/p&gt;

&lt;h3&gt;
  
  
  보안 계층
&lt;/h3&gt;

&lt;p&gt;3중 보안으로 인프라를 보호한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;인터넷 → WAF (Rate Limiting) → ALB → 보안 그룹 → ECS 태스크
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WAF&lt;/strong&gt;: 5분간 2000 요청 초과 시 IP 차단&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;보안 그룹&lt;/strong&gt;: 마이크로서비스 포트는 개발 서버 + 사무실 IP만 허용&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets Manager&lt;/strong&gt;: 환경 변수에 시크릿 하드코딩 없이 ECS가 런타임에 주입&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  무중단 배포 — Expand-Contract 패턴
&lt;/h2&gt;

&lt;p&gt;Blue/Green 배포 중 Gateway(v2)와 Microservice(v1)가 공존하는 순간이 있다. MessagePattern이나 DTO를 함부로 바꾸면 이 순간에 장애가 발생한다.&lt;/p&gt;

&lt;h3&gt;
  
  
  DTO 변경 규칙
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad: 기존 호출자가 mode를 안 보내면 validation 실패&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Good: 기존 호출자가 없어도 기본값으로 동작&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsOptional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;변경 유형&lt;/th&gt;
&lt;th&gt;허용 여부&lt;/th&gt;
&lt;th&gt;조건&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request에 필드 추가&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@IsOptional()&lt;/code&gt; + 기본값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request에서 필드 제거&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;2단계 배포 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response에 필드 추가&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;호출자가 모르는 필드는 무시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response에서 필드 제거&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;2단계 배포 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  MessagePattern 변경: 3단계 배포
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stage 1 (Expand):   마이크로서비스에 신규 패턴 추가, 기존 패턴 유지
Stage 2 (Migrate):  Gateway가 신규 패턴 사용으로 전환
Stage 3 (Contract): 기존 패턴 제거
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 규칙 덕분에 1년 넘게 배포 중 서비스 간 통신 장애가 &lt;strong&gt;0건&lt;/strong&gt;이다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blue/Green vs Rolling Update
&lt;/h3&gt;

&lt;p&gt;외부 노출 서비스(Gateway, 데이터 파이프라인)는 &lt;strong&gt;CodeDeploy Blue/Green&lt;/strong&gt;으로 트래픽을 즉시 전환한다. 내부 서비스(AI 처리, 문서 생성)는 &lt;strong&gt;Rolling Update&lt;/strong&gt;로 리소스를 절약한다. 실패 시 Blue/Green은 자동 롤백, Rolling은 이전 Task Definition으로 수동 복구한다.&lt;/p&gt;

&lt;h3&gt;
  
  
  PM2 무중단 배포
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ecosystem.config.js&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service-a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./dist/main.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;exec_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fork&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// SSE 연결 유지를 위해 cluster 아닌 fork&lt;/span&gt;
  &lt;span class="nx"&gt;wait_ready&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// process.send("ready") 대기&lt;/span&gt;
  &lt;span class="nx"&gt;listen_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;kill_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NestJS 부트스트랩 완료 후 &lt;code&gt;process.send("ready")&lt;/code&gt;를 호출하면 PM2가 트래픽을 새 프로세스로 전환한다. 기존 프로세스는 5초 유예 후 종료.&lt;/p&gt;

&lt;h2&gt;
  
  
  테스트 전략 — jest.fn() 없는 세계
&lt;/h2&gt;

&lt;p&gt;MSA에서 테스트가 어려운 이유는 서비스 간 의존성이 복잡하기 때문이다. &lt;strong&gt;Fake 구현체 패턴&lt;/strong&gt;으로 이 문제를 풀었다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│  E2E (Cucumber BDD, 8 병렬 워커)             │
│  "사용자 시나리오가 동작하는가?"                │
├─────────────────────────────────────────────┤
│  Integration (InMemory DB + Fake 구현체)     │
│  "Service → Repository 쿼리가 맞는가?"       │
├─────────────────────────────────────────────┤
│  Unit (Component / 순수 함수)                │
│  "이 판단/변환 로직이 맞는가?"                │ ← 여기를 최대한 늘린다
└─────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  jest.fn() 금지, Fake 구현체 사용
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// jest.fn() — 타입 안전성 없음, 인터페이스 변경 시 조용히 통과&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mockDb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;getAgentRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mockResolvedValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockRepo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockDb&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// as any 필수&lt;/span&gt;

&lt;span class="c1"&gt;// Fake 구현체 — 컴파일 타임 검증, 실제 동작&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FakeAgentDatabaseService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DataSource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;getAgentRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;_tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EntityTarget&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Repository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// InMemory DB&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fake는 &lt;code&gt;implements&lt;/code&gt; 키워드로 인터페이스를 구현하므로, 원본 인터페이스가 변경되면 &lt;strong&gt;컴파일 에러가 발생&lt;/strong&gt;한다. &lt;code&gt;jest.fn()&lt;/code&gt;은 &lt;code&gt;as any&lt;/code&gt;로 타입을 우회하기 때문에 이런 보호가 없다.&lt;/p&gt;

&lt;h3&gt;
  
  
  공유 Fake 패키지
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// @myorg/infrastructure/testing&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FakeAgentDatabaseService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createInMemoryDataSource&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NoOpLogger&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FakeS3Service&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FakeConfigService&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FakeProducer&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;한 번 만들면 프로젝트 전체에서 재사용한다. 레포가 분리되어 있었을 때는 각 레포에 비슷한 Fake를 따로 만들어야 했지만, 모노레포 통합 후에는 한 곳에서 관리하고 모든 서비스가 동일한 테스트 인프라를 쓴다. Fake 자체도 테스트 자산으로 축적된다.&lt;/p&gt;

&lt;h3&gt;
  
  
  단위 테스트: mock 0개
&lt;/h3&gt;

&lt;p&gt;Usecase에서 순수 로직을 Component로 추출하면, NestJS DI 없이 &lt;code&gt;new&lt;/code&gt;로 생성해서 테스트할 수 있다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;StatusComponent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StatusComponent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// DI 불필요&lt;/span&gt;

  &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;READY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SENT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FAILED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;%s 상태이면 재처리 가능하다&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderEntity&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validateCanRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toThrow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  모니터링 — CloudWatch + Grafana + Slack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  에러 로그 분리
&lt;/h3&gt;

&lt;p&gt;일반 로그와 에러 로그를 분리한다. 에러만 따로 모으면 장애 대응 시간이 단축된다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ECS 로그 → CloudWatch Log Group (/ecs/service-a)
                    │
                    ├── 전체 로그 (30일 보관)
                    └── ERROR/WARN 필터 → 에러 전용 로그 그룹
                                              │
                                              └→ CloudWatch Alarm → SNS → Slack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5분간 5xx 에러 10회 이상이면 Slack으로 즉시 알림이 온다. Grafana에서 서비스 간 호출 지연, 에러율 트렌드, 리소스 사용 패턴을 한 눈에 파악한다.&lt;/p&gt;

&lt;h3&gt;
  
  
  운영 스크립트
&lt;/h3&gt;

&lt;p&gt;장애 발생 시 AWS 콘솔을 뒤지는 대신 터미널 한 줄로 대응한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/deploy/service-status.sh gateway     &lt;span class="c"&gt;# 서비스 상태 확인&lt;/span&gt;
./scripts/deploy/scale-service.sh service-a 4  &lt;span class="c"&gt;# 긴급 스케일링&lt;/span&gt;
./scripts/deploy/rollback-service.sh gateway   &lt;span class="c"&gt;# 이전 버전 롤백&lt;/span&gt;
./scripts/deploy/logs-service.sh service-b     &lt;span class="c"&gt;# 실시간 로그&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  여러 레포 → MSA Lite, 1년간의 체감 비교
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;관점&lt;/th&gt;
&lt;th&gt;여러 레포 (Before)&lt;/th&gt;
&lt;th&gt;MSA Lite 모노레포 (After)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;배포 단위&lt;/strong&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;strong&gt;장애 격리&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;한 서비스 장애가 다른 레포에 영향 없지만 내부는 전체 다운&lt;/td&gt;
&lt;td&gt;서비스별 격리 (Gateway 살아있으면 부분 동작)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;스케일링&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;서비스 통째로만 가능&lt;/td&gt;
&lt;td&gt;CPU 집약 서비스만 선택적 스케일 아웃&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;코드 공유&lt;/strong&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;strong&gt;디버깅&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;레포 넘나들며 추적&lt;/td&gt;
&lt;td&gt;분산 트레이싱 (AsyncLocalStorage)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;로컬 개발&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm start&lt;/code&gt; 하나&lt;/td&gt;
&lt;td&gt;여러 서비스 동시 기동 필요 (PM2/Docker)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;인프라 복잡도&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;중간 (Redis, 큐, Service Discovery)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;새 기능 추가&lt;/strong&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;/div&gt;

&lt;p&gt;&lt;strong&gt;솔직한 평가:&lt;/strong&gt; 백엔드 2명이서 통합 작업을 진행하는 건 쉽지 않았다. 하지만 한 번 통합하고 나니 이후 모든 개발 속도가 올라갔다. 특히 &lt;strong&gt;새 서비스 추가 비용이 극적으로 줄었다&lt;/strong&gt; — 공유 패키지에서 가져다 쓰고, Terraform 모듈 하나 추가하면 인프라까지 끝이다.&lt;/p&gt;

&lt;h2&gt;
  
  
  아쉬웠던 점
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gateway의 역할이 커졌다.&lt;/strong&gt; 공통 로직을 Gateway에 넣다 보니 가장 큰 서비스가 됐다. 인증/세션을 별도 서비스로 분리할지 고민 중이지만, 백엔드 2명 규모에서는 분리 복잡도가 이점보다 크다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP 통신의 타입 안전성 부재.&lt;/strong&gt; &lt;code&gt;client.send()&lt;/code&gt;에 잘못된 타입을 넣어도 컴파일러가 통과시킨다. gRPC의 &lt;code&gt;.proto&lt;/code&gt; 같은 계약이 없는 셈이다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform 모듈 분리 시점을 놓쳤다.&lt;/strong&gt; &lt;code&gt;main.tf&lt;/code&gt; 하나에 모든 리소스를 넣다가 1000줄이 넘어간 후에야 분리했다. 처음부터 모듈 단위로 설계했어야 했다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;로컬 개발 환경 복잡도.&lt;/strong&gt; 모든 서비스를 동시에 띄워야 해서 PM2 + Docker 의존성이 늘었다. &lt;code&gt;npm start&lt;/code&gt; 하나로 끝나던 시절이 그립기도 하다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  추후 발전 방향
&lt;/h2&gt;

&lt;h3&gt;
  
  
  단기 (3~6개월)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry 도입&lt;/strong&gt;: AsyncLocalStorage 트레이싱을 OTEL 표준으로 마이그레이션. Jaeger/Tempo 연동&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Circuit Breaker&lt;/strong&gt;: TCP 통신에 재시도/서킷 브레이커 패턴 추가&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto Scaling&lt;/strong&gt;: 현재 고정 태스크 수 → CPU/메모리 기반 자동 스케일링&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  중기 (6~12개월)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;이벤트 기반 통신 병행&lt;/strong&gt;: 동기 TCP + 비동기 이벤트(Kafka/NATS) 하이브리드&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP 타입 안전성 강화&lt;/strong&gt;: 공유 패키지에 서비스별 인터페이스 정의, 컴파일 타임 페이로드 검증&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;카나리 배포&lt;/strong&gt;: CodeDeploy Linear/Canary 전략으로 10% 트래픽 먼저 전환&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  장기 (1년 이상)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes 마이그레이션&lt;/strong&gt;: ECS → EKS. Service Mesh로 트래픽 관리 고도화&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitOps&lt;/strong&gt;: ArgoCD/Flux로 인프라 변경을 Git PR 기반으로 관리&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  시리즈를 마치며 — 배운 점
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"백엔드 2명도 MSA를 할 수 있다. 단, 범위를 잘 잡아야 한다."&lt;/strong&gt; 풀 MSA의 인프라 오버헤드를 NestJS 내장 기능 + Terraform 모듈화로 대체하면, 소규모 팀에서도 서비스 분리의 이점을 누릴 수 있다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;모노레포가 MSA의 고통을 반으로 줄인다.&lt;/strong&gt; 코드 공유, 일관된 린트/빌드, 원자적 커밋. 여러 레포로 분산되어 있었을 때의 패키지 버전 관리 지옥이 사라졌다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;인프라 코드는 자산이다.&lt;/strong&gt; TracingClientProxy, MicroserviceExceptionFilter, DistributedCron 같은 인프라 레이어와 Terraform 모듈을 공들여 만들면, 이후 서비스 추가 비용이 극적으로 줄어든다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IaC 없는 MSA는 관리 불가능하다.&lt;/strong&gt; 서비스가 3개를 넘어가면 수동 인프라 관리는 현실적으로 불가능하다. Terraform 도입 비용은 첫 달에 회수된다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fake 구현체는 프로젝트 자산이다.&lt;/strong&gt; 한 번 만들면 모든 서비스에서 재사용한다. jest.fn()을 매번 setup하는 것보다 효율적이고, 인터페이스 변경 시 컴파일 에러로 동기화된다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;과도하게 나누지 마라.&lt;/strong&gt; 기능 단위로 적절히 분리하는 선에서 멈췄다. 처음부터 10개 서비스로 시작하면 2명이서 인프라 관리에 매몰된다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;Terraform 모듈 설계에 Claude Code를 활용했다. 14개 모듈 간 의존성 그래프를 분석하고, 순환 참조 없이 output → input을 연결하는 구조를 잡는 데 효과적이었다. 보안 그룹 규칙 검증 — 불필요하게 열린 포트나 과도한 IP 범위를 탐지하는 데도 AI가 도움이 됐다. 테스트 전략 수립 시에도 코드베이스 전체의 의존성 패턴을 분석해서 Fake 구현체의 인터페이스를 설계하는 데 활용했다.&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>typescript</category>
      <category>devops</category>
      <category>terraform</category>
    </item>
    <item>
      <title>NestJS MSA Lite 실전 아키텍처 (2/3) — 데이터 레이어와 비동기 처리</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Tue, 10 Mar 2026 15:15:51 +0000</pubDate>
      <link>https://dev.to/archist/nestjs-msa-lite-siljeon-akitegceo-23-deiteo-reieowa-bidonggi-ceori-3aij</link>
      <guid>https://dev.to/archist/nestjs-msa-lite-siljeon-akitegceo-23-deiteo-reieowa-bidonggi-ceori-3aij</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;여러 레포에 흩어져 있던 B2B SaaS 서비스를 MSA Lite 모노레포로 통합한 경험을 정리한 시리즈의 두 번째 글이다.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Part 1: 서비스 구조와 통신 설계&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2: 데이터 레이어와 비동기 처리&lt;/strong&gt; (이 글)&lt;/li&gt;
&lt;li&gt;Part 3: 운영, 배포, 그리고 진화&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  멀티테넌트 데이터베이스 — 테넌트별 DataSource 동적 관리
&lt;/h2&gt;

&lt;p&gt;SaaS 서비스에서 가장 까다로운 문제 중 하나가 멀티테넌시다. 우리 경우 난이도가 한 단계 더 높았다.&lt;/p&gt;

&lt;p&gt;각 병원(테넌트)에는 &lt;strong&gt;소스 DB&lt;/strong&gt;가 있다 — 병원 EMR 시스템의 데이터베이스다. 문제는 병원마다 EMR 벤더가 다르고, DB 브랜드(Oracle, MSSQL, PostgreSQL)와 버전이 제각각이며, 심지어 같은 벤더라도 병원이 확장하면 스키마가 달라질 수 있다는 점이다. 여기에 직접 서비스 로직을 태울 수는 없다.&lt;/p&gt;

&lt;p&gt;그래서 병원별 &lt;strong&gt;서비스 전용 데이터베이스(AgentDB)&lt;/strong&gt;를 별도로 두고, 소스 DB에서 필요한 데이터를 ETL로 가져와서 정규화된 스키마로 적재하는 구조를 택했다. 서비스 로직은 항상 AgentDB만 바라본다.&lt;/p&gt;

&lt;h3&gt;
  
  
  아키텍처
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;소스 DB (병원 EMR)                    서비스 DB (AgentDB)
├── 병원 A: Oracle 11g  ──ETL──→    ├── 병원 A: PostgreSQL
├── 병원 B: MSSQL 2019  ──ETL──→    ├── 병원 B: PostgreSQL
├── 병원 C: Oracle 19c  ──ETL──→    ├── 병원 C: PostgreSQL
│   (SSH 터널 경유)                   └── ...
└── ...
                                           ↑
Config DB (공유)                    AgentDatabaseService
├── 테넌트 마스터 데이터              (런타임 DataSource 관리)
├── DataSourceConfig
│   (테넌트 → DB 접속 정보 매핑)
└── 시스템 설정
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;소스 DB의 다양성은 ETL 레이어가 흡수하고, AgentDB는 모두 PostgreSQL로 통일했다. 덕분에 서비스 코드에서는 DB 브랜드를 신경 쓸 필요가 없다. 하지만 &lt;strong&gt;테넌트별로 독립된 AgentDB가 존재&lt;/strong&gt;하므로, 런타임에 동적으로 DataSource를 관리해야 한다.&lt;/p&gt;

&lt;h3&gt;
  
  
  핵심 구현
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentDatabaseService&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;OnModuleInit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OnModuleDestroy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;datasourcesMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DataSource&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;getAgentRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;ObjectLiteral&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EntityTarget&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Repository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrCreateDataSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getOrCreateDataSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DataSource&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. 캐시 히트 → 즉시 반환&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datasourcesMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isInitialized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Config DB에서 접속 정보 조회&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadTenantConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. DataSource 생성 + 커넥션 풀 초기화&lt;/span&gt;
    &lt;span class="nx"&gt;ds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DataSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databaseUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databaseType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SHARED_ENTITIES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;synchronize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 프로덕션: migration으로만 스키마 변경&lt;/span&gt;
      &lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datasourcesMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  성능 포인트
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lazy Loading&lt;/strong&gt;: 요청이 들어온 테넌트만 DataSource 생성. 100개 테넌트가 있어도 실제 접속하는 테넌트만 메모리를 사용한다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;커넥션 풀 최적화&lt;/strong&gt;: 테넌트당 max 20으로 제한. 초기에 기본값(1000)을 그대로 썼더니 100개 테넌트 × 1000 커넥션으로 DB 서버가 다운됐던 경험이 있다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful Shutdown&lt;/strong&gt;: &lt;code&gt;onModuleDestroy&lt;/code&gt;에서 모든 DataSource를 &lt;code&gt;Promise.allSettled&lt;/code&gt;로 순차 정리. 한 테넌트의 정리 실패가 다른 테넌트에 영향을 주지 않는다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health Check&lt;/strong&gt;: 전체 테넌트 DB 연결 상태를 한 번에 점검하는 &lt;code&gt;healthCheck()&lt;/code&gt; 메서드 제공.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  사용하는 쪽 코드
&lt;/h3&gt;

&lt;p&gt;모든 서비스에서 동일한 패턴으로 테넌트 DB에 접근한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 어떤 서비스든 동일한 인터페이스&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentDatabaseService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getAgentRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrderEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;OrderEntity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ACTIVE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  비동기 처리 — BullMQ 3계층 패턴
&lt;/h2&gt;

&lt;p&gt;외부 AI 서버와의 스트리밍 통신, PDF 생성, ETL 배치 같은 작업을 동기 API로 처리하면 타임아웃의 늪에 빠진다. BullMQ로 비동기 처리하되, &lt;strong&gt;Producer → Consumer → Handler 3계층&lt;/strong&gt;으로 관심사를 분리했다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Producer: 큐에 작업 추가 (thin wrapper)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentGenerateProducer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;produce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GeneratePayload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exponential&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;removeOnComplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;removeOnFail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 실패 작업은 모니터링용으로 보존&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Consumer: BullMQ Worker (DI 브릿지 역할만)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Processor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;document-generate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentGenerateConsumer&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;WorkerHost&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DocumentGenerateHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Job&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;GeneratePayload&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;OnWorkerEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Handler: 순수 비즈니스 로직 (테스트 가능)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DocumentGenerateHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GeneratePayload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 실제 문서 생성 로직&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handleFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GeneratePayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// DB 상태 복구 (예: status → FAILED)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  왜 3계층인가?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;레이어&lt;/th&gt;
&lt;th&gt;테스트 용이성&lt;/th&gt;
&lt;th&gt;책임&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Producer&lt;/td&gt;
&lt;td&gt;높음 (&lt;code&gt;FakeProducer&lt;/code&gt;로 대체)&lt;/td&gt;
&lt;td&gt;큐에 작업 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer&lt;/td&gt;
&lt;td&gt;낮음 (&lt;code&gt;@Processor&lt;/code&gt; 데코레이터 종속)&lt;/td&gt;
&lt;td&gt;BullMQ ↔ DI 브릿지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handler&lt;/td&gt;
&lt;td&gt;높음 (&lt;code&gt;new Handler(fakeDeps)&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;비즈니스 로직&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Consumer가 BullMQ에 강결합되어 있어 단위 테스트가 어렵다. Handler를 분리하면 &lt;code&gt;new Handler(fakeDb, fakeS3)&lt;/code&gt;로 mock 없이 테스트할 수 있다. E2E 테스트에서는 Handler 전체를 MockHandler로 교체하여 큐 의존성을 제거한다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bull Board 모니터링
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;BullMQRootModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forRoot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;bullBoardRoute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/queues&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/queues&lt;/code&gt; 경로에서 모든 큐의 대기/처리/실패 작업을 실시간으로 확인할 수 있다. 실패 작업의 스택 트레이스, 재시도 횟수, 처리 시간까지 대시보드에서 바로 보인다. 프로덕션 장애 대응 시 가장 먼저 확인하는 화면이다.&lt;/p&gt;

&lt;h2&gt;
  
  
  실시간 업데이트 — Redis PubSub 기반 SSE
&lt;/h2&gt;

&lt;p&gt;WebSocket 대신 SSE(Server-Sent Events)를 선택한 이유:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;클라이언트 → 서버 양방향 통신이 불필요 (서버 → 클라이언트 단방향 Push만 필요)&lt;/li&gt;
&lt;li&gt;HTTP/2 환경에서 다중 스트림 지원&lt;/li&gt;
&lt;li&gt;연결 끊김 시 브라우저가 자동 재연결&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;문제는 &lt;strong&gt;마이크로서비스에서 이벤트가 발생하는데, SSE 커넥션은 Gateway에 있다&lt;/strong&gt;는 점이다. Redis PubSub으로 이 간극을 메웠다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Microservice                Redis PubSub            Gateway SSE
(이벤트 발생)                                       (클라이언트 연결)
     │                           │                       │
Publisher.publish()  ─────→  Channel  ─────→  Subscriber.onMessage()
     │                           │                       │
     │                           │                  res.write(event)
     │                           │                       │
     │                    Multi-instance 동기화           │
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  베이스 클래스 상속 패턴
&lt;/h3&gt;

&lt;p&gt;새로운 도메인의 SSE를 추가할 때, 채널명과 이벤트 타입만 정의하면 된다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 도메인별 Publisher — 이벤트 채널 정의만 하면 된다&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderSsePublisher&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BaseSsePublisher&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrderEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;getChannelName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`order_updates_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Gateway의 Orchestrator — 구독 + 정리 라이프사이클 관리&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderSseOrchestrator&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BaseSseOrchestrator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrderEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getChannelName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startHeartbeat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;            &lt;span class="c1"&gt;// 30초 간격 ping&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;  &lt;span class="c1"&gt;// 연결 종료 시 리소스 정리&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;주의점:&lt;/strong&gt; Gateway에서 SSE 라우트는 compression 미들웨어에서 반드시 제외해야 한다. gzip이 응답을 버퍼링하면 실시간 스트리밍이 깨진다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-instance 동기화
&lt;/h3&gt;

&lt;p&gt;Gateway가 2대 이상일 때, 클라이언트 A가 Gateway-1에 연결되어 있는데 이벤트가 Gateway-2를 통해 들어올 수 있다. Redis PubSub은 &lt;strong&gt;모든 구독자에게 브로드캐스트&lt;/strong&gt;하므로 이 문제가 자연스럽게 해결된다. 추가 로직 없이 모든 Gateway 인스턴스가 이벤트를 수신한다.&lt;/p&gt;

&lt;h2&gt;
  
  
  분산 크론 잡 — 다중 인스턴스에서 한 번만 실행
&lt;/h2&gt;

&lt;p&gt;ECS에서 서비스를 2개 이상 인스턴스로 띄우면, &lt;code&gt;@Cron&lt;/code&gt; 데코레이터가 &lt;strong&gt;모든 인스턴스에서 동시 실행&lt;/strong&gt;된다. 매일 아침 알림을 2번 보내거나, ETL이 2번 실행되는 사고가 발생한다.&lt;/p&gt;

&lt;p&gt;Redis 분산 락 기반 &lt;code&gt;@DistributedCron&lt;/code&gt; 데코레이터로 해결했다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;DistributedCron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0 8 * * *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;daily-notification&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 글로벌 유니크 락 키&lt;/span&gt;
  &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Asia/Seoul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ttlMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// 5분 TTL (자동 연장)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;sendDailyNotification&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 여러 인스턴스 중 단 하나만 실행&lt;/span&gt;
  &lt;span class="c1"&gt;// 나머지는 "[daily-notification] Skipped" 로그 후 스킵&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  내부 동작
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Redis &lt;code&gt;SET key lockId PX ttlMs NX&lt;/code&gt; — key가 없을 때만 설정 (원자적 락 획득)&lt;/li&gt;
&lt;li&gt;락 획득 성공 → 작업 실행 + TTL의 2/3 시점마다 자동 연장&lt;/li&gt;
&lt;li&gt;락 획득 실패 → 스킵 (다른 인스턴스가 실행 중)&lt;/li&gt;
&lt;li&gt;작업 완료 → 락 해제&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;자동 연장이 중요한 이유:&lt;/strong&gt; TTL을 5분으로 설정했는데 작업이 7분 걸리면? TTL의 2/3 시점(3분 20초)에 자동으로 TTL을 갱신하므로, 장시간 작업에서도 락이 중간에 만료되지 않는다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;사용자 코드에서는 락을 전혀 의식할 필요가 없다.&lt;/strong&gt; NestJS의 &lt;code&gt;@Cron&lt;/code&gt;을 &lt;code&gt;@DistributedCron&lt;/code&gt;으로 바꾸고 &lt;code&gt;name&lt;/code&gt;만 추가하면 끝이다.&lt;/p&gt;

&lt;h2&gt;
  
  
  다음 편 예고
&lt;/h2&gt;

&lt;p&gt;Part 3에서는 &lt;strong&gt;운영과 진화&lt;/strong&gt;를 다룬다:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;무중단 배포 (Expand-Contract 패턴)&lt;/li&gt;
&lt;li&gt;테스트 전략 (jest.fn() 없는 세계)&lt;/li&gt;
&lt;li&gt;모놀리스 vs MSA Lite 체감 비교&lt;/li&gt;
&lt;li&gt;추후 발전 방향 (OpenTelemetry, Circuit Breaker, K8s)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;소스 DB(병원 EMR)의 스키마 차이를 정규화하는 ETL 매핑 로직을 작성할 때 Claude Code를 활용했다. 병원별로 미묘하게 다른 컬럼명, 데이터 타입, 코드 체계를 분석하고 AgentDB의 통일된 스키마로 변환하는 매퍼를 생성하는 데 특히 유용했다. 멀티테넌트 DB의 커넥션 풀 최적화 값을 결정할 때도 전체 코드베이스의 동시 쿼리 패턴을 분석해 테넌트당 최대 동시 커넥션 수를 추정하는 데 활용했다.&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>typescript</category>
      <category>database</category>
      <category>redis</category>
    </item>
    <item>
      <title>NestJS MSA Lite 실전 아키텍처 (1/3) — 서비스 구조와 통신 설계</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Tue, 10 Mar 2026 14:36:02 +0000</pubDate>
      <link>https://dev.to/archist/nestjs-maikeuroseobiseu-siljeon-akitegceo-13-yeoreo-monolriseureul-msa-lite-monoreporo-tonghabhan-iyagi-564n</link>
      <guid>https://dev.to/archist/nestjs-maikeuroseobiseu-siljeon-akitegceo-13-yeoreo-monolriseureul-msa-lite-monoreporo-tonghabhan-iyagi-564n</guid>
      <description>&lt;h2&gt;
  
  
  왜 "MSA Lite"인가
&lt;/h2&gt;

&lt;p&gt;혼자 B2B SaaS 백엔드 코드베이스를 여러 개 운영하고 있었다. 같은 의료 도메인을 다루는 여러 개의 NestJS 서비스가 별도 레포에서 돌아가고 있었는데, 서비스가 커지면서 코드 중복, 기능별 스케일링 불가, 배포 파이프라인 다중 관리가 한계에 달했다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;그렇다, 결국엔 합쳐야 했다.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;하지만 단순히 하나의 모놀리스로 합치면 또 다른 문제가 생길 것 같았다. 코드 중복은 줄겠지만, 수십 건이 병렬로 도는 ETL 배치나 외부 AI 서버와 장시간 스트리밍 통신하는 기능이 가벼운 CRUD API와 같은 프로세스에 묶이면, 기능별 스케일링은 여전히 불가능하고 한쪽의 부하가 전체 응답성을 끌어내린다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;합치되, 나눌 수 있는 구조가 필요했다.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;풀 MSA를 도입하려면 서비스 메시, API Gateway 전용 솔루션, 분산 트랜잭션 관리, 독립 배포 파이프라인 등 인프라 투자가 막대하다. 스타트업에서 &lt;strong&gt;백엔드 개발자 2명&lt;/strong&gt;이 이 모든 걸 감당하기는 불가능에 가깝다. 🥲&lt;/p&gt;

&lt;p&gt;내가 절충하여 선택한 방식은 &lt;strong&gt;모노레포 안에서 서비스를 논리적으로 분리&lt;/strong&gt;하되, 통신은 NestJS 내장 TCP 트랜스포트를 사용한다. Kafka나 gRPC 같은 외부 의존성을 추가하지 않으면서도, 서비스별 독립 스케일링과 장애 격리를 확보하는 구조다. 이것을 "MSA Lite"라 부르기로 했다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────┐
│                      Monorepo                           │
│                                                         │
│  apps/                    packages/                     │
│  ├── gateway  (HTTP)      ├── api        (DTO/SDK)     │
│  ├── service-a (TCP)      ├── infrastructure (Core)    │
│  ├── service-b (TCP)      └── shared     (Constants)   │
│  ├── service-c (TCP)                                    │
│  └── admin-ui  (React)                                  │
│                                                         │
└─────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1년 넘게 모놀리식을 프로덕션에서 운영하다가 MSA Lite 전환 후 3개월차가 된 현재, 느낀 장단점과 고도화 포인트를 공유한다.&lt;/p&gt;

&lt;h2&gt;
  
  
  출발점 — 여러 개의 레포를 하나로 합치다
&lt;/h2&gt;

&lt;p&gt;위에서 말한 상황을 정리하면 여러 레포에 흩어진 서비스들이 같은 데이터 구조를 다루면서 각자 Entity, DTO, 유틸리티를 들고 있었고, 인증/인가는 한쪽 레포에서 담당하며 나머지가 이를 공유하는 구조였다.&lt;/p&gt;

&lt;p&gt;구체적으로 터지고 있던 문제들:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;코드 중복&lt;/strong&gt;: 한쪽에서 버그를 고치면 다른 레포에는 여전히 남아있다&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;기능별 스케일링 불가&lt;/strong&gt;: ETL 배치나 스트리밍 통신이 CRUD API와 같은 프로세스에 묶여 있어 따로 스케일업할 수 없었다&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;패키지 버전 불일치&lt;/strong&gt;: 레포마다 TypeORM 버전이 달라 미묘한 동작 차이로 원인 불명의 버그가 발생&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;배포 파이프라인 다중 관리&lt;/strong&gt;: 레포마다 CI/CD를 따로 설정. 인프라 변경 시 모든 파이프라인을 동시에 수정해야 했다&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;기존 서비스들의 비즈니스 로직을 보존하면서, &lt;strong&gt;공통 코드는 공유 패키지로 추출하고, 기능 단위로 서비스를 재분리하고, 서비스 간 통신은 TCP로 표준화&lt;/strong&gt;하는 방향으로 통합을 시작했다.&lt;/p&gt;

&lt;h2&gt;
  
  
  서비스 토폴로지
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gateway — HTTP 진입점 + 공통 비즈니스 로직
&lt;/h3&gt;

&lt;p&gt;Gateway는 단순한 TCP 프록시가 아니다. &lt;strong&gt;인증/인가, 세션 관리, 계정 관리&lt;/strong&gt; 등 모든 서비스가 공통으로 필요한 비즈니스 로직을 담당한다. 기존에 한쪽 레포에서 담당하던 인증 로직을 Gateway로 끌어올린 것이다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gateway가 직접 처리하는 영역:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;영역&lt;/th&gt;
&lt;th&gt;핵심 기능&lt;/th&gt;
&lt;th&gt;규모&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;인증/인가&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JWT 발급/검증, 역할 기반 접근 제어, 권한 가드&lt;/td&gt;
&lt;td&gt;8개 Guard/Strategy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;세션 관리&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Redis 기반 세션 생명주기, 재접속 유예, SSE 동기화&lt;/td&gt;
&lt;td&gt;7일 TTL, 30초 grace period&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;계정 관리&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CRUD, 비밀번호 재설정, 초대 시스템, 관리자 계정&lt;/td&gt;
&lt;td&gt;35개+ Usecase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;감사 로그&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;민감 필드 마스킹, 비동기 저장, 경로 필터링&lt;/td&gt;
&lt;td&gt;BullMQ 기반&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;병원 관리&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;온보딩 워크플로우, 설정, 가격 정책&lt;/td&gt;
&lt;td&gt;Slack 알림 연동&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;사용자 설정&lt;/strong&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;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Gateway의 인증 — 단순 프록시가 아닌 실제 비즈니스 로직&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Throttle&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;short&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;// 브루트포스 방지&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoginRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. 자격 증명 검증 (DB 직접 조회)&lt;/span&gt;
  &lt;span class="c1"&gt;// 2. JWT 토큰 발급&lt;/span&gt;
  &lt;span class="c1"&gt;// 3. Redis 세션 생성 (7일 TTL)&lt;/span&gt;
  &lt;span class="c1"&gt;// 4. SSE로 세션 상태 브로드캐스트&lt;/span&gt;
  &lt;span class="c1"&gt;// 5. 쿠키 설정 후 응답&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 세션 관리 — 네트워크 불안정 대응&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthSessionService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 연결 끊김 시 30초 유예 → 재접속하면 세션 유지&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handleDisconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`grace:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GRACE_PERIOD_SEC&lt;/span&gt;  &lt;span class="c1"&gt;// 30초&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gateway가 TCP로 위임하는 영역:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;도메인 특화 비즈니스 로직은 해당 마이크로서비스에 TCP로 위임한다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TCP 위임 — 도메인 로직은 마이크로서비스에서 처리&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CreateOrderRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;firstValueFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;이 설계의 근거:&lt;/strong&gt;&lt;br&gt;
인증/세션 같은 횡단 관심사를 각 마이크로서비스에 분산시키면 일관성 유지가 어렵다. 기존에도 한쪽 레포에서 인증을 중앙 처리하고 있었으므로, 이 패턴을 Gateway로 자연스럽게 이관한 것이다. Gateway를 수평 확장할 때도 세션은 Redis에 있으므로 상태 문제가 발생하지 않는다.&lt;/p&gt;
&lt;h3&gt;
  
  
  Microservices — TCP + HTTP 이중 스택
&lt;/h3&gt;

&lt;p&gt;각 마이크로서비스는 TCP(서비스 간 통신)와 HTTP(헬스체크/Swagger)를 동시에 서빙한다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// main.ts (마이크로서비스)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;NestFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AppModule&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// TCP 서버 연결 (서비스 간 통신)&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connectMicroservice&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MicroserviceOptions&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TCP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.0.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TCP_PORT&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startAllMicroservices&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HTTP_PORT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// PM2 무중단 배포 시그널&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;왜 TCP인가?&lt;/strong&gt; NestJS 내장이라 추가 인프라 없이 바로 쓸 수 있고, JSON 직렬화 기반이라 디버깅이 쉽다. gRPC 대비 성능은 떨어지지만, 우리 트래픽 규모(초당 수백 요청)에서는 병목이 되지 않는다. 무엇보다 백엔드 2명이 별도 인프라를 관리할 여유가 없었다. Kafka는 이벤트 소싱이 필요할 때 도입하기로 하고, 현재는 BullMQ로 비동기 처리를 충분히 커버한다.&lt;/p&gt;

&lt;h2&gt;
  
  
  공유 패키지 3계층 — 모노레포의 핵심 무기
&lt;/h2&gt;

&lt;p&gt;여러 레포를 합치면서 가장 먼저 한 일이 &lt;strong&gt;중복 코드를 공유 패키지로 추출&lt;/strong&gt;하는 것이었다. 거의 같은 도메인을 다루던 서비스들이었기에 중복이 상당했고, 이것을 정리하는 것이 모노레포 전환의 가장 큰 수확이다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/
├── api/              # DTO, Request/Response 타입
│   └── dto/          # Swagger 데코레이터 포함 → SDK 자동 생성
├── infrastructure/   # Entity, DB, 공통 서비스
│   ├── database/     # TypeORM Entity + Migration
│   ├── bullmq/       # 큐 설정 + Bull Board
│   ├── filter/       # 예외 필터
│   ├── interceptor/  # 인터셉터
│   ├── sse/          # Server-Sent Events 베이스 클래스
│   ├── lock/         # 분산 락 + @DistributedCron
│   └── testing/      # Fake/Stub (FakeDBService, NoOpLogger 등)
└── shared/           # 상수, MessagePattern, ConfigService
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;패키지&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;의존 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shared&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;상수, 메시지 패턴, 설정&lt;/td&gt;
&lt;td&gt;없음 (leaf)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DTO 정의 + Swagger&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;shared&lt;/code&gt;, &lt;code&gt;infrastructure&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;infrastructure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;핵심 인프라 서비스&lt;/td&gt;
&lt;td&gt;&lt;code&gt;shared&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;이전에 여러 레포에서 복사해서 쓰던 코드가 이제 한 곳에만 존재한다. 버그를 고치면 모든 서비스에 즉시 반영된다. &lt;strong&gt;Turbo 빌드 파이프라인&lt;/strong&gt;이 의존 순서를 자동으로 해결한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;turbo.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dist/**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  분산 트레이싱 — AsyncLocalStorage로 서비스 경계를 넘는 Request ID
&lt;/h2&gt;

&lt;p&gt;MSA에서 한 요청이 여러 서비스를 거치면 로그 추적이 지옥이 된다. 이 문제를 &lt;strong&gt;Node.js AsyncLocalStorage + TCP 페이로드 주입&lt;/strong&gt; 조합으로 해결했다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client → Gateway(RequestIdMiddleware) → TracingClientProxy → TCP → Microservice(RequestContextInterceptor)
         │                                │                          │
         │ AsyncLocalStorage에             │ 페이로드에                │ 페이로드에서
         │ requestId 저장                  │ __requestId 주입         │ __requestId 추출 →
         │                                │                          │ AsyncLocalStorage에 저장
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1단계: Gateway에서 Request ID 생성&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RequestIdMiddleware&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;runWithRequestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2단계: TCP 호출 시 자동 주입&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TracingClientProxy — NestJS ClientProxy 래퍼&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;injectRequestId&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getRequestId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// AsyncLocalStorage에서 조회&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;__requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3단계: 마이크로서비스에서 추출 + 전파&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RequestContextInterceptor&lt;/span&gt;
&lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CallHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;switchToRpc&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;__requestId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__requestId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 비즈니스 로직에 노출되지 않도록 제거&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Observable&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;subscriber&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;runWithRequestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriber&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 설계의 강점: &lt;strong&gt;비즈니스 코드에서 Request ID를 전혀 의식하지 않는다.&lt;/strong&gt; 파라미터로 전달하지 않아도 &lt;code&gt;getRequestId()&lt;/code&gt; 한 줄이면 어디서든 조회 가능하다.&lt;/p&gt;

&lt;h2&gt;
  
  
  예외 전파 체인 — 서비스 경계를 넘는 에러 핸들링
&lt;/h2&gt;

&lt;p&gt;MSA에서 가장 놓치기 쉬운 부분이 예외 전파다. Microservice에서 &lt;code&gt;NotFoundException&lt;/code&gt;을 던졌는데 Gateway가 500으로 응답하면 클라이언트는 원인을 알 수 없다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Microservice                    Gateway                     Client
NotFoundException(404)          RpcToHttpInterceptor         HTTP 404
  ↓                              ↓                           ↓
MicroserviceExceptionFilter     {statusCode:404, message}    {statusCode:404,
  ↓                              → HttpException(404)         message:"Not found"}
throwError({statusCode, msg})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Microservice 측: 모든 예외를 정규화&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Catch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MicroserviceExceptionFilter&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;RpcExceptionFilter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Observable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Internal server error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HttpException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;throwError&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gateway 측: 정규화된 에러를 HTTP 예외로 변환&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RpcToHttpInterceptor&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;NestInterceptor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CallHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;catchError&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HttpException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;결과:&lt;/strong&gt; 클라이언트는 어느 서비스에서 에러가 발생했든 일관된 HTTP 응답을 받는다.&lt;/p&gt;

&lt;h2&gt;
  
  
  TCP 통신의 계약 — MessagePattern 설계
&lt;/h2&gt;

&lt;p&gt;서비스가 늘어나면 "어떤 서비스가 어떤 메시지를 처리하는가"를 추적하기 어려워진다. 모든 메시지 패턴을 하나의 객체로 중앙 관리하는 방식을 택했다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/shared/src/message/message-pattern-map.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMessagePatternMap&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;ServiceA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;updateStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;Report&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;getResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;ServiceB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 자동 생성: Messages.ServiceA.Order.create = "ServiceA.Order.create"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이렇게 하면 IDE에서 자동완성이 되고, 존재하지 않는 패턴을 호출하면 컴파일 타임에 잡힌다. &lt;code&gt;grep&lt;/code&gt;으로 "누가 이 메시지를 보내고, 누가 처리하는가"를 한 번에 추적할 수 있다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;한계:&lt;/strong&gt; 메시지 패턴 자체는 타입 세이프하지만, 페이로드 타입까지 강제하지는 못한다. &lt;code&gt;client.send(Messages.ServiceA.Order.create, 잘못된타입)&lt;/code&gt;을 넣어도 컴파일러는 통과시킨다. gRPC의 &lt;code&gt;.proto&lt;/code&gt; 같은 계약이 없는 셈이다. 이 부분은 공유 패키지에 서비스별 인터페이스를 정의하는 방향으로 개선 중이다.&lt;/p&gt;

&lt;h2&gt;
  
  
  서비스 간 의존성 관리 — 누가 누구를 호출하는가
&lt;/h2&gt;

&lt;p&gt;MSA에서 서비스 간 호출이 자유로우면 스파게티가 된다. 우리는 단순한 규칙을 세웠다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gateway ──→ ServiceA
Gateway ──→ ServiceB
Gateway ──→ ServiceC
ServiceA ──→ ServiceB  (필요시)

❌ ServiceB → Gateway  (역방향 금지)
❌ ServiceA ↔ ServiceC (순환 금지)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;원칙:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gateway는 모든 마이크로서비스를 호출할 수 있다&lt;/li&gt;
&lt;li&gt;마이크로서비스 간 직접 호출은 최소화한다. 필요하면 한 방향으로만&lt;/li&gt;
&lt;li&gt;순환 호출은 절대 금지. 순환이 생기면 설계가 잘못된 것이다&lt;/li&gt;
&lt;li&gt;비동기로 풀 수 있는 건 BullMQ로 돌린다 (2편에서 다룸)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TCP 클라이언트 등록도 이 규칙을 코드로 강제한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ServiceA의 모듈 — ServiceB만 호출 가능&lt;/span&gt;
&lt;span class="nx"&gt;TracingClientsModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerAsync&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ServiceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SERVICE_B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;useFactory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConfigService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TCP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SERVICE_B_HOST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SERVICE_B_PORT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ConfigService&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;등록하지 않은 서비스는 물리적으로 호출할 수 없다. "하지 마"라는 문서보다 "할 수 없다"는 코드가 더 확실하다.&lt;/p&gt;

&lt;h2&gt;
  
  
  공유 패키지의 경계 — 무엇을 공유하고 무엇을 분리하는가
&lt;/h2&gt;

&lt;p&gt;모노레포에서 가장 흔한 실수가 "공유 패키지에 뭐든 넣는 것"이다. 우리도 초기에 이 함정에 빠졌다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;공유해야 하는 것:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Entity 정의 (DB 스키마는 하나)&lt;/li&gt;
&lt;li&gt;DTO / Request / Response (서비스 간 계약)&lt;/li&gt;
&lt;li&gt;MessagePattern (통신 계약)&lt;/li&gt;
&lt;li&gt;인프라 모듈 (DB 접속, 로깅, 예외 필터 등)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;공유하면 안 되는 것:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;서비스 고유 비즈니스 로직&lt;/li&gt;
&lt;li&gt;서비스 내부에서만 쓰는 헬퍼/유틸리티&lt;/li&gt;
&lt;li&gt;특정 서비스의 설정값&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;경험적으로, &lt;strong&gt;"2개 이상의 서비스에서 import하는가?"&lt;/strong&gt;가 공유 패키지에 넣을지 말지의 기준이 됐다. 한 곳에서만 쓰는데 공유 패키지에 넣으면, 변경할 때 불필요한 리빌드가 전파된다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/api/              → 서비스 간 "계약"만. DTO, Request, Response
packages/infrastructure/   → 서비스가 "공통으로 쓰는 인프라". DB, 큐, 로깅
packages/shared/           → 서비스가 "공통으로 참조하는 값". 상수, 메시지 패턴

apps/service-a/src/        → ServiceA만의 비즈니스 로직. 여기 있는 건 절대 공유 안 함
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  여러 레포 → MSA Lite 모노레포, 구조 관점의 전과 후
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;관점&lt;/th&gt;
&lt;th&gt;여러 레포 (Before)&lt;/th&gt;
&lt;th&gt;MSA Lite 모노레포 (After)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;코드 공유&lt;/strong&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;strong&gt;통신 계약&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;암묵적 (문서 또는 구두)&lt;/td&gt;
&lt;td&gt;MessagePattern 중앙 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;서비스 경계&lt;/strong&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;strong&gt;의존성 방향&lt;/strong&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;strong&gt;패키지 버전&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;레포마다 제각각&lt;/td&gt;
&lt;td&gt;하나의 lockfile로 통일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;리팩토링&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;여러 레포 동시 PR&lt;/td&gt;
&lt;td&gt;원자적 커밋 하나로 완료&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  다음 편 예고
&lt;/h2&gt;

&lt;p&gt;이번 편에서는 MSA Lite의 서비스 구조와 통신 설계를 다뤘다. 하지만 서비스를 나누고 통신을 연결하는 건 시작일 뿐이다. 실제 프로덕션에서는 멀티테넌트 DB 관리, 비동기 작업 처리, 실시간 이벤트 전파 같은 데이터 레이어 문제가 기다리고 있다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2편: 데이터 레이어와 비동기 처리&lt;/strong&gt;에서는 테넌트별 DataSource 동적 관리, BullMQ 3계층 패턴, Redis PubSub 기반 SSE 설계를 다룬다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3편: 운영, 배포, 그리고 진화&lt;/strong&gt;에서는 무중단 배포의 Expand-Contract 패턴, 분산 크론, 테스트 전략, 그리고 1년간의 회고를 공유한다.&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>typescript</category>
      <category>architecture</category>
      <category>microservices</category>
    </item>
    <item>
      <title>NestJS 멀티테넌트 DB 커넥션 풀 동적 관리: Promise 캐싱과 Lazy Loading</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Sun, 08 Mar 2026 12:57:30 +0000</pubDate>
      <link>https://dev.to/archist/nestjs-meoltiteneonteu-db-keonegsyeon-pul-dongjeog-gwanri-promise-kaesinggwa-lazy-loading-2a7e</link>
      <guid>https://dev.to/archist/nestjs-meoltiteneonteu-db-keonegsyeon-pul-dongjeog-gwanri-promise-kaesinggwa-lazy-loading-2a7e</guid>
      <description>&lt;h2&gt;
  
  
  배경 / 문제 상황
&lt;/h2&gt;

&lt;p&gt;SaaS 서비스를 운영하다 보면 &lt;strong&gt;테넌트마다 별도 데이터베이스&lt;/strong&gt;를 써야 하는 경우가 있다. 우리 서비스는 각 고객사가 독립된 DB를 사용하는 구조인데, 처음에는 단순하게 접근했다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 초기 접근: 요청마다 DataSource 생성&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EntityTarget&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DataSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRepository&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;문제는 금방 드러났다:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;커넥션 폭발&lt;/strong&gt;: 요청마다 새 DataSource를 만들어서 DB 커넥션이 기하급수적으로 증가&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;초기화 중복&lt;/strong&gt;: 동시 요청이 들어오면 같은 테넌트에 대해 여러 번 초기화&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;메모리 누수&lt;/strong&gt;: 사용 끝난 DataSource를 정리하지 않아 메모리 점유 증가&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;장애 전파&lt;/strong&gt;: 한 테넌트 DB가 느려지면 전체 서비스가 영향 받음&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CTO 관점에서 되돌아보면&lt;/strong&gt;, 초기에 "일단 돌아가게" 만든 코드가 프로덕션에서 얼마나 빠르게 시한폭탄이 되는지를 보여주는 전형적인 사례다. 테넌트가 3개일 때는 괜찮았지만, 10개를 넘기자마자 새벽에 알람이 울리기 시작했다.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  접근 방법
&lt;/h2&gt;

&lt;p&gt;핵심 전략은 세 가지:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Map 캐싱&lt;/strong&gt;: 테넌트별 DataSource를 Map에 저장하여 재사용&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Promise 캐싱&lt;/strong&gt;: 초기화 중인 Promise를 캐싱하여 동시 요청의 중복 초기화 방지&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy Loading&lt;/strong&gt;: 시작 시 전체 초기화 + 미등록 테넌트는 런타임에 동적 생성&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  왜 이 조합인가?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;대안&lt;/th&gt;
&lt;th&gt;검토 결과&lt;/th&gt;
&lt;th&gt;채택 여부&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Schema-based 멀티테넌시&lt;/td&gt;
&lt;td&gt;고객사별 DB 격리 요구사항 충족 불가&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection Pool만 공유&lt;/td&gt;
&lt;td&gt;테넌트별 DB 주소가 다르므로 불가&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lazy Loading만&lt;/td&gt;
&lt;td&gt;첫 요청 레이턴시 문제&lt;/td&gt;
&lt;td&gt;부분 채택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eager Loading만&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;strong&gt;Eager + Lazy 혼합&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;기존 테넌트는 즉시, 신규는 동적&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;설계 결정의 핵심은 "재배포 없이 테넌트를 추가할 수 있는가"&lt;/strong&gt;였다. 병원이 새로 계약하면 Config DB에 레코드만 추가하면 되는 구조를 목표로 했다.&lt;/p&gt;




&lt;h2&gt;
  
  
  구현
&lt;/h2&gt;

&lt;h3&gt;
  
  
  핵심 구조
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MultiTenantDatabaseService&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;OnModuleInit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OnModuleDestroy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 테넌트별 DataSource 캐시&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;datasourcesMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DataSource&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Config 조회용 DataSource (메타 정보 DB)&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;configDataSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DataSource&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 초기화 상태 관리&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;initialized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;initializationPromise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;네 가지 상태를 명확히 분리했다:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;datasourcesMap&lt;/code&gt;: 실제 테넌트 DataSource 저장소&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;configDataSource&lt;/code&gt;: 테넌트 설정 정보를 조회하는 메타 DB&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;initialized&lt;/code&gt;: 초기화 완료 플래그&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;initializationPromise&lt;/code&gt;: &lt;strong&gt;동시성 제어의 핵심&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Promise 캐싱으로 중복 초기화 방지
&lt;/h3&gt;

&lt;p&gt;가장 까다로운 부분이 동시성 제어다. NestJS는 &lt;code&gt;onModuleInit&lt;/code&gt;을 한 번만 호출하지만, 초기화가 완료되기 전에 요청이 들어올 수 있다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onModuleInit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 이미 초기화 중이면 같은 Promise를 반환&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initializationPromise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initializationPromise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 새 Promise 생성 및 캐싱&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initializationPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initializationPromise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 패턴의 핵심은 &lt;strong&gt;같은 Promise 객체를 공유&lt;/strong&gt;하는 것이다. 세 개의 요청이 동시에 들어와도 &lt;code&gt;initialize()&lt;/code&gt;는 단 한 번만 실행되고, 나머지는 같은 Promise의 resolve를 기다린다.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;트레이드오프&lt;/strong&gt;: Mutex나 Semaphore를 쓰면 더 정교한 제어가 가능하지만, Node.js의 싱글 스레드 특성상 Promise 캐싱만으로 충분하다. 오히려 외부 라이브러리 의존성을 줄이는 것이 장기적으로 유리하다고 판단했다.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  Lazy Loading: 런타임 동적 생성
&lt;/h3&gt;

&lt;p&gt;서비스 시작 시 Config DB에서 모든 테넌트 설정을 읽어 DataSource를 미리 생성한다. 하지만 &lt;strong&gt;나중에 추가된 테넌트&lt;/strong&gt;도 처리해야 한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getDataSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DataSource&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 초기화 보장&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initialized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ensureInitialized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datasourcesMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Map에 없으면 Config DB에서 조회하여 동적 생성&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTenantDataSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;dataSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datasourcesMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dataSource&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isInitialized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`DataSource for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; not found. Available: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datasourcesMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;에러 메시지에 &lt;strong&gt;현재 사용 가능한 테넌트 목록&lt;/strong&gt;을 포함시킨 게 디버깅에 큰 도움이 된다. 프로덕션에서 "테넌트를 찾을 수 없다"는 에러가 뜨면, 어떤 테넌트들이 로드되어 있는지 바로 확인할 수 있다.&lt;/p&gt;




&lt;h3&gt;
  
  
  커넥션 풀 최적화
&lt;/h3&gt;

&lt;p&gt;초기에는 테넌트당 커넥션을 1000개로 설정했다가 10개 테넌트만 연결해도 DB 서버가 비명을 질렀다. 실제 트래픽을 분석해서 적정값을 찾았다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;createTenantDataSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConfigEntity&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DataSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databaseUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databaseType&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SHARED_ENTITIES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;synchronize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 스키마 동기화는 별도 모듈에서&lt;/span&gt;

    &lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;// 테넌트당 최대 20개 (기존 1000)&lt;/span&gt;
      &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                     &lt;span class="c1"&gt;// 테넌트당 최소 2개 (기존 10)&lt;/span&gt;
      &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// 30초 idle timeout&lt;/span&gt;
      &lt;span class="na"&gt;acquireTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 10초 acquire timeout&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;

    &lt;span class="na"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 5초 connection timeout&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datasourcesMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;핵심 수치 변경:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;설정&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;근거&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;테넌트당 동시 쿼리 최대 15개 관측&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;min&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;유휴 시간대 커넥션 낭비 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idleTimeoutMillis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;30초&lt;/td&gt;
&lt;td&gt;유휴 커넥션 자동 반환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;acquireTimeoutMillis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;10초&lt;/td&gt;
&lt;td&gt;풀 고갈 시 빠른 실패&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;테넌트 10개 기준 총 200개로 충분했고, DB 서버의 &lt;code&gt;max_connections&lt;/code&gt; 설정과도 균형을 맞출 수 있었다.&lt;/p&gt;




&lt;h3&gt;
  
  
  부분 실패 허용 (Promise.allSettled)
&lt;/h3&gt;

&lt;p&gt;시작 시 모든 테넌트 DataSource를 초기화하는데, &lt;strong&gt;한 테넌트가 실패해도 나머지는 정상 동작&lt;/strong&gt;해야 한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;initializeAllDataSources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConfigEntity&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTenantDataSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 실패한 테넌트만 로깅 (서비스는 계속 동작)&lt;/span&gt;
  &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`Failed to initialize DataSource for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Promise.all&lt;/code&gt; 대신 &lt;code&gt;Promise.allSettled&lt;/code&gt;를 쓴 이유가 여기 있다. 한 테넌트 DB가 점검 중이어도 나머지 테넌트는 정상 서비스된다.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;실무에서 이 결정이 빛났던 순간&lt;/strong&gt;: 특정 고객사가 DB 서버를 점검하느라 2시간 동안 연결이 불가능했다. &lt;code&gt;Promise.all&lt;/code&gt;이었다면 서비스 전체가 시작 실패했겠지만, &lt;code&gt;Promise.allSettled&lt;/code&gt; 덕분에 나머지 9개 고객사는 아무 영향 없이 사용할 수 있었다.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  안전한 리소스 정리
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onModuleDestroy&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 모든 테넌트 DataSource 정리 (개별 실패 허용)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;closePromises&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datasourcesMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isInitialized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Failed to close DataSource for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;closePromises&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 완전 초기화&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datasourcesMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configDataSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initialized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initializationPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;정리 시에도 &lt;code&gt;try-catch&lt;/code&gt;로 감싸서 한 DataSource 정리 실패가 다른 정리를 막지 않도록 했다.&lt;/p&gt;




&lt;h3&gt;
  
  
  사용 패턴
&lt;/h3&gt;

&lt;p&gt;호출하는 쪽은 이 복잡함을 전혀 모른다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Service/Usecase에서 사용&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;databaseService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrderEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;tenantCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;OrderEntity&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;테넌트 코드만 넘기면 적절한 DataSource에서 Repository를 반환한다. &lt;strong&gt;이 단순한 인터페이스가 설계의 성공 기준&lt;/strong&gt;이었다.&lt;/p&gt;




&lt;h2&gt;
  
  
  결과 / 배운 점
&lt;/h2&gt;

&lt;p&gt;이 구조로 변경한 후:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;개선율&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;테넌트당 DB 커넥션&lt;/td&gt;
&lt;td&gt;~1000개&lt;/td&gt;
&lt;td&gt;~20개&lt;/td&gt;
&lt;td&gt;98% 감소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메모리 사용량&lt;/td&gt;
&lt;td&gt;지속 증가&lt;/td&gt;
&lt;td&gt;안정&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;장애 격리&lt;/td&gt;
&lt;td&gt;전파 발생&lt;/td&gt;
&lt;td&gt;테넌트별 격리&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;신규 테넌트 추가&lt;/td&gt;
&lt;td&gt;재배포 필요&lt;/td&gt;
&lt;td&gt;Config DB 추가만&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;배운 점:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Promise 캐싱은 동시성 제어의 가장 단순한 해법&lt;/strong&gt; — Mutex나 Semaphore 없이 Promise 객체 하나로 중복 초기화를 막을 수 있다&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Promise.allSettled&lt;/code&gt;는 MSA의 필수 도구&lt;/strong&gt; — 부분 실패를 허용해야 전체 시스템의 가용성이 올라간다&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;커넥션 풀은 보수적으로&lt;/strong&gt; — max 1000은 "혹시 모르니까"의 함정. 실제 트래픽 기반으로 설정하자&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  아쉬웠던 점
&lt;/h2&gt;

&lt;p&gt;솔직히 이 구현에는 몇 가지 알면서도 넘어간 부분이 있다.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Lazy Loading 시 per-tenant 락 미구현&lt;/strong&gt;: 동시에 같은 신규 테넌트에 대한 요청이 들어오면, &lt;code&gt;getDataSource()&lt;/code&gt;에서 Config DB 조회와 DataSource 생성이 중복 실행될 수 있다. Promise 캐싱은 &lt;code&gt;onModuleInit&lt;/code&gt; 레벨에서만 적용했고, 개별 테넌트의 Lazy Loading에는 적용하지 않았다. 현재까지 실제 문제가 된 적은 없지만, 테넌트 수가 더 늘어나면 race condition이 발생할 수 있다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DataSource 수 증가에 따른 메모리 모니터링 부재&lt;/strong&gt;: 테넌트가 늘어날수록 Map에 쌓이는 DataSource가 많아지는데, 이에 대한 메트릭 수집이 없다. 현재 몇 개의 DataSource가 활성 상태인지, 각각의 커넥션 풀 사용률이 어떤지 대시보드에서 확인할 수 없다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Config DB가 단일 장애점(SPOF)&lt;/strong&gt;: 모든 테넌트 설정을 하나의 Config DB에서 읽는다. 이 DB가 죽으면 Lazy Loading이 불가능해지고, 서비스 재시작 시 어떤 테넌트도 초기화할 수 없다. Eager Loading으로 이미 로드된 테넌트는 동작하지만, 신규 테넌트 추가나 설정 변경은 불가능하다.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  향후 보완할 점
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LRU 기반 DataSource 캐시 eviction&lt;/strong&gt;: 장기간 사용되지 않는 테넌트의 DataSource를 자동으로 정리하는 메커니즘 도입. &lt;code&gt;Map&lt;/code&gt; 대신 TTL이 있는 캐시를 사용하거나, 마지막 접근 시간을 기록해서 주기적으로 evict하는 방식을 검토 중이다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;커넥션 풀 메트릭 수집 (Prometheus)&lt;/strong&gt;: 테넌트별 활성/유휴 커넥션 수, 풀 대기 시간, acquire timeout 횟수 등을 Prometheus로 수집하고 Grafana 대시보드로 시각화할 계획이다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Config DB 이중화&lt;/strong&gt;: PostgreSQL Streaming Replication 또는 읽기 전용 복제본을 두어, Primary 장애 시에도 테넌트 설정 조회가 가능하도록 구성. 더 나아가 Config 정보를 Redis에 캐싱하는 방안도 고려 중이다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;테넌트별 커넥션 풀 동적 조정&lt;/strong&gt;: 현재는 모든 테넌트에 동일한 &lt;code&gt;max: 20&lt;/code&gt; 설정을 사용하지만, 트래픽이 많은 대형 고객사와 소형 고객사의 풀 사이즈를 다르게 가져가야 한다. Config DB에 테넌트별 풀 설정을 추가하고, 트래픽 패턴에 따라 런타임에 조정하는 것이 목표다.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;Claude Code로 이 서비스를 리팩토링할 때, 기존 코드의 문제점(커넥션 폭발, 초기화 중복)을 분석하고 Promise 캐싱 패턴을 제안받았다. 특히 &lt;code&gt;onModuleDestroy&lt;/code&gt;에서 Map 정리 순서와 &lt;code&gt;Promise.allSettled&lt;/code&gt; 적용은 AI가 놓치기 쉬운 엣지 케이스를 짚어줬다.&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>typescript</category>
      <category>architecture</category>
      <category>database</category>
    </item>
    <item>
      <title>NestJS MSA에서 TypeORM FindOperator가 사라지는 문제 — Interceptor로 해결하기</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Sun, 08 Mar 2026 12:56:36 +0000</pubDate>
      <link>https://dev.to/archist/nestjs-msaeseo-typeorm-findoperatorga-sarajineun-munje-interceptorro-haegyeolhagi-cpo</link>
      <guid>https://dev.to/archist/nestjs-msaeseo-typeorm-findoperatorga-sarajineun-munje-interceptorro-haegyeolhagi-cpo</guid>
      <description>&lt;h2&gt;
  
  
  문제: TCP로 보내면 FindOperator가 깨진다
&lt;/h2&gt;

&lt;p&gt;NestJS 마이크로서비스 아키텍처에서 Gateway → Microservice 간 TCP 통신을 사용한다. 문제는 TypeORM의 &lt;code&gt;FindOperator&lt;/code&gt;가 클래스 인스턴스라는 점이다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Gateway에서 이렇게 보내면&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;In&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;READY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SENT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-01-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-12-31&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TCP 전송 과정에서 JSON 직렬화가 일어난다. &lt;code&gt;In([...])&lt;/code&gt; 같은 FindOperator는 클래스 인스턴스이므로, 직렬화되면 이렇게 바뀐다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"READY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SENT"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"_useParameter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"_multipleParameters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"createdAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"between"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"2025-01-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-12-31"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Microservice에서 이 데이터를 그대로 TypeORM &lt;code&gt;find()&lt;/code&gt;에 넘기면? &lt;strong&gt;프로토타입 체인이 없는 순수 객체&lt;/strong&gt;이므로 TypeORM이 인식하지 못한다. 조건 없이 전체 조회가 되거나, 에러가 발생한다.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CTO 관점&lt;/strong&gt;: 이 문제는 MSA 전환 시 가장 간과하기 쉬운 유형이다. 모놀리식에서는 존재하지 않던 "직렬화 경계(serialization boundary)" 문제가 서비스를 분리하는 순간 터져나온다. &lt;strong&gt;단일 프로세스에서 잘 동작하던 코드가 MSA에서는 근본적으로 동작하지 않을 수 있다&lt;/strong&gt;는 것을 팀 전체가 인식해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  발견 과정
&lt;/h2&gt;

&lt;p&gt;필터가 있는 목록 API를 TCP 기반 MSA로 전환하면서 발견했다. 단일 서비스였을 때는 &lt;code&gt;@Transform()&lt;/code&gt; 데코레이터가 생성한 FindOperator가 같은 프로세스 내에서 바로 사용되니까 문제가 없었다. MSA로 분리하는 순간, JSON 직렬화라는 벽에 부딪혔다.&lt;/p&gt;

&lt;p&gt;핵심 인사이트: TypeORM FindOperator는 내부적으로 &lt;code&gt;_type&lt;/code&gt;과 &lt;code&gt;_value&lt;/code&gt; 프로퍼티를 갖고 있고, 이것이 JSON 직렬화를 살아남는다. &lt;strong&gt;역직렬화만 해주면 된다.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;직렬화 경계에서의 데이터 변환:

Gateway (클래스 인스턴스)     →  TCP (JSON)              →  Microservice (순수 객체)
In(["READY","SENT"])         →  {_type:"in",_value:[...]}  →  ??? (TypeORM 인식 불가)
                                                            ↓ Interceptor 적용
                                                          In(["READY","SENT"]) ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  해결: TypeORM Deserializer Interceptor
&lt;/h2&gt;

&lt;p&gt;NestJS Interceptor로 RPC 요청의 페이로드를 가로채서, &lt;code&gt;_type&lt;/code&gt; 프로퍼티가 있는 객체를 실제 FindOperator로 복원한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TypeOrmDeserializerInterceptor&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;NestInterceptor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CallHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Observable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rpc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getArgs&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deserializeTypeOrmOperators&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;deserializeTypeOrmOperators&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deserializeTypeOrmOperators&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;between&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;In&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;like&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Like&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;isNull&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;IsNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// Not의 내부 값을 재귀적으로 역직렬화&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deserializeTypeOrmOperators&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inner&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// ... ArrayContains, ArrayOverlap, MoreThanOrEqual 등&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;// 일반 객체는 재귀적으로 모든 속성 처리&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deserializeTypeOrmOperators&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;동작 흐름:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gateway                    TCP Transport              Microservice
In(["READY","SENT"])  →  {_type:"in",_value:[...]}  →  Interceptor  →  In(["READY","SENT"])
Between(a, b)         →  {_type:"between",_value:..} →  Interceptor  →  Between(a, b)
Not(IsNull())         →  {_type:"not",_value:{       →  Interceptor  →  Not(IsNull())
                           _type:"isNull"}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;설계 결정&lt;/strong&gt;: Interceptor를 선택한 이유는 &lt;strong&gt;비즈니스 코드 변경 없이&lt;/strong&gt; 인프라 레벨에서 문제를 해결할 수 있기 때문이다. Custom Pipe나 Guard로도 가능하지만, Interceptor가 요청/응답 양쪽을 다룰 수 있어 확장성이 좋다.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Not(IsNull()) — 재귀가 필요한 이유
&lt;/h2&gt;

&lt;p&gt;처음엔 단순한 &lt;code&gt;_type → 연산자&lt;/code&gt; 매핑으로 끝날 줄 알았다. &lt;code&gt;Not(IsNull())&lt;/code&gt;을 만나기 전까지는.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Not(IsNull())&lt;/code&gt;이 직렬화되면:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"isNull"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"_value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Not&lt;/code&gt;의 &lt;code&gt;_value&lt;/code&gt; 안에 또 다른 FindOperator가 들어있다. 그래서 &lt;code&gt;Not&lt;/code&gt; 처리 시 내부 값을 &lt;strong&gt;재귀적으로 역직렬화&lt;/strong&gt;해야 한다. &lt;code&gt;IsNull()&lt;/code&gt;의 &lt;code&gt;_value&lt;/code&gt;는 &lt;code&gt;null&lt;/code&gt;이므로, 초기 버전에서 &lt;code&gt;if (obj._value)&lt;/code&gt; 같은 truthy 체크를 했더니 &lt;code&gt;IsNull&lt;/code&gt;이 무시되는 버그도 있었다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;이 버그를 발견하기까지 걸린 시간: 이틀.&lt;/strong&gt; &lt;code&gt;Not(IsNull())&lt;/code&gt; 조건이 있는 쿼리가 전체 데이터를 반환하는데, 필터가 아예 적용되지 않은 건지 &lt;code&gt;Not&lt;/code&gt; 조건만 무시된 건지 구분하기 어려웠다. &lt;code&gt;null&lt;/code&gt;이 falsy라는 JavaScript의 기본 동작이 이렇게 치명적일 수 있다는 교훈을 얻었다.&lt;/p&gt;




&lt;h2&gt;
  
  
  DTO와의 연동
&lt;/h2&gt;

&lt;p&gt;Gateway 쪽 DTO에서 &lt;code&gt;@Transform()&lt;/code&gt;으로 FindOperator를 생성한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderFilterInput&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;In&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;In&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;FindOperator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DateDto&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;FindOperator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Microservice 컨트롤러에서 Interceptor를 적용하면, DTO의 Transform → JSON 직렬화 → Interceptor 역직렬화가 자동으로 이어진다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;MessagePattern&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseInterceptors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TypeOrmDeserializerInterceptor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderListInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrderListOutput&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// body.filter.status는 이미 In(["READY", "SENT"])로 복원됨&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByPagination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  적용 범위 선택
&lt;/h2&gt;

&lt;p&gt;두 가지 등록 방식을 상황에 따라 사용한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. 모듈 전역: 모든 핸들러에 필터가 있을 때&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provide&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APP_INTERCEPTOR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;useClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TypeOrmDeserializerInterceptor&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. 핸들러 단위: 특정 API만 필터를 사용할 때&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;MessagePattern&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseInterceptors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TypeOrmDeserializerInterceptor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderListInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;등록 방식&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;th&gt;권장 상황&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;모듈 전역&lt;/td&gt;
&lt;td&gt;누락 방지&lt;/td&gt;
&lt;td&gt;불필요한 순회 발생&lt;/td&gt;
&lt;td&gt;대부분 핸들러가 필터 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;핸들러 단위&lt;/td&gt;
&lt;td&gt;정확한 범위&lt;/td&gt;
&lt;td&gt;데코레이터 누락 가능&lt;/td&gt;
&lt;td&gt;필터 사용 핸들러가 소수&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;필터가 필요 없는 &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt; 핸들러에는 불필요한 재귀 순회를 하지 않도록 핸들러 단위 적용을 권장한다.&lt;/p&gt;




&lt;h2&gt;
  
  
  결과 / 배운 점
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MSA에서 클래스 인스턴스 전송은 근본적으로 불가능하다.&lt;/strong&gt; JSON 직렬화를 거치는 순간 프로토타입 체인이 사라진다. 이것은 TypeORM에만 국한된 문제가 아니다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeORM FindOperator의 &lt;code&gt;_type&lt;/code&gt; 프로퍼티가 구원이었다.&lt;/strong&gt; 내부 구조를 들여다보니 역직렬화에 필요한 정보가 이미 있었다. 라이브러리 소스를 읽는 습관이 여기서 빛을 발했다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;재귀 설계에서 edge case는 항상 중첩에서 나온다.&lt;/strong&gt; &lt;code&gt;Not(IsNull())&lt;/code&gt;처럼 연산자 안에 연산자가 들어가는 케이스를 놓치기 쉽다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interceptor 패턴이 인프라 레벨 문제에 적합하다.&lt;/strong&gt; 비즈니스 코드를 전혀 수정하지 않고, 직렬화/역직렬화 문제를 한 곳에서 해결했다.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  아쉬웠던 점
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TypeORM 버전 업데이트 시 &lt;code&gt;_type&lt;/code&gt; 값 변경 리스크&lt;/strong&gt;: 이 Interceptor는 TypeORM의 &lt;strong&gt;내부 구현(private property)&lt;/strong&gt;에 의존한다. &lt;code&gt;_type&lt;/code&gt;은 public API가 아니므로, TypeORM 메이저 버전 업데이트 시 이름이 바뀌거나 구조가 변경될 수 있다. 현재 TypeORM 0.3.x 기준으로 작성했는데, 0.4.x나 1.0에서 깨질 가능성을 인지하면서도 별도의 방어 장치를 두지 않았다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;ArrayContains&lt;/code&gt; 등 일부 연산자 지원 누락 발견이 늦었음&lt;/strong&gt;: 초기에 &lt;code&gt;In&lt;/code&gt;, &lt;code&gt;Between&lt;/code&gt;, &lt;code&gt;Like&lt;/code&gt;, &lt;code&gt;IsNull&lt;/code&gt;, &lt;code&gt;Not&lt;/code&gt;만 구현했는데, 프로덕션에 배포한 후 한참 뒤에야 &lt;code&gt;ArrayContains&lt;/code&gt;, &lt;code&gt;ArrayOverlap&lt;/code&gt;, &lt;code&gt;MoreThanOrEqual&lt;/code&gt; 등이 필요한 쿼리에서 조용히 실패하는 것을 발견했다. "조용한 실패"가 가장 위험하다 — 에러 대신 필터가 무시되어 전체 데이터가 반환되는 식이었다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;성능 오버헤드 측정 미비&lt;/strong&gt;: 모든 RPC 페이로드를 재귀적으로 순회하는 비용을 측정하지 않았다. 현재 페이로드가 작아서 체감되지 않지만, 대형 응답 객체에 전역 적용하면 불필요한 오버헤드가 발생할 수 있다.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  향후 보완할 점
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TypeORM 버전별 &lt;code&gt;_type&lt;/code&gt; 매핑 자동화 테스트&lt;/strong&gt;: CI 파이프라인에 TypeORM 버전 업그레이드 시 자동으로 돌아가는 테스트를 추가할 계획이다. 각 FindOperator를 직렬화 → 역직렬화하여 원본과 동일한 쿼리를 생성하는지 검증한다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;FindOperator 커버리지 100% 단위 테스트&lt;/strong&gt;: TypeORM이 제공하는 모든 FindOperator(&lt;code&gt;Raw&lt;/code&gt;, &lt;code&gt;Any&lt;/code&gt;, &lt;code&gt;ArrayContains&lt;/code&gt;, &lt;code&gt;ArrayOverlap&lt;/code&gt;, &lt;code&gt;ILike&lt;/code&gt;, &lt;code&gt;LessThan&lt;/code&gt;, &lt;code&gt;MoreThan&lt;/code&gt; 등)에 대한 직렬화/역직렬화 테스트를 작성하고, 미지원 연산자가 들어오면 명시적으로 에러를 throw하도록 개선한다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;역직렬화 성능 벤치마크&lt;/strong&gt;: 페이로드 크기별(100B, 1KB, 10KB, 100KB) 역직렬화 소요 시간을 측정하고, 임계치를 넘기면 경고를 내는 메트릭을 추가한다. 이를 근거로 모듈 전역 vs 핸들러 단위 적용 기준을 정량적으로 제시할 수 있다.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;TypeORM FindOperator의 내부 구조(&lt;code&gt;_type&lt;/code&gt;, &lt;code&gt;_value&lt;/code&gt;)를 Claude Code로 분석했다. TypeORM 소스에서 &lt;code&gt;FindOperator&lt;/code&gt; 클래스의 프로퍼티 목록과 각 연산자별 &lt;code&gt;_type&lt;/code&gt; 값을 빠르게 추출해서, 지원해야 할 연산자 목록을 확정하는 데 활용했다.&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>typeorm</category>
      <category>typescript</category>
      <category>architecture</category>
    </item>
    <item>
      <title>NestJS 마이크로서비스에서 SSE 스트리밍 추상화하기 — Redis Pub/Sub 기반 3-Tier Base Class 설계</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Sun, 08 Mar 2026 08:44:55 +0000</pubDate>
      <link>https://dev.to/archist/nestjs-maikeuroseobiseueseo-sse-seuteuriming-cusanghwahagi-redis-pubsub-giban-3-tier-base-class-seolgye-2n8k</link>
      <guid>https://dev.to/archist/nestjs-maikeuroseobiseueseo-sse-seuteuriming-cusanghwahagi-redis-pubsub-giban-3-tier-base-class-seolgye-2n8k</guid>
      <description>&lt;h1&gt;
  
  
  NestJS 마이크로서비스에서 SSE 스트리밍 추상화하기 — Redis Pub/Sub 기반 3-Tier Base Class 설계
&lt;/h1&gt;

&lt;h2&gt;
  
  
  배경 / 문제 상황
&lt;/h2&gt;

&lt;p&gt;MSA 환경에서 실시간 이벤트를 클라이언트에 전달해야 했다. 우리 서비스는 ECS 위에 여러 인스턴스가 뜨고, 이벤트를 발행하는 마이크로서비스와 SSE를 내려보내는 Gateway가 물리적으로 다른 프로세스다.&lt;/p&gt;

&lt;p&gt;문제는 이렇다:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;인스턴스 A&lt;/strong&gt;의 BullMQ Consumer가 작업을 완료하고 이벤트를 발생시킨다&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;인스턴스 B&lt;/strong&gt;의 Gateway에 클라이언트가 SSE로 연결되어 있다&lt;/li&gt;
&lt;li&gt;A에서 발생한 이벤트가 B의 클라이언트에게 도달해야 한다&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;단순히 &lt;code&gt;EventEmitter&lt;/code&gt;로는 불가능하다. 프로세스 경계를 넘어야 하니까.&lt;/p&gt;

&lt;p&gt;게다가 SSE 도메인이 점점 늘어나면서 (실시간 편집 세션, 인증 세션, 채팅 등) 매번 Redis 구독, 하트비트, 연결 정리 코드를 반복 작성하고 있었다. 한 곳에서 버그를 고치면 다른 곳에는 여전히 남아있는 상황이 반복됐다.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CTO 관점에서의 설계 판단&lt;/strong&gt;: SSE vs WebSocket vs Long Polling을 비교했다. WebSocket은 양방향 통신이 필요 없는 우리 케이스에서 과하고, ALB WebSocket 연결 유지 비용도 부담이었다. Long Polling은 지연이 불가피했다. &lt;strong&gt;SSE는 HTTP/1.1 기반으로 인프라 변경 없이 적용 가능하고, 단방향 실시간 전달에 최적화되어 있어 선택&lt;/strong&gt;했다.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  접근 방법
&lt;/h2&gt;

&lt;p&gt;SSE 연결의 생명주기를 분석하면 도메인과 무관하게 항상 같은 흐름이 반복된다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;연결 → 세션 시작 → Redis 구독 → 이벤트 수신/전송 → 하트비트 → 연결 종료 → 정리
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 흐름을 3개의 추상 클래스로 분리했다:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;클래스&lt;/th&gt;
&lt;th&gt;책임&lt;/th&gt;
&lt;th&gt;위치&lt;/th&gt;
&lt;th&gt;도메인이 구현할 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BaseSsePublisher&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Redis Pub/Sub 채널에 이벤트 발행&lt;/td&gt;
&lt;td&gt;마이크로서비스&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;getChannelName()&lt;/code&gt;, 이벤트 발행 메서드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BaseSseSubscriber&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Redis 구독 + SSE 응답 스트리밍&lt;/td&gt;
&lt;td&gt;Gateway&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;handleMessage()&lt;/code&gt;, &lt;code&gt;sendConnected()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BaseSseOrchestrator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;연결 생명주기 전체 조율&lt;/td&gt;
&lt;td&gt;Gateway&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;startSession()&lt;/code&gt;, &lt;code&gt;endSession()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;핵심 설계 원칙은 &lt;strong&gt;"도메인 로직만 구현하면 나머지는 Base가 처리한다"&lt;/strong&gt;였다.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;트레이드오프&lt;/strong&gt;: 상속 기반 설계는 컴포지션보다 유연성이 떨어진다. 하지만 SSE의 생명주기가 모든 도메인에서 &lt;strong&gt;동일한 순서로 실행되어야 하는&lt;/strong&gt; 제약이 있어서, Template Method 패턴이 적합하다고 판단했다. 잘못된 순서로 호출하는 실수를 컴파일 타임에 방지할 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  구현
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Publisher — 이벤트 발행의 단일 진입점
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BaseSsePublisher&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;channelPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;redisService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RedisService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;getChannelName&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;publishEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redisService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;createSubscriber&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redisService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSubscriber&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;마이크로서비스에서 BullMQ Handler가 작업을 완료하면 Publisher를 호출한다. Redis Pub/Sub이므로 어떤 인스턴스에서 발행하든, 구독 중인 모든 Gateway 인스턴스가 수신한다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BullMQ Handler 내부&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ssePublisher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publishStatusChanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMPLETED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  2. Subscriber — SSE 응답 스트리밍의 핵심
&lt;/h3&gt;

&lt;p&gt;여기가 가장 까다로운 부분이다. Express &lt;code&gt;Response&lt;/code&gt; 객체를 직접 다루면서 Redis 구독, 하트비트, 연결 끊김 감지를 모두 처리해야 한다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BaseSseSubscriber&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="na"&gt;subscriber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RedisSubscriber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nx"&gt;isClosed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nx"&gt;heartbeatInterval&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;NodeJS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nx"&gt;onWriteFailureCallback&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;redisService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RedisService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;enableHeartbeat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;heartbeatIntervalMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;redisService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSubscriber&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;setupHeaders&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Connection&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keep-alive&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Accel-Buffering&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Nginx 버퍼링 비활성화&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flushHeaders&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 서브클래스가 이벤트 타입별 분기만 구현&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;sendConnected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;가 빠지면 Nginx 뒤에서 이벤트가 지연된다.&lt;/strong&gt; 실제로 이것 때문에 프로덕션에서 이벤트가 30초씩 밀리는 이슈를 겪었다. Base 클래스에 넣어둔 이유다.&lt;/p&gt;




&lt;h4&gt;
  
  
  Write Failure 감지
&lt;/h4&gt;

&lt;p&gt;SSE에서 가장 흔한 버그 중 하나가 "이미 끊긴 연결에 계속 쓰는 것"이다. &lt;code&gt;res.write()&lt;/code&gt;의 반환값으로 이를 감지한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;sendEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isClosed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`event: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// 버퍼가 가득 찼거나 연결이 끊김&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onWriteFailureCallback&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onWriteFailureCallback&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;res.write()&lt;/code&gt;가 &lt;code&gt;false&lt;/code&gt;를 반환하면 TCP 버퍼가 가득 찬 것이다. 대부분 클라이언트가 이미 떠났다는 의미다. 이 콜백으로 세션 상태를 즉시 업데이트할 수 있다.&lt;/p&gt;




&lt;h4&gt;
  
  
  리소스 정리 — 중복 호출 방지
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isClosed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 가드&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isClosed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heartbeatInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heartbeatInterval&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quit&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isClosed&lt;/code&gt; 플래그가 없으면 &lt;code&gt;res.on('close')&lt;/code&gt;와 에러 핸들러가 동시에 cleanup을 호출하면서 Redis unsubscribe가 두 번 날아간다.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;프로덕션에서 배운 것&lt;/strong&gt;: cleanup에서 &lt;code&gt;.catch(() =&amp;gt; {})&lt;/code&gt;로 에러를 무시하는 것은 편의상 그렇게 했지만, 최소한 로깅은 해야 한다. Redis 연결 문제가 cleanup에서 발생하면 디버깅 단서가 완전히 사라진다.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  3. Orchestrator — 생명주기 조율
&lt;/h3&gt;

&lt;p&gt;Orchestrator는 "세션 시작 → SSE 설정 → 이벤트 등록 → 종료 처리"라는 전체 흐름을 하나의 &lt;code&gt;start()&lt;/code&gt; 메서드로 캡슐화한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BaseSseOrchestrator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;
  &lt;span class="nx"&gt;TSubscriber&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;BaseSseSubscriber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;TStartResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nx"&gt;sessionStarted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;createSseSubscriber&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;TSubscriber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;startSession&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TStartResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;endSession&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;getConnectedData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TStartResult&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionStarted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupHeaders&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerEventHandlers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendConnected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getConnectedData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionResult&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startHeartbeat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessionStarted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sessionStarted&lt;/code&gt; 플래그가 중요하다. &lt;code&gt;startSession()&lt;/code&gt;에서 예외가 발생하면 &lt;code&gt;endSession()&lt;/code&gt;을 호출하면 안 된다. &lt;strong&gt;시작하지도 않은 세션을 종료하면 다른 사용자의 세션을 건드릴 수 있다.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;에러 발생 시에도 SSE 프로토콜을 지킨다. JSON을 직접 내려보내는 게 아니라 SSE 포맷으로 에러 이벤트를 전송한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;handleError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flushHeaders&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`event: error\ndata: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Connection failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  실제 사용 — 도메인 구현체
&lt;/h3&gt;

&lt;p&gt;새로운 SSE 도메인을 추가할 때 구현해야 할 것:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Publisher (마이크로서비스)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderSsePublisher&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BaseSsePublisher&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrderEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;channelPrefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order:status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;getChannelName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channelPrefix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Subscriber (Gateway)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderSseSubscriber&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BaseSseSubscriber&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrderEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OrderEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;sendConnected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CONNECTED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Orchestrator (Gateway)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderSseOrchestrator&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;BaseSseOrchestrator&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OrderSseSubscriber&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;createSseSubscriber&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderSseSubscriber&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;startSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* TCP로 마이크로서비스 호출 */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;endSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* 세션 정리 */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;getConnectedData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;새 도메인 추가 시 작성하는 코드 vs Base가 처리하는 코드:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;도메인이 구현&lt;/th&gt;
&lt;th&gt;Base가 처리&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;채널명 생성&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이벤트 타입별 분기&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;세션 시작/종료&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis 구독/해제&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSE 헤더 설정&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;하트비트&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write failure 감지&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;연결 끊김 정리&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;에러 핸들링&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  잊기 쉬운 함정: Compression 미들웨어
&lt;/h3&gt;

&lt;p&gt;SSE 라우트는 반드시 compression 미들웨어에서 제외해야 한다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// gateway.module.ts&lt;/span&gt;
&lt;span class="nf"&gt;compression&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/order/sse/subscribe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 압축 비활성화&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;compression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;compression이 켜져 있으면 응답 버퍼링이 발생해서 이벤트가 실시간으로 내려가지 않는다. 이것도 Base 수준에서 해결할 수 없는 문제라 문서화해뒀다.&lt;/p&gt;




&lt;h2&gt;
  
  
  결과 / 배운 점
&lt;/h2&gt;

&lt;p&gt;이 구조를 적용한 뒤 새로운 SSE 도메인을 추가하는 데 걸리는 시간이 크게 줄었다. 하트비트, 연결 정리, Write failure 감지 같은 보일러플레이트를 신경 쓸 필요가 없다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;핵심 교훈:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSE는 "보내기"보다 &lt;strong&gt;"정리"가 어렵다&lt;/strong&gt;. &lt;code&gt;isClosed&lt;/code&gt; 가드, &lt;code&gt;sessionStarted&lt;/code&gt; 플래그 같은 방어 코드가 프로덕션 안정성을 결정한다&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;res.write()&lt;/code&gt;의 반환값을 무시하면 좀비 연결이 쌓인다. 반드시 체크해야 한다&lt;/li&gt;
&lt;li&gt;Redis Pub/Sub은 다중 인스턴스 SSE의 가장 실용적인 해법이다. Kafka나 RabbitMQ는 이 용도엔 과하다&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;와 compression 제외는 코드 리뷰에서 빠지기 쉬운데, 빠지면 프로덕션에서 바로 장애가 된다&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  아쉬웠던 점
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Redis Pub/Sub의 at-most-once 특성으로 이벤트 유실 가능&lt;/strong&gt;: Redis Pub/Sub은 구독자가 없으면 메시지를 버린다. 클라이언트가 SSE 재연결하는 짧은 순간에 발행된 이벤트는 영원히 유실된다. 현재 서비스 특성상 "최종 상태만 정확하면 되는" 케이스가 대부분이라 큰 문제는 아니었지만, 정확한 이벤트 순서가 중요한 도메인에서는 치명적일 수 있다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;하트비트만으로 연결 상태를 판단하는 한계&lt;/strong&gt;: 30초 간격 하트비트로 연결 유지를 확인하지만, 하트비트 사이에 연결이 끊어지면 최대 30초간 감지가 안 된다. 그 사이에 세션 상태가 "활성"으로 남아있어서, 같은 사용자가 다른 디바이스에서 접속할 때 중복 세션 문제가 발생한 적이 있다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Orchestrator의 제네릭 타입이 복잡해져서 신규 도메인 추가 시 학습 비용&lt;/strong&gt;: &lt;code&gt;BaseSseOrchestrator&amp;lt;TSubscriber extends BaseSseSubscriber&amp;lt;TEvent&amp;gt;, TStartResult&amp;gt;&lt;/code&gt; 같은 중첩 제네릭이 되면서, TypeScript에 익숙하지 않은 팀원이 새 도메인을 추가할 때 타입 에러와 씨름하는 시간이 길어졌다. 추상화의 편의성을 타입 복잡성이 상쇄하는 아이러니한 상황이었다.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  향후 보완할 점
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Redis Streams 도입 검토 (이벤트 유실 방지)&lt;/strong&gt;: Redis Pub/Sub 대신 Redis Streams를 사용하면 메시지가 영구 저장되어, 구독자가 없는 동안의 이벤트도 나중에 소비할 수 있다. Consumer Group을 활용하면 다중 인스턴스 환경에서도 정확히 한 번 처리(at-least-once)가 가능하다. 다만 Pub/Sub 대비 복잡도가 증가하므로, 이벤트 유실이 치명적인 도메인부터 단계적으로 적용할 계획이다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SSE 재연결 시 &lt;code&gt;lastEventId&lt;/code&gt; 기반 이벤트 재전송&lt;/strong&gt;: SSE 표준의 &lt;code&gt;Last-Event-ID&lt;/code&gt; 헤더를 활용하여, 재연결 시 놓친 이벤트를 재전송하는 메커니즘을 구현할 계획이다. Redis Streams의 메시지 ID와 SSE의 &lt;code&gt;id&lt;/code&gt; 필드를 연계하면, 클라이언트가 마지막으로 받은 이벤트 이후의 메시지만 정확하게 재전송할 수 있다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Base 클래스 가이드 문서 정비&lt;/strong&gt;: 제네릭 타입의 복잡성을 코드로 해결하기보다, &lt;strong&gt;잘 작성된 예제와 가이드&lt;/strong&gt;로 학습 비용을 줄이는 것이 현실적이라고 판단했다. 각 도메인별 구현 예제, 자주 발생하는 타입 에러와 해결법, 체크리스트를 포함한 내부 가이드 문서를 작성 중이다.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;Claude Code로 3개의 Base 클래스를 동시에 리팩토링하면서 기존 구현체와의 호환성을 검증했다. 특히 제네릭 타입 파라미터 설계(&lt;code&gt;BaseSseOrchestrator&amp;lt;TSubscriber, TStartResult&amp;gt;&lt;/code&gt;)에서 여러 도메인의 사용 패턴을 한꺼번에 분석해 최적의 인터페이스를 도출하는 데 AI가 효과적이었다.&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>sse</category>
      <category>redis</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Claude Code를 팀의 시니어 개발자로 만들기: .claude 디렉토리 설계 전략</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Sun, 08 Mar 2026 08:41:59 +0000</pubDate>
      <link>https://dev.to/archist/claude-codereul-timyi-sinieo-gaebaljaro-mandeulgi-claude-diregtori-seolgye-jeonryag-5cmo</link>
      <guid>https://dev.to/archist/claude-codereul-timyi-sinieo-gaebaljaro-mandeulgi-claude-diregtori-seolgye-jeonryag-5cmo</guid>
      <description>&lt;h2&gt;
  
  
  배경 / 문제 상황
&lt;/h2&gt;

&lt;p&gt;AI 코딩 도구를 사용하다 보면 한 가지 불만이 생긴다. &lt;strong&gt;매번 같은 규칙을 알려줘야 한다는 것.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Entity에는 반드시 comment를 넣어줘", "DTO에는 ApiProperty 빼먹지 마", "import 순서는 이렇게 해"... 10명짜리 팀에서도 코드 리뷰 때마다 같은 코멘트를 반복하는데, AI한테까지 매번 프롬프트로 컨벤션을 주입해야 한다니.&lt;/p&gt;

&lt;p&gt;더 큰 문제는 &lt;strong&gt;컨텍스트 유실&lt;/strong&gt;이다. 대화가 길어지거나 새 세션을 열 때마다 AI는 프로젝트의 아키텍처, 네이밍 규칙, 배포 전략 같은 맥락을 잃어버린다. 이전 대화에서 "우리는 Blue/Green 배포를 하니까 Request DTO에 필수 필드를 갑자기 추가하면 안 돼"라고 설명해놓아도 다음 세션에서는 아무것도 기억하지 못한다.&lt;/p&gt;

&lt;p&gt;Claude Code의 &lt;code&gt;.claude&lt;/code&gt; 디렉토리를 체계적으로 설계하면, AI가 프로젝트의 규칙과 패턴을 &lt;strong&gt;항상&lt;/strong&gt; 이해한 상태에서 코드를 작성한다. 마치 프로젝트에 오래 있었던 시니어 개발자처럼.&lt;/p&gt;

&lt;p&gt;나는 NestJS 기반 MSA 모노레포에서 &lt;code&gt;.claude&lt;/code&gt; 디렉토리를 설계하면서 얻은 경험을 공유한다.&lt;/p&gt;




&lt;h2&gt;
  
  
  접근 방법
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.claude&lt;/code&gt; 디렉토리는 크게 &lt;strong&gt;5개 레이어&lt;/strong&gt;로 나눈다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.claude/
├── settings.json        # 전역 설정 (hooks, MCP, permissions)
├── rules/               # 코드 작성 규칙 (항상 로드)
├── agents/              # 역할별 전문 에이전트 (12개)
├── skills/              # 슬래시 명령어 워크플로우 (11개)
├── hooks/               # 자동화 훅 (3개)
└── docs/                # 참조 문서
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;각 레이어의 역할이 명확히 구분되어 있다:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;레이어&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;비유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rules&lt;/strong&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;strong&gt;Agents&lt;/strong&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;strong&gt;Skills&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;무엇을 할 것인지&lt;/td&gt;
&lt;td&gt;반복 업무 SOP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;자동으로 체크할 것&lt;/td&gt;
&lt;td&gt;CI/CD 파이프라인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Docs&lt;/strong&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;/div&gt;




&lt;h2&gt;
  
  
  구현
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Rules: 코드 컨벤션을 강제하는 법
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;.claude/rules/&lt;/code&gt; 에 도메인별 규칙 파일을 분리했다. 총 14개의 규칙 파일이 있다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rules/
├── entity.md              # Entity 작성 규칙
├── dto.md                 # DTO 데코레이터 규칙
├── usecase.md             # Usecase 20줄 제한
├── gateway.md             # Gateway에 비즈니스 로직 금지
├── bullmq.md              # Consumer + Handler 분리 패턴
├── backward-compatible.md # Blue/Green 배포 호환성
├── error-handling.md      # 에러 전파 흐름
├── tcp-communication.md   # 마이크로서비스 통신 규칙
├── sse.md                 # SSE 스트리밍 규칙
├── e2e-test.md            # E2E 테스트 병렬 실행 규칙
├── distributed-cron.md    # 분산 크론 잡 규칙
├── module.md              # NestJS 모듈 구성 규칙
├── import-order.md        # Import 순서 규칙
└── microservice.md        # 마이크로서비스 레이어 규칙
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;핵심은 &lt;strong&gt;구체적인 코드 예시&lt;/strong&gt;를 함께 넣는 것이다. "이렇게 하지 마" + "이렇게 해"의 Before/After 패턴이 효과적이다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 잘못된 예 - 기존 호출자가 mode를 안 보내면 실패&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 올바른 예 - 기존 호출자가 안 보내도 동작&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsOptional&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;legacy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;CTO 관점에서 중요한 포인트&lt;/strong&gt;: Rules 파일 하나가 보통 30~80줄인데, 이 분량이 적절하다. 너무 길면 AI가 핵심을 놓치고, 너무 짧으면 맥락이 부족하다. 하나의 Rule 파일은 &lt;strong&gt;하나의 관심사&lt;/strong&gt;만 다루도록 했다.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Agents: 역할별 전문가 팀 구성
&lt;/h3&gt;

&lt;p&gt;12개의 전문 에이전트를 구성했다. 각 에이전트는 &lt;strong&gt;모델&lt;/strong&gt;, &lt;strong&gt;사용 가능 도구&lt;/strong&gt;, &lt;strong&gt;출력 형식&lt;/strong&gt;이 다르다:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;에이전트&lt;/th&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;도구 제한&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;planner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;opus&lt;/td&gt;
&lt;td&gt;구현 계획 수립&lt;/td&gt;
&lt;td&gt;Read-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;code-reviewer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sonnet&lt;/td&gt;
&lt;td&gt;코드 리뷰&lt;/td&gt;
&lt;td&gt;Read-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;debugger&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sonnet&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;monorepo-builder&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sonnet&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;api-designer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;haiku&lt;/td&gt;
&lt;td&gt;API 엔드포인트 설계&lt;/td&gt;
&lt;td&gt;전체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dto-entity-designer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;haiku&lt;/td&gt;
&lt;td&gt;Entity/DTO 생성&lt;/td&gt;
&lt;td&gt;전체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refactorer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sonnet&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;test-designer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sonnet&lt;/td&gt;
&lt;td&gt;테스트 설계&lt;/td&gt;
&lt;td&gt;Read-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;business-logic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sonnet&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;feature-researcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sonnet&lt;/td&gt;
&lt;td&gt;기존 기능 분석&lt;/td&gt;
&lt;td&gt;Read-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;migration-designer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;haiku&lt;/td&gt;
&lt;td&gt;DB 마이그레이션 설계&lt;/td&gt;
&lt;td&gt;전체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;docker-troubleshooter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;sonnet&lt;/td&gt;
&lt;td&gt;Docker 문제 해결&lt;/td&gt;
&lt;td&gt;전체&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read-only 에이전트를 분리한 게 핵심이다. &lt;code&gt;code-reviewer&lt;/code&gt;와 &lt;code&gt;planner&lt;/code&gt;는 &lt;code&gt;disallowedTools: Edit, Write&lt;/code&gt;로 설정해서 코드를 &lt;strong&gt;분석만&lt;/strong&gt; 하고 &lt;strong&gt;수정하지 않는다&lt;/strong&gt;. 리뷰하면서 코드를 바꿔버리는 실수를 방지한다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;code-reviewer&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Grep, Glob, Bash&lt;/span&gt;
&lt;span class="na"&gt;disallowedTools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Edit, Write&lt;/span&gt;  &lt;span class="c1"&gt;# 읽기 전용!&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonnet&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;모델 선택 전략도 중요하다.&lt;/strong&gt; 계획 수립처럼 고수준 판단이 필요한 작업은 opus, 코드 리뷰나 디버깅처럼 분석 위주 작업은 sonnet, Entity/DTO 생성처럼 패턴화된 작업은 haiku로 배정했다. 비용 대비 품질의 균형이다.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Skills: 반복 워크플로우를 명령어로
&lt;/h3&gt;

&lt;p&gt;Skills는 &lt;code&gt;/명령어&lt;/code&gt; 형태로 호출하는 워크플로우다. 11개를 만들었는데, 가장 임팩트가 큰 3개:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/aidlc&lt;/code&gt; (AI-Driven Development Lifecycle):&lt;/strong&gt;&lt;br&gt;
User Story 티켓을 기반으로 Unit -&amp;gt; Bolt 단위 구현 계획을 수립한다. MCP 서버에서 User Story를 조회하고, 4시간 단위 작업(Bolt)으로 분해한다. 각 Bolt는 Acceptance Criteria 기반 검증 체크리스트를 포함한다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/implement&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
AIDLC에서 만든 Bolt 문서를 읽고 Entity -&amp;gt; DTO -&amp;gt; Service -&amp;gt; Usecase -&amp;gt; Controller 순서로 구현한다. 작업 완료마다 Bolt 문서의 체크리스트를 업데이트하고, 구현 전에 Mermaid 다이어그램으로 데이터 흐름을 시각화한다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/bdd-cycle&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
User Story의 Acceptance Criteria를 Cucumber E2E 테스트로 자동 변환한다. 테스트 결과를 MCP로 다시 업데이트하는 양방향 동기화. 기존 Feature 파일이 있으면 MCP 코드 태그를 자동 매칭한다.&lt;/p&gt;

&lt;p&gt;이 세 가지가 연결되면 &lt;strong&gt;기획 -&amp;gt; 계획 -&amp;gt; 구현 -&amp;gt; 테스트&lt;/strong&gt; 사이클이 완성된다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/aidlc US-42 → spec.md + bolt-01.md 생성
/implement US-42 bolt-01 → 코드 구현 + 빌드 검증
/bdd-cycle 42 → E2E 테스트 자동 생성 + 실행 + MCP 업데이트
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Hooks: 실수를 자동으로 차단
&lt;/h3&gt;

&lt;p&gt;3개의 hook을 설정했다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash .claude/hooks/block-dangerous.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash .claude/hooks/auto-format.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash .claude/hooks/pr-convention.sh"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;block-dangerous.sh&lt;/strong&gt; (PreToolUse): &lt;code&gt;DROP TABLE&lt;/code&gt;, &lt;code&gt;rm -rf /&lt;/code&gt;, &lt;code&gt;force push main&lt;/code&gt; 같은 위험한 명령을 &lt;strong&gt;실행 전에&lt;/strong&gt; 차단&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;auto-format.sh&lt;/strong&gt; (PostToolUse): 파일 수정 후 자동으로 Prettier 실행, &lt;code&gt;.ts/.tsx/.js/.json&lt;/code&gt; 파일만 대상&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pr-convention.sh&lt;/strong&gt; (PostToolUse): PR 생성 시 변경된 scope와 stats를 자동으로 PR body에 추가&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;특히 &lt;code&gt;block-dangerous.sh&lt;/code&gt;는 MCP로 DB에 연결된 환경에서 필수다. AI가 실수로 위험한 쿼리를 실행하는 것을 원천 차단한다. 실제로 이 블로그 글을 작성할 때도 본문에 포함된 키워드 때문에 hook이 발동해서 차단당했다. &lt;strong&gt;Hook이 잘 작동한다는 증거다.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5. MCP 서버 연동
&lt;/h3&gt;

&lt;p&gt;MCP 서버 2개를 연결해서 AI가 프로덕션 DB를 읽고, User Story를 관리할 수 있게 했다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"my-db"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://my-service.example.com/sse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${MCP_TOKEN}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"my-data-platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://my-platform.example.com/sse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${MCP_TOKEN}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DB MCP는 실제 스키마를 조회하면서 Entity를 설계하고, Data Platform MCP는 User Story를 조회/업데이트하며 AIDLC 사이클을 돌린다. &lt;strong&gt;AI가 실제 데이터를 보면서 코드를 쓰기 때문에 "테이블에 이 컬럼 있어?" 같은 질문이 사라진다.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  결과 / 배운 점
&lt;/h2&gt;

&lt;p&gt;이 설정으로 얻은 것:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;코드 일관성&lt;/strong&gt;: Entity comment 누락, import 순서 위반 같은 실수가 거의 없어짐&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;워크플로우 자동화&lt;/strong&gt;: 기획 티켓에서 테스트까지 &lt;code&gt;/aidlc -&amp;gt; /implement -&amp;gt; /bdd-cycle&lt;/code&gt; 3단계로 완성&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;안전장치&lt;/strong&gt;: Hooks로 위험한 명령 차단, Read-only 에이전트로 리뷰와 수정 분리&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;온보딩 시간 단축&lt;/strong&gt;: 새 팀원이 &lt;code&gt;.claude&lt;/code&gt; 디렉토리를 보면 프로젝트 컨벤션을 한눈에 파악 가능&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;설계하면서 배운 점:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rules는 구체적일수록 좋다&lt;/strong&gt; — "좋은 코드를 써" 보다 "Entity에 comment 필수, Before/After 예시 포함"이 100배 효과적&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;에이전트는 역할로 나눠야 한다&lt;/strong&gt; — 만능 에이전트보다 &lt;code&gt;planner(Read-only)&lt;/code&gt; + &lt;code&gt;implementer(Edit 가능)&lt;/code&gt; 분리가 안전&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skills는 조합이 핵심&lt;/strong&gt; — 개별 스킬보다 &lt;code&gt;/aidlc -&amp;gt; /implement -&amp;gt; /bdd-cycle&lt;/code&gt; 체인이 진짜 가치&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  아쉬웠던 점
&lt;/h2&gt;

&lt;p&gt;솔직히 몇 가지 아쉬운 부분이 있다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Rules 파일이 너무 많아졌다.&lt;/strong&gt; 14개 규칙 파일이 매 대화마다 컨텍스트에 로드되면서 토큰을 상당량 차지한다. AI의 응답 품질과 속도에 영향을 줄 수 있다. 핵심 규칙과 참조용 규칙을 분리하는 전략이 필요했다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Agent 간 컨텍스트 공유가 안 된다.&lt;/strong&gt; &lt;code&gt;planner&lt;/code&gt;가 수립한 계획을 &lt;code&gt;code-reviewer&lt;/code&gt;가 참조하려면 파일 시스템을 경유해야 한다. 에이전트 간 직접적인 컨텍스트 전달 메커니즘이 없어서, &lt;code&gt;.aidlc/&lt;/code&gt; 디렉토리에 중간 산출물을 남기는 우회 방식을 쓰고 있다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Skill 프롬프트 유지보수 비용이 크다.&lt;/strong&gt; &lt;code&gt;/implement&lt;/code&gt; 스킬 하나가 400줄짜리 마크다운이다. 프로젝트 구조가 바뀌면 스킬 프롬프트도 같이 수정해야 하는데, 이 동기화가 빠지기 쉽다. 스킬이 코드베이스의 실제 상태와 어긋나면 AI가 존재하지 않는 패턴을 따르려고 한다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Hook의 false positive.&lt;/strong&gt; &lt;code&gt;block-dangerous.sh&lt;/code&gt;가 본문 내용의 키워드를 감지해서 정상적인 명령도 차단하는 경우가 있었다. 실제로 이 블로그 글을 DEV.to에 올릴 때도 본문에 포함된 DB 관련 키워드 때문에 curl 명령이 차단당했다. 패턴 매칭의 정밀도를 높여야 한다.&lt;/p&gt;




&lt;h2&gt;
  
  
  향후 보완할 점
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Rules의 계층화&lt;/strong&gt;: 현재는 모든 규칙이 동일한 우선순위로 로드된다. &lt;code&gt;rules/core/&lt;/code&gt;(항상 로드)와 &lt;code&gt;rules/reference/&lt;/code&gt;(필요 시 참조)로 나눠서 토큰 효율을 높일 계획이다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Skill 자동 검증&lt;/strong&gt;: 스킬 프롬프트에서 참조하는 파일 경로가 실제로 존재하는지 CI에서 검증하는 스크립트를 추가하려고 한다. &lt;code&gt;grep -r 'apps/' .claude/skills/ | xargs -I{} test -f {}&lt;/code&gt; 같은 간단한 체크만으로도 상당한 drift를 잡을 수 있다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Agent 메모리 시스템 활용&lt;/strong&gt;: Claude Code의 auto memory 기능을 좀 더 적극적으로 활용해서, 자주 반복되는 디버깅 패턴이나 의사결정 히스토리를 세션 간에 공유할 수 있게 할 예정이다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. 팀 단위 확장&lt;/strong&gt;: 현재는 1인 CTO가 설계하고 사용하는 구조다. 팀원들이 각자의 &lt;code&gt;.claude/settings.local.json&lt;/code&gt;으로 개인 설정을 오버라이드하면서도 핵심 Rules와 Skills는 공유하는 구조로 발전시켜야 한다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. 성과 측정&lt;/strong&gt;: &lt;code&gt;.claude&lt;/code&gt; 설정의 ROI를 정량적으로 측정하고 싶다. AI가 생성한 코드의 코드 리뷰 리젝률, 컨벤션 위반 건수, 기능 개발 리드타임 같은 메트릭을 수집해서 설정 최적화에 피드백 루프를 만들 계획이다.&lt;/p&gt;




&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;Claude Code 자체의 &lt;code&gt;.claude&lt;/code&gt; 시스템을 최대한 활용한 사례다. 별도 도구 없이 Claude Code가 제공하는 rules, agents, skills, hooks만으로 개발 워크플로우 전체를 커버했다.&lt;/p&gt;

&lt;p&gt;특히 &lt;strong&gt;MCP 서버 + Skills 조합&lt;/strong&gt;이 강력했다. AI가 단순히 코드만 쓰는 게 아니라, 기획 티켓을 읽고 -&amp;gt; 계획을 세우고 -&amp;gt; 구현하고 -&amp;gt; 테스트까지 하는 전체 사이클을 돌린다.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.claude&lt;/code&gt; 디렉토리 설계에 초기 투자만 하면, 이후 모든 기능 개발에서 그 투자가 복리로 돌아온다. 다만 그 "초기 투자"가 생각보다 크고, 유지보수도 꾸준히 필요하다는 점은 미리 감안해야 한다.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>nestjs</category>
      <category>productivity</category>
    </item>
    <item>
      <title>AWS 클라우드 환경에서 SSH 터널 서버 안정성 확보하기 — 다층 방어 설계</title>
      <dc:creator>Archist</dc:creator>
      <pubDate>Sun, 08 Mar 2026 08:35:59 +0000</pubDate>
      <link>https://dev.to/archist/aws-keulraudeu-hwangyeongeseo-ssh-teoneol-seobeo-anjeongseong-hwagbohagi-daceung-bangeo-seolgye-3kcf</link>
      <guid>https://dev.to/archist/aws-keulraudeu-hwangyeongeseo-ssh-teoneol-seobeo-anjeongseong-hwagbohagi-daceung-bangeo-seolgye-3kcf</guid>
      <description>&lt;h2&gt;
  
  
  문제 상황
&lt;/h2&gt;

&lt;p&gt;MSA 환경에서 온프레미스 DB에 접근하기 위해 리버스 SSH 터널을 사용하고 있었다. 구조는 이렇다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[온프레미스 PC] ──리버스 터널──► [EC2 SSH 서버] ◄──포워드 터널── [앱 서버 (NestJS)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;온프레미스 DB에 방화벽 인바운드가 차단되어 있어서, 현장 미니 PC가 먼저 밖으로 SSH 연결을 열어두고(리버스 터널), 앱 서버가 그 통로를 통해 DB에 접근하는 구조다.&lt;/p&gt;

&lt;p&gt;그런데 EC2 터널 서버가 &lt;strong&gt;주기적으로 CPU 급상승 후 넉다운&lt;/strong&gt;되는 현상이 반복되었다. 연결성 검사(health check)가 실패 상태로 남아, 수동 복구가 필요한 상황이 계속됐다.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CTO 관점&lt;/strong&gt;: 리버스 SSH 터널은 "빠르게 연결할 수 있는" 방법이지 "운영에 적합한" 방법은 아니다. 처음에 이 구조를 선택한 이유는 온프레미스 환경의 네트워크 제약(인바운드 차단, VPN 불가)과 빠른 MVP 출시 압박 때문이었다. &lt;strong&gt;기술 부채를 인식하면서도 일단 출시하고 나중에 개선하자는 판단&lt;/strong&gt;이었는데, 그 "나중"이 새벽 알람으로 돌아왔다.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  원인 분석: 3개 레이어의 복합 문제
&lt;/h2&gt;

&lt;p&gt;Claude Code로 코드베이스 전체(터널 관리, ETL 스케줄링, PM2 설정, BullMQ 큐)를 병렬로 분석한 결과, 한 곳이 아니라 &lt;strong&gt;3개 레이어&lt;/strong&gt;에 걸친 복합 문제였다.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;레이어&lt;/th&gt;
&lt;th&gt;문제&lt;/th&gt;
&lt;th&gt;영향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;앱 서버 코드&lt;/td&gt;
&lt;td&gt;백오프 없는 재연결 폭풍&lt;/td&gt;
&lt;td&gt;sshd fork 폭발&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH 서버 설정&lt;/td&gt;
&lt;td&gt;좀비 sshd 방치&lt;/td&gt;
&lt;td&gt;메모리/CPU 누적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;온프레미스 네트워크&lt;/td&gt;
&lt;td&gt;NAT 만료로 터널 끊김&lt;/td&gt;
&lt;td&gt;위 두 문제의 트리거&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  Layer 1 — 앱 서버: 백오프 없는 재연결 폭풍
&lt;/h3&gt;

&lt;p&gt;터널 매니저가 10초마다 헬스체크를 하고, 실패하면 &lt;strong&gt;아무 제어 없이 즉시 재연결&lt;/strong&gt;을 시도하고 있었다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 문제: setInterval로 10초마다 무조건 실행, 백오프 없음&lt;/span&gt;
&lt;span class="nf"&gt;startHealthCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TunnelConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkAndReconnectTunnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;checkAndReconnectTunnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TunnelConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isPortReachable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localPort&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// 실패 → 바로 새 SSH 연결 → 터널 서버에 sshd fork&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTunnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;거기에 PM2로 &lt;strong&gt;같은 서비스를 2개 인스턴스&lt;/strong&gt;로 띄우고 있어서, 동일한 터널에 대해 사실상 5초에 한 번꼴로 재연결을 시도했다. 각 재연결은 터널 서버에 새로운 &lt;code&gt;sshd&lt;/code&gt; 프로세스를 fork한다.&lt;/p&gt;




&lt;h3&gt;
  
  
  Layer 2 — SSH 서버: 좀비 sshd 방치
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sshd_config&lt;/code&gt;에 &lt;code&gt;ClientAliveInterval&lt;/code&gt; 설정이 없었다. 앱 서버가 &lt;code&gt;client.end()&lt;/code&gt;로 SSH 세션을 종료해도, TCP 연결이 이미 끊어진 상태면 그 메시지가 원격 &lt;code&gt;sshd&lt;/code&gt;에 도달하지 않는다. 결과적으로 좀비 &lt;code&gt;sshd&lt;/code&gt; 프로세스가 &lt;strong&gt;OS 기본 TCP keepalive(2시간)&lt;/strong&gt; 동안 살아남는다.&lt;/p&gt;




&lt;h3&gt;
  
  
  Layer 3 — 온프레미스 네트워크 불안정
&lt;/h3&gt;

&lt;p&gt;온프레미스 미니 PC의 리버스 터널이 끊기면 연쇄 반응이 시작된다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;트래픽 감소 → NAT 테이블 만료 → 리버스 터널 끊김
  → 앱 서버 헬스체크 "포트 도달 불가"
  → 10초마다 재연결 (2인스턴스 x N개 테넌트)
  → 리버스 터널이 없으므로 전부 실패
  → 하지만 sshd fork는 이미 생성됨
  → 좀비 누적 → CPU 급상승 → 넉다운
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;특히 트래픽이 적은 시간대에 NAT 매핑이 만료되면서 이 사이클이 가속된다.&lt;/strong&gt; 새벽에 알람이 오는 이유가 바로 이것이었다.&lt;/p&gt;




&lt;h2&gt;
  
  
  해결: 코드만으론 부족하다
&lt;/h2&gt;

&lt;p&gt;"코드만 고치면 되겠지?"라고 생각했지만, 3개 레이어를 전부 손봐야 했다.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. 앱 서버 코드 — 재연결 제어
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;setInterval → setTimeout 체인:&lt;/strong&gt; 이전 체크가 끝나야 다음이 스케줄되도록 변경했다. &lt;code&gt;setInterval&lt;/code&gt;은 콜백 실행 시간과 무관하게 다음 호출이 잡히기 때문에, 재연결이 오래 걸리면 체크가 겹쳐서 동시 SSH 연결이 발생할 수 있었다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 개선: setTimeout 체인 + 완료 후 다음 스케줄&lt;/span&gt;
&lt;span class="nf"&gt;startHealthCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TunnelConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scheduleNext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkAndReconnectTunnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Health check error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;// 현재 체크가 완료된 후에만 다음 체크 스케줄&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;healthCheckIntervals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;scheduleNext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;healthCheckIntervals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;scheduleNext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Per-name 락:&lt;/strong&gt; 같은 터널에 대한 동시 생성 시도를 차단했다. 2개 인스턴스가 동시에 같은 포트의 터널을 만들려 할 때 하나만 실행되고 나머지는 대기 후 리턴한다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;createTunnel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TunnelConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 동일 터널의 동시 생성 방지&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existingLock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingLock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;existingLock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 다른 호출이 이미 생성 완료&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lockPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTunnelInternal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lockPromise&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;lockPromise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;분산 크론:&lt;/strong&gt; ETL 스케줄러의 &lt;code&gt;@Cron&lt;/code&gt; → &lt;code&gt;@DistributedCron&lt;/code&gt;으로 변경하여, 2개 인스턴스 중 하나만 ETL을 실행하도록 했다. 이전에는 두 인스턴스 모두 동일한 ETL을 실행하며 동시에 터널 연결을 시도하고 있었다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: 2개 인스턴스 모두 실행&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Cron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0 6 * * *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;morning-etl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Asia/Seoul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// After: Redis 락으로 하나만 실행&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;DistributedCron&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0 6 * * *&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;morning-etl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Asia/Seoul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  2. EC2 터널 서버 — 좀비 sshd 자동 정리 스크립트
&lt;/h3&gt;

&lt;p&gt;sshd 설정 강화(&lt;code&gt;ClientAliveInterval&lt;/code&gt;)와 별개로, &lt;strong&gt;2중 안전장치&lt;/strong&gt;로 좀비 sshd를 직접 탐지해서 kill하는 스크립트를 작성했다.&lt;/p&gt;

&lt;p&gt;단순히 "오래된 프로세스를 죽이는" 것이 아니라, &lt;strong&gt;실제 I/O가 있는지 측정&lt;/strong&gt;해서 판단한다. 리버스 터널이 정상 작동 중인 세션은 절대 건드리지 않아야 하기 때문이다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# cleanup-idle-tunnels.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;SAMPLE_SEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10       &lt;span class="c"&gt;# I/O 측정 시간 (초)&lt;/span&gt;
&lt;span class="nv"&gt;MIN_AGE_SEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3600    &lt;span class="c"&gt;# 1시간 미만 세션은 스킵&lt;/span&gt;

&lt;span class="c"&gt;# sshd 메인 프로세스의 자식들만 대상&lt;/span&gt;
&lt;span class="nv"&gt;MAIN_SSHD_PID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; sshd&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CHILD_PIDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-P&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAIN_SSHD_PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; sshd&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;핵심 판별 로직은 &lt;code&gt;/proc/{pid}/net/dev&lt;/code&gt;에서 네트워크 바이트를 10초 간격으로 두 번 읽어서 delta가 0인지 확인하는 것이다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;get_net_bytes&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;2 { rx+=$2; tx+=$10 } END { print rx+tx }'&lt;/span&gt; &lt;span class="s2"&gt;"/proc/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;/net/dev"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;PID &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$CHILD_PIDS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c"&gt;# 1시간 미만 세션은 보호 (방금 생성된 정상 세션일 수 있음)&lt;/span&gt;
  &lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;NOW &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %Y &lt;span class="s2"&gt;"/proc/&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$AGE&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="nv"&gt;$MIN_AGE_SEC&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;

  &lt;span class="c"&gt;# 10초간 I/O 측정&lt;/span&gt;
  &lt;span class="nv"&gt;BYTES_BEFORE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_net_bytes &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SAMPLE_SEC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;BYTES_AFTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_net_bytes &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;BYTES_AFTER &lt;span class="o"&gt;-&lt;/span&gt; BYTES_BEFORE&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;           &lt;span class="c"&gt;# graceful 종료 시도&lt;/span&gt;
    &lt;span class="nb"&gt;sleep &lt;/span&gt;2
    &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;  &lt;span class="c"&gt;# 안 죽으면 강제&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;정리 결과는 &lt;strong&gt;Slack으로 자동 리포트&lt;/strong&gt;된다. CPU, 메모리, 로드 평균과 함께 세션 현황을 알려준다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Tunnel Server] Idle 세션 정리 완료
────────────────────
Host: tunnel-server
Time: 2026-03-08 11:00:05 KST

[시스템 리소스]
CPU: 12.3%
Memory: 487MB / 978MB (49.8%)
Load Avg: 0.15 0.10 0.08

[SSH 세션]
Total: 14 | Killed: 6 | Active: 5 | Skipped: 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;하루 2번 cron으로 등록했다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 11 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/local/bin/cleanup-idle-tunnels.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/cleanup-idle-tunnels.log 2&amp;gt;&amp;amp;1
0 23 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/local/bin/cleanup-idle-tunnels.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/cleanup-idle-tunnels.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--dry-run&lt;/code&gt; 모드도 지원해서, 처음에는 kill 없이 상태만 Slack으로 받아보며 패턴을 관찰한 후 실제 적용했다.&lt;/p&gt;




&lt;h3&gt;
  
  
  왜 sshd 설정만으로 부족한가?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ClientAliveInterval 30&lt;/code&gt; + &lt;code&gt;ClientAliveCountMax 3&lt;/code&gt;을 설정하면 90초 내에 죽은 세션이 정리된다. 하지만 이것은 &lt;strong&gt;SSH 프로토콜 레벨&lt;/strong&gt; keepalive다. TCP 연결 자체가 half-open 상태(한쪽만 끊어진 상태)일 때는 SSH keepalive 패킷이 OS TCP 스택에서 버퍼링만 되고 실제 전송되지 않을 수 있다. 특히 중간에 NAT 장비가 있으면 이 상황이 빈번하다. 그래서 &lt;strong&gt;프로세스 레벨에서 실제 I/O를 측정하는 2중 안전장치&lt;/strong&gt;가 필요하다.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. 온프레미스 미니 PC — keepalive 강화
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SSH keepalive 30초로 단축 (NAT 만료 방지)&lt;/span&gt;
ssh &lt;span class="nt"&gt;-R&lt;/span&gt; 5555:192.168.x.x:1433 &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ServerAliveInterval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30 &lt;span class="se"&gt;\ &lt;/span&gt;   &lt;span class="c"&gt;# 60 → 30초&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ServerAliveCountMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="se"&gt;\&lt;/span&gt;
  tunnel-service@ssh-server &lt;span class="nt"&gt;-p&lt;/span&gt; 222
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  해결 구조 요약
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[앱 서버 코드]
  - setInterval → setTimeout 체인 (겹침 방지)
  - 10초 → 30초 헬스체크 주기
  - per-name 락 (동시 터널 생성 차단)
  - @DistributedCron (2인스턴스 중복 실행 방지)

[EC2 터널 서버]
  - sshd_config: ClientAliveInterval 30 (SSH 레벨)
  - sysctl: tcp_keepalive_time 60 (OS TCP 레벨)
  - cleanup-idle-tunnels.sh cron (프로세스 I/O 레벨)
  - Slack 모니터링 알림

[온프레미스 미니 PC]
  - SSH keepalive 60초 → 30초 (NAT 만료 방지)
  - 절전 모드 해제
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  배운 점
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"코드만 고치면 된다"는 착각이다.&lt;/strong&gt; 이 문제는 앱 코드, SSH 서버 설정, 온프레미스 네트워크 3개 레이어가 전부 기여하고 있었다. 어느 하나만 고치면 증상이 완화될 뿐 재발한다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;주기적 장애는 트래픽 패턴과 리소스 생명주기의 교차점을 의심해야 한다.&lt;/strong&gt; 활성 트래픽이 연결을 유지해주다가, 트래픽이 줄어드는 시간대에 숨어있던 결함이 드러나는 패턴이다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;재연결 로직에는 반드시 백오프가 필요하다.&lt;/strong&gt; 상대방이 죽었을 때 더 빨리 재연결한다고 살아나지 않는다. 오히려 시체를 더 빨리 쌓을 뿐이다.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;방어는 다층으로.&lt;/strong&gt; SSH 프로토콜 keepalive → OS TCP keepalive → 프로세스 I/O 측정, 각 레이어가 서로 다른 실패 모드를 커버한다.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  아쉬웠던 점
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;3개 레이어를 동시에 수정해야 해서 배포 조율이 복잡했다&lt;/strong&gt;: 앱 서버 코드 변경은 CI/CD로 배포하지만, EC2 터널 서버의 sshd_config와 cron 스크립트는 Ansible로, 온프레미스 미니 PC의 SSH 설정은 현장 담당자에게 요청해야 했다. 세 가지를 동시에 적용하지 않으면 효과가 반감되는데, 실제로는 2주에 걸쳐 순차 적용했다. 그 2주 동안 부분적으로만 개선된 상태에서 여전히 간헐적 알람이 발생했다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;cleanup 스크립트의 10초 sleep이 프로덕션에서 부담&lt;/strong&gt;: 각 sshd 프로세스마다 I/O를 10초간 측정하므로, 좀비가 20개 쌓이면 스크립트 실행에 최소 200초(3분 이상)가 소요된다. 그 동안 정상 세션에 대한 측정도 지연된다. 병렬 측정을 도입하면 좋겠지만, bash 스크립트의 한계로 복잡도가 급격히 증가한다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;온프레미스 환경 제어가 어려운 현실&lt;/strong&gt;: 미니 PC의 네트워크 환경, OS 업데이트, 전원 상태 등을 원격으로 확인하거나 제어할 수 없다. 현장 담당자에게 "절전 모드 해제해달라", "라우터 재부팅해달라" 요청을 반복하는 것은 확장 가능한 운영 방식이 아니다.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  향후 보완할 점
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;WireGuard VPN으로 SSH 터널 대체 검토&lt;/strong&gt;: WireGuard는 UDP 기반으로 NAT 환경에서 더 안정적이고, 커널 레벨에서 동작하여 sshd 프로세스 관리 문제가 근본적으로 사라진다. 온프레미스 PC에 WireGuard 클라이언트를 설치하고, AWS 측에 WireGuard 서버를 두는 구조를 PoC 중이다. 가장 큰 허들은 온프레미스 환경에 소프트웨어를 설치하는 것 자체의 승인 프로세스다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;터널 상태 대시보드 구축&lt;/strong&gt;: 현재 Slack 알림만으로는 전체 터널 상태를 한눈에 파악하기 어렵다. 테넌트별 터널 상태(connected/disconnected/reconnecting), 마지막 성공 시간, 재연결 횟수 등을 Grafana 대시보드로 시각화할 계획이다. CloudWatch Custom Metrics로 수집하거나, 앱 서버에서 Prometheus 메트릭을 노출하는 방식을 검토 중이다.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;온프레미스 PC를 IoT 디바이스 수준으로 관리하는 MDM 도입&lt;/strong&gt;: 원격 모니터링(CPU, 메모리, 네트워크 상태), 원격 명령 실행, 자동 업데이트 등이 가능한 MDM(Mobile Device Management) 솔루션 도입을 검토하고 있다. 현장 방문 없이 미니 PC의 상태를 파악하고 문제를 해결할 수 있어야 테넌트 수가 늘어나도 운영이 가능하다.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  AI 활용 포인트
&lt;/h2&gt;

&lt;p&gt;Claude Code의 병렬 에이전트로 터널 관리 코드, ETL 스케줄러, PM2 설정, BullMQ 큐, SSH 아키텍처 문서를 동시에 분석했다. 수십 개 파일을 읽으며 상관관계를 파악해야 하는 작업에서, AI가 전체 코드베이스를 빠르게 훑고 "이 &lt;code&gt;setInterval&lt;/code&gt;이 여기서 문제를 일으키고, 그 결과가 저 서버에서 좀비 sshd로 나타난다"는 &lt;strong&gt;레이어 간 인과관계&lt;/strong&gt;를 짚어주는 것이 결정적이었다.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ssh</category>
      <category>devops</category>
      <category>troubleshooting</category>
    </item>
  </channel>
</rss>
