sxngt

「FastAPI로 그게 됩니까?」 3부 — 정직한 청구서, 그리고 통제라는 자산

· Retrospect · 10 min

PyCon 발표 준비 3부작의 마지막 글이다. 1부 — 금요일 밤의 아키텍처, 2부 — 번역의 시간에서 이어진다.

발표자료를 만들며 스스로에게 건 규칙이 하나 있었다. 자랑 한 장마다 청구서 한 장. 기술 발표가 광고가 되는 순간을 나는 청중석에서 여러 번 봤고, 그때마다 신뢰가 어떻게 증발하는지도 봤다. 회고의 가치는 성과가 아니라 정산에 있다. 그래서 마지막 글은 청구서부터 연다.

그 전에, 좋았던 것부터 빠르게 정리하자. 정산은 수입과 지출을 다 적어야 정산이다.

운영이라는 수입

아키텍처의 성패는 새벽 배포에서 판정된다고 1부에 썼다. 그 기준으로 지금을 적는다.

새 배포 파이프라인 — 품질 게이트를 통과해야만 배포된다

CI 파이프라인은 다섯 벌에서 한 파일이 되었다. 모든 push는 품질 게이트 — ruff checkruff format --check, pytest 836개, 그리고 OpenAPI 드리프트 검사 — 를 통과해야 배포 잡으로 넘어간다. 배포는 git reset --harduv sync --all-packages --frozen이 전부다. 컨테이너 이미지 빌드가 0회라서, 레거시에서 6분 42초 걸리던 파이프라인의 배포 구간이 초 단위로 끝난다. .env는 SSM Parameter Store에서 명령 한 번으로 통째로 생성된다 — 스물세 번의 순차 호출로 한 줄씩 이어 붙이던 시절은 갔다.

마이그레이션 이력도 처음으로 생겼다. 레거시의 TypeORM은 synchronize: true — 마이그레이션 파일이 0개였다. 지금은 Alembic 단일 체인에 리비전 72개, head는 정확히 하나이고, 배포마다 ORM 메타데이터와 실제 DB를 대조하는 스키마 드리프트 검사가 돈다. 이 검사기의 docstring에는 그것이 막아 주는 사고가 명시되어 있다. "마이그레이션 없이 모델만 바뀐 경우(UndefinedColumnError로 런타임 503)를 배포 전에 잡는다."

그리고 한 가지 오해를 풀어 둘 것이 있다. 모놀리스라고 해서 프로세스가 하나인 것은 아니다. 코드베이스는 하나지만 런타임은 gunicorn 5개 그룹 × uvicorn worker 2개 — 보호자용, 관리자용, 챗봇용, 학교용, 아동정보용 앱이 각자의 포트에서 따로 뜬다. 장애 격리와 개별 재시작은 앱 단위로 살아 있다. 이 전체의 운영 표면이 bash 스크립트 350줄이다. PM2도, systemd도, Kubernetes도 없다. 1인 조직에는 이것이 정확한 규모의 도구다.

삽질 로그 — 기록되는 순간 자산이 되는 것들

일 년치 커밋과 문서를 뒤지며 발표에 넣을 삽질을 네 개 골랐다. 여기서는 파이썬 개발자에게 가장 실용적인 하나와, 가장 사적인 하나를 옮긴다.

삽질 로그 — 린트 규칙 하나 켰다가 noqa 1,200개

noqa 1,200개 사건. ruff의 TCH(flake8-type-checking) 규칙은 타입으로만 쓰이는 import를 if TYPE_CHECKING: 블록으로 옮기라고 권한다. 일반적인 파이썬 프로젝트에서는 훌륭한 규칙이다. 그런데 우리 코드베이스에서 이 규칙을 켜자 noqa 주석이 순식간에 1,200개 생겼다. 원인은 명확했다 — Pydantic과 FastAPI는 타입 어노테이션을 런타임에 읽는다. DTO import를 TYPE_CHECKING으로 보내면 OpenAPI 스키마 생성이 forward reference 에러로 깨진다. 일반론의 상식이 이 스택에서는 정확히 역효과였던 것이다. 규칙은 껐고, 끈 이유는 pyproject.toml 주석으로 남겼다. FastAPI 코드베이스의 린트 설정은 다르다 — 이 한 줄이 오늘 발표에서 가장 실용적인 지식일지도 모른다.

649행 전부 NULL인 컬럼. 스키마를 정리하다가 participation_status라는 컬럼을 발견했다. 마이그레이션으로 추가되고 enum까지 정의되어 있는데, 한 번도 값이 쓰인 적이 없었다. 649행 전부 NULL. 나는 진단 문서에 이렇게 적었다. "이전 누군가 같은 고민을 하다 멈춘 흔적이며, 지금 정식으로 결론 내야 함." 적고 나서야 깨달았다. 그 '누군가'는 4개월 전의 나였다. 1인 개발의 코드 리뷰어는 미래의 자신이다 — 그 리뷰어가 읽을 수 있도록 결론을 문서로 남기는 것까지가 일이라는 것을, 나는 이 컬럼에서 배웠다.

청구서 — 경계는 새고 있다

이제 제일 아픈 항목이다.

2부에서 나는 "패키지 매니저가 지키는 경계는 조금 더 오래 간다"고 썼다. 오래 간다고 했지, 영원하다고 하지 않았다. 발표 준비 중 저장소 전체를 grep하다가 나는 우리 README의 문장 — "도메인 패키지는 core에만 의존한다. 도메인 패키지끼리 import하지 않는다" — 이 더 이상 사실이 아님을 확인했다.

README의 주장과 grep의 현실 — 경계 누수의 지도

child, counseling, voucher, care_center 네 도메인이 서로를 참조하는 순환이 있었다. 누수의 메커니즘이 교묘하다. 리포지토리 메서드 안에서, 함수 지역(function-local) import로 이웃 도메인의 ORM 모델을 가져와 크로스 도메인 SQL 조인을 만드는 패턴이다. 모듈 레벨 순환이 아니므로 파이썬 인터프리터는 아무 말도 하지 않고, 의존성 선언 검사도 — uv sync --all-packages가 모든 패키지를 한 가상환경에 설치하는 탓에 — 잡아내지 못한다.

뼈아픈 것은 이 누수가 모놀리스의 최대 장점과 정확히 같은 자리에서 발생한다는 점이다. MSA에서 네트워크 3홉이던 조회가 모놀리스에서 SQL 조인 한 방이 된 것 — 그것이 payoff였는데, 그 조인을 만드는 코드가 바로 경계를 녹이는 코드다. payoff와 cost는 같은 줄에 있다.

청구서의 나머지 항목도 마저 적는다. DI 컨테이너를 거부한 대가인 provider 함수 452개, 약 3,900줄의 수제 배선. mypy --strict 설정은 있으나 CI 게이트에는 아직 없는 것. JWT 시크릿 하나를 다섯 앱이 공유해서, 타 앱의 토큰이 서명 검증을 통과하고 방어는 claim 검사뿐인 것 — 이 취약점은 해당 코드의 docstring에 자백으로 적어 두었다.

이 항목들을 발표에서 빼자는 유혹이 없었다면 거짓말이다. 남긴 이유는 하나다. 관리되는 부채와 방치된 부채의 차이는 어디서 새는지 아느냐뿐이다. 우리는 누수의 위치를 알고, 완화 장치(공개 join helper 표면)를 두었고, import-linter 도입을 백로그 1순위에 올렸다. 모른 채 자신만만했던 MSA 시절보다, 알고 겸허한 지금이 높은 자리라고 나는 믿는다.

감정 곡선

회고인 만큼, 숫자 못지않게 이것도 데이터다.

9개월의 감정 곡선

설렘으로 시작한 MSA가 저장소 여섯 개로 불어나던 내리막, 금요일 밤들의 바닥, 3월 18일의 결심, 13일 스프린트의 몰입 — 그리고 사람들이 잘 말하지 않는 구간이 그다음에 온다. 전환 직후의 camelCase 일괄 전환 스윕, 3주에 걸친 지루한 계약 정리 작업의 현타. 마이그레이션 서사는 보통 컷오버에서 끝나지만, 실제 감정의 저점 하나는 그 뒤에 있었다.

곡선의 마지막 점이 최고점이 아닌 것은 의도된 정직함이다. 경계 누수를 발견한 7월의 겸허를 지나, 지금의 나는 "어디서 새는지 알고 있다"는 상태에 있다. 그 상태가, 모르고 자신만만했던 어떤 시절보다도 높은 곳이라고 생각한다.

번역이 가능해진 이유 — 세 개의 기둥

여기까지가 정산이다. 이제 처음의 질문으로 돌아갈 차례다. 「FastAPI로 그게 됩니까?」

솔직히 말하면, 3년 전이었다면 이 마이그레이션은 훨씬 어려웠을 것이다. "됩니다"라는 대답은 내 실력의 증명이라기보다 생태계가 성숙한 시점을 운 좋게 만난 기록에 가깝다.

번역을 가능하게 한 세 개의 기둥 — uv, Python 3.12, FastAPI+Pydantic v2

uv. workspace가 모듈러 모놀리스의 물리적 토대를 제공했다. 패키지 25개가 락파일 하나를 공유하고, uv sync --frozen이 초 단위로 환경을 재현하며, 파이썬 버전 관리까지 한 도구로 끝난다. 모노레포에서 패키지 경계를 선언으로 긋는다는 발상은 uv workspace 없이는 공중누각이었다.

Python 3.12. 타입 시스템이 DDD의 어휘를 표준 라이브러리만으로 받아낼 만큼 성숙했다. typing.Protocol이 구조적 인터페이스를, StrEnum이 도메인 열거형을, Annotated가 선언적 의존성 표현을 준다. 십 년 전 "동적 타입 언어로 엔터프라이즈를?"이라던 회의론이 겨냥하던 파이썬은 이제 없다.

FastAPI와 Pydantic v2. Rust로 다시 쓰인 pydantic-core가 검증 성능의 논쟁을 종결시켰고, Depends 그래프와 요청 단위 캐시가 IoC 컨테이너 없는 DI를 실용의 영역으로 가져왔으며, 타입 힌트가 곧 OpenAPI 계약이 되는 구조가 프론트엔드 다섯 개와의 협업을 기계 검증으로 바꿔 놓았다. 그리고 async 네이티브라는 본성 덕분에, LLM 스트리밍 파이프라인까지 같은 프레임워크 위에서 돈다.

이 세 기둥 위에서, 정설이 지키던 패턴들의 번역 완료 여부를 최종 점검했다.

정설이 지키던 패턴들 — 번역 완료 확인서

Layered Architecture, DDD의 전술 패턴(Aggregate Root 53개, 도메인 이벤트 92개), CQRS(핸들러 497개), Dependency Injection(provider 452개, 컨테이너 없음), Unit of Work(요청 = 트랜잭션, 세션 제너레이터 40줄), API Contract(OpenAPI 518 paths + CI 드리프트 게이트), 모듈 경계(uv workspace 패키지 25개). 일곱 항목 전부, 이론이 아니라 오늘 아침에도 프로덕션에서 돌아가고 있던 코드다.

1부에서 세운 두 통념 — FastAPI는 빠른 API 도구일 뿐이고, 대규모 계층 시스템은 Spring과 NestJS의 영역이라는 — 에 대한 나의 대답은 그래서 이렇다. 2026년에는, 더 이상 유효하지 않다.

통제라는 자산

마지막으로, CTO의 자리에서 이 일 년을 결산하고 싶다. 기술적 성취의 목록이 아니라, 조직의 대차대조표에 무엇이 올라갔는가로.

숫자로 보는 전환 — 그대로인 것은 팀 규모뿐

저장소 6 → 1. 배포 유닛 9 → 5. 데이터베이스 4 → 1. 환경변수 121 → 28. 인증 구현 4벌 → 1벌. 서비스 간 배관 3,185줄 → 0줄. CI에서 도는 테스트 0 → 836. 마이그레이션 이력 없음 → 리비전 72개의 단일 체인. 그리고 이 모든 숫자의 아래에, 변하지 않은 단 하나 — 팀 규모, 1명.

하지만 진짜 결산은 숫자 바깥에 있다.

레거시 시절, 이 시스템에 관한 지식의 최종 저장소는 내 머리였다. 여섯 저장소의 컨텍스트, 포트 지도, 기동 순서, 시크릿 세 종류, "그 코드가 어느 레포에 있더라" — 전부 나의 상주 메모리에 로드되어 있어야 했다. 조직의 관점에서 이것은 자산이 아니다. 한 사람의 퇴근과 함께 휘발되는 부채다.

지금은 다르다. 규칙은 훅과 CI가 기억한다 — 도메인 레이어에 프레임워크 import가 들어오면 훅이 경고하고, 스펙이 어긋나면 빌드가 죽는다. 계약은 OpenAPI가 기억한다. 스키마의 역사는 Alembic이 기억한다. 아키텍처의 결정과 그 이유는 — enum의 흉터까지 포함해서 — 저장소 안의 문서가 기억한다. 내가 기억해야 할 것은 makedeploy.sh뿐이다.

기억을 사람에서 시스템으로 옮기는 일. 일 년의 리팩토링이 실제로 한 일은 그것이었다. 그리고 그 이관이 끝났을 때 내 손에 남은 것을, 나는 이 단어로 부르기로 했다. 통제(control).

통제란 전능함이 아니다. 시스템의 어느 부분이든 하루 안에 파악할 수 있고, 어떤 변경이든 그 파급 범위를 기계가 알려 주며, 새는 곳이 어디인지 알고 있고, 언젠가 팀이 커지면 패키지 경계선을 따라 소유권을 나눠 줄 수 있는 상태 — 그것이 통제다. 성능 좋은 시스템은 많다. 그러나 한 조직이 완벽히 통제하고, 장기적으로 발전시킬 수 있는 시스템만이 기술자산(technical asset)이라는 이름값을 한다. 나는 CTO로서 지난 일 년 동안 기능을 만든 것이 아니라, 이 자산을 만들었다.

그러니 발표의 마지막 슬라이드는 처음부터 정해져 있었던 셈이다.

이제, 겁먹지 않으셔도 됩니다

FastAPI의 Fast는 서빙 속도로 시작했지만, 우리에게는 조직이 움직이는 속도가 되었다. 엔드포인트 32개가 65개가 되는 데 하루가 걸리던 날, 그 단어의 의미가 바뀌었다.

혹시 지금 Spring이나 NestJS의 세계에서, 혹은 여섯 개의 저장소 사이 어딘가에서, "파이썬으로 이게 될까"를 재고 있는 분이 있다면 — 일 년 치의 실측을 통과한 대답을 전한다.

됩니다. 그리고 이제, 겁먹지 않으셔도 됩니다.

파이콘에서 만나요.


이 3부작의 모든 수치는 실제 프로덕션 코드베이스와 git log, 인프라 문서에서 실측한 값이다. 발표 슬라이드 전체(PDF, 71장)를 공개해 두었다.