
10분 분량
2022년 11월 18일
저를 모르신다면, 저는 지난 5년 동안 풀타임으로 피어 투 피어 애플리케이션을 만들어 왔습니다. 같은 네트워킹 구현을 두 번이나 다시 썼죠. 지난 5년 동안 제가 깨달은 것 중 하나는 평균적인 프로그래머가 네트워킹에 대해 아는 것이 놀랄 만큼 적다는 겁니다. 이를 조금이나마 바로잡는 데 도움이 되길 바라며, 여기 제가 네트워킹에 관해 배운 것들(그 5년 전후를 통틀어)의 브레인 덤프를 공유합니다.
아래 목록은, 실험 삼아 소켓을 몇 번 만들어 본 적은 있지만 실제로 이를 직업적으로 다뤄 본 적은 없는 평균적인 프로그래머를 상대로 한다고 가정하고 씁니다.
Get tomaka’s stories in your inbox
이 작가의 업데이트를 받으려면 Medium에 무료로 가입하세요.
자, 시작합니다.
일반 상식
- 평균적인 프로그래머나 최종 사용자는 IP 주소가 무엇인지조차 거의 모릅니다. UX에서 이들에게 어떤 기본 지식도 기대하지 마세요. 예를 들어 포트를 연다거나 닫는다는 개념도 포함됩니다.
- 한 번도 네트워킹 코드를 써 본 적 없는 평균적인 프로그래머는, 자신이 좋은 네트워킹 코드를 작성할 수 있는 역량을 과대평가합니다.
- 평균적인 프로그래머는 네트워킹을 이미 해결된 문제, 즉 제대로만 쓰면 그냥 동작하는 지루한 것으로 여깁니다. 전혀 그렇지 않습니다.
TCP
- TCP 연결은 두 개의 단방향 스트림(각 방향 하나씩)을 가집니다. 각 측은 자신의 송신 측을 닫기 위해 FIN을 보낼 수 있으며(이 경우 상대는 EOF를 받음), 그 이후에도 계속 데이터를 수신할 수 있습니다.
- 위 사실을 아는 프로그래머는 놀랄 만큼 적습니다.
- TCP 프로토콜은 적당히 복잡합니다. TCP 프로토콜의 구현은 미치도록 복잡합니다. 구현이 어떻게 동작하는지 이해하려 들 가치가 없을 정도이며, 그에 열정이 있는 게 아니라면 말이죠.
- TCP 트래픽을 TCP로 터널링하지 마세요.
- TCP는 상위 레벨 프로토콜로부터 더 많은 정보를 받을 수 있었다면 원칙적으로 더 나아질 수 있었습니다. 대신 TCP는 사용 방식에 상관없이 “마법처럼” 동작하려고 하면서 여러 트레이드오프를 택했습니다. 그래서 QUIC이 개발되었습니다.
- TCP의 슬로 스타트 때문에, 소켓에 데이터를 몇 초 이상 보내지 않다가 다음 데이터를 쓰면 아주 느린 속도로 전송됩니다. 가끔씩 데이터를 폭발적으로 보내고 싶다면 문제가 될 수 있습니다.
- 아마도 Nagle 알고리즘을 비활성화하고, _send_를 호출하기 전에 가능한 한 많은 데이터를 버퍼링하는 것이 좋습니다.
그렇게 하려면, 지금 더 쓸 수 있는 데이터가 남아 있는지, 아니면 프로토콜이 이제 원격의 데이터를 기다리느라 막힌 상태인지 판단할 수 있도록 코드를 설계해야 합니다. 이를 API 설계에서 고려하지 않으면, 코드의 거대한 부분을 리팩터링해야 할 수도 있습니다.
- 만약 여러분의 코드가 송신 데이터를 버퍼링해 버퍼가 가득 찼을 때나 일정 지연 후에 보내도록 한다면, 그것은 정확히 Nagle 알고리즘이 하는 일과 같습니다. 이 경우에는 Nagle을 활성화해 두고 여러분 쪽 버퍼링을 제거하는 편이 낫습니다.
- 이론적으로 TCP 위에 멀티플렉싱 프로토콜을 구현할 수 있지만, 실제로는 TCP 구현 세부에 접근할 수 없기 때문에 제대로 구현하는 것은 불가능합니다. 그럼에도 사람들은 여러시도를 해 왔습니다.
IP
- 2022년인 지금도 최종 사용자 기기나 ISP에서 IPv6가 활성화되어 있다고 기대할 수 없습니다. 어디서든 IPv4 폴백을 마련해야 합니다.
- 일부 IP 주소는 예약되어 있습니다. 예를 들어 203.0.113.0은 문서화 용도로 예약되어 있습니다.
- 연결된 머신의 정체성을 IP 주소에 의존하지 마세요. IP 주소는 스푸핑될 수 있습니다. 연결된 머신의 정체성을 추적하려면 암호학을 사용해야 합니다.
- 여러분의 소프트웨어가 Kubernetes에서 또는 어떤 종류의 프록시 뒤에서 실행된다고 가정하세요. 소스 IP 주소가 전 세계적으로 접근 가능하다고 가정하지 마세요.
- IP 주소에 전이성을 가정하지 마세요. Alice가 어떤 IP 주소로부터 메시지를 받았다면, Alice는 그 IP로 되돌려 메시지를 보낼 수 있다고 가정할 수 있습니다. 하지만 Alice가 그 IP 주소를 Bob에게 알려 준다고 해서, Bob이 그 주소로 메시지를 보낼 수 있다고 가정할 수는 없습니다.
- 다만, 휴리스틱이나 게임을 만들 때는 IP 주소에 전이성이 있다고 가정해도 됩니다. 실제로는 대부분 그런 경우이기 때문입니다.
- 특히 휴대폰은 Wi-Fi와 모바일 인터넷 간 전환 등으로 IP 주소가 비교적 자주 바뀔 수 있습니다. TCP의 경우, 이런 변경은 기존 모든 연결을 재수립해야 함을 의미합니다. 애플리케이션이 적절히 대응하지 않으면 UX가 나빠질 수 있습니다.
백프레셔
- 수신 속도가 처리 속도보다 빠르면 문제가 생깁니다. 이 문제는 송신자를 늦추는 방식(TCP가 하는 방식)으로 해결하거나, 일부 데이터를 조용히 폐기하는 방식(UDP가 하는 방식)으로 해결할 수 있습니다.
- 이는 네트워킹 트래픽뿐 아니라 디스크에서 데이터를 읽는 작업, 외부 프로세스로부터 데이터를 받는 작업, 프로세스 내 다른 부분으로부터 데이터를 받는 고수준 개념에도 적용됩니다. 데이터가 “외부로부터” 들어오는 모든 상황은 백프레셔 문제가 관련됩니다.
- 이 문제는 무시하기 쉽습니다. 실제로는 처리 속도가 병목이 되는 경우가 드물기 때문입니다(데이터 처리는 보통 CPU 연산을 하거나 디스크에 쓰는 작업인데, 둘 다 대개 네트워킹에서 데이터를 받는 것보다 빠릅니다).
하지만 한 번 문제가 발생하면(예: 트래픽 급증, 디스크가 바빠져 쓰기가 느려짐), 이 문제를 무시한 대가로 연쇄적인 문제들이 쏟아집니다.
- 이 문제를 초기에 무시하면, 나중에 코드의 큰 부분을 다시 써야 할 수도 있고, 사업적 이유로 결국 영영 고치지 못할 수도 있습니다. 초기에 무시하지 마세요.
- 송신자를 늦추는 전략을 택했다면, 데이터 흐름에 루프가 생기지 않도록 완전히 피해야 합니다. 그렇지 않으면 두 송신자가 서로 상대가 보내기를 끝내길 기다리느라 막히는 데드락 위험이 있습니다.
- 데이터를 받을 때, 버퍼를 무한히 동적으로 키우지 마세요. 버퍼의 최대 크기에 한계를 두세요. 그렇지 않으면 처리 속도보다 빠른 속도로 데이터를 받는 경우 메모리 누수가 됩니다.
- 수신 데이터 버퍼가 너무 커지면, 데이터가 수신된 시점과 처리되는 시점 사이의 지연이 길어질 수 있습니다. 응답이 필요한 경우 이 지연이 타임아웃을 일으킬 만큼 길어질 수도 있습니다. 수신 버퍼의 크기는 허용 가능한 최대 지연에 맞춰 설정해야 합니다.
- 수신 버퍼가 너무 작으면, 불필요하게 많은 컨텍스트 스위치나 네트워킹 왕복이 필요해집니다. 문제가 될 수 있지만, 버퍼가 너무 큰 것보다는 덜 문제입니다.
멀티플렉싱
- 멀티플렉싱은 여러 스트림을 하나로 결합하는 것을 말합니다.
- 멀티플렉싱은 인터넷 인프라 전반에서 사용되지만, 애플리케이션 내부에서는 상대적으로 덜 알려진 개념입니다. TCP 위에서 제대로 된 멀티플렉싱을 구현하는 것이 사실상 불가능하기 때문입니다.
- 멀티플렉싱의 목적은 헤드 오브 라인 블로킹 문제를 해결하는 것입니다. 여러 요청을 병렬로 하고 싶다면 멀티플렉싱을 사용하는 것이 좋습니다.
- 어느 시점에 열려 있을 수 있는 서브스트림의 개수에는 한도를 둬야 합니다. 그렇지 않으면 악의적인 원격이 메모리 누수를 유발할 수 있습니다.
- 멀티플렉싱은 긴 정체 중에도 긴급 데이터를 우선적으로 보낼 수 있게 해 줍니다. 마치 비상차량이 정체를 가로지르는 것과 같습니다.
- API 관점에서, 멀티플렉싱은 종종 나쁜 API 때문에 “지금 송신 버퍼를 플러시해야 하는지, 곧 더 데이터가 추가될지”에 대한 통제력을 잃는 지점입니다. 쓰기 쉬운 고수준 API는 대개 멀티플렉싱 코드가 이를 알 방법을 제공하지 않으며, 불행히도 더 복잡한 API가 필요합니다. 이는 API 설계 초기부터 염두에 둬야 할 문제입니다.
프로토콜 설계
- 모든 것을 하는 단일 프로토콜을 설계하는 것은 엄청나게 복잡할 수 있습니다. 대신 멀티플렉싱을 사용해 개별적으로 단순한 여러 프로토콜을 결합하세요.
- 추상화 레이어를 차곡차곡 쌓다가, 데이터가 와이어 위에서 어떻게 전송되는지에 대한 통제력을 잃지 마세요. 저수준 문제를 고쳐야 할 때는 언제든 그럴 수 있어야 합니다.
- 어떤 때는 데이터 스트림을 보내고, 어떤 때는 개별 패킷을 보낼 겁니다. 둘은 쉽게 상호 변환할 수 있지만, 추상화 레이어 때문에 이리저리 여러 번 변환하는 일은 피하세요.
- 프로토콜의 명세를 작성하고, 애플리케이션이 그 프로토콜을 준수하는지 확인하는 단위 테스트를 작성하세요.
- 멀티플렉싱으로 여러 단순한 프로토콜을 결합한다면, 각 프로토콜에서 각 당사자가 메시지를 어떤 정확한 순서로 보내는지 정하세요. 이를 보장하지 못하면, 양쪽 버퍼가 가득 차 서로가 비우길 기다리느라 양쪽이 데드락 상태에 빠질 수 있습니다.
- 고수준 프로토콜은 보통 두 가지 범주로 나눌 수 있습니다. Alice가 Bob에게 요청을 보내고 Bob이 응답을 보내는 경우, 혹은 Alice가 Bob에게(명시적 또는 암묵적으로) 최신 상태를 유지해 달라고 요청하고 Bob이 이벤트 스트림을 보내는 경우입니다.
- 요청/응답의 경우, Alice는 추가 요청을 보내지 않음으로써 응답 속도를 늦출 수 있습니다.
- 요청과 응답에는 크기 제한이 필요합니다. 메모리 누수를 피하기 위해서입니다.
- 상대방이 메시지를 보내는 속도가 조금 느리거나 대역폭이 제한된 상황과, 상대방이 아예 아무 것도 보내지 않는 상황을 구분하기 어렵습니다. 가능하면 빨리 둘을 구분하기 위해, 타임아웃과 요청/응답의 최대 크기를 비교적 작게 두세요.
- 따라서, 큰 요청을 피하세요. 큰 요청을 여러 개의 작은 요청으로 나누고, 응답을 합치는 방식이 낫습니다. 예를 들어 50MiB 응답 하나를 받는 하나의 요청 대신, 각 1MiB 응답을 받는 50개의 요청을(가능하다면 병렬로) 보내는 편을 선호하세요.
- 이벤트 스트림의 경우, 해당 이벤트는 대개 네트워킹만을 위해 생성되는 것이 아니라 어떤 외부 소스에서 옵니다.
그래서 송신자를 늦추는 식의 백프레셔 전략에는 결함이 있습니다. 송신자 측에서 이벤트가 쌓일 때의 전략이 필요하기 때문입니다.
이벤트를 고수준에서 설계할 때, 안전하게 무시하거나 병합할 수 있도록 하세요. 예를 들어 “값이 이제 3”이라는 메시지는 뒤이어 “값이 이제 5”라는 메시지가 오면 폐기해도 됩니다.
- 평균적인 인터넷 댓글러는 DoS 공격이 무엇인지 제대로 이해하지 못합니다.
- DoS 공격에는 크게 두 종류가 있습니다. “저수준”과 애플리케이션 수준.
- “저수준” DoS 공격(IP 레벨이나 TCP/UDP 레벨)은 애플리케이션 개발자가 해결할 수 없기 때문에 거의 논의되지 않습니다. 일반적으로는 해결된 문제로 간주되며, 그 해결책은 CloudFlare라 불립니다.
- 애플리케이션 수준 DoS 공격을 방지하는 핵심은, 프로토콜을 제대로 설계해 모든 코너 케이스를 다루는 것입니다. 예: “원격이 <X> 메시지를 말도 안 되게 많이 보내면?”, “네트워킹 메시지 하나가 하나 이상의 요소를 버퍼에 추가하게 한다면, 이 버퍼의 크기에 제한이 있는가?”, “원격이 초당 1바이트 속도로 데이터를 보내면?”, “원격이 곧 200GB짜리 패킷을 보낼 거라고 알리면?” 등.
- _O(n)_이나 _O(1)_에 해당하는 동등한 알고리즘보다 측정상 더 빠르더라도, O(n²) 이상의 알고리즘은 피하세요. 악의적인 원격이 의도적으로 여러분 코드의 나쁜 알고리즘 복잡도를 유발하는 페이로드를 보낼 수 있습니다.
- DoS 공격의 목적은 합법적 사용자의 서비스 이용을 방해하는 것입니다. 그래서 Denial-of-Service라는 이름이 붙었죠. 서버 충돌을 일으키는 것을 반드시 의미하진 않습니다.
- 여러분의 애플리케이션이 높은 부하에 대해 합법적 사용자에게 오류를 반환하는 방식으로 대응한다면, 여러분은 공격자가 공격을 더 쉽게 하도록 돕는 셈입니다.
- 어떤 악의적인 피어가 무엇을 보내더라도, 연결된 모든 피어에게 최소한의 서비스 수준을 보장할 수 있다면, 그 애플리케이션은 DoS에 탄력적이라고 말할 수 있습니다.
- 목표가 최소한의 서비스 수준을 보장하는 것이고, CPU와 메모리는 제한되어 있으므로, 궁극적으로 애플리케이션은 일정 수 이상의 피어만 서비스할 수 있습니다. 그 이후에는 새 피어의 접근을 거부해야 합니다.
DDoS 공격은 보통 합법적 사용자가 연결하지 못하도록 사용 가능한 모든 슬롯을 점유하려고 합니다.
- IP 주소 차단 같은 DoS 완화 기술은 모두 외부 애플리케이션(방화벽, CloudFlare 등)에 맡기세요. 애플리케이션 안에 직접 구현하지 마세요.
- 수직 확장은 단일 애플리케이션이 서비스할 수 있는 피어 수를 늘리기 위해 CPU와 메모리를 늘리는 것이고, 수평 확장은 더 많은 머신과 애플리케이션 인스턴스를 띄워 총 CPU와 메모리를 늘리는 것입니다.
- 궁극적으로 DDoS에 버티는 것은 더 많은 CPU와 메모리에 돈을 쓰는 일이며, 공격자도 공격을 위해 돈을 씁니다. 누가 더 늦게 돈이 떨어지는지가 승자입니다. 프로그래머의 목표는 방어자가 써야 할 돈을 줄이는 것입니다.
관측성
- 관측성에 익숙하지 않다면 Prometheus가 어떻게 하는지 보세요. 예를 들어 대역폭 사용량을 측정하는 것은 애플리케이션 시작 이후 보낸/받은 총 바이트 수를 카운터로 유지하기만 하면 되고, 속도 계산은 UI에서 합니다.
- 거의 모든 것을 측정해야 합니다. 애플리케이션의 대역폭 사용량뿐 아니라 연결 수, 서브스트림 수, 요청 수, 요청 크기, 응답 크기, 에러 수, 도달한 타임아웃 등.
- 네트워킹 코드에는 보통 많은 레이어가 얽혀 있어, 쉬운 최적화를 놓치기 쉽습니다. 모든 것을 측정하면 이상한 지점을 발견하는 데 도움이 됩니다.
- 정치와 마찬가지로, 통계는 무엇이든 말하게 만들 수 있습니다. 올바른 것을 측정하는 일은 종종 문제를 고치는 것보다 더 복잡합니다.
피어 투 피어 네트워크
- 노드는 언제든 네트워크에 합류하거나 떠날 수 있기 때문에, 네트워크의 노드를 추적하고 누구와 연결해야 하는지 아는 일은 엄청나게 복잡한 열린 문제입니다.
- 위 이유로, 피어 투 피어 애플리케이션이 왜 제대로 동작하지 않는지 사용자에게 피드백을 제공하는 것은 매우 도전적입니다. 전통적인 클라이언트-서버 애플리케이션과 달리, 연결 실패는 명백한 하드 에러가 아닙니다.
- 게임을 설계함으로써 일부 문제를 비교적 쉽게 해결할 수 있습니다. 하지만 게임은 창발적 행동을 유발하는 경향이 있고, 예컨대 네트워크의 모든 노드가 서로 동기화될 수 있습니다. 이는 큰 CPU 스파이크로 이어질 수 있고, 백프레셔가 제대로 되지 않으면 더욱 증폭됩니다.
- 비정상 트래픽(DoS 공격이나 버그 등)과 창발적 행동을 구분하기가 매우 어려울 수 있습니다.
- 원칙적으로 피어 투 피어 애플리케이션이 전통적인 클라이언트-서버보다 DoS 내성이 더 어렵지는 않지만, 실제로는 더 복잡한 프로토콜을 쓰는 경향이 있습니다. 게다가 클라이언트-서버 프로토콜에서는 클라이언트가 서버를 신뢰한다고 가정하는 경우가 많지만, 피어 투 피어에서는 그런 가정을 할 수 없습니다.
기타
- 여러분이 Kubernetes를 쓰지 말라고 권고해도, 사용자들은 여러분 소프트웨어를 Kubernetes에서 돌릴 것이고, 제대로 동작하지 않으면 여러분에게 불평할 겁니다.
- 경험이 부족하지만 기술에 밝은 사람들 중 상당수는, 집에서 24/7 서버를 돌리는 것이 클라우드 제공자에게 맡기는 것보다 더 쉬운 해결책이라고 생각합니다.
- 네트워킹 전문가를 찾아 채용하는 것은 사실상 불가능합니다. 구인 시장에 그런 사람은 없습니다.
- 네, 이 모든 건 믿기 어렵게 복잡합니다.