CodeOnWeb
로그인

챕터 8. 고급 반복자(Advanced Iterators)

반복자를 더 깊이 알아보겠습니다.

Park Jonghyun 2015/09/21, 12:00

내용

파이썬 3에 뛰어들기 (22)

챕터 -1. “파이썬 3로 뛰어들기”에서 달라진 점 챕터 0. 파이썬 설치하기 챕터 1. 첫 파이썬 프로그램 챕터 2. 고유 자료형 챕터 3. 컴프리헨션(Comprehensions) 챕터 4. 문자열(Strings) 챕터 5. 정규표현식(Regular Expressions) 챕터 6. 클로저와 제너레이터(Closures & Generators) 챕터 7. 클래스와 반복자(Classes & Iterators) 챕터 8. 고급 반복자(Advanced Iterators) 챕터 9. 단위 테스트(Unit Testing) 챕터 10. 리팩토링(Refactoring) 챕터 11. 파일 챕터 12. XML 챕터 13. 파이썬 객체 직렬화 챕터 14. HTTP 웹서비스 챕터 15. 사례 연구: chardet을 파이썬 3로 이식하기 챕터 16. 파이썬 라이브러리 패키징하기 부록 A. 2to3를 이용해서 코드를 파이썬 3로 이식하기 부록 B. 특수 함수 이름 부록 C. 이제 어디로 가야 할까요? 부록 D. 문제 해결

"큰 벼룩의 등에는 작은 벼룩이 앉아 큰 벼룩의 등을 물고 있고,
작은 벼룩의 등에는 더 작은 벼룩이 있으며, 이는 무한히 반복된다."
— 오거스터스 드모르간(Augustus De Morgan)

뛰어들기

정규표현식이 문자열 처리에 날개를 달았다면, itertools라는 모듈은 반복자에 날개를 달아준다고 할 수 있습니다. 하지만 먼저 고전적인 퍼즐 하나를 보여드리고자 합니다.

이런 방식의 퍼즐을 복면산(cryptarithms, alphametics)이라고 합니다. 단어의 각 문자를 0-9 사이의 숫자로 바꾸어 수식으로 표현했을 때, 각 문자가 어떤 숫자에 대응되는지 찾아내는 것이 문제입니다. 같은 문자는 모두 같은 숫자에 대응되고, 서로 다른 문자는 서로 다른 숫자에 대응됩니다. 그리고 한 단어는 숫자 0으로 시작할 수 없습니다.

가장 유명한 복면산 퍼즐은 SEND + MORE = MONEY 입니다.

이번 챕터에서는, Raymond Hettinger가 최초 작성한 훌륭한 파이썬 프로그램으로 뛰어들 것입니다. 이 프로그램은 단 14줄 만으로 복면산 퍼즐을 풀어냅니다.

이 프로그램을 한 번 실행 해보세요. 만약 리눅스의 커맨드 라인에서 실행하신다면 아래와 비슷하게 출력될 겁니다. (컴퓨터의 속도에 따라 시간이 좀 걸릴 수도 있습니다. 따로 진행 상태를 표시해주지는 않으니 조금만 기다려 보세요.)

패턴에서 모든 문자 찾기

이 복면산 연산기가 가장 먼저 하는 일은 퍼즐에 등장한 모든 문자(A-Z)를 찾는 것입니다.

  1. 파이썬에서 정규표현식을 다루기 위해서는 re 모듈을 불러들여야 합니다. re 모듈에는 findall()이라는 멋진 함수가 하나 있는데, 정규표현식 패턴과 문자열 하나를 인자로 받은 뒤 문자열 내에 패턴과 일치하는 부분을 모두 반환합니다. 이 예제에서는 숫자가 연속적으로 나열된 패턴을 찾는 정규표현식이 주어졌습니다. findall() 함수는 그 패턴과 일치하는 모든 부분을 반환하고 있습니다.
  2. 이 정규표현식 패턴은 문자가 연속으로 나열된 부분과 매칭됩니다. 반환된 값은 리스트 형태이고, 그 리스트의 각 항목은 정규표현식 패턴과 일치하지요.

여러분의 뇌를 좀 더 유연하게 해줄 다음 예제를 보겠습니다.

영어에서 발음하기 어렵기로 악명 높은 표현이 나왔군요.

좀 이상한데요? 이 정규표현식은 (빈 칸 하나, s, 아무 문자의 나열(.*?), 공백, 다른 s)의 패턴을 가지는 부분과 매칭됩니다. 이 문자열에서는 총 다섯 군데 있는 것 같습니다.

  1. The sixth sick sheikh's sixth sheep's sick.
  2. The sixth sick sheikh's sixth sheep's sick.
  3. The sixth sick sheikh's sixth sheep's sick.
  4. The sixth sick sheikh's sixth sheep's sick.
  5. The sixth sick sheikh's sixth sheep's sick.

그런데 re.findall() 함수는 세 개의 항목만 반환했습니다. 구처적으로 말하면 첫 번째, 세 번째, 다섯 번째 매칭되는 부분이네요. 이는 겹치는 부분은 다시 매칭하지 않는 findall() 함수의 특성 때문입니다. 그래서 겹치는 부분은 반환도 되지 않습니다. 첫 번째와 두 번째 매칭은 겹치는 부분이 있으므로, 첫 번째 부분만 반환되고 두 번째는 그냥 지나칩니다. 세 번째와 네 번째 매칭도 겹치므로 세 번째 부분이 반환되고 네 번째는 지나갑니다. 마지막으로 다섯 번째 부분이 반환되겠군요. 그래서 세 개의 항목을 가진 리스트가 반환됩니다.

물론 이 예제는 복면산 풀이와는 아무 관계가 없습니다. 그래도 재미있지 않나요?

배열에서 유일한 항목만 골리내기

집합(set)을 이용하면 어떤 배열에서 유일한 항목만 골라내는 것은 식은 죽 먹기입니다.

  1. 문자열을 항목으로 가진 리스트에 set() 함수를 적용하면, 리스트의 항목에서 유일한 문자열만으로 이루어진 집합이 반환됩니다. 다음과 같은 for 문을 돈다고 생각하면 이해하기 쉽습니다. 리스트에서 첫 번째 항목을 꺼내어 집합에 집어넣습니다. 두 번째, 세 번째, 네 번째 항목도요. 다섯 번째 항목도... 잠깐, 같은 것이 이미 집합에 있군요! 파이썬의 집합은 중복 항목을 허용하지 않으므로 그냥 지나갑니다. 여섯 번째는 넣습니다. 일곱 번째 항목도 중복이니까 버립시다. 최종 결과는요? 원래 리스트에서 중복된 것 없는 유일한 항목들로 구성된 집합이 나왔습니다. 원래 리스트는 정렬되어 있을 필요도 없습니다.
  2. 같은 방식을 문자열 하나에도 적용할 수 있습니다. 문자열은 문자의 배열이니까요.
  3. 문자열을 항목으로 가지는 리스트를 ''.join(a_list)와 같은 식으로 넘겨주면, 모든 문자열이 하나로 합쳐집니다.
  4. 3번의 결과로 나온 문자열을 set() 함수에 넘기면 문자열을 구성하는 유일한 문자만 골라 반환합니다. 중복된 문자는 없습니다.

복면산 풀이에 이 테크닉이 사용됩니다. 퍼즐에 나오는 유일한 문자만을 골라 내는 것이지요.

이 리스트는 후에 복면산 프로그램이 숫자를 각 문자에 할당할 때 사용됩니다.

Assertion 발생시키기

다른 프로그래밍 언어와 마찬가지로 파이썬도 assert 문을 가지고 있습니다. 어떻게 동작하는지 보시죠.

  1. assert 문 뒤에는 유효한 파이썬 표현이 나옵니다. 이 경우 1 + 1 == 2는 참이므로 assert 문은 아무 것도 하지 않습니다.
  2. 하지만 파이썬 표현이 거짓으로 평가되면 assert 문은 AssertionError를 발생시킵니다.
  3. AssertionError가 발생할 때 출력된 메시지도 뒤에 붙일 수 있습니다.

따라서 다음과 같은 코드는:

다음과 같습니다:

복면산 프로그램은 퍼즐로 주어진 문자열의 길이가 10보다 큰지 판단하기 위해 위와 정확히 같은 코드를 사용합니다. 각 문자는 각 숫자에 일대 일로 대응되고 숫자는 10개 밖에 없으므로(0-9), 10개 이상의 서로 다른 문자가 주어지면 해답을 구할 수 없습니다.

제너레이터 표현식(Generator Expressions)

제너레이터 표현식(generator expression)은 함수가 없는 제너레이터 함수라고 생각할 수 있습니다.

  1. 제너레이터 표현식은 값을 yield 해주는 이름 없는 함수라고 할 수 있습니다. 리스트 컴프리헨션과 비슷해 보이지만 대괄호([]) 대신 소괄호(())로 묶여 있습니다.
  2. 제너레이터 표현식은 반복자(iterator)를 반환합니다...
  3. next(gen)을 호출하면 반복자에서 다음 값을 반환합니다. (집합은 순서가 없으므로 출력 결과가 다를 수 있습니다.)
  4. 제너레이터 표현식을 tuple(), list(), set() 함수에 던지면, 표현식에 있는 모든 값을 튜플, 리스트, 집합으로 반환합니다. 이 때 인자로 던져주는 표현을 괄호로 다시 감쌀 필요는 없고, 그냥 ord(c) for c in unique_characters 그대로 넘겨주기만 하면 됩니다. 파이썬이 알아서 제너레이터 표현식이라는 것을 파악합니다.

리스트 컴프리헨션 대신 제너레이터 표현식을 사용하면 CPU와 메모리를 절약할 수 있습니다. tuple()이나 set()에 한 번 넘겨주고 말 목적으로 리스트를 사용하고 있다면, 제너레이터 표현식을 한 번 써보세요!

제너레이터 함수를 이용해서 똑같은 일을 구현할 수도 있습니다.

제너레이터 표현식은 훨씬 간단하게 같은 일을 할 수 있지요.

순열 계산하기... 게으른 방법으로!

일단 순열(permutation)이 뭔지부터 알아야겠습니다. 순열은 수학적인 개념입니다. (사실 수학의 분야에 따라 몇 가지 정의가 있는데, 여기서는 순열 조합론(combinatorics)에 나오는 개념을 말합니다. 처음 들어보셨다면 위키피디아에서 찾아보는 것도 좋은 생각입니다.)

간단하게 말씀드리면 다음과 같습니다. 여러 개의 항목을 가진 리스트(숫자든, 문자든, 아니면 춤추는 곰의 무리라도 좋습니다)가 있을 때, 정해진 갯수의 항목을 순서대로 나열해 만들 수 있는, 가능한 모든 작은 리스트를 얻어보자는 것입니다. 이 작은 리스트는 모두 같은 크기를 가지는데(애초에 정해진 갯수의 항목을 꺼내어서 만들었죠), 그 크기는 1부터 전체 리스트의 길이 사이의 값을 가집니다. 아, 그리고 작은 리스트 안에서는 한 항목이 중복되어 나올 수 없습니다. 수학자들은 이렇게 말하곤 하지요. "세 개의 항목에서 두 개의 항목을 선택했을 때의 순열을 찾아봅시다." 우리 말로 해석하자면, 세 개의 항목이 있을 때, 두 개의 항목만 꺼내어 만들 수 있는 가능한 모든 순서쌍을 찾아보자는 이야기입니다.

  1. itertools 모듈은 그 안에 재미있는 것들을 잔뜩 포함하고 있습니다. permutations() 함수도 그 중 하나인데, 순열을 찾는 온갖 어려운 일을 다 처리해줍니다.
  2. permutations() 함수는 배열(여기서는 세 정수로 구성된 리스트)과 숫자 하나(작은 리스트의 크기)를 인자로 받아 반복자를 반환하는데, for 문이나 반복이 필요한 어떤 곳에든 쓸 수 있습니다. 여기서는 각 항목을 하나 하나 보여드리기 위해 수동으로 반복자를 호출해 봅시다.
  3. [1, 2, 3]에서 두 개씩 집었을 때 첫 번째 순열은 (1, 2)입니다.
  4. 순열에서는 항목의 순서가 상관있다는 점을 주의하세요: (2, 1)과 (1, 2)는 다늡니다.
  5. 이게 마지막입니다! [1, 2, 3]에서 두 개씩 집었을 때 가능한 순열은 이게 다입니다. (1, 1)이나 (2, 2)와 같이 같은 항목이 중복되는 경우는 유효한 순열이 아니므로 제외됩니다. 가능한 순열이 더 이상 존재하지 않으면 반복자는 StopIteration 예외를 발생시킵니다.

permutations() 함수에 꼭 리스트를 인자로 줄 필요는 없습니다. 문자열을 포함한 어떤 배열도 처리할 수 있습니다.

  1. 문자열은 문자의 배열입니다. 순열을 찾는 문제에 있어서 문자열 'ABC'는 리스트 ['A', 'B', 'C']와 동일하게 취급됩니다.
  2. ['A', 'B', 'C']에서 세 개의 항목을 나열했을 때 첫 번째 순열은 ('A', 'B', 'C')입니다. 이 외에 다섯 개의 순열이 더 있습니다. 같은 세 개의 문자가 모든 가능한 순서로 배열된 것을 볼 수 있습니다.
  3. permutations() 함수는 항상 반복자를 반환하므로, 제대로 된 순열이 반환되는지 확인하는 가장 쉬운 방법은 반복자를 list() 함수에 넣어보는 것입니다. 모든 순열 항목이 다 출력됩니다.

itertools 모듈의 다른 재미있는 기능

  1. itertools.product() 함수는 두 배열의 곱집합(Cartesian product)을 가지는 반복자를 반환합니다.
  2. itertools.combinations() 함수는 주어진 배열에서 주어진 크기만큼의 항목을 뽑아 순서 없이 나열한 조합의 반복자를 반환합니다. itertools.permutations() 함수와 거의 비슷하지만 각 항목의 순서를 고려하지 않는다는 차이가 있습니다. 예를 들어, itertools.permutations('ABC', 2)는 ('A', 'B')와 ('B', 'A')를 모두 반환하지만, itertools.combinations('ABC', 2)는 ('B', 'A')는 반환하지 않습니다. ('A', 'B')와 배열 순서는 다르지만 항목 자체는 같기 때문입니다.

  1. 텍스트 파일에서 다음과 같은 리스트를 읽어들였다고 가정합시다.
  2. 불행하게도 각 이름의 끝에 있던 줄바꿈 문자도 같이 읽어들인 것 같습니다. 이 리스트 컴프리헨션은 rstrip()이라는 문자열 메소드를 이용해서 각 줄의 우측편에 있는 공백을 제거했습니다. (문자열은 문자열 좌측의 공백을 제거하는 lstrip()이라는 메소드와, 양 쪽편 모두의 공백을 제거하는 strip()이라는 메소드도 가지고 있습니다.)
  3. sorted() 함수는 리스트를 인자로 받아 정렬한 결과를 돌려줍니다. 기본적으로 알파벳 순서대로 정렬합니다.
  4. sorted() 함수는 key라는 함수 인자를 받아들여 key에 할당된 함수를 기준으로 삼아 정렬을 할 수도 있습니다. 이 경우 사용되는 정렬 함수는 len() 이므로, len(각 항목)을 기준으로 정렬합니다. 짧은 길이의 이름이 먼저 오고, 긴 길이의 이름은 나중에 옵니다.

이 것들이 itertools 모듈과 무슨 관계가 있는 걸까요? 질문해줘서 감사합니다.

  1. itertools.groupby() 함수는 배열과 key 함수 하나를 인자로 받은 후, 한 쌍의 값을 반환하는 반복자를 반환합니다. 한 쌍의 값은 key_function(각 항목)의 결과와 그 결과에 해당되는 항목을 담고 있는 또 다른 반복자로 구성되어 있습니다.
  2. list() 함수를 호출함으로써 반복자를 다 소모해버렸습니다. 즉, 리스트를 만들기 위해 반복자가 준비하고 있던 모든 항목을 다 생성해버린 것이지요. 반복자에는 "리셋" 버튼 같은 것이 없으므로, 한 번 값을 생성한 후에는 다시 처음으로 돌아가서 다시 같은 값을 생성해낼 수 없습니다. 다시 한 번 반복자를 얻고 싶으면 itertools.groupby()를 다시 호출해서 새로운 반복자를 만들어야 합니다.
  3. 이 예제의 names 리스트는 이미 길이를 기준으로 정렬되어 있으므로, itertools.groupby(names, len)은 길이가 4인 모든 이름을 한 반복자로, 길이가 5인 모든 이름도 한 반복자로 할당합니다. groupby() 함수는 굉장히 일반적으로 쓰일 수 있습니다. 문자열의 첫 번째 글자를 기준으로 그룹지을 수도 있고, 숫자를 첫 번째 인수의 값을 기준으로 그룹지을 수도 있습니다. 이 외에도 여러분이 생각할 수 있는 모든 함수를 쓸 수 있습니다.

itertools.groupby() 함수는 인자로 전달되는 배열이 정렬되어 있는 경우에만 제대로 사용할 수 있습니다. 위의 예제에서는 len() 함수를 이용해서 리스트의 이름을 그룹지었는데, 이는 그 리스트가 이미 길이를 기준으로 정렬되어 있었기 때문에 가능한 것입니다.

잘 따라오고 계신가요?

  1. itertools.chain() 함수는 두 개의 반복자를 인자로 받고, 첫 반복자의 모든 항목과 두 번째 반복자의 모든 항목을 다 포함하는 반복자 하나를 반환합니다. (사실, 인자로 받을 수 있는 반복자의 개수에는 제한이 없습니다. 함수의 인자에 위치한 순서대로 연결해서 돌려줍니다.)
  2. zip() 함수는 별 거 아닌 것 같지만 사실은 매우 유용한 일을 합니다. 여러 개의 배열을 인자로 받은 뒤 각 배열의 첫 번째 항목끼리 묶은 튜플, 두 번째 항목끼리 묶은 튜플, ...을 반환하는 반복자를 반환합니다.
  3. zip() 함수는 인자로 들어온 가장 짧은 배열을 기준으로 튜플을 생성합니다. range(10, 14)는 네 개의 항목을 가지고 있지만(10, 11, 12, 13) range(0, 3)이 세 개의 항목만 가지고 있으므로, zip() 함수는 세 개의 항목을 생성하는 반복자를 반환합니다.
  4. 반면, itertools.zip_longest() 함수는 가장 긴 배열을 기준으로 튜플을 생성합니다. 짧은 배열의 존재하지 않는 부분은 None을 채워넣습니다.

자, 뭐 흥미롭군요. 그런데 이것들은 또 복면산 풀이와 무슨 관계가 있늘까요. 이걸 한 번 보시죠.

  1. 주어진 문자와 숫자의 리스트를 zip() 함수로 넘겨 문자와 숫자의 쌍을 순서대로 만들어 냅니다.
  2. 이게 왜 멋지냐고요? 이렇게 만들어진 데이터의 구조가 dict() 함수를 통해 키가 각 문자이고 값은 그에 대응하는 숫자인 사전 데이터를 만드는데 아주 편한 형태이기 때문입니다. (물론 다른 방식으로도 사전을 만들어낼 수 있습니다. 사전 컴프리헨션을 통해 바로 만드는 것도 한 방법이지요.) 인자로 받은 문자 배열과 숫자 배열의 순서를 따라 각 문자가 그에 대응하는 숫자 하나와 정확히 짝을 이루고 있는 것을 볼 수 있습니다. 쌍을 이루고 있는 항목 사이의 순서가 좀 다르긴 하지찬 이는 중요하지 않습니다. (사전에는 순서가 없죠.)
  3. 복면산 프로그램에서는 이 테크닉을 이용해서 사전을 만듭니다. 그 사전은 퍼즐에 등장하는 각 문자 키가 그 문자에 해당한다고 추측되는 숫자를 값으로 가지고 있습니다.

그런데 translate() 메소드는 무엇일까요? 아, 여러분, 이제 정말 재미있는 부분으로 들어갑니다.

새로운 방식의 문자열 조작

파이썬의 문자열은 많은 메소드를 가지고 있습니다. 문자열 챕터에서 lower(), count(), format() 등과 같은 몇 가지는 공부했지요. 이제 많이 알려지지는 않았지만 문자열을 조작하는 매우 강력한 방법인 translate() 메소드에 대해 소개하겠습니다.

  1. 문자열 번역은 한 문자를 다른 문자로 매핑하는 사전인 번역 테이블(translation table)에서 부터 시작합니다. 사실 "문자"라고 부르는 것은 잘못입니다. 사실 번역 테이블은 한 바이트를 다른 바이트로 매핑합니다.
  2. 파이썬 3에서 바이트는 정수라는 것을 기억하세요요. ord() 함수는 문자 하나를 입력받아 그에 해당하는 ascii 값을 반환합니다. 이 예제의 경우에는 A-Z 사이의 문자만 쓰므로, 바이트의 값도 65-90 사이가 됩니다.
  3. 문자열의 translate() 메소드는 인자로 받은 번역 테이블을 가지고 문자열을 순회합니다. 순회하다가 번역 테이블의 키(key)에 해당하는 문자가 발견되면, 그 문자를 키의 값으로 바꾸어 놓습니다. 이 예제에서는 MARK를 MORK로 "번역"했군요.

이건 또 복면산 퍼즐을 푸는 것과 무슨 관계가 있는걸까요? 곧 드러나겠지만 모든 것과 관계있습니다.

  1. 제너레이터 표현식을 이용해서 문자열을 구성하는 각 문자의 바이트 값을 빠르게 구했습니다. characters는 solve() 함수(챕터 시작 부분의 코드를 보세요)에 있는 sorted_characters를 바이트 값으로 바꾸는 한 예시입니다.
  2. 다른 제너레이터 표현식을 사용해서 문자열을 구성하는 각 숫자의 바이트 값을 구했습니다. guess는 solve() 함수 내에 있는 itertools.permutations() 함수를 호출했을 때 반환되는 값의 한 예시입니다.
  3. 이 번역 테이블은 characters와 guess를 zip()을 이용해서 묶고 그 결과를 다시 사전으로 변환해서(dict()) 만들어졌습니다. solve() 함수의 for 문 안에서 하는 일과 정확히 같습니다.
  4. 마지막으로, 퍼즐로 주어진 문자열의 translate() 메소드에 방금 생성된 번역 테이블을 인자로 전달해줍니다. 그러면 문자열의 각 문자는 번역 테이블이 지시하는 바에 따라 그에 해당하는 숫자로 바뀌게 됩니다. 그 결과는 유효한 파이썬 표현식이 되었군요. 물론 문자열 형태이긴 하지만요.

정말 인상적입니다. 그런데 문자열 형태로 된 파이썬 식을 가지고 뭘 어떻게 해야할까요?

임의의 문자열을 파이썬 표현식으로 평가하기

이제 퍼즐을 푸는 마지막 조각만 남아 있습니다. 아주 맵시있는 문자열 조작을 거쳐서 '9567 + 1085 == 10652'과 같은 문자열을 얻었습니다. 하지만 이것은 문자열인데 무슨 쓸모가 있을까요? 파이썬의 평가 도구(evaluation tool)인 eval() 함수로 한 번 들어가 봅시다.

잠시 기다리세요. 더 있습니다! eval() 함수는 boolean 표현식을 평가하는 것보다 더 많은 일을 할 수 있거든요. 사실 어떤 파이썬 표현도 다룰 수 있고, 그 결과로 데이터 형을 반환할 수도 있습니다.

잠깐만요. 아직도 끝이 아니에요!

  1. eval()이 인자로 받아들이는 표현식은 eval() 밖에서 정의된 글로벌 변수를 참조할 수 있습니다. 한 함수 내에서 호출된다면 그 함수의 지역 변수도 참조할 수 있습니다.
  2. 함수도 쓸 수 있고.
  3. 모듈에도 접근할 수 있네요.

엇, 잠깐만요... 모듈에도 접근할 수 있다고요?

  1. subprocess 모듈을 사용하면 임의의 쉘 명령(shell commands)을 실행하고 그 결과를 파이썬 문자열로 받아올 수 있습니다.
  2. 어떤 쉘 명령은 돌이킬 수 없는 결과를 가져오기도 하지요. subprocess 모듈을 불러오지 않으면 괜찮을까요.

실제로는 훨씬 더 심각할 수 있는데, 전역 함수인 __import__()는 인자로 모듈의 이름을 받은 뒤 그 모듈을 import해서 그에 대한 참조를 반환합니다. eval()과 결합하면 단 한 줄의 코드로 여러분 컴퓨터에 있는 모든 파일을 삭제할 수 있습니다.

  1. 'rm -rf ~' 명령의 결과를 상상해보세요. 사실 화면에 출력되는 결과는 없을 것이고요, 그냥 여러분의 홈 디렉토리에 아무 파일도 남아있지 않을 겁니다.

eval() is EVIL

문제는 신뢰받지 않은 임의의 코드를 실행한다는 부분이지요. eval() 함수는 신뢰성 있는 입력에만 사용해야 합니다. 물론 어떤 것이 "신뢰"를 가지고 있는 입력인지 판단하기가 쉽지는 않습니다. 하지만 제가 확실히 말할 수 있는 것도 있습니다. 작고 재미있는 웹 서비스를 만들겠다며 이 복면산 프로그램을 인터넷에 띄우지는 마세요. "이 함수는 eval()을 호출하기 전에 복잡한 문자열 조작을 하니까 그걸 알아볼 사람은 없겠지"라고 잘못된 생각은 마시기 바랍니다. 누군가는 그 모든 문자열 조작을 지나쳐서 은밀하고 지저분한 실행 코드를 집어넣을 겁니다. 그러면 여러분의 서버와는 안녕이죠.

그러면 좀 안전하게 식을 평가하는 방법은 없을까요? eval()을 샌드박스(sandbox)에 넣어 바깥 세계에 영향을 주거나 망칠 수 없게요. 음. 있기도 하고 없기도 하네요.

  1. eval() 함수에 전달되는 두, 세 번째 인자는 표현식을 평가할 때 각각 전역(global), 지역(local) 네임스페이스(namespace)가 됩니다. 이 예제에서는 두 개 모두 비어있습니다. 즉, 문자열 "x * 5"를 평가할 때 x라는 변수를 전역/지역 네임스페이스 어디에서도 찾을 수 없다는 이야기지요. 그래서 eval()은 예외를 발생시킵니다.
  2. 원하는 변수를 명시적으로 적어서 전역 네임스페이스에 그 값을 포함시킬 수도 있습니다. 그러면 평가하는 동안 그 변수에(만) 접근할 수 있습니다.
  3. math 모듈을 방금 import 했더라도 eval() 함수의 네임스페이스에 추가해 주지 않았으므로 평가는 실패합니다.

히히 쉽네요. 이제 웹 서비스를 만들어도 되겠지용~

  1. 전역/지역 네임스페이스에 빈 사전을 넘겨준다고 하더라도 파이썬의 모든 내장 함수에는 여전히 접근 가능합니다. 그래서 pow(5, 2)는 작동합니다. 5와 2는 숫자이고 pow()는 내장 함수거든요.
  2. 그리고 불행히도(왜 불행인지 감이 안 잡히시면 계속 읽어주세요) __import__() 함수 역시 내장 함수라서 잘 접근할 수 있습니다.

네, 그 말은 eval()을 부를 때 전역/지역 네임스페이스를 둘 다 비우더라도 여전히 나쁜 짓을 할 수가 있다는 것이지요.

아이고. 복면산 프로그램으로 웹 서비스를 만들지 않기를 잘 했군요. eval()을 좀 더 안전하게 쓰는 방법은 없나요? 음. 있기도 하고 없기도 합니다.

  1. 신뢰성 없는 표현을 안전하게 평가하려면 전역 네임스페이스 사전에 "__builtins__"를 None으로 설정해야 합니다. 내부적으로 "내장" 함수는 "__builtins__"이라는 의사 모듈(pseudo-module)에 포함되어 있습니다. 이 의사 모듈(즉, 내장 함수의 집합)은 이렇게 명시적으로 덮어 써주지 않으면 평가시 항상 접근 가능합니다.
  2. __builtins__를 덮어쓰세요. __builtin__이나 __built-ins__가 아닙니다.

그래서 이제 eval()은 안전한가요? 음. 그렇기도 하고 아니기도 합니다.

  1. __builtins__에 접근할 수 없더라도 DOS(denial-of-service) 공격을 당할 수 있습니다. 예를 들어, 2의 2147483647 승을 평가한다면 여러분의 CPU 사용률은 100%에서 꽤나 오랫동안 머물겁니다. (커맨드라인에서 이 명령어를 실행해버렸다면 Ctrl-C를 몇 번 눌러서 빠져나오세요.) 기술적으로, 이 표현식은 결국에 값을 반환하겠지만, 그동안 여러분의 서버는 거의 아무 것도 못 할 것입니다.

결론적으로, 신뢰받지 못한 파이썬 표현식을 어느 정도 안전하게 실행하는 것이 가능하긴 하지만, 실제로 그렇게 유용하지는 못한 것 갈습니다. 그냥 좀 갖고 논다거나 신뢰성 있는 입력만 준다면 괜찮을 겁니다. 하지만 다른 무언가를 하려면 곧 여러 문제에 봉착하게 되겠지요.

이 모두를 모아서

정리해볼까요: 이 프로그램은 복면산 퍼즐을 아주 무식한 방법으로 풀어냅니다. 즉, 모든 가능한 답을 하나 하나 돌려가면서 맞는지 안 맞는지 대조해보지요. 이를 위해...

  1. re.findall() 함수를 이용해서 퍼즐로 주어진 문자열 내의 모든 문자를 찾습니다.
  2. 집합과 set() 함수를 이용해서 배열의 유일한 문자만 뽑아냅니다.
  3. assert 문을 이용해서 10개 이상의 유일한 문자가 있는지 확인합니다. (넘는다면 퍼즐을 풀 수 없다는 이야기입니다.)
  4. 제너레이터 객체를 이용해서 문자를 그에 해당하는 ASCII 코드로 변환합니다.
  5. itertools.permutations() 함수를 이용해서 가능한 모든 해를 계산합니다.
  6. translate() 문자열 메소드를 이용해서 각각의 해를 파이썬 표현식으로 변환합니다.
  7. eval() 함수를 이용해 변환된 파이썬 표현식을 평가합니다.
  8. 평가가 참인 첫 번째 해를 반환합니다.

... 단 14줄의 코드입니다.

더 읽을 거리

itertools module
itertools — Iterator functions for efficient looping
Watch Raymond Hettinger’s “Easy AI with Python” talk at PyCon 2009
Recipe 576615: Alphametics solver, Raymond Hettinger’s original alphametics solver for Python 2
More of Raymond Hettinger’s recipes in the ActiveState Code repository
Alphametics on Wikipedia
Alphametics Index, including lots of puzzles and a generator to make your own

제가 코드를 파이썬 3로 포팅해서 이 챕터에서 사용할 수 있게 해준 Raymond Hettinger에게 큰 감사를 드립니다.


이 강의는 영어로 된 원문을 기초로 작성되었으며, Creative Commons Attribution Share-Alike 라이센스하에 자유로운 변경, 배포가 가능합니다

6063 읽음
이전 챕터 7. 클래스와 반복자(Classes & Iterators)
다음 챕터 9. 단위 테스트(Unit Testing)

저자

토론이 없습니다

Please log in to leave a comment

16.5.11618.20190612.allo
문제 보고 · 사용조건 · 개인정보보호
래블업 주식회사 · 대한민국 서울 테헤란로 145 · 대표: 신정규 · 사업자번호: 864-88-00080 · +82 70-8200-2587

거절 확인

닫기
좋아요 책갈피 토론

아래 주소를 복사하세요