[cpython] CPython 내부 들여다보기: logging.getLogger()는 어떻게 33% 더 빨라졌나?
PR 링크: python/cpython#150825 상태: Merged | 변경: +11 / -1
들어가며
파이썬 애플리케이션에서 logging.getLogger(name)은 가장 빈번하게 호출되는 함수 중 하나입니다. 대규모 시스템에서는 모듈 임포트 시점뿐만 아니라, 특정 함수 내부에서 로거를 동적으로 가져오는 경우도 많습니다.
기존의 logging.getLogger()는 호출될 때마다 전역 락(_lock)을 획득했습니다. 로거가 이미 존재하든 아니든 상관없이 말이죠. 하지만 실제 운영 환경에서 로거는 한 번 생성되면 프로세스가 종료될 때까지 유지되는 싱글톤(Singleton)에 가깝습니다. 즉, 99% 이상의 호출은 이미 존재하는 로거를 단순히 반환하는 작업임에도 불구하고, 매번 불필요한 락 오버헤드를 지불하고 있었던 것입니다.
이번에 분석할 gh-150825 PR은 바로 이 지점을 개선했습니다. 기존 로거를 조회할 때 락을 사용하지 않는 'Fast Path'를 도입하여, 성능을 약 33% 향상시킨 기법을 살펴보겠습니다.
코드 분석: 무엇이 바뀌었나?
핵심 변경 사항은 Lib/logging/__init__.py 파일의 Manager.getLogger 메서드에 집중되어 있습니다.
Before: 모든 조회에 락(Lock) 사용
기존 코드에서는 로거의 존재 여부를 확인하기 전에 먼저 with _lock: 블록으로 진입합니다.
# 변경 전 코드
def getLogger(self, name):
if not isinstance(name, str):
raise TypeError('A logger name must be a string')
rv = None
with _lock:
if name in self.loggerDict:
rv = self.loggerDict[name]
if isinstance(rv, PlaceHolder):
ph = rv
rv = (self.loggerClass or _loggerClass)(name)
rv.manager = self
self.loggerDict[name] = rv
self._fixupParents(rv)
self._fixupChildren(ph, rv)
else:
rv = (self.loggerClass or _loggerClass)(name)
rv.manager = self
self.loggerDict[name] = rv
self._fixupParents(rv)
return rv
이 구조에서는 loggerDict에 이미 로거가 들어있더라도 무조건 락을 잡아야 합니다. 멀티스레드 환경에서 로깅이 잦은 애플리케이션이라면 이 전역 락이 병목 지점이 될 수 있습니다.
After: 락 프리 패스트 패스(Lock-free Fast Path) 도입
개선된 코드는 락을 잡기 전에 dict.get()을 이용해 로거가 이미 존재하는지 먼저 확인합니다.
# 변경 후 코드
def getLogger(self, name):
if not isinstance(name, str):
raise TypeError('A logger name must be a string')
# Fast path: 이미 등록된, PlaceHolder가 아닌 로거는 락 없이 반환 가능
rv = self.loggerDict.get(name)
if rv is not None and not isinstance(rv, PlaceHolder):
return rv
with _lock:
if name in self.loggerDict:
rv = self.loggerDict[name]
if isinstance(rv, PlaceHolder):
# ... (중략: PlaceHolder 처리 및 실제 로거 생성 로직)
여기서 주목할 점은 단순히 name in self.loggerDict만 체크하는 것이 아니라, isinstance(rv, PlaceHolder) 여부까지 확인한다는 것입니다. 파이썬의 로깅 시스템은 계층 구조를 가집니다. 예를 들어 a.b.c 로거를 먼저 생성하면, 아직 생성되지 않은 상위 로거 a.b는 PlaceHolder 객체로 loggerDict에 임시 저장됩니다. 이러한 '가짜' 로거를 반환하면 안 되기 때문에 PlaceHolder 체크가 반드시 필요합니다.
왜 이게 좋은 최적화인가?
1. 원자적 연산(Atomic Operation)의 활용
PR 설명에서도 언급되었듯이, 파이썬의 dict.get() 연산은 GIL(Global Interpreter Lock) 하에서 원자적(Atomic)입니다. 즉, 한 스레드가 딕셔너리에서 값을 읽는 동안 다른 스레드가 딕셔너리를 수정하더라도 데이터 구조가 깨지지 않습니다.
특히 최근 파이썬 3.13에서 도입된 Free-threading(GIL 제거) 모델에서도 dict 연산의 스레드 안전성을 보장하기 위한 설계가 반영되어 있으므로, 이 최적화는 미래 지향적입니다. 로거 객체는 락 안에서 완전히 초기화된 후 딕셔너리에 삽입되므로, 패스트 패스에서 읽어온 로거가 '생성 중인 불완전한 객체'일 확률은 없습니다.
2. 성능 수치 (33% 개선)
벤치마크 결과에 따르면, 기존 로거를 조회하는 속도가 6.68 µs에서 5.02 µs로 약 33% 단축되었습니다. 수치상으로는 마이크로초 단위의 작은 차이처럼 보일 수 있지만, 수천 개의 모듈과 수만 번의 로깅 호출이 발생하는 대규모 시스템에서는 누적 오버헤드를 유의미하게 줄여줍니다.
3. 하위 호환성과 안전성
이 변경사항은 API를 전혀 수정하지 않으면서 내부 구현만 개선했습니다. 리뷰 과정에서 StanFromIreland가 "성능 개선도 기능(Feature)으로 보고 백포트(Backport)를 제한해야 하는가?"라는 의문을 제기했지만, 메인테이너 vsajip은 API 변경이 없는 안전한 최적화이므로 이전 버전에도 혜택을 줄 수 있다고 판단했습니다. 이는 실질적인 사용자 이득을 우선시하는 시니어 엔지니어의 관점을 보여줍니다.
리뷰 피드백 분석
리뷰어 중 한 명인 sobolevn은 기존 코드에 있던 rv = None 할당문이 왜 삭제되었는지 질문했습니다.
# Before
rv = None
if not isinstance(name, str):
raise TypeError(...)
# After
if not isinstance(name, str):
raise TypeError(...)
rv = self.loggerDict.get(name)
과거 코드에서는 rv를 미리 선언해두었으나, 새로운 로직에서는 self.loggerDict.get(name)의 결과를 바로 rv에 할당하므로 불필요한 초기화 코드를 제거하여 가독성을 높였습니다. 사소해 보이지만 코드의 순수성을 유지하려는 오픈소스 커뮤니티의 꼼꼼함을 엿볼 수 있습니다.
결론: 일반적인 교훈
이 PR은 소프트웨어 최적화의 고전적인 원칙을 다시 한번 상기시켜 줍니다.
- Common Case Fast Path: 가장 자주 발생하는 케이스(이미 존재하는 로거 조회)를 락 없이 처리하도록 분리하라.
- Double-Checked Locking의 변형: 락을 잡기 전에 한 번 확인하고, 필요할 때만 락을 잡는 패턴은 동시성 프로그래밍에서 매우 유효하다.
- 언어의 특성 활용: 사용 중인 언어(Python)의 런타임이 보장하는 원자성(Atomicity) 범위를 정확히 이해하면 안전한 락 프리 코드를 작성할 수 있다.
파이썬 3.14(혹은 백포트되는 하위 버전)를 사용하는 개발자들은 자신도 모르는 사이에 조금 더 쾌적한 로깅 환경을 누리게 될 것입니다.
참고 자료
- https://docs.python.org/3/library/logging.html#logging.getLogger
- https://docs.python.org/3/library/stdtypes.html#dict.get
- https://peps.python.org/pep-0703/
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
- [cpython] Python re 모듈의 findall, sub, subn 성능 개선: PyList_AppendTakeRef 도입
- [cpython] tarfile 스트리밍 모드(r|*) 성능 개선: 파이썬 압축 파일 처리의 숨겨진 병목 제거
- [cpython] Python의 os.fork 후 발생하던 성능 프로파일링 충돌 문제 해결 및 최적화 분석
- [cpython] CPython의 PySequence_GetSlice 성능 개선: 불필요한 참조 카운트 연산 제거
- [cpython] Python JIT 최적화: 트레이스 버퍼 오버헤드 관리 개선
PR Analysis 의 다른글
- 이전글 [sglang] 실시간 RGB 전송 속도 향상을 위한 최적화 분석
- 현재글 : [cpython] CPython 내부 들여다보기: logging.getLogger()는 어떻게 33% 더 빨라졌나?
- 다음글 [uv] uv, 대규모 워크스페이스 탐색 속도 1.8배 향상: 중복 파일 읽기 제거
댓글