본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 01. 01:32

Go 엔지니어가 첫 번째 실제 Python 서비스를 구축하며 배운 것들

요약

Go 엔지니어가 Python을 사용하여 멱등성 작업 큐(idempotent task queue)를 직접 구축하며 경험한 기술적 통찰을 다룹니다. Postgres 기반의 워커, 재시도 로직, 관찰 가능성 확보 과정을 통해 언어 전환 시의 아키텍처 전이와 성능 병목 지점을 분석합니다.

핵심 포인트

  • Postgres의 SELECT ... FOR UPDATE SKIP LOCKED를 활용한 작업 처리
  • 상태 머신(State Machine) 모델의 언어 간 일관된 적용
  • Python 환경에서의 Postgres 커넥션 풀 경합 문제 확인
  • 헥사고날 아키텍처를 통한 코드 구조 유지

저는 지난 3년 동안 Go 언어로 코드를 작성하며 시간을 보냈습니다. NPCI에서는 잘못된 기본 설정(defaults)이 실제 금전적 손실로 이어질 수 있는 결제 시스템을 구축했습니다. ShopUp에서는 정확성, 속도, 그리고 관찰 가능성(observability) 순으로 반드시 지켜져야 하는 백엔드 서비스(backend services)를 다루고 있습니다.

이번 주말, 저는 저의 첫 번째 실제 Python 서비스인 멱등성 작업 큐(idempotent task queue)를 구축했습니다. 이 서비스는 Postgres 기반의 워커(worker), 재시도(retries), 데드 레터 큐(dead-letter queue), 완전한 Prometheus 관찰 가능성(observability), 그리고 16개의 테스트 스위트(test suite)를 갖추고 있습니다. mkdir부터 GitHub 릴리스(release)까지 단 8시간 만에 완료했습니다.

저를 놀라게 했던 것들에 대해 써보고 싶습니다. 그중 일부는 언어(language)에 관한 것이었지만, 대부분은 그렇지 않았습니다.

내가 구축한 것

저장소(repo)는 여기에 있습니다. 요약하자면 다음과 같습니다: Idempotency-Key 헤더(Stripe 스타일)와 함께 작업을 수락하고 이를 Postgres에 영속화(persist)하는 HTTP API, 그리고 SELECT ... FOR UPDATE SKIP LOCKED를 사용하여 작업을 가져와 실행한 뒤 결과를 다시 기록하는 별도의 비동기 워커(async worker) 프로세스입니다.

동일한 키(key) + 동일한 본문(body)은 캐시된 응답을 반환합니다. 동일한 키 + 다른 본문은 422를 반환합니다. 실패한 작업은 max_attempts까지 재시도하며, 할당된 횟수를 모두 소진한 작업은 운영자의 검토를 위해 DEAD_LETTER 상태로 이동합니다.

이것은 대부분의 결제 시스템이 구축되는 패턴입니다. 저는 NPCI에서 Go 라이브러리를 통해 이 패턴을 사용해 왔지만, 처음부터 직접 구현해 본 적은 없었습니다.

제 MacBook Air M2에서의 벤치마크(Benchmark) 결과: 동시성(concurrency) 50에서 GET /tasks 호출 시 590 req/s, p50 67ms, p99 228ms를 기록했습니다. 지연 시간(latency)의 꼬리(tail) 부분은 Postgres 커넥션 풀(connection-pool) 경합(contention)이 지배하고 있습니다. 풀 크기(pool size)가 10인데 동시 요청이 50개라면 40개는 대기 상태가 됩니다. 이것은 Python의 문제가 아닙니다. 동일한 풀 설정이라면 어떤 언어에서도 겪게 될 동일한 문제입니다. 운영 환경(Production)에서의 해결책은 PgBouncer를 사용하거나 더 큰 풀을 사용하는 것입니다.

Go에서 전이된 것들

예상했던 것보다 더 많았습니다.

상태 머신 (State Machine)에 대한 멘탈 모델 (Mental Model)은 깔끔하게 전이되었습니다. 작업(Task)은 PENDING 상태이며, 워커(Worker)가 이를 점유하면 PROCESSING이 되고, SUCCEEDED, FAILED, 또는 DEAD_LETTER로 종료됩니다. 상태 가드 (State Guards)는 Go에서 구현하던 방식과 동일하게 코드에서 강제됩니다. 즉, 누군가 잘못된 전이 (Transition)를 시도하면 409 에러를 반환하는 식입니다. SQLAlchemy의 Enum 타입은 Postgres의 task_status Enum으로 매핑되므로, 데이터베이스 수준에서도 잘못된 상태를 거부합니다. 이는 제가 Go에서 구축했을 때와 동일한 이중 안전장치 (Belt-and-braces)입니다.

헥사고날 아키텍처 (Hexagonal Architecture)는 일대일로 매핑됩니다. 모델은 하나의 패키지에, 영속성 (Persistence)은 다른 패키지에, 핸들러 (Handler)는 세 번째 패키지에, 그리고 전송 계층 (Transport, HTTP)은 가장 바깥쪽 (Edge)에 위치합니다. Pydantic 모델은 검증 태그 (Validation tags)가 붙은 Go의 구조체 (Struct) 역할을 수행하며, SQLAlchemy ORM 모델은 sqlx의 로우 타입 (Row types) 역할을 수행합니다. 경계 (Boundaries)는 동일하며, 오직 문법만 다를 뿐입니다.

비동기 우선 (Async-first) 사고방식은 큰 마찰 없이 전이되었습니다. 저는 이 부분이 가장 어려울 것이라고 예상했습니다. 고루틴 (Goroutine)은 네이티브하게 느껴지는 반면, Python의 이벤트 루프 (Event Loop)는 신경 써야 하는 대상이기 때문입니다. 실제로 httpx와 SQLAlchemy 2.0의 비동기 지원을 결합한 asyncio를 사용해 보니, Go와 거의 동일하게 읽히는 코드를 작성할 수 있었습니다. 가장 큰 차이점은 암시적인 고루틴 스케줄링 (Implicit goroutine scheduling) 대신 모든 곳에 await를 사용해야 한다는 점입니다.

계약으로서의 데이터베이스 트랜잭션 (Database Transactions). 이 부분에서 가장 편안함을 느꼈습니다. 모든 멱등성 엔드포인트 (Idempotent endpoint)에서 발생하는 레이스 컨디션 (Race condition) — 즉, 동일한 키를 가진 두 요청이 존재 여부 확인을 통과해 경합하는 상황 — 은 두 언어 모두에서 동일한 기본 요소로 처리됩니다. 데이터베이스의 유니크 제약 조건 (Unique constraint), 충돌 시 발생하는 IntegrityError, 그리고 승자를 찾기 위한 재조회 (Re-read) 방식입니다. Postgres의 정합성 보장 (Correctness guarantees)은 호출하는 언어가 무엇인지 상관하지 않습니다.

Python에서 놀라웠던 점

이 부분은 제가 경고를 받았던 대목입니다. 하지만 대부분의 경고는 틀렸습니다.

의존성 주입 (Dependency injection)은 Go의 인터페이스 (Interfaces)보다 무겁게 느껴지지만, 이는 처음뿐입니다. Go에서는 생성자 (Constructor)를 작성하고 인터페이스를 받으면 끝납니다. FastAPI에서는 Depends() 함수를 작성하고, 이를 Annotated로 감싸고, 가독성을 위해 타입 별칭 (Type-alias)을 만든 다음, 모든 핸들러 시그니처 (Handler signature)에서 참조해야 합니다. 더 장황합니다 (Verbose). 하지만 이를 수십 번 작성해 본 후, 한 가지를 깨달았습니다. 테스트 인체공학 (Test ergonomics)이 실제로 더 낫다는 점입니다.

app.dependency_overrides[get_db] = lambda: fake_db 방식은 Go에서 테스트 컨테이너 (Test container)를 연결하는 것보다 깔끔합니다. 이러한 장황함은 무언가를 얻기 위한 대가입니다.

mypy --strict는 컴파일러 (Compiler)입니다 — 만약 당신이 그것을 실행한다면 말이죠. 이 부분은 Go 엔지니어들이 과소평가하는 지점입니다. mypy --strict와 Pydantic, 그리고 ruff를 결합한 현대적인 Python은 Go 컴파일러가 잡아낼 수 있는 거의 모든 것을 잡아냅니다. 문제는 "당신이 그것을 실행하느냐"에 달려 있습니다. Go는 모든 빌드 시 이를 강제하지만, Python은 pre-commit hooks와 CI를 설정하는 것은 사용자의 몫입니다. 저는 첫날에 hooks를 구축했고, 그것은 몇 시간 만에 제 값을 했습니다.

처음부터 새로 배워야 했던 것들

목록은 짧지만, 각 항목은 실제적인 작업이었습니다:

Python의 비동기 (Async) 모델은 Go와 다릅니다. Goroutine은 선점형 (Preemptive)이며 런타임 (Runtime)에 의해 스케줄링되고 비용이 저렴합니다. Python의 코루틴 (Coroutines)은 협력형 (Cooperative)입니다 — 오직 await 지점에서만 제어권을 양보합니다. 만약 비동기 핸들러 내부에서 CPU 집약적 (CPU-bound)인 함수를 작성하면, 전체 이벤트 루프 (Event loop)가 차단됩니다. 이것이 바로 Go 런타임이 당신을 보호해 주는 종류의 문제입니다. Python에서는 이를 직접 알고 있어야 합니다.

커넥션 풀 (Connection pool) 크기 조정은 첫 순간부터 중요합니다. 첫 번째 부하 테스트 (hey -n 1000 -c 50)를 실행했을 때, 긴 지연 시간 꼬리 (Latency tail)를 목격했습니다. 저는 이를 거의 "Python이 느려서" 발생하는 문제로 치부할 뻔했습니다. 그러다 제 SQLAlchemy 풀 설정을 확인했습니다: pool_size=10. 동시성 (Concurrency)이 50일 때, 40개의 요청이 연결을 기다리고 있었습니다. 정확히 동일한 문제가 Go에도 존재합니다. 다만 NPCI에서 사용하던 프로덕션 프레임워크들은 이를 저를 위해 미리 튜닝해 두었을 뿐입니다. Python으로 처음부터 구축하면서, 프레임워크가 무엇을 하고 있었는지 강제로 배우게 되었습니다.

Alembic는 진심으로 go-migrate보다 낫습니다. ORM 모델과 실제 스키마를 비교(diffing)하여 마이그레이션을 자동 생성하는 워크플로우는 Go에는 딱히 없는 기능입니다. 저는 처음에는 회의적이었지만(자동 생성은 마법처럼 느껴지니까요), 적용하기 전에 생성된 SQL을 읽어보는 것이 적절한 안전장치 역할을 합니다. 다시 Go로 돌아가면 이 기능이 그리울 것 같습니다.

Pydantic-settings를 사용하면 설정(config) 문제는 더 이상 문제가 되지 않습니다. 타입 안정성(Type-safe)을 갖추고, 환경 변수 파일(.env)을 인식하며, 시작 시점에 검증(validated)됩니다. Go에는 Viper나 직접 구현한 구조체 언마샬링(struct unmarshaling)이 있지만, 그에 비하면 둘 다 임시방편(ad-hoc)처럼 느껴집니다.

다른 Go 엔지니어에게 해주고 싶은 말

첫날부터 내면화했더라면 좋았을 네 가지입니다:

  1. Python을 Go처럼 만들려고 하지 마세요. Python의 관용구(idioms) — Depends(), Annotated, Pydantic 모델, 비동기 컨텍스트 매니저(async context managers) — 에 몸을 맡기세요. 관용구와 싸우는 것은 일주일의 시간을 낭비할 뿐만 아니라, 다른 Python 엔지니어들이 싫어할 비관용적인(unidiomatic) 코드를 만들어냅니다.
  2. 단 한 줄의 비즈니스 로직을 작성하기 전에 mypy --strict와 pre-commit hooks를 설정하세요. 이것이 Go의 컴파일 타임 보장(compile-time guarantees)에 가장 가까워지는 방법입니다. 이것이 없다면, 당신은 타입 주석만 달린 JavaScript를 작성하고 있는 것과 다름없습니다.
  3. 기능(feature)을 만들기 전에 관측성(observability)을 구축하세요. 저는 structlog, OpenTelemetry, 그리고 Prometheus를 도입했습니다. Go 엔지니어들은 이를 본능적으로 알고 있지만, Python 튜토리얼에서는 이를 건너뜁니다.
  4. Python은 생각보다 작성하기는 빠르지만, 기대만큼 실행 속도가 빠르지는 않습니다. 제 벤치마크 결과는 초당 590개 요청(590 req/s)이었습니다. 동일한 하드웨어에서 Go로 구현했다면 그보다 3~5배는 더 빨랐을 것입니다. 대부분의 서비스에서는 이것이 중요하지 않습니다. 어차피 데이터베이스나 LLM API에서 병목 현상(bottleneck)이 발생할 것이기 때문입니다. 하지만 어떤 서비스들에게는 이것이 절대적으로 중요합니다. 당신이 무엇을 만들고 있는지 파악하세요.

저장소는 여기에 있습니다. FastAPI + 관측성(observability) + LLM 게이트웨이 프로토타입이 담긴 다른 저장소는 여기에 있습니다. 두 저장소 모두 제 GitHub에 고정되어 있습니다.

이 글이 도움이 되었다면 여기나 GitHub에서 저를 팔로우해 주세요. 이번 전환기 동안 매주 글을 작성할 예정입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0