콘텐츠로 이동

Ch.2 CS Drill Down (1) - print()는 어디로 가는가

< 사례와 코드 | CS Drill Down (2) >


앞에서 print() 유무만으로 API 응답이 수십~수백 배 차이 나는 걸 확인했다. 코드 로직은 완전히 동일한데 왜 이런 일이 벌어지는가?

원인을 모를 때는 추측을 던지고 하나씩 검증해 들어가는 게 디버깅의 기본이다. 이 페이지는 그 과정을 그대로 따라간다. "print가 ~해서 느린 거 아닐까?"라는 가설을 세우고, 근거를 까보고, 맞으면 "그런데 그걸로 다 설명되나?"를 묻고, 아니면 "그럼 어디를 더 봐야 하나?"로 넘어간다.

출발점에서 분명한 사실은 하나다. 두 함수의 차이는 print() 한 줄뿐이고, 나머지 로직은 완전히 같다. 그러니 범인은 print() 안에 있다. 안으로 들어가 보자.

가설 1 — print()가 무거운 연산을 하는 함수 아닐까?

가장 단순한 추측부터. print()가 내부에서 뭔가 복잡한 계산을 하느라 느린 거라면? 확인하려면 Python이 print()를 실제로 어떤 명령들로 바꾸는지 보면 된다. dis 모듈로 bytecode를 깐다.

import dis

def with_print():
    print("a")

def without_print():
    x = "a"

dis.dis(with_print)
dis.dis(without_print)

실행하면 (아래는 핵심 instruction만 추출한 단순화 버전이다. 실제 Python 3.12에서 돌리면 RESUME, POP_TOP, 줄 번호, 오프셋 등이 추가로 보이는데, 지금은 무시해도 된다):

=== with_print ===
  LOAD_GLOBAL    print       # print 함수를 찾아온다
  LOAD_CONST     'a'         # 문자열 'a'를 준비한다
  CALL           1           # print 함수를 호출한다

=== without_print ===
  LOAD_CONST     'a'         # 문자열 'a'를 준비한다
  STORE_FAST     x           # 변수 x에 저장한다

읽어보면 김이 샌다. print는 Python 키워드가 아니라 함수고, 하는 일은 CALL 명령 하나, 그냥 함수 호출이다. 무거운 연산 같은 건 보이지 않는다. 비교용으로 둔 without_printSTORE_FAST 하나, 변수에 값 하나 넣는 게 전부다.

(Python 2에서는 print가 문(statement)이었고, Python 3에서 함수로 바뀌었다. 이 차이 때문에 Python 2 코드를 3으로 옮길 때 print "hello"가 에러 나는 거다.)

그래서 가설 1은 반쯤 틀렸다. print() 자체가 복잡한 계산을 하는 건 아니다. 하지만 김이 새는 대신 단서가 하나 남는다. "함수 호출"이라는 건 그 함수 안에서 뭔가가 더 일어난다는 뜻이다. bytecode는 "여기서 함수를 부른다"까지만 보여줄 뿐, 그 함수 안은 안 보여준다. 호출된 함수 안으로 한 겹 더 들어가야 한다.

가설 2 — print() 안에서 하는 일이 비싼 거 아닐까?

이번엔 print() 안으로 들어가 본다. print("a")를 호출하면 내부적으로 문자열 "a"와 줄바꿈 "\n"을 stdout에 쓴다. Python 내부에서 텍스트를 바이트로 변환하고 버퍼에 쌓는 작업을 거친 뒤, 최종적으로 "a\n"이라는 2바이트 데이터를 fd 1번(stdout)에 쓰기를 요청한다.

stdout (표준 출력, Standard Output)

프로그램이 텍스트를 내보내는 기본 출력 통로다.

터미널에서 프로그램을 실행했을 때 화면에 글자가 나오는 건, 그 글자가 stdout을 통해 터미널로 전달되기 때문이다.

Unix/Linux 시스템에서는 stdout이 "파일"처럼 취급된다. 이게 핵심이다.

모든 프로그램은 시작할 때 자동으로 3개의 표준 스트림을 받는다.

  • stdin (표준 입력, fd 0): 키보드 입력
  • stdout (표준 출력, fd 1): 화면 출력
  • stderr (표준 에러, fd 2): 에러 출력

여기서 결정적인 한 줄이 "stdout은 파일처럼 취급된다"는 말이다. Unix/Linux에서는 화면 출력도, 파일 쓰기도, 네트워크 전송도 전부 "파일에 쓰기"로 추상화된다. 이 추상화의 핵심이 File Descriptor다. (Java의 System.out.println()도 결국 같은 구조다. 언어만 다르지, OS 위에서 돌아가는 한 같은 길을 거친다.)

File Descriptor (파일 디스크립터, fd)

운영체제가 열려있는 파일(또는 파일처럼 취급되는 대상)을 식별하기 위해 부여하는 정수 번호다.

프로그램이 파일을 열면 운영체제가 번호를 하나 준다. 이후 그 번호로 "이 파일에 써줘", "이 파일에서 읽어줘"라고 요청한다.

모든 프로세스는 기본적으로 3개의 fd를 가지고 시작한다.

  • 0: stdin
  • 1: stdout
  • 2: stderr

네트워크 소켓도 fd를 부여받는다. "Everything is a file"이라는 Unix 철학의 핵심이 여기 있다.

정리하면: print("a") -> sys.stdout.write("a\n") -> fd 1번에 쓰기. 그래서 print()가 하는 일의 정체는 "파일에 쓰기"였다.

가설 2는 방향이 맞다. 그런데 여기서 또 막힌다. 파일에 쓰는 게 변수 할당(STORE_FAST)보다 왜 그렇게 비싸야 하는가? 둘 다 결국 메모리 어딘가에 바이트를 옮기는 일 아닌가? "fd에 쓴다"가 실제로 무슨 동작인지 모르면 비용을 설명할 수 없다. 한 겹 더 내려간다.

가설 3 — "fd에 쓴다"가 사실은 커널을 부르는 일 아닐까?

fd 1번에 "쓴다"는 건 구체적으로 무슨 의미인가? 내 프로그램이 직접 하드웨어(터미널)에 접근해서 글자를 찍는 걸까?

아니다. 절대 아니다. 일반 프로그램은 하드웨어에 직접 접근할 수 없다. 위험하니까. 만약 모든 프로그램이 디스크에 마음대로 쓸 수 있다면, 버그 하나로 다른 프로그램의 데이터를 날려버릴 수 있다. 그래서 운영체제는 "사용자 프로그램이 할 수 있는 영역"과 "커널만 할 수 있는 영역"을 칼로 자르듯 나눠놨다.

하드웨어에 뭔가를 쓰려면, 반드시 커널에게 "이거 좀 대신 해줘"라고 부탁해야 한다. 이 부탁하는 행위가 System Call이다.

System Call (시스템 콜)

사용자 프로그램이 운영체제 커널에게 "이 일 좀 해줘"라고 요청하는 공식적인 방법이다.

파일 읽기/쓰기, 네트워크 통신, 메모리 할당, 프로세스 생성 등 하드웨어와 관련된 작업은 전부 System Call을 통해야 한다.

대표적인 System Call: write(), read(), open(), close(), fork(), exec()

Kernel (커널)

운영체제의 핵심 프로그램이다. 하드웨어와 소프트웨어 사이의 중재자 역할을 한다.

CPU, 메모리, 디스크, 네트워크 장치 등 모든 하드웨어 자원을 관리하고, 프로그램들이 이 자원을 안전하게 사용할 수 있도록 중재한다.

Linux 커널, Windows NT 커널, macOS의 XNU 커널 등이 있다.

여기서 가설 3이 맞아떨어진다. print() 한 줄은 결국 fd에 쓰기를 요청하고, 그 요청은 write() System Call이 되어 커널을 부른다. 세 번의 디깅이 하나의 경로로 이어진다: 함수 호출(가설 1) → stdout = 파일 쓰기(가설 2) → write() System Call로 커널 호출(가설 3).

여기까지의 흐름

print("a")
  -> sys.stdout.write("a\n")
    -> fd 1번에 쓰기
      -> write() System Call 호출
        -> 커널에게 "터미널에 이거 찍어줘" 부탁

이 경로가 첫 번째 핵심이다. 그리고 대조군을 다시 보면 왜 차이가 나는지 윤곽이 잡힌다. without_printSTORE_FAST는 이 사다리를 한 칸도 내려가지 않는다. 순수하게 사용자 영역 안에서 끝나는 메모리 연산이다. 반면 print 한 줄은 매번 커널 문을 두드린다. 같은 루프를 도는데 한쪽은 사용자 영역에 머물고, 한쪽은 경계 너머 커널까지 왕복한다. 140배의 냄새가 여기서 난다.

그런데 — 이걸로 정말 140배가 설명되나? "print는 커널까지 가고 dontPrint는 안 간다"까지는 알았는데, 곰곰이 따져보면 아직 느린 이유를 못 밝혔다. 커널에 한 번 부탁하는 게 그렇게까지 비쌀 일인가? 함수 하나 더 호출하는 정도 아닌가? 왜 아직도 이게 느린지가 안 풀린다.

그렇다면 다음 차례는 분명하다. 이 write() System Call이라는 놈을 한 겹 더 파보는 거다. 커널 문을 한 번 두드릴 때 안에서 실제로 무슨 비용이 드는지, 다음 페이지에서 숫자로 잰다.


< 사례와 코드 | CS Drill Down (2) - System Call이 왜 비싼가 >