Python 인터프리터는 어떻게 동작할까? (feat. 효율적인 코드)

백준호·2024년 7월 7일
1

python은 인터프리터 언어라고 알려져있다. 인터프리터 언어와 python 인터프리터 동작 방식에 대해서 이해하는 것은 코드를 더욱 효율적으로 작성하는 것에 많은 도움을 줄 수 있다. 인터프리터 언어와 python 인터프리터 언어의 동작 방식을 알아보고, 실제 효율성이 차이나는 코드를 확인해보자.

인터프리터 언어?

인터프리터 언어란 코드를 한 줄 한 줄 읽어가며 명령을 바로 처리하는 언어를 말한다. 전체 코드를 컴파일 하여 기계어로 번역 후 실행하는 컴파일 언어와 비교하면 전체 프로그램 실행 시간이 느리지만, 바로 코드를 실행 가능하기 때문에 디버깅과 개발이 상대적으로 빠르다.

Python 인터프리터?

python은 인터프리터 언어라고 하지만 동작 방식이 약간은 다르다.

python은 인터프리터가 한 줄 한 줄 코드를 기계어로 바로 변환하여 실행하는 것이 아닌 인터프리터 내부에서 컴파일러를 이용해 바이트 코드로 변환 후 변환된 바이트 코드 명령어를 가상 머신이 하나씩 기계어로 변환하고 실행한다. 바이트 코드란 기계어와 실제 코드 사이의 중단 단계 코드를 의미한다.

import dis

def hello():
    print("Hello, World!")

# 바이트코드 출력
dis.dis(hello)
  3           0 RESUME                   0

  4           2 LOAD_GLOBAL              1 (NULL + print)
             14 LOAD_CONST               1 ('Hello, World!')
             16 PRECALL                  1
             20 CALL                     1
             30 POP_TOP
             32 LOAD_CONST               0 (None)
             34 RETURN_VALUE

위와 같은 구조로 되어있는 이유는 크게 두가지가 있다.

첫번째는 플랫폼 종속성 없이 실행 가능하다는 장점이다. PVM(Python Virtual Machine)은 플랫폼(기계)에 맞게 프로그래밍 되어 있어서 바이트 코드를 실행하여 기계에 맞는 기계어로 변환해주는 역할을 한다. 이러한 가상 머신을 이용해 동일한 바이트 코드를 여러 기계에서 따로 변환 작업 없이 실행이 가능하다.

두번째는 해석 속도 향상이다. 실제 코드를 바로 기계어로 해석하는 것보다 바이트 코드를 기계어로 해석하는 것이 빠른데, 이유는 바이트 코드로 변환하는 과정에서 코드 최적화가 이루어지기 때문이다. 실제 컴파일러는 중복 코드 삭제나 리스트 컴프리헨션과 같은 python 특화 코드를 최적화 하여 바이트 코드로 변환한다.

실제 코드 최적화 예시

python 코드가 바이트 코드로의 변환을 거치며 최적화 된다는 것을 알았다. 실제 코드를 보며 최적화 되는 예시를 살펴보자.

리스트 컴프리헨션 VS 단순 루프

import time

# 루프를 사용한 방법
def using_loop(n):
    result = []
    for i in range(n):
        result.append(i ** 2)
    return result

# 리스트 컴프리헨션을 사용한 방법
def using_list_comprehension(n):
    return [i ** 2 for i in range(n)]

# 성능 비교
n = 1000000
start = time.time()
using_loop(n)
end = time.time()
print(f"루프 실행 시간: {end - start:.5f} 초")

start = time.time()
using_list_comprehension(n)
end = time.time()
print(f"리스트 컴프리헨션 실행 시간: {end - start:.5f} 초")

위 코드는 단순하게 반복문을 이용해 배열에 값을 삽입하는 방법과 리스트 컴프리헨션을 사용한 방법을 나타낸다. 두 코드의 실행 시간을 살펴보면 리스트 컴프리헨션 방법이 좀 더 우세하다. 이유를 알아보기 위해 바이트 코드를 살펴보자.

*******************루프*******************
  5           0 RESUME                   0

  6           2 BUILD_LIST               0
              4 STORE_FAST               1 (result)

  7           6 LOAD_GLOBAL              1 (NULL + range)
             18 LOAD_FAST                0 (n)
             20 PRECALL                  1
             24 CALL                     1
             34 GET_ITER
        >>   36 FOR_ITER                26 (to 90)
             38 STORE_FAST               2 (i)

  8          40 LOAD_FAST                1 (result)
             42 LOAD_METHOD              1 (append)
             64 LOAD_FAST                2 (i)
             66 LOAD_CONST               1 (2)
             68 BINARY_OP                8 (**)
             72 PRECALL                  1
             76 CALL                     1
             86 POP_TOP
             88 JUMP_BACKWARD           27 (to 36)

  9     >>   90 LOAD_FAST                1 (result)
             92 RETURN_VALUE
*******************리스트 컴프리헨션*******************
 12           0 RESUME                   0

 13           2 LOAD_CONST               1 (<code object <listcomp> at 0x7fe3407dee80, file "/tmp/user_code.py", line 13>)
              4 MAKE_FUNCTION            0
              6 LOAD_GLOBAL              1 (NULL + range)
             18 LOAD_FAST                0 (n)
             20 PRECALL                  1
             24 CALL                     1
             34 GET_ITER
             36 PRECALL                  0
             40 CALL                     0
             50 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x7fe3407dee80, file "/tmp/user_code.py", line 13>:
 13           0 RESUME                   0
              2 BUILD_LIST               0
              4 LOAD_FAST                0 (.0)
        >>    6 FOR_ITER                 7 (to 22)
              8 STORE_FAST               1 (i)
             10 LOAD_FAST                1 (i)
             12 LOAD_CONST               0 (2)
             14 BINARY_OP                8 (**)
             18 LIST_APPEND              2
             20 JUMP_BACKWARD            8 (to 6)
        >>   22 RETURN_VALUE

루프 바이트 코드를 보면 LOAD_METHODCALL을 이용해 명시적으로 append 메서드를 호출하는 것을 알 수 있다. 이와 비교하여 리스트 컴프리헨션 바이트 코드는 LIST_APPEND라는 명령어를 직접 추가하는 방식으로 최적화 되어있다. 또한 더 적은 바이너리 코드를 사용하는 등의 최적화가 이루어져 있다. 이와 같은 방식으로 python 컴파일러는 코드를 최적화 한다.

제너레이터 VS 리스트

이번에는 제너레이터와 단순 리스트 사용을 메모리 관점에서 효율성을 따져보자.

import sys

# 리스트 사용
def using_list(n):
    return [i ** 2 for i in range(n)]

# 제너레이터 사용
def using_generator(n):
    for i in range(n):
        yield i ** 2

n = 1000000
list_size = sys.getsizeof(using_list(n))
gen_size = sys.getsizeof(using_generator(n))

print(f"리스트 크기: {list_size} 바이트")
print(f"제너레이터 크기: {gen_size} 바이트")

크기에서 매우 큰 차이가 나는 것을 알 수 있다. 이는 프로그램 메모리 효율성 측면에서 큰 차이가 난다는 의미이다.

리스트 크기: 8448728 바이트
제너레이터 크기: 208 바이트

바이트 코드를 살펴보면 이유를 확인할 수 있다.

**********리스트**********
  5           0 RESUME                   0

  6           2 LOAD_CONST               1 (<code object <listcomp> at 0x7f3f7a1eae80, file "/tmp/user_code.py", line 6>)
              4 MAKE_FUNCTION            0
              6 LOAD_GLOBAL              1 (NULL + range)
             18 LOAD_FAST                0 (n)
             20 PRECALL                  1
             24 CALL                     1
             34 GET_ITER
             36 PRECALL                  0
             40 CALL                     0
             50 RETURN_VALUE

Disassembly of <code object <listcomp> at 0x7f3f7a1eae80, file "/tmp/user_code.py", line 6>:
  6           0 RESUME                   0
              2 BUILD_LIST               0
              4 LOAD_FAST                0 (.0)
        >>    6 FOR_ITER                 7 (to 22)
              8 STORE_FAST               1 (i)
             10 LOAD_FAST                1 (i)
             12 LOAD_CONST               0 (2)
             14 BINARY_OP                8 (**)
             18 LIST_APPEND              2
             20 JUMP_BACKWARD            8 (to 6)
        >>   22 RETURN_VALUE
**********제너레이터**********
  9           0 RETURN_GENERATOR
              2 POP_TOP
              4 RESUME                   0

 10           6 LOAD_GLOBAL              1 (NULL + range)
             18 LOAD_FAST                0 (n)
             20 PRECALL                  1
             24 CALL                     1
             34 GET_ITER
        >>   36 FOR_ITER                 9 (to 56)
             38 STORE_FAST               1 (i)

 11          40 LOAD_FAST                1 (i)
             42 LOAD_CONST               1 (2)
             44 BINARY_OP                8 (**)
             48 YIELD_VALUE
             50 RESUME                   1
             52 POP_TOP
             54 JUMP_BACKWARD           10 (to 36)

 10     >>   56 LOAD_CONST               0 (None)
             58 RETURN_VALUE

리스트를 사용한 코드의 바이트 코드를 살펴보면 BUILD_LIST 명령어를 이용해 리스트를 생성하고 전체 값을 계산 후 리스트를 반환한다. 따라서 모든 값을 메모리에 올려야한다.

반면에 제너레이터 바이트코드는 YIELD_VALUE 명령어를 이용해 각 요청 시에만 값을 생성하고 계산하여 반환하는 로직으로 되어있다. 이러한 코드 차이가 메모리 효율성 차이를 불러일으킨 것이다.


관련 자료

Python에 대하여, Python은 어떻게 동작하는가? Python의 장단점

Back to the basic - Python interpreter의 작동방식

[파이썬] Interpreter(인터프리터) 알아보기

인터프리터 언어(Interpreter Language) vs 컴파일 언어(Compiled Language)

profile
회고하는 개발자

0개의 댓글