<?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>Forem: Archist</title>
    <description>The latest articles on Forem by Archist (@archist).</description>
    <link>https://forem.com/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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3812612%2Fea7da566-26b1-454c-8ee8-c97a88a91317.png</url>
      <title>Forem: Archist</title>
      <link>https://forem.com/archist</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/archist"/>
    <language>en</language>
    <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://forem.com/archist/ec2-dev-seobeo-unyeonggi-pm2eseo-sijaghaeseo-ecs-productionggaji-53im</link>
      <guid>https://forem.com/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://forem.com/archist/sogyumo-b2b-saas-timeul-wihan-dyueolteuraeg-aejail-2ju-saikeul-siljeon-unyeonggi-jn3</link>
      <guid>https://forem.com/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://forem.com/archist/terraformeuro-aws-managed-grafana-moniteoring-cegye-gucughagi-msa-6gae-seobiseureul-han-nune-gj8</link>
      <guid>https://forem.com/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://forem.com/archist/nestjs-msa-lite-siljeon-akitegceo-33-unyeong-baepo-geurigo-jinhwa-1d1l</link>
      <guid>https://forem.com/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://forem.com/archist/nestjs-msa-lite-siljeon-akitegceo-23-deiteo-reieowa-bidonggi-ceori-3aij</link>
      <guid>https://forem.com/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://forem.com/archist/nestjs-maikeuroseobiseu-siljeon-akitegceo-13-yeoreo-monolriseureul-msa-lite-monoreporo-tonghabhan-iyagi-564n</link>
      <guid>https://forem.com/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://forem.com/archist/nestjs-meoltiteneonteu-db-keonegsyeon-pul-dongjeog-gwanri-promise-kaesinggwa-lazy-loading-2a7e</link>
      <guid>https://forem.com/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://forem.com/archist/nestjs-msaeseo-typeorm-findoperatorga-sarajineun-munje-interceptorro-haegyeolhagi-cpo</link>
      <guid>https://forem.com/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://forem.com/archist/nestjs-maikeuroseobiseueseo-sse-seuteuriming-cusanghwahagi-redis-pubsub-giban-3-tier-base-class-seolgye-2n8k</link>
      <guid>https://forem.com/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://forem.com/archist/claude-codereul-timyi-sinieo-gaebaljaro-mandeulgi-claude-diregtori-seolgye-jeonryag-5cmo</link>
      <guid>https://forem.com/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://forem.com/archist/aws-keulraudeu-hwangyeongeseo-ssh-teoneol-seobeo-anjeongseong-hwagbohagi-daceung-bangeo-seolgye-3kcf</link>
      <guid>https://forem.com/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>
