sxngt

「FastAPI로 그게 됩니까?」 2부 — 번역의 시간, Spring의 어휘를 파이썬으로

· Retrospect · 10 min

PyCon 발표 준비 3부작의 두 번째 글이다. 1부 — 금요일 밤의 아키텍처에서 이어지며, 발표 슬라이드 전체(PDF)를 함께 볼 수 있다.

마이그레이션을 결심하고 나서 가장 먼저 한 일은 코드를 짜는 것이 아니었다. 사전을 만드는 일이었다.

NestJS는 Spring의 사상을 TypeScript로 옮긴 프레임워크다. IoC 컨테이너(Inversion of Control container), 데코레이터 기반 의존성 주입(Dependency Injection), 모듈 단위 구성, 계층형 아키텍처(Layered Architecture). 이 어휘들은 수십 년에 걸쳐 검증된 엔터프라이즈 설계의 표준어다. 그러니 마이그레이션이란 결국 번역이었다. 표준어의 문장들을 파이썬이라는 언어의 관용구로, 뜻은 보존하되 어색한 직역은 피해서 옮기는 일.

번역을 하려면 먼저 두 언어를 모두 알아야 한다. 나는 운 좋게도 — 혹은 불운하게도 — 둘 다에서 살아 봤다.

번역 사전

발표자료의 핵심 슬라이드가 된 대응표를 먼저 보이고, 굵직한 항목을 하나씩 풀어 보겠다.

NestJS ↔ FastAPI 개념 번역 지도

@Module → 파이썬의 import, 그리고 pyproject.toml

NestJS에서 코드의 단위는 모듈이다. 모든 provider — 서비스, 리포지토리, 유스케이스 — 는 @Module({ imports, providers, exports }) 선언문에 손으로 등록해야 하고, 다른 모듈이 쓰려면 다시 exports 배열에 올려야 한다. 우리 레거시의 상담의뢰 모듈은 이 선언문만 95줄이었고, 그중 UseCase 클래스 17개가 providers 배열에 일일이 나열되어 있었다. 더 아픈 것은 provider가 모듈 스코프라는 점이다. 같은 리포지토리를 여러 모듈이 쓰면 등록도 여러 번 해야 해서, 'CounselRequestRepository'라는 문자열 토큰이 저장소 전체에서 14회 재등록되어 있었다.

파이썬으로 번역하면 이 선언문은 통째로 사라진다. 파이썬의 import 그래프가 곧 의존성 그래프이기 때문이다. 모듈 경계는 코드가 아니라 패키지 메타데이터 — pyproject.tomldependencies — 로 옮겨 간다. 상담(counseling) 도메인 패키지의 선언 전문은 13줄이다.

[project]
name = "yeirin-counseling"
version = "2.0.0"
description = "상담기관 연계, B-IMPACT 인증 도메인"
requires-python = ">=3.12"
dependencies = ["yeirin-core", "openai"]   # FastAPI가 없다 — 뒤에서 다시 말한다

[tool.uv.sources]
yeirin-core = { workspace = true }

발표에서 이 대목의 대사는 이렇게 정했다. "NestJS의 모듈 시스템은 파이썬에 이미 있습니다. import라고 부릅니다."

DI 컨테이너 → Depends 함수 체인

NestJS의 DI는 런타임 레지스트리다. @Injectable()을 붙이고, 모듈에 등록하고, 생성자 매개변수 타입으로 주입받는다. reflect-metadata가 타입 정보를 읽어 컨테이너가 인스턴스를 조립한다. 우아한 구조지만 비용이 있다. 우리 레거시의 컨트롤러 하나는 생성자에서 UseCase 17개를 주입받았는데, NestJS는 컨트롤러 단위로 의존성을 조립하므로 DELETE 요청 하나를 처리할 때도 17개가 전부 인스턴스화되었다. 그리고 인터페이스 기반 주입에는 @Inject('문자열토큰')이 필요했다 — 오타는 컴파일러가 아니라 런타임 크래시가 알려 준다.

FastAPI의 번역은 급진적으로 단순하다. 의존성 주입은 함수 인자다. Depends()는 함수의 반환값을 매개변수에 꽂아 주고, 의존성이 의존성을 갖는 그래프는 함수가 함수를 Depends로 받는 체인으로 표현된다. 엔드포인트는 자기가 쓸 핸들러 하나만 받는다. FastAPI는 같은 요청 안에서 동일 의존성을 캐시하므로, Spring의 request-scoped bean에 해당하는 것을 공짜로 얻는다.

DBSession = Annotated[AsyncSession, Depends(get_db_session)]

def get_counsel_request_repo(session: DBSession):
    from yeirin_counseling.infrastructure.counsel_request.repositories import (
        SqlAlchemyCounselRequestRepository,
    )
    return SqlAlchemyCounselRequestRepository(session)

def get_create_counsel_request_handler(repo=Depends(get_counsel_request_repo)):
    from yeirin_counseling.application.counsel_request.commands import CreateCounselRequestHandler
    return CreateCounselRequestHandler(repo)

컨테이너 라이브러리는 쓰지 않았다. dependency-injector도, punq도. 등록이라는 세리머니야말로 우리가 떠나온 것이었기 때문이다. 대신 비용을 치른다 — 이런 provider 함수를 452개, 약 3,900줄 손으로 쓴다. 이 청구서 이야기는 3부에서 정직하게 하겠다. 여기서는 얻은 것만 적는다. 마법이 0이다. 전부 grep으로 찾을 수 있는 평범한 함수라서, 신입이 와도 배선 전체를 한나절이면 읽는다.

class-validator → Pydantic

레거시에서 세 번째로 큰 파일은 놀랍게도 DTO였다. create-counsel-request.dto.ts, 780줄. TypeScript의 타입은 컴파일하면 지워지기 때문에, 런타임 검증을 위해 필드마다 데코레이터를 겹겹이 발라야 한다.

NestJS class-validator DTO — 필드 하나에 데코레이터 4개

@ApiProperty는 문서를 위해, @IsInt@Min/@Max는 검증을 위해, @ValidateNested@Type(() => X)는 — 타입 선언에 이미 적혀 있는 타입을 — 런타임에 한 번 더 알려 주기 위해 존재한다. 문서와 검증과 타입이 세 개의 독립된 시스템이라서, 서로 어긋나도 아무도 알려 주지 않는다.

파이썬의 번역은 이 세 시스템을 하나로 합친다. 파이썬의 타입 힌트는 런타임에 남고, Pydantic이 그것을 읽는다.

Pydantic — 타입 힌트 하나가 세 몫을 한다

str | None 하나가 @IsString() @IsOptional()을 대체하고, 같은 어노테이션이 그대로 OpenAPI 스키마가 되고, mypy가 정적으로 검사하는 대상도 된다. 같은 요청 페이로드가 780줄에서 34줄이 되었다. 96%의 감소지만, 줄 수보다 중요한 것은 어긋날 수 있는 시스템의 개수가 3에서 1로 줄었다는 사실이다.

TypeORM → SQLAlchemy 2.0, 그리고 enum의 흉터

ORM 번역에서 두 가지 결정을 했다.

첫째, 관계(relationship)를 쓰지 않는다. TypeORM의 @ManyToOnereq.child 같은 자동 탐색을 공짜로 주지만, N+1 쿼리와 암묵적 로딩이라는 지뢰도 함께 준다. 새 코드는 SQLAlchemy 2.0의 Mapped[] / mapped_column() 선언만 쓰고, 도메인 경계를 넘는 조인은 리포지토리에서 명시적 select().outerjoin()으로 쓴다. 외래 키 제약도 패키지 경계를 넘는 곳에는 걸지 않고, 컬럼 주석에 "논리 FK"로만 문서화한다 — 모듈러 모놀리스의 경계를 DB 레벨에서도 존중하기 위해서다.

둘째, enum은 코드에만 존재한다. 레거시의 TypeORM 엔티티는 type: 'enum'으로 PostgreSQL 네이티브 enum을 만들었다. 그 결과 상태값 하나를 추가할 때마다 ALTER TYPE 마이그레이션이 필요했고, 타입 이름이 꼬여서 급기야 fix-enum-names.ts라는 수리 스크립트까지 등장했다. 그 파일은 지금도 레거시 저장소에 흉터처럼 남아 있다. 새 플랫폼의 절대규칙은 그 흉터에서 나왔다. 도메인에는 StrEnum, DB에는 String 컬럼, 유효성은 Mapper에서 StrEnum(value) 변환으로 보장. 현재 이 규칙 아래 StrEnum이 156개 있고, PostgreSQL 네이티브 enum은 0개다.

규칙에는 흉터가 있어야 지켜진다는 것을, 나는 이 enum에서 배웠다.

Guard → 시그니처에 보이는 인증

NestJS의 인증은 전역 Guard로 기본 적용(default-on)하고 @Public() 데코레이터로 빠져나가는 방식이었다. 이 구조의 말로가 어땠는가 하면 — 나중에 관리자용 인증 스택을 별도로 붙이면서, 전역 가드 두 곳에 if (request.path.startsWith('/admin')) return true;라는 경로 문자열 우회가 박혔다. 인증 코드는 세 벌, 합쳐서 337줄이 되었다.

FastAPI의 번역은 방향 자체를 뒤집는다. 기본은 비인증이고, 인증이 필요한 엔드포인트가 시그니처에 opt-in한다.

CurrentUser = Annotated[TokenPayload, Depends(get_current_user)]

async def list_counsel_requests(user: CurrentUser, ...) -> ...:

인증 요구가 함수 서명에 보인다. 전체 구현은 30줄이다. 역할 가드는 Depends(get_current_center_staff)Depends(require_center_write)로 바꿔 끼우는 것으로 끝난다 — 함수 합성이 곧 권한 계층이다.

경계의 뼈대 — uv workspace

번역된 문장들을 담을 책장이 필요했다. 그 책장이 uv workspace다.

새 플랫폼 전체 지도 — 패키지 25개, 단일 스키마

루트 pyproject.toml[tool.uv.workspace] members = ["packages/*"] 한 줄 아래, 패키지 25개가 산다. 도메인 패키지 19개(child, counseling, voucher, care_center, assessment, myfriend_engine …)와 앱 패키지 6개(app_guardian, app_admin, app_plobi_school …), 그리고 core. 락파일은 하나다 — 레거시의 yarn.lock 하나와 uv.lock 세 개가 uv.lock 하나(174개 의존성)로 합쳐졌다.

구조의 규칙은 세 문장으로 요약된다.

  1. 도메인 패키지가 비즈니스 로직을 소유한다. Entity와 Value Object, Repository Protocol, 그리고 모든 Command/Query와 Handler. 도메인은 "누가 호출하는가"를 모른다.
  2. 앱 패키지는 조립만 한다. Router, 인증, 응답 스키마, Depends 배선. 비즈니스 로직 작성은 금지다.
  3. core는 작게 유지한다. Base 클래스, DB 세션, 설정, JWT, 예외 핸들러 — 전체 코드의 1.1%(1,179줄)뿐이다. 공유 커널이 비대해지는 순간 모듈러 모놀리스는 그냥 진흙 덩어리가 되기 때문이다.
선언이 곧 경계 — 앱과 도메인의 의존 그래프

이 그림에서 내가 가장 아끼는 디테일은 이것이다. 도메인 패키지의 pyproject.toml에는 FastAPI가 없다. 선언하지 않은 것은 import할 수 없으므로, 도메인 레이어가 웹 프레임워크에 오염되는 일이 의존성 선언 수준에서 차단된다. 아키텍처 문서로 지키는 경계는 언젠가 무너진다. 패키지 매니저가 지키는 경계는 조금 더 오래 간다. (완전하지는 않다는 것을 3부에서 고백하겠다.)

도메인 내부는 domain / application / infrastructure의 3계층이고, 인터페이스는 추상 클래스(ABC)가 아니라 typing.Protocol — 구조적 타이핑(structural typing) — 로 선언했다. 도메인이 구현체의 존재 자체를 모르게 하려는 선택이다. 애플리케이션 계층은 CQRS를 가볍게 적용해서 Command/Query와 Handler로 구성했다. 전사에 Handler가 497개 있다.

13일의 리듬 — Strangler Fig

전략은 처음부터 정해져 있었다. 서비스는 멈출 수 없고, 나는 혼자다. 이 두 제약이 빅뱅 재작성을 배제하고 스트랭글러 패턴(Strangler Fig pattern) — 낡은 시스템을 감싸며 자라다 마침내 대체하는 무화과나무의 방식 — 을 골라 주었다.

다만 스트랭글러의 단위를 나는 코드가 아니라 도메인으로 잡았다. 그 결정이 남긴 흔적이 git log에 그대로 있다.

git log가 증거다 — 도메인마다 같은 3연타 커밋

3월 18일에 workspace를 초기화하고, 이튿날부터 하루에 도메인 하나 꼴로 같은 리듬이 반복된다. 도메인 모델 → Command/Query 핸들러 → SQLAlchemy 모델·Mapper·Repository. 13일 동안 도메인만 쌓았고, 14일째에야 첫 앱을 조립했다. 앱 레이어가 순수한 조립이라는 명제는 설계 문서의 주장이 아니라 커밋 순서가 증언하는 사실이다.

레거시와의 호환은 파생 필드로 풀었다. 옛 API는 flat한 camelCase DTO를 반환했는데, 새 모델은 정규화된 위성 테이블 구조였다. 그래서 새 도메인이 옛 계약의 필드들을 파생(derive) 해서 내려 주도록 했다 — 프론트엔드는 백엔드가 통째로 바뀐 것을 끝내 알아차리지 못했다. 완성하지 못한 기능 여덟 개는 숨기는 대신 스텁으로 정직하게 뚫어 놓고 껍데기부터 출하했다.

킬러 슬라이드 — HTTP에서 import로

발표의 클라이맥스로 정해 둔 장면이 있다. 1부에서 이야기한 593줄짜리 HTTP 클라이언트 — 아동의 심리검사 결과를 이웃 서비스에서 가져오던 그 코드 — 가 모놀리스에서 어떻게 되었는가.

같은 기능의 전부 — 라우터 14줄과 import 한 줄

이게 전부다. 네트워크 홉, 공유 시크릿, 타임아웃 설정, axios 에러 분류 사다리, 그리고 "404는 결과 없음으로 해석한다"는 문서화되지 않은 관례까지 — 전부 사라졌다. 593+45줄이 14줄이 되었다.

줄 수보다 본질적인 변화는 타입에 있다. 예전에는 응답 스키마가 와이어(wire)를 사이에 두고 두 벌 존재했고 — 파이썬 쪽 Pydantic 원본과 TS 쪽 손 복사본 — 드리프트를 막는 것은 주석뿐이었다. 지금 AssessmentResultDto는 호출하는 쪽과 정의하는 쪽에서 같은 파이썬 객체다. 어긋남(drift)이라는 개념 자체가 성립하지 않는다.

물론 모놀리스 안에서도 계약은 필요하다. 프론트엔드 다섯 개가 이 백엔드를 소비하기 때문이다. 그 계약은 OpenAPI로 옮겨 갔다. 모든 엔드포인트는 반환 타입 어노테이션이 강제되고, 추출된 스펙(518 paths)은 git에 커밋되며, CI가 매번 스펙을 재추출해 diff가 생기면 빌드를 실패시킨다 — 스키마 드리프트 게이트다. 프론트엔드는 그 스펙에서 TypeScript 타입과 Zod 스키마, TanStack Query 훅을 코드 생성(codegen)으로 뽑아 쓴다. MSA 시절 "스키마와 동일"이라는 주석이 하던 일을, 지금은 기계가 한다.

정직한 저울

번역의 성과를 숫자로 요약하면 이렇다. 동일 기능 기준의 실측이다.

같은 기능, 코드 줄 수 — 정직한 비교

상담의뢰 도메인 전체가 7,636줄에서 2,805줄로 63% 줄었다. DTO는 96%, 서비스 간 조회는 98%, 인증은 82% 줄었다. 하지만 이 차트에서 내가 발표 때 가장 먼저 짚기로 한 막대는 맨 아래에 있다. Repository 구현은 77줄에서 400줄로, 420% 늘었다. TypeORM의 관계 매핑이 공짜로 주던 조인을 명시적 SQL로 옮긴 대가다. 공짜는 없었다. 다만 어떤 형태의 빚을 질지 골랐을 뿐이다 — 프레임워크의 마법 대신, 눈에 보이는 보일러플레이트를.

그 청구서의 전체 내역 — 그리고 이 아키텍처가 어디서 새고 있는지, 그럼에도 왜 이것이 한 조직의 기술자산이 되었다고 말할 수 있는지 — 는 마지막 글에서 계산하겠다.


3부에서는 배포 파이프라인의 before/after, 삽질 로그, 경계 누수의 실측, 그리고 「FastAPI로 그게 됩니까?」라는 질문에 대한 최종 답변을 다룬다. — 3부 읽기