CODICT
10. Python으로 알아보는 병행성과 네트워크 본문
지금까지 작성한 대부분의 프로그램은 한 장소(싱글 머신)에서 한 번에 한 라인씩(순차적으로) 실행했습니다. 여러 장소(분산 컴퓨팅 혹은 네트워킹)에서 동시에 여러 개의 일(병행성(Concurrency))을 할 수 있습니다. 시간과 공간에 도전하는 여러 가지 좋은 이유가 있습니다.
- 성능(Performance)
- 느린 요소(Component)를 기다리지 않고, 빠른 요소를 바쁘게 유지합니다.
- 견고함(Robustness)
- 하드웨어 및 소프트웨어의 장애를 피하기 위해 작업을 복제하여 여러 가지 안정적인 방식으로 운영합니다.
- 간소화(Simplicity)
- 복잡한 작업을 좀 더 이해하기 쉽고, 해결하기 쉬운 여러 작은 작업으로 분해합니다.
- 커뮤니케이션(Communication)
- 데이터(바이트)를 보내고 싶은 곳에 원격으로 전송하고, 다시 데이터를 수신받는다.
이번 게시글에서는 먼저 병행성에 대해 이야기합니다(네트워킹 기술이 없는 병행성은 이전 게시글의 '프로세스 생성하기'에 설명되어 있습니다). 그러고 나서 콜백(Callback), 그린 스레드(Green Thread), 코루틴(Coroutine) 같은 다른 접근 방법에 대해 다룹니다. 마지막으로 병행성과 네트워크에 대한 기술을 실제로 어떻게 사용하는지 살펴봅니다.
1. 병행성
파이썬 공식 사이트는 일반적으로 표준 라이브러리에서 병행성을 설명합니다. 이 페이지는 다양한 패키지와 기술에 대한 링크가 있습니다. 이번 장에서는 그중 가장 유용한 것들을 살펴봅니다.
컴퓨터가 일을 수행하면서 뭔가 기다린다면, 보통 다음 두 가지 이유 중 하나입니다.
- I/O 바운드
- 대부분 이 경우에 해당합니다. 컴퓨터의 CPU는 엄청나게 빠릅니다. 메모리보다 몇 백배, 디스크나 네트워크보다 몇 천배 빠릅니다.
- CPU 바운드
- 과학이나 그래픽 작업과 같이 엄청난 계산이 필요할 때 발생합니다.
다음 두 가지 용어는 병행성과 관련 있습니다.
- 동기(Synchronous)
- 한 줄의 장례 행렬처럼, 한 작업은 다른 작업을 따릅니다.
- 비동기(Asynchronous)
- 사람들이 각기 다른 차를 타고 파티에 가는 것처럼, 작업들이 독립적입니다.
현실 문제를 간단한 시스템과 작업으로 진행하는 것처럼, 어떤 측면에서는 병행성이 필요합니다. 예를 들어 웹사이트를 생각해봅시다. 사용자들에게 정적 및 동적 페이지를 빠르게 제공해야 합니다. 만약 어떤 작업이 길어져서 결과가 화면에 늦게 표시되면 사용자들이 짜증을 낼 것입니다. 구글이나 아마존 같은 곳에서 웹 페이지가 조금만 느려지더라도 트래픽이 빠르게 감소한다는 결과를 테스트했습니다.
파일을 업로딩 하거나 이미지 크기 조정 혹은 데이터베이스 쿼리를 질의하는 데 시간이 오래 걸리는 경우에는 무엇을 해야 할까요? 동기 웹 서버 코드 내에서는 아무것도 할 수 없습니다. 어떤 작업이 끝날 때까지 기다려야 하기 때문입니다.
싱글 머신에서 다수의 작업을 가능한 한 빠르게 처리하고 싶다면, 이들을 독립적으로 만들어야 합니다. 느린 작업이 나머지 다른 작업을 블로킹하면 안 됩니다.
이전에 작성한 게시글에서는 싱글 머신에 작업을 덮어 씌우기 위해 multiprocessing 모듈을 사용하는 코드를 보여줬습니다. 만약 이미지의 크기를 조정한다면, 웹 서버에서는 비동기적으로 동시에 실행하기 위해 이미지를 조정하는 별도의 전용 프로세스를 호출할 수 있습니다. 이미지를 조정하는 멀티 프로세스를 호출하여 애플리케이션을 수평으로 확장할 수 있습니다.
묘책은 서로 같이 일을 처리하기 위해 모든 작업을 가져오는 것입니다. 모든 공유 제어 또는 상태에서는 병목(Bottleneck) 현상이 발생할 수 있습니다. 심지어 더 큰 묘책은 실패로 빠질 수 있습니다. 병렬 컴퓨팅이 일반 컴퓨팅보다 더 어렵기 때문입니다. 더 많은 일이 잘못될 수 있으며, 단말 간의 작업 수행 성공 확률도 낮습니다.
이러한 복잡성을 어떤 방법으로 처리할 수 있을까요? 여러 작업을 잘 관리하는 큐(Queue)로 시작해봅시다.
1.1. 큐
큐는 리스트와 같습니다. 일을 한쪽 끝에서 추가하고, 다른 쪽 끝에서 가져갑니다. 즉, 먼저 들어온 순서대로 가져갑니다. 일반적으로 큐는 FIFO(First In First Out)라고 합니다.
설거지를 한다고 생각해봅시다. 접시들이 쌓여 있습니다. 다양한 방법으로 설거지를 할 수 있습니다. 먼저 첫 번째 접시를 씻고, 말린 후, 정리합니다. 두 번째 접시, 그다음 접시도 마찬가지로 반복합니다. 또는 일괄적으로 모든 접시를 먼저 씻은 후, 말려서 정의할 수 있습니다. 물론 각 과정에 싱크대와 접시를 말리는 건조대가 있다고 가정합니다. 이들은 모두 하나의 워커(Worker)로 한 번에 한 가지 일을 수행하는 동기 접근법입니다.
다른 대안으로 한두 개의 헬퍼를 사용할 수 있습니다. 만약 여러분이 접시를 씻는다면(Washer), 씻은 접시를 건조기에 넣어서 말리고(Dryer), 마른 접시를 정리하는 기계에 넣습니다(Put-away-er). 각 과정이 같은 속도로 진행되면 혼자 하는 것보다 설거지가 훨씬 빨리 끝납니다.
그러나 접시를 씻는 속도가 건조하는 속도보다 빠르다면, 젖은 접시를 바닥에 쌓거나 건조기에 여유가 생길 때까지 기다려야 합니다. 그리고 접시를 씻는 속도가 건조하는 속도보다 느리면 건조기는 접시를 씻을 때까지 기다려야 합니다. 다수의 워커가 있으나, 전반적인 작업은 여전히 동기적으로 진행됩니다. 그리고 가장 느린 워커에 따라서 속도가 결정됩니다.
'백지장도 맞들면 낫다'라는 옛말이 있습니다. 워커가 추가되면 외양간을 짓든 설거지를 하든 작업을 빠르게 할 수 있습니다. 이것은 큐를 포함합니다.
일반적으로 큐는 메시지를 빠르게 전달합니다. 메시지는 모든 종류의 정보가 될 수 있습니다. 분산 작업 관리를 위한 큐의 경우 작업 큐(Work Queue, Job Queue, Task Queue)라고 알려져 있습니다. 싱크대의 각 접시는 작업 가능한 사람에게 할당됩니다. 그 사람은 접시를 씻고, 사용 가능한 건조기에 넘겨줍니다. 그리고 건조기는 마른 접시를 정리하는 기계에 넘겨줍니다. 이는 동기(워커들은 접시가 처리될 때까지 기다린 후 다른 워커에 넘겨줍니다)이거나 비동기(접시가 다른 속도로 워커 사이에 쌓입니다)일 수 있습니다. 충분한 워커가 있고, 이 워커들이 작업을 계속하는 한, 접시들은 빠르게 처리됩니다.
1.2. 프로세스
큐를 여러 가지 방법으로 구현할 수 있습니다. 싱글 머신에서 표준 라이브러리 multiprocessing 모듈은 Queue 함수를 포함합니다. 한 대의 식기세척기와 여러 대의 건조기 프로세스를 시뮬레이션해봅시다. 그리고 dish_queue가 이 과정에 개입합니다. 이 프로그램을 dishes.py라고 합시다.
import multiprocessing as mp
def washer(dishes, output):
for dish in dishes:
print('Washing', dish, 'dish')
output.put(dish)
def dryer(input):
while True:
dish = input.get()
print('Drying', dish, 'dish')
input.task_done()
dish_queue = mp.JoinableQueue()
dryer_proc = mp.Process(target=dryer, args=(dish_queue,))
dryer_proc.daemon = True
dryer_proc.start()
dishes = ['salad', 'bread', 'entree', 'dessert']
washer(dishes, dish_queue)
dish_queue.join()
프로그램을 실행해봅시다.
$ python dishes.py
#Washing salad dish
#Washing bread dish
#Drying salad dish
#Washing entree dish
#Washing dessert dish
#Drying bread dish
#Drying entree dish
#Drying dessert dish
이 큐는 일련의 접시를 처리하는 간단한 파이썬 이터레이터(Iterator)와 매우 비슷합니다. 이것은 실제로 분리된 프로세스를 시작하며, 식기세척기(Washer)와 건조기(Dryer)가 통신합니다. 이 코드에서는 JoinableQueue 함수와 모든 접시가 건조되었다는 것을 식기세척기가 알게 하는 join() 메소드를 사용했습니다. multiprocessing 모듈에는 다른 큐 타입도 있는데, 자세한 사항과 코드는 문서를 참고하세요.
1.3. 스레드
스레드는 한 프로세스 내에서 실행됩니다. 프로세스의 모든 자원에 접근할 수 있으며, 다중인격가 비슷합니다. multiprocessing 모듈은 프로세스 대신에 스레드를 사용하는 threading이라 불리는 친척이 있습니다(사실 multiprocessing 모듈은 프로세스 기반의 대응으로 나중에 설계되었습니다). 이전 프로세스 코드를 스레드로 다시 구현해봅시다.
import threading
def do_this(what):
whoami(what)
def whoami(what):
print("Thread %s says: %s" % (threading.current_thread(), what))
if __name__ == "__main__":
whoami("I'm the main program")
for n in range(4):
p = threading.Thread(target=do_this, args=("I'm function %s" % n,))
p.start()
실행 결과는 다음과 같습니다.
#Thread <_MainThread(MainThread, started 140684444002112)> says: I'm the main program
#Thread <Thread(Thread-1, started 140684421764864)> says: I'm function 0
#Thread <Thread(Thread-2, started 140684338132736)> says: I'm function 1
#Thread <Thread(Thread-3, started 140684329740032)> says: I'm function 2
#Thread <Thread(Thread-4, started 140684321347328)> says: I'm function 3
프로세스 기반의 dishes.py 코드를 스레드로 구현해봅시다.
import threading, queue
import time
def washer(dishes, dish_queue):
for dish in dishes:
print("Washing", dish)
time.sleep(5)
dish_queue.put(dish)
def dryer(dish_queue):
while True:
dish = dish_queue.get()
print("Drying", dish)
time.sleep(10)
dish_queue.task_done()
dish_queue = queue.Queue()
for n in range(2):
dryer_thread = threading.Thread(target=dryer, args=(dish_queue,))
dryer_thread.start()
dishes = ['salad', 'bread', 'entree', 'dessert']
washer(dishes, dish_queue)
dish_queue.join()
multiprocessing 모듈과 threading 모듈의 한 가지 다른 점은 threading 모듈에는 terminate() 함수가 없다는 것입니다. 실행되고 있는 스레드를 종료할 수 있는 쉬운 방법은 없습니다. 자신과 코드의 모든 타입에 문제를 일으킬 수 있기 때문입니다.
스레드는 위험합니다. C와 C++ 언어에서 메모리 관리를 수동으로 하는 것처럼, 매우 찾기 힘든 버그가 발생할 수 있습니다. 스레드를 사용하려면 프로그램의 모든 코드와 이 프로그램을 사용하는 외부 라이브러리에서 반드시 스레드-세이프(Thread-Safe)한 코드를 작성해야 합니다. 이전 예시 코드의 스레드들은 전역 변수를 공유하지 않아서 아무런 문제 없이 독립적으로 실행될 수 있었습니다.
불가사의한 유령의 집을 관찰하는 사람이라고 상상해봅시다. 유령들은 복도를 배회하지만, 서로 다른 유령을 인식하지 않습니다. 이들은 언제든지 유령의 집 물건을 보거나, 이동하거나, 없애거나, 추가할 수 있습니다.
불안한 마음으로 유령의 집을 걸어가면서 천천히 물건을 살펴봅니다. 그리고 조금 전에 놓여 있던 촛대가 갑자기 없어졌다는 것을 알아챘습니다. 여기서 유령의 집은 프로세스, 유령은 스레드, 물건은 프로그램의 변수에 비유할 수 있습니다. 만약 유령의 집에 있는 물건들만 볼 수 있다면, 아무런 문제가 없을 것입니다. 이것은 상수값을 읽거나 변수를 변경하지 않고 값을 읽는 스레드와 같습니다.
그런데 일부 보이지 않는 유령들은 손전등이나 벽난로가 불타오를 때 비친 모습을 통해 확인할 수 있습니다. 정말 포착하기 힘든 유령은 알아차리지 못하게 방에 있는 물건들을 변경합니다.
매우 중요한 물건인데도 불구하고 누가, 언제, 어떻게 했는지 매우 파악하기 힘듭니다.
멀티 스레드 대신 멀티 프로세스를 사용했다면, 많은 집을 가지고 있는 것과 같습니다. 하지만 각 집에는 한 사람만 살고 있습니다. 만약 벽난로 앞에 와인을 놓은 경우, 와인은 한 시간 뒤에도 거기에 있을 것입니다. 일부가 증발되었다면, 아마 같은 장소에서 없어졌을 것입니다.
스레드는 전역 데이터가 관여하지 않을 때 유용하고 안전합니다. 특히 일부 I/O 작업을 완료할 때까지 기다리는 시간을 절약하는 데 유용하게 쓰입니다. 이러한 경우 각 작업은 완전히 별개의 변수를 가지고 있기 때문에 데이터와 씨름할 필요가 없습니다.
그러나 전역 데이터를 변경하기 위해 때때로 스레드를 사용하는 좋은 이유가 있습니다. 여러 개의 스레드를 사용하는 일반적인 이유는 일부 데이터 작업을 나누기 위해서입니다. 이 경우 데이터 변경에 대한 확실한 정도를 예상할 수 있습니다.
데이터를 안전하게 공유하는 일반적인 방법은 스레드에서 변수를 수정하기 전에 소프트웨어 락(Lock)을 적용하는 것입니다. 이것은 한 스레드에서 변수를 수정하는 동안 다른 스레드의 접근을 막아줍니다. 이것은 방에 있는 유령을 사냥하지 않고, 지켜보는 감시자와 같습니다. 묘책은 언락(Unlock)할지 기억해야 한다는 것입니다. 또한 락은 중첩될 수 있습니다. 만약 또 다른 유령 감시자가 가은 방 혹은 그 집을 감시하는 경우에는 어떻게 될까요? 관습적으로 락을 사용하지만, 똑바로 사용하는 것은 매우 어렵습니다.
참고로, 파이썬의 스레드는 CPU 바운드 작업을 빠르게 처리하지 못합니다. GIL(Global Interpreter Lock)이라는 표준 파이썬 시스템 세부 구현 사항 때문입니다. GIL은 파이썬 인터프리터의 스레딩 문제를 피하기 위해 존재합니다. 실제로 파이썬의 멀티 스레드 프로그램은 싱글 스레드 혹은 멀티 프로세스 버전의 프로그램보다 느릴 수 있습니다.
다음과 같이 파이썬을 사용할 것을 추천드립니다.
- I/O 바운드 문제 - 스레드 사용
- CPU 바운드 문제 - 프로세스, 네트워킹, 이벤트 사용
1.4. 그린 스레드와 gevent
개발자들은 전통적으로 별도의 스레드나 프로세스를 실행하여 프로그램의 속도가 느린 부분을 피합니다 아파치 웹 서버가 이러한 설계의 예시입니다.
또 하나의 대안은 이벤트 기반(Event-Based) 프로그래밍입니다. 이벤트 기반 프로그램은 중앙 이벤트 루프를 실행하고, 모든 작업을 조금씩 실행하면서 루프를 반복합니다. 엔진엑스 웹 서버는 이러한 설계를 따르므로, 일반적으로 아파치 웹 서버보다 빠릅니다.
gevent 라이브러리는 이벤트 기반이며, 멋진 묘책을 수행합니다. 보통 명령 코드를 작성하고, 이 조각들을 코루틴(Coroutine)으로 변환합니다. 코루틴은 다른 함수와 서로 통신하여, 어느 위치에 있는지 파악하고 있는 제너레이터와 같습니다. gevent는 블로킹(Blocking) 대신 이러한 메커니즘을 사용하기 위해 파이썬의 socket과 같이 많은 표준 객체를 수정합니다. gevent는 일부 데이터베이스 드라이버처럼 C로 작성된 파이썬의 확장 코드와 작동하지 않습니다.
pip를 이용해서 gevent를 설치합니다.
$ pip install gevent
gevent 웹사이트에는 다양한 예시 코드가 있습니다. 추후에 작성될 게시글에서 DNS(Domain Name System)에 대해 설명할 때 socket 모듈의 gethostbyname() 함수를 볼 수 있을 것입니다. 이 함수는 동기적이어서 (아마도 몇 초 정도) 기다려야 합니다. 기다리는 동안 이 함수는 IP 주소를 찾기 위해 전 세계에 퍼져있는 네임 서버를 쫓아다닙니다. 그러나 독립적으로 여러 사이트의 주소를 찾기 위해 gevent 모듈의 함수를 사용할 수 있습니다. 다음 코드를 gevent_test.py로 저장합니다.
import gevent
from gevent import socket
hosts = ['www.nave.com', 'www.daum.net', 'www.google.com']
jobs = [gevent.spawn(gevent.socket.gethostbyname, host) for host in hosts]
gevent.joinall(jobs, timeout=5)
for job in jobs:
print(job.value)
위 코드에는 한 줄의 리스트 컴프리헨션이 있습니다. 각 호스트네임은 차례차례 gethostbyname()의 호출에 전달됩니다. gevent 버전의 gethostbyname()이기 때문에 비동기적으로 실행됩니다.
gevent_test.py를 실행합니다.
$ python gevent_test.py
#64.99.80.121
#203.133.167.81
#172.217.161.68
gevent.spawn()은 각각의 gevent.socket.gethostbyname(host)를 실행하기 위해 greenlet(그린 스레드(Green Thread) 혹은 마이크로 스레드(Microthread)라고 알려져 있음)을 생성합니다.
greenlet과 일반적인 스레드의 차이점은 블록(block)을 하지 않는 것입니다. 만약 한 스레드에서 무슨 일이 발생하여 블록 되었다면, gevent는 제어를 다른 하나의 greenlet으로 바꿉니다.
gevent.joinall() 메소드는 생성된 모든 작업이 끝날 때까지 기다립니다. 그리고 호스트네임에 대한 IP 주소를 한 번에 얻게 됩니다.
gevent 버전의 socket 대신 기억하기 쉬운 이름의 몽키-패치(Monkey-Patch) 함수를 쓸 수 있습니다. 이 함수는 gevent 버전의 모듈을 호출하지 않고, greenlet을 사용하기 위해 socket과 같은 표준 모듈을 수정합니다. 이것은 gevent에 적용하고 싶은 작업이 있을 때 유용합니다. 심지어 gevent에 접근할 수 없는 코드에도 적용할 수 있습니다.
프로그램의 맨 위에 다음 코드를 추가합니다.
from gevent import monkey
monkey.patch_socket()
이것은 여러분 프로그램과 심지어 표준 라이브러리에서도 소켓이 호출되는 모든 곳에 gevent 소켓을 사용하겠다는 뜻입니다. 다시 말하지만 이것은 C로 작성된 라이브러리가 아닌 파이썬 코드에서만 작동합니다.
몽키-패치 함수는 심지어 표준 라이브러리 모듈보다 더 많습니다.
from gevent import monkey
monkey.patch_all()
gevent의 영향을 가능한 한 많이 받아서 속도가 향상될 수 있도록, 프로그램 상단에 위 코드를 추가합니다.
다음 프로그램을 gevent_monkey.py로 저장합니다.
import gevent
from gevent import monkey; monkey.patch_all()
import socket
hosts = ['www.naver.com', 'www.daum.net', 'www.google.com']
jobs = [gevent.spawn(socket.gethostbyname, host) for host in hosts]
gevent.joinall(jobs, timeout=5)
for job in jobs:
print(job.value)
프로그램을 실행합니다.
$ python gevent_monkey.py
#210.89.160.88
#211.231.99.17
#216.58.197.196
gevent를 사용하면 잠재적 위험이 있습니다. 모든 이벤트 기반의 시스템에서, 실행하는 각 코드 단위는 상대적으로 빠르게 처리되어야 합니다. 논블로킹(Nonblocking) 임에도 불구하고 많은 일을 처리해야 하는 코드는 여전히 느립니다.
어떤 사람은 몽키-패치를 하는 것을 불안하게 생각합니다. 아직까지 핀터레스트(Pinterest)와 같은 규모가 큰 사이트에서는 속도를 높이기 위해 gevnet를 사용합니다 약의 처방전에 있는 글시처럼, 놓여 있는 상황과 지시에 따라 gevent를 잘 활용하면 됩니다.
참고로, tornado와 gunicorn이라는 인기 있는 이벤트 기반의 두 프레임워크가 있습니다. 이들은 저수준의 이벤트 처리와 빠른 웹 서버 모두를 제공합니다. 아파치와 같은 전통적인 웹 서버 없이 빠른 웹사이트를 구축하기 위해 이들을 접해볼 가치가 있습니다.
1.5 twisted
twisted는 비동기식 이벤트 기반 네트워킹 프레임워크입니다. twisted는 데이터를 받거나 커넥션을 닫는 것과 같이 이벤트오 함수를 연결합니다. 그리고 이 함수는 이러한 이벤트가 발생할 때 호출됩니다. 이것은 콜백(Callback) 디자인으로 되어 있으며, 자바스크립트의 코드와 친숙해 보입니다. 콜백 디자인에 익숙하지 않다면 이해하기 힘들 수도 있습니다. 일부 개발자는 콜백 기반의 코드는 애플리케이션이 커지면 관리하기 어려워진다고 이야기합니다.
다음의 명령어로 twisted를 설치합니다.
$ pip install twisted
twisted는 TCP와 UDP 위에서 많은 인터넷 프로토콜을 지원하는 큰 패키지입니다 일단 twisted 예시 코드에서 짧고 간단한 똑똑(Knock-Knock) 서버와 클라이언트를 봅시다. 먼저 서버 측 knock_server.py를 살펴봅시다.
from twisted.internet import reactor, protocol
class Knock(protocol.Protocol):
def dataReceived(self, data):
print('Client:', data)
if data.startswith(b"Knock knock"):
response = b"Who's there?"
else:
response = data + b" who?"
print("Server:", response)
self.transport.write(response)
def main():
factory = protocol.ServerFactory()
factory.protocol = Knock
reactor.listenTCP(8000,factory)
reactor.run()
if __name__ == '__main__':
main()
클라이언트 측의 knock_client.py를 살펴봅시다.
from __future__ import print_function
from twisted.internet import reactor, protocol
class KnockClient(protocol.Protocol):
def connectionMade(self):
self.transport.write(b"Knock knock")
def dataReceived(self, data):
if data.startswith(b"Who's there?"):
response = b"Disappearing client"
self.transport.write(response)
else:
self.transport.loseConnection()
reactor.stop
def connectionLost(self, reason):
print("connection lost")
class KnockFactory(protocol.ClientFactory):
protocol = KnockClient
def clientConnectionFailed(self, connector, reason):
print("Connection failed - goodbye!")
reactor.stop()
def clientConnectionLost(self, connector, reason):
print("Connection lost - goodbye!")
reactor.stop()
def main():
f = KnockFactory()
reactor.connectTCP("localhost", 8000, f)
reactor.run()
if __name__ == '__main__':
main()
먼저 서버를 실행합니다.
$ python knock_server.py
그러고 나서 클라이언트를 실행합니다.
$ python knock_client.py
서버와 클라이언트는 메시지를 교환합니다. 그리고 서버에서 대화 내용을 출력합니다.
#Client: b'Knock knock'
#Server: b"Who's there?"
#Client: b'Disappearing client'
#Server: b'Disappearing client who?'
클라이언트는 종료되고, 서버는 또 다른 요청을 기다립니다.
메시지를 직접 입력하여 전달하고 싶다면 twisted 문서에서 다른 예시들을 살펴보세요.
1.6. asyncio
귀도 반 로섬은 파이썬의 병행성 이슈를 들고 나왔습니다. 많은 패키지는 자신의 이벤트 루프를 가지며, 각 이벤트 루프는 자신이 유일하길 원합니다. 콜백, greenlet 등과 같은 메커니즘을 그가 어떻게 조정할 수 있을까요? 수많은 토론 후, 그는 '비동기 입출력 지원 재정리: ayncio 모듈'(코드명 튤립)을 제안했습니다. asyncio 모듈은 파이썬 3.4에서 먼저 등장했습니다. 현재 twisted와 gevent 그리고 다른 비동기 메소드와 호환될 수 있는 일반적인 이벤트 루프를 제공합니다. 파이썬 공식 사이트에는 asyncio 모듈의 다양한 예제가 수록되어 있습니다.
1.7. Redis
프로세스 또는 스레드를 사용한 이전의 설거지 코드는 싱글 머신에서 실행했습니다. 싱글 머신이나 네트워크를 통해 실행할 수 있는 또 다른 큐 접근법을 시도해봅시다. 싱글 머신에서는 멀티 프로세스와 스레드를 실행하기에 충분하지 않을 때도 있습니다. 이번 절에서는 싱글박스(하나의 머신)와 멀티박스 병행성 사이를 연결해주는 브리지(다리)에 대해 다룹니다.
이 절의 코드를 실행하려면 Redis 서버와 Redis 파이썬 모듈이 필요합니다. Redis에 대한 설명은 이전 게시글에서 한 적이 있습니다. 그곳에서 Redis는 데이터베이스 역할을 합니다. 여기서는 병행성에 대해 다룹니다.
큐를 만들 수 있는 빠른 방법은 Redis의 리스트입니다. Redis 서버는 하나의 머신에서 실행합니다. 클라이언트는 같은 머신에서 실행하거나 네트워크를 통해서 접근할 수 있습니다. 두 경우 모두 클라이언트는 TCP를 통해 서버와 통신을 하여 네트워킹을 합니다. 하나 이상의 공급자 클라이언트는 리스트의 한쪽 끝에 메시지를 푸시(Push)합니다. 하나 이상의 클라이언트 워커는 리스트를 감시하며, 블로킹 팝(Pop) 연산을 수행합니다. 리스트가 비어 있는 경우에는 메시지를 기다립니다. 메시지가 도착하자마자 첫 번째 워커가 메시지를 처리합니다.
이전의 프로세스와 스레드 기반의 코드처럼 redis_washer.py는 접시의 시퀀스를 생성합니다.
import redis
conn = redis.Redis()
print('Washer is starting!')
dishes = ['salad', 'bread', 'entree', 'dessert']
for dish in dishes:
msg = dish.encode('utf-8')
conn.rpush('dishes', msg)
print('Washed', dish)
conn.rpush('dishes', 'quit')
print('Washer is done')
for 문에서 접시의 이름을 포함한 4개의 메시지를 생성합니다. 그리고 마지막에 'quit' 메시지를 추가합니다. 각 메시지는 Redis 서버의 dishes 리스트에 추가됩니다(파이썬 리스트에 추가하는 것과 비슷합니다).
첫 번째 접시가 준비되자마자 redis_dryer.py에서 작업을 수행합니다.
import redis
conn = redis.Redis()
print('Dryer is starting!')
while True:
msg = conn.blpop('dishes')
if not msg:
break
val = msg[1].decode('utf-8')
if val == 'quit':
break
print('Dried', val)
print('Dishes are dried')
이 코드는 첫 번째 토큰이 'dishes'인 메시지를 기다립니다. 그리고 각 접시를 건조합니다. 종료(quit) 메시지를 받으면 루프를 끝냅니다.
먼저 건조기(Dryer)를 가동한 후, 식기세척기(Washer)를 가동합니다. 끝에 있는 &는 첫 번째 프로그램을 백그라운드(Background)에서 실행하겠다는 뜻입니다. 실행은 하지만, 키 입력을 더 이상 받지 않겠다는 것입니다. 이 코드는 리눅스, OS X, 윈도우에서 작동합니다. 그다음에는 포그라운드(Foreground)에서 식기세척기를 가동합니다. 혼합된 두 프로세스의 출력 결과를 볼 수 있습니다.
$ python redis_dryer.py &
#Dryer is starting
$ python redis_washer.py
#Washer is starting
#Washed salad
#Dried salad
#Washed bread
#Dried bread
#Washed entree
#Dried entree
#Washed dessert
#Dried dessert
#Washer is done
#Dried dessert
#Dishes are dried
식기세척기 프로세스로부터 접시의 ID가 Redis에 도착하자마자 건조기 프로세스는 이 접시를 가지고 와서 건조합니다. 마지막 센티널(Sentinel) 값(한 단위 정보의 시작 또는 끝 또는 특별한 지점을 나타내는 값)의 'quit' 문자열을 제외하고, 각 접시의 ID는 숫자입니다 건조기 프로세스에서 'quit' 접시 ID를 읽을 때, 건조기 프로세스는 종료되고, 일부 백그라운드 프로세스에 대한 정보가 터미널에 출력됩니다(시스템에 따라 다릅니다). 데이터 스트림 자체에서 뭔가 특별한 것을 나타내기 위해 센티널(다른 점에서 무효한 값)을 사용할 수 있습니다. 이 경우 quit 문자열로 작업이 완료되었다는 것을 나타냈습니다.. 센티널 값을 사용하지 않는다면 다음과 같이 더 많은 프로그램 로직을 추가해야 합니다.
- 최대 접시 수(센티널 역할을 함)가 넘어갔을 때 작업을 종료합니다.
- 특정 구간 외(데이터 스트림이 아닐 때) 프로세스 간 통신을 합니다.
- 특정 시간 간격 내에 새로운 데이터가 없다면 작업을 종료합니다.
설거지 코드를 조금 수정해봅시다.
- 여러 건조기 프로세스를 생성합니다.
- 각 건조기에 대한 시간제한(Timeout)을 추가합니다.
다음은 새로운 redis_dryer2.py입니다.
def dryer():
import redis
import os
import time
conn = redis.Redis()
pid = os.getpid()
timeout = 20
print('Dryer process %s is starting' % pid)
while True:
msg = conn.blpop('dishes', timeout)
if not msg:
break
val = msg[1].decode('utf-8')
if val == 'quit':
break
print('%s: dried %s' % (pid, val))
time.sleep(0.1)
print('Dryer process %s is done' % pid)
import multiprocessing
DRYERS = 3
for num in range(DRYERS):
p = multiprocessing.Process(target=dryer)
p.start()
건조기 프로세스를 백그라운드에서, 식기세척기 프로세스를 포그라운드에서 실행합니다.
$ python redis_dryer2.py &
#Dryer process 44447 is starting
#Dryer process 44448 is starting
#Dryer process 44446 is starting
$ python redis_washer.py
#Washer is starting
#Washed salad
#44447: dried salad
#Washed bread
#44448: dried bread
#Washed entree
#44446: dried entree
#Washed dessert
#Washer is done
#44447: dried dessert
한 건조기 프로세스가 quit ID를 읽고 종료합니다.
#Dryer process 44448 is done
20초 후, 다른 건조기 프로세스들은 blpop 호출로부터 시간이 지났다는 None 값을 반환받고, 종료합니다.
#Dryer process 44447 is done
#Dryer process 44446 is done
마지막 건조기의 하위 프로세스가 종료된 후, 건조기 메인 프로그램을 종료합니다.
1.8. 큐를 넘어서
저금 더 나아가서 설거지 프로세스 라인이 중단될 가능성에 대해 생각해봅시다. 만약 어느 연회에서 설거지를 하는 경우, 충분한 워커들이 있나요? 건조기가 먹통이면 어떡하죠? 싱크대가 막혀있으면 어떡하죠? 걱정이 참 많네요.
이들을 어떻게 대처해야 할까요? 다행스럽게도 적용 가능한 다음 몇 가지 기술이 있습니다.
- 실행 후 잊어버리기(Fire and Forget)
- 접시를 전달할 곳에 아무것도 없더라도, 전달한 후 그 결과에 대해 생각하지 않습니다. 접시를 바닥에 놓는 방법입니다.
- 요청-응답(Request-Reply)
- 식기세척기는 건조기로부터, 건조기는 접시를 정리하는 기계로부터 파이프라인의 각 접시에 대한 신호를 받습니다.
- 역압 또는 압력 조절(Back Pressure or Throtting)
- 느린 워커의 속도가 빠른 워커의 속도를 따라갈 수 없을 때, 빠른 워커의 속도를 조절합니다.
실제 시스템에서는 워커를 필요한 수만큼 잘 유지해야 합니다. 그렇지 않으면 접시가 바닥에 쌓이게 됩니다. 일부 워커 프로세스가 최신 메시지를 팝해서 작업 리스트에 추가하는 동안, 새 작업을 대기 리스트에 추가해야 합니다. 메시지가 완료되면 작업 리스트에서 메시지를 제거하고, 완료 리스트에 추가합니다. 이렇게 하면 어떤 작업이 실패했는지 혹은 얼마나 오래 기다렸는지 알 수 있습니다. 이것을 Redis를 사용하여 스스로 구현하거나 누군가 이미 작성하고 테스트한 시스템을 사용할 수 있습니다. 이와 같이 외부 관리가 추가된 파이썬 기반의 큐 패키지들이 있습니다. 그중 일부는 Redis를 사용합니다.
- celery
- 이 패키지는 접해볼 가치가 있습니다. 이번 게시글에서 다뤘던 multiprocessing, gevent 등을 사용하여 동기 또는 비동기 분산 작업을 실행합니다.
- thoonk
- Redis에서 작업 큐와 발행-구독(Publish-Subscribe) 모델(다음 절에서 다룹니다)을 제공하기 위한 패키지입니다.
- rq
- 작업 큐를 위한 Redis 기반의 파이썬 라이브러리입니다.
- Queues
- 파이썬을 포함해서 여러 가지 언어를 기반으로 큐 소프트웨어에 대한 정보를 제공합니다.
2. 네트워크
지금까지는 병행성을 이야기하면서, 주로 시간에 대한 싱글 머신 솔루션(프로세스, 스레드, 그린 스레드)을 다뤘습니다. 또한 네트워크 솔루션(twisted, Redis)의 일부를 간단하게 다뤘습니다. 이 절에서는 공간을 포함한 네트워킹 및 분산 컴퓨팅에 대해 살펴봅니다.
2.1. 패턴
몇 가지 기본 패턴으로 네트워킹 애플리케이션을 만들 수 있습니다.
가장 일반적인 패턴은 요청-응답 패턴으로, 클라이언트-서버 패턴으로도 알려져 있습니다. 이 패턴은 동기적입니다. 클라이언트는 서버의 응답이 올 때까지 기다립니다. 이때까지 많은 요청-응답 예시를 살펴봤습니다. 웹 브라우저 또한 HTTP 요청을 만들어서 웹 서버의 응답을 기다리는 클라이언트입니다.
또 다른 일반적인 패턴은 푸시(Push) 도는 팬아웃(Fanout) 패턴입니다. 데이터를 프로세스 풀(Pool)에 있는 사용 가능한 워커로 전송합니다. 예를 들어 로드 밸런서 뒤에는 웹 서버가 있습니다.
푸시의 반대는 풀(Pull) 또는 팬인(Fanin)입니다. 하나 이상이 소스로부터 데이터를 받습니다. 예를 들어 멀티 프로세스에서 텍스트 메시지를 받아서, 하나의 로그 파일에 작성하는 로거(Logger)가 있습니다.
패턴은 발행-구독(Publish-Subscribe; pub-sub)하는 라디오나 텔레비전의 방송과 유사합니다. 이 패턴은 발행자(Publisher)가 데이터를 전송합니다. 간단한 발행-구독 시스템의 모든 구독자(Subscriber)는 데이터의 복사본을 받습니다. 구독자는 자주 특정 타입에 대한 데이터(토픽(Topic))에 관심이 있음을 표시할 수 있습니다. 그리고 발행자는 단지 토픽을 전송합니다. 푸시 패턴과는 달리, 여러 구독자는 주어진 데이터 조각을 받습니다. 토픽에 대한 구독자가 없는 경우, 데이터는 무시됩니다.
2.2. 발행-구독 모델
발행-구독은 큐가 아닌 브로드캐스트(Broadcast)입니다. 하나 이상의 프로세스가 메시지를 발행합니다. 각 구독자 프로세스는 수신하고자 하는 메시지의 타입을 표시합니다. 각 메시지의 복사본은 타입과 일치하는 구독자에 전송됩니다. 그리고 주어진 메시지는 한 번 또는 여러 번 처리되거나, 아예 처리되지 않을 수도 있습니다. 각 발행자는 단지 브로드캐스팅(Broadcasting)만 할 뿐, 누가 구독하는지 알지 못합니다.
Redis
Redis를 사용하여 발행-구독 시스템을 빠르게 구현할 수 있습니다. 구독자는 토픽, 값과 함께 메시지를 전달하고, 구독자는 수신받고자 하는 토픽을 말합니다.
다음은 구독자의 redis_pub.py입니다.
import redis
import random
conn = redis.Redis(8080)
cats = ['siamese', 'persian', 'maine coon', 'norwegian forest']
hats = ['stovepipe', 'bowler', 'tam-o-shanter', 'fedora']
for msg in range(10):
cat = random.choice(cats)
hat = random.choice(hats)
print('Publish: %s wears a %s' % (cat, hat))
conn.publish(cat, hat)
각 토픽은 고양이의 품종입니다. 그리고 동반되는 메시지는 모자의 타입입니다.
다음은 한 구독자의 redis_sub.py입니다.
import redis
conn = redis.Redis(8080)
topics = ['maine coon', 'persian']
sub = conn.pubsub()
sub.subscribe(topics)
for msg in sub.listen():
if msg['type'] == 'message':
cat = msg['channel']
hat = msg['data']
print('Subscribe: %s wears a %s' % (cat, hat))
구독자는 단지 고양이 타입 'maine coon'과 'persian'에 대한 모든 메시지를 받길 원합니다. listen() 메소드는 딕셔너리를 반환합니다. 메시지의 타입이 'message'일 때, 발행자가 보낸 기준에 일치한 것입니다. 'channel' 키는 토픽(cat)입니다. 'data' 키는 메시지(hat)를 포함합니다.
만약 구독자 없이 발행자를 먼저 실행한다면, 이것은 마치 관객이 없는 무대와 같습니다. 먼저 구독자를 실행합니다.
$ python redis_sub.py
다음에 발행자를 실행합니다. 10개의 메시지를 보낸 후 종료합니다.
$python redis_pub.py
#Publish: maine coon wears a tam-o-shanter
#Publish: siamese wears a tam-o-shanter
#Publish: persian wears a bowler
#Publish: siamese wears a bowler
#Publish: norwegian forest wears a stovepipe
#Publish: siamese wears a tam-o-shanter
#Publish: siamese wears a stovepipe
#Publish: norwegian forest wears a fedora
#Publish: persian wears a tam-o-shanter
#Publish: maine coon wears a stovepipe
구독자는 두 품종의 고양이에만 관심 있습니다.
$python redis_sub.py
#Subscribe: b'maine coon' wears a b'tam-o-shanter'
#Subscribe: b'persian' wears a b'bowler'
#Subscribe: b'persian' wears a b'tam-o-shanter'
#Subscribe: b'maine coon' wears a b'stovepipe'
구독자를 종료하는 코드를 넣지 않아서 계속 메시지를 기다리고 있습니다. 발행자를 재시작하면 구독자는 몇 개의 메시지를 잡아서 출력합니다.
원하는 만큼 구독자와 발행자를 가질 수 있습니다. 메시지에 대한 구독자가 없다면, Redis 서버로부터 메시지가 사라집니다. 구독자가 있다면, 모든 구독자가 메시지를 받을 때까지 메시지는 서버에 남습니다.
ZeroMQ
ZeroMQ는 중앙 서버가 없으므로, 각 발행자는 모든 구독자에 메시지를 전달합니다. ZeroMQ를 위한 고양이-모자의 발행-구독 코드를 다시 작성해봅시다. 다음은 발행자의 zmq_pub.py입니다.
import zmq
import random
import time
host = '*'
port = 6789
ctx = zmq.Context()
pub = ctx.socket(zmq.PUB)
pub.bind('tcp://%s:%s' % (host, port))
cats = ['siamese', 'persian', 'maine coon', 'norwegian forest']
hats = ['stovepipe', 'bowler', 'tam-o-shanter', 'fedora']
time.sleep(1)
for msg in range(10):
cat = random.choice(cats)
cat_bytes = cat.encode('utf-8')
hat = random.choice(hats)
hat_bytes = hat.encode('utf-8')
print('Publish: %s wears a %s' % (cat, hat))
pub.send_multipart([cat_bytes, hat_bytes])
이 코드에서는 토픽과 값 문자열에 대해 UTF-8 인코딩을 사용했습니다.
다음은 구독자의 zmq_sub.py입니다.
import zmq
host = '127.0.0.1'
port = 6789
ctx = zmq.Context()
sub = ctx.socket(zmq.SUB)
sub.connect('tcp://%s:%s' % (host, port))
topics = ['maine coon', 'persian']
for topic in topics:
sub.setsockopt(zmq.SUBSCRIBE, topic.encode('utf-8'))
while True:
cat_bytes, hat_bytes = sub.recv_multipart()
cat = cat_bytes.decode('utf-8')
hat = hat_bytes.decode('utf-8')
print('Subscribe: %s wears a %s' % (cat, hat))
이 코드에서는 다른 두 바이트 값을 구독했습니다. UTF-8로 인코딩된 토픽의 두 문자열입니다.
참고로, 모든 토픽을 구독하고 싶다면 빈 바이트 문자열 b''를 입력하세요. 아무것도 입력하지 않으면 아무것도 구독할 수 없습니다.
발행자에서 send_multipart()를 호출하고, 구독자에서 recv_multipart()를 호출합니다. 첫 번째 부분을 토픽으로 한 여러 메시지를 발행자에서 전달합니다. 또한 토픽과 메시지를 한 문자열 혹은 바이트 문자열로 보낼 수 있지만, 고양이와 모자를 분리하는 것이 깔끔해 보입니다.
구독자 먼저 실행합니다.
$ python zmq_sub.py
그다음에 발행자를 실행합니다. 즉시 10개의 메시지를 보낸 후 종료합니다.
$python zmq_pub.py
#Publish: maine coon wears a stovepipe
#Publish: maine coon wears a fedora
#Publish: persian wears a tam-o-shanter
#Publish: siamese wears a stovepipe
#Publish: norwegian forest wears a bowler
#Publish: siamese wears a bowler
#Publish: norwegian forest wears a tam-o-shanter
#Publish: siamese wears a bowler
#Publish: norwegian forest wears a stovepipe
#Publish: norwegian forest wears a stovepipe
구독자는 요청하여 받은 정보를 출력합니다.
#Subscribe: maine coon wears a stovepipe
#Subscribe: maine coon wears a fedora
#Subscribe: persian wears a tam-o-shanter
기타 발행-구독 도구
다음의 발행-구독 링크를 방문하면 많은 도움이 될 것입니다.
- RabbitMQ
- 메시징 브로커로 잘 알려져 있습니다. pika는 RabbitMQ를 위한 파이썬 API입니다. pika 문서와 publish/subscribe 튜토리얼을 참고하세요.
- pypi.python.org
- 오른쪽 상단의 검색창에 pubsub를 입력하여 pypubsub와 같은 패키지를 찾아봅니다.
- pubsubhubbub
- 이 프로토콜은 구독자가 발행자와 함께 콜백을 등록할 수 있게 해 줍니다.
2.3. TCP/IP
우리는 지하층이 잘 설계된 네트워킹의 집을 걷고 있습니다. 이제 실제로 지하층으로 내려가서 지상층을 움직이는 배선 및 파이프를 살펴봅시다.
인터넷은 커넥션을 맺고, 데이터를 교환하고, 커넥션을 종료하고, 타임아웃을 처리하는 등의 방법에 대한 규칙에 의거합니다. 이것을 프로토콜(Protocol)이라고 하며, 계층(Layer)으로 정렬되어 있습니다. 계층의 목적은 일을 처리하는 데 있어 새로운 여러 가지 대안을 허용하기 위함입니다. 위 계층과 아래 계층을 처리하는 규칙을 따른다면, 한 계층에서 원하는 모든 작업을 수행할 수 있습니다.
가장 낮은 계층에서는 전기 신호를 처리합니다. 그리고 각 계층은 층층이 쌓여 있습니다. 중간에 있는 계층은 네트워크의 위치와 데이터 흐름의 패킷(Packet; 청크(Chunk))을 명시하는 IP(Internet Protocol) 계층입니다. IP 계층에는 네트워크 위치 사이에서 바이트를 이동하는 방법을 기술하는 다음 두 가지 프로토콜이 있습니다.
- UDP(User Datagram Protocol; 사용자 데이터그램 프로토콜)
- 이 프로토콜은 짧은 데이터 교환에 사용됩니다. 데이터그램은 엽서의 짧은 글처럼, 한 단위(Single Burst)로 전송되는 작은 메시지입니다.
- TCP(Transmission Control Protocol; 전송 제어 프로토콜)
- 이 프로토콜은 수명이 긴 커넥션에 사용됩니다. TCP는 바이트 스트림이 중복 없이 순서대로 도착하는 것을 보장합니다.
UDP 메시지는 응답 메시지(AGK)가 없습니다. 그래서 메시지가 목적지에 잘 도착했는지 확인할 수 없습니다. 반면에 TCP는 송신자와 수신자 사이의 커넥션을 보장하기 위해 핸드셰이크(Handshake; 악수)를 설정합니다.
당신의 로컬 머신은 항상 127.0.0.1의 IP 주소와 localhost의 이름을 가집니다. 어디선가 루프백 인터페이스(Loopback Interface)를 들어봤을 것입니다. 만약 인터넷에 연결되어 있다면 공인(Public) IP 주소를 갖습니다. 집에서 컴퓨터를 사용한다면 케이블 모뎀이나 라우터와 같은 장비 뒤에 연결되어 있습니다. 같은 머신의 프로세스 간에도 인터넷 프로토콜을 실행할 수 있습니다.
웹과 데이터베이스 서버 등 우리가 소통하는 대부분의 인터넷은 IP 프로토콜의 맨 위에서 실행되고 있는 TCP 프로토콜입니다(간단하게 TCP/IP라고 합니다). 먼저 몇 가지 기본적인 인터넷 서비스를 살펴본 후, 일반적인 네트워킹 패턴에 대해 살펴봅시다.
2.4. 소켓
상위 수준의 인터넷을 사용하기 위해 하위 수준의 세부사항을 모두 알 필요는 없기 때문에, 지금까진 이 주제를 되도록이면 안 꺼냈습니다. 하위 수준에 대해 알고 싶을까, 생각하여 준비하였습니다.
네트워크 프로그래밍의 가장 낮은 수준은 C언어와 유닉스 운영체제에서 빌려온 소켓(Socket)을 사용합니다. 소켓-레벨의 코딩은 지루합니다. ZeroMQ와 같은 것을 사용하면 조금 더 재미있겠지만, 그 아래의 동작원리를 보는 것은 유용합니다. 예를 들어 소켓에 대한 메시지는 가끔 네트워킹 에러가 발생해야 발견됩니다.
아주 간단한 클라이언트-서버의 데이터 교환 프로그램을 작성해봅시다. 클라이언트는 UDP 데이터그램 안의 한 문자열을 서버로 보냅니다. 서버는 문자열이 담긴 데이터의 패킷을 반환합니다. 서버는 우체국과 우체국 사서함 같이 특정 주소와 포트를 청취(Listen) 해야 합니다. 클라이언트는 메시지를 전달하고 서버로부터 어떤 응답을 받기 위해 이 두 값을 알아야 합니다.
다음의 클라이언트와 서버 코드에서 server_address는 (주소, 포트)의 튜플입니다. 주소는 이름 혹은 IP 주소의 문자열이 될 수 있습니다. 같은 컴퓨터로 통신하고 싶은 경우 'localhost' 이름을 사용하거나 같은 의미인 '127.0.0.1' 주소를 사용하면 됩니다.
먼저 한 프로세스에서 다른 프로세스로 작은 데이터를 전송한 후, 데이터를 다시 보낸 사람에게 반환해봅시다. 먼저 서버 프로그램을 작성한 뒤, 클라이언트 프로그램을 작성합니다. 각 프로그램은 시간을 출력하고 소켓을 엽니다. 서버는 소켓 연결을 청취하고, 클라이언트는 서버에 전송할 소켓을 작성합니다.
먼저 서버 프로그램(udp_server.py)을 작성해봅시다.
from datetime import datetime
import socket
server_address = ('localhost', 6789)
max_size = 4096
print('Starting the server at', datetime.now())
print('Wating for a client to call.')
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(server_address)
data, client = server.recvfrom(max_size)
print('At', datetime.now(), client, 'said', date)
server.sendto(b'Are you talking to me?', client)
server.close()
서버는 socket 패키지에서 임포트한 두 메소드를 통해 네트워킹을 설정하고 있습니다. 첫 번째 socket.socket 메소드는 소켓을 생성합니다. 두 번째 bind 메소드는 소켓에 바인딩합니다(해당 IP주소와 포트에 도달하는 모든 데이터를 청취합니다). AF_INET은 인터넷(IP) 소켓을 생성한다는 것을 의미합니다(또 다른 타입의 유닉스 도메인 소켓이 있지만, 로컬 머신에서만 작동합니다). SOCK_DGRAM은 데이터그램을 송수신하겠다는, 즉 UDP를 사용하겠다는 의미입니다.
이 시점에서 서버는 들어오는 데이터그램을 기다립니다(recvfrom). 데이터그램이 도착하면 서버는 깨어나서 클라이언트에 대한 데이터와 정보를 얻습니다. client 변수는 클라이언트에 접근하기 위한 주소와 포트가 결합된 데이터를 포함합니다. 서버는 응답을 전송하고 커넥션을 종료합니다.
클라이언트 프로그램(udp_client.py)을 살펴봅시다.
import socket
from datetime import datetime
server_address = ('localhost', 6789)
max_size = 4096
print('Starting the client at', datetime.now())
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.sendto(b'Hey!', server_address)
data, server = client.recvfrom(max_size)
print('At', datetime.now(), server, 'said', data)
client.close()
클라이언트는 서버와 거의 같은 메소드를 가지고 있습니다(bind() 메소드 제외), 클라이언트는 데이터를 전송하고 나서 데이터를 받습니다. 반면 서버는 데이터를 먼저 받습니다.
먼저 한 창에서 서버를 실행합니다. 서버가 시작된 시간을 출력한 후, 클라이언트에서 데이터 보내기를 기다립니다.
$ python udp_server.py
#Starting the server at 2020-03-19 01:23:51.414759
#Wating for a client to call.
다음에는 또 다른 창에서 클라이언트를 실행합니다. 데이터를 서버로 보내고, 응답받은 데이터를 출력한 후 종료합니다.
$ python udp_client.py
#Starting the client at 2020-03-19 01:24:12.043285
#At 2020-03-19 01:24:12.051771 ('127.0.0.1', 6789) said b'Are you talking to me?'
마지막으로 서버는 아래 문장을 출력한 후 종료합니다.
#At 2020-03-19 01:24:12.051263 ('127.0.0.1', 54458) said b'Hey!'
클라이언트는 서버의 주소와 포트 번호를 알아야 하지만, 자신의 포트 번호를 지정할 필요는 없습니다. 시스템에서 자동으로 할당합니다. 이 경우 포트 번호는 54888입니다.
참고로, UDP는 한 청크(Chunk)에 데이터를 보냅니다. 데이터 전송을 보장하지는 않습니다. UDP를 통해 여러 메시지를 보낼 경우, 순서 없이 도착하거나 모두 도착하지 않을 수 있습니다. UDP는 빠르고, 가볍고, 신뢰할 수 없고, 비연결형(Connectionless)입니다.
우리는 왜 TCP(Transmission Control Protocol)를 선호할까요? TCP는 웹과 같은 수명이 긴 커넥션에 사용합니다. TCP는 데이터를 보낸 순서대로 전달합니다. 전송에 문제가 생기면 다시 보냅니다. TCP를 통해 클라이언트에서 서버로 패킷을 전달해봅시다.
tcp_client.py는 이전 UDP 클라이언트의 코드처럼 한 문자열을 서버로 보냅니다. 그러나 소켓을 호출하는 부분은 약간 다릅니다.
import socket
from datetime import datetime
address = ('localhost', 6789)
max_size = 1000
print('Starting the client at', datetime.now())
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(address)
client.sendall(b'Hey!')
data = client.recv(max_size)
print('At', datetime.now(), 'someone replied', data)
client.close()
스트리밍 프로토콜(TCP)을 얻기 위해 SOCK_DGRAM을 SOCK_STREAM으로 바꿨습니다. 또한 스트림을 설정하기 위해 connect() 호출을 추가했습니다. 인터넷에서 각 데이터그램은 야생 그 자체이기 때문에 UDP에 대한 스트림은 필요 없습니다.
tcp_server.py 또한 UDP와 다릅니다.
from datetime import datetime
import socket
address = ('localhost', 6789)
max_size = 1000
print('Starting the server at', datetime.now())
print('Wating for a client to call.')
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(address)
server.listen(5)
client, addr = server.accept()
data = client.recv(max_size)
print('At', datetime.now(), client, 'said', data)
client.sendall(b'Are you talking to me?')
client.close()
server.close()
server.listen(5)는 대기 중인 커넥션의 최대 수를 5로 지정합니다. server.accept()는 도착한 첫 번째 유효한 메시지를 얻습니다. client.recv(1000)은 최대 허용 메시지의 길이를 1000바이트로 제한합니다.
전처럼 서버를 먼저 실행합니다.
$ python tcp_server.py
#Starting the server at 2020-03-19 01:47:56.985834
#Wating for a client to call.
다음에는 클라이언트를 실행합니다. 서버에 메시지를 전송하고, 응답을 받은 후 종료합니다.
$ python tcp_client.py
#Starting the client at 2020-03-19 01:48:14.516975
#At 2020-03-19 01:48:14.525910 someone replied b'Are you talking to me?'
서버는 메시지를 모아서, 이를 출력하고, 응답을 한 후 종료합니다.
#At 2020-03-19 01:48:14.524979 <socket.socket fd=676, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 6789), raddr=('127.0.0.1', 6125)> said b'Hey!'
TCP 서버는 client.sendall()을 호출하고, 이전 UDP 서버는 client.sendto()를 호출하여 응답합니다 TCP는 소켓의 여러 호출을 통해 클라이언트와 서버의 커넥션을 유지하고, 클라이언트의 IP 주소를 기억합니다.
만약 좀 더 복잡한 코드를 작성한다면 저수준의 소켓이 실제로 어떻게 동작하는지 볼 수 있습니다. 아래에 몇 가지 TCP와 UDP의 특징을 적었습니다.
- UDP는 메시지를 전송하는 데 크기의 제한이 있습니다. 그리고 메시지가 목적지까지 도달하는 것을 보장하지 않습니다.
- TCP는 메시지가 아닌 바이트 스트림을 전송합니다. 시스템의 각 송수신 호출에서 얼마나 많은 바이트가 전달되는지 알 수 없습니다.
- TCP를 통해 전체 메시지를 전달하기 위해 세그먼트(Segment)로부터 전체 메시지를 재구성하기 위한 몇 가지 추가 정보가 필요합니다(고정 메시지 크기(바이트), 메시지 전체 크기, 구분자).
- 메시지는 유니코드 텍스트 문자열이 아닌 바이트이기 때문에 파이선 바이트 타입을 사용해야 합니다. 유니코드와 바이트 타입에 대한 자세한 사항은 이전 게시글들을 참고하세요.
파이썬 소켓 프로그래밍에 관한 좀 더 자세한 사항은 Socket Programming HOWTO를 참고하세요.
2.5. ZeroMQ
ZeroMQ는 라이브러리입니다. 발행-구독 모델에서 ZeroMQ 소켓을 이미 살펴봤습니다. 강력한 소켓(Sockets on Steroids)이라 불리는 ZeroMQ 소켓은 다음과 같은 일을 수행합니다.
- 전체 메시지 교환
- 커넥션 재시도
- 송신자와 수신자 사이에 전송 타이밍이 맞지 않는 경우 데이터 보존을 위한 버퍼 사용
온라인 가이드는 재미있게 잘 작성되어 있습니다. 파이썬 예시도 볼 수 있습니다. 이번 절에서는 ZeroMQ의 몇 가지 기본적인 사용법을 보여드리겠습니다.
ZeroMQ는 레고 세트와 같아서, 몇 가지 조각으로 여러 가지 멋진 작품을 만들 수 있습니다. ZeroMQ의 경우 몇 가지 소켓의 타입과 패턴으로부터 네트워크를 구축합니다. 다음 목록에 기술되어 있는 기본적인 '레고 조각'들은 ZeroMQ의 소켓 타입입니다. 일부 타입은 네트워크 패턴에서 살펴본 것입니다.
- REQ(동기 요청; Synchronous Request)
- REP(동기 응답; Synchronous Reply)
- DEALER(비동기 요청; Asynchronous Request)
- ROUTER(비동기 응답; Asynchronous Reply)
- PUB(발행; Publish)
- SUB(구독; Subscribe)
- PUSH(팬아웃; Fanout)
- PULL(팬인; Fanin)
다음 명령을 입력하여 파이썬 ZeroMQ 라이브러리를 설치합니다.
$ pip install pyzmq
가장 간단한 패턴은 단일 요청-응답 패턴입니다. 한 소켓이 요청하면, 다른 소켓에서 응답하는 동기적인 방식입니다. 먼저 응답(서버)하는 코드를 작성해봅시다(zmq_server.py).
import zmq
host = '127.0.0.1'
port = 6789
context = zmq.Context()
server = context.socket(zmq.REP)
server.bind("tcp://%s:%s" % (host, port))
while True:
# 클라이언트에서 다음 요청을 기다린다.
request_bytes = server.recv()
request_str = request_bytes.decode('utf-8')
print("That voice in my head says: %s" % request_str)
reply_str = "Stop saying: %s" % request_str
reply_bytes = bytes(reply_str, 'utf-8')
server.send(reply_bytes)
상태를 유지하는 ZeroMQ 객체인 Context 객체를 생성합니다. 그러고 나서 REP(REPly) 타입의 ZeroMQ socket을 만듭니다. bind()를 호출하여 특정 IP 주소와 포트를 청취합니다. 여기서는 일반 소켓의 예제에서 본 튜플을 사용하지 않고 'tcp://localhost:6789'와 같은 문자열을 사용했습니다.
이 예제는 송신자로부터 요청을 받으면 응답을 보내줍니다. 메시지가 아주 길어질 경우, ZeroMQ에서 이런 사항을 처리해줍니다.
다음은 해당 요청(클라이언트)에 대한 코드입니다(zmq_client.py). 이 타입은 REQ(REQuest)며, bind()가 아닌 connect()를 호출합니다.
import zmq
host = '127.0.0.1'
port = 6789
context = zmq.Context()
client = context.socket(zmq.REQ)
client.connect("tcp://%s:%s" % (host, port))
for num in range(1, 6):
request_str = "message #%s" % num
request_bytes = request_str.encode('utf-8')
client.send(request_bytes)
reply_bytes = client.recv()
reply_str = reply_bytes.decode('utf-8')
print("Sent %s, received %s" % (request_str, reply_str))
이제 이들을 실행해봅시다. 평범한 소켓 예제와 한 가지 다른 점은 서버와 클라이언트의 실행 순서를 다르게 할 수 있다는 것입니다. 한 창에서 서버를 백그라운드로 실행해봅시다.
$ python zmq_server.py &
같은 창에서 클라이언트를 실행합니다.
$ python zmq_client.py
다음은 클라이언트와 서버의 혼합된 출력 결과입니다.
#That voice in my head says 'message #1'
#Sent 'message #1', received 'Stop saying message #1'
#That voice in my head says 'message #2'
#Sent 'message #2', received 'Stop saying message #2'
#That voice in my head says 'message #3'
#Sent 'message #3', received 'Stop saying message #3'
#That voice in my head says 'message #4'
#Sent 'message #4', received 'Stop saying message #4'
#That voice in my head says 'message #5'
#Sent 'message #5', received 'Stop saying message #5'
클라이언트는 다섯 번째 메시지를 보낸 후 종료합니다. 그러나 우리는 서버를 종료하도록 명령하지 않았습니다. 클라이언트를 다시 실행하면 똑같이 5줄의 결과를 출력하고, 서버도 5줄의 결과를 출력합니다. zmq_server.py 프로세스를 죽이지 않고 또 다른 zmq_server.py를 실행하면, 파이썬에서는 해당 주소를 이미 사용하고 있다는 에러를 내보냅니다.
메시지는 바이트 문자열로 전송해야 합니다. 예제에서는 UTF-8 포맷으로 텍스트 문자열을 인코딩했습니다. 메시지를 바이트로 변환하면 모든 종류의 메시지를 전송할 수 있습니다. 예제의 메시지는 간단한 텍스트 문자열입니다. 그래서 encode()와 decode()를 통해 문자열을 바이트로, 바이트를 문자열로 변환했습니다. 메시지가 다른 데이터 타입이라면 MessagePack과 같은 라이브러리를 사용할 수 있습니다.
기본적인 요청-응답(REQ-REP) 패턴은 일부 복잡한 커뮤니케이션 패턴을 허용합니다. 다수의 REQ 클라이언트는 하나의 REP 서버에 connect() 할 수 있기 때문입니다. 이 서버는 한 번에 하나의 요청을 동기적으로 처리합니다. 하지만 그 사이에 도착하는 다른 요청을 뿌리치지 않습니다. ZeroMQ는 메시지를 특정 한계까지, 가능한 만큼 버퍼에 넣습니다. 이것이 바로 ZeroMQ에 Q가 잇는 이유입니다. Q는 큐(queue)를, M은 메시지(Message)를 의미합니다. Zero는 브로커(Broker)가 필요 없다는 의미입니다.
비록 ZeroMQ에는 중앙 브로커(중개자)가 없지만, 필요한 경우 브로커를 만들 수 있습니다. 예를 들면 여러 소스(Source)와 목적지(Destination)를 비동기적으로 연결하기 위해 DEALER와 ROUTER 소켓을 사용합니다.
여러 개의 REQ 소켓은 하나의 ROUTER 소켓에 연결됩니다. 각 요청은 ROUTER 소켓을 통과해서 DEALER 소켓으로 전달됩니다. 그러고 나서 DEALER 소켓은 연결된 어떤 REP 소켓에 접근합니다. 이것은 웹 서버 팜(Web Server Farm) 앞의 프록시 서버(Proxy Server)에 접근하는 다수의 브라우저와 유사합니다. 이것은 필요에 따라 클라이언트와 서버를 추가할 수 있도록 해줍니다.
REQ 소켓은 ROUTER 소켓에만 연결됩니다. DEALER 소켓은 그 뒤의 여러 REP 소켓에 연결됩니다. ZeroMQ는 복잡한 세부사항을 처리합니다. 요청이 로드 밸런싱 되었는지 그리고 응답이 올바른 위치로 갔는지 보장해줍니다.
벤틸레이터(Ventilator)라 불리는 또 다른 네트워킹 패턴은 PUSH 소켓을 사용하여 비동기 작업을 처리하고, PULL 소켓을 사용하여 결과를 수집합니다.
마지막으로 ZeroMQ의 중요한 특징은 소켓이 생성될 때 소켓의 커넥션 타입을 바꿔서 스케일 업(Scale Up)과 스케일 다운(Scale Down)을 한다는 것입니다.
- tcp: 하나 이상의 머신에서 프로세스 간 통신
- ipc(Inter-Process Communication): 하나의 머신에서 프로세스 간 통신
- inproc(IN-PROcess(Inter-Thread) Communication): 한 프로세스에서 스레드 간 통신
inproc은 락(Lock) 없이 스레드 간 데이터를 전달하는 방법입니다. threading 코드는 1.3 절을 참고하시면 됩니다.
참고로, ZeroMQ는 파이썬이 지원하는 유일한 메시지 전달(Message-Passing) 라이브러리는 아닙니다. 메시지 전달은 네트워킹에서 가장 인기 있는 아이디어 중 하나입니다 그리고 파이썬은 다른 언어와 함께 네트워크 라이브러리를 지원하고 있습니다. 이전 게시글에서 본 '아파치' 웹 서버의 아파치 프로젝트 또한 간단한 텍스트 지향의 STOMP(Single (or Streaming) Text Orientated Messaging Process) 프로토콜을 사용한 몇몇 파이썬 인터페이스가 포함된 ActiveMQ를 관리하고 있습니다. RabbitMQ 역시 유용한 파이썬 튜토리얼을 제공하며, 매우 인기 있습니다.
2.6. Scapy
웹 API를 디버깅하거나 일부 보안 문제를 추적해야 할 때 네트워킹 스트림을 잡아서 바이트를 자세히 살펴볼 필요가 있습니다. scapy 라이브러리는 패킷을 분석하기 위한 유용한 파이썬 라이브러리입니다. 그리고 C 프로그램보다 코드를 작성하고 디버깅하는 것이 쉽습니다. scapy는 실제로 패킷을 만들고, 분석하기 위한 작은 언어입니다. 몇 가지 예시 코드를 포함하려고 했지만, 입문하기 위한 절차가 까다롭다는 점을 들어하지는 않겠습니다. 설치하는 방법은 웹 페이지를 참고하면 됩니다.
scapy에 관심이 있다면 웹 문서에서 예제를 살펴보면 됩니다.
아, 이전 게시글에서 크롤링과 스크래핑을 설명할 때 다뤘던 scrapy와 혼동하지 마세요.
2.7. 인터넷 서비스
파이썬은 광범위한 네트워킹 도구 세트를 제공합니다. 이번 절에서는 가장 인기 있는 인터넷 서비스 중 몇몇을 자동화하는 방법에 대해 살펴봅니다. 전반적인 내용은 파이썬 공식 문서를 참고합니다.
DNS
컴퓨터는 85.2.101.94와 같이 숫자로 된 IP 주소를 갖고 있습니다. 그러나 숫자보다 이름이 더 기억하기 쉽습니다. DNS(Domain Name System)는 분산된 데이터베이스를 통해 IP 주소를 이름으로 바꾸거나, 그 반대를 수행하는 아주 중요한 인터넷 서비스입니다. 웹 브라우저에서 사이트를 입력할 때 갑자기 '호스트 검색 중...'과 같은 메시지를 본다면, 아마 인터넷 연결이 안 됐거나 DNS 검색 실패를 의심할 수 있습니다.
몇몇 DNS 함수는 저수준 socket 모듈에 있습니다. gethostbyname()은 도메인 이름에 대한 IP 주소를 반환합니다. 그리고 확장판인 gethostbyname_ex()는 이름, 또 다른 이름의 리스트, 주소의 리스트를 반환합니다.
>>> import socket
>>> socket.gethostbyname('www.naver.com')
#'210.89.164.90'
>>> socket.gethostbyname_ex('www.naver.com')
#('www.naver.com.nheos.com', ['www.naver.com'], ['210.89.164.90', '125.209.222.141'])
getaddrinfo() 메소드는 IP 주소를 검색합니다. 또한 소켓을 생성하고 연결하기 위한 충분한 정보를 반환합니다.
>>> socket.getaddrinfo('www.naver.com', 80)
#[(<AddressFamily.AF_INET: 2>, 0, 0, '', ('125.209.222.142', 80)), (<AddressFamily.AF_INET: 2>, 0, 0, '', ('125.209.222.141', 80))]
위 호출은 두 튜플을 반환합니다. 첫 번째는 UDP, 두 번째는 TCP입니다. TCP 또는 UDP에 대한 정보만 요청할 수도 있습니다.
>>> socket.getaddrinfo('www.naver.com', 80, socket.AF_INET, socket.SOCK_STREAM)
#[(<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_STREAM: 1>, 0, '', ('210.89.164.90', 80)), (<AddressFamily.AF_INET: 2>, <SocketKind.SOCK_STREAM: 1>, 0, '', ('125.209.222.141', 80))]
일부 TCP와 UDP 포트 번호는 IANA(Internet Assigned Numbers Authority; 인터넷 할당 번호 관리기관)에 의해 특정 서비스에 예약되어 있고, 서비스 이름과 연결되어 있습니다. 예를 들면 HTTP의 이름은 http이라고 TCP 80 포트로 할당되어 있습니다.
다음 함수들은 서비스 이름과 포트 번호를 서로 변환합니다.
>>> import socket
>>> socket.getservbyname('http')
#80
>>> socket.getservbyport(80)
#'http'
파이썬 이메일 모듈
표준 라이브러리는 다음 이메일 모듈을 포함합니다.
- smtplib 모듈: SMTP(Simple Mail Transfer Protocol)를 통해 이메일 전송하기
- email 모듈: 이메일 생성 및 파싱하기
- poplib 모듈: POP3(Post Office Protocol 3)를 통해 이메일 찾기
- imaplib 모듈: IMAP(Internet Message Access Protocol)을 통해 이메일 읽기
파이썬 공식 문서는 위 모든 라이브러리에 대한 샘플 코드를 제공합니다.
파이선으로 SMTP 서버를 만들고 싶다면 smtpd 모듈을 참고하세요.
Lamson이라는 순수 파이썬 SMTP 서버는 이메일 메시지를 데이터베이스에 저장할 수 있을 뿐만 아니라 스팸 메일을 차단할 수 있습니다.
기타 프로토콜
표준 ftplib 모듈은 FTP(File Transfer Protocol)를 사용하여 바이트를 전송할 수 있습니다. FTP는 아주 오래된 프로토콜이지만, 여전히 아주 잘 작동합니다.
인터넷 프로토콜의 표준 라이브러리 지원은 웹 문서를 참고합니다.
2.8. 웹 서비스와 API
정보 제공자들은 웹사이트를 운영하지만, 자동화가 아닌 사람의 눈을 대상으로 합니다. 만약 데이터가 웹사이트에 게시되어 있다면, 데이터에 접근하여 구조화하고 싶은 누군가가 스크래퍼(Scraper)를 작성하고, 페이지 형식이 바뀔 때마다 스크래퍼를 다시 작성합니다. 정말 지루한 일입니다. 반대로 웹사이트에서 데이터에 대한 API를 제공한다면 클라이언트 프로그램에서 데이터를 직접 사용할 수 있습니다. API는 웹페이지 레이아웃이 덜 자주 변경되므로, 클라이언트는 보통 코드를 다시 작성하는 일이 적습니다. 빠르고 깨끗한 파이프라인 도한 매시업(Mashup) 구축을 쉽게 해줍니다. 매시업은 예측할 수 없지만, 유용하고 유익한 조합은 될 수 있습니다.
여러 가지 면에서 가장 쉬운 API는 일반 텍스트나 HTML보다 JSON이나 XML 같은 구조화된 포맷의 데이터를 제공하는 웹 인터페이스입니다. API는 최소한으로 혹은 제대로 갖춘 RESTful API일 수도 있지만, 불규칙적인 데이터를 위한 또 다른 인터페이스를 제공합니다.
API는 트위터, 페이스북, 링크드인과 같은 잘 알려진 소셜 미디어 사이트의 채굴(Mining) 작업에 특히 유용합니다. 이 사이트들은 무료로 API를 제공합니다. API를 사용하려면 사이트에 등록하고 키(긴 텍스트 문자열, 토큰(Token)이라고 부르기도 합니다)를 얻어야 합니다. 사이트는 누가 데이터에 접근할 수 있는지 키로 판단합니다. 또한 서버의 요청 트래픽을 제한하기 위해 키가 사용될 수 있습니다.
2.9. 원격 프로세싱
이때까지 작성한 대부분의 코드는 같은 머신, 그리고 일반적으로 같은 프로세스에서 파이썬 코드를 어떻게 실행하는지 보여줍니다. 또한 로컬 머신처럼 다른 머신의 코드를 실행할 수 있습니다. 만약 싱글 머신에서 실행 공간이 부족하다면, 원격 프로세싱으로 그 공간을 확장할 수 있습니다. 머신의 네트워크는 더 많은 프로세스와 스레드를 접근하게 해줍니다.
원격 프로시저 호출
RPC(Remote Procedure Call; 원격 프로시저 호출)는 평범한 함수처럼 보이지만, 네트워크를 통해 원격에 있는 머신을 실행합니다. URL 또는 요청 바디(Body)에서 인코딩된 인자들과 RESTful API를 호출하는 대신 RPC 함수를 호출합니다. RPC 클라이언트에서는 다음과 같은 일이 발생합니다.
- 함수의 인자를 바이트로 변환합니다(마샬링(Marshalling) 또는 직렬화(Serializing) 또는 그냥 인코딩이라고 부릅니다).
- 인코딩된 바이트를 원격 머신으로 전송합니다.
그리고 원격 머신에서는 다음과 같은 일이 발생합니다.
- 인코딩된 요청 바이트를 수신합니다.
- 바이트를 수신한 후, RPC 클라이언트는 다시 원래의 데이터 구조(혹은 다른 두 머신 사이의 하드웨어와 소프트웨어가 서로 다른 경우 그에 맞는 구조)로 바이트를 디코딩합니다.
- 클라이언트는 디코딩된 데이터와 함께 로컬 함수를 찾아서 호출합니다.
- 함수 결과를 인코딩합니다.
- 클라이언트는 인코딩된 바이트를 다시 호출자에 전송합니다.
마지막으로 호출자의 머신에서 반환된 바이트 값을 디코딩합니다.
RPC 기술은 인기 있습니다. 그리고 사람들은 여러 가지 방법으로 RPC를 구현합니다. 서버 측에서는 서버 프로그램을 구동하고, 몇몇의 바이트 전송과 인코딩/디코딩 메소드와 함께 RPC에 연결하여 일부 서비스 함수들을 정의하고, 신호를 받기 위한 RPC 서버를 구동합니다. 클라이언트는 서버에 연결하여 RPC를 통해 함수를 호출합니다.
표준 라이브러리에서는 교환 포맷으로 XML을 사용하여 RPC를 구현한 xmlpc 모듈을 제공합니다. 서버에 함수를 정의하고 등록합니다. 그리고 클라이언트는 임포트된 것처럼 이 함수를 호출합니다. 먼저 xmlpc_server.py를 살펴봅시다.
from xmlpc.server import SimpleXMLRPCServer
def double(num):
return num * 2
server = SimpleXMLRPCServer(("localhost", 6789))
server.register_function(double, "double")
server.serve_forever()
서버에서 제공하는 함수는 double()입니다. 이 함수는 숫자를 인자로 받아서 숫자의 2배 값을 반환합니다. 서버는 명시된 주소와 포트에서 시작합니다. 클라이언트가 RPC를 통해 함수를 사용할 수 있도록 함수를 등록합니다. 그리고 RPC 서버를 구동합니다.
다음은 클라이언트의 xmlrpc_client.py입니다.
import xmlrpc.client
proxy = xmlrpc.client.ServerProxy("http://localhost:6789/")
num = 7
result = proxy.double(num)
print("Double %s is %s" % (num, result))
클라이언트는 ServerProxy()를 사용해서 서버에 연결합니다. 그러고 나서 proxy.double() 함수를 호출합니다. 이 함수는 어디에서 온 것일까요? 함수는 서버에서 동적으로 생성된 것입니다. RPC 머신은 원격 서버에 대한 호출로 이 함수의 이름을 후킹(Hooking)합니다.
먼저 서버를 실행합니다.
$ python xmlrpc_server.py
다음에는 클라이언트를 실행합니다.
$ python xmlrpc_client.py
#Double 7 is 14
서버의 출력 결과는 다음과 같습니다.
#127.0.0.1 - - [19/Mar/2020 16:51:23] "POST / HTTP/1.1" 200 -
인기 있는 전송 방법으로 HTTP와 ZeroMQ가 있습니다. XML을 제외한 일반적인 인코딩에는 JSON, 프로토콜 버퍼(Protocol Buffer), 메시지팩(MessagePack)이 있습니다. JSON 기반의 RPC에 대한 여러 파이썬 패키지가 있지만, 대부분의 패키지는 파이썬 3을 지원하지 않거나, 얽혀 있습니다. 표준 라이브러리와 메시지팩의 파이썬 RPC 구현을 비교해봅시다. 다음과 같이 메시지팩을 설치합니다.
$ pip install msgpack-rpc-python
이 라이브러리를 전송으로 사용하기 위해 이벤트 기반의 웹 서버인 tornado도 함께 설치됩니다. 평소와 같이 먼저 서버를 살펴봅시다(msgpack_server.py).
from msgpackrpc import Server, Address
class Services():
def double(self, num):
return num * 2
server = Server(Services())
server.listen(Address("localhost", 6789))
server.start()
Services 클래스는 메소드를 RPC 서비스에 노출시킵니다. 클라이언트를 살펴봅시다(msgpack_client.py).
from msgpackrpc import Client, Address
client = Client(Address("localhost", 6789))
num = 8
result = client.call('double', num)
print("Double %s is %s" % (num, result))
먼저 서버를 실행합니다. 그다음에 클라이언트를 실행한 후 결과를 살펴봅니다.
$ python msgpack_server.py
$ python msgpack_client.py
#Double 8 is 16
2.10. 빅데이터와 맵리듀스
구글과 다른 인터넷 회사들이 성장을 하게 되면서 전통적인 컴퓨팅 솔루션이 확장성이 없다는 것을 발견했습니다. 싱글 또는 수십 대의 머신을 위한 소프트웨어는 수천 대의 머신을 유지할 수 없었습니다.
데이터베이스와 파일의 디스크 스토리지는 디스크 헤드의 기계적인 움직임이 필요해서 찾는데 너무 많은 시간이 걸립니다(비닐 레코드를 생각해보면, 한 트랙에서 또 다른 트랙으로 바늘을 수동으로 이동하는 데 시간이 오래 걸립니다. 그리고 귀에 거슬리는 날카로운 소리를 생각해봅시다. 음악에 빠져들면 레코드 엔지니어에 의해 만들어지는 소리에 대해서는 생각하지 않습니다). 하지만 디스크의 연속적인 세그먼트를 더 빠르게 스트리밍 할 수 있습니다.
개발자들은 네트워크가 연결된 머신에서 데이터를 분산하여 분석하는 것이 개별적인 머신에서 수행하는 것보다 더 빠르다는 것을 발견했습니다. 이들은 단순하게 들리는 알고리즘을 사용하여 대규모의 분산된 데이터가 전반적으로 더 잘 작동한다는 것을 실제로 확인했습니다. 그중 하나가 바로 맵리듀스(MapReduce)입니다. 이는 여러 머신에서 계산을 수행하여 결과를 수집합니다. 맵리듀스는 큐 작업과 비슷합니다.
구글은 이 결과를 논문으로 발표한 후, 야후에서 하둡(Hadoop; 리드 프로그래머의 아들이 갖고 놀던 장난감의 이름)이라는 자바 기반의 오픈소스 패키지를 개발했습니다.
빅데이터는 '아주 큰 데이터를 머신에 맞게(Data too big to fit on my machine)'이라는 문구의 의미가 담겨 있습니다. 여기서 데이터는 디스크, 메모리, CPU 타임을 초과하는 데이터를 의미합니다. 만약 빅데이터가 어느 질문에 나온다면, 일부 조직에서 그 대답은 항상 하둡입니다. 하둡은 머신 간에 데이터를 복사하여 맵과 리듀스 프로그램을 통해 이들을 실행합니다. 그리고 각 단계에서 디스크에 결과를 저장합니다.
이 배치 프로세스는 느릴 수 있습니다. 하둡 스트리밍이라 부르는 빠른 메소드는 각 단계에서 디스크에 기록할 필요 없이 프로그램을 통해 데이터를 스트리밍 하는 유닉스의 파이프처럼 작동합니다. 파이썬을 포함한 모든 언어에서 하둡 스트리밍 프로그램을 작성할 수 있습니다.
하둡을 위해 작성된 여러 모듈이 있습니다. 하둡의 라이벌인 스파크(Spark)는 하둡보다 10배에서 100배 빠르게 실행하도록 설계되었습니다. 스파크는 모든 하둡 데이터 소스와 포맷을 읽고 처리할 수 있습니다. 스파크는 파이썬과 다른 언어들의 API를 제공합니다. 스파크 사이트에서 설치 문서를 제공합니다.
또 다른 하둡의 대안은 디스코(Disco)입니다. 디스코에서 맵리듀스 프로세싱은 파이썬을, 커뮤니케이션은 얼랭(Erlang)을 사용합니다. 디스코는 pip로 설치할 수 없습니다. 웹 문서를 참고하세요.
2.11. 클라우드
대부분의 호스팅 서비스는 서버를 무료로 관리해줍니다. 그러나 여전히 물리적인 장치를 임대해야 하고, 항상 트래픽이 치솟는 부하 구성을 위해 비용을 지불해야 합니다.
개별적인 머신이 많을수록 시스템 장애가 자주 발생합니다. 장애는 매우 일반적입니다. 서비스를 수평 확장하고, 데이터를 다중으로 저장할 필요가 있습니다. 네트워크가 싱글 머신처럼 동작한다고 가정할 수 없습니다. 피터 도이치(Peter Deutsch)에 의하면 분산 컴퓨팅의 8가지 착오는 다음과 같습니다.
-
네트워크는 신뢰할 만하다.
-
지연시간(Latency)은 0이다.
-
대역폭(Bandwidth)은 무한하다.
-
네트워크는 안전하다.
-
토폴로지(Topology)는 바뀌지 않는다.
-
한 명의 관리자(Administrator)가 있다.
-
전송 비용은 0이다.
-
네트워크는 동일(Homogeneous)하다.
이러한 복잡한 분산 시스템을 구축하기 위해 많은 작업과 다양한 도구 세트가 필요합니다. 소수의 서버를 애완동물에 비유해봅시다. 이들에게 이름을 지어주고, 성격을 파악하고, 건강 상태를 확인합니다. 하지만 확장된 다수의 서버는 가축에 비유할 수 있습니다. 이들은 생김새가 비슷하고, 숫자가 있으며, 어떤 문제가 생길 때 바로 교체됩니다.
분산 시스템을 구축하는 대신 클라우드의 서버를 임대할 수 있습니다. 이 모델을 적용함으로써 유지보수를 다른 누군가의 문제로 돌릴 수 있습니다. 그리고 사용자들에게 제공하고 싶은 서비스에만 집중할 수 있습니다. 웹 대시보다(Dashboard)와 API를 사용하여 필요한 구성의 서버를 쉽고 빠르게 돌릴 수 있습니다. 즉, 서버를 탄력적으로 운영할 수 있습니다. 서버의 상태를 모니터링할 수 있으며, 트래픽이 주어진 임계치를 초과하는 경우 경고 알림을 받을 수 있습니다. 클라우드는 현재 매우 뜨거운 주제이며, 클라우드 컴포넌트에 지출하는 회사들이 급증하고 있습니다.
몇몇 인기 있는 클라우드에서 파이썬을 어떻게 활용할 수 있는지 살펴봅시다.
구글
구글은 내부적으로 파이썬을 많이 사용하고, 뛰어난 몇몇 파이썬 개발자를 고용했습니다(때때로 귀도 반 로섬이 직접 채용하기도 합니다).
구글 웹 엔진 사이트로 가서 파이썬을 선택합니다. 또는 클라우드 플랫폼 입력창에 'Python'을 입력합니다. 그리고 Python SDK를 찾아서 내려받은 후 컴퓨터에 설치합니다. 그러면 자신의 하드웨어에서 구글 클라우드 API를 개발할 수 있습니다. 다음은 웹 엔진에서 애플리케이션을 배포하는 방법에 대한 세부항목입니다.
구글 클라우드 메인 페이지에서 서비스의 자세한 정보를 볼 수 있습니다.
-
App Engine
-
flask와 django 같은 파이썬 도구를 포함하는 높은 수준의 플랫폼입니다.
-
-
Compute Engine
-
대규모 분산 컴퓨팅 작업을 위한 가상 머신 클러스터를 생성합니다.
-
-
Cloud Storage
-
오브젝트(객체) 스토리지(오브젝트는 파일이지만, 디렉터리 구조는 아닙니다.)
-
-
Cloud Datastore
-
대규모 NoSQL 데이터베이스
-
-
Cloud SQL
-
대규모 SQL 데이터베이스
-
-
Cloud Endpoints
-
애플리케이션 접근(Restful)
-
-
Big Query
-
하둡 계열의 빅데이터
-
구글 서비스는 아마존, 오픈스택과 경쟁하고 있습니다.
아마존
아마존이 수백에서 수천, 수만 대의 서버를 확장할 때, 개발자들은 분산 시스템의 끔찍한 문제에 시달리고 있었습니다. 2002년 어느 날 아마존의 CEO 제프 베조스(Jeff Bezos)는 이제부터 모든 데이터와 기능은 파일도, 데이터베이스도, 로컬 함수의 호출도 아닌 네트워크 서비스 인터페이스를 통해서만 노출되어야 한다는 필요성을 직원들에게 선언했습니다. 직원들은 공개적으로 대중에게 제공되는 것처럼 이러한 인터페이스를 설계했습니다. 결국 '이 일을 하지 않는 직원은 해고될 것이다'라는 메모는 직원들에게 큰 동기부여가 되었습니다.
직원들은 열심히 개발했고, 시간이 지남에 따라 매우 큰 서비스 지향의 아키텍처를 구축했습니다. 많은 솔루션을 빌려와서 발전시킨 결과, 현재 시장을 지배하고 있는 아마존 웹 서비스(Amazone Web Service; AWS)로 진화했습니다. 지금은 수십 개의 서비스를 제공합니다. 그중 일부 서비스를 간단히 소개합니다.
-
Elastic Beanstalk
-
높은 수준의 애플리케이션 플랫폼
-
-
EC2(Elastic Compute)
-
분산 컴퓨팅
-
-
S3(Simple Storage Service)
-
오브젝트 스토리지
-
-
RDS
-
관계형 데이터베이스(MySQL, PostgreSQL, Oracle, MSSQL)
-
-
DynamoDB
-
NoSQL 데이터베이스
-
-
Redshift
-
데이터 웨어하우스
-
-
EMR
-
하둡
-
AWS 서비스에 대한 자세한 내용은 Amazon Python SDK를 내려받아서 도움말을 참고하세요. 공식 파이썬 AWS 라이브러리는 boto입니다. 다른 라이브러리는 Python Package Index 검색창에서 'aws' 또는 'amazon'을 입력하여 찾을 수 있습니다.
오픈스택
두 번째로 가장 인기 있는 클라우드 서비스 제공자는 Rackspace였습니다. 2010년 클라우드 인프라의 일부를 오픈스택(OpenStack)으로 병합하기 위해 미국 항공우주국(NASA)과 특별한 파트너십을 맺었습니다. 오픈스택은 공개, 비공개, 하이브리드 클라우드를 구축하기 위해 자유롭게 사용할 수 있는 오픈소스 플랫폼입니다. 6개월마다 새 버전이 출시됩니다. 최근 기여자(Contributor)들은 125만 줄이 넘는 파이썬 코드를 기여했습니다. 오픈스택은 유럽 입자물리연구소(CERN)와 페이팔(PayPal)을 포함한 많은 조직에서 사용하고 있습니다.
오픈스택의 메인 API는 RESTful입니다. 프로그래밍 인터페이스를 제공하는 파이썬 모듈과 쉘 자동화를 위한 커맨드 라인 프로그램을 제공합니다. 다음은 표준 서비스의 일부입니다.
-
Keystone
-
인증(예: 사용자/비밀번호)과 권한(기능), 서비스 디스커버리를 제공하는 신원 서비스
-
-
Nova
-
네트워크에 연결된 서버를 통해 작업을 배포하는 컴퓨팅 서비스
-
-
Swift
-
아마존 S3와 같은 오브젝트 스토리지. Rackspace의 클라우드 파일 서비스에서 사용합니다.
-
-
Glance
-
중간 수준의 이미지 스토리지 서비스
-
-
Cinder
-
저수준의 블록 스토리지 서비스
-
-
Horizon
-
서비스에 대한 웹 기반의 대시보드
-
-
Neutron
-
네트워크 관리 서비스
-
-
Heat
-
오케스트레이션(Orchestration; 멀티 클라우드) 서비스
-
-
Ceilometer
-
텔레메트리(Telemetry; 측정, 모니터링, 미터링) 서비스
-
때때로 인큐베이션 프로세스를 통해 오픈스택 플랫폼의 일부가 될 수 있도록 다른 서비스들을 제안하고 있습니다.
오픈스택은 리눅스 또는 리눅스 가상 머신(VM)에서 실행됩니다. 오픈스택은 핵심 서비스의 설치를 다소 요구합니다. 리눅스에서 오픈스택을 설치하는 가장 빠른 방법은 DevStack을 사용하는 것이며, 설치하면서 날아다니는 모든 설명을 잘 보면 됩니다. 설치 후 다른 서비스를 보고 제어하는 하나의 웹 대시보드가 생성됩니다.
수동으로 오픈스택의 일부 혹은 전체를 설치하고 싶다면 리눅스 배포 패키지 매니저를 사용합니다. 모든 메이저 리눅스 벤더들은 오픈스택을 지원하고, 다운로드 서버에 공식 패키지를 제공하고 있습니다. 설치 문서, 뉴스, 관련 정보는 오픈스택 사이트를 참고하세요.
오픈스택은 개발 및 기업 지원을 가속화하고 있습니다. 오픈스택은 독점적인 유닉스 버전의 지원을 중단했을 때 리눅스에 비유되었습니다.
'Programming > Python' 카테고리의 다른 글
9. Python으로 시스템 다루기 (0) | 2020.03.16 |
---|---|
8. Python으로 웹 살펴보기 (0) | 2020.03.13 |
7. Python으로 데이터 다루기(하) (0) | 2020.03.11 |
6. Python으로 데이터 다루기(상) (0) | 2020.02.29 |
5. Python의 객체와 클래스 (0) | 2020.02.06 |