views.py 14,000줄과의 이별 — Django 모놀리스를 Java 21 MSA로 옮기는 중입니다
무인카페 SaaS 백엔드 이야기입니다. 전국 매장의 결제·주문·기기관리·포인트·정산을 처리하는 시스템인데, 처음 물려받았을 때 상태가 이랬습니다.
- Django 4.2 모놀리스, views.py 단일 파일 14,646줄
- 모델 67개, URL 218개
- MySQL과 MongoDB가 뒤섞인 하이브리드 저장소
- 그런데 이게 실제로 돈을 벌고 있는 운영 시스템
마지막 줄이 중요합니다. 돌아가는 시스템을 세워놓고 새로 만들 수는 없습니다. 그래서 스트랭글러 피그(Strangler Fig) 패턴으로 갔습니다.
전환 전략
한 리포에 3세대가 공존합니다. 운영 중인 Django 모놀리스, 전환 중인 Java 21 MSA, 새로 만든 Next.js 어드민. 도메인 하나씩 Java로 옮기고, 트래픽을 점진적으로 넘기고, 패리티가 검증되면 Django 쪽 코드를 죽입니다.
MSA 모노레포는 19모듈로 잡았습니다. 마이크로서비스 11개(store, catalog, machine, machine-gateway, payment, order, point, billing, notification, integration, analytics) + 공용 라이브러리 8개. 공용 라이브러리에는 이벤트 봉투(CloudEvents), Outbox 패턴, 보안, 관측(observability) 같은 횡단 관심사를 넣었습니다.
아키텍처를 문서가 아니라 CI로 강제하기
이번 프로젝트에서 제일 잘했다고 생각하는 것: ArchUnit 규칙 5개를 CI 게이트로 걸었습니다.
api → application → domain ← infrastructure의존 방향 강제- 도메인 레이어에 Spring import 금지
@Transactional은 application 레이어에만- 필드 주입 금지
“헥사고날 아키텍처로 갑시다”라고 문서에 적어봐야 3개월이면 무너집니다. 테스트로 만들어두면 PR이 머지가 안 되니까 무너질 수가 없습니다. AI와 협업할 때 특히 중요합니다. Claude Code가 아무리 좋은 코드를 짜도, 아키텍처 경계는 사람이 정하고 기계가 지키게 해야 합니다.
이벤트 파이프라인
서비스 간 통신은 RabbitMQ로 통일하고, 모든 이벤트를 CloudEvents 1.0 표준 형식으로 감싸서 주고받게 했습니다. 각 이벤트에는 같은 메시지가 두 번 처리되는 것을 막아주는 고유 ID(ULID), 요청 흐름을 추적하는 traceId, 어느 고객사의 이벤트인지 구분하는 tenantId가 항상 함께 실립니다. 발행 쪽은 Outbox 패턴을 @OutboxPublisher 어노테이션 + Relay로 라이브러리화해서, 각 서비스는 어노테이션 하나만 붙이면 됩니다. 이벤트 명세 문서만 599줄인데, 이게 서비스 간 계약서 역할을 합니다.
NCP에서 AWS로, 무중단으로
전환 중에 클라우드 이사까지 했습니다. 네이버클라우드에서 AWS로. 시나리오를 3개 만들어 비교하고, MySQL은 PK 델타 싱크, DocumentDB는 ObjectId 델타 싱크 스크립트를 직접 짜서 데이터를 따라잡게 한 뒤 컷오버했습니다. 다운타임 0. 계획 문서만 134KB가 나왔는데, 마이그레이션은 계획서 쓰는 게 일의 8할이라는 걸 다시 확인했습니다.
운영하면서 잡은 것들
커밋 로그에서 몇 개만 꺼내보면:
- 기기 1만 대 동시 접속 폭주 때 machine-service OOM → 태스크 증설 + 게이트웨이 인증 핫패스 타임아웃 조정(5s/2s)
- Netty DNS 리졸버가 1만 WebSocket 인증에서 연쇄 500 유발 → JDK 리졸버로 교체
- 어드민 카테고리 필터 HTML 1.1MB → 208KB, 응답 8.8초 → 1.1초
레거시 전환은 화려한 일이 아닙니다. 대부분은 이런 지루한 싸움의 연속입니다. 그래도 views.py 14,646줄이 서비스별 아키텍처 테스트가 걸린 19개 모듈로 바뀌어가는 걸 보면, 이 일을 하길 잘했다는 생각이 듭니다.