Claude Code로 거래소 만들기(4) — 내렸던 거래소를 EC2 한 대에 다시 올렸다
3편 마지막에 이렇게 적었습니다. “라이브 데모는 지금 내렸습니다, AWS 비용 문제로.” 그 글을 올리고 며칠 동안 계속 걸렸습니다. 포트폴리오에 “이런 걸 만들었습니다”라고 써놓고 눌러볼 게 없는 것과, 주소를 눌렀을 때 실제로 호가창이 움직이는 건 완전히 다른 이야기니까요.
그래서 다시 올렸습니다. 대신 구성을 통째로 바꿨습니다. 지금 exchange.agentmichael.me에서 돌아가고 있습니다.
무엇을 뺐나
원래 구성은 ECS Fargate에 서비스 15개, 거기에 RDS MySQL, ElastiCache Redis, MSK Kafka 브로커 2대, ALB 2개, NAT 게이트웨이가 붙어 있었습니다. 교과서적인 배치입니다. 그리고 개인이 데모용으로 켜두기엔 부담스러운 금액이 매달 나옵니다.
비용의 대부분은 트래픽이 아니라 켜져 있다는 사실 자체에서 나옵니다. MSK 브로커는 아무도 주문을 넣지 않아도 시간당 요금이 붙고, NAT 게이트웨이도 마찬가지입니다. 데모에 붙는 사람은 하루에 몇 명인데 가용성 설계는 실서비스급으로 되어 있었던 겁니다.
그래서 관리형 서비스를 전부 걷어내고 EC2 한 대에 컨테이너로 다시 묶었습니다.
- EC2 r7g.large 1대 (Graviton, arm64)
- 컨테이너 21개 — Rust 매칭 엔진, Go 서비스 11개, Next.js 앱 3개(거래·관리자·대리점), MySQL·Redis·MongoDB·Kafka·ZooKeeper, 그리고 앞단의 Caddy
- 앞에는 CloudFront 3개. 도메인과 인증서가 이미 붙어 있어서 새로 만들지 않고 원본만 갈아 끼웠습니다
가용성은 확실히 내려갔습니다. 이 한 대가 죽으면 전부 죽습니다. 그런데 데모에 필요한 건 다중 AZ 이중화가 아니라 눌렀을 때 뜨는 것이었습니다. 필요한 수준을 정하고 거기에 맞추는 게 설계라고 생각합니다.
빌드는 어디서 하나
첫 번째로 막힌 지점입니다. EC2가 2 vCPU인데 여기서 프론트 3개 + Go 11개 + Rust 엔진을 빌드하면 한 시간이 넘습니다.
다행히 제 맥이 arm64이고 r7g도 Graviton(arm64)입니다. 아키텍처가 같으니 로컬에서 빌드한 이미지를 그대로 옮길 수 있습니다. ECR도 만들지 않았습니다. 한 대짜리 배포에 레지스트리를 세울 이유가 없어서, 이미지를 통째로 묶어 보냅니다.
docker save "${IMAGES[@]}" | gzip -1 | ssh ec2-user@$HOST 'gunzip | docker load'
이미지마다 ssh를 새로 열면 왕복이 계속 늘어나서, 한 번에 묶어 파이프로 흘려보냅니다.
한 도메인에 프론트와 API를 같이 얹으면 경로가 부딪힌다
로컬에서는 프론트가 3000번, 게이트웨이가 8080번이라 문제가 없었습니다. 그런데 하나의 도메인에 올리는 순간 부딪힙니다.
Next.js에 /exchange/BTC_USDT 페이지가 있는데, 게이트웨이도 /exchange/를 자기 것으로 봅니다. 같은 주소를 두 곳이 서로 자기 거라고 주장하는 상황입니다.
API 쪽에 /api 접두사를 붙이고, Caddy에서 떼어내 게이트웨이로 넘기는 것으로 정리했습니다.
handle /api/* {
uri strip_prefix /api
reverse_proxy gateway:8080
}
프론트는 빌드할 때 API 주소를 https://exchange.agentmichael.me/api로 박아둡니다. 게이트웨이 입장에서는 접두사가 떨어진 원래 경로가 들어오니 코드는 한 줄도 안 바뀝니다.
CloudFront 3개가 같은 EC2를 볼 때 어느 앱인지 구분하기
거래·관리자·대리점 앱이 각각 CloudFront 배포를 하나씩 갖고 있는데, 원본은 EC2 한 대입니다. Caddy는 요청이 들어와도 이게 어느 앱으로 갈 건지 알 방법이 없습니다.
원본 주소를 앱마다 따로 두는 것으로 풀었습니다. o-exchange / o-admin / o-agent 세 개를 Route53에 만들어 전부 같은 EC2를 가리키게 하면, TLS 연결을 맺는 단계에서 어느 이름으로 붙었는지가 드러납니다. Caddy는 그 이름만 보고 갈라주면 됩니다.
여기서 502를 한참 봤습니다. CloudFront가 원본에 요청을 넘길 때 어떤 헤더를 전달할지 고르는 설정이 있는데, 처음에 “뷰어가 보낸 걸 전부 그대로 넘김”을 골랐습니다. 그러면 Host 헤더까지 뷰어 것(exchange.agentmichael.me)이 넘어갑니다. Caddy 설정에는 o-exchange 블록만 있으니 매칭되는 곳이 없습니다.
증상이 헷갈렸던 건 인증서는 멀쩡했다는 점입니다. 연결은 o-exchange라는 이름으로 맺어지니 TLS는 통과하고, 그 다음 HTTP 단계에서 갈 곳이 없어서 끊깁니다. “Host 헤더만 원본 것으로 두고 나머지는 전달”하는 설정으로 바꾸니 바로 붙었습니다.
배포가 “성공”으로 끝났는데 거래가 한 건도 안 됐다
가장 오래 헤맨 건 인프라가 아니라 스키마였습니다.
초기 스키마 파일들과 번호가 붙은 마이그레이션 파일들을 ls | sort로 한 번에 적용했습니다. 그러면 이름이 schema.sql인 기본 스키마가 043_... 같은 번호 파일들보다 뒤로 밀립니다. 번호 마이그레이션들이 아직 만들어지지도 않은 테이블을 고치려다 줄줄이 실패했고, exchange_trade 같은 핵심 표가 통째로 빠진 채로 배포 스크립트는 초록불을 냈습니다.
서비스는 전부 정상적으로 떴습니다. 화면도 나왔습니다. 그런데 주문이 하나도 안 들어갔고, 그 이유를 찾는 데 한참 걸렸습니다.
두 가지를 고쳤습니다. 적용 순서를 이름 정렬이 아니라 의존 순서(초기 스키마 → 기본 스키마 → 번호 마이그레이션)로 명시했고, 마지막에 핵심 표가 실제로 존재하는지 확인해 없으면 배포를 거기서 멈추게 했습니다.
for t in exchange_trade exchange_order exchange_coin robot_params member; do
... 없으면 exit 1
done
배포 스크립트가 초록불을 내는 조건을 “명령이 에러 없이 끝났다”가 아니라 “이게 있어야 서비스가 성립한다“로 바꾼 셈입니다. 조용히 실패하는 배포가 시끄럽게 실패하는 배포보다 훨씬 위험합니다.
덤으로 만난 것들
ssh로 스크립트를 밀어 넣으면 뒷부분이 사라진다. 원격에서 돌릴 스크립트를 ssh의 입력으로 밀어 넣었는데, 스크립트 안에 있던 docker compose exec -T가 같은 입력을 읽어버려서 그 뒤 내용이 통째로 없어졌습니다. MySQL 준비 확인까지만 돌고 스키마 적용부터가 실행되지 않았습니다. 스크립트를 파일로 먼저 보내고 실행하는 것으로 해결했습니다.
zsh에서 $var:latest를 쓰면 안 된다. :l이 소문자 변환 수식어로 해석돼서 agent-web:latest가 agent-webatest로 만들어집니다. 실제로 한 번 당했고, docker load가 내는 “invalid reference format” 메시지만으로는 원인이 전혀 안 보입니다.
남은 이야기
지금 이 구성으로 거래 화면, 관리자 백오피스, 대리점 포털이 전부 돌아갑니다. 시세는 실시간으로 갱신되고, 마켓메이킹 봇이 호가를 채웁니다.
3편을 쓰면서 “만들면서 배울 건 다 배웠으니 미련은 없다”고 했는데, 다시 올려보니 배포를 줄이는 과정에서 배운 게 만들 때만큼 있었습니다. ECS Fargate에 올릴 때는 관리형 서비스가 감춰주던 것들 — 스키마를 언제 어떤 순서로 적용할지, 앞단이 뒷단을 어떻게 구분할지 — 을 이번엔 직접 정해야 했으니까요.
늘리는 설계는 자료가 많습니다. 줄이는 설계는 직접 부딪히면서 배우는 것 같습니다.