throttled-py의 관측가능성 확보 (1) - Hook 시스템 설계부터 55개 리뷰 통과까지

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

오픈소스 기여모임을 소개합니다!

오픈소스 기여모임을 통해 이런 기회를 얻게해주셔서 감사합니다. 이 모임이 아니었더라면 저는 이 작업을 시작조차 못했을 거에요.

들어가며

오픈소스 기여를 해보고 싶다는 생각은 오래전부터 있었습니다. 하지만 "어떤 프로젝트에?" 라는 질문 앞에서 늘 멈추곤 했습니다. 이 글에서는 throttled-py를 왜 선택했고, 어떤 이슈를 잡았으며, 미들웨어 패턴 기반 Hook 시스템을 어떻게 구현해서 머지시켰는지를 이야기합니다.

이 글에서 다루는 것:

1. 왜 throttled-py 인가

프로젝트를 고를 때 기준은 꽤 실용적이었습니다.

무엇보다, "이 프로젝트에 기여하면 성장할 수 있겠다"는 느낌이 있었습니다. 잘 정리된 코드베이스, 테스트 커버리지에 대한 기준이 있는 환경에서 리뷰를 받으면 분명 배울 것이 있으리라 생각했습니다.

2. AI를 활용한 코드 분석

코드를 읽기 시작하면서 가장 먼저 부딪힌 것은 라이브러리의 아키텍처 패턴이었습니다. import throttled 한 줄이 메타클래스를 통해 rate limiter 구현체들을 자동 등록하고, 믹스인으로 sync/async 양쪽의 공통 로직을 공유하는 구조였습니다.

이 두 패턴을 이해하지 않으면 코드 한 줄 쓸 수 없었기에, Claude와 Codex를 활용해 코드베이스를 분석했습니다. AI에게 소스 파일을 넘기고 "이 코드의 import 체인을 추적해줘", "이 메타클래스가 하는 일을 설명해줘" 같은 질문을 던졌습니다. 하지만 AI가 출력한 내용을 절대 신뢰하지 않았고, 직접 디버거를 붙여 검증하면서 정리했습니다.

이 과정에서 정리한 내용은 아래 글을 참고해주세요:

코드 구조가 머릿속에 잡힌 뒤에야, 이슈를 골라 작업을 시작할 수 있었습니다.

3. Issue #37 선택

프로젝트를 둘러보다 눈에 들어온 이슈가 있었습니다.

[#37] Observability via OpenTelemetry - "Expose granular rate limiting metrics for monitoring and alerting."

2025년 4월에 메인테이너가 직접 올린 feature request였고, 약 9개월간 아무도 손대지 않은 상태였습니다. 이 이슈를 선택한 이유는 세 가지입니다.

  1. 범위가 명확했습니다. "OTel metrics를 붙여라"라는 요구사항이 우선 쉬워보였습니다. 관측 가능성을 확보하기 위한 데이터만 추려내면 된다고 생각했죠.
  2. 메인테이너가 직접 올린 이슈였습니다. feature request를 메인테이너 본인이 만들었으므로, 방향만 맞으면 리뷰와 머지까지 이어질 가능성이 높았습니다.
  3. 해보고 싶은 분야였습니다. OpenTelemetry SDK는 실무 경험이 있었고 관심있게 살펴보았기 때문에 선택하였습니다.

결론부터 말하면, 그 자신감은 반만 맞았습니다. 코드를 작성하는 건 할 수 있었지만, 그 프로젝트의 스타일과 메인테이너의 수준까지 올리는 것은 전혀 다른 문제였습니다.

4. 첫번째 이슈제기

부풀어오른 마음으로 자신만만하게 첫 제안을 올립니다.

4.1. 첫 제안

의사소통을 위해 제 설계를 이슈 코멘트로 설계 방향을 먼저 공유했습니다. 처음 제안한 Hook은 단순한 데이터 조회 객체였습니다.

from throttled import Throttled, per_sec, Hook, HookContext

class LoggingHook(Hook):
    def on_limit(self, context: HookContext) -> None:
        status = "denied" if context.limited else "allowed"
        print(f"[{context.key}] {status} - remaining: {context.remaining}")

throttle = Throttled(key="/api/users", quota=per_sec(10), hooks=[LoggingHook()])

rate limit 실행이 끝난 뒤 status를 함수에서 처리하는 방식입니다. 당시에는 이 구조로 충분하다고 생각했습니다. 결과를 관찰할 수만 있으면 OTel 메트릭을 보내는 구조를 잡을 수 있겠거니 하고 생각했어요.

하지만 메인테이너는 이슈 코멘트에서 이 설계의 한계를 짚어주었습니다:

훅 시스템 리뷰

"for scenarios like timing statistics and exception handling, Hook should be able to wrap the entire limit like a decorator."

hook이 rate limit 실행 전후를 감쌀 수 없으면, duration 측정이나 예외처리 같은 시나리오를 다룰 수 없다는 것이었습니다. 그리고 middleware 패턴(Chain of Responsibility) 기반의 의사코드를 직접 제시해주었습니다. next_fn을 호출하면 다음 hook으로 넘어가고, 최종적으로 실제 rate limiter가 실행되는 구조였습니다. 이 피드백을 받아 설계를 전면 재구성했습니다.

4.2. 미들웨어 패턴 (양파 모델)

메인테이너가 제안한 미들웨어 패턴은 Django middleware나 Express.js와 동일한 "양파 모델(onion model)"입니다. 각 hook이 rate limit 호출을 감싸서 before/after 로직을 삽입합니다:

maintainer-suggestion

4.3. Hook 인터페이스

메인테이너의 의사코드를 바탕으로 재설계한 Hook 인터페이스입니다.

class Hook(abc.ABC):
    @abc.abstractmethod
    def on_limit(
        self,
        call_next: Callable[[], RateLimitResult],
        context: HookContext,
    ) -> RateLimitResult:
        raise NotImplementedError

call_next를 호출하면 다음 hook이 실행되고, 최종적으로 실제 rate limiter가 호출됩니다. HookContextfrozen=True dataclass로 key, cost, algorithm, store_type 메타데이터를 담습니다.

4.4. build_hook_chain - 체인 구성

def build_hook_chain(hooks, do_limit, context):
    if not hooks:
        return do_limit

    chain = do_limit
    for hook in reversed(hooks):
        post_chain = chain

        def make_chain(h, next_fn):
            def chain_fn():
                try:
                    return h.on_limit(next_fn, context)
                except Exception:
                    return next_fn()
            return chain_fn

        chain = make_chain(hook, post_chain)
    return chain

reversed(hooks)로 뒤에서부터 감싸서 hooks = [A, B]일 때 A.on_limit(B.on_limit(do_limit)) 구조가 됩니다. make_chain 팩토리 함수가 필요한 이유는 Python의 late binding closure 문제 때문입니다. 루프 안에서 직접 클로저를 만들면 모든 클로저가 자연스럽게 마지막 hook만 참조하게 되죠.

이 함수의 구조에 대해 좀더 궁금하신 분은 부록: 클로저, 코드 객체, 프레임을 참고해주세요.

4.5. 예외 안전성 (resilience)

hook에서 예외가 발생해도 체인이 끊어지지 않도록 했습니다. hook은 관측 도구이지 rate limiter의 핵심 로직이 아닙니다. OTelHook이 실패했다고 rate limiting 자체가 멈추면 안 되기 때문이죠.

4.6. OTelHook 구현

class OTelHook(OTelHookBase, Hook):
    def on_limit(self, call_next, context) -> RateLimitResult:
        start = time.perf_counter()
        result = call_next()
        duration = time.perf_counter() - start
        self._record_metrics(context, result, duration=duration)
        return result

call_next() 전후로 시간을 측정하고, throttled.requests (Counter)와 throttled.duration (Histogram) 두 메트릭을 기록합니다. 설계 자체에는 자신이 있었습니다. 문제는 그 다음이었습니다.

5. 55개 리뷰 코멘트에서 배운 것

2026년 1월 23일, PR #125를 처음 올렸습니다. 20개 파일, +1,290줄. 나름 꼼꼼하게 준비했다고 생각했습니다.

다음 날 아침, 메인테이너의 리뷰가 도착했습니다. 최종 merge까지 4일간, 총 55개의 리뷰 코멘트가 달렸습니다. 처음에는 부담스러웠지만, 하나하나 읽어보니 모든 코멘트에 이유가 있었습니다. 그리고 그것은 단순한 코드 수정이 아니라 사고방식의 교정이었습니다.

리뷰 (1) - 아키텍처 피드백

"Hooks can be used not only for observation tracking but also for retries and exception handling."

Hook이 단순한 observability 도구가 아니라 범용 middleware라는 관점입니다. 그런 부분을 생각해서 재설계 했어야 했죠.

"add_hook and remove_hook can remove hooks. Hooks can only be specified during the Throttled initialization phase."

제가 만든 동적 hook 추가/삭제라는 공개 API를 제거해달란 피드백이었습니다. 초기화 시점에 immutable하게 설정하는 것이 더 안전하고 예측 가능하단 설계방안을 배웠습니다. 첫 설계때만 닫아놓고 필요할 때 다른 방안을 모색하면 되니까요.

"Hooks should be used to track Throttled.limit, not self.limiter.limit."

Hook이 감싸야 하는 범위에 대한 지적이었습니다. blocking/retry 로직을 포함한 전체 Throttled.limit을 감싸야, hook에서 측정하는 duration이 사용자 체감 시간과 일치한단 거였죠.

리뷰 (2) - 네이밍과 DI

"From the perspective of execution order, naming it post_chain would be more appropriate."

변수명 하나에 대한 코멘트입니다. 코드가 동작하면 되는 게 아니라, 읽는 사람이 의도를 바로 파악할 수 있어야 한단 걸 다시금 체감했습니다.

"meter should be passed as a constructor parameter."

전역 meter를 가져오는 방식에서 constructor injection으로 바꾸어달란 피드백 이었습니다. 너무나 명확해서 바로 고쳤고, 테스트 용이성과 유연성을 바로 이해할 수 있었습니다.

코드 스타일

"Comment styles should always use reStructuredText."

이 프로젝트는 Sphinx autodoc을 쓰기 때문에 Google style의 주석 형태가(Args:/Returns:) 아니라 :param:/:return:을 써야 했습니다.

"The test logic seems a bit complex. It's recommended to test using a mock meter."

OpenTelemetry SDK를 직접 쓰며 테스트하는 것 대신 MagicMock으로 단순화하라는 피드백입니다. 여기선 외부 의존성의 인터페이스를 이해하고 모킹하면 불필요한 준비를 덜어내고 테스트가 간편해질 수 있다는 걸 몸소 체득했습니다. 테스트 더블이 감이 안오면 너무나 막연했고 심지어 예측가능한 로직을 모킹하려는 식으로 접근했는데(!) 이제는 그러지 않을 수 있겠더라구요.

리뷰를 대하는 자세

4일간의 리뷰 과정에서 가장 중요했던 건 모든 코멘트에 성실하게 응답하는 것이었습니다. 각 피드백에 대해 어떤 커밋에서 수정했는지 링크를 달고, 의도를 설명하고, 질문이 있으면 솔직하게 물었습니다.

심지어 pyproject.tomlper-file-ignores에서 tests/*.py 패턴이 하위 디렉토리를 커버하지 못한다는 점을 발견해 역으로 질문하니, 메인테이너가 "You are right, it should be tests/**/*.py"라고 확인해주었습니다. 사소한 줄 알았는데 이런 것 하나하나를 물어봐야 알 수 있다는 걸 깨달았습니다.

2026년 1월 27일, 메인테이너가 남긴 메시지입니다.

"LGTM. Thank you for your landmark contribution to this issue!"

이라는 말을 토대로 오픈소스를 향한 제 첫 PR이 머지되었습니다. 🥳

my-very-first-merge

리뷰 코멘트를 처리하면서 계속 아쉬웠던 것이 있었습니다. 기여 가이드가 없다는 것이었는데요.

rst docstring을 써야 한다는 것, 테스트를 class 기반으로 써야 한다는 것, Conventional Commits를 따라야 한다는 것을 리뷰 과정에서 별도로 배웠습니다. 조직에 온보딩 되고서도 문서 작성을 해본 경험이 있었던지라, 현재 오픈소스 규모 정도라면 기여 가이드를 빠르게 할 수 있겠단 생각이 들었어요.

PR #125가 merge되기 전에 이슈 #127을 열었습니다.

"While working on #125, I felt that a CONTRIBUTING.md would be helpful for new contributors like myself."

345줄의 CONTRIBUTING.md와 PR template을 포함한 PR #130을 제출했습니다. 제가 리뷰에서 겪은 아쉬운 점을 정리한 문서였습니다.

사실 메인테이너가 PR #125 리뷰 중에 이런 말을 한 적이 있습니다:

"This information can also be placed in CONTRIBUTING.md"

메인테이너가 먼저 필요성을 인정한 셈입니다.

첫 기여가 꼭 코드일 필요는 없습니다. 리뷰 과정에서 발견한 프로젝트의 간극을 문서로 메우는 것도 가치 있는 기여입니다. 그리고 이런 기여가 메인테이너와의 신뢰를 형성하는 데 큰 역할을 했습니다.

마치며

첫 PR은 4일, 55개의 리뷰 코멘트가 필요했습니다. "코드를 작성하는 것"과 "오픈소스에 기여하는 것"은 다른 일이라는 걸 체감했습니다. 프로젝트의 문화를 이해하고, 메인테이너와 소통하고, 피드백을 흡수하며 함께 더 좋은 코드를 만들어가는 과정에서 정말 많은 걸 배웠다고 생각했습니다.

다음 편에서는 설계한 Hook 시스템의 비동기 버전을 구현한 내용을 말씀드릴 예정입니다. 그리고 비동기 버전 구현 중 이중 실행 버그, 타입 검증 이슈를 직접 제기하고 해결한 과정을 이야기합니다.

참고자료