20201122

nginx 1.18 + OpenResty 사용기

nginx 1.18에 OpenResty 얹어서 인증/리밋 로직을 Lua로 처리하는 게이트웨이 만들어봄. 전에는 express + 미들웨어로 했는데, 단순 체크만 하는 거면 openresty 쪽이 훨씬 빠르고 리소스도 덜 먹는다.

토큰 검증(JWT 간단 버전) 예제.

location /api/ {
    access_by_lua_block {
        local jwt = require "resty.jwt"
        local auth = ngx.var.http_authorization
        if not auth then
            ngx.exit(401)
        end
        local token = string.gsub(auth, "Bearer ", "")
        local verified = jwt:verify("mysecret", token)
        if not verified.verified then
            ngx.status = 401
            ngx.say(verified.reason)
            ngx.exit(401)
        end
        ngx.req.set_header("X-User-Id", verified.payload.sub)
    }
    proxy_pass http://backend;
}

레이트 리밋은 resty.limit.req + shared dict.

http {
    lua_shared_dict req_limit 10m;
}

location /api/ {
    access_by_lua_block {
        local limit = require("resty.limit.req").new("req_limit", 200, 100)
        local delay, err = limit:incoming(ngx.var.binary_remote_addr, true)
        if not delay then
            if err == "rejected" then
                ngx.exit(429)
            end
        end
    }
}

체감 성능. wrk -t4 -c200 기준, 같은 토큰 검증 로직을 node 미들웨어로 할 때 대비 openresty가 약 3.2배 throughput. 근데 복잡한 로직 lua로 짜는 게 유지보수적으로 힘들어서 "라우팅 + 인증 + 레이트리밋" 수준에서만 쓰는 게 답이라고 본다.

lua_shared_dict 크기 잘 잡아야 함. 넘치면 eviction 돌고 결국 정확도 떨어진다. 주요 도메인당 10~20m 추천.

20201020

React 17 — 변화보다 업그레이드 루틴

React 17 정식 나왔다. 공식 블로그 제목부터 "No New Features". 동의한다. 유저 페이싱 기능 변화는 거의 없음. 대신 업그레이드 루틴을 정리하고 18 준비(concurrent)를 위한 토대를 닦는 릴리스. 실제 작업한 기록.

실제 중요한 변화

1. 이벤트 위임 타겟이 document → root. 16까지는 모든 합성 이벤트(onClick 등)가 document에 붙어 있었다. 이게 여러 React 트리가 한 페이지에 공존할 때(마이크로프론트엔드, 위젯 삽입) 이벤트 가로채기 순서가 꼬이는 원인. 17은 ReactDOM.render(el, rootEl)의 rootEl에 이벤트가 붙는다.

실제 이득: 레거시 코드(jQuery)와 React가 섞인 페이지에서, jQuery에서 e.stopPropagation()했는데도 React가 잡아버리던 현상이 사라진다. 또 17 + 16 트리를 한 페이지에 섞어 돌리는 "부분 업그레이드"가 가능해진다. 우리는 레거시 관리자 내부에 일부 페이지만 React 화했던 적이 있어서 이 변경이 반갑다.

2. 새 JSX 트랜스폼. 16까지는 컴포넌트 파일마다 import React from 'react'가 필수(JSX가 React.createElement로 변환되므로). 17 + Babel 7.9(@babel/preset-react 7.9.0 이상) 또는 TS 4.1부터 automatic runtime 지원. 빌드 타임에 필요한 함수만 자동 import.

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-react', { runtime: 'automatic' }]
  ]
}

번들 사이즈 미세 감소(프로젝트 크기에 비례해서 몇 KB). 더 큰 건 개발 편의. 우리 레포에 import React from 'react'가 800번 나오던 거 전부 codemod로 제거 가능.

npx react-codemod update-react-imports

3. useEffect cleanup 타이밍. 16까지는 언마운트 시 cleanup이 동기적으로 실행 → 화면이 잠깐 잠김. 17부터는 렌더 이후 비동기로. 무거운 cleanup(구독 해제, 타이머 정리)이 UI를 막지 않음. 대신 cleanup 완료 전에 다음 렌더가 일어날 수 있으므로 cleanup 내부에서 mutate하는 코드는 다시 한번 확인.

4. 일관된 error handling. componentDidMount/componentDidUpdate의 에러가 unmount를 강제하도록 변경. 이전에는 에러 후에도 컴포넌트가 일부 남아서 crash가 퍼지는 경우 있었음.

업그레이드 과정

우리 프로젝트는 react-scripts 3.x(CRA)였고 17 쓰려면 react-scripts 4.x로 동반 올림. eslint, babel, webpack, postcss 쪽 peer dep이 쫙 바뀐다.

npm i react@17 react-dom@17 react-scripts@4
# peer dep 경고가 과격하면
npm i --legacy-peer-deps

그 외 실수하기 쉬운 포인트.

  • enzyme 어댑터가 17 대응이 늦어서 문제. 공식 enzyme-adapter-react-17이 아직 unofficial. 우리는 이 기회에 @testing-library/react로 전환 시작. 테스트 코드 꽤 많이 갈아엎어야 함
  • 사용하던 외부 라이브러리 peer deps: react-router 5.x OK, redux/react-redux 7.2 OK, material-ui 4.x OK(5는 별도 마이그 계획). react-dnd는 11 이상 필요
  • 클래스 컴포넌트에서 UNSAFE_componentWillMount 사용 중이면 경고. strict mode면 더 요란함. 이 기회에 리팩터

개발 경험 변화

  • HMR(react-refresh)은 react-scripts 4에서 기본. fast-refresh가 안정적이고 상태 보존이 좋다
  • Strict Mode 이중 렌더링: 18 concurrent 준비로 effect의 순수성을 확인하는 진단 모드. 17도 영향 있으니 개발 환경에서 켜서 잡아내는 게 좋다
  • TypeScript 4.1 + automatic JSX runtime이 깔끔. tsconfig의 "jsx": "react-jsx"

성능 실측

우리 admin SPA(Redux + react-router, 120개 라우트) 기준.

  • 초기 번들: 412KB → 408KB (automatic jsx 효과로 -4KB)
  • TTI(로컬): 1.92s → 1.88s. 유의미한 차이 아님. "기능은 없다"는 공식 발표 그대로
  • HMR 반영 속도: 1.4s → 0.9s(react-refresh 적용 효과)

결론

17은 18(concurrent features) 가는 정거장. 지금 업그레이드 해둬야 18 전환 비용이 줄어든다. 체감은 애매하지만 "ImportReact 전부 제거", "enzyme → RTL 전환", "Strict Mode 켜서 잠재 버그 훑어보기" 이 세 가지를 할 명분이 생기는 게 크다. 팀 코드베이스 정화 핑계로 삼자.

남은 숙제: webpack 4 → 5, material-ui 4 → 5 베타 둘 다 큰 건이라 다음 분기로 밀어둠.

20200920

MongoDB 4.4 transactions 실전

몽고DB 트랜잭션이 4.0에서 replica set, 4.2에서 sharded cluster까지 열린 뒤 사내 일부 서비스에서 조심스럽게 쓰기 시작. 4.4 올리고 정리.

드라이버는 pymongo 3.11. 세션 열고 with_transaction 블록으로 감싸는 게 표준 패턴.

def transfer(session, src, dst, amount):
    accounts.update_one(
        {"_id": src, "balance": {"$gte": amount}},
        {"$inc": {"balance": -amount}},
        session=session,
    )
    accounts.update_one(
        {"_id": dst},
        {"$inc": {"balance": amount}},
        session=session,
    )

with client.start_session() as session:
    session.with_transaction(
        lambda s: transfer(s, "a", "b", 100),
        read_concern=ReadConcern("snapshot"),
        write_concern=WriteConcern("majority"),
    )

운영 체감:

  • 트랜잭션은 "가능"한 거지 "권장"은 아님. 짧게, 적은 컬렉션만 건드리는 게 원칙.
  • 60초 기본 타임아웃. 이 안에 안 끝나면 abort. 안에서 외부 API 호출 금물.
  • sharded tx는 정말 느리다. 벤치 결과 단일 문서 업데이트 대비 3~5배. 정말 필요한 경우에만.
  • 충돌 시 TransientTransactionError 나오면 자동 retry되는 편. 명시적 retry 필요한 경우도 있으니 테스트 필수.

4.4 개선점 중 유용한 것:

  • hedged reads: secondary 두 개에 병렬로 읽어서 빠른 쪽 응답 사용. p99 개선됨
  • refinable shard keys: 샤드 키에 접두어 추가 가능. 기존엔 샤드 키 잘못 정하면 리샤딩 외엔 답 없었음
  • custom aggregation expressions ($function) — 쓰고 싶은 유혹은 있지만 퍼포먼스 함정이라 피하는 중

결론: 트랜잭션 가능해졌어도 스키마를 트랜잭션 없이 돌아가게 설계하는 원칙은 유지. 쓰기 싫게 생겼을 때만 쓴다.

20200825

Docker Compose v2 체감

Docker Compose v2 technical preview 들여다봄. Docker Desktop 2.3에 번들로 들어있음(Linux는 수동 플러그인). v1(Python)이 아니라 Go로 재작성한 거고, CLI도 docker compose ...(스페이스). v1이 docker-compose ...(하이픈). 3월에 alpha 한 번 만져봤는데 이번 preview에서 꽤 정돈.

기존 compose yml은 거의 그대로 돈다. 중요한 건 CLI 진입점 변화.

docker compose up -d
docker compose ps
docker compose logs -f api
docker compose exec api python manage.py shell
docker compose --profile worker up -d

체감 차이 — 정량

  • 시작 속도. docker-compose ps(v1) 298ms vs docker compose ps(v2) 58ms. 5배 차이. 파이썬 import 비용이 큼
  • 스택 up. 8개 서비스 구성 cold start 23s → 14s. 차이는 빌드 병렬화와 BuildKit 기본 덕
  • 오류 메시지. v1은 파이썬 traceback이 노출되어 "yaml 스키마 에러"가 호출 스택과 섞여 보였다. v2는 docker CLI 스타일(컴포넌트/단계 표시)
  • buildx 통합. 이미지 빌드 시 BuildKit이 기본. 멀티 아키텍처(amd64/arm64) 빌드도 docker compose build --platform=linux/amd64,linux/arm64로 가능

구조적 차이

Docker CLI 플러그인 아키텍처를 채택. ~/.docker/cli-plugins/docker-compose 단일 Go 바이너리가 docker CLI에 확장 명령어로 붙는 구조. 이 구조의 이점은 docker context(ECS/ACI/쿠버네티스 원격) 기반으로 compose를 원격 런타임에도 투사할 수 있다는 것. 로컬 개발용 compose.yml을 그대로 ECS에 올리는 실험적 시나리오가 가능.

v1의 docker-py 경유 Engine API 호출 방식은 Docker가 기능을 추가할 때마다 파이썬 바인딩이 뒤쳐지는 고질적 문제가 있었다. v2는 docker CLI와 동일한 gRPC/HTTP 클라이언트를 씀.

실용 기능 몇 개

1. profiles. v1 1.28에 먼저 들어왔고 v2에서 1급 시민. 개발/테스트/프로덕션 마다 일부 서비스만 켜는 운영.

services:
  api:
    image: myorg/api
  worker:
    image: myorg/worker
    profiles: ["worker"]
  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]
docker compose up -d                    # api만
docker compose --profile worker up -d   # api + worker
docker compose --profile dev up -d      # api + mailhog

2. --wait. healthcheck가 healthy 될 때까지 블록. CI에서 up 직후 통합 테스트 바로 때릴 수 있다.

docker compose up -d --wait
pytest -q tests/integration

3. env_file과 interpolation. 변수 확장 규칙이 더 정밀해짐. ${VAR:-default}(unset이면 default), ${VAR-default}(정의 안 됨이면 default) 구분이 엄격.

4. build.cache_from / cache_to. 레지스트리 기반 분산 빌드 캐시. CI 러너가 여러 대일 때 매 빌드 cold 시작 방지.

마이그레이션 시 걸린 것들

  • compose file version. v2는 version: "3.9" 필드를 사실상 무시. Compose Spec으로 통일 중이라 version을 적지 않아도 됨. 그러나 v1과 병행 운영 중이면 명시 유지
  • links. 이미 deprecated였는데 v2에서 확실히 noop화. 네트워크 경유 서비스 디스커버리가 기본이니 문제는 거의 없음
  • command의 signal handling. v2에서 stop_grace_period 대응이 엄격. 앱이 SIGTERM을 제대로 처리 안 하면 grace 뒤 SIGKILL로 강제 종료되는 타이밍이 v1보다 조금 더 정확. gunicorn/uvicorn 기동 스크립트에서 signal 처리 재확인
  • host-gateway. 컨테이너에서 호스트로 host.docker.internal 식 접근이 macOS/Windows는 기본, Linux는 extra_hosts: ["host.docker.internal:host-gateway"]로 명시 필요. v2는 이 트릭이 안정
  • Windows PATH. 업데이트 후 docker compose가 안 먹는 경우 Docker Desktop 재설치로 해결

관성 대응

손이 docker-compose ...를 계속 친다. shell alias로 처리.

# ~/.zshrc
alias dc="docker compose"
alias dcu="docker compose up -d"
alias dcl="docker compose logs -f"

운영 결정

  1. 로컬 개발: v2 전면 전환. 기동 속도, 오류 메시지 개선 체감
  2. CI: v2로 전환하되 기존 스크립트에서 docker-composedocker compose로 바꾸고, 스택 up 시 --wait 활용
  3. 프로덕션 쪽은 원래 compose를 직접 안 쓰기 때문에 영향 없음(K8s + Helm)

v1은 내년쯤 deprecated 예상. 지금부터 전환해 두는 게 낫다. 호환성은 거의 문제없고, 성능과 UX 이득이 분명.

20200712

HuggingFace Transformers BERT 파인튜닝

HuggingFace transformers 3.0 나왔다. 기존 2.x 쓸 때 trainer API가 애매해서 직접 루프 짜곤 했는데, 3.0부터 Trainer가 꽤 쓸만해졌음. 한국어 분류 파인튜닝 기록.

데이터

사내 상품 카테고리 분류. 한글 텍스트 3만건, 14 클래스. 클래스 불균형 심함(상위 3개가 60%).

모델

KoBERT는 직접 업데이트가 뜸해서 이번엔 bert-base-multilingual-cased로 갔다. 사이즈 이유로 실운영은 distilbert 다국어 버전도 병행.

from transformers import (
    AutoTokenizer, AutoModelForSequenceClassification,
    Trainer, TrainingArguments
)

tok = AutoTokenizer.from_pretrained("bert-base-multilingual-cased")
model = AutoModelForSequenceClassification.from_pretrained(
    "bert-base-multilingual-cased", num_labels=14
)

args = TrainingArguments(
    output_dir="out",
    per_device_train_batch_size=16,
    num_train_epochs=3,
    learning_rate=2e-5,
    warmup_ratio=0.1,
    weight_decay=0.01,
    evaluation_strategy="epoch",
    fp16=True,
)
Trainer(model=model, args=args, train_dataset=train_ds, eval_dataset=val_ds).train()

결과

  • 베이스라인(TFIDF + LR): macro F1 0.58
  • mBERT fine-tune: macro F1 0.73
  • Distil-mBERT: 0.70 (훨씬 가벼움, 서빙 용)

소수 클래스는 여전히 약함. focal loss 시도해봤는데 효과는 미미. 데이터 리샘플링(상위 클래스 undersampling)이 더 효과 있었다. macro F1 0.73 → 0.76.

서빙은 onnx 변환 후 CPU 추론. 한 건당 35ms 정도. 실시간 API에 붙이기엔 아슬아슬해서 비동기 큐 사용.

20200415

Redis 6 ACL 운영 도입

redis 6 GA 됐고 ACL이 제일 기다렸던 기능. 지금까진 requirepass 하나로 "다 되거나 안 되거나"였는데 이제 유저 단위 권한 줄 수 있음.

설정 예.

ACL SETUSER reporter on >s3cret ~stats:* +@read
ACL SETUSER worker on >w0rkerpw ~jobs:* +@read +@write -@admin
ACL SETUSER default off

설명:

  • ~stats:* : key 패턴 제한
  • +@read : read 계열 명령만 허용
  • -@admin : admin 명령 차단
  • default off : 무인증 접속 차단

우리는 prod에서 앱 서비스별로 별도 유저 생성. reporter는 읽기 전용, worker는 쓰기/읽기, backup은 스캔 + 덤프 계열만 허용.

aclfile /etc/redis/users.acl로 외부 파일로 빼서 관리. ConfigMap에 올리고 SIGHUP으로 리로드. CONFIG REWRITE는 ACL 반영이 완전하지 않아서 aclfile 쓰는 게 낫다.

운영 팁:

  1. 패스워드는 >로 평문 넣지 말고 SHA256 해시로 # 접두어 써서 넣기
  2. MONITOR 명령은 기본적으로 큼직한 권한이라 운영 유저엔 주지 말 것. -monitor 박자
  3. slow log 같은 건 +@slow+slowlog로 따로

기존 requirepass 써드파티들은 호환 모드로 그대로 돌아감. 다만 redis-cli 쓸 때 6 이후로는 AUTH 명령이 AUTH user pass 형태로 변해서 스크립트들 수정 필요.

클러스터에도 적용하려면 각 노드에 같은 ACL 배포 필요. 아직 통합 관리는 없고 Sentinel도 따로. 이 부분은 개선 여지.

20200322

Kubernetes prod 장애 회고 — OOM

지난주 목요일 오전 9시 30분, prod 클러스터 중 하나에서 결제 관련 파드들이 줄줄이 죽기 시작했다. OOMKilled. 30분간 결제 실패율이 치솟았고, 우리는 그 30분 동안 장님이었다.

회고 정리.

무슨 일이 있었나

한 서비스가 점점 메모리를 먹다가 limit(1Gi) 찍고 죽음. 재시작. 다시 먹음. 죽음. 이게 10분마다 반복. 처음엔 로드 스파이크 때문인 줄 알았는데 트래픽은 오히려 평소보다 낮았다.

원인

어제 배포한 PR에 있던 캐시 로직. 요청별 temp 데이터를 모듈 레벨 dict에 쌓는데, 만료 로직이 잘못돼서 삭제 안 되고 계속 쌓임. 배포 직후엔 쌓이는 속도가 느려서 테스트 환경에서 안 걸림. 12시간 쯤 지나니 1Gi 넘음.

코드 대략 이런 모양이었다.

_cache = {}

def get_or_set(key, fn, ttl=60):
    now = time.time()
    if key in _cache:
        val, exp = _cache[key]
        if exp > now:
            return val
    val = fn()
    _cache[key] = (val, now + ttl)  # 만료된 key는 영원히 안 지워짐
    return val

TTL 체크만 있고 eviction이 없다. key가 매 요청마다 유니크(request id가 섞여있었음)했기 때문에 증가만 함. 아 이건 진짜 허무한 버그.

왜 바로 못 잡았나

1) 메모리 알람 임계치가 85%인데 pod은 limit에 닿으면 그냥 kill. 알람이 울릴 시간이 없음.

2) prometheus 수집 주기 30초라 짧은 라이프사이클 pod의 메모리 그래프가 "어? 막 생겼다 사라지네" 수준으로만 보임.

3) 로그에 OOMKilled 로그 이벤트 집계 대시보드가 없었음. kubectl describe pod으로 일일이 봐야 알 수 있었다.

고친 것

  • 해당 캐시 로직을 cachetools TTLCache로 교체 (내부에 eviction 있음)
  • kube_pod_container_status_last_terminated_reason="OOMKilled" 메트릭을 alertmanager에 등록
  • 메모리 limit의 80% 닿으면 pre-alert (scrape 주기도 15초로)
  • 모든 서비스에 GOMEMLIMIT 혹은 resource 기반 인메모리 캐시 상한 명시 규칙 추가

개인적 교훈

"모듈 레벨 dict에 뭐 쌓는 순간 이게 무한 성장 가능한지 자문해라." 작년 이맘때도 비슷한 거 한 번 냈었다. 같은 실수 반복. 그래서 이번엔 린트 룰로라도 박을까 고민 중. 아니면 최소한 코드리뷰 체크리스트에 넣든가.

OOM은 보이지 않는 버그라 무섭다. 부하 테스트만으로는 못 잡고 시간이 지나야 드러난다.

20200224

FastAPI + SQLAlchemy 1.4 async 전환

SQLAlchemy 1.4가 async 지원 한다고 해서 프리뷰(b1 정도) 버전 물려서 FastAPI 서비스 한 개를 async db로 전환. 아직 정식은 아님, 2020년 하반기 정도에 1.4 GA 예정이라는 분위기.

현재 스택: FastAPI 0.52, SQLAlchemy 1.4.0b1, asyncpg 0.20, PostgreSQL 12.

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

engine = create_async_engine(
    "postgresql+asyncpg://user:pw@localhost/db",
    pool_size=10,
    max_overflow=5,
)

AsyncSessionLocal = sessionmaker(
    engine, class_=AsyncSession, expire_on_commit=False
)

async def get_db():
    async with AsyncSessionLocal() as session:
        yield session

쿼리 모양. 기존 session.query() 대신 select() 쓰는 2.0 스타일이 강요됨.

from sqlalchemy import select

@app.get("/users/{user_id}")
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
    stmt = select(User).where(User.id == user_id)
    result = await db.execute(stmt)
    user = result.scalar_one_or_none()
    return user

걸린 점 몇 가지 적어둠.

- expire_on_commit=False 꼭 필요. 안 하면 commit 후 속성 접근할 때 lazy load 들어가서 "another operation in progress" 예외. 처음에 한참 헤맸음.

- relationship의 lazy loading은 여전히 sync 전제. selectinload, joinedload로 eager load하거나 AsyncSession.refresh 명시 호출 필요.

- Alembic은 아직 sync 전용이라 migration은 sync 커넥션으로 돌림. 지금은 이게 맞다.

벤치는 별 의미 없는데, 동시성 200 기준 p95 latency가 sync 대비 40% 정도 좋음. 단 psycopg2 대비 asyncpg는 드라이버 자체가 빨라서 그 영향도 큼.

beta라 돌다가 내부 에러 뜨는 경우 있고, 스택트레이스가 greenlet/asyncio 섞여서 난해함. prod 넣을 때는 1.4 GA 까지 기다리는 게 현명할 듯.

20200125

Django 3 async ORM 기대 반 현실 반

Django 3.0 나왔다. async 관련 공식 발표 한 번 정리.

"Django 3.0 begins our journey to making Django fully async-capable... views, middleware, and handlers can be async. ORM is not async yet."

즉 이번 버전에서 할 수 있는 것:

  • async view 작성 가능
  • ASGI 기반 실행 (daphne/uvicorn)
  • async middleware

아직 안 되는 것:

  • ORM. 이게 핵심인데 안 됨.
  • cache, sessions 등 써드파티 백엔드 대부분

결국 async view 안에서 ORM 호출하려면 sync_to_async로 감싸야 한다.

from asgiref.sync import sync_to_async

async def my_view(request):
    items = await sync_to_async(list)(Item.objects.filter(active=True))
    return JsonResponse({"items": [i.name for i in items]})

이게 결국 스레드풀로 던지는 거라 순수 async 이득이 없다. 외부 HTTP 호출이 섞여 있을 때만 의미가 있음.

결론적으로 Django 3.0의 async는 "앞으로 갈 방향"을 열어둔 것. 실무에서 async 필요하면 아직은 FastAPI나 aiohttp 쓰는 게 낫다. 전체 스택이 async-ready 될 때까지는 3-4년 더 걸릴 듯하다.

업그레이드 자체는 2.2 LTS에서 3.0으로 가도 큰 이슈는 없었음. Python 3.5 지원 끊긴 것 정도.