네트워크 프로토콜을 I/O 없이 구현하는 이유와 Python에서 이를 실천하는 구체적인 방법과 설계 원칙을 설명합니다.
이 문서는 네트워크 I/O를 수행하지 않고 네트워크 프로토콜을 구현하는 접근을 정당화하고, 이를 Python에서 수행하기 위한 구체적인 도움과 지침을 제공하려는 정보 문서입니다.
I/O 없는 프로토콜 구현(구어적으로 “sans-IO” 구현이라 부름)은 어떤 형태의 네트워크 I/O나 어떤 형태의 비동기 흐름 제어도 수행하지 않는 코드를 전혀 포함하지 않는 네트워크 프로토콜 구현을 말합니다. 달리 말해, sans-IO 프로토콜 구현은 동기 함수가 동기 결과를 반환하는 방식만으로 전적으로 정의되며, 어떤 형태의 I/O로도 블로킹하거나 대기하지 않습니다. 이 종류의 애플리케이션 예시는 랜딩 페이지에서 볼 수 있습니다.
이런 프로토콜 구현은 표면적으로는 그다지 유용하지 않습니다. 결국 네트워크와 대화하지 않는 네트워크 프로토콜 구현은 별 도움이 되지 않기 때문입니다! 하지만 이런 방식으로 프로토콜을 구현하면 더 넓은 소프트웨어 생태계에도, 그리고 여러분 자신의 코드 품질에도 매우 유용한 여러 이점을 제공합니다.
이 문서의 나머지 부분에서는 그 이점이 무엇인지 설명하고, 이런 방식으로 프로토콜 스택을 작성할 때 흔히 사용하는 기법들을 소개합니다.
Note
sans-IO 구현 스타일은 여러 더 넓은 소프트웨어 설계 모범 사례의 특정한 한 단면이라는 점을 알아둘 필요가 있습니다. 특히, Bob Martin의 Clean Architecture, 잘 구현된 Model-View-Controller 애플리케이션, Gary Bernhardt의 Functional Core and Imperative Shell, 그리고 관심사 분리라는 더 넓은 소프트웨어 설계 원칙과 비교해 볼 수 있습니다. 이 모든 것은 그 자체로 이해할 가치가 있는 훌륭한 패턴이지만, 특정 문제 도메인에 적용하여 논의하는 것이 도움이 될 때가 많습니다. 바로 이것이 sans-IO가 다루는 바입니다.
sans-IO 프로토콜 구현을 작성하면, 구현 자체에도 더 넓은 소프트웨어 생태계에도 여러 유용한 이점을 제공합니다. 이를 차례대로 살펴보겠습니다.
개발자 입장에서, I/O가 없는 프로토콜 구현을 작성하면 고품질 구현을 훨씬 쉽게 만들 수 있습니다. 네트워크 I/O는 특히나 아주 단순한 경우에도 거의 언제든 발생할 수 있는 다양한 예기치 않은 실패 모드에 취약하다는 것은 잘 알려진 사실입니다. 프로토콜 구현이 더 이상 자체 I/O를 구동하지 않고, 대신 메모리 내 바이트 버퍼를 통해 데이터를 주고받게 되면, 가능한 실패 공간이 대폭 줄어듭니다.
메모리에 쓰고 읽는 일이 실패하지 않는다고 가정하면, 구현은 자신의 데이터를 훨씬 단순하게 관리할 수 있습니다. 이제 구현이 신경 써야 할 유일한 문제는 불완전한 데이터(아직 완전히 파싱할 수 없는 데이터)를 버퍼링하는 것입니다. 데이터 버퍼링은 소켓을 다루는 것에 비해 일반적으로 훨씬 단순한 문제이며, 어차피 필요하기도 합니다(짧은 recv 응답을 겪어본 사람이라면 누구나 증언할 수 있듯이). 이 모든 것은 구현이 입력과 출력 데이터를 훨씬 단순하게 관리하도록 만듭니다.
이 단순성은 구현 내부의 흐름 제어에도 이어집니다. 구현이 이제 바이트 버퍼에서 읽고 쓰는 데에만 시간을 쓰기 때문에, 버퍼에 공간이 부족한 경우를 제외하고 구현이 블로킹하거나 중단될 일이 없습니다. 더 많은 데이터가 도착하기를 기다리거나 데이터가 전송되기를 기다리기 위해 계산을 일시 중지해야 할 요구가 전혀 없으며, 구현을 재진입에 안전하도록 만들 필요도 비교적 적습니다. 이 모든 것은 구현을 거치는 가능한 제어 흐름의 수를 크게 제한하여, 구현을 훨씬 잘 이해하도록 도와줍니다.
단순성은 곧 테스트 용이성으로 이어집니다. 프로그램을 거치는 제어 흐름의 수가 훨씬 적기 때문에, 가능한 진입점과 상태 공간의 위치가 훨씬 줄어들어 테스트로 분기 커버리지 100%에 도달하기가 매우, 매우 쉬워집니다.
또한, 테스트 대상 코드가 입력과 출력 모두에 대해 바이트 버퍼만 다루므로, 테스트 코드는 더 이상 소켓을 관리하는 척할 필요가 없습니다. 테스트는 구현의 올바름을 검증하기가 매우 쉬워지는데, 그저 바이트 시퀀스를 밀어 넣고, 나오는 바이트 시퀀스를 검증하기만 하면 되기 때문입니다. 구현에 I/O가 전혀 없기 때문에(모킹된 I/O조차도), 테스트에서 비결정성이 발생할 위험이 없습니다. 테스트는 통과하거나 실패할 뿐입니다. 재현하기 어려운 테스트 환경이 필요한 “플레이키(flaky)” 테스트가 되지 않습니다.
또한, 구현이 I/O 호출을 수행할 때는 일반적으로 불가능한 특정한 주장(어설션)을 테스트 대상 코드에 대해 할 수 있습니다. 예를 들어, 공개 API만 사용하여 코드와 분기 커버리지 100%를 달성해야 한다는 주장은 무리가 아닙니다. 즉, 바이트 시퀀스를 전달하거나 공개 API 함수를 호출하는 것만으로 달성해야 합니다. 그런 호출로 도달할 수 없는 코드 분기가 있다면, 사용자가 여러분의 구현을 몽키패칭하지 않는 한 그 분기에 도달하는 것은 사실상 불가능합니다. 그런 코드는 완전히 불필요하며, 안심하고 전부 제거할 수 있습니다.
마지막으로, 모킹도 없고 실제 I/O도 없으므로 테스트는 매우 빠르고 병렬 실행에 안전해집니다. 테스트 픽스처와 테스트의 조합적 확장도 꽤 쉽게 제공할 수 있어, 수천 개의 테스트 케이스를 제공할 수 있습니다. 또한 버그를 재현하고 회귀를 방지하기 위한 새로운 테스트 케이스를 작성하기도 매우 쉽습니다.
구현이 비동기 흐름 제어 프리미티브나 실제 I/O를 사용하는 경우에는 이러한 모든 것을 달성하기가 훨씬 어려워집니다. 모든 가능한 I/O 오류에 직면했을 때 구현의 올바름을 검증하는 것은 매우 어렵고, 사실상 코드의 가능한 모든 위치에서 발생 가능한 모든 I/O 오류를 유발한 다음, 구현이 예상대로 그 오류를 처리하는지 검증해야 합니다. 비동기 흐름 제어 프리미티브에 대해서도 비슷한 개념이 적용됩니다. 올바름에 대한 같은 보장을 하기 위해서는 가능한 모든 순서로 트리거되어야 합니다.
단순성과 테스트 용이성은 정확성으로 이어집니다. 구현의 상대적인 단순성과, 달성할 수 있는 매우 높은 수준의 테스트 커버리지 덕분에 프로토콜 구현의 올바름에 대해 매우 높은 신뢰를 가질 수 있습니다.
이 수준의 정확성은 그 구현을 사용하는 애플리케이션에서 달성되지 않을 수 있지만, 그럼에도 불구하고 프로토콜 구현이 모든 경우에 재현 가능하고 일관된 방식으로 동작하며, 정형화된 출력을 생성할 가능성이 매우 높다는 사실을 아는 것은 매우 유용합니다.
이것은 특히 네트워크 프로토콜에서 가치가 있는데, 현실 세계의 다른 구현과 상호 운용할 수 있는 확률을 크게 높여주기 때문입니다. 그리고 상호 운용성 문제에 직면하더라도, 그 문제를 테스트 환경에서 쉽게 재현하여, 프로토콜을 오해한 쪽이 여러분의 구현인지 상대 구현인지 확인하기가 쉬워집니다.
마지막으로, 정말 개인적인 관점에서, 구현의 정확성이 높을수록 사용자로부터 처리해야 할 버그 리포트가 줄어듭니다!
sans-IO 프로토콜 구현을 작성했을 때 얻는 덜 이기적인 개선점은 재사용성이 비약적으로 높아진다는 것입니다. 2016년의 Python 생태계에는 거의 모든 일반적인 네트워크 프로토콜에 대한 구현이 다수 존재하지만, 반올림 오차 수준까지 따지면 실질적인 프로토콜 코드를 공유하는 경우는 사실상 전무합니다.
이는 엄청난 중복 노력입니다. 비교적 단순한 프로토콜의 프로토콜 스택을 작성하는 것도 꽤 많은 작업이고, 복잡한 프로토콜의 경우 사람-시간 수백 시간이 걸릴 정도로 매우 큰 작업입니다. 이러한 노력을 중복하는 것은 오픈 소스 및 자유 소프트웨어 커뮤니티가 점점 감당하기 어려워하는 자원 배분입니다.
노력의 중복도 충분히 나쁘지만, 우리는 같은 버그를 반복해서 작성하고 있기도 합니다. 이는 I/O 기반 프로토콜 구현의 정확한 작성을 어렵게 만드는 이유(위 Simplicity, Testability, and Correctness 참조) 때문에 어느 정도는 불가피하지만, 이러한 다양한 구현의 개발 팀이 서로 겹치지 않는 경우가 많기 때문이기도 합니다. 이로 인해 우리는 같은 미묘한 이슈에 반복해서 걸려 넘어지면서, 그에 대한 지식을 공유하지 못할 뿐더러 문제를 고치기 위한 코드를 공유할 수도 없습니다. 이는 비효율을 기하급수적으로 키웁니다.
물론 네트워크 프로토콜에 대한 경쟁 구현을 작성할 훌륭한 이유는 많이 있습니다. 프로토콜이 어떻게 동작하는지 학습하고 싶을 수도 있고, 현재 구현의 API 또는 정확성이 부족하다고 믿을 수도 있습니다. 하지만 많은 재구현은 이런 이유로 일어나지 않습니다. 대신, 현재의 모든 구현이 I/O를 내부에 강하게 결합했거나, 예상되는 흐름 제어 메커니즘을 내부에 강하게 결합했기 때문에 일어납니다. 예를 들어, aiohttp는 httplib의 파서를 사용할 수 없는데, httplib이 소켓 호출을 그 파서에 박아 넣었기 때문에 asyncio 환경에 적합하지 않기 때문입니다.
프로토콜 구현에서 비동기 흐름 제어와 I/O를 분리해 두면, 그 구현을 모든 형태의 흐름 제어에서 사용할 수 있게 됩니다. 즉, 프로토콜 구현의 핵심이 I/O 수행 방식이나 API 설계 방식으로부터 완전히 분리됩니다. 이는 Python 커뮤니티에 막대한 이점을 제공합니다.
더 단순하거나 더 나은 API 설계를 실험하고 싶은 사람들은, 프로토콜 구현을 작성할 필요도, 기존 API 설계에 제약을 받을 필요도 없이 그렇게 할 수 있습니다.
curio 같은 특이한 비동기 흐름 제어 접근을 추구하는 사람들은, 모든 프로토콜에 정통하지 않더라도, 최소한의 노력으로 그 새로운 접근과 호환되는 새 구현을 얻을 수 있습니다.
특이하거나 고성능 I/O 요구 사항이 있는 사람들은 전체 프로토콜 스택을 다시 작성할 필요 없이 자신의 I/O 코드를 직접 제어할 수 있습니다. 예를 들어, 고성능 HTTP/2 구현을 작성하려는 사람은 대부분의 I/O 포함 구현에서는 쉽게 할 수 없는 TCP_NOTSENT_LOWAT 소켓 옵션을 중심으로 I/O를 설계하고 싶어할 것입니다.
이것은 또한 우리 작업을 중앙집중화할 수 있게 합니다. 모든 Python 라이브러리, 또는 대부분의 라이브러리가 인기 프로토콜의 소수 구현을 중심으로 모이게 되면, Python 커뮤니티에서 최고의 프로토콜 전문가들이 핵심 프로토콜 구현의 버그를 수정하고 기능을 추가하는 데에 노력을 집중할 수 있어, 커뮤니티 전체에 ‘밀물이 모든 배를 떠올린다’는 효과를 가져옵니다.
Why Write I/O-Free Protocol Implementations?에 설득되었다면, 논리적인 다음 질문은 이것입니다. I/O를 전혀 하지 않는 프로토콜 구현은 어떻게 작성할까요?
프로토콜마다 고유한 점이 있지만, sans-IO 구현을 위한 비계를 제공하는 데 도움이 되는 몇 가지 핵심 설계 원칙이 있습니다.
네트워크 프로토콜의 근본적인 수준에서, 모든 프로토콜은 바이트 시퀀스를 소비하고 생성합니다. TCP(또는 어떤 SOCK_STREAM 유형의 소켓) 위에 구현된 프로토콜은 바이트 스트림을 사용합니다. UDP나 그보다 더 낮은 수준의 프로토콜(예: IP 바로 위) 위에 구현된 프로토콜은 바이트 스트림이 아닌 데이터그램 단위로 통신합니다.
바이트 스트림 기반 프로토콜의 경우, 프로토콜 구현은 하나의 입력 버퍼와 하나의 출력 버퍼를 사용할 수 있습니다. 입력(즉, 네트워크에서 데이터를 수신하는 것)의 경우, 호출 측 코드는 단일 입력(종종 receive_bytes와 같은 이름의 메서드를 통해)을 통해 구현에 데이터를 전달할 책임이 있습니다. 구현은 이 바이트들을 내부 바이트 버퍼에 추가합니다. 이 시점에서 구현은 그 바이트들을 즉각 적극적으로 처리할지, 아니면 호출 측의 요구에 따라 지연 처리할지 선택할 수 있습니다.
출력을 생성할 때, 바이트 스트림 기반 프로토콜에는 두 가지 선택지가 있습니다. hyper-h2처럼 바이트를 내부 버퍼에 쓰고 그 버퍼에서 바이트를 추출하는 API를 제공하든가, h11처럼 호출 측 코드가 이벤트를 트리거할 때 직접 바이트를 반환하든가입니다(이에 대해서는 뒤에서 더 자세히 다룹니다). 이 두 선택의 차이는 그리 중요하지 않습니다. 서로 쉽게 전환할 수 있기 때문입니다. 다만 입력 바이트를 수신하는 행위가 출력 바이트를 생성하게 만들 수 있다면, 즉 프로토콜 구현이 때때로 사용자 입력 없이도 상대에게 자동으로 응답한다면 내부 바이트 버퍼를 사용하는 것이 권장됩니다.
데이터그램 기반 프로토콜의 경우에는 데이터그램 경계를 보존하는 것이 보통 중요합니다. 따라서 위의 일반적인 구조는 그대로지만, 입력과 출력을 바이트 문자열의 이터러블을 소비하고 반환하도록 바꾸는 것이 좋습니다. 이터러블의 각 요소는 하나의 데이터그램에 해당합니다.
대부분의 sans-IO 프로토콜 스택에서 사용하는 주요 추상화는 네트워크에서 수신한 바이트를 “이벤트”로 변환하는 것입니다. 본질적으로, 이 추상화는 네트워크 프로토콜을 그 프로토콜에서 발생할 수 있는 의미론적 “이벤트”들의 시퀀스를 직렬화하는 메커니즘으로 정의합니다.
이 추상화 모델에서 프로토콜의 양측 피어는 이벤트를 방출하고 수신합니다. 이벤트 수신과 관련해서는, 바이트가 제공될 때마다 즉시 이벤트를 호출 측에 반환하도록 하거나, 호출 측의 요청에 응답하여 지연 생성하도록 할 수 있습니다. 두 접근 모두 장단점이 있으며, 어느 것을 선택하든 큰 문제는 없습니다.
이벤트 방출과 관련해서는 몇 가지 가능한 접근이 있지만, 두 가지가 활발히 사용됩니다. 첫 번째이자 단연 가장 일반적인 방법은 함수 호출을 사용해 이벤트를 방출하는 것입니다. 예컨대 HTTP 구현은 send_headers라는 이름의 함수 호출을 가질 수 있고, 이는 동일한 구현에서 수신될 경우 RequestReceived 이벤트가 방출되도록 하는 바이트 스트림을 방출합니다. 이는 hyper-h2가 사용하는 접근입니다.
그러나 대안으로, 구현이 방출하는 것과 동일한 이벤트를 인수로 받는 단일 메서드를 두는 방법이 있습니다. 이는 h11이 사용하는 접근입니다. 이 방법은 구현의 입력과 출력에 대칭성이 있다는 큰 장점이 있지만, 많은 개발자에게는 다소 편하지 않은 프로그래밍 방식이라는 단점이 있습니다.
두 접근 모두 잘 작동합니다.
물론 어느 시점에서는 sans-IO 프로토콜 구현을 실제 I/O와 결합해야 합니다. 이때 두 가지 명확한 목표가 있습니다. 첫째는 주어진 I/O 모델에 대해 완전하고 네이티브한 느낌의 API를 제공하는 것입니다. 둘째는 여러 I/O 모델에서 쉽게 바꿔가며 실행할 수 있는 구현을 제공하는 것입니다. 각 목표는 서로 다른 설계 요구 사항을 가집니다.
특정 I/O 모델(예: Twisted 또는 asyncio)에 대해 완전하고 네이티브한 느낌의 API를 설계하려면, 해당 플랫폼의 표준 설계 패턴을 전적으로 따르는 것이 좋습니다. 흐름 제어 프리미티브와 적절한 인터페이스, I/O 메커니즘을 적극적으로 사용하여 데이터를 전송하세요. 이렇게 하면 HTTP를 바닥부터 다시 구현할 필요 없이 aiohttp 같은 모듈을 만들 수 있습니다. 또한 일반적인 사용 사례에 최적화하고, 마찰 없는 인터페이스를 제공할 수 있습니다.
또 다른 가능성은 I/O와 흐름 제어 프리미티브를 가능한 한 프로그램이나 라이브러리의 ‘가장자리’로 밀어내고, 여러 백엔드를 위한 통합 지점을 제공하는 것입니다. 이를 위해서는 상당한 주의와 규율이 필요합니다. 코드베이스 전체가 주어진 플랫폼의 I/O와 흐름 제어 프리미티브를 사용하는 극히 작은 핵심을 제외하고는 sans-IO 프리미티브에 기반하도록 해야 하기 때문입니다. 이렇게 하면 아주 적은 변경만으로 여러 I/O 및 흐름 제어 패러다임에 깔끔하게 들어맞는 단일 코드베이스를 가질 수 있지만, 일부 또는 전부에서 아주 네이티브하게 느껴지지 않을 수 있다는 대가가 따릅니다.
이 문서는 Python Async Special Interest Group의 훌륭한 분들의 노력 없이는 존재할 수 없었습니다. 이들은 Python에서 비동기 프로그래밍 패러다임을 구축하고 확장하기 위해 지칠 줄 모르고 일해 왔으며, Python 커뮤니티가 사용하고 사랑하는 모든 비동기 프로그래밍 프레임워크 뒤편의 프로그래밍 커뮤니티 또한 마찬가지로 큰 기여를 했습니다.