V1 Docker Compose 구조의 한계 분석 - 100-hours-a-week/KTB4-3rd-CLOUD GitHub Wiki

V1 Docker Compose 구조의 한계 분석

V1은 EC2 1대에서 Nginx·Next.js·Spring Boot·MySQL을 Docker Compose로 운영한다. 이 문서는 V1의 현재 구조를 확인하고, 부하 측정으로 확인한 한계 지점과 V2로 서비스가 성장할 때 드러나는 구조적 한계를 정리해 V2 인프라의 요구사항을 도출한다.


1. V1의 현재 구조

V1은 Dev와 Prod를 각각 t3.medium(2 vCPU / 4 GiB) EC2 1대에서 운영한다. 하나의 인스턴스 안에서 4개의 컨테이너가 호스트 자원을 나눠 쓰며, 사용자 요청은 다음 순서로 처리된다.

  1. Route 53이 EC2의 EIP로 요청을 전달하고, Nginx 컨테이너가 443 포트에서 TLS를 종료한다.
  2. / 경로는 Next.js 컨테이너로 전달한다.
  3. /api 경로는 Spring Boot 컨테이너로 전달하고, Spring Boot는 같은 호스트의 MySQL 컨테이너에 접근한다.
  4. /api/wss 경로는 HTTP Upgrade 후 REST API와 같은 Spring Boot JVM 안의 STOMP WebSocket 서버로 연결한다.

컨테이너별 자원은 호스트 CPU를 100% 배분하도록 설정되어 있다.

컨테이너 CPU 메모리 기본 설정값
nginx 0.25 128 MB worker_connections 16384
frontend (Next.js) 0.50 512 MB 상시 384 MB 점유
backend (Spring Boot, REST + WebSocket) 0.75 768 MB -Xmx512m, HikariCP 10, Tomcat threads 200
mysql 0.50 768 MB buffer pool 384 MB, max_connections 151
합계 2.00 2,176 MB

배포와 설정 관리는 다음과 같다.

  • 배포: GitHub Actions가 aws ssm send-command로 호스트의 배포 스크립트를 실행하고, 스크립트가 Compose로 컨테이너를 재생성한다(Recreate). 워크플로는 SSM 명령을 보낸 시점에 성공으로 끝난다.
  • 빌드 설정: GitHub vars/secrets에 레포 3개 × environment로 나뉘어 있다.
  • 런타임 설정: 각 EC2의 /opt/moyeota/ 아래 env 파일 4개에 있다.
  • 데이터: 같은 호스트의 MySQL 컨테이너와 Docker 볼륨에 저장한다.

Docker는 Compose 배포를 “가장 쉬운 방법은 단일 서버에서 실행하는 것”으로 설명하고, 확장이 필요하면 Swarm 클러스터를 쓰도록 안내한다 (Docker: Use Compose in production). 또한 docker compose up은 설정이나 이미지가 바뀐 서비스의 컨테이너를 중지한 뒤 재생성한다 (Docker: docker compose up). 즉 V1 구조는 한 호스트 안에서 실행하고, 바꿀 때는 재생성하는 구조다.

V1 단계에서 이 구조를 선택한 이유는 3개월 인프라 예산 90만 원, 인프라 담당 1명, DAU 1,500명(피크 조회 약 1.25 RPS)이라는 초기 조건이다. 단일 EC2 장애 시 서비스 중단과 배포 중 짧은 중단은 103-V1-아키텍쳐-설계와 3단계-CD(지속적-배포)-파이프라인-설계에서 이미 수용한 제약이다.


2. 실측한 V1의 한계 지점

V2 성장 지표를 V1 구조에 부하로 넣었을 때, 다음 자원이 V2 요구 수준보다 먼저 포화되었다. 측정 설계와 신뢰 범위는 wiki-403-V1-한계-지점과-scale-out-scale-up과 V2 서비스 확장 가정 및 V1의 한계 지점을 따른다.

최초 포화 자원 실측 임계값 V2 요구 수준 충족
WebSocket 동시 연결 약 1,700개 (handshake 30초 타임아웃) 1,800개 ✕
Spring JVM heap·GC (REST·WS 공유) WS 1,642개에서 조회 p99 27배 1,800 연결 + 180 RPS 동시 ✕
HikariCP 공용 풀 10개 채팅 저장 47 msg/s, 동시 승인 20건 36 msg/s + 조회 동시 ✕
동일 팟 row lock 동시 50건에서 실패율 59.5% 정합성 100% ✕
Spring CPU quota 0.75 641 RPS에서도 throttling 0 91 RPS (행사 180 RPS) ○
MySQL CPU 0.5 274 RPS 조회에서 배정량의 21% — ○

1. REST와 WebSocket이 한 JVM을 공유: 조회 p99 33.4 ms → 914.8 ms

REST 조회 부하에 WebSocket 연결 1,642개를 함께 걸었을 때의 결과다. (V2의 백엔드 애플리케이션 인프라 설계)

항목 REST 단독 WebSocket 1,642개 + REST 변화
조회 p95 11.0 ms 167.2 ms 15배
조회 p99 33.4 ms 914.8 ms 27배
조회 실패율 0% 4.54% 증가
heap 최대 123 MB 277 MB 2.3배
GC pause p95 3.1 ms 257 ms 82배
HikariCP 대기 / MySQL CPU 0 / 0.017 cores 0 / 0.018 cores 변화 없음
  • 원인 위치: HikariCP와 MySQL CPU는 변하지 않았고 heap과 GC pause만 증가했다. 지연의 원인은 DB가 아니라 같은 JVM의 공유다.
  • 확장 시 악화: Oracle GC 튜닝 가이드는 작은 시스템에서 무시할 만한 GC 부담이 시스템이 커지면 주요 병목이 될 수 있다고 설명한다 (Oracle Java 17 GC Tuning Guide). WebSocket 세션이 늘수록 heap이 커지고, 그 GC 비용을 REST 요청이 함께 낸다.

즉 WebSocket 연결 수가 REST API의 꼬리 지연과 오류율을 결정하는 구조다.


2. 공용 커넥션 풀과 row lock: 쓰기 경합이 조회까지 번진다

목표 RPS Hikari active 평균 / 최대 pending 평균 (최대)
45.5 0.7 / 10 0 (0)
91 8.9 / 10 162 (191)
180 10 / 10 184 (189)
226 9.9 / 10 157 (191)
  • 조회 부하: 91 RPS부터 pending이 크게 관측됐고, 226 RPS 목표에서 실제 처리율은 157 RPS였다.
  • 채팅 저장: 47 msg/s에서 풀 active 10/10, 대기 53건, 커넥션 획득 p95 445 ms였고, 이때 조회 p95가 13.6배로 늘고 실패율 1.53%가 되었다.
  • 좌석 승인: 같은 팟에 동시 50건이 몰리면 실패율 59.5%, 조회 p99 48배였다.

MySQL CPU는 모든 경우에 배정량의 21% 이하였다. 즉 병목은 DB 처리 능력이 아니라 Spring의 커넥션 점유와 row lock이고, 하나의 풀을 조회·채팅·좌석 경로가 함께 쓰기 때문에 쓰기 경합이 조회 실패로 번진다.


3. 스펙 업으로 풀리지 않는 한계: CPU 여유는 있는데 실패한다

  • CPU: 조회 641 RPS(기준 91 RPS의 7배)에서 Spring CPU throttling 0, 호스트 CPU 27.8%였다.
  • WebSocket 연결 상한: 약 1,700개의 상한은 연결당 heap 28.2 KB(수용량 약 11,800개)와 CPU 사용률 2.2%로 설명되지 않는다.
  • 메모리: Spring 컨테이너는 limit 768 MB의 7585%(574652 MB)를 사용했다.

인스턴스를 키우면 GC 임계점과 OOM 시점은 늦출 수 있지만, REST·WebSocket이 같은 JVM을 쓰고 조회·채팅·좌석이 같은 풀을 쓰는 원인은 그대로 남는다. 즉 scale-up은 임계점을 미루는 단기 완화책이다.


4. 서로 다른 부하가 같은 두 자원에서 만난다

[행사 유입]                  [인기 팟 마감]             [채팅 저장 36~47 msg/s]
 WS 약 1,700개 포화             동시 승인 20건 초과          풀 active 10/10, 대기 53
      │                              │                           │
      ▼                              ▼                           ▼
 공유 Spring JVM                공유 HikariCP 10개 ◀──────────────┘
 GC pause 3.1 → 257 ms          좌석 실패 59.5%, 조회 p95 13.6배·p99 48배
      │                              │
      └──────────── 조회 p99 27배 · 오류 4.54% ┘

세 시나리오는 서로 다른 입구로 들어오지만 Spring JVM과 HikariCP라는 같은 두 자원에서 만나고, 부하와 무관한 탐색·매칭 사용자 여정까지 실패시킨다.


3. V2 성장에 따른 V1의 구조적 한계

V2는 서비스 지역을 판교에서 수도권 6개 업무지구로 넓힌다. BlaBlaCar 스페인 2025 활동 자료(월평균 고유 활성 사용자 30~40만, 연간 카풀 승객 400만)에서 DAU/MAU 활동률을 산출하고 (BlaBlaCar Spain 2025 activity report), 잠재 이용자 115만 명에 적용해 DAU를 36,000명으로 산정했다. Airship 보고서는 앱 카테고리별로 DAU/MAU와 세션 길이가 다르다고 보고하고 (Airship 2023 Mobile Lifecycle Benchmarks), Sendbird는 Travel 앱 사용자의 70%가 30일 동안 앱을 5회 이하로 실행한다고 정리한다 (Sendbird). 모여타처럼 이동이 필요할 때 몰려서 쓰는 서비스는 평시와 피크의 차이가 크다.

지표 V1 V2
DAU 1,500 36,000 (24배)
평시 평균 — 3.9 RPS
출퇴근 순간 피크 1.25 RPS 91 RPS (평시의 약 23배)
행사 burst — 180 RPS, WebSocket 1,800개, 36 msg/s
출퇴근 + 행사 중첩 — 225.5 RPS + WebSocket 1,800개
가용성 목표 — 핵심 REST·WebSocket rolling 28일 99.9%

수치의 산정 과정은 V2 서비스 확장 가정 및 V1의 한계 지점과 ECS Task Auto Scaling 정책을 따른다. 99.9%의 오류 예산은 28일 기준 약 40분(28일 × 1,440분 × 0.1%)이다. 이 성장 조건에 2절의 실측 결과를 대입하면 V1 구조의 한계는 다음과 같이 정리된다.


1. 장애 격리 경계가 없다: 한 기능의 부하가 모든 사용자 여정으로 번진다

V2의 행사 burst(WebSocket 1,800개 + 180 RPS)는 V1에서 이미 실패한 조건(WebSocket 1,642개 + REST)보다 크다. REST·WebSocket이 같은 JVM을, 조회·채팅·좌석이 같은 커넥션 풀을 쓰는 한 행사 하나가 탐색·매칭·채팅 전체의 SLO를 깬다.

AWS Well-Architected의 REL10-BP03은 장애의 영향을 제한된 수의 구성 요소로 한정하도록 bulkhead 아키텍처를 구현하라고 권장한다 (REL10-BP03). Docker 컨테이너는 기본적으로 자원 제한이 없고, 호스트 메모리가 부족하면 커널이 Docker를 포함한 프로세스를 종료할 수 있다 (Docker: Resource constraints). V1의 컨테이너 한도는 한 컨테이너의 폭주는 막지만, 기능 사이의 격벽 역할은 하지 못한다.


2. WebSocket을 여러 대로 늘릴 수 없다: Simple Broker는 클러스터를 지원하지 않는다

WebSocket 연결 상한은 약 1,700개로 V2 요구 1,800개보다 작다. 연결을 여러 인스턴스로 나누면 될 것 같지만, V1의 Spring Simple Broker는 메시지를 같은 JVM 안에서만 전달한다.

Spring Framework 문서는 Simple Broker가 클러스터링에 적합하지 않다(not suitable for clustering)고 밝히고, 외부 메시지 브로커를 쓰도록 안내한다 (Spring: External Broker). 같은 채팅방 사용자가 서로 다른 인스턴스에 연결되면 메시지가 전달되지 않는다. 즉 V1 구조에서는 WebSocket을 수평 확장할 수 없다.


3. 단일 장애점과 수직 확장: 호스트 1대가 서비스 전체다

CPU 예산 2.00 vCPU가 이미 호스트 전체에 배분돼 있어 특정 컴포넌트만 키울 수 없다. Spring CPU가 부족해도 Next.js와 MySQL이 함께 있는 EC2 전체를 증설해야 하고, 2절 3항처럼 증설은 원인을 없애지 못한다. 반대로 호스트가 멈추면 프론트엔드·API·WebSocket·DB가 동시에 중단된다.

  • 단일 장애점: REL10-BP01은 단일 장애점을 피하는 것을 AWS 서비스 설계의 기본 원칙으로 들고, 프로덕션 워크로드를 최소 2개 AZ에 배포하라고 권장한다 (REL10-BP01).
  • 확장 경로: Docker는 Compose 앱의 확장 경로로 Swarm 클러스터를 안내한다. 단일 호스트 Compose만으로는 수평 확장 수단이 없다.
  • 오류 예산: 호스트 1대가 멈추고 사람이 복구하는 시간만으로 28일 오류 예산 약 40분을 넘을 수 있다.

4. 수요에 맞춰 자동으로 확장할 수 없다: 피크는 평시의 23배

하루 요청의 70%가 출퇴근 4시간에 몰리고, 행사 burst는 예고 없이 10분 안에 들어온다. V1은 피크에 맞춘 고정 용량(나머지 20시간 유휴)과 평시에 맞춘 고정 용량(피크 실패) 중 하나를 골라야 하고, 용량을 바꾸려면 사람이 인스턴스 타입을 바꾸고 재기동해야 한다.

REL07-BP01은 예상 수요에 맞춰 용량을 수동으로 추정하는 것을 안티패턴으로 들고, Auto Scaling 그룹과 ECS task의 자동 확장을 IaC로 구성하라고 권장한다. 예측 가능한 사용 패턴에는 예약 스케일링을 함께 쓰라고 안내한다 (REL07-BP01). 출퇴근처럼 예측 가능한 피크와 행사처럼 예측 불가능한 burst를 모두 받으려면 예약 확장과 지표 기반 확장이 함께 필요하다.


5. 배포가 곧 중단이다: 컨테이너 재생성과 성공 판정 부재

V1 CD는 Compose로 컨테이너를 재생성하므로 진행 중인 HTTP 요청이 실패하고 WebSocket 연결이 끊긴다. 대체 인스턴스가 없어 Readiness 검사로도 트래픽을 넘길 곳이 없다. 또한 워크플로는 SSM 명령 전송까지만 확인하므로, 호스트에서 컨테이너가 실제로 떴는지 알지 못한다.

  • 배포 자동화: REL08-BP05는 프로덕션 변경을 가장 큰 위험 영역 중 하나로 보고, 사전 테스트·자동 롤백·단계적 배포를 배포 파이프라인에 넣으라고 권장한다 (REL08-BP05).
  • 오류 예산: Google SRE는 오류 예산을 모두 쓰면 릴리스를 멈추고 안정성 작업을 하도록 운영한다 (Google SRE Book: Embracing Risk).

V1에서는 피크 시간대 배포를 피하는 것으로 이 중단을 관리했다. 하지만 V2에서는 배포마다 생기는 중단이 약 40분의 오류 예산을 직접 소진하므로, 배포 횟수가 늘수록 기능 배포가 SLO와 충돌한다.


6. 앱과 DB의 장애 경계가 묶여 있다

V2의 DB 인프라 설계의 대안별 트레이드오프에서 MySQL 전용 호스트 분리가 해결하려는 문제를 “앱과 DB의 자원·장애 경계 결합”으로 정의했다. MySQL Buffer Pool이 늘면 Spring과 Next.js가 쓸 메모리가 줄고, 호스트 장애나 앱 배포 작업이 DB의 생명주기와 분리되지 않는다. 백업·복구·패치·장애조치도 팀이 직접 운영한다.

V2에서는 앱 서버를 여러 대로 늘리고 자주 교체해야 하므로, DB는 앱 호스트의 생명주기와 분리되어야 한다. RDS는 자동 백업과 시점 복구(PITR)를 관리형으로 제공한다 (RDS 자동 백업).


7. 설정값이 흩어져 있어 오류가 배포 후에야 드러난다

한 기능에 필요한 값이 빌드 값은 GitHub, 런타임 값은 EC2 파일로 나뉘고, 레포 3개 × environment, 호스트 2대 × 파일 4개에 흩어져 있었다. (#46 SSM Parameter store으로 환경 변수 관리 변경, #42 컨테이너 환경변수 관리)

  • 빌드 값 누락: 등록되지 않은 GitHub vars는 빈 문자열이 되고 FE 빌드에 기본값까지 붙어 있어, 값이 빠져도 빌드가 성공한다. 오류는 배포 후 지도가 뜨지 않거나 Sentry 환경이 섞인 뒤에야 보인다.
  • 런타임 값 누락: EC2 env 파일에 키가 빠져도 컨테이너는 일단 뜨고, CD는 성공으로 표시된다.
  • 추적 불가: GitHub secret은 등록 후 다시 읽을 수 없고, EC2 파일은 변경 이력이 남지 않는다.

The Twelve-Factor App은 설정을 코드에서 엄격히 분리해 배포마다 바꿀 수 있게 하라고 한다 (12factor: Config). AWS Parameter Store는 설정을 한 곳에서 관리하고, 파라미터마다 최근 100개 버전을 보관하며, IAM으로 읽기·쓰기 권한을 제어한다 (AWS Parameter Store). V2는 스테이지가 dev·stg·prod 3개, 서비스가 rest·websocket·frontend·fastapi 4개로 늘어나므로, 저장소가 흩어진 상태에서는 누락을 사람이 찾기 어렵다.


8. 운영 상태가 코드 밖에 있다

Compose 구조에서 “어떤 컨테이너가 떠 있는가”, “DB 스키마가 엔티티와 맞는가”는 사람이 호스트에서 만든 상태다. Terraform plan에도, CD 결과에도 드러나지 않는다.

10/13 V2 dev 배포 실패가 이 한계를 보여준다. 이 장애는 V1 서버가 아니라 V2에 남아 있던 Compose 방식의 데이터 계층에서 발생했다.

  1. V1 dev 서버에 있던 Compose MySQL·Redis를 V2 dev EC2에도 있다고 가정했다.
  2. user_data 부트스트랩이 Compose 설치 전에 실패했지만 아무 신호도 남기지 않았다.
  3. 수동 적용한 DDL에서 테이블 3개가 빠져, task가 하나씩 종료되면서야 누락을 알 수 있었다.

REL08-BP04는 실행 중인 인프라를 제자리에서 바꾸는 것을 안티패턴으로 들고, 관리되지 않은 설정 변경(drift)은 감지해 바로잡아야 한다고 설명한다 (REL08-BP04). 인스턴스가 자동으로 생기고 없어지는 V2 구조에서는 사람이 호스트에 만든 상태가 새 인스턴스에 재현되지 않는다.


4. V2 요구사항

위의 구조적 한계는 모두 호스트 1대에 모든 것을 둔다는 V1의 한 가지 선택에서 나온다. 자원 공유에서 1·6번이, 단일 인스턴스에서 2·3·4번이, 호스트에 남는 상태에서 5·7·8번이 나온다. 따라서 V2의 성장 지표와 가용성 목표를 견디는 구조의 조건을 다음과 같이 정의할 수 있다.

  1. REST와 WebSocket은 별도 프로세스·배포 단위로 격리되어 부하가 서로 전파되면 안 된다.
  2. WebSocket 메시지는 여러 인스턴스 사이에서 전달되어야 한다.
  3. 서비스는 여러 인스턴스에서 실행되고 로드밸런서로 분산되어야 한다.
  4. 수요에 따라 task와 인스턴스가 자동으로 늘고 줄어야 한다.
  5. 배포는 중단 없이 진행되고, 실패는 자동으로 감지·롤백되어야 한다.
  6. DB는 앱 호스트와 분리되고 백업·복구는 관리형으로 운영되어야 한다.
  7. 설정은 한 저장소에서 관리되고, 누락은 배포 전에 실패해야 한다.
  8. 인프라·데이터 계층·스키마 상태는 코드로 관리되어야 한다.

요구사항별 수용 기준과 V2 설계 반영

수용 기준은 V2 서비스 확장 가정 및 V1의 한계 지점의 확장성 검증 시나리오를 따른다.

요구사항 해소하는 한계 수용 기준 V2 설계 반영
1. REST·WebSocket 격리 3-1 225.5 RPS + WS 1,800 + 36 msg/s 혼합 부하에서 한 기능의 부하가 다른 CUJ의 SLO를 위반하지 않음, 분리 후 조회 p95 악화 20% 미만 rest·websocket 별도 ECS Service + 별도 ASG (V2의 백엔드 애플리케이션 인프라 설계)
2. WebSocket 메시지 전달 3-2 WS 1,800개, 연결 성공률 ≥ 99.95%, 메시지 유실·중복 0건 Redis Pub/Sub fan-out (V2-WebSocket-메시지-버스와-Redis-도입-설계)
3. 다중 인스턴스 + 로드밸런서 3-3 인스턴스 1대 장애 시 서비스 유지, 컨테이너 단위 장애 RTO 측정 ECS Cluster + ALB 서비스별 대상 그룹 (V2의 Nginx 인프라 설계)
4. 자동 확장 3-4 91 RPS 4시간·180 RPS burst에서 REST 오류율 ≤ 0.05%, timeout 0건 On-Demand baseline + REST Spot ASG, 예약·지표 기반 확장 (ECS Task Auto Scaling 정책)
5. 무중단 배포와 자동 롤백 3-5 실패 배포를 워크플로가 실패로 판정, 배포 중 요청 실패율·WS 재접속률 측정 ECS 롤링 배포 + circuit breaker, COMPLETED까지 대기하는 CD
6. DB 분리 3-6 PITR 복구 시간·복구 시점 측정, 정합성 오류 0건 Prod RDS for MySQL Single-AZ (V2의 DB 인프라 설계)
7. 설정 일원화 3-7 빌드·런타임 값 누락 시 빌드 또는 배포 단계에서 실패, 변경 이력 추적 SSM Parameter Store /moyeota/v2/<stage>/...
8. 상태의 코드화 3-8 새 인스턴스가 사람 손 없이 같은 상태로 기동, 스키마 불일치를 배포 전에 감지 앱 계층은 v2/ Terraform으로 코드화. dev 데이터 계층 코드화와 마이그레이션 도구 도입은 진행 중

요구사항 3~5와 7은 ECS의 관리형 기능으로 충족한다.

  • 롤링 배포: ECS는 minimumHealthyPercent·maximumPercent 범위 안에서 기존 task를 새 task로 교체한다 (ECS rolling update). circuit breaker는 실패한 배포를 감지해 롤백한다 (ECS circuit breaker).
  • task 확장: ECS Service Auto Scaling은 target tracking·step·scheduled·predictive 정책으로 task 수를 조정한다 (ECS service auto scaling).
  • 인스턴스 확장: Capacity Provider managed scaling은 CapacityProviderReservation 지표로 EC2 인스턴스 수를 조정한다 (ECS cluster auto scaling).
  • 설정 주입: Task Definition의 secrets에 Parameter Store ARN을 지정하면 컨테이너 시작 시 환경변수로 주입되고, 값을 바꾸면 새 배포로 반영한다 (ECS + Parameter Store).

V2 구조에서 수용한 제약

V2가 모든 한계를 없애지는 않는다. 다음은 비용과 일정 때문에 수용한 제약이며, 공식 권장과 차이가 있다.

수용한 제약 공식 권장과의 차이 판단 근거
ECS 용량을 단일 AZ에 배치. AZ 장애 시 REST·WebSocket이 함께 중단될 수 있어 99.9%는 목표치이고 보장치가 아니다 REL10-BP01은 최소 2개 AZ 배포를 권장 V2의 백엔드 애플리케이션 인프라 설계
RDS Single-AZ로 AZ 장애 시 자동 failover가 없다 Multi-AZ는 관리형 standby·failover 제공 V2의 DB 인프라 설계
모든 스테이지가 롤링 배포이고 Stg·Prod Blue/Green은 아직 적용하지 않았다 REL08-BP04는 canary·blue/green 패턴을 권장 현재 V2 CD 구성 (ECS circuit breaker)
HikariCP pending의 원인과 WebSocket 연결 상한 약 1,700개의 원인은 아직 규명하지 않았다 —

따라서 V2는 V1의 구조적 한계 중 격리·수평 확장·자동 확장·무중단 배포·설정 일원화를 해소하고, AZ 단위 장애와 데이터 계층의 코드화는 다음 단계의 과제로 남긴다.

⚠️ **GitHub.com Fallback** ⚠️