본문 바로가기
만들고싶은거 만들며 돈벌기

9년 된 서버를 통째로 갈아엎었다

by soulduse 2026. 7. 29.

500개 앱이 연결된 9년 된 Spring + Kotlin 서버를 FastAPI로 교체한 기록

2017년에 만든 서버를 최근까지 운영해왔습니다.

당시에는 Kotlin과 Spring Boot가 제가 가장 익숙하게 다루던 기술이었고, 안정적인 운영을 위해 EC2, RDS, SQS, S3 등 AWS의 여러 서비스를 함께 사용했습니다.

처음부터 거대한 서버를 만들 계획은 아니었습니다.

앱이 하나씩 늘어날 때마다 필요한 API를 추가했고, 데이터 처리와 외부 연동, 배치 작업, 실시간 기능이 하나씩 붙었습니다. 그렇게 9년이 흐르는 동안 제가 만들어 출시한 500개가 넘는 앱이 모두 이 서버 하나를 바라보게 됐습니다.

서버의 규모도 상당히 커졌습니다.

  • Kotlin·Spring 코드 약 21만 줄
  • 전체 파일 약 2,400개
  • REST API 약 1,100개
  • 컨트롤러 약 235개
  • Entity와 Repository 각각 200개 이상
  • @Transactional 약 878개
  • @Scheduled 약 175개
  • SQS 리스너 18개
  • 외부 API 연동 84개
  • SSE, Redis Pub/Sub, WebSocket 기반 실시간 기능
  • Mustache 기반 관리자 웹 페이지 52개

오랫동안 큰 문제 없이 돌아온 서버였지만, 어느 순간부터 이런 생각이 들기 시작했습니다.

개인 앱을 운영하는 1인 개발자에게 지금처럼 무겁고 복잡한 구조가 정말 필요할까?

Spring Boot는 분명 강력한 프레임워크입니다.

AWS 역시 안정적이고 훌륭한 클라우드 플랫폼입니다.

문제는 좋은 기술이 항상 지금의 나에게 가장 적합한 기술은 아니라는 점이었습니다.

직접 코딩하지 않은 지도 2년이 넘었습니다. 예전에는 특정 언어와 프레임워크를 얼마나 잘 다루느냐가 중요했다면, 지금은 AI와 함께 얼마나 빠르고 정확하게 결과물을 만들어낼 수 있는지가 더 중요해졌습니다.

반면 기존 서버는 배포와 운영 측면에서 점점 무거운 구조가 되고 있었습니다.

서버 비용은 매달 약 25만 원 정도 발생했고, 실제 컴퓨팅 비용보다 관리형 서비스와 주변 인프라 비용이 더 컸습니다.

그래서 오래전부터 미뤄왔던 결정을 내렸습니다.

9년 된 Kotlin·Spring Boot 서버를 Python·FastAPI로 옮기고, AWS 중심의 인프라를 더 작고 단순한 구조로 다시 설계하기로 했습니다.

하지만 막상 시작하고 보니 코드를 옮기는 일은 프로젝트의 절반에 불과했습니다.

이미 수백 개의 앱이 기존 서버를 바라보며 동작하고 있었기 때문입니다.

달리는 기차의 바퀴를 갈아끼우는 것만으로는 부족했습니다.

바퀴를 갈아끼우는 동안 기차 전체도 다른 기차로 바꿔야 했습니다.


마이그레이션의 시작은 코드가 아니라 전수조사였다

처음부터 AI에게 이렇게 요청할 수도 있었습니다.

이 Spring Boot 서버를 FastAPI로 옮겨줘.

하지만 9년 동안 쌓인 서버에 이런 방식으로 접근하는 것은 지나치게 위험하다고 판단했습니다.

겉으로 보기에는 단순한 API 하나라도 내부에서는 데이터베이스 트랜잭션, 캐시, 메시지 큐, 비동기 처리, 외부 API, 스케줄러와 연결돼 있을 수 있었습니다.

어떤 기능이 어디에서 사용되고 있는지 제대로 파악하지 않은 상태에서 포팅을 시작하면, 코드상으로는 옮겨진 것처럼 보여도 실제 서비스에서 누락되는 기능이 생길 가능성이 컸습니다.

그래서 실제 마이그레이션에 앞서 기존 서버 전체를 먼저 전수조사하도록 했습니다.

Claude Fable 5에게 다음 항목을 모두 분석하고 문서화하도록 요청했습니다.

  • 전체 API와 각 API의 역할
  • 스케줄러 목록과 실행 주기
  • 데이터베이스 구조와 주요 테이블
  • 트랜잭션 처리 방식
  • 캐싱 처리
  • SQS 리스너와 메시지 흐름
  • 비동기 처리
  • 실시간 통신
  • 주요 기능 단위와 도메인 분류
  • Service, Controller, Repository, Entity 구조
  • 기존 코드 스타일과 공통 패턴
  • 웹 프론트 구조
  • 관리자 기능
  • 외부 API 연동
  • 앱에서 실제로 사용되는 기능
  • 배치 작업과 백그라운드 처리

단순히 파일 목록만 뽑도록 한 것은 아니었습니다.

각 기능이 어떤 역할을 하는지, 어디에서 호출되는지, 어떤 데이터와 연결되는지, 마이그레이션할 때 무엇을 주의해야 하는지까지 기록하도록 했습니다.

기존 서버를 문서화하는 데만 Fable 5를 약 5시간 정도 사용했습니다.

처음에는 마이그레이션 전에 너무 많은 시간을 쓰는 것이 아닌가 싶었습니다.

하지만 결과적으로 이 과정이 전체 작업에서 가장 중요한 기반이 됐습니다.


문서도 하나의 시스템처럼 설계했다

문서가 많아지면 문서 자체가 또 다른 복잡성이 됩니다.

하나의 거대한 문서에 모든 내용을 넣으면 사람이 읽기도 어렵고, AI가 필요한 내용을 찾아 참고하기도 어려워집니다.

그래서 문서 역시 코드 구조처럼 잘게 나눴습니다.

API 문서 하나에 모든 엔드포인트를 몰아넣는 대신 기능과 범주별로 여러 파일로 분리했습니다.

큰 범주는 별도의 목차 문서를 만들었습니다.

목차가 여러 개 생기면 그 목차들을 다시 묶는 상위 목차를 만들었습니다.

대략 다음과 같은 구조였습니다.

migration-docs/
├── index.md
├── architecture/
│   ├── index.md
│   ├── controllers.md
│   ├── services.md
│   ├── repositories.md
│   └── entities.md
├── api/
│   ├── index.md
│   ├── group-a/
│   ├── group-b/
│   ├── group-c/
│   └── admin/
├── database/
│   ├── schema.md
│   ├── transactions.md
│   └── migrations.md
├── scheduler/
├── sqs/
├── cache/
├── async/
├── realtime/
├── frontend/
└── progress/

문서는 단순한 분석 결과물이 아니었습니다.

이후 모든 에이전트와 세션이 같은 기준을 참고할 수 있도록, 전체 마이그레이션을 지휘하는 지도 역할을 하게 만들었습니다.

진행 상황을 관리하는 Progress 문서도 별도로 구성했습니다.

각 작업에는 다음과 같은 상태를 기록하도록 했습니다.

  • 대기
  • 분석 중
  • 마이그레이션 진행 중
  • 코드 작성 완료
  • 테스트 작성 중
  • 테스트 통과
  • 검증 완료
  • 문제 발생
  • 재작업 필요
  • 최종 완료

문제가 생겼을 때는 단순히 실패로 표시하지 않았습니다.

어떤 문제가 발생했는지, 어디까지 진행됐는지, 다음 작업은 무엇인지까지 기록하도록 했습니다.

이 과정을 통해 느낀 점이 하나 있습니다.

대규모 AI 작업에서는 프롬프트 자체보다 AI가 참고할 수 있는 구조화된 문서와 진행 상태가 더 중요할 수 있습니다.


처음 Goal 프롬프트는 완성도를 우선했다

문서화가 끝난 뒤 Fable 5의 Goal Mode로 실제 마이그레이션을 시작했습니다.

처음 설계한 작업 순서는 상당히 보수적이었습니다.

  1. 관련 문서를 읽는다.
  2. 기존 Spring Boot 코드를 꼼꼼하게 분석한다.
  3. FastAPI로 마이그레이션한다.
  4. 기존 코드와 새 코드를 비교한다.
  5. 테스트 코드를 작성한다.
  6. 테스트가 통과할 때까지 수정한다.
  7. 진행 상태를 문서에 기록한다.
  8. 다음 기능으로 이동한다.

기능 하나를 옮길 때마다 분석부터 테스트까지 모두 마무리한 뒤 다음 기능으로 넘어가도록 했습니다.

완성도만 놓고 보면 좋은 방식이었습니다.

문제는 속도였습니다.

Goal 프롬프트가 4일이 지나도록 끝나지 않고 계속 실행됐습니다.

9시간이 지나고, 20시간이 지나도 전체 작업의 일부만 진행됐습니다.

Max 20x 계정의 사용량을 모두 소진하면 새로운 계정을 추가 구독했고, 그 계정도 다시 한도에 도달했습니다.

물론 Claude 사용량 전체를 마이그레이션만 진행한 것은 아니었습니다.

동시에 다른 서비스 개발과 기존 기능 개선도 병행하고 있었기 때문에, AI 계정은 최종적으로 10개까지 늘어났습니다.

Spring Boot와 Kotlin 코드를 FastAPI로 포팅하는 데만 러프하게 일주일 정도가 걸렸습니다.

처음에는 AI가 거대한 작업을 알아서 빠르게 끝내줄 것이라고 기대했습니다.

하지만 분석, 구현, 비교, 테스트, 수정까지 모든 절차를 직렬로 처리하도록 하니 작업량과 소요되는 시간이 기하급수적으로 늘어났습니다.


완벽한 직렬 처리에서 빠른 병렬 처리로

작업이 너무 오래 걸리면서 전략을 바꿨습니다.

처음에는 기능 하나를 분석하고, 옮기고, 테스트하고, 검증한 뒤 다음 기능으로 이동했습니다.

이 방식은 안전하지만 1,000개가 넘는 API와 수많은 기능을 가진 서버에서는 현실적으로 너무 느렸습니다.

그래서 일단 마이그레이션 속도를 우선하기로 했습니다.

테스트 코드 작성과 세밀한 검증은 잠시 뒤로 미루고, 서로 충돌하지 않는 영역을 최대한 여러 서브 에이전트에 나눠 병렬로 처리하도록 했습니다.

예를 들면 다음처럼 역할을 분리했습니다.

  • 사용자 및 인증 관련 기능
  • 콘텐츠 처리
  • 통계와 분석
  • 관리자 기능
  • 스케줄러
  • 외부 API 연동
  • 메시지 큐
  • 실시간 통신
  • 공통 인프라 코드

같은 파일이나 같은 핵심 모델을 동시에 수정하지 않는 범위에서 최대한 병렬화했습니다.

이때부터 작업 속도가 눈에 띄게 빨라졌습니다.

직렬 처리에서는 기능 하나가 끝나야 다음 기능을 시작할 수 있었습니다.

병렬 처리로 바꾼 뒤에는 여러 기능 영역이 동시에 FastAPI로 옮겨졌고, 마이그레이션의 큰 뼈대가 빠르게 완성됐습니다.


먼저 빠르게 옮기고, 별도의 검증 루프를 돌렸다

병렬 작업은 속도를 크게 높였지만 놓치는 부분도 생겼습니다.

그래서 전체 포팅이 끝난 뒤 별도의 세션을 하나 더 열었습니다.

이번 세션의 역할은 구현이 아니라 검증이었습니다.

  • 기존 Spring 코드와 FastAPI 코드 비교
  • 누락된 API 탐색
  • 요청 파라미터 비교
  • 응답 구조 비교
  • 예외 처리 비교
  • 트랜잭션 범위 비교
  • 날짜와 숫자 직렬화 비교
  • 테스트 코드 작성
  • 테스트 실패 시 수정
  • API 계약 불일치 탐색
  • 문서와 실제 구현의 차이 확인

처음부터 모든 기능을 완벽하게 검증하며 옮기는 대신, 먼저 병렬로 빠르게 옮기고 전체 비교와 테스트를 별도의 루프로 돌린 것입니다.

이 방식이 전체적으로 훨씬 효율적이었습니다.

첫 번째 단계에서는 처리량을 최대화하고, 두 번째 단계에서는 정확도를 높였습니다.

대규모 작업에서는 하나의 에이전트가 처음부터 끝까지 모든 것을 맡는 것보다, 작업의 목적에 따라 역할과 세션을 분리하는 편이 더 효과적이었습니다.


코드가 완성된 순간이 프로젝트의 절반이었다

여러 차례의 분석과 포팅, 비교, 테스트를 거쳐 기존 Spring Boot 서버의 기능은 대부분 FastAPI로 옮겨졌습니다.

처음에는 여기까지 오면 끝이라고 생각했습니다.

하지만 사실 코드를 다 옮긴 시점은 프로젝트의 절반에 불과했습니다.

새 서버는 완성됐지만 기존 서버에는 여전히 하루 수만건이 넘는 요청이 들어오고 있었습니다.

500개가 넘는 앱이 하나의 도메인을 바라보고 있었습니다.

이미 배포된 앱들의 서버 주소를 한 번에 바꾸는 것은 불가능합니다.

앱을 모두 업데이트한다고 해도 사용자가 새로운 버전을 설치하지 않으면 기존 앱은 계속 예전 주소를 호출합니다.

결국 앱을 새 서버에 맞출 수는 없었습니다.

서버가 앱에 맞춰야 했습니다.

같은 주소, 같은 API, 같은 요청과 응답을 그대로 유지하면서 그 뒤에서 요청을 처리하는 서버만 통째로 바꿔야 했습니다.

이런 규모의 인프라 전환을 직접 해본 적은 없었습니다.

달리는 기차를 멈추지 않고 바퀴를 갈아끼우면서, 동시에 기차 자체를 다른 모델로 바꾸는 상황에 가까웠습니다.


달리는 기차의 바퀴를 갈아끼우다

전략은 단순했다. 한 번에 바꾸지 않는다

가장 쉬운 방법은 어느 날 새벽 DNS를 새 서버로 돌리는 것입니다.

전환하고 문제가 생기면 기존 서버로 다시 돌리면 됩니다.

하지만 1,100개가 넘는 API와 500개 이상의 앱이 연결된 환경에서는 이런 빅뱅 전환이 도박에 가깝습니다.

API 하나라도 잘못 옮겨졌다면 수백 개 앱이 동시에 영향을 받을 수 있습니다.

여러 기능에서 문제가 동시에 발생하면 어떤 API에서 문제가 생겼는지 찾는 동안 장애는 계속됩니다.

그래서 한 번에 모든 것을 바꾸지 않기로 했습니다.

선택한 방식은 스트랭글러 패턴이었습니다.

기존 시스템을 한 번에 없애는 대신, 기능과 경로를 조금씩 새로운 시스템으로 넘기고 기존 시스템의 역할을 점진적으로 줄여나가는 방식입니다.

이를 위해서는 먼저 트래픽을 경로 단위로 나눠 보낼 수 있는 분기점이 필요했습니다.


1단계 — 트래픽의 관제탑을 만들다

기존 구조에서는 DNS가 AWS 로드밸런서를 바라보고 있었습니다.

문제는 로드밸런서에서 외부에 있는 새로운 서버로 API 경로별 트래픽을 자유롭게 분리하기 어렵다는 점이었습니다.

그래서 먼저 DNS를 Cloudflare로 위임했습니다.

진행 순서는 다음과 같았습니다.

  1. Route53의 DNS 레코드를 Cloudflare에 그대로 복제
  2. 도메인의 네임서버를 Cloudflare로 변경
  3. 초기에는 기존 AWS 서버를 그대로 오리진으로 유지
  4. 프록시 모드를 활성화해 모든 요청이 Cloudflare 엣지를 통과하도록 구성

이 단계에서는 실제 백엔드가 바뀌지 않았습니다.

사용자 요청은 여전히 100% 기존 Spring 서버로 전달됐습니다.

달라진 것은 모든 요청이 제가 직접 라우팅 로직을 넣을 수 있는 지점을 지나게 됐다는 점입니다.

그 지점에 Cloudflare Worker 기반의 엣지 라우터를 만들었습니다.

구조는 단순했습니다.

요청 경로가 이관 화이트리스트에 있으면
→ 새 FastAPI 서버로 전달

화이트리스트에 없으면
→ 기존 Spring 서버로 전달

전체 문제가 발생하면
→ killSwitch로 모든 요청을 기존 서버로 원복

가장 중요한 원칙은 기본값이 기존 서버라는 점이었습니다.

검증이 끝난 API만 명시적으로 새 서버로 넘겼습니다.

누락된 API가 실수로 새 서버에 들어가 404를 반환하는 상황을 구조적으로 막을 수 있었습니다.

설정 변경부터 배포까지 걸리는 시간은 약 10초였습니다.

문제가 생겼을 때 이전 상태로 돌아가는 시간도 약 10초였습니다.


2단계 — 그림자 트래픽으로 실제 사용자에게 채점받다

코드를 옮기고 테스트 코드가 통과했다고 해서 실제 서비스에서 동일하게 동작한다는 보장은 없습니다.

테스트는 결국 개발자가 예상한 입력을 기반으로 작성됩니다.

하지만 실제 사용자는 개발자가 상상하지 못한 데이터를 보냅니다.

그래서 전환 전에 섀도우 트래픽을 구성했습니다.

사용자의 GET 요청이 들어오면 Cloudflare Worker에서 요청을 복제했습니다.

  • 사용자에게는 기존 Spring 서버의 응답을 전달
  • 뒤에서는 동일한 요청을 FastAPI 서버에도 전달
  • 두 서버의 응답을 비교
  • 불일치가 발견되면 로그와 문서에 기록

사용자에게는 기존 서버의 응답만 전달됐기 때문에 아무런 영향이 없었습니다.

새 서버는 실제 트래픽을 받았지만 결과를 사용자에게 보내지 않았습니다.

실제 사용자의 요청으로 새 서버를 리허설한 셈입니다.

이 과정에서 테스트 코드로는 쉽게 발견하기 어려운 문제들이 나타났습니다.

  • 날짜 직렬화에서 특정 조건일 때 출력 형식이 달라지는 문제
  • Kotlin과 Python의 부동소수점 계산 차이
  • 외부 서비스가 요청 헤더에 따라 다른 결과를 반환하는 문제
  • 컨테이너 타임존 설정으로 날짜가 하루 밀리는 문제
  • null과 빈 문자열 처리 차이
  • JSON 기본값과 필드 표현 차이
  • 정수와 실수 직렬화 차이
  • Spring과 FastAPI의 예외 응답 차이

불일치가 발견될 때마다 FastAPI 서버를 수정하고 다시 실트래픽을 대조했습니다.

응답 차이가 거의 0에 수렴할 때까지 이 과정을 반복했습니다.

AI가 작성한 테스트 코드도 중요했지만, 실제 트래픽이 가장 현실적인 테스트 데이터였습니다.


3단계 — 조회부터 옮기고, 쓰기는 나중에

응답 검증이 끝난 경로부터 실제 트래픽을 새 서버로 보내기 시작했습니다.

전환 순서에는 명확한 원칙을 뒀습니다.

GET 요청부터 옮기고, POST·PUT·DELETE는 기능 영역별로 신중하게 옮긴다.

조회 요청은 문제가 발생해도 데이터가 오염될 가능성이 낮습니다.

잘못된 응답이 잠시 표시될 수는 있지만 데이터베이스 상태가 바뀌지는 않습니다.

반면 쓰기 요청은 잘못 처리되면 데이터 중복, 누락, 잘못된 상태 변경으로 이어질 수 있습니다.

그래서 조회 API를 먼저 새 서버로 옮겼습니다.

쓰기 API는 기존 서버가 반환하던 성공 응답뿐 아니라 오류 응답, 예외 코드, 트랜잭션 처리 방식까지 다시 확인했습니다.

위험도가 높은 기능은 한 번에 전량 전환하지 않고, 일부 트래픽만 새 서버로 보내는 카나리 방식도 사용했습니다.

이렇게 경로와 기능 그룹별로 트래픽을 조금씩 넘겼습니다.

새 서버가 처리하는 요청은 점점 늘어났고, 기존 서버의 역할은 점점 줄어들었습니다.


트래픽과 데이터베이스 이전은 별개의 작업으로 분리했다

애플리케이션 서버와 데이터베이스를 동시에 바꾸면 문제가 생겼을 때 원인을 찾기 어렵습니다.

API 코드 문제인지, 네트워크 문제인지, 데이터베이스 이전 문제인지 구분하기 어려워지기 때문입니다.

그래서 트래픽 전환과 데이터베이스 이전을 완전히 별개의 축으로 운영했습니다.

FastAPI 서버가 사용자 요청을 처리하기 시작한 뒤에도 데이터베이스는 기존 RDS를 계속 사용했습니다.

Spring 서버와 FastAPI 서버가 동일한 RDS를 바라보도록 했습니다.

어느 서버가 요청을 처리하더라도 같은 데이터를 읽고 쓰게 만든 것입니다.

이 구조 덕분에 애플리케이션 마이그레이션부터 충분히 검증한 뒤 데이터베이스를 별도로 옮길 수 있었습니다.


서버 한 대였는데 IP는 왜 7개나 필요했을까

AWS 비용을 분석하며 가장 의아했던 항목 중 하나는 퍼블릭 IPv4였습니다.

애플리케이션 서버는 사실상 한 대였는데, IP 관련 비용은 7개 분량이 잡혀 있었습니다.

처음부터 IP 7개가 필요하다고 판단해 구성한 것은 아니었습니다.

주변 인프라가 IP를 하나씩 늘려가는 구조였습니다.

가장 많은 IP를 차지한 것은 로드밸런서였습니다.

로드밸런서가 여러 가용 영역에 네트워크 인터페이스를 구성하면서 IP 여러 개를 사용했고, 도메인 레코드는 이 주소들을 바라보고 있었습니다.

실제 애플리케이션 서버는 한 대여도, 앞단의 로드밸런서가 여러 IP를 사용하고 있던 것입니다.

오토스케일링으로 관리되는 인스턴스에도 퍼블릭 IPv4가 자동으로 할당됐습니다.

평상시에는 한 대만 실행되더라도 배포나 인스턴스 교체, 헬스체크 실패로 인한 재생성 과정에서 새로운 인스턴스가 만들어질 때마다 퍼블릭 IP가 붙었습니다.

퍼블릭 IPv4 자체가 과금 대상이 되면서 이런 구조도 모두 비용이 됐습니다.

나머지 하나는 아웃바운드 IP를 고정하기 위한 프록시였습니다.

일부 외부 API는 사전에 등록한 IP에서 들어오는 요청만 허용합니다.

애플리케이션 서버가 배포되거나 교체될 때마다 외부로 나가는 IP가 바뀌면 매번 허용 목록을 수정해야 합니다.

이를 피하기 위해 소형 프록시 서버에 고정 IP를 붙이고, 해당 외부 API로 나가는 요청만 이 프록시를 거치도록 구성해뒀습니다.

결국 당시 구조는 다음과 같았습니다.

  • 로드밸런서가 여러 IP 사용
  • 애플리케이션 인스턴스가 퍼블릭 IPv4 사용
  • 아웃바운드 프록시가 고정 IP 사용
  • 교체와 오토스케일링 과정에서도 IP 자원 사용

즉, 서버 한 대이니 IP도 하나면 된다는 계산이 맞지 않았습니다.

로드밸런서, 오토스케일링, 아웃바운드 고정이라는 각각의 안전장치가 자기 몫의 IP를 들고 있었습니다.

개별 구성은 나름의 이유가 있었지만, 전체를 보면 작은 모놀리식 서버 하나를 위해 주변 네트워크 자원이 지나치게 많이 붙어 있었습니다.

이번 이전에서 로드밸런서와 오토스케일링 구조를 걷어내면서 IP 관련 비용도 함께 사라졌습니다.


모든 AWS 서비스를 없앤 것은 아니다

인프라를 단순화했다고 해서 AWS 서비스를 전부 제거한 것은 아닙니다.

이번 작업의 기준은 단순했습니다.

비싸다는 이유로 모두 없애는 것이 아니라, 현재 구조에서 비용 대비 가치가 있는지를 다시 판단한다.

대표적인 예가 SQS였습니다.

기존 서버에는 18개의 SQS 리스너가 있었습니다.

비동기 처리를 여러 기능에 사용하고 있었기 때문에 겉으로 보면 메시지 큐 역시 비용 최적화 대상처럼 보일 수 있습니다.

하지만 실제 비용을 확인해보니 SQS는 문제가 아니었습니다.

현재 트래픽 규모에서 SQS 비용은 월 1~2달러 수준이었습니다.

유휴 큐를 유지한다고 별도의 서버 비용이 발생하는 구조도 아니었습니다.

Kafka나 RabbitMQ 클러스터를 직접 운영했다면 1인 서비스에는 과도했을 것입니다.

하지만 SQS는 운영 부담이 거의 없고 실측 비용도 매우 낮았습니다.

현재도 일부 배치 작업과 독립 실행형 워커는 SQS를 그대로 사용합니다.

삭제해봤자 절감되는 비용은 거의 없는데, 제거하려면 비동기 처리 구조를 다시 설계해야 했습니다.

따라서 SQS는 전량 유지했습니다.

비용 문제의 본체는 메시지 큐가 아니었습니다.

  • RDS
  • 로드밸런서
  • 여러 퍼블릭 IPv4
  • 관리형 구조의 주변 비용
  • 불필요하게 남아 있던 리소스
  • 현재 규모에 비해 과한 고가용성 구성

이런 항목이 비용의 대부분을 만들고 있었습니다.

단순화란 모든 것을 하나의 서버에 몰아넣는 것이 아닙니다.

싸고 안정적이며 관리 부담이 낮은 서비스는 유지하고, 실제로 비용을 키우는 구조만 걷어내는 편이 더 합리적이었습니다.


비용 절감만 목적이었다면 코드까지 다시 쓸 필요는 없었다

이 작업을 보면 이런 의문이 생길 수 있습니다.

로드밸런서를 없애고 서버를 직접 연결한 뒤, RDS를 새로운 MySQL로 옮기면 되는 것 아닌가? 왜 굳이 21만 줄이 넘는 Spring Boot 서버를 FastAPI로 다시 작성했는가?

인프라 비용 절감만 목표였다면 이 지적이 맞습니다.

Spring Boot 서버를 그대로 새로운 가상 서버에 옮기고 MySQL을 함께 구성하는 편이 훨씬 빠르고 안전했을 것입니다.

하지만 당시 문제는 인프라 비용 하나만이 아니었습니다.

Spring 기반의 개발과 배포 구조 자체가 점점 병목이 되고 있었습니다.


하루에도 여러 번 배포하는데 한 번에 10분 이상이 걸렸다

당시에는 하루에 적게는 몇 번, 많게는 열 번 이상 서버를 배포할 때도 있었습니다.

하지만 Spring 서버는 빌드부터 배포, 애플리케이션 재기동까지 매번 최소 10분 이상이 필요했습니다.

새 버전을 배포하는 데 10분이 걸리고, 문제가 발견돼 이전 버전으로 원복할 때도 다시 같은 시간을 내야 했습니다.

평상시 서버를 한 대만 운영했기 때문에 이 부담은 더 컸습니다.

여유 서버를 여러 대 운영한다면 새로운 버전을 별도로 띄우고 트래픽만 전환할 수 있습니다.

하지만 비용을 줄이기 위해 한 대만 운영하는 환경에서는 배포와 재시작의 충격을 그대로 받아야 했습니다.

JVM이 올라오고 애플리케이션이 초기화되는 동안 서버 자원이 순간적으로 크게 사용되는 구간도 있었습니다.

AI를 활용하면서 코드 작성 속도는 빨라졌지만, 실제 사용자에게 반영하는 과정은 여전히 느렸습니다.

개발 속도는 몇 배 빨라졌는데 배포 구조가 그 속도를 따라오지 못하고 있었습니다. 거기에 더해 배포시간이 늘어날수록, 배포가 잦을수록 Github Action 비용 또한 꾸준히 증가하고 있었습니다.


Python 코드가 서버 바깥에 계속 늘어나고 있었다

또 다른 문제는 스택의 파편화였습니다.

일부 외부 서비스나 도구는 Python SDK만 제공하고 Java용 라이브러리는 지원하지 않는 경우가 있었습니다.

데이터 수집, 문서 처리, 파싱, 자동화 작업도 Python 생태계를 이용하는 편이 더 편리한 경우가 많았습니다.

결국 기존 Spring 서버에서 처리하기 불편한 기능은 별도의 Python 코드로 분리되기 시작했습니다.

  • 서버리스 함수
  • 컨테이너 기반 배치
  • 외부 데이터 처리
  • 자동화 스크립트
  • 독립 실행형 워커
  • 크롤링과 파싱 작업

메인 서버는 Kotlin과 Spring인데, 주변 기능은 Python으로 계속 늘어났습니다.

같은 서비스를 운영하면서 언어와 실행 환경이 여러 곳에 흩어지기 시작한 것입니다.

그 결과 관리해야 할 것도 함께 늘어났습니다.

  • 공통 모델을 두 언어로 관리
  • 인증과 환경 변수 중복 설정
  • 오류 처리 방식의 차이
  • 배포 파이프라인 분리
  • 로그와 모니터링 분산
  • 직렬화 차이
  • 공통 로직 중복 구현

그래서 이번 마이그레이션에는 비용 절감 외에도 또 하나의 목적이 있었습니다.

이미 주변부에서 넓게 사용하고 있던 Python을 중심으로 서버 스택을 다시 통합한다.

새로운 기능을 추가할 때마다 별도의 Python 서비스를 만들지 않고, FastAPI 서버와 같은 언어와 도구를 사용할 수 있도록 만들고 싶었습니다.


기존 서버에는 애플리케이션과 DB를 함께 넣을 여유가 없었다

기존 EC2 인스턴스는 Spring 애플리케이션 하나를 실행하는 것만으로도 자원이 빠듯했습니다.

여기에 MySQL과 Redis, 백업 작업까지 함께 올리는 것은 현실적이지 않았습니다.

메모리를 늘리면 비용이 커졌고, 기존 사양을 유지하면 메모리나 디스크 중 하나에서 문제가 발생할 가능성이 높았습니다.

단순히 기존 서버의 Docker Compose에 MySQL을 추가하는 방식으로는 해결하기 어려웠습니다.

결국 새로운 서버 환경으로 옮겨야 했습니다.

그렇다면 기존 Spring 환경을 그대로 복사해 단기적으로 비용만 줄일 것인지, 개발과 배포의 병목까지 함께 해결할 것인지 선택해야 했습니다.

저는 후자를 선택했습니다.

서버를 옮기는 김에 다음 문제들을 함께 해결하고 싶었습니다.

  • 느린 빌드와 배포
  • 긴 콜드 스타트
  • 증가하는 CI 실행 비용
  • Python 코드의 파편화
  • 작은 서버에서의 높은 메모리 사용량
  • 복잡한 AWS 관리형 구조
  • 느린 장애 원복

따라서 이번 작업은 단순한 호스팅 이전이 아니었습니다.

인프라 이전과 애플리케이션 스택 재설계를 함께 진행한 리모델링에 가까웠습니다.


4단계 — 데이터베이스를 10초 만에 복원한 것은 아니다

모든 주요 트래픽이 FastAPI 서버로 넘어간 뒤 마지막으로 데이터베이스를 이전했습니다.

이 과정에서 “9년치 데이터베이스를 약 10초 만에 옮겼다”는 표현은 오해를 만들 수 있습니다.

10초 안에 데이터베이스 전체를 덤프하고 복원한 것은 아닙니다.

실제 데이터 이동은 최종 전환보다 훨씬 전에 시작됐습니다.

먼저 기존 RDS의 전체 데이터를 덤프해 새로운 MySQL 서버에 적재했습니다.

그다음 새 MySQL을 기존 RDS의 복제본으로 연결했습니다.

이후 RDS에서 발생하는 변경 사항은 binlog를 통해 새 MySQL에 계속 반영됐습니다.

기존 RDS
   │
   │ 최초 전체 덤프
   ▼
새 MySQL

이후 변경 데이터
   │
   │ binlog replication
   ▼
새 MySQL에 지속 반영

초기 전체 데이터는 미리 옮겨놓고, 이후 발생하는 변경 사항만 실시간으로 따라가게 만든 것입니다.

이 복제 상태를 며칠간 유지하면서 새 MySQL은 기존 RDS와 거의 동일한 상태를 유지했습니다.

최종 전환 시점에 한 작업은 다음과 같습니다.

  1. 복제 지연이 0인지 확인
  2. 약 10초 동안 쓰기 요청 정지
  3. 마지막 binlog 반영 확인
  4. 복제 좌표 고정
  5. 애플리케이션 DB 연결을 새 MySQL로 변경
  6. 쓰기 요청 재개

따라서 약 10초는 전체 데이터 복원 시간이 아닙니다.

마지막 데이터 정합성을 보장하기 위해 쓰기를 멈춘 시간이었습니다.

전체 데이터의 이동은 이미 사전에 완료돼 있었고, 마지막 순간 발생할 수 있는 차이만 제거했습니다.


왜 Dual-write 대신 복제를 선택했을까

데이터베이스를 옮기는 또 다른 방법으로 Dual-write가 있습니다.

애플리케이션에서 저장 요청이 발생할 때 기존 RDS와 새 MySQL 양쪽에 동시에 기록하는 방식입니다.

일정 기간 두 결과를 비교한 뒤 새 데이터베이스로 전환할 수 있습니다.

하지만 1인 개발 환경에서는 Dual-write가 오히려 더 위험하다고 판단했습니다.

Dual-write를 적용하려면 모든 쓰기 경로를 수정해야 합니다.

  • 두 DB에 모두 저장됐는지 확인
  • 한쪽만 실패했을 때 보상 처리
  • 트랜잭션 경계 관리
  • 재시도로 인한 중복 방지
  • 순서가 중요한 데이터 처리
  • 데이터 불일치 탐지와 재동기화
  • 전환 후 Dual-write 코드 제거

쓰기 API가 많고 트랜잭션 구조도 복잡한 서버에서 이 로직을 애플리케이션 코드에 추가하면 새로운 버그 가능성이 크게 늘어납니다.

반면 데이터베이스 복제는 애플리케이션의 쓰기 로직을 거의 건드리지 않습니다.

기존 서버와 새 서버는 계속 RDS 하나만 바라보고, 데이터베이스 계층에서 변경 사항을 새 MySQL로 복제합니다.

애플리케이션은 데이터베이스 이전 준비가 진행되고 있다는 사실을 알 필요가 없습니다.

저에게는 이 방식이 훨씬 단순하고 위험이 낮았습니다.

애플리케이션에서 두 군데에 쓰는 것보다, 데이터베이스가 자신의 변경 내역을 복제하도록 한다.

마이그레이션 과정에서 복잡성을 줄이기 위해 Dual-write와 별도 backfill 대신 MySQL replication을 선택했습니다.


RDS 없이 운영하기 위해 백업부터 다시 설계했다

RDS를 제거할 때 가장 신경 쓴 것은 데이터베이스 백업이었습니다.

관리형 서비스를 사용하지 않으면 자동 백업과 특정 시점 복구도 직접 구성해야 합니다.

현재는 전체 백업과 증분 백업을 함께 사용합니다.

매일 새벽 전체 데이터베이스를 덤프합니다.

덤프 파일은 압축한 뒤 Cloudflare R2에 업로드하고 14일 동안 보관합니다.

MySQL 전체 덤프
→ 압축
→ 무결성 확인
→ Cloudflare R2 업로드
→ 14일 보관

여기에 15분마다 MySQL binlog를 R2로 전송합니다.

전체 덤프와 binlog를 조합하면 서버가 통째로 사라지더라도 최대 15분 전 시점까지 복구할 수 있습니다.

RDS의 특정 시점 복구와 비슷한 구조를 직접 만든 셈입니다.

하지만 백업 파일이 생성되고 업로드된다고 해서 안심할 수는 없습니다.

백업은 실제로 복원해보기 전까지는 백업이 아닙니다.

그래서 R2에 저장된 백업 파일을 내려받아 임시 MySQL 서버에 복원하는 리허설도 진행했습니다.

복원에는 약 5분 26초가 걸렸습니다.

복원 후 테이블 수와 주요 데이터 건수를 실제 서버와 비교했고, 정상적으로 일치하는 것을 확인했습니다.

그제야 RDS의 최종 스냅샷을 삭제할 수 있었습니다.


일부 배치 작업은 AWS에 남겼다

모든 기능을 무조건 AWS 밖으로 옮긴 것은 아닙니다.

외부 데이터를 수집하거나 주기적으로 데이터를 정리하는 것처럼, 특정 시간대에만 짧게 실행되는 배치 작업들이 있었습니다.

이 작업들은 하루 종일 실행되지 않고 정해진 시간에 몇 분 정도만 동작합니다.

항상 켜져 있는 서버로 모두 합치는 것보다, 사용한 시간만 비용을 내는 AWS Fargate에 남겨두는 편이 더 효율적이었습니다.

비용도 월 몇 달러($2~4) 수준으로 크지 않았습니다.

이번 작업의 목적은 AWS를 완전히 제거하는 것이 아니었습니다.

각 작업의 특성에 맞는 환경을 다시 선택하는 것이었습니다.

문제는 Fargate에서 실행되는 배치가 AWS 밖에 있는 MySQL에 접근해야 한다는 점이었습니다.

데이터베이스 포트를 외부 인터넷에 그대로 노출할 수는 없었습니다.

그래서 Tailscale로 사설 네트워크를 구성했습니다.

배치 컨테이너 옆에 Tailscale 사이드카를 붙이고, 새 서버와 암호화된 P2P 네트워크로 연결했습니다.

서로 다른 클라우드 환경이었지만 네트워크 관점에서는 하나의 내부망처럼 동작하게 만들었습니다.


5단계 — 모든 전환이 끝난 뒤 기존 인프라를 철거했다

모든 기능과 데이터베이스가 새 서버로 넘어간 뒤 마지막으로 기존 AWS 인프라를 철거했습니다.

이 단계에서도 순서를 지켰습니다.

  1. 엣지 라우터의 화이트리스트 제거
  2. 모든 API 요청을 FastAPI 서버로 전환
  3. 기존 Spring 서버 트래픽이 0인지 확인
  4. DNS 오리진을 AWS 로드밸런서에서 새 서버로 직결
  5. 일정 기간 모니터링
  6. 기존 AWS 리소스 삭제

삭제한 항목은 생각보다 많았습니다.

  • Elastic Beanstalk 환경
  • EC2 인스턴스
  • 로드밸런서
  • 여러 퍼블릭 IPv4와 고정 IP
  • RDS 인스턴스
  • 배포 아카이브
  • 사용하지 않는 DNS 존
  • 오래된 스토리지 버킷
  • 불필요한 로그
  • 더 이상 사용하지 않는 도메인 자동 갱신
  • 남아 있던 테스트 리소스

9년동안 운영되던 RDS를 삭제하고 처음보는 빈공간

철거 과정에서 인상적인 문제도 발견했습니다. 

RDS를 먼저 삭제하게 되면서 이미 사용하지 않는 서버가 헬스체크에 계속 실패하고 있었습니다.

오토스케일링은 서버가 죽었다고 판단해 새 인스턴스를 만들었습니다.

새 인스턴스도 같은 이유로 실패했고 다시 새로운 인스턴스를 만들었습니다.

아무 일도 하지 않는 서버가 혼자서 인스턴스를 4대까지 늘리며 가장 열심히 비용을 사용하고 있었습니다.

복잡한 인프라는 사람이 잊고 있는 동안에도 자동으로 돈을 쓸 수 있다는 사실을 다시 확인했습니다.


모든 단계에 원복 레버를 두었다

전체 전환 과정에서 가장 중요하게 지킨 원칙은 하나였습니다.

어떤 단계도 되돌릴 수 없는 상태로 만들지 않는다.

각 단계마다 문제가 생기면 즉시 이전 상태로 돌아갈 수 있는 수단을 준비했습니다.

단계원복 방법예상 시간

Cloudflare 프록시 전환 프록시 비활성화 수초
특정 API 경로 전환 라우팅 설정 복원 약 10초
전체 트래픽 원복 killSwitch 활성화 약 10초
FastAPI 배포 문제 이전 이미지 재배포 수십 초
DB 연결 전환 기존 RDS 주소로 복원 약 1분
DNS 오리진 변경 기존 오리진 재지정 수분
AWS 리소스 삭제 즉시 원복 불가 최종 단계에서만 실행

비가역적인 작업은 항상 마지막에 배치했습니다.

기존 서버가 필요 없다는 사실을 실제 트래픽과 운영 지표로 충분히 확인한 뒤에만 삭제했습니다.

되돌릴 수 있다는 확신이 있었기 때문에 다음 단계로 이동할 수 있었습니다.


물론 장애도 있었다

결과만 보면 무중단에 가까운 성공적인 전환처럼 보이지만 과정이 완벽했던 것은 아닙니다.

전환 기간 중 새벽에 약 5시간 동안 장애가 발생했습니다.

원인은 외부 Redis 서비스의 커넥션 한도였습니다.

FastAPI 서버의 Redis 커넥션 풀에 명확한 상한을 설정하지 않았습니다.

요청이 증가하면서 커넥션 풀이 계속 커졌고, 공유 플랜의 전체 커넥션 한도를 소진했습니다.

Redis 연결 실패가 헬스체크 실패로 이어졌고, 헬스체크 실패가 정상적으로 처리할 수 있는 요청까지 막는 연쇄 문제가 발생했습니다.

이 장애 이후 여러 부분을 수정했습니다.

  • Redis 커넥션 풀 최대 크기 제한
  • 서비스별 커넥션 예산 설정
  • 헬스체크를 soft와 hard로 분리
  • Redis 장애가 전체 서버 장애로 번지지 않도록 격리
  • 재시도 정책과 타임아웃 조정
  • 외부 Redis를 제거하고 서버 내부로 이전

결과적으로 이 장애는 또 하나의 외부 의존성과 비용을 제거하는 계기가 됐습니다.

구조를 단순화한다고 해서 운영 문제가 자동으로 사라지는 것은 아니었습니다.

새로운 구조에 맞는 운영 경험은 다시 쌓아야 했습니다.


서버 비용은 약 8분의 1로 줄었다

기존 AWS 서버 비용은 월 약 25만 원이었습니다.

내역을 살펴보면 실제 애플리케이션 서버 비용보다 주변 인프라 비용이 더 컸습니다.

  • 컴퓨팅 서버 약 48달러
  • RDS 약 42달러
  • 퍼블릭 IPv4와 고정 IP 약 25달러
  • 로드밸런서 약 22달러
  • 모니터링, DNS, 큐 등 약 18달러
  • 데이터 전송과 기타 비용

현재는 월 약 3만 5천 원짜리 Vultr 서버에 FastAPI, MySQL, Redis를 함께 운영하고 있습니다.

백업은 Cloudflare R2에 저장하고, 엣지 라우팅은 Cloudflare Worker가 담당합니다.

일부 배치 작업과 SQS는 비용과 운영 효율을 고려해 기존 환경에 남겨뒀습니다.

서비스와 사용자 수는 그대로지만 비용은 약 8분의 1로 줄었습니다.

오히려 API 응답 속도가 빨라진 부분도 많았습니다.

애플리케이션과 MySQL이 같은 서버에 있어 네트워크 구간이 짧아졌고, 여러 관리형 서비스를 거치던 구조도 단순해졌기 때문입니다.


RDS는 좋은 서비스다. 하지만 나에게 맞는 서비스였을까

RDS는 분명 좋은 서비스입니다.

자동 백업, 모니터링, 장애 대응, 스냅샷 관리와 버전 관리 등 데이터베이스 운영에 필요한 많은 작업을 대신 처리해줍니다.

여러 개발자가 함께 일하거나, 몇 분의 장애도 큰 손실로 이어지는 서비스라면 충분히 합리적인 선택입니다.

하지만 저처럼 직접 서버를 관리할 수 있는 1인 개발자에게도 같은 수준의 인프라가 필요했는지는 다시 생각하게 됐습니다.

9년 동안 서버를 운영하며 실제로 겪은 문제를 돌아보면, 기존 인프라의 상당 부분은 1년에 한 번, 아니 3년에 한 번 일어날까 말까 한 상황에 대비하기 위한 것이었습니다. (스냅샷은 여러번 만들었지만 9년동안 스냅샷을 복원한 기억은 2회 정도 밖에 없었습니다..)

지금까지 한 번도 발생하지 않은 문제도 많았습니다.

저는 실제 트래픽을 처리하기 위한 비용보다 아직 일어나지 않은 장애에 대한 안심을 위해 더 많은 비용을 내고 있었던 셈입니다.

모든 장애를 막기 위해 매달 높은 비용을 지불하는 방법도 있습니다.

반면 문제가 생겼을 때 최대 15분 전 시점으로 데이터를 복구하고, 몇 분 안에 데이터베이스를 복원할 수 있도록 준비하는 방법도 있습니다.

서비스의 특성과 장애 허용 범위를 생각하면 저에게는 후자가 더 합리적이었습니다.


결국 비용이 아니라 구조의 문제였다

처음에는 월 25만 원이 넘는 서버 비용을 줄이는 작업으로 시작했습니다.

하지만 내역을 하나씩 들여다보니 단순히 비싼 서버를 저렴한 서버로 바꾸는 문제가 아니었습니다.

비용은 기존 구조가 어떻게 만들어졌는지를 보여주는 결과였습니다.

  • 서버 한 대 앞에 로드밸런서가 존재
  • 로드밸런서와 오토스케일링이 여러 IP 사용
  • 외부 연동을 위해 별도 아웃바운드 프록시 운영
  • 데이터베이스는 관리형 서비스로 분리
  • Python 기능은 여러 실행 환경에 파편화
  • 배포와 원복에는 긴 시간이 필요
  • 사용하지 않는 리소스도 자동으로 유지되거나 증가
  • 저렴한 서비스와 비싼 서비스가 구분 없이 함께 유지

각각의 선택은 당시에는 모두 합리적이었습니다.

하지만 9년 동안 선택이 누적되면서 전체 구조는 현재 서비스 규모보다 훨씬 무거워졌습니다.

이번 작업에서 중요한 것은 무조건 모든 것을 한 서버로 합친 것이 아니었습니다.

각 구성 요소를 다시 평가한 것이었습니다.

  • 비용이 낮고 유용한 SQS는 유지
  • 짧게 실행되는 작업은 종량제 환경에 유지
  • 비용을 크게 만드는 RDS, 로드밸런서, IP 구조는 제거
  • 외부로 파편화된 Python 기능은 통합
  • 데이터베이스는 복제를 이용해 안전하게 이전
  • 배포와 원복이 느린 애플리케이션 스택은 교체

결국 비용이 8분의 1로 줄어든 것은 단순히 저렴한 서버를 선택했기 때문만은 아닙니다.

무엇이 정말 필요한지를 9년 만에 처음부터 다시 판단했기 때문입니다.


AI가 했던 일과 사람이 해야 했던 일

이번 마이그레이션은 AI가 없었다면 시작하기 어려운 작업이었습니다.

21만 줄이 넘는 코드와 1,100개 이상의 API를 분석하고, 문서화하고, FastAPI로 옮기고, 테스트 코드를 만들고, 인프라 전환 계획까지 세우는 작업을 혼자 처리하는 것은 현실적으로 부담이 컸습니다.

AI는 제가 처리할 수 있는 작업의 범위를 크게 확장해줬습니다.

하지만 AI가 모든 것을 알아서 끝낸 것은 아닙니다.

사람이 계속 판단해야 했습니다.

  • 문서를 어떤 범위로 나눌지
  • 어떤 순서로 마이그레이션할지
  • 어떤 기능을 병렬 처리해도 되는지
  • 어떤 테스트는 뒤로 미뤄도 되는지
  • 어떤 불일치가 실제 버그인지
  • 어떤 API가 위험한 쓰기 작업인지
  • 어느 시점에 실제 트래픽을 전환할지
  • 언제 기존 서버를 삭제해도 되는지

처음 Goal 프롬프트처럼 모든 단계를 하나의 거대한 작업으로 묶으면 완성도는 높았지만 너무 느렸습니다.

반대로 병렬 처리만 강조하면 속도는 빨랐지만 누락이 생겼습니다.

결국 가장 효과적이었던 방식은 작업을 단계별로 분리하는 것이었습니다.

  1. 전수조사와 문서화
  2. 진행 상태 관리 체계 구성
  3. 기능 단위 병렬 포팅
  4. 별도 검증 세션
  5. 테스트 코드와 기존 코드 비교
  6. 섀도우 트래픽 검증
  7. 점진적 트래픽 전환
  8. 데이터베이스 복제와 이전
  9. 백업 복원 리허설
  10. 기존 인프라 철거

AI에게 모든 일을 한 번에 맡기는 것보다 역할을 나누고 각 단계의 목표를 명확히 해야 했습니다.


이번 작업에서 배운 것

대규모 AI 작업은 문서화부터 시작해야 한다

코드가 많을수록 바로 수정하는 것보다 먼저 지도를 만드는 편이 중요합니다.

기능, 구조, 의존성, 진행 상태를 문서화하면 여러 에이전트와 세션이 같은 기준으로 작업할 수 있습니다.

하나의 거대한 Goal이 항상 효율적인 것은 아니다

분석, 구현, 테스트, 검증을 모두 직렬로 묶으면 완성도는 높지만 지나치게 오래 걸릴 수 있습니다.

작업량이 크다면 구현과 검증을 분리하고, 서로 충돌하지 않는 기능은 병렬로 처리하는 편이 효과적입니다.

빠른 포팅과 정밀 검증은 서로 다른 단계로 운영할 수 있다

처음부터 모든 기능을 완벽하게 만들려고 하면 전체 진행이 멈춥니다.

먼저 기능을 빠르게 옮기고, 이후 별도의 세션에서 기존 코드 비교와 테스트 루프를 돌리는 방식이 더 현실적이었습니다.

트래픽을 분기할 수 있어야 점진 전환이 가능하다

엣지 라우터라는 관제탑이 생기면서 빅뱅 전환이 점진 전환으로 바뀌었습니다.

경로별로 넘기고 문제가 생기면 10초 안에 되돌릴 수 있었습니다.

테스트보다 실트래픽이 더 많은 것을 알려준다

테스트 코드는 개발자가 예상한 입력을 검증합니다.

섀도우 트래픽은 예상하지 못한 사용자 입력과 운영 환경의 차이를 찾아냈습니다.

읽기부터, 쓰기는 나중, 데이터베이스는 마지막

위험이 낮은 부분부터 전환하면 장애의 폭발 반경을 줄일 수 있습니다.

애플리케이션과 데이터베이스를 동시에 옮기지 않은 것도 문제의 원인을 분리하는 데 도움이 됐습니다.

모든 단계에 원복 수단이 있어야 한다

10초 안에 돌아갈 수 있다는 확신이 있었기 때문에 실제 트래픽을 조금씩 넘길 수 있었습니다.

돌아갈 수 없는 삭제는 모든 검증이 끝난 마지막 순간에만 실행했습니다.

백업은 실제로 복원해야 검증된다

업로드에 성공한 백업 파일이 정상적으로 복원된다는 보장은 없습니다.

복원 리허설을 끝낸 뒤에야 RDS를 삭제할 수 있었습니다.

단순화란 무조건 하나로 합치는 것이 아니다

저렴하고 운영 부담이 낮은 외부 서비스는 유지했습니다.

중요한 것은 구성 요소의 개수가 아니라, 각 요소가 실제로 제공하는 가치와 비용이었습니다.


마치며

처음에는 Spring Boot 코드를 FastAPI로 옮기는 작업이 가장 어려울 것이라고 생각했습니다.

실제로도 쉽지는 않았습니다.

기존 서버를 문서화하는 데만 여러 시간이 들었고, Goal 프롬프트는 며칠 동안 끝나지 않았으며, Max 20x 계정을 계속 추가해야 했습니다.

병렬 처리로 전략을 바꾼 뒤에야 속도가 붙었고, 빠르게 작업하며 놓친 부분은 별도의 검증 세션과 테스트 루프로 채웠습니다.

하지만 기능 포팅이 끝난 뒤 더 큰 작업이 기다리고 있었습니다.

수백 개의 앱과 매일 들어오는 수십만 건의 요청을 그대로 유지한 채 서버와 데이터베이스, DNS와 인프라를 모두 교체해야 했습니다.

결국 한 번에 바꾸지 않았습니다.

문서와 기능을 잘게 나눴고, API 경로를 잘게 나눴습니다.

읽기와 쓰기를 나눴고, 애플리케이션과 데이터베이스 이전도 나눴습니다.

각 단계에는 항상 되돌릴 수 있는 수단을 붙였습니다.

돌이켜보면 이번 작업은 AWS를 버리고 저렴한 서버로 옮긴 이야기가 아닙니다.

9년 동안 하나씩 추가된 선택을 다시 펼쳐놓고, 지금도 필요한 것과 더 이상 필요하지 않은 것을 구분하는 작업이었습니다.

큐처럼 저렴하고 유용한 것은 남겼습니다.

반면 실제 서버보다 더 많은 비용을 만들던 로드밸런서, 퍼블릭 IP, 관리형 데이터베이스 구조는 걷어냈습니다.

단순히 인프라만 옮겼다면 그대로 남았을 느린 배포와 분산된 개발 스택까지 함께 정리했습니다.

기차는 지금도 계속 달리고 있습니다.

서버는 Spring Boot에서 FastAPI로 바뀌었고, 무거운 관리형 인프라는 작은 서버 중심의 구조로 바뀌었습니다.

데이터베이스도 RDS에서 직접 운영하는 MySQL로 옮겨졌습니다.

하지만 수백 개의 앱을 사용하는 사람들은 그 사실을 알지 못합니다.

앱을 업데이트할 필요도 없었고, 새로운 서버 주소를 입력할 필요도 없었습니다.

기존과 같은 앱을 그대로 사용했을 뿐입니다.

아마 그것이 이번 작업이 성공했다는 가장 확실한 증거일 겁니다.

 

반응형