파이썬 기반의 프로젝트를 작업하다 보면 __pycache__ 디렉토리와 .pyc확장자의 파일을 본 적이 있을 겁니다. 혹은 저장소에 올라가지 않도록 해당 파일과 디렉토리를 gitignore 파일에 추가한 적도 있을 겁니다. 그런 파일을 작성한 적이 없다며 의아할 수 있겠지만, 해당 파일은 이름에서 유추할 수 있듯이 캐시와 비슷한 역할을 합니다. 파이썬을 사용하면서 캐시가 왜 필요하며 어떻게 동작하는지 자세히 살펴봅시다.


1. Interpreted Language

파이썬의 가장 큰 특징은 인터프리터 언어(interpreted language)라는 점입니다. C, C++과 같은 컴파일 언어(compiled language)는 작성한 소스 코드를 컴파일하여 기계어(machine code)와 같은 중간 언어로 바꾸어 주는 작업이 필요합니다. 보통 바이너리 파일 혹은 실행 파일 형식으로 변환되며 해당 파일은 특정 운영체제나 아키텍쳐에 종속적일 수 있습니다. 따라서 만일 컴파일된 파일을 다른 운영체제에서 실행하려면 해당 환경에서 다시 컴파일 과정이 필요합니다.

하지만 Python, Javascript와 같은 인터프리터 언어는 별도로 컴파일 과정 없이 소스 코드를 직접 한 줄씩 읽으면서 실행됩니다. 소스 코드를 직접 읽는 방식이므로 특정 운영체제에 종속되지 않고 어느 환경에서나 실행할 수 있습니다. 또한 코드 수정한 후에도 별도 컴파일 과정이 필요 없어서 곧바로 실행할 수 있다는 장점이 있습니다.

언어마다 조금씩 차이가 있긴 하지만 일반적으로 컴파일 언어는 인터프리터 언어보다 실행 속도가 빠릅니다. 사람이 직접 작성한 소스 코드보다는 컴파일을 거친 기계어가 좀 더 실행에 최적화된 상태이기 때문입니다.


2. pycache

파이썬은 속도 이슈를 개선하기 위해 소스 코드를 캐싱하는 전략을 택하였습니다. 인터프리터가 소스 코드를 직접 읽는 대신 그보다는 읽기 쉬운 중간 산출물로 변환하고 해당 파일을 읽는 방식으로 동작합니다. 이렇게 파이썬 소스 코드가 변환된 결과가 바로 .pyc 확장자를 가진 캐시 파일입니다.

앞서 인터프리터 언어를 설명하면서 파이썬 인터프리터가 소스 코드를 직접 읽는다고 했지만, 엄밀히 말하면 파이썬에서도 컴파일 과정이 존재합니다. 파이썬은 이러한 컴파일 과정을 거쳐서 캐시 파일을 생성하는데, 일반적인 컴파일 과정으로 생성되는 바이너리 파일이나 실행 파일 형식과 달리 파이썬은 좀 덜 추상화된 바이트 코드(bytecode)를 만들어 냅니다. 바이트 코드는 기계어와는 달리 특정 운영체제에 종속적이지 않습니다. 즉 한번 컴파일된 바이트 코드는 어떤 환경에서나 동일하게 실행될 수 있습니다.

바이트 코드로 이루어진 캐시 파일은 운영체제와는 독립적이지만 파이썬 버전 간에는 이슈가 생길 수 있습니다. 파이썬 3.8과 3.9 같이 인접한 버전의 인터프리터가 생성한 바이트 코드는 어느 정도 호환이 가능합니다. 하지만 파이썬 2 와 3처럼 메이저 버전이 변경된 경우 호환이 힘들 수 있습니다. 또한 인접한 버전이더라도 특정 버전에서만 작동되는 라이브러리가 존재하거나 버전이 올라가면서 도입된 새로운 기능을 사용하는 경우 문제가 발생할 수 있습니다. 따라서 되도록 같은 버전으로 컴파일하고 실행하는 것을 권장하고 있습니다.


3. Compile

아래와 같은 간단한 스크립트를 작성해 봅시다. 단순히 현재 파이썬이 실행되고 있는 프로세스의 id를 반환하는 내용입니다. 해당 디렉토리에는 파이썬 소스 코드 외에는 어떤 파일도 존재하지 않고 있습니다.

# sample.py
import os

print(os.getpid())
$> ls
sample.py

아래 명령어를 이용하여 강제로 바이트 코드를 생성할 수 있습니다. py_compile은 파이썬 내장 모듈로 파이썬 소스 코드를 컴파일하여 .pyc 파일을 만들어 내는 함수와 클래스를 담고 있습니다.

$> python -m py_compile sample.py

$> ls
__pycache__     sample.py

$> ls __pycache__
sample.cpython-311.pyc

컴파일 과정 후에 __pycache__ 디렉토리가 생겨난 것을 확인할 수 있습니다. 또한 그 디렉토리 내부에는 .pyc 파일이 생성되어 있습니다. 해당 파일은 바이트 코드 명령어를 포함하고 있어서 사람이 읽을 수 없는 형태이고 텍스트 편집기에서 열 수도 없습니다.

캐시 파일은 소스 코드와 동일한 이름으로 생성됩니다. 또한 이름 뒤에 cpython-311과 같이 컴파일된 인터프리터와 버전 정보까지 명시되어 있습니다. 이러한 명명 규칙을 통해 여러 버전에서 컴파일된 캐시 파일이 공존할 수 있고, 인터프리터는 적합한 버전의 바이트 코드를 실행할 수 있습니다.

또한 파이썬 인터프리터는 소스 코드와 캐시 파일의 수정 시간을 비교하여 캐시 된 바이너리 파일이 최신 상태인지를 비교합니다. 로직은 아래와 같은 순서로 진행됩니다.

  1. 먼저 파이썬 인터프리터는 먼저 실행할 .py 파일과 동일한 경로의 __pycache__ 디렉토리에서 같은 파일명의 .pyc 파일을 찾습니다.
  2. 그리고 두 파일의 수정 시간을 비교하는데, 만일 .pyc 파일이 최신이 아닌 경우 원본 소스 코드 파일이 수정되었다고 간주하고 .py 파일을 다시 컴파일한 후에 갱신된 .pyc 파일을 실행합니다.
  3. .pyc 파일이 최신인 경우에는 해당 바이트 코드를 로드하여 실행합니다.

이러한 방식으로 파이썬 코드가 변경될 때마다 바이트 코드는 업데이트되며 인터프리터는 상항 최신으로 반영된 바이트 코드를 읽을 수 있습니다. 또한 소스 코드를 미리 파싱해 두고 이후에는 바이트 코드를 실행하게 되어 성능을 향상할 수 있습니다.


References