20191024

Python asyncio 운영 경험 회고

asyncio 실운영 1년 반 되어가는 시점의 회고. 3.6에서 시작해서 지금 3.7로 올라와있음. aiohttp 서버, 크롤러, 외부 API 게이트웨이 — 성격 다른 세 개 굴려봤다. 솔직한 얘기 적음.

잘한 것

외부 API를 대량으로 호출하는 게이트웨이 성격의 서비스는 asyncio가 정답이었다. 이전엔 gevent로 돌렸는데 monkey patch 들어가는 순간부터 스택트레이스가 망가지고, 서드파티 라이브러리 중 일부가 patch와 궁합이 나쁨. asyncio로 가니 속 편함. 단일 프로세스에서 동시 5000 커넥션 무리 없음.

잘 못한 것

첫째, 섞은 것. 처음에 "async로 전환" 하려다가 결국 sync 라이브러리 일부 남겨둔 채로 배포했다. 그러면 한 코루틴이 sync I/O로 블록되는 순간 이벤트 루프 전체가 멈춘다. psycopg2 그대로 쓰다가 장애 한 번 났고, 그 후 aiopg로 다 바꿨음. 교훈: 반쯤 async는 async가 아니다.

둘째, CPU bound 작업을 그대로 async 함수로. JSON 파싱, 큰 리스트 정렬 같은 걸 async def에 넣고 await 없이 처리. 이건 그냥 sync 함수보다 느리기만 함. run_in_executor로 빼야 한다는 걸 뒤늦게 학습.

셋째, 에러 핸들링. create_task로 띄운 태스크의 예외를 놓치면 "Task exception was never retrieved" 경고가 로그에 뜨면서 조용히 묻힌다. 태스크 레퍼런스 안 들고 있으면 GC 타이밍에 따라 취소도 됨. 결국 태스크는 전부 등록/트래킹하는 헬퍼 하나 만들어서 돌림.

다시 선택한다면

여전히 asyncio 쓸 것. 다만 프로젝트 초기에 "이 서비스가 async여야 하는 이유"를 명확히 하고 간다. 그냥 "요즘 거니까"로 쓰면 낭패 본다. I/O 바인드면 맞고, CPU 바운드거나 sync DB 락이 중요하면 멀티프로세스 + sync가 낫다.

3.8에 asyncio.run(), named task 기능 추가된다고. 꾸준히 좋아지고 있다.

20190903

Kubernetes 1.16 custom controller

1.16 알파/베타 올라오면서 CRD v1 승격 얘기가 나오는 중. 사내에 운영 반복되는 패턴(예: 환경별 secret rotation, 특정 ConfigMap 자동 동기화) 몇 개를 custom controller로 빼는 작업.

kubebuilder 2.0 써서 세팅.

kubebuilder init --domain example.com
kubebuilder create api --group ops --version v1alpha1 --kind SecretSync

Reconcile 함수 구조는 결국 이거다.

func (r *SecretSyncReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
    ctx := context.Background()
    var sync opsv1.SecretSync
    if err := r.Get(ctx, req.NamespacedName, &sync); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }
    // 현재 상태 측정
    // 목표 상태와 diff
    // 적용
    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

배운 점 두 개.

1) controller는 idempotent해야 한다. 같은 입력이 100번 와도 같은 결과. 절대 increment 같은 로직 넣지 말 것.

2) 에러 리턴하면 exponential backoff로 재시도된다. 재시도 원하는 거면 error, 아니면 RequeueAfter. 이거 헷갈리면 무한 재시도 또는 무반응.

CRD v1 정식은 1.16 GA에서. v1beta1은 deprecation 예정인데 우리처럼 지금 만들면 v1beta1로 생성해놓고 나중에 변환 웹훅으로 옮겨야 함. 귀찮.

20190826

Kafka 2.3 Exactly-once 운영 메모

카프카 2.3 올리면서 exactly-once 기능을 프로듀서 쪽에서 제대로 설정했다. 2.1 때부터 multi-partition EoS 가능했는데 우린 그냥 at-least-once에 consumer 쪽에서 idempotent 처리하는 구조로 버티고 있었음. 이제 한 번 정리.

브로커 설정은 기존과 크게 안 바뀜. 프로듀서 쪽만.

acks=all
enable.idempotence=true
max.in.flight.requests.per.connection=5
retries=Integer.MAX_VALUE
transactional.id=order-service-1

스트리밍 앱 쪽은 이렇게.

props.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, "exactly_once");

운영 포인트:

  • transactional.id는 인스턴스 유니크하게 줘야 한다. 컨테이너 재시작 시 같은 id면 괜찮지만 수평 확장시 인스턴스별로 달라야 함.
  • 트랜잭션 커밋 실패 시 ProducerFencedException 나오면 프로세스 종료하고 restart. 복구 시도하지 말 것.
  • commit interval 짧게 하면 throughput 떨어짐. 우리는 500ms로 잡고 있음.

성능 영향 — 기존 대비 write throughput 15~20% 감소. 근데 컨슈머 쪽 중복 제거 로직 없애니까 전체 코드 엄청 줄었다. 트레이드오프 괜찮음.

2.3 들어온 Incremental Cooperative Rebalancing도 같이 켬. 컨슈머 리밸런싱이 한 번에 전체 멈추는 게 아니라 점진적으로 바뀌어서 p99 latency 스파이크가 훨씬 덜함.

20190705

TensorFlow 2.0 beta 마이그레이션

TF 2.0 beta로 사내 모델 두 개 옮겼다. 절반은 수월, 절반은 개고생. 기록 겸.

바뀐 것 핵심

  • eager execution 기본
  • session.run, placeholder, global_variables 이런 거 안 씀
  • tf.contrib 사라짐. slim 썼던 코드는 전부 리팩터
  • @tf.function으로 감싼 함수는 graph로 컴파일됨

tf_upgrade_v2 돌려보기

tf_upgrade_v2 --infile model.py --outfile model_v2.py

이 스크립트가 있는 건 다행. 변수 rename 같은 건 잘 바꾸는데, tf.contrib.slim 의존 코드는 "이건 니가 고쳐라" 주석만 박아둠. 결국 keras로 다시 쓰는 게 빠름.

@tf.function 주의

@tf.function
def train_step(x, y):
    with tf.GradientTape() as tape:
        pred = model(x, training=True)
        loss = loss_fn(y, pred)
    grads = tape.gradient(loss, model.trainable_variables)
    optimizer.apply_gradients(zip(grads, model.trainable_variables))
    return loss

파이썬 side effect (print, list.append 등)는 graph 만들 때만 실행되고 이후엔 무시된다. 디버깅할 땐 tf.print 써야 함. 이거 모르고 한참 헤맴.

결론

2.0 안 가도 되면 1.14에 머무는 게 당분간은 안전. 다만 eager 기반으로 코드 짜는 게 진짜 편하긴 하다. 프로덕션 모델은 9월 GA 되면 옮기기로.

20190422

GitLab CI 파이프라인 리팩터

gitlab-ci 11.x 파이프라인 오래 방치했다가 전면 정리. 원래 build → test → deploy 직렬 3단계였는데 뭐가 뭔지도 모르는 스크립트 덩어리였음.

정리 원칙 세 개만.

1. job은 한 가지만 한다. build-and-test 이런 거 금지. 실패 원인 추적 안 됨.

2. 공통은 .template 숨김 job으로. extends로 끌어오기. 이게 생각보다 강력.

.docker-base:
  image: docker:stable
  services:
    - docker:dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

build:
  extends: .docker-base
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

3. cache는 branch 기준, artifacts는 job 간 전달용. 섞어 쓰면 꼬인다. pip 캐시는 cache, 빌드 산출물은 artifacts.

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .cache/pip/

needs: 키워드가 이번 버전에 들어오면서 DAG 순서로 병렬 돌리는 게 가능해졌다. 기존 3단계가 약 9분 걸렸는데 병렬로 재배치 후 4분 30초. 생각보다 크다.

rules: 아직 only/except 기반으로 남김. rules가 12에 들어온다고 해서 그때 다시 정리 예정.

20190314

MySQL 8 윈도우 함수 실전

MySQL 8(8.0.15 기준)에 window function 들어온 지 좀 됐는데 이번에 분석/집계 대시보드 쿼리 리팩터링하면서 본격적으로 썼다. 5.7까지는 Postgres 부러워만 했는데 이제 꽤 자리잡음. 케이스별 실전 사용과 EXPLAIN 주의점.

케이스 1: 그룹별 top-N

상품별 최신 3건. 5.7까지 가장 흔한 관용구는 상관서브쿼리 + LIMIT 이었는데 성능이 박살나기 쉽다.

-- 8.0
SELECT *
FROM (
  SELECT o.*,
         ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at DESC) AS rn
  FROM orders o
  WHERE created_at >= '2019-01-01'
) t
WHERE t.rn <= 3;

실행 동작. MySQL은 윈도우 함수를 Volcano 스타일 이터레이터의 마지막 단계(WINDOW 연산자)에서 계산. PARTITION BY의 순서가 인덱스와 맞으면 sort 회피 가능. 위 쿼리에서 (product_id, created_at DESC) 조합 인덱스가 있으면 Using filesort가 사라진다. 인덱스가 없으면 tmp table + filesort가 들어가서 결과셋이 커질수록 급격히 느려짐.

대안: LATERAL JOIN이 MySQL 8.0.14에서 들어왔다. 각 product_id별로 subquery를 따로 돌려 top-3만 긁어오는 방식. 결과 세트에서 차지하는 비중이 작을 때는 LATERAL이 더 빠르다. 우리 케이스는 행당 주문 수가 수백 이상이라 window + row_number가 더 빨랐음.

케이스 2: 누적합/이동 평균

-- 일자별 매출 + 누적합 + 7일 이동평균
SELECT ymd,
       amount,
       SUM(amount) OVER w_cum AS cum,
       AVG(amount) OVER w_7d AS ma7
FROM daily_sales
WINDOW
  w_cum AS (ORDER BY ymd ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),
  w_7d  AS (ORDER BY ymd ROWS BETWEEN 6 PRECEDING AND CURRENT ROW);

frame 지정 주의. ROWS와 RANGE가 다르다. ROWS는 물리적 행 개수 기준, RANGE는 ORDER BY 값의 범위 기준. 날짜가 중간에 빠져 있는 데이터에서 "최근 7일 이동평균"을 진짜 기간 기준으로 하려면 RANGE BETWEEN INTERVAL 6 DAY PRECEDING — 인데 MySQL 8.0 초기 버전은 RANGE의 interval 프레임 지원이 제한적. 8.0.2+에서 일부 들어왔지만 운영에서는 ROWS로 쓰되 날짜 gap을 직접 메우는 쪽이 안전.

케이스 3: LAG/LEAD로 증감 계산

SELECT ymd, amount,
       amount - LAG(amount, 1, 0)  OVER (ORDER BY ymd) AS dod_diff,
       amount - LAG(amount, 7, 0)  OVER (ORDER BY ymd) AS wow_diff,
       (amount / NULLIF(LAG(amount, 7) OVER (ORDER BY ymd), 0) - 1) AS wow_pct
FROM daily_sales;

5.7 시대엔 self join 또는 사용자 변수(@prev := ...) 트릭으로 짰던 쿼리. 사용자 변수 방식은 8.0에서 warning 뜨고, 평가 순서가 보장되지 않는다는 문서 경고가 강해져서 사실상 금지 권고. LAG로 대체.

케이스 4: 그룹 내 비중

SELECT product_id,
       region,
       revenue,
       revenue / SUM(revenue) OVER (PARTITION BY product_id) AS share,
       RANK() OVER (PARTITION BY product_id ORDER BY revenue DESC) AS rk
FROM sales_by_region;

제품별 지역 매출 비중과 순위 한 번에. 이걸 예전엔 GROUP BY product_id로 합계 subquery 만들어 조인해야 했다.

성능 및 EXPLAIN

  • 동일 결과 기준, 상관서브쿼리 대비 1.5~2배 빠름(우리 데이터 기준, orders 800만, products 6만)
  • window 연산자는 EXPLAIN FORMAT=TREE(8.0.16+)로 보면 명확. -> Window aggregate: ... 라인 확인
  • 정렬 회피 여부가 결정적. PARTITION BY ~ ORDER BY 컬럼 조합이 인덱스 선두 컬럼과 맞아떨어져야 Using filesort가 사라진다
  • FRAME 없이 window 쓰면 기본이 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. row_number, rank 같은 랭킹 함수에는 상관없지만 SUM, AVG는 결과 의미가 달라진다

CTE(WITH) 함께 쓰기

WITH daily AS (
  SELECT DATE(created_at) AS ymd, SUM(amount) AS amount
  FROM orders WHERE created_at >= '2019-01-01'
  GROUP BY DATE(created_at)
)
SELECT ymd, amount,
       SUM(amount) OVER (ORDER BY ymd) AS cum
FROM daily;

MySQL 8의 CTE는 Postgres와 달리 기본적으로 materialize되지 않고 subquery처럼 inline. 그래도 derived_merge 최적화가 안 먹는 복잡한 쿼리에서는 임시 테이블로 펼쳐질 수 있다. EXPLAIN으로 확인 필수. 재귀 CTE(WITH RECURSIVE)는 최대 cte_max_recursion_depth(기본 1000)에 걸리니 큰 트리 순회는 주의.

마이그레이션 주의

  • 예약어 확장. RANK, ROW, OVER가 예약어. 기존 컬럼명이 이것들이면 따옴표 처리 or 개명
  • 사용자 변수로 짠 "rolling" 쿼리는 8.0에서 결과가 달라질 수 있음. 순서 보장 경고 강화. 이번 기회에 전부 window로 교체 중
  • 캐릭터셋 기본값이 utf8mb4로 바뀌어서 old client 호환성 체크. 우리 서비스는 5.7 시절부터 utf8mb4라 영향 없음

한 줄 결론: 윈도우 함수 없던 시절로 돌아가긴 싫다. 특히 대시보드/분석 쿼리가 읽기 쉬워짐. 다음 스프린트는 기존 집계 배치 8개를 전부 window로 리팩터링 예정.

20190225

Keras tf.keras로 통일

keras-team에서 tf.keras로 통일하는 방향을 사실상 확정한 분위기. 2.2.x 이후 multi-backend keras는 maintenance 모드로 들어가고, 앞으로는 TensorFlow 2.0에 포함되는 tf.keras에 힘을 싣겠다는 게 Chollet 본인 발표. 지난주 실무 마이그하면서 느낀 점 정리.

개인적으로 multi-backend 시절의 자유도가 아쉽긴 하다. 근데 실질적으로 Theano는 2017년에 개발 종료, CNTK도 최근 커밋이 끊겼고 PlaidML 정도가 남았는데 그것도 작은 커뮤니티. 결국 TF와 PyTorch 양강 구도에서 keras는 TF 위로 올라가는 게 자연스러운 흐름.

마이그레이션 — 실제로 달라지는 것

# before — multi-backend keras 2.2.4
from keras.models import Sequential
from keras.layers import Dense, Conv2D
from keras.callbacks import EarlyStopping
from keras import backend as K

# after — tf.keras (TF 1.13)
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Conv2D
from tensorflow.keras.callbacks import EarlyStopping
import tensorflow as tf
# backend 호출은 tf.keras.backend 또는 직접 tf 사용

대부분의 고수준 API(Model, Layer, callbacks, losses, metrics)는 시그니처가 거의 동일해서 import 경로만 바꾸면 돌아간다. 걸리는 건 다음 몇 가지.

  • 옵티마이저 객체. TF 1.x는 tf.train.AdamOptimizerkeras.optimizers.Adam이 따로 존재. tf.keras 안에서는 원래 multi-backend keras의 옵티마이저와 유사하지만 내부 wrapper가 다름. 저장된 모델의 옵티마이저 상태를 다른 구현으로 로드하면 학습 이어붙일 때 loss가 튄다. 한 프로젝트 안에서 반드시 하나로 통일
  • 커스텀 레이어 내부의 backend. K.function, K.gradients, K.learning_phase 같은 함수 호출은 tf.keras에서 대응 API가 미묘하게 다르거나, TF 2.0에서는 eager/graph 모드 구분 때문에 재설계 필요
  • Sequence, DataGenerator. 대체로 호환인데 multiprocessing 워커 수, worker_init_fn 처리가 TF 기반으로 통일되며 살짝 바뀜. use_multiprocessing=True는 OS 플랫폼에 따라 여전히 까다로움
  • Learning phase 자동 전환. multi-backend keras는 BatchNorm/Dropout이 학습/추론 모드를 전역 플래그로 관리. tf.keras + eager에서는 모델 호출 시 training=True/False 인자로 명시적. 커스텀 레이어 call(self, x, training=None)에서 training을 받아서 내부 BN에 넘기는 패턴을 꼭 써야 함

TF 2.0 alpha

며칠 전에 2.0 alpha 공개됨. 가장 큰 변화는 eager execution이 기본이라는 점. 1.x 시대의 "그래프 먼저 그리고 session 돌리기"가 사라지고, 파이썬 코드 그대로 실행되는 정의-실행 동시 모델. PyTorch에서 건너오는 사람들에게 자연스러운 방식.

그리고 tf.function 데코레이터. 성능이 필요한 구간만 그래프 컴파일. 내부적으로 AutoGraph가 파이썬 제어문(for, while, if)을 그래프 오퍼레이션으로 변환. 이 조합으로 "디버깅은 PyTorch처럼, 성능은 TF 1.x처럼"을 노린다.

import tensorflow as tf

@tf.function
def train_step(x, y, model, loss_fn, optimizer):
    with tf.GradientTape() as tape:
        logits = model(x, training=True)
        loss = loss_fn(y, logits)
    grads = tape.gradient(loss, model.trainable_variables)
    optimizer.apply_gradients(zip(grads, model.trainable_variables))
    return loss

loop을 이렇게 직접 쓰는 게 어색할 수도 있는데, 실전에서 loss에 auxiliary term 붙이거나 gradient clipping/accumulation을 커스텀하기 훨씬 쉽다. model.fit의 편의성이 필요한 건 그대로 쓰고, 실험적인 학습 로직은 custom loop. 하이브리드 구성.

할 일

  • 현재 서비스 3개 모델 import 경로 → tf.keras
  • 옵티마이저와 체크포인트 포맷 통일(SavedModel로)
  • 이번 분기 중 TF 2.0 beta 나오면 소규모 모델부터 2.0 마이그
  • 커스텀 레이어의 backend 호출 제거, tf.function 적용 구간 선별

아쉬운 점: Theano 시절 theano.gradient.jacobian 같은 걸 잘 썼는데 tf.keras + TF는 JAX 나오기 전까지 고차 미분 쪽 편의성이 떨어짐. JAX가 안정화되면 그쪽으로 연구용은 옮길지도. 운영용은 당분간 tf.keras로 정착.

20190109

FastAPI 0.30 타입 기반 API

FastAPI 써봐야지 하다가 0.30이 올라온 김에 사이드 API 하나 구현. Flask/aiohttp를 주로 쓰다가 넘어왔는데, 타입힌트 기반 요청 파싱이 자동으로 되는 게 가장 큰 차이. 사용 소감 + 구조 분석 메모.

from fastapi import FastAPI, Depends, HTTPException, status
from pydantic import BaseModel, Field
from typing import List, Optional

app = FastAPI(title="rdx-api", version="0.1")

class ItemIn(BaseModel):
    name: str = Field(..., min_length=1, max_length=80)
    price: float = Field(..., gt=0)
    tags: List[str] = []
    note: Optional[str] = None

class ItemOut(ItemIn):
    id: int
    class Config:
        orm_mode = True

@app.post("/items/", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
async def create(item: ItemIn, db = Depends(get_session)):
    row = await db.execute(
        "INSERT INTO items(name, price, tags, note) VALUES($1,$2,$3,$4) RETURNING *",
        item.name, item.price, item.tags, item.note
    )
    return row

pydantic이 요청 본문 검증(타입, 길이, 범위)을 자동 처리. marshmallow 같은 걸 따로 붙일 필요가 없다. response_model로 직렬화 스키마도 분리 가능해서 "내부 모델은 넓고, 응답은 제한적으로"가 깔끔하게 표현된다. orm_mode=True로 ORM 객체 직접 반환도 지원.

OpenAPI 자동 생성이 꽤 크다. /docs에 Swagger UI, /redoc에 ReDoc. 타입힌트 + pydantic 모델만으로 스펙이 완성되니까 문서-코드 싱크 문제가 원천 차단. 프런트와 협업할 때 openapi.json을 openapi-generator에 물려 TS 클라이언트 자동 생성. swagger-ui 수동 유지하던 부담이 사라졌다.

내부 구조 훑기

FastAPI는 Starlette 위에 타입힌트 기반 디스패치를 얹은 구조. 요청이 들어오면 Starlette의 ASGI 라우팅이 엔드포인트 함수를 찾고, FastAPI가 함수 시그니처를 inspect해서:

  1. 파라미터가 경로인지({id} 매칭), 쿼리인지, 바디인지, 의존성인지 분류
  2. pydantic 모델은 JSON body로 추정, 기본형은 쿼리 파라미터로 추정
  3. Depends(x)로 선언된 것은 함수 호출 결과를 주입
  4. 모두 검증 통과하면 view 호출, 반환값은 response_model에 맞춰 직렬화

시그니처 인스펙션은 요청마다 반복하면 당연히 비싸니까 앱 기동 시점에 pre-analysis. 라우트 등록 시 ParamField 메타데이터를 구성해 캐싱. 그래서 첫 요청 이후엔 reflection이 일어나지 않는다.

의존성 주입이 특히 깔끔. 인증, DB 세션, 페이지네이션 공통 파라미터 같은 반복 보일러플레이트를 Depends(...)로 빼면 뷰 함수가 본질만 남는다. 의존성 자체도 다른 의존성을 가질 수 있어서 트리 구조. 같은 의존성이 요청 내 여러 자리에서 쓰이면 기본적으로 캐싱(use_cache=True).

벤치 (단순 echo)

wrk -t4 -c100 -d30s, 본문 140 바이트 JSON echo.

  • Flask 1.0 + gunicorn sync 4 worker: ~3,100 rps, p99 62ms
  • Flask + gunicorn + gevent 100 worker: ~5,400 rps, p99 48ms
  • aiohttp 3.5: ~9,100 rps, p99 22ms
  • FastAPI 0.30 + uvicorn(uvloop) 1 worker: ~8,700 rps, p99 24ms
  • FastAPI + uvicorn 4 worker: ~28,000 rps, p99 18ms

단순 echo라서 숫자가 크게 나오지만 DB 붙으면 차이는 거의 DB 쪽에서 결정된다. 실질 이득은 속도보다 "개발 경험 + 스키마 자동화"에 있음. 그리고 async 기반이라 외부 HTTP 많이 치는 엔드포인트에선 gunicorn sync 대비 확연한 처리량 차이.

걸리는 점

  • 0.x 브레이킹. 0.25 → 0.30 사이에 Depends 반환 타입 추론, security scheme 필드 이름 같은 것들이 미묘하게 바뀜. 릴리스 노트 꼭 확인. requirements에 pin 하는 걸 권장
  • pydantic 1.0 아직. 0.x대라 BaseModel의 validator 방식이 달라질 가능성. 지금은 @validator("x", pre=True) 관용구인데 1.0에서 바뀔 조짐
  • sync 라이브러리와 섞기. ORM으로 SQLAlchemy 1.2 쓰면 세션이 sync라 async 뷰에서 블록. 실전에선 asyncpg/databases로 가거나, sync 구간을 run_in_threadpool로 격리. 우리는 databases 0.1대로 갔는데 migrations/유틸은 여전히 sqlalchemy로. 이중 관리 약간 귀찮음
  • 테스트 도구. FastAPI TestClient는 내부적으로 Starlette의 TestClient → requests. 훌륭한데 async 경로 디버깅이 sync wrapping을 한 번 더 타서 traceback이 살짝 길다
  • 중간 마이그레이션. Flask 전체를 FastAPI로 바꿀 이유는 적다. 새 엔드포인트는 FastAPI, 기존은 Flask 유지하고 reverse proxy로 경로 분리 운영이 현실적

종합

프로덕션 전면 적용하기엔 아직 버전이 초기. 0.30 기준 내부 툴, 관리자 API, 신규 microservice 같은 곳부터 경험 쌓기 좋다. 단순한 API 문법이면 pydantic 덕분에 마르셜링 레이어를 따로 쓸 필요가 없어서 코드가 30~40% 짧아지는 체감. 1.0 도달하면 메인 스택 후보로 검토할만함.