List Comprehension은 정말 빠른가?
현재 글에서는
파이썬 List Comprehension 성능에 대해서 조사하고 분석할 때 우리는 무엇을 알아야 하는가?
라는 질문에 대한 답을 내가 직접 조사하고 분석한 예시를 통해 전달하려고 한다.
List Comprehension 개념에 관련해서는 이미 많은 내용들을 쉽게 검색해서 접근할 수 있으므로 따로 정리하는 작업은 제외하도록 하겠다. 내가 List Comprehension에 대해서 좀 더 깊게 분석하게 된 계기는 면접을 준비하는 지인이 아래의 질문을 공유해 줬기 때문이다.
파이썬에서 List Comprehension을 쓰면 더 빠르다던데 그 이유가 뭐예요?
일단 공유받은 질문부터 다시 접근해 보도록 하겠다. 우리는 흔히 성능에 관한 잘못된 질문을 위의 예시처럼 하고 답을 정리하여 암기하는 경우가 있는데 주의해야 한다. 성능은 상대적인 수치이고 상황에 따라서 다르므로 위의 문장처럼 단순 정의할 수는 없다. 항상 비교대상이 있어야 하고 성능이 어떤 상황에 유의미하게 다른지를 알아야 한다. 따라서 질문에서 없는 정보를 추가해서 문장을 만들어보면 아래와 같다.
파이썬에서 List Comprehension은 동일한 의미의 For loop보다 항상 유의미하게 빠르다던데 그 이유가 뭐예요?
이제 올바른 질문을 만들어봤으니 해당 질문을 분석해 보자.
이미 List Comprehension과 For Loop를 분석해 놓은 블로그 내용이 있으므로 일단 해당 내용을 찾았고 해당 내용을 기반으로 추가 분석을 하겠다.
관련 블로그:
https://whatisand.github.io/why-fast-list-comprehension-python/ (한글 블로그)
사실 블로그에 대한 분석만큼만 이해하여도 우리는 파이썬에 대해서 더 깊게 이해할 수 있게 된다. 그런데 문제는 해당 내용만 알고 있으면 우리는 잘못된 코딩을 하거나 잘못된 정보를 전달하게 되는 일이 생길 것이다. 블로그에서 다룬 내용에 관하여 조금씩만 관점을 다르게 했다면 더 많은 정보를 쓰임에 맞게 알 수 있으므로 해당 내용을 설명하도록 하겠다.
일단 블로그들의 내용을 정리해 보면
List Comprehension은 일반적인 For Loop보다 list의 append 연산에 대한 바이트코드를 특별 처리하므로 성능이 유의미하게 거의 항상 빠르다.
라고 정리할 수 있다.
그런데 정말 거의 항상 유의미하고 특별처리하므로 빠르다는 내용이 우리가 원하는 답일까? 블로그의 주어진 실험만 단순하게 보고 넘어간다면 파이썬에서 특수처리하므로 항상 유의미하게 빠르다고 논리 없는 정답을 가져가게 된다. 그러면 그게 정말 사실일지 왜 그럴지 내가 자주 하는 가정을 해보자. 바로 우리가 파이썬을 만든 개발자라고 가정하는 것이다. 그리고 생각해 보자. 우리가 파이썬을 만든 개발자라면 의미상으로 똑같은 List Comprehension과 For Loop의 성능을 왜 굳이 다르게 만들었을까? 한참 곰곰이 생각해 봤는데 똑같은 연산 2개를 성능이 다르게 만들 이유가 없다. 굳이 다르게 만들었다면 다르게 만들 수밖에 없는 상황이 있었을 것 같다. 그러면 두 코드의 성능이 달라지게 될 상황이 무엇이었을까?라는 질문을 하고 코드를 보면 우리가 파이썬 개발자라고 생각했을 때 List Comprehension상황과 For Loop 상황에서 받을 수 있는 정보가 다른 부분이 있다는 것을 알 수 있다. List Comprehension은 1줄에 모든 정보가 다 들어가 있고 For Loop는 여러 줄에 거쳐서 정보가 표현되어 있다. 파이썬은 기본적으로 인터프리터로 동작할 것이고 줄 단위로 분석할 것이기 때문에 1줄에 모든 정보가 표현되어 있는 List Comprehension 코드와 여러 줄에 거쳐 표현된 For Loop의 코드 내용이 동일한 코드임을 알 수 없을 것이다. 따라서, For Loop의 경우 인터프린터 상황에서 동일한 코드라고 알 수 없으므로 추가적인 최적화를 하지 못해서 성능의 차이가 발생했다는 것을 생각할 수 있다. For Loop의 경우에 Loop 안에서 사용된 append함수가 파이썬 시스템 내부에서 제공되는 함수라서 미리 제공된 바이트코드로 처리하면 빠르다는 생각을 못하고 특정 객체의 메서드라고 생각하고 호출하는 패턴을 사용하게 된다.
그런데 파이썬 개발자라면 방금 설명한 인터프린터 상황은 필수가 아니라는 것을 알 수 있다. 파이썬 코드도 더 효율적으로 최적화하여 동작시키기 위하여 컴파일 기능을 제공한다. 그리고 컴파일러를 통하여 동작시키면 여러 줄에 거쳐서 정보가 있어서 최적화를 못하는 경우가 발생하지 않게 된다. 그러면 컴파일러로 동작시에는 성능차이가 일어나지 않는다라는 것을 유추할 수 있다. 물론 유추만으로는 불안하므로 직접 컴파일하여 코드를 돌려서 증명을 해보자. 그러면 컴파일 시에는 성능차이가 일어나지 않는다는 것을 쉽게 알 수 있다.
[실험 코드]
import timeit
from time import sleep
for_loop_code = '''def square_nums(num_range):
square_lst = list()
for i in range(1, 100):
square_lst.append(i ** 2)
return square_lst'''
comprehension_code = '''def square_nums(num_range):
square_lst = [i ** 2 for i in range(1, 100)]
return square_lst'''
print(timeit.timeit(stmt=comprehension_code, number=100000000))
print(timeit.timeit(stmt=for_loop_code, number=100000000))
sleep(5)
[컴파일 후 결과]
3.7018760000000004
3.7239170999999995
그러면 다시 정리했던 블로그의 내용을 검토해 보면 인터프리터의 상황만 고려했다는 것을 우리는 알 수 있다. 그리고 컴파일러로 동작하는 경우가 특이한 경우인가라고 생각해 보면 실제 개발하는 경우에도 성능을 고려했다면 컴파일 안 하고 돌릴 이유가 없으므로 특이한 경우도 아니다. 그리고 유의미하게 성능차이가 나는 경우도 특정 스펙의 컴퓨터에서 엄청난 숫자의 반복문을 돌려야 하고 실행 시간에 굉장히 민감한 프로그램일 경우다. 따라서 위에서 파악한 내용으로 좀 더 구체적으로 다시 정리하면
List Comprehension은 인터프리터 모드로 항상 동작해야 하는 프로그램에서 엄청난 횟수의 반복문을 실행하고 실행 시간에 굉장히 민감한 경우에만 성능을 위하여 차이를 두고 써야 한다.
라고 정리할 수 있다. 개인적인 생각에 위의 문장으로 정리된다면 성능이 빠르다 보다는 굳이 크게 신경 쓰지 않고 코딩해도 좋다는 게 맞아 보인다.
이렇게 분석한 내용이 얼마나 중요한지 해당 지식을 알고 있는 사람의 행위에 대하여 과장하여 비교해 보면 기존 내용만 알고 있는 개발자는 List Comprehension 광신론자가 되어서 성능을 높이겠다고 다른 개발을 안 하고 For Loop문을 굳이 큰 가독성을 얻을 수 없음에도 불구하고 List Comprehension문으로 변경하느라 시간을 보내고 주변 사람들에게도 해당 내용을 따르도록 추천하느라 커뮤니케이션 비용도 많이 사용했을 것이다. 그런데 변경된 내용을 알고 있다면 좀 더 For Loop문에 대해서도 유연하게 허용을 해주고 성능이 중요한 경우에 컴파일을 통하여 코드를 돌리도록 하고 다른 중요한 개발에 좀 더 신경을 쓰게 될 것이다.
'이런 개념 하나 잘못 알았다고 뭐 얼마나 큰일이 나겠어요'라고 할 수 있지만 보통 이렇게 잘못 알고 있는 지식이 하나가 아닐 것이고 그 쌓인 잘못된 지식들이 얼마나 비효율적인 작업을 만들지 상상해 보면 이런 사소한 부분이 중요하다는 것을 알 수 있다.
Reference:
https://whatisand.github.io/why-fast-list-comprehension-python/