오늘은 정석적인 컴퓨터 네트워크와 관련된 개념 하나를 짚고 넘어가보도록 하려고 합니다. 바로 OSI 7계층 중 전송 계층(Transport Layer)에 속한 TCP와 UDP의 특징과 차이점입니다.
10분만에 읽으실 수 있을지 장담은 못 하겠지만… 바쁘신 분들은 이 사진만 봐도 됩니다
네트워크 수업을 너무 오래 전에 들어서, 기억이 가물가물한 분들이 계실 것 같군요. 최근에는 웬만하면 복잡한 통신 과정들을 운영 체제나 낮은 계층의 소프트웨어가 알아서 다 해주기 때문에, 응용 프로그램 개발자에게 있어서는 그렇게 잘 와닿지 않는 이야기로 느껴질 수도 있을 것 같습니다.
사실 TCP와 UDP를 비교한 글은 이미 많이 찾아볼 수 있습니다. 하지만 최근의 추세를 살펴보자면… 왠지 웹 개발자에게 네트워크 개념에 대한 중요성이 다시금 부각될 것 같다는 느낌이 들어 이번 포스트를 작성하게 됐습니다.
HTTP/3부터는 더 이상 TCP가 아니라 UDP 위에서 동작한다는 사실은 웬만한 개발자분들이라면 이미 잘 알고 있는 사실일 것입니다. 사용하는 프로토콜 자체가 바뀐만큼 꽤나 파격적인 변화인데요, 그런만큼 웹 개발을 하시는 분들이라면 두 프로토콜 사이의 차이점을 물어보는 것이 면접에서도 충분히 나올 만한 질문인 듯 합니다.
다만 네트워크에 깊게 들어가다보면 또 한도 끝도 없이 복잡해지기 때문에(…), 오늘의 포스트는 둘 사이의 개념을 간략하게 짚고 넘어가고자 하는데 의의를 두고자 합니다.
이번 포스트를 통해 TCP와 UDP의 특징과 차이점에 대해 짚고 넘어가고 싶은 분들께 도움이 되기를 바랍니다.
OSI 7계층 맛보기
본격적으로 TCP와 UDP를 비교하기 전에, 네트워크를 다루는만큼 OSI 7계층 이야기를 하지 않을 수가 없습니다. 사실 각 계층(Layer) 간의 경계가 최근 들어서 많이 희미해지긴 했지만, 그래도 우리가 어떤 계층을 건드리는지에 대해서는 알고 넘어갈 필요가 있기 때문에 이 부분 역시 알아보고 넘어가겠습니다.
1계층은 물리 계층(Physical Layer) 입니다. 이름처럼 소프트웨어보다는 하드웨어에 가까운 계층입니다. 전기 신호를 물리적으로 전송하는 기술에 대해 다루기 때문에, 전자공학이나 전기공학에 더 가깝습니다. 제가 잘 모르는 분야다보니 깊게 다루지는 않고, 그냥 이러한 계층이 제일 밑에서 동작하는 것 정도만 알아두면 될 듯 합니다.
2계층은 데이터 링크 계층(Data Link Layer) 입니다. 물리적으로 인접한 노드 간(node-to-node) 의 전송 기술을 다루는데, 우리가 잘 알고 있는 이더넷(Ethernet)이 대표적인 예입니다. 노드라고 하니 그래프 이론을 떠올려야만 할 것 같아서 거창해보이지만, 그냥 근거리 통신망(LAN)에서 연결된 다양한 컴퓨팅 장비들을 그렇게 부른다고 생각하면 됩니다.
이와 같은 통신망에서는 각 노드를 구별하기 위해 식별자가 필요한데, 이 때 MAC 주소를 쓰죠. MAC 주소는 공장에서 하드웨어를 생산할 때 각인처럼 박히기 때문에, 통상적으로 고유하고 수정할 수 없는 정보입니다. 1, 2계층까지는 대부분 하드웨어 영역에 속한다고 생각하면 됩니다.
3계층은 네트워크 레이어(Network Layer) 혹은 인터넷 레이어(Internet Layer) 라고 불립니다. 우리가 잘 알고 있는 IP 주소가 여기에 속하죠. 이 계층에서는 호스트 간(host-to-host) 의 전송 기술을 다루는데, 호스트 간 연결이라는 것은 2계층(데이터 링크 계층)을 통해 물리적으로 도착한 데이터를 식별해, 네트워크에 연결된 내 컴퓨터와 상대방 컴퓨터 사이를 고유하게 구별하고 연결하는 것을 의미합니다.
전송 계층(Transport Layer)
택배를 받으려면 아파트 이름뿐만 아니라 우리 집이 몇 호인지까지 알려줘야 합니다.
우리가 확인하고 싶은 TCP와 UDP는 4계층인 전송 계층(Transport Layer) 에서 동작하는 프로토콜입니다. 우리들의 주인공이 속하는 계층인만큼, 좀 더 자세히 다루어보고자 합니다.
4계층은 프로세스 간(process-to-process) 의 전송 기술을 다룹니다. 2계층에서는 노드, 3계층에서는 호스트(컴퓨터)를 구분한 것에 비해, 구별하는 범위가 상당히 좁아졌죠? 호스트보다 더 좁은 범위인 프로세스를 식별 가능하다는 것은, 내가 실행 중인 프로그램이 전 세계에서 유일하게 식별 가능하다 는 점에서 꽤 큰 의미를 지닙니다.
하지만 호스트 내에서도 다양한 프로세스가 동시에 실행 중일 수 있기 때문에, 프로세스를 고유하게 식별하기 위해서 포트(Port) 번호가 필요합니다. 이 중에서도 사전에 약속을 통해 특별하게 예약된 번호들(Well-known)이 있습니다. HTTP에 80번, HTTPS에 443번을 쓰는 것처럼요.
비유하자면 IP 주소는 아파트 주소와 같고, 포트 번호는 집 호수와 같습니다. 그리고 일반적으로 둘을 합쳐서 소켓 주소(Socket Address)라는 이름으로 부릅니다.
연결 지향 방식과 비연결 방식
이렇게 프로세스를 식별할 수 있는 주소를 이용하면 프로세스 간 통신이 가능합니다. 이 때 두 가지 방식을 이용해 통신이 가능한데, 연결 지향(Connection-Oriented) 방식과 비연결(Connectionless Protocol) 방식이 있습니다.
연결 지향 방식은 송신자와 수신자가 1:1로 연결 상태를 유지하면서 통신하는 것을 의미합니다. 이 방식은 데이터를 보내기 전에는 연결 설정(Connection Establish) 단계를, 보낸 후에는 연결 종료(Connection Close) 단계를 거쳐야 합니다. 데이터를 교환하는 데 있어 좀 더 안전하고 믿을 수 있는(reliable) 환경을 제공하지만, 교환 전후로 과정이 좀 더 복잡하죠. 이 방식의 대표적인 예가 바로 TCP입니다.
한편, 비연결 방식은 송신자와 수신자가 연결 상태를 유지하지 않고 통신하는 것을 의미합니다. 이게 무슨 말이냐? 데이터 송신자는 수신자의 수신 여부와 상관없이 일방적으로 데이터를 뿌리고 끝냅니다. 송신자 입장에선 간단한 방법이지만, 수신자의 입장에서는 데이터가 정확한지도 알 수 없고, 데이터의 순서도 보장할 수 없으며(unreliable), 심지어 못 받을 수도 있습니다. 이 방식의 대표적인 예가 바로 UDP입니다.
두 프로토콜이 지향하는 방식이 다르다는 것을 알 수 있었는데요, 지금부터는 본격적으로 각 프로토콜이 어떤 방식으로 데이터를 교환하는지에 대해 알아보겠습니다.
아래 설명에서 사용되는 데이터 단위 중 헷갈릴 수 있는 용어가 있어, 미리 짚고 넘어가고자 합니다.
- 세그먼트(Segment) : 데이터를 적절한 크기로 분할한 덩어리로, 4계층에서 만들어짐
- 패킷(Packet) : 세그먼트를 목적지로 전달하기 위해 IP 주소를 포함한 헤더가 붙은 형태, 3계층에서 만들어짐
- 프레임(Frame) : 패킷을 목적지로 전달하기 위해 MAC 주소를 포함한 헤더가 붙은 형태, 2계층에서 만들어짐
UDP
UDP는 사용자 데이터그램 프로토콜(User Datagram Protocol) 의 약자입니다. 데이터그램은 쉽게 말해 사용자가 전달하고자 하는 데이터를 네트워크로 전송하기 위해, 통신 정보 등을 담은 헤더를 씌운 독립적인 패킷(Packet) 이라고 생각하면 됩니다.
위의 헤더 사진을 보면 알 수 있듯이, 프로세스 간 통신을 위한 정보를 제외하고는 아무런 정보도 더하지 않죠. 왜 포트 번호만 붙어있는지 의아하게 생각하시는 분이 계실 수도 있는데, IP 주소는 4계층이 아닌 3계층에서 다루기 때문에 3계층에 도달해서야 IP 헤더가 붙게 됩니다. (이는 TCP에서도 마찬가지입니다.)
체크섬 정도의 오류 검출 기능은 있긴 하지만, 이것만으로는 복잡한 데이터를 검증하기가 대단히 어렵기 때문에, 통신에서 발생할 수 있는 예외 처리 기능들을 직접 구현해야 하는 어려움이 있습니다.
그리고 독립적이라는 말에서 유추할 수 있듯이, 데이터들이 독립적인 패킷으로 전송되는데다가 헤더 자체에는 순서와 관련된 정보가 없기 때문에 수신 측에서 알아서 순서를 재조립해야 합니다. 만약 중간에 패킷이 손실되어도, 프로토콜 자체적으로 손실을 알아낼 방법은… 없습니다.
이 모든 원인은 위에서 말했듯, UDP가 비연결 방식의 프로토콜이기 때문입니다. 이러한 UDP의 특징 때문에 실시간성이 중요하거나 데이터의 신뢰성이 굳이 보장되지 않아도 되는 곳에 사용합니다.
동작 과정
손님, 나가시는 문은 왼쪽, 들어오시는 문은 오른쪽에 계십니다.
헤더만큼이나 동작 과정 역시 꽤나 간단합니다. 아까 프로세스를 식별하기 위해 포트 번호를 쓴다고 한 것, 기억 나시나요? 각 프로세스가 시작되면 운영 체제로부터 포트 넘버를 부여받게 됩니다.
이때 UDP는 해당 프로세스를 식별할 수 있는 포트와 통신하기 위해 버퍼 역할을 하는 두 개의 큐(Queue) 를 만들고 연결하게 됩니다. 큐는 송신용 목적인 송신 큐(Outgoing Queue), 수신용 목적인 수신 큐(Incoming Queue) 로 구분됩니다.
프로세스에서 만약 데이터를 외부로 전송하기 위해 송신 큐에 데이터를 밀어넣으면, UDP는 프로세스에서 보낸 데이터를 하나씩 읽고 헤더를 붙여서 3계층(IP)으로 넘겨준 후 송신 큐에서 제거합니다.
그렇다고 해서 프로세스가 무작정 송신 큐에 막대한 데이터를 집어넣다간 자칫 오버플로가 발생할 수 있습니다. 그래서 보통 운영 체제에서는 송신 큐에 오버플로가 발생하지 않게 따로 관리를 해줍니다. 가령, 송신 큐가 가득 찼을 때에는 각 프로세스들에 “송신 큐가 가득 찼으니 잠깐만 기다려줘!” 라고 이야기를 하는 것처럼 말이죠.
수신 큐 역시 비슷하게 동작합니다. 3계층(IP)에서 수신된 패킷이 있다면, 이를 분석하여 통신하고자 하는 프로세스의 포트 번호와 연결을 시도합니다. 만약 현재 컴퓨터에 해당 포트 번호를 가진 프로세스가 있다면 패킷에서 통신 과정에 필요했던 헤더 정보를 벗겨낸 후, 이를 프로세스로 보내게 되죠.
TCP
TCP 헤더는 신뢰성을 유지하기 위해 헤더가 상당히 무겁다.
한편 TCP는 전송 제어 프로토콜(Transmission Control Protocol) 의 약자로, 연결 지향 방식임과 동시에 스트림 기반(Stream-Oriented) 방식으로도 불립니다. 이는 TCP 통신이 마치 가상의 연결선을 이용해 두 컴퓨터가 연속적으로 데이터 교환이 가능하게 하는 것처럼 보이기 때문입니다.
헤더의 사진에서 볼 수 있듯이, UDP에 비해 담긴 데이터의 양이 상당히 무거운 편에 속하는 것을 알 수 있습니다. 좀 더 자세히 살펴보면 헤더에는 목적지 정보를 포함해 시퀀스 넘버, 승인 넘버(Acknowledgement Number), 제어 필드(Control Fields) 등 많은 데이터들이 담겨 있죠.
헤더의 모든 영역들에 대해 설명하지는 않고, TCP의 핵심적인 특징을 담당하는 부분만 설명해보고자 합니다. 그럼 TCP의 핵심적인 3가지 특징에 대해 알아보도록 하죠.
핸드셰이크
TCP는 연결 지향 방식 이기 때문에 데이터 교환 전후로 연결 설정/해제 과정을 거치는데 이를 핸드셰이크(Handshake) 과정이라고 부릅니다. 이 부분은 조금 복잡한데, 여기서는 굳이 복잡하게 외울 필요 없이 슥 읽고 넘어가면 됩니다.
연결 설정 단계에서는 3단계로 핸드셰이크 과정을 진행합니다. 이 단계에서는 연결 요청을 나타내는 SYN
플래그를 클라이언트와 서버가 교환하는 것에 의의를 둡니다. 그리고 이 과정에서 데이터를 잘 받았다는 사실을 알리기 위해 ACK 넘버를 함께 전송하는데, 이 때는 각자 상대방으로부터 받은 시퀀스 번호에 1을 더해 보내게 됩니다.
- 클라이언트에서 2개의 데이터를 전송
- 시퀀스 넘버: 클라이언트에서 랜덤으로 생성한 시퀀스 넘버
x
SYN
플래그: 연결 설정 요청 플래그- 서버에서는 4개의 데이터를 전송
- 시퀀스 넘버: 서버에서 랜덤으로 생성한 시퀀스 넘버
y
- ACK 넘버: 클라이언트로부터 받은 시퀀스 넘버에 1을 더한 값, 즉
x + 1
SYN
플래그: 서버에서 클라이언트로 연결 설정 요청을 나타내는 플래그ACK
플래그: 상대방이 이전에 보낸 데이터를 제대로 수신했음을 나타내는 플래그- 클라이언트에서 다시 3개의 데이터를 전송
- 시퀀스 넘버: 처음 보냈던 시퀀스 넘버
x
- ACK 넘버: 서버에서 보낸 시퀀스 넘버에 1을 더한 값, 즉
y + 1
ACK
플래그: 상대방이 이전에 보낸 데이터를 제대로 수신했음을 나타내는 플래그
이렇게 연결이 설정되면 데이터 교환 과정을 거치게 됩니다. 하지만 이 단계는 핸드셰이크처럼 엄밀하게 순서가 정해져 있지 않습니다. 대신 각자 내부적으로 타이머를 이용해 배치(Batch) 작업으로 송신과 수신 상태가 담긴 패킷을 보냅니다.
이 과정에서도 클라이언트와 서버는 데이터를 잘 수신했다는 ACK 넘버를 상대방에게 보내는데, 데이터 교환 과정에서는 상대방에게서 마지막으로 받은 시퀀스 넘버에 수신한 데이터(세그먼트)의 바이트 수 + 1만큼을 더한 값을 보내게 됩니다. TCP는 이 값을 이용해 통신 과정 중 어떤 부분에서 손실이 났는지를 파악하죠.
- 클라이언트에서 서버로 데이터를 전송
- 시퀀스 넘버: 세그먼트의 바이트 길이만큼 다음 시퀀스 넘버를 증가시킴
ACK
플래그: 상대방이 이전에 보낸 데이터를 제대로 수신했음을 나타내는 플래그- 서버에서 클라이언트로 데이터를 전송
- 시퀀스 넘버: 세그먼트의 바이트 개수만큼 다음 시퀀스 넘버를 증가시킴
- ACK 넘버: 상대방으로 처음 받은 시퀀스 넘버에 현재까지 수신한 바이트 길이를 더한 값 + 1
ACK
플래그: 상대방이 이전에 보낸 데이터를 제대로 수신했음을 나타내는 플래그
연결 종료 단계는 4단계의 핸드셰이크 과정을 거칩니다. 일방적으로 연결을 종료하게 되면, 상대방은 연결이 끊어졌는지 지속되고 있는지를 알 방법이 없기 때문에 종료 과정은 반드시 필요합니다.
종료 단계에서는 FIN
플래그를 교환하는 것에 의의를 두며, 연결 설정 단계처럼 상대방에게서 받은 시퀀스 번호에 1을 더해 ACK 넘버를 전송합니다.
- 클라이언트에서 서버로 4개의 데이터를 전송
- 시퀀스 넘버, ACK 넘버,
ACK
플래그FIN
플래그: 종료 의사를 표현하는 플래그- 서버에서 클라이언트의 종료 의사를 수신
- 시퀀스 넘버, ACK 넘버,
ACK
플래그- 서버에서 데이터를 모두 보낸 경우, 4개의 데이터를 전송
- 시퀀스 넘버, ACK 넘버,
ACK
플래그FIN
플래그: 종료 의사를 표현하는 플래그- 클라이언트에서 서버의 종료 의사를 수신
- 시퀀스 넘버, ACK 넘버,
ACK
플래그
위의 플로우 차트를 보면 알 수 있지만, 내가 보내고자 하는 패킷에 ACK와 같은 수신 상태 정보를 함께 실어서 보내는 것을 확인할 수 있습니다. 이를 피기배킹(Piggybacking)이라고 부르는데, 내가 보낼 정보 뒤에 꼬리표를 붙이듯이 수신 정보를 함께 다는 것을 의미합니다.
신뢰성과 흐름 제어
내가 받은 이 데이터… 믿을 수 있는 건가?
TCP의 중요한 특징이 바로 신뢰성과 흐름 제어 기능입니다. 이는 TCP의 최초 개발 동기가 군사적인 목적이었기 때문입니다.
전쟁 상황에서는 적의 공격으로 인해 회선이 물리적으로 파괴될 수도 있는데, 이 상황에서도 안정적인 교환 환경을 유지해야 합니다. 또한 사람의 목숨이 달린만큼, 중간에 손실되거나 왜곡되는 데이터가 없어야 합니다. 이 외에도 천재지변의 발생 등으로 인해 네트워크 손실이 발생할 수도 있기 때문에, 언제 어떤 상황에서 오류가 발생할지를 쉽게 추측할 수가 없었습니다.
그래서 TCP는 데이터가 중간에 소실되거나 회선 교환에 실패했다 하더라도, 이를 알아차리고 복구하기 위해 다양한 해결책을 제시하는 것이 특징입니다. TCP는 데이터를 교환함과 동시에 헤더에 기록된 정보를 이용해 데이터의 신뢰성 제어와 흐름 제어를 동시에 진행합니다.
중간 손실이 나서 ACK가 중복된다면, 빠른 재전송 방법을 취할 수 있다.
우선 신뢰성 관련 부분입니다. 위의 핸드셰이크 과정에서도 이야기했지만, 만약 내가 보낸 데이터에 대한 ACK
가 오지 않았다면 이는 중간에 패킷이 손실된 경우로 간주할 수 있습니다.
이처럼 중간에 손실이 발생하면 상대방으로부터 이미 받은 ACK
를 중복해서 받게 됩니다. TCP는 이를 이용해 손실이 난 부분을 재전송하거나, 손실이 발생한 부분부터 다시 보내는 방식을 취합니다.
흐름 제어에는 TCP의 송신/수신용 버퍼로 사용되는 큐(Queue)를 이용합니다. 이 큐의 사이즈를 윈도우 사이즈(Window Size)라고 하고, 슬라이딩 윈도우(Sliding Window) 기법으로 관리합니다.
수신 측에서는 자신이 현재 수신 버퍼에서 처리할 수 있는 용량을 헤더에 실어서 보내주는데, 송신 측은 패킷이 도착하는데 걸린 시간과 수신 측의 윈도우 사이즈를 고려해서 오버플로가 발생하지 않게 데이터의 양을 적당히 조절하곤 합니다.
혼잡도 제어
리소스는 한정되어 있는데, 이를 여러 프로세스에 공평하게 잘 나눠주는 방법이 있을까?
TCP의 또 다른 특징은 혼잡도 제어(Congestion Control) 입니다. 초기의 TCP는 단순히 데이터를 정확히 그리고 잘 전달하는 것이 목적이었기 때문에, 여러 사용자가 몰리는 혼잡도 설계가 완전하지 않았습니다. 하지만 TCP로 통신을 하다보니 가령 특정 순간에 너무 많은 네트워크 요청이 몰리거나, 여러 사용자가 한 네트워크를 동시에 쓰는 경우가 발생했죠.
만약 A라는 프로세스가 통신을 진행 중인데, 다른 프로세스가 또 통신을 시도하려고 한다면 어떻게 해야 할까요? 하나의 TCP에서 처리하고 수용할 수 있는 리소스의 양은 정해져있으니, 아마 A가 쓰고 있는 리소스를 절반 정도 떼어서 B가 쓸 수 있게 주어야 할 것입니다. 이것이 어떻게 가능할까요?
이는 TCP가 각 네트워크에 할당되는 리소스를 늘려나가는데, 이 과정에서 오류가 발생하면 리소스를 초기화하는 과정을 거치게 됨으로써 가능합니다.
이걸 말로만 설명하면 어려우니 예시를 들어볼게요. 내가 상대방에게 2만큼의 데이터를 보냈는데 잘 받았다는 응답이 오면? 그러면 4만큼을 보내봅니다. 4도 잘 받았다? 그럼 8만큼 보내봅니다. 하지만 8은 받지 못했다면? 다시 보내는 양을 1로 줄여서 보내봅니다. 그 사이에 다른 프로세스가 4만큼의 리소스를 차지했고, 통신에 성공했습니다.
이처럼 혼잡함을 회피하기 위해 여러가지 알고리즘이 있는데, 이를 일컫어 TCP 혼잡 방지 알고리즘 이라고 합니다. 다만 이것을 모두 설명하기에는 너무 복잡하니, 간단한 예시 두 가지만 소개해보겠습니다.
우선 위의 예시에서 소개했던 슬로우 스타트(Slow Start) 방식입니다. 데이터 전송에 성공하게 되면, 다음 시도는 리소스(CWND, 혼잡 윈도우)를 지수(Exponential) 스케일만큼 더 많이 사용하는 통신을 시도하게 됩니다. 이 때, 실패하게 되면 1부터 다시 시작합니다.
합 증가/곱 감소(AIMD, Additive Increase/Multiplicative Decrease) 방식은 이름에서 볼 수 있듯이 증가할 때는 합으로, 감소할 때는 절반으로 감소하는 방식입니다.
이러한 방식으로 혼잡도를 제어하게 되면 한정된 네트워크 자원을 여러 프로세스에게 균등하게 나눠줄 수 있다는 장점이 있지만, 통신 실패가 지속적으로 발생하기 때문에 그래프가 톱니바퀴처럼 울퉁불퉁하게 생겼죠.
그러다보니 실제로는 속도를 최대한 균등하게 유지하기 위해 앞서 말한 규칙들을 적당히 혼합해서 쓰게 됩니다. 대표적인 방법이 바로 타호(Tahoe) 와 리노(Reno) 방식이 있죠. 하지만 이러한 혼잡도 제어에도 불구하고, 통신 실패를 막거나 속도를 균일하게 유지할 수는 없기 때문에 TCP는 태생적으로 속도의 한계를 가지고 있다고 볼 수 있습니다.
마무리
혹시라도 HTTP/3가 왜 UDP를 선택했는지에 대해 궁금하신 분들께는 이 포스트를 읽어보시는 것을 추천드립니다.
혹시라도 잘못된 부분이 있다면 피드백 부탁드립니다!