throttled-py의 관측가능성 확보 (2) - 비동기 시스템 구현과, 버그수정, 문서화

이 글은 시리즈의 두 번째 편입니다.

들어가며

이전 편에서 동기 Hook 시스템(PR #125)을 머지한 과정을 다뤘습니다. 이번 편에서는 비동기 확장 작업 중 발견한 이중 실행 버그, 그리고 머지 후 코드를 다시 읽다가 발견한 '조용한 실패' 문제를 직접 이슈로 제기하고 해결한 과정을 이야기합니다.

이 글에서 다루는 것:

비동기 확장은 쉬운 줄 알았으나...

해결했던 이슈 #37의 로드맵 중 하나였고 설계 방향은 명확했기에 제가 바로 이슈를 잡아서 비동기 버전도 빠르게 초안을 구현 했었습니다.

# Sync Hook
class Hook(abc.ABC):
    def on_limit(self, call_next: Callable[[], RateLimitResult], context) -> RateLimitResult: ...

# Async Hook은 call_next 타입만 Awaitable로 변경
class AsyncHook(abc.ABC):
    async def on_limit(self, call_next: Callable[[], Awaitable[RateLimitResult]], context) -> RateLimitResult: ...

HookContext는 sync/async 공유, 체인 빌더도 sync 버전과 동일한 역순 체이닝입니다. 처음에는 AsyncHook, build_async_hook_chain이라 이름 붙였지만, 리뷰로 Async 접두사를 빼는 것이 좋겠다는 피드백을 주었습니다. throttled.asyncio라는 패키지 자체가 이미 async 네임스페이스이므로 sync와 async 모듈에서도 같은 방식을 써서 일관성을 갖추자는 이유였습니다. AsyncThrottled이 없는 것 처럼 말이죠.

# sync
from throttled.hooks import Hook, build_hook_chain
# async , 같은 이름, 다른 모듈
from throttled.asyncio.hooks import Hook, build_hook_chain

테스트로 로직도 보강하여 머지까지 완료했기에 끝났구나 생각했습니다.

그런데 코드를 다시 읽다가 예상치 못한 버그를 발견했습니다.

1. 이중 실행 버그 발견

문제는 build_hook_chain의 예외 핸들러에 있었습니다. sync 버전(PR #125)에 이미 존재하던 버그를, async 구현 과정에서 비로소 발견한 것입니다.

버그가 있던 코드

def make_chain(h, next_fn):
    def chain_fn():
        try:
            return h.on_limit(next_fn, context)
        except Exception:
            return next_fn()  # hook 실패 시 스킵
    return chain_fn

이 상황에서 hook이 call_next()를 호출한 뒤에 예외를 던지면 어떻게 될까요?

class FailAfterHook:
    def on_limit(self, call_next, context):
        result = call_next()        # do_limit 실행 (1회, quota 소모)
        raise RuntimeError("후처리에서 터짐")

except 블록이 next_fn()다시 호출합니다. 즉 rate limiter가 두 번 실행되어 quota가 2배 소모되고 Redis 환경에서는 불필요한 네트워크 왕복도 발생합니다.

이 버그는 hook이 정상 동작할 때는 절대 발현되지 않습니다. call_next() 호출 후 후처리에서 예외가 발생하는 드문 경우에만 트리거됩니다. 그래서 첫 PR 리뷰에서도 놓쳤습니다.

오픈소스 사례를 살펴봅시다

먼저 다른 프로젝트들이 이 문제를 어떻게 처리하는지 조사했습니다. 유명한 프레임워크들은 예외가 발생을 어떻게 처리하는지 위주로 살펴보았고, OpenTelemetry는 스펙을 먼저 보기로 결정했습니다.

웹 프레임워크의 구현사례

먼저 유명 프레임워크들의 전략을 정리하면 아래와 같았습니다. Starlette는 async streaming에서의 이중 전파라는 구현 버그를 막도록 설계되었고, Django는 프레임워크 아키텍처 차원에서 모든 request 예외를 HTTP response로 변환하는 방식을 택했죠.

프로젝트 이름 예외처리 전략 기록을 남기는가?
Starlette 전파(double-raise 방지) X
Django 별도 응답으로 생성(exception → response 변환) O (log_response)

OpenTelemetry 설계방침 살펴보기

OTel의 구현방침은 "계측 코드가 사용자 앱을 죽여서는 안 된다"는 정책 원칙입니다.

throttled-py의 hook은 어디까지느 rate limiter의 부가 기능이죠. 그러니 hook의 실패가 본체(rate limiting)를 죽여서는 안 되고, 그렇다고 실패를 조용히 묻어서도 안 됩니다. 그래서 저는 OpenTelemetry 설계방침을 따르고, 구현방식은 잘 참고해두는 것으로 갈무리 지었습니다.

2. tracked_next 클로저를 활용한 상태 추적

지난번 클로저 구현에 이어 tracked_next 라는 객체를 추가 설계했습니다. call_next()를 감싸서 호출 여부를 추적하고 결과를 기록하기 위함이었습니다.

def chain_fn():
    next_called = False
    next_result = None

    def tracked_next():
        nonlocal next_called, next_result
        next_result = next_fn()
        next_called = True      # next_fn() 성공 후에 설정
        return next_result

    try:
        return h.on_limit(tracked_next, context)
    except Exception:
        if next_called:
            return next_result  # 이미 실행됨 → 캐시 반환
        return next_fn()         # 아직 실행 전 → hook 스킵

하지만 잠재적인 문제가 숨어있었습니다.

next_called 설정 순서 함정

처음에는 next_called = Truenext_fn() 호출 전에 설정했습니다. 이렇게 하면 next_fn() 자체가 예외를 던질 때(예: Redis 연결 에러) next_calledTrue인데 next_resultNone이 됩니다. except 블록이 None을 반환하게 됩니다.

시나리오 호출 전 설정 호출 후 설정 (최종)
hook이 call_next() 전에 예외 next_fn() 호출 next_fn() 호출
hook이 call_next() 후에 예외 캐시 반환 캐시 반환
next_fn() 자체가 예외 None 반환 예외 전파

즉시 테스트해보고, 호출 후에 설정해야 store 에러가 정상적으로 전파되는 것을 파악했습니다.

3. '조용한 실패' 발견

또한 코드를 다시 읽다가 두 가지 이상한 점을 발견했습니다. Hook 객체 검증과 예외 무시가 그것입니다.

아무 객체나 hook으로 넣어도 에러가 나지 않습니다

배포해보고 혹시 몰라 문자열을 넣었더니, 에러를 그냥 삼켜버린 채 넘어가는 것도 확인되었습니다.

throttle = Throttled(
    key="/api/test",
    quota=per_sec(10),
    hooks=["not a hook"],  # 문자열을 hook으로?
)
result = throttle.limit()  # 에러 없이 성공합니다

더 심각한 것은 sync/async hook 교차 사용이었습니다.

# async 모듈의 Hook을 sync Throttled에 넣으면?
from throttled.asyncio.hooks import Hook  # async Hook

class ObservingHook(Hook):
    async def on_limit(self, call_next, context) -> RateLimitResult:
        result = await call_next()
        return result

throttle = Throttled(key="/api/test", quota=per_sec(10), hooks=[ObservingHook()])
result = throttle.limit()  # 에러 없이 통과, hook만 조용히 무시됩니다

on_limitasync def이므로 코루틴 객체를 반환하지만, 동기 컨텍스트에서는 await 없이 그대로 버려집니다. 자칫 잘못 호출되면 RuntimeWarning: coroutine was never awaited를 남기지만, 이를 catch하는 쪽에서는 전혀 엉뚱한 에러인데다가, hook이 왜 동작하지 않는지 직접적인 로그가 없어 디버깅하기 전까지는 추적이 매우 어려울 것이란 사실을 깨달았습니다.

except Exception의 두 얼굴

except Exception은 hook 실패가 rate limiter 본체를 죽이지 않도록 하기 위한 방어적 처리입니다. 하지만 잡은 예외를 로그 없이 삼키다 보니, 타입 수준의 프로그래밍 실수까지 조용히 묻어버리는 부작용이 생겼습니다. 런타임에 "훅이 불안정하다"와 "훅 타입이 틀렸다"를 구분할 수 없습니다.

이슈 제기

여기까지 분석을 마치고, 이슈 #134를 작성했습니다. 단순히 "이거 버그 같아요"가 아니라, 메인테이너가 판단할 수 있는 근거를 제공하기 위해 아래와 같은 형태의 리포트로 의사소통 하였습니다.

먼저 문제를 "타입 검증이 없다"와 "로깅이 없다"로 나눠서 기술했습니다. hook이 조용히 무시되는 이슈는 시나리오와 테스트 케이스, 해결책 예시로 제안했고, 로거 네이밍 정책처럼 제가 결정할 수 없는 부분은 메인테이너에게 질문으로 남겼습니다.

문제 해결하기

메인테이너의 피드백을 반영하여 타입 검증과 tuple 불변 저장의 형태로 구현했습니다.

_ALLOWED_HOOK_TYPES 클래스 변수

모듈명이 같더라도 import 되는 네임스페이스가 다르기 때문에 사용했습니다. 또한 메인테이너가 throttled-py에서 즐겨쓰는 패턴이기도 했기에 리뷰를 받고 반영했습니다.

class BaseThrottledMixin(Generic[_LimiterT, _HookT]):
    _ALLOWED_HOOK_TYPES: tuple[type[HookP], ...] = ()

# sync Throttled → Hook만 허용
class BaseThrottled(BaseThrottledMixin[BaseRateLimiter, Hook]):
    _ALLOWED_HOOK_TYPES = (Hook,)

# async Throttled → async 모듈의 Hook만 허용
class Throttled(BaseThrottled):  # throttled/asyncio/throttled.py
    _ALLOWED_HOOK_TYPES = (Hook,)  # throttled.asyncio.hooks.Hook

_validate_hooks()의 fail-fast 구현

논의 결과, hook 사용 시의 실수는 바로 TypeError를 리턴하게 구성하였습니다. hook 시스템이라는 약속을 두었으니, 제대로 구동되는 것을 검증하는 건 이쪽의 책임이라 생각했습니다.

def _validate_hooks(self, hooks: Sequence[_HookT] | None) -> tuple[_HookT, ...]:
    if not hooks:
        return ()
    for hook in hooks:
        if not isinstance(hook, self._ALLOWED_HOOK_TYPES):
            expected = ", ".join(t.__name__ for t in self._ALLOWED_HOOK_TYPES)
            raise TypeError(
                f"Invalid hook type: {type(hook).__name__}. Expected: {expected}"
            )
    return tuple(hooks)

이제 잘못된 타입을 넣으면 즉시 TypeError가 발생합니다.

>>> Throttled(key="k", quota=per_sec(1), hooks=[NoopHook()])
TypeError: Invalid hook type: NoopHook. Expected: Hook

tuple 불변 저장

Throttled 객체 선언 시 hook 을 받을 땐 파라미터 Sequence[_HookT]로 유연하게 받되, 내부에는 tuple로 저장하도록 구성했습니다.

self._hooks: tuple[_HookT, ...] = self._validate_hooks(hooks)

tuple이므로 생성 시점에 _validate_hooks()를 통과한 hook만 담기고, 이후에는 실수로 쉽게 hook 을 추가하지 못하도록 구성했습니다.

logger.exception() 추가

hook chain의 except Exception 블록에 모듈별 로거를 추가했습니다. 예외를 삼키되, 어떤 hook이 왜 실패했는지는 로그로 남깁니다.

logger: logging.Logger = logging.getLogger(__name__)

# build_hook_chain 내부
except Exception:
    logger.exception("Hook %r raised during on_limit", h)
로깅 처리에 왜 % 템플릿을 쓰나요?

로깅 문자열 처리에 % 템플릿을 쓰는 이유는 성능이 아니라, logging API의 msg/args 분리 설계를 따르기 위해서입니다.
자세한 내용은 해당 글을 참고 해주세요.

이를 작업한 PR #135 또한 머지되었습니다.

찜찜한 것도 다시 봅시다

또한 제가 추가한 CONTRIBUTING.md도 업데이트를 거쳤습니다.

처음 PR올릴 때, main 과의 싱크가 맞지 않아 메인테이너의 요청으로 force-push를 진행한 적이 있었습니다. 이것때문에 오해하여 CONTRIBUTING.md 파일에 커밋이력을 모두 지운 채로 PR을 올리도록 가이드를 작성했었죠. 그렇지만, 메인테이너도 그렇게 작업하지 않고 아무리 생각해도 커밋이력을 지우는 게 너무 이상했습니다. 그래서 이 부분을 바로잡기 위해 이슈를 생성했습니다.

데이터들을 모으고 PR을 보내자, 메인테이너가 빠르게 리뷰 후 머지를 진행했습니다. 고맙다는 인사와 함께말이죠.

메인테이너의 메시지

The earlier guidance about squashing on the contributor branch was a misunderstanding on our side. Contributors do not need to squash before pushing or when posting follow-up commits during review. Since we squash-merge PRs at the end, keeping branch commits during review is completely fine and usually makes the review history easier to follow.

처음 코드를 작성할 때도 느꼈지만, 모르는건 역시 바로바로 물어서 해결해야된단 걸 다시금 깨달았습니다.

끝으로 - 코드에 애착이 생겨요

이 글을 쓰는 현재 시점으로 총 5개의 PR이 머지되었습니다.

five-merged-pr-in-throttled-py

처음엔 단순한 흥미로 메인테이너가 올린 이슈(#37)를 구현했습니다. 생각보다 큰 이슈였기에 55개의 리뷰 코멘트를 통과하면서 이 프로젝트만이 가지는 기준 과 복잡하지만 매우 우아한 코드 패턴, 그리고 코드 스타일 도 배웠습니다. 그러면서 동시에 헷갈렸던 모킹 테스트 기법도 실전으로 배웠죠. 또한 제가 만든 코드를 다시 읽다가 문제를 발견하고, 설계 옵션을 분석하고, 이슈를 직접 제기하고, 구현까지 완료했습니다. 귀찮을 수도 있는 문서작업과 프로세스에서도 이상함이 느껴지면 바로 보강했습니다. 오류인 것 같으면 바로 질문했습니다. 이 과정은 누가 시킨 것이 아닙니다. 문제를 발견하고 제안하는 사람으로 몰입하고, 다른 사람이 이를 쓸 수 있게 설계하는 것이 즐거웠기에 할 수 있었습니다.

이런 부분을 알아주어서였을까요? 메인테이너가 PR 리뷰에서 "프로젝트에 메인테이너로 초대하겠다" 고 말해주었습니다. 단순히 코드를 보낸 사람이 아니라, 프로젝트를 함께 돌보는 사람으로 신뢰받았다는 점이 크게 와닿았습니다.

the-suggestion

프로젝트를 우연히 선택한 후, 내 일처럼 정성껏 짠 코드가 수많은 사람에게 영향을 주었다는 것은 뿌듯함과 동시에 책임감이 더해지기도 합니다. 이런 기여의 마음 중심에는 프로젝트를 자기 것처럼 바라보는 애정이 있어야 할 수 있지 않을까 합니다. 저는 마침내 파이썬 생태계에 작게나마 기여할 수 있게 되어 너무너무 기쁩니다. 🥰

Hook 시스템에 대한 구현, 문제 수정, 추가 기여 이야기는 여기서 마무리하겠습니다. 앞으로의 throttled-py 기여 여정도 기회가 있으면 지속적으로 공개하겠습니다.

긴 글 읽어주셔서 감사합니다.

참고자료