파이썬은 처음이라 – 느긋하긴 처음이라

흔하지 않은 컨셉이기는 하나 느긋함(lazyness)이라는 컨셉은 코드를 바라보는 시각을 크게 바꿀 수 있는 중요한 지점이 될 수 있다. 이 글에서는 파이썬에서 느긋함이란 무엇이며, 파이썬에서는 어떻게 적용되는지, 그리고 이 컨셉을 통해서 기존 코드를 어떤식으로 개선할 수 있는지에 대해 살펴보도록 하겠다.  다음은 이 글의 내용과 예제 코드를 이해하는데 필요한 몇 가지 사전 정보이다. 

느긋함 – Lazyness

프로그래밍에 있어서 느긋함이란 컨셉은 그리 일반적인 개념은 아니다. 느긋함에 대해서 논의하기 가장 좋은 소재는 바로 제너레이터인데, 제너레이터는 리스트나 튜플과 같은 실제 집합은 아니지만 수열의 점화식의 원리에 의해서 이전항으로부터 다음항을 생성할 수 있는 객체라 설명했었다.

즉 외부로부터의 요청 (next() 와 같은…)에 의해서 미리 정해진 규칙에 따라 원소를 1개씩 차례로 내놓을 수 있는 객체가 바로 제너레이터인데, 각각의 원소가 미리 만들어져 있지 않고 필요한 시점에 그 때 그 때 만들어진다는 점이 리스트와 구별되는 가장 큰 차이라 하겠다. 그리고 이렇게 ‘필요할 때까지 유보되었다가 그 때 그 때 값을 만들어낸다’는 것이 프로그래밍에 있어서 느긋함이다. 

느긋한 연속열의 장점

즉 제너레이터는 ‘느긋한 리스트’로 볼 수 있다. 느긋함의 가장 큰 장점이라면 메모리를 많이 사용하지 않는 것이다. 예를 들어 자연수 1~1,000,000 사이의 수의 리스트를 사용해서 for 루프를 돈다고 생각해보자. 리스트를 생성하는 시점부터 정수 객체 100만개를 저장할 메모리 공간이 필요하다. 하지만 제너레이터를 사용한다면 메모리 공간이 거의 필요하지 않다. 왜냐면 100만개의 정수 객체는 아직 만들어지지 않았고 필요한 시점에 순서대로 1개씩 생성될 것이기 때문이다. 

# 정수 100만개짜리 리스트를 생성
a = list(range(100_0000))
# 제너레이터 준비
b = range(100_0000)

거대한 리스트를 메모리 내에 생성하는 일은 생각보다 ‘비싼’ 작업이다. 그만큼 큰 메모리 내의 연속적인 공간을 할당해야 하는데, 이것은 시스템에게는 제법 부담스러운 일이다. 위 두 명령의 성능차이는 대략 1만~10만배 수준으로 차이가 난다. 물론 백만개짜리 리스트를 생성하는 일이 흔하지는 않고, 또 10만배 느리다고 해도 1ms초도 안걸리는 수준이기 때문에 무시할 수 있을지도 모르겠다. 하지만 수행시간보다 중요한 메모리 낭비를 생각해보면 이 두 방식의 차이는 분명 존재한다. 

또한 파이썬에서 이러한 연속열을 다룰 때의 흔한 시나리오는 각 원소에 대해서 특정한 변형이나 조건에 따른 필터링을 통해서 연속열의 값을 변환하는 일이 잦다는 것이다. 일반적인 map, filter, reduce 동작은 모두 리스트나 튜플과 같은 연속열의 원소를 순차적으로 검사하고 변형하는 작업이며 많은 경우에 여러 가지의 변형이 복합적으로 하나의 연속열에 적용되어 최종 데이터를 생성한다.

이런 경우들에서 미리 데이터를 모두 생성해둬야 하는 리스트를 사용하게 되면 원본 리스트 뿐만 아니라 변형 과정에서 발생하는 중간 데이터들이 추가적으로 메모리 내에 임시 데이터로 쌓이게 된다.  반면 느긋한 연속열은 최종값 하나만을 생성하므로 이런 경우에 상당한 장점을 가지게 된다. 

많은 파이썬 교재에서 리스트를 가장 중요한 파이썬의 핵심 데이터 타입으로 손꼽고 있지만, 아이러니하게도 현재 대부분의 반복 및 집합 관련한 파이썬의 표준 API는 대부분 제너레이터를 사용하도록 바뀌었다. 이는 리스트가 가장 중요한 파이썬의 데이터 타입이 된 것은 리스트 그 자체의 특성 보다는 ‘연속열’이라고 하는 집합 객체들의 공통적인 특성이 중요하기 때문이며, 따라서 이러한 연산들이 느긋하게 처리되는 것이 보다 안정적이고 빠르게 동작한다는 것이 단순한 유행이 아니라 오랜 경험에 의해서 증명되기 때문일 것이다. 

대부분의 사용 케이스에서 느긋한 연속열, 즉 제너레이터를 사용했을 때의 단점은 별로 없다.  map(), filter()sum() 등의 대부분의 연속열 관련 함수들은 리스트 대신에 제너레이터 객체를 받을 수 있다.1  심지어 많은 ‘반복되는’ 성질을 가진 파이썬 내장 객체들은 이제 기본적으로 느긋한 연속열, 즉 제너레이터이다. (대표적으로 range() 함수에 의해 생성되는 range 객체가 있으며, map(), filter() 함수를 거친 결과 역시 이제는 리스트가 아니라 map, filter 객체라는 제너레이터가 된다.)

실제로 우리가 알지못하고 사용하던 많은 사례에서 우리는 느긋한 연속열을 많이 사용해왔다. 몇 가지 실제 사례를 통해 확인해보자.

파일입출력과 제너레이터

다음은 흔히 볼 수 있는 파이썬 기초 예제 중 하나이다.

각 행에 정수값이 쓰여있는 파일이 있다. (파일이름은 “data.txt”라 하자.) 이 파일을 읽어서 각 행에 적혀있는 숫자값들을 모두 더하고, 그 합계를 출력하는 프로그램을 작성해보자.

이 문제를 만났을 때 흔히 이런식으로 풀이한다.(틀린 건 아니다.)

with open('data.txt') as f:
  lines = f.read().splitlines()  #1
  total = 0
  for line in lines: #2
    n = int(line) #3
    total += n
  print(total) #4

이 코드는 매우 정직하게 문제의 조건을 그대로 기술하고 있다.

  1. 파일의 내용을 읽어서 각 라인별로 분해해서 문자열의 리스트를 만들었다.
  2. 각 라인을 순회하면서
  3. 정수로 변환하고 그 값을 total 이라는 이름에 합산해나간다.
  4. 최종 결과를 출력한다.

이 과정을 도식화해보면 다음과 같다.

입력값(파일이름) --> 파일
 --> 파일을 읽어서 --> 각 라인으로 분해 
 --[-> 라인을 정수로 변환
    -> 정수를 누적값에 합산
    -> 리스트에 대해 반복 -]-> 출력

간단한 문제이기 때문에 별다른 고민이 필요없을지도 모르지만, 만약 이것이 현실의 문제에 적용되어야 한다면 이 코드는 큰 약점을 하나 가진다. 그것은 바로 처리하려고 하는 데이터의 크기가 ‘별로 크지 않다’는 가정을 하고 있다는 점이다.

우리는 이런 예제를 만나면 “data.txt” 파일이 고작 수백~수천라인 정도의 크기가 될 것이라 예상한다. 그래서 read() 를 사용하여 한 번에 파일을 읽고, 그것을 다시 라인 기준으로 잘라서 사용했다. 그런데 만약 이 파일이 수십기가바이트나 되는  거대한 크기라면 어쩔까? (로그 파일등은 실제로 수 기가 사이즈로 늘어나가 쉽다.)

흔하게 보기는 어렵겠지만, 분명 이런 케이스들은 적지 않게 존재한다. 이 때 read()를 썼다면 프로그램은 전체 파일을 메모리에 로드하려고 시도할 것이고, 다시 splitlines()를 통해서 전체 데이터를 행별로 자른 리스트 데이터만큼 메모리를 추가로 할당하려 한다.  컴퓨터에 램이 넉넉하다면 운이 좋겠지만, 그렇지 않은 경우에 대부분은 프로그램이 펑하고 폭발할 것이다.

이런 문제에 봉착한다면 어떤 누군가는 파일에 대해서 readline() 이라는 메소드가 있다는 사실을 알아낼 수 있을지 모른다.  readline()은 한번에 한 라인만큼씩만 읽어들이는 식으로 메모리의 부담을 줄여준다.

‘한 번에 한 줄’씩 읽어들인다. 이것은 분명 느긋하게 동작하는 연속열의 멋진 실제 사례이다.

줄 단위로 느긋하게 파일 읽기

따라서 while문을 사용해서 읽어들일 내용이 있으면 계속 처리하면서 읽어들이는 것으로 코드를 수정할 수 있다. 이제 아래의 코드는 아무리 큰 파일을 액세스하더라도 메모리 부족으로 죽는 사태는 맞이 하지 않는다. 즉 이 방법을 사용하면 성급하게 메모리에 모든 데이터를 올려놓는 대신에 ‘느긋하게’ 데이터를 불러올 수 있게 된다.

with open('data.txt') as f:
  total = 0
  while True:
    line = f.readline()
    if not line:
      break
    total += int(line)
  print(total)

텍스트 파일 객체의 느긋한 성질

그런데 사실 이 코드는 조금 더 우아하게 다듬을 수 있다. 읽기 모드로 열린 텍스트 파일은 그 자체로 파일 콘텐츠의 각 라인의 제너레이터처럼 동작한다. 즉 파일 객체를 for 문에 넣었다면, 우리는 파일의 내용을 한 줄씩 필요한 만큼 읽어들여서 처리할 수 있다. 

with open('data.txt') as f:
  total = 0
  for line in f:
    total += int(line)
print(total)

여기까지 생각이 전개되면, 텍스트 파일을 읽어서 처리하는 프로세스 전체가 조금 다르게 해석될 수 있다. 즉 파일 객체를 가리키는 f 자체를 한 줄씩에 해당하는 문자열의 연속열로 볼 수 있는 것이다. 다시 말하지만 제너레이터는 느긋하긴하지만 연속열처럼 취급될 수 있으며 그 때문에  축약 문법이나 map() 같은 함수를 그대로 적용할 수 있다. 따라서 위 코드는 아래와 같이 다시 정리할 수 있다. 

with open('data.txt') as f:
  print(sum(map(int, f)))

키보드 입력과 제너레이터

텍스트 파일이 문장들의 집합이라는 개념은 사실 느긋함과는 크게 관련이 없다고 생각할지도 모르겠다. 그저 문자열의 리스트와 같은 거 아니냐….라고 만 생각해도 기존의 사고 모델에서 받아들이는데에는 별다른 어려움이 없기 때문이다. 그렇다면 이번에는 조금 다른 문제를 살펴보자. 이 문제의 최종 결과는 제법 mind-blowing할 수 있다.

사용자로부터 입력받은 문자열을 대문자로 바꾸어 출력하는 프로그램을 작성한다. 사용자의 입력은 빈 줄을 입력받을 때까지 반복된다. 단, 출력은 매행은 입력받고 출력하는 것이 아니라 모든 입력이 완료된 후에 출력되어야 한다.

역시 간단한 기초 예제 수준의 문제이다. 문자열의 upper() 메소드를 알고 있다면 쉽게 풀 수 있다. 키보드로부터 입력을 받는 반복의 횟수가 미리 정해지지 않았기 때문에 for 문이 아닌 while 문을 당연히 써야하는 것으로 본다면 다음과 같이 풀 수 있다.

while True:
  line = input()
  if not line: 
    break
  print(line.upper())

그런데 위 코드는 정답이 아니다. 문제가 제시하는 조건은 ‘입력을 계속 받은 후, 입력이 종료되면 결과를 출력한다’ 인데, 이 코드는 한줄을 입력받을 때마다 그 라인을 대문자로 변환해서 출력한다. 즉 결과의 출력을 입력 완료 시점까지 유보해야 한다. 그 사이에 입력받은 결과를  어딘가 보관해야 할 것이다.

그래서 빈 리스트에 입력받은 내용을 추가해두었다가 한꺼번에 출력하는 다음과 같은 코드를 작성할 수 있다.

lines = []
while True:
  line = input()
  if not line: break
  lines.append(line)
for line in lines:
  print(line.upper())

여기서 한가지 아쉽다면 아쉬운 부분은 중간 결과를 저장하기 위해서 빈 리스트를 만들고 여기에 문자열들을 하나씩 추가해 나가고 있다는 점이다. 이 문제는 입력될 라인의 수가 몇 개인지 프로그램이 실행되는 시점에 결정되지 않는다는 점이다. 

만약 입력될 횟수가 미리 정해진다면 (실행시점에 정해지더라도 상관없이) 다음과 같이 코드를 작성해볼 수 있을 것이다. 

n = int(input())
print('\n'.join(input().upper() for _ in range(n)))

위 코드는 최초에 실행횟수를 n 번으로 입력받고, n 번의 루프를 돌면서 매 반복당 한 라인을 입력받고 대문자로 변환한다. 모든 입력이 끝나면 그 내용을 개행문자로 묶어서 한 번에 출력한다. 

그 횟수가 결정된 ‘입력받지 않은 문자열들’은 장차 문자열로 평가될 어떤 값들의 리스트로 취급되며, 실제로 각 값들이 평가될 시점에 키보드 입력을 통해서 값이 결정된다. 이것도 일종의 느긋하게 평가되는 값들의 리스트로 볼 수 있다. 그러면 이 ‘느긋한 값들의 리스트’를 조금 더 느긋하게 만들어보자. 그러니까 길이가 정해지지 않은 제너레이터로 만드는 것이다. 

아래 get_lines() 함수는 제너레이터를 생성하는 제너레이터 함수로, 맨 처음 코드와 같이 while 문을 돌면서 빈 값이 입력될 때까지 매번 입력된 라인을 내놓는다. 즉 길이가 정해지지 않고 요청될 때마다 키보드로부터 그 때 그 때 값을 입력받아 내놓는 느긋한 연속열이다. 

def get_lines():
  while True:
    line = input()
    if not line: 
        break
    yield line

그리고 이 제너레이터를 이용하여 위의 짧은 코드와 같이 문제를 풀어보면 다음과 같은 코드가 나온다. 

print('\n'.join(x.upper() for x in get_lines()))

위 코드에서는 get_lines() 로부터 매번 x를 꺼내어 그 값을 대문자로 변환하고 최종적으로 모든 값을 개행문자로 묶어서 출력한다. 따라서 중간에 임시 리스트를 만들지 않으면서 필요한 만큼의, 개수가 정해지지 않은 문자열들을 획득할 수 있다. join() 역시 한 번에 평가되지 않고 모든 input() 수행이 끝날 때까지 느긋하게 기다리며, 우리가 원했던 동작 – 모든 입력이 완료된 후에 결과는 한 번에 출력하는 -을 하게된다. 

재미있는 지점은 이 get_lines()에서는 각 라인을 평가하려는 시점에 input() 함수가 실행되기 때문에 다음과 같이 실행하는 경우에는 맨 처음 코드와 같이, 매번 입력했을 때마다 재깍재깍 그 문장을 대문자로 변환하여 출력하게 할 수도 있다는 것이다.

for line in get_lines():
  print(line.upper())
## 각 라인을 입력한 직후게 바로 변환하여 출력한다.

이 방식을 잘 활용하면, get_lines() 제너레이터 함수를 별도의 모듈로 만들어두면 이와 같이 ‘빈 줄을 입력받을 때까지 입력받은 후’ 데이터를 처리하는 형태의 모든 문제에 간편하게 활용할 수 있다는 점이다. 

느긋한 수열 – 무한수열을 다루기

수열의 각 원소를 만드는 방법을 알고 있다면, 즉 어떤 수열의 점화식을 알고 있다면 이 수열을 표현하는 가장 좋은 방법은 제너레이터를 만드는 것이다. 

가장 흔히 볼 수 있는 예로 피보나치 수열을 들 수 있다. 피보나치 수열은 코딩 관련한 퀴즈에서 아주 흔하게 등장하는데, 이 수열은 N번째 항이 N-1 번째항과 N-2 번째항의 합으로 만들어진다. 

특정 항을 알기 위해서는 그 직전항과 그 앞의 항을 알아야 한다는 특성 때문에 반복적인 계산이 많이 일어나고 기하급수적으로 연산양이 많아지는 유형의 문제로 자주 등장한다. 그래서 보통 피보나치 수열은 재귀호출과 관련된 예제에서 빠지지 않는 단골 주제인 동시에, 일반적인 재귀로 피보나치의 일반항을 구하는 것은 엄청나게 비효율적인 관계로 메모이제이션과 같은 최적화와 관련된 주제에서도 많이 다룬다. 

다음 문제를 보자. 오일러 프로젝트의 초반 문제 중 하나이다. 

피보나치 수열은 1, 1로 시작하며 앞의 두 항의 합이 다음 항이 되는 수열입니다.(1, 1, 2, 3, 5, 8 ,… ) 피보나치 수열의 맨 처음에서 짝수인 항 500개의 합을 구하세요.

이 문제를 그야말로 절차적으로 풀어보도록 하자. 사실 오히려 이 코드가 더 간단할 수 있다. 

a, b, t, c = 1, 1, 0, 0
while c < 500:
  if a % 2 == 0:
    t, c = t + a, c + 1
  a, b = b, a + b
print(t)

문제는 너무 간단한 나머지 코드가 의미하는 바를 알기 어렵다는 것이 문제이다. 만약 어딘가에서 실수를 해서 이 코드가 오답을 계산해낸다면 어디가 문제인지를 알아내는 것도 쉽지 않을 것이다. (물론 이 코드가 의미하는 바를 완전히 이해하는 사람도 적지 않게 있음은 분명할테지만 말이다.)

생각의 흐름을 따라 문제를 정리하기

접근 방식을 달리해서, 이 문제를 우리가 생각하는 순서대로 풀어내는 방식으로 접근해보자. 무한한 피보나치 수열의 리스트가 있다고 가정해보는 것이다. 이 무한한 피보나치 수열의 리스트는, 피보나치 수열의 점화식을 알고 있으므로 제너레이터로 만들 수 있을 것이다. 

그런 다음에 이 피보나치 수열을 짝수값만 필터링하는 필터에 집어 넣는다. 그러면 피보나치 수열의 값 중에서 짝수값만을 취하는 무한한 수열을 얻게 된다고 볼 수 있다. 

최종적으로 만들어진 짝수값 수열에서 앞에서 500개를 취하는 함수 혹은 제너레이터를 만들었다고 가정하자. 그러면 최종 결과는 그 제너레이터의 모든 원소의 합을 sum() 함수를 통해서 구하는 것이다. 

먼저 피보나치 수열을 생성하는 제너레이터는 다음과 같이 작성할 수 있다. 

def fib():
  a, b = 1, 1
  while True:
    yield a
    a, b = b, a + b

이제 피보나치 수열에서 짝수항만을 필터링한 수열은 다음과 같이 구할 수 있다. 

fib_even = filter(lambda x: x % 2 == 0, fib())

비록 fib()가 무한한 수열이기는 하지만 전체를 평가하지 않는 이상, 지금까지의 코드는 아무 문제없이 동작한다. 이번에는 특정한 연속열에서 앞에서 n 개 까지의 원소만 추려내는 제너레이터 함수를 작성해보자.

def take(n, xs):
  for i, x in enumerate(xs):
    if not (i < n):
      return
    yield x

위 코드에서는 xs가 무한히 긴 수열임을 가정하는데, 만약 xs가 n보다 짧은 수열일 때 예외를 던지고 싶다면 다음과 같이 만들 수도 있다. 

def take(n, xs):
  i = iter(xs)
  try:
    for _ in range(n):
      yield next(i)
  except StopIteration:
    raise IndexError("Exceed range of sequence")

우리가 이미 만든 fib_even 에 이 take를 적용하면 앞에서 500개 까지의 원소만 안전하게 뽑아낼 수 있다. 따라서 최종 코드는 다음과 같이 정리된다. 

print(sum(take(500, fib_even))

다시 지금까지의 코드를 정리해보자. 

fib_even = filter(lambda x: x % 2 == 0, fib())
result = take(500, result)
print(sum(result))

이 코드를 읽는 그대로 해석해보자면 다음과 같다. 

  1. 피보나치 수열의 짝수인 항만 골라낸 fib_even 이라는 수열을 만든다. 
  2. fib_even 에서 앞 500개의 항을 골라내여 result 라 한다. 
  3. result의 합계를 출력한다. 

우리는 take()라는 별도의 함수를 따로 작성하기는 하였지만, 각각의 내용이 단순하기 짝이 없는 몇 개의 함수 (filter, take, sum)의 조합으로 이 문제를 풀 수 있음을 보였다. while 반복문을 사용하는 방법이 틀린 것은 아니지만, 중간 결과를 저장하고, 계산된 수의 개수를 카운트하는 등의 여러 너저분한 중간 변수들과 종료 조건을 확인하는 부분에서 실수를 범하기 쉽다. 예를 들어 이런 문제를 계산할 때 초보자가 흔히 저지르는 실수로는 다음과 같은 것들이 있다.

  1. 계산 후에 카운트를 올려주는 count += 1 구문을 빼먹어서 무한루프에 빠짐
  2. count = 0 으로 초기 조건을 설정한 후 while count <= 500: 으로 설정하여 500개가 아닌 501개를 더하여 오답을 구함

여기서는 비교문과 WHILE 문을 최대한 배제하고 반복가능한 객체의 본질에 접근하여 문제를 풀었다. 재차 강조하지만 중요한 것은 코드를 짧게 쓰는 것이 아니라, 값 혹은 값의 집합으로부터 원하는 결과를 만들기 위해서 그것에 대한 변형(맵/필터/리듀스)을 더해나가는 과정으로서의 프로그램을 보는 관점이다. 앞으로도 이 연재의 대부분의 코드는 이 관점을 유지해 나갈 것이며, 이러한 관점으로 문제를 바라보는 습관 그 자체가 분명 문제 해결이라는 측면에서 강력한 무기가 될 수 있을 거라고 확신한다.


  1. 단 한가지 예외로 len 함수는 제너레이터를 받지 않는다. 하지만 간단한 트릭을 통해서 제너레이터가 만들어내는 원소의 개수를 구할 수 있다. 제너레이터의 각 원소를 1로 치환하여 이를 합하면 된다. 즉 sum(1 for _ in gen)과 같은 방법으로 원소의 개수를 구할 수 있다.