![]()
`stream.write` 한 줄이 만든 차이: pprint가 커스텀 스트림에서 깨지는 이유를 고친 PR #145894
meta_description: pprint.PrettyPrinter가 frozendict를 출력할 때 write = stream.write를 캐시해놓고도 다시 stream.write(...)를 호출하던 실수를 PR #145894가 고쳤다. 출력은 단순하다는 가정이 깨지는 순간(래퍼/프록시/테스트 스트림)에서 어떤 증상이 생기는지, 내 코드에서 어떻게 빨리 재현·방어할지 정리한다.
meta_keywords: pprint, PrettyPrinter, stream, write, frozendict, CPython, PR145894, GH145887, logging, proxy, wrapper, io, StringIO, TextIOBase, monkeypatch, regression, 테스트, 출력, 버그, 파이썬
meta_robots: index,follow
로그나 리포트 출력은 보통 그냥 문자열 찍는 것으로 취급된다. 그래서 더 골치 아프다. 출력이 깨지면 대개 기능이 멈추는 게 아니라, 디버깅이 멈춘다.
이번 CPython PR #145894는 그 전형적인 케이스다. 바뀐 건 한 줄인데, 가끔만 깨지는 출력 버그를 없앤다.
write = stream.write
이건 흔한 패턴이다.
stream.write를 로컬 변수로 잡아두면(lookup/cache) 그 아래에서 호출이 조금 싸지고- 커스텀 스트림이 들어와도 write라는 기능만 있으면 된다는 의도를 코드에 남긴다
그런데 버그는 이런 식으로 생긴다.
- stream.write(cls.__name__ + '(')
+ write(cls.__name__ + '(')
즉, write = stream.write를 이미 해놨는데, 첫 줄에서만 실수로 stream.write(...)를 직접 호출하고 있었다.
둘 다 똑같이 동작하는데?라고 생각하기 쉬운데, 실무에선 그렇지 않다.
왜 이게 실무에서 터지나: 래퍼/프록시/가짜 스트림이 있는 환경
pprint가 쓰는 stream은 보통 sys.stdout 같은 진짜 파일 객체라고 가정한다. 하지만 우리는 종종 더 복잡한 걸 넣는다.
- 테스트에서
io.StringIO()로 출력 캡처 - 로깅/관측에서 write를 가로채서 버퍼링하거나 prefix를 붙이는 래퍼
- 타입 체킹/계측을 위해
write만 제공하는 간단한 객체(파일처럼 보이게 만든 더미)
이때 중요한 차이가 생긴다.
- 어떤 래퍼는
stream.write속성 접근을 통제하거나(프록시) write를 메서드가 아니라 다른 callable로 제공하거나write = stream.write를 만들어 둔 순간과, 나중에stream.write를 다시 읽는 순간이 다르게 동작할 수 있다
PR #145894는 여기서는 write를 캐시해서 쓰겠다고 결정했으면, 처음부터 끝까지 그 결정을 지켜라는 쪽으로 코드를 정리한 거다.
내 코드에서 확인할 포인트: 출력 경로는 생각보다 쉽게 갈라진다
이 PR은 pprint 내부 얘기지만, 내 코드에도 그대로 적용된다. 특히 아래처럼 출력 함수를 캐시하는 코드가 있을 때.
write = stream.write
# ... 중간에 뭔가 많은 일 ...
stream.write("...") # 여기부터 위험해질 수 있음
이게 왜 위험하냐면, 캐시한 write와 나중에 다시 읽은 stream.write가 다른 함수가 될 수 있기 때문이다.
예를 들어 테스트에서 monkeypatch로 stream.write를 바꾸거나, 프록시 객체가 속성 접근을 다르게 처리하면, 둘은 갈라진다. 이런 버그는 재현이 어려워진다. 출력이 깨지는 조건이 코드 경로가 아니라 객체가 만들어진 방식으로 이동하기 때문이다.
정리하면 체크 포인트는 간단하다.
write = stream.write를 썼으면, 그 블록에서는write(...)만 호출하기- 반대로, 캐시하지 않을 거면 끝까지
stream.write(...)로만 가기 - 둘을 섞는 순간, 어떤 환경에서만 깨지는 버그가 생길 여지가 커진다
재현 가능한 미니 테스트(10줄): 이런 버그를 빨리 잡는 방법
이런 류의 버그는 진짜 파일 객체로는 잘 안 드러난다. 그래서 테스트에 일부러 귀찮은 스트림을 넣어보는 게 좋다.
아래는 개념적으로 이런 문제가 왜 생기는지 보여주는 최소 예시다.
class Proxy:
def __init__(self):
self.buf = []
self._write = self.buf.append
@property
def write(self):
# 매번 다른 callable을 돌려준다고 가정(프록시/계측 흉내)
return lambda s: self._write(f"[X]{s}")
p = Proxy()
write = p.write
write("a")
# p.write("b") # 캐시한 write와 다른 경로를 타게 만들 수 있음
현실의 프록시는 이렇게 노골적이진 않지만, 속성 접근 시점이 다르면 함수가 달라질 수 있다는 감각을 얻는 데는 충분하다.
이런 테스트를 하나 만들어두면, 출력 경로를 건드리는 리팩터링에서 생각보다 많은 버그가 초기에 걸린다.
커스텀 스트림을 만들 때 최소 계약을 문서로 남겨라
실무에서 커스텀 스트림은 생각보다 자주 등장한다.
- 테스트에서 출력 캡처를 하고 싶어서
- CLI에서 색깔/프리픽스를 붙이고 싶어서
- 서버에서 한 줄 로그를 구조화해서 저장하고 싶어서
문제는 이 커스텀 스트림들이 대부분 완전한 파일 객체가 아니라는 점이다. TextIOBase를 상속받아 완성형으로 만들지 않고, 필요한 메서드만 간단히 제공하는 경우가 많다. 그게 나쁜 건 아니다. 다만 그렇게 만들었으면, 최소 계약을 스스로에게 적어두는 편이 안전하다.
예를 들어 나는 스트림을 만들 때 아래 네 가지를 기준으로 삼는다.
1) write(str) -> int 또는 write(str) -> None 중 무엇을 보장할 건지
- 표준 파일 객체는 보통 쓴 글자 수를 돌려준다.
- 하지만 테스트용 더미는 반환을 안 하는 경우가 있다.
- 호출자(라이브러리)가 반환값을 쓰지 않는다면 둘 다 가능하지만, 경계가 애매하면 버그의 냄새가 된다.
2) write는 한 번 호출하면 한 번 기록인지
- 내부에서 버퍼링을 하거나, 여러 번 분할해서 쓰는지에 따라 디버깅이 달라진다.
3) 멀티스레드에서 안전한지 - 출력이 섞이는 걸 허용하는지(대부분은 허용) - 아니면 내부에서 락을 잡아줄 건지
4) 예외를 어떻게 던질 건지 - 출력 실패는 보통 기능 실패보다 나쁜 영향을 준다(원인을 숨김). - 그래서 출력 실패를 삼켜도 되는지/안 되는지를 정해두는 게 좋다.
PR #145894 같은 사소한 실수는, 이런 계약이 흐릿한 환경에서 더 잘 튀어나온다. 출력 경로의 상호작용이 늘어나기 때문이다.
내 코드에서 바로 적용하는 방어 패턴 2개
이 PR을 보면서 내가 다시 확인하게 된 패턴이 있다. 둘 다 단순하지만, 장기적으로 꽤 큰 차이를 만든다.
패턴 A) write = stream.write를 썼으면 끝까지 write()만 쓴다
이건 이번 PR이 직접 보여준 교훈이다. 캐시한 write를 쓰겠다는 결정을 했다면, 중간에 한 번이라도 stream.write(...)로 돌아가지 않는다. 그 한 번이 특정 환경에서만 깨지는 조건을 만들어버릴 수 있다.
패턴 B) 프린트/로깅 경로는 최소 하나의 귀찮은 스트림으로 테스트한다
실제 파일 객체만 넣어서는 버그가 잘 안 드러난다. 나는 아래 둘 중 하나는 꼭 끼워 넣는다.
io.StringIO()(가장 기본)- 속성 접근을 감시하는 프록시(위에 예시처럼, 매번
write를 람다로 돌려주는 형태)
이 두 번째는 조금 과하다고 느껴질 수 있는데, 출력 경로가 라이브러리/프레임워크를 거치면서 프록시가 되는 순간이 생각보다 많다. (예: 테스트 러너, 캡처 도구, 관측 도구)
마무리: 출력은 부수효과가 아니라, 디버깅의 입력이다
PR #145894는 한 줄 변경이지만, 메시지는 크다.
출력은 부수효과가 아니라 디버깅의 입력이다. 그래서 출력 경로는 비즈니스 로직만큼 자주 깨진다. write를 캐시할지 말지 결정했으면, 그 결정은 코드 전체에서 일관되게 지켜야 한다.
특히 테스트/관측/프록시가 얽힌 환경에서라면 더.
References
- CPython PR #145894 — Use
write()instead ofstream.write()inPrettyPrinter._pprint_frozendict- https://github.com/python/cpython/pull/145894
- Diff
- https://github.com/python/cpython/pull/145894.diff
- Commit 0b6a234
- https://github.com/python/cpython/commit/0b6a2346e5b203fb988a382fdc3d51b36641fe1a
이미지 크레딧/라이선스
- Laptop Programmcode.jpg — Negative Space / CC0
- https://commons.wikimedia.org/wiki/File:Laptop_Programmcode.jpg
- http://creativecommons.org/publicdomain/zero/1.0/deed.en
댓글
댓글 쓰기