본문 바로가기
School/GNPS

[GNPS] 챕터 1 네트워크 시스템 개요, 챕터 3 신뢰성 있는 TCP 데이터 스트림

by 세똔구리 SEDDONGURI 2025. 10. 3.
GNPS (Go Network Programming School)의 내용을 정리한 글입니다.
Go 언어를 활용한 네트워크 프로그래밍 - 책으로 스터디를 진행합니다.

귀여운 바보미가 없는 표지의 Gopher


챕터 1: 네트워크 시스템 개요                    

네트워크와 토폴로지          

컴퓨터 네트워크는 두 개 이상의 장치 또는 노드 간 연결을 말하며 각 노드가 데이터를 공유할 수 있도록 합니다.

여러 노드를 연결하여 네트워크 상의 노드를 구성하는 것을 토폴로지라고 합니다.

가장 단순한 형태는 점대점 (point-to-point) 연결로 두 노드가 하나의 연결을 공유하는 구조입니다.

점대점 연결

이런 점대점 연결을 연속해서 하는 것을 데이지 체인(daisy chain)이라고 합니다. 노드 A에서 노드 F로 가기 위해선 반드시 B와 C 노드를 지나야 하죠.

점대점 연결이 이어지는 데이지 체인

여기서 중간에 거치는 노드를 일반적으로 이라고 표현합니다.

공유 네트워크 링크를 가지는 형태의 버스형 토폴로지도 있습니다. 유선보다는 무선 네트워크에 많이 사용되며 자신에게 필요한 트래픽만을 선택적으로 받을 수 있게 되어있습니다.

버스형 토폴로지

일부 광섬유 네트워크 배포에 사용되는 링형 토폴로지는 데이터가 단방향으로 이동하는 폐쇄 루프입니다.

링형 토폴로지

스타형 토폴로지는 중앙의 노드를 통해 모든 노드를 점대점으로 연결합니다. 유선 네트워크의 기본적인 토폴로지 형태이며 일반적으로 중앙의 노드는 네트워크 스위치라는 장치입니다.

스타형 토폴로지

스타형 토폴로지에서는 다른 노드에 통신을 위해 단 하나의 홉만 이동합니다.

모든 노드는 다른 모든 노드와 직접 연결되는 그물형 토폴로지도 존재합니다.

그물형 토폴로지

앞서 본 스타형 토폴로지는 중앙의 노드가 단일 장애 지점이 될 수 있지만, 그물형 네트워크에서는 단일 노드의 장애가 다른 노드에 영향을 끼치지 않아 단일 장애 지점을 제거합니다. 다만, 이는 복잡성이 높아져 대규모 무선 네트워크에서만 볼 수 있는 특이한 토폴로지입니다.

일반적으로 현대의 네트워크 토폴로지는 둘 이상의 기본적인 토폴로지를 결합한 하이브리드 네트워크 토폴로지를 사용하고 있습니다.

버스와 링형이 결합된 하이브리드 토폴로지

하이브리드 토폴로지를 사용하면 각 토폴로지의 장점을 활용하고 단점을 개별 네트워크 단위로 제한하여 안정성을 높이고 확장성과 유연성을 향상할 수 있게 된다고 합니다.

 

대역폭과 레이턴시          

네트워크에서 대역폭과 레이턴시라는 용어는 자주 사용되는 용어입니다.

대역폭이라는 단어는 bandwidth 라고도 불리며 일정 시간 내에 네트워크 연결을 통해 전송할 수 있는 데이터의 양을 의미합니다. 이는 실제 처리한 데이터 양을 의미하는 게 아닌 이론상 처리할 수 있는 양으로 생각할 수 있습니다. 여기서 단순히 일정 시간 내 전송 가능한 데이터 양이 높다고 해서 네트워크의 속도가 빠른 것을 의미하는 건 아니죠.

레이턴시는 네트워크로 요청을 보내고 응답을 받는 사이에 측정된 시간을 의미합니다. 일반적으로 체감할 수 있는 것은 모바일 앱이나 웹사이트에 들어갔을 때 화면이 처음 뜨는 시간이나, 버튼을 클릭하고 로딩바가 돌면서 기다리다가 보이는 페이지를 생각할 수 있습니다. 레이턴시가 크면 사용자 경험이 나빠지는 여러 연구 결과도 있다고 하니 latency 가 사용자 경험에 있어 굉장히 중요한 요소라 할 수 있습니다.

대역폭과 레이턴시를 택시에 대입해보겠습니다. 요즘에는 4인승 택시 10인승 택시등 다양한 택시의 종류가 많습니다. 택시 한 대에 한 번에 태울 수 있는 승객의 수가 대역폭이고, 목적지까지 얼마나 빠르게 갔는지가 레이턴시입니다. 

레이턴시는 정말 다양한 이유로 발생합니다. 클라이언트와 서버 사이에서, 서버와 데이터베이스 사이에서, 서버에서 데이터를 가공하는 시간, 페이지를 렌더링 하는 데 걸리는 시간 등. 이런 시간들이 길어지면 사용자들은 서비스를 떠나게 될 것입니다. 그렇기 때문에 성능적인 부분인 레이턴시를 최소화하는 것은 ux에서 굉장히 중요하며 이는 비즈니스와도 직접적으로 연관을 가지는 중요한 요소로 작용합니다. 그렇기 때문에 엔지니어는 항상 레이턴시를 생각해야합니다.

 

OSI 참조 모델과 TCP/IP 모델          

https://www.routexp.com/2020/03/osi-model-vs-tcpip-model.html

 

1. OSI 참조 모델

1970년대 네트워크가 복잡해지면서 서로 다른 시스템 간의 통신을 표준화할 필요가 생겼습니다. 이에 따라 개방형 시스템 상호 연결(Open Systems Interconnection, OSI) 참조 모델이 개발되었습니다. OSI 참조 모델은 프로토콜 개발과 통신을 위한 프레임워크 역할을 하며, 여기서 프로토콜이란 네트워크를 통해 전송되는 데이터의 형식과 순서를 정의하는 규칙을 의미합니다.

여기서 '프로토콜 개발과 통신을 위한 프레임워크 역할을 한다'는 부분은 OSI 참조 모델이 네트워크 통신의 규격을 제공한다고 생각할 수 있습니다. 표준적인 절차를 제공하고 있으니 해당 규격에 맞는 다면 네트워크 통신이 가능한 것이죠. 규격 내에서는 여러 규칙 (프로토콜)을 다양하게 정의하고 사용할 수 있게 해 둔 것입니다.

OSI 참조 모델은 총 7 계층으로 구성되어 있으며, 물리 계층, 데이터 링크 계층, 네트워크 계층, 전송 계층, 세션 계층, 프레젠테이션 계층, 애플리케이션 계층으로 구분됩니다. 각 계층은 하위 계층의 복잡한 과정을 숨기고 필요한 기능만 추상화하여 상위 계층에 제공하며, 이 과정에서 계층 간 독립성을 유지합니다.

애플리케이션 계층에서 시작된 요청은 상위 계층에서 처리된 후 차례대로 하위 계층으로 전달됩니다. 전송 과정에서 각 계층은 데이터를 캡슐화하여 하위 계층으로 내려보냅니다. 캡슐화란 데이터를 전송할 때 필요한 추가 정보를 포함시키고, 상위 계층에서는 숨겨진 상태로 전달되는 방식을 말합니다. 이는 마치 편지를 보낼 때 편지 본문은 봉투 안에 넣고, 봉투 겉면에는 주소와 같은 전달 정보를 붙이는 것과 유사합니다.

전송 계층(OSI 4 계층)의 대표적인 프로토콜인 TCP는 두 노드 간 데이터 전송을 안정적으로 수행하기 위해 다양한 기능을 제공합니다. TCP는 데이터 전송 속도를 제어하고, 전송 중 누락된 데이터는 재전송하며, 수신 데이터를 확인하고 순서를 보장합니다. 이 과정에서 전송 단위는 세그먼트(segment)라고 하며, 상위 계층의 데이터를 페이로드(payload)로 포함합니다.

세그먼트는 하위 계층으로 전달되면서 네트워크 계층에서 패킷(packet)으로 캡슐화됩니다. 네트워크 계층은 출발지에서 목적지까지 데이터가 올바르게 전달되도록 라우팅을 담당하며, IP 주소 기반으로 데이터를 전달합니다. IP 계층에서 사용되는 대표적인 프로토콜로는 IPv4, IPv6, ICMP, BGP, IGMP, IPsec 등이 있습니다.

데이터는 다시 데이터 링크 계층에서 프레임(frame)으로 캡슐화됩니다. 이 계층에서는 프레임 헤더에 MAC 주소를 포함시켜, 네트워크 인터페이스를 통해 물리적 매체로 데이터를 전송합니다. ARP(Address Resolution Protocol)는 IP 주소를 MAC 주소로 변환하는 역할을 수행합니다.

마지막으로 물리 계층에서는 프레임이 비트 형태로 변환되어 전기적 신호, 광 신호, 또는 무선 신호 등으로 전송됩니다. 수신 측에서는 이 과정을 역순으로 수행하여, 물리 계층에서 받은 신호를 상위 계층으로 전달하고, 각 계층에서 추가된 헤더와 푸터를 제거하며 원래의 데이터를 복원합니다.

 

2. TCP/IP 모델

TCP/IP 모델은 OSI 7 계층 모델과 유사하지만, 계층 수를 4 계층으로 단순화한 모델입니다. TCP/IP 모델은 링크 계층, 인터넷(네트워크) 계층, 전송 계층, 애플리케이션 계층으로 구성됩니다. TCP/IP 모델에서 애플리케이션 계층은 소프트웨어 애플리케이션과 직접 상호작용하며, HTTP, FTP, SMTP, DHCP, DNS와 같은 프로토콜이 이 계층에서 동작합니다.

전송 계층은 OSI 4 계층과 동일하게 데이터 전송의 무결성과 순서를 보장합니다. TCP는 안정성을 중심으로 데이터 전송을 처리하며, UDP는 빠른 전송을 위해 신뢰성보다는 속도를 중시합니다.

인터넷 계층은 출발지와 목적지 노드 사이의 데이터 전달을 담당하며, 복잡한 네트워크 환경에서도 목적지까지 데이터를 전송할 수 있도록 보장합니다. 이 계층에서 사용되는 대표적인 프로토콜에는 IP, ICMP, BGP, IGMP, IPsec 등이 있습니다.

링크 계층은 물리적인 매체와 상위 계층 사이의 인터페이스 역할을 수행합니다. 이 계층은 데이터를 전송 가능한 프레임으로 변환하고, ARP를 통해 IP 주소를 MAC 주소로 변환하며, 프레임 헤더에 MAC 주소를 포함시켜 실제 네트워크로 전달합니다.

 


챕터 3: 신뢰성 있는 TCP 데이터 스트림                   

안정적인 tcp          

네트워크 통신에서는 다양한 문제들이 발생합니다. 그중 대표적인 것들은 다음과 같습니다.

패킷 손실

무선 네트워크 간섭이나 네트워크 정체등의 이유로 데이터가 전송에 실패해 목적지까지 도달하지 못한 상태입니다. 여기서 네트워크 정체는 네트워크 연결상 처리할 수 있는 양 이상의 데이터를 전송하려고 하는 경우 발생합니다. 

패킷 순서 불일치

전송 중 데이터가 라우팅 되어 수신되는 패킷의 순서가 뒤죽박죽 깨질 수 있습니다. 모든 패킷이 동일한 네트워크의 홉을 따라서 목적지에 도착하지 않기 때문에 순서가 보장되지 않을 수 있습니다.

 

전송 계층 프로토콜인 TCP를 사용하면 네트워크에서 발생하는 다양한 문제들을 해결해 주기 때문에 보내고 받는 데이터에만 집중할 수 있습니다. tcp는 흐름 제어 (flow control)을 통해 수신자가 처리할 수 있는 양만큼의 데이터를 보내 버퍼 오버플로우를 막아줍니다.

동시에 혼잡 제어(congestion control)를 통해 네트워크 상태에 맞춰 전송 속도를 조절하여 패킷 손실과 지연을 최소로 합니다. 또한 전송 중에 순서가 바뀐 패킷은 헤더의 sequence number 기반으로 다시 패킷을 정렬하여 애플리케이션에 올바른 순서로 전달합니다.

이와 같이 안정적인 패킷 전송 메커니즘을 통해서 신뢰성 있는 데이터 전송을 가능하게 합니다.

그렇다면 어떻게 TCP는 이처럼 안정적인 데이터 처리 메커니즘을 사용할 수 있는 것일까요?

 

1. TCP 세션

그 핵심에는 바로 TCP 세션이 있습니다.

TCP를 안정적이라고 표현하는 이유는 TCP 세션 덕분입니다. TCP 간 연결은 클라이언트와 서버의 three-way handshake라고 불리는 상호 연결 확인 과정을 통해서 이루어지게 됩니다.

세 단계를 거쳐 연결이 수립되기 때문에 three! 서로 요청하는 방향성이 있기 때문에 way! 그리고 상호 간의 요청 응답을 통해 연결 수립이 일어나기 때문에 handshake!

서버는 클라이언트에서 패킷을 수신받기 위해 연결 요청을 받기 위한 수신 대기 상태로 기다립니다.

첫 단계는 클라이언트는 SYN라는 플래그가 설정된 패킷을 서버로 전송합니다. 해당 패킷의 헤더에는 클라이언트의 추가적인 정보가 들어있습니다. 자세한 헤더 정보는 세그먼트 헤더 페이지를 참조하세요.

두 번째 단계는 서버는 ACK 플래그와 SYN 플래그가 설정된 패킷을 클라이언트로 전송합니다. ACK 플래그는 SYN 플래그가 있는 패킷을 정상 수신했다는 의미입니다.

세 번째 단계로 클라이언트는 서버의 SYN 패킷을 승인하는 ACK 플래그가 담긴 패킷을 보내 양방향 TCP 세션이 수립됩니다.

수립된 TCP 세션을 사용하여 두 노드는 데이터를 안정적으로 주고받을 수 있는 상태가 됩니다. 이때, 한쪽에서 데이터를 전송하지 않으면 TCP 세션이 Idle 상태로 남아있게 되어 불필요한 자원을 소모할 수 있습니다.

 

2. 시퀀스 번호 (Sequence Number)를 이용한 패킷 전송

3-way handshake를 진행하면서 서버와 클라이언트는 ISN이라는 초기 시퀀스 번호 (Initial Sequence Number)를 교환합니다. 클라이언트와 서버는 해당 시퀀스 번호를 기준으로 시퀀스 번호를 증가시키면서 데이터를 전송합니다.

3-way handshake를 통한 연결 이후 송신 측과 수신 측은 데이터를 전송하면서 ACK 패킷을 보냅니다. 이때 ACK 패킷의 시퀀스 번호는 해당 시퀀스 번호를 포함하는 번호까지 이전 모든 패킷을 수신했다는 의미입니다. 따라서 송신자는 ACK 패킷의 시퀀스 번호를 사용하여 재전송을 결정합니다. 1부터 100까지 시퀀스 번호를 가지는 패킷을 보냈는데 ACK 패킷에 90이 온다면, 송신자는 91부터 100까지 재전송을 해야 한다는 것을 알 수 있습니다.

 

3. 수신 버퍼, 슬라이드 윈도와 윈도 크기

앞서 얘기했듯이 TCP에서는 데이터 전송 시 수신 확인을 위한 ACK 패킷을 사용합니다. 클라이언트는 서버로부터 데이터를 받으면, 받은 데이터를 수신 버퍼에 저장하는 시점 혹은 일정 시간 지연 후 ACK 패킷을 보냅니다. 이는 클라이언트가 서버에게 데이터를 잘 받았다는 의미로 보내는 신호입니다. ACK 패킷에는 클라이언트의 중요한 정보중 하나인 수신 측의 사용 가능한 수신 버퍼의 크기도 함께 넘깁니다. 이때 사용되는 것이 슬라이딩 윈도 크기입니다.

수신 버퍼는 데이터를 읽어가기 전 임시 저장하는 공간으로 만약 해당 공간이 가득 차면 새로운 데이터를 받을 수 없습니다. 그렇기 때문에 TCP는 현재 수신 버퍼에 어느 정도의 여유가 있는지를 계산해서 ACK 패킷에 담아 보냅니다. 데이터를 송신하는 측은 전송 가능한 데이터의 양을 ACK 패킷으로 넘어온 윈도 크기 (Window Size)를 참고하여 결정하고, 수신 측의 버퍼가 넘치지 않도록 흐름 제어를 통해 데이터를 전송하게 됩니다.

예를 들어 클라이언트 A 가 서버 B와 연결된 상태에서 수신버퍼가 4KB라고 생각해 보겠습니다. 클라이언트 A는 ACK 패킷을 서버 B에 보낼 때 윈도 크기(Window Size)를 4KB로 설정해서 전송합니다. 서버는 이를 보고 클라이언트가 현재 가용할 수 있는 최대 수신 버퍼는 4KB라는 것을 알 수 있습니다. 그래서 서버는 4KB가 넘지 않도록 데이터를 전달합니다.

그리고 4KB의 데이터를 수신해 수신 버퍼에 데이터가 저장되고 ACK 패킷과 함께 윈도 크기 2KB를 함께 전송합니다. 이는 클라이언트가 서버로 ACK 패킷을 보내기 전 수신버퍼에서 2KB의 데이터를 읽었다는 것을 의미합니다. 이후 서버는 클라이언트에게 2KB 의 패킷을 전달하고, 클라이언트는 다시 서버에 윈도 크기와 함께 ACK 패킷을 전달합니다.

수신 버퍼, 슬라이딩 윈도, ACK 패킷등을 이용하여 TCP는 송신 측의 전송량을 조절을 통해 흐름 제어(Flow Control)를 수행합니다.

 

4. TCP 세션의 연결 종료

TCP는 연결도 고상한 방식으로 안정적으로 맺고, 아주 아름다운 방식의 절차를 통해서 세션을 종료합니다. 이 과정은 양쪽이 서로 더 이상 데이터를 주고받지 않는다는 것을 확인하고 안전하게 연결을 닫기 위해 필요합니다.

먼저 클라이언트의 FIN 패킷을 시작으로 4 way handshake 가 시작됩니다. FIN 은 "더 이상 데이터를 보내지 않겠다"는 신호입니다. FIN 패킷을 보내기 전 `ESTABLISHED` 상태였던 클라이언트의 연결상태는 `FIN_WAIT_1` 상태가 됩니다. 

서버는 클라이언트의 FIN을 받으면 ACK 패킷으로 응답합니다. 이때 서버는 기존 `ESTABLISHED` 상태에서 `CLOSE_WAIT` 상태가 되며, ACK 패킷을 수신한 클라이언트는 `FIN_WAIT_2` 상태가 됩니다.

그다음 서버의 연결 종료가 준비되면 클라이언트에게 "더 이상 데이터를 보내지 않아"라는 신호의 FIN을 보냅니다. 이때 `CLOSE_WAIT` 상태에서 `LAST_ACK` 상태로 들어가며 클라이언트의 ACK를 기다립니다.

마지막으로 클라이언트가 서버의 FIN을 받으면 ACK 패킷으로 응답합니다. FIN 패킷을 받은 클라이언트는 TIME_WAIT 상태가 되어 일정 시간 대기 후 연결을 완전히 종료한 상태인 CLOSED 상태가, 서버는 ACK 패킷을 받으면 CLOSED 상태가 됩니다.

 

Go 언어 표준 라이브러리를 이용한 TCP 연결 수립          

Go 언어는 표준 라이브러리인 net 패키지를 이용해 TCP 기반의 서버와 클라이언트를 만드는 기능을 제공합니다. 지금까지는 TCP의 특징적인 부분을 개념적으로 살펴봤다면 Go 언어로 작성된 코드를 통해서 개념이 어떻게 코드와 연결되는지 알아보도록 하겠습니다.

1. TCP 서버-클라이언트 상호작용

package tcpbasic

import (
	"io"
	"net"
	"testing"
)

func TestDial(t *testing.T) {

	// net.Listen 을 사용하여 루프백 아이피의 랜덤 포트를 통해 TCP 연결 포트 바인딩을 수행합니다.
	l, err := net.Listen("tcp", "127.0.0.1:")
	if err != nil {
		t.Fatal(err)
	}

	// 종료 처리를 위한 채널을 생성합니다.
	done := make(chan struct{})

	go func() {
		defer func() {
			done <- struct{}{}
		}()

		for {
			// 리스너를 통해 들어온 tcp 연결을 수락하여 새로운 tcp 세션을 만듭니다.
			conn, err := l.Accept()
			if err != nil {
				t.Log(err)
				return
			}

			t.Log("tcp connection connected")
			go func(c net.Conn) {
				defer func() {
					c.Close()
					done <- struct{}{}
				}()

				// 한번에 1024 바이트만큼 데이터를 수신하며, EOF 가 발생하기 전까지는 계속해서 데이터를 수신합니다.
				buf := make([]byte, 1024)
				for {
					n, err := c.Read(buf)
					if err != nil {
						if err != io.EOF {
							t.Error(err)
						}
						return
					}
					t.Logf("received: %q", buf[:n])
				}
			}(conn)
		}
	}()

	// Go routine 으로 실행되는 listener 의 동작과 별개로 메인 고루틴에서 Dial 요청을 보냅니다.
	conn, err := net.Dial("tcp", l.Addr().String())
	if err != nil {
		t.Fatal(err)
	}

	// 연결된 tcp connection 을 통해 hello 라는 문자열을 발신합니다.
	_, err = conn.Write([]byte("hello"))
	if err != nil {
		t.Error(err)
	}

	// 안정적인 연결 종료
	conn.Close() // connection close 로 tcp session 에 EOF 가 발생합니다.
	<-done       // 이때 고루틴으로 실행되는 listener 의 핸들러가 종료되며 defer 에서 done 채널로 struct 를 발송할 때 까지 블락됩니다.
	l.Close()    // 리스너를 close 하게 되면 line28 에 error 가 발생합니다.
	<-done       // 고루틴으로 실행되는 함수의 defer 에서 done 채널로 struct 발송할 때 까지 블락됩니다.
}

 

이 코드는 TCP 서버-클라이언트 상호작용을 테스트합니다.

고 루틴으로 TCP 리스너를 실행해 연결을 기다리는 서버를 만듭니다(Listener).  그 후, 메인 고 루틴에서 클라이언트가 서버에 접속하여 "hello" 메시지를 보내고 즉시 연결을 종료합니다 (Dial). 서버는 이 메시지를 수신하고, 클라이언트가 연결을 끊을 때 발생하는 EOF 신호를 감지하여 세션을 닫습니다. 마지막으로 `done` 채널을 이용해 서버와 핸들러 고 루틴이 모두 정상적으로 종료되는 것을 확인하며 테스트를 마칩니다.

 

2. Deadline을 사용한 timeout

package tcpbasic

import (
	"context"
	"net"
	"syscall"
	"testing"
	"time"
)

func TestContextDeadline(t *testing.T) {
	dl := time.Now().Add(5 * time.Second)
	ctx, cancel := context.WithDeadline(context.Background(), dl)
	defer cancel()

	// 커스텀 다이얼러를 생성합니다.
	var d net.Dialer
	// Control 함수는 다이얼을 진행하는 중에 호출툅니다.
	// 여기서는 데드라인(5초)보다 약간 긴 시간 동안 대기하여 타임아웃을 강제로 발생시킵니다.
	d.Control = func(network, address string, c syscall.RawConn) error {
		time.Sleep(5*time.Second + time.Millisecond)
		return nil
	}

	// 컨텍스트를 사용하여 연결을 시도합니다.
	// 데드라인이 지나면 이 함수는 에러를 반환합니다.
	conn, err := d.DialContext(ctx, "tcp", "10.0.0.0:80")
	if err == nil { // 에러가 없다면, 타임아웃이 발생하지 않은 것이므로 테스트 실패입니다.
		conn.Close()
		t.Fatal("connection did not timeout")
	}

	// 반환된 에러가 net.Error 타입인지 확인합니다. ner.Error 는 네트워크 연결에 대한 에러가 자세하게 포함되어 있습니다.
	nErr, ok := err.(net.Error)
	if !ok {
		t.Error(err)
	} else {
		if !nErr.Timeout() {
			t.Errorf("error is not a timeout: %v", err)
		}
	}

	// 컨텍스트의 에러가 DeadlineExceeded인지 확인합니다.
	if ctx.Err() != context.DeadlineExceeded {
		t.Errorf("expected deadline exceeded; actual: %v", ctx.Err())
	}
}

이 코드는 `context.WithDeadline` 을 사용하여 `net.Dialer` 의 연결 시도에 대한 타임아웃 기능을 테스트합니다.

5초의 데드라인을 가진 콘텍스트를 생성하고, `Dialer` 의 `Control` 함수에서 의도적으로 5초보다 길게 작업을 지연시켜 타임아웃 상황을 강제로 연출합니다. `DialContext`는 이 콘텍스트를 이용해 연결을 시도하는데, 설정된 데드라인이 지나면 연결 시도를 중단하고 에러를 반환하게 됩니다. 마지막으로, 테스트는 반환된 에러가 예상대로 네트워크 타임아웃 에러인지, 그리고 콘텍스트의 상태가 `DeadlineExceeded` 인지를 확인함으로써 타임아웃 기능의 정상 동작을 검증합니다.

 

3. Ping을 사용한 Deadline 연장

package tcpbasic

import (
	"context"
	"io"
	"net"
	"testing"
	"time"
)

func TestPingerDeadline(t *testing.T) {
	done := make(chan struct{})

	// "127.0.0.1:" 주소로 TCP 리스너를 생성합니다. 포트는 자동으로 선택됩니다.
	listener, err := net.Listen("tcp", "127.0.0.1:")
	if err != nil {
		t.Fatal(err)
	}

	begin := time.Now() // 테스트 시작 시간 기록

	// server 역할을 하는 함수를 고루틴에서 실행
	go func() {
		defer close(done) // 함수 종료 시 done 채널을 닫아 완료 신호를 보냅니다.

		conn, err := listener.Accept()
		if err != nil {
			t.Log(err)
			return
		}

		ctx, cancel := context.WithCancel(context.Background())
		defer func() {
			cancel() // Pinger 고루틴에 종료 신호를 보냅니다.
			conn.Close()
		}()

		// Pinger의 타이머를 리셋하기 위한 채널을 생성합니다.
		resetTimer := make(chan time.Duration, 1)
		resetTimer <- time.Second // 초기 ping 간격을 1초로 설정합니다.
		// Pinger를 별도의 고루틴으로 시작합니다.
		go Pinger(ctx, conn, resetTimer)

		// 연결에 대한 첫 데드라인을 5초로 설정합니다.
		// 5초 동안 아무런 Read/Write 활동이 없으면 연결은 타임아웃됩니다.
		err = conn.SetDeadline(time.Now().Add(5 * time.Second))
		if err != nil {
			t.Error(err)
			return
		}

		buf := make([]byte, 1024) // 데이터를 읽기 위한 버퍼
		for {
			n, err := conn.Read(buf)
			if err != nil {
				return
			}

			t.Logf("[%s] %s", time.Since(begin).Truncate(time.Second), buf[:n])

			// Pinger의 타이머를 리셋합니다.
			resetTimer <- 0
			// 데이터를 성공적으로 읽었으므로, 데드라인을 다시 5초 연장합니다.
			err = conn.SetDeadline(time.Now().Add(5 * time.Second))
			if err != nil {
				t.Error(err)
				return
			}
		}
	}()

	// 클라이언트 역할을 하는 코드
	// 서버 리스너 주소로 TCP 연결을 시도합니다.
	conn, err := net.Dial("tcp", listener.Addr().String())
	if err != nil {
		t.Fatal(err)
	}
	defer conn.Close()

	buf := make([]byte, 1024) // 서버로부터 데이터를 읽기 위한 버퍼

	// 서버로부터 4개의 "ping" 메시지를 읽습니다.
	for range 4 {
		n, err := conn.Read(buf)
		if err != nil {
			t.Fatal(err)
		}
		t.Logf("[%s] %s", time.Since(begin).Truncate(time.Second), buf[:n])
	}

	// 서버로 "PONG!!!" 메시지를 보냅니다.
	// 이 메시지를 받은 서버는 데드라인을 5초 연장합니다.
	_, err = conn.Write([]byte("PONG!!!"))
	if err != nil {
		t.Fatal(err)
	}

	// 서버가 데드라인 타임아웃으로 연결을 닫을 때까지 계속 읽기를 시도합니다.
	for range 4 {
		n, err := conn.Read(buf)
		if err != nil {
			// 서버가 연결을 닫았으므로 io.EOF 에러가 예상됩니다.
			if err != io.EOF {
				t.Fatal(err)
			}
			break // EOF를 받으면 루프를 빠져나옵니다.
		}
		t.Logf("[%s] %s", time.Since(begin).Truncate(time.Second), buf[:n])
	}

	<-done // 서버 고루틴이 종료될 때까지 대기합니다.
	end := time.Since(begin).Truncate(time.Second)
	t.Logf("[%s] done", end)

	if end != 9*time.Second {
		t.Fatalf("expected EOF at 9 seconds; actual %s", end)
	}
}

const defaultPingInterval = 30 * time.Second

// Pinger는 주어진 간격(interval)마다 "ping" 메시지를 io.Writer에 씁니다.
// reset 채널을 통해 간격을 동적으로 변경할 수 있습니다.
// 컨텍스트(ctx)가 취소되면 Pinger는 중지됩니다.
func Pinger(ctx context.Context, w io.Writer, reset <-chan time.Duration) {
	var interval time.Duration
	select {
	case <-ctx.Done():
		return
	case interval = <-reset: // reset 채널에서 초기 간격 값을 받아옵니다.
	default:
		// 채널에 값이 없으면 non-blocking으로 기본값을 설정하기 위해 default를 사용합니다.
	}

	if interval <= 0 {
		interval = defaultPingInterval // 간격이 0 이하면 기본값(30초)을 사용합니다.
	}

	timer := time.NewTimer(interval)
	defer func() {
		// Pinger가 종료될 때 타이머를 확실히 중지시켜 고루틴 릭을 방지합니다.
		if !timer.Stop() {
			// 타이머가 이미 만료된 경우, 채널에 남아있는 값을 비워줍니다.
			<-timer.C
		}
	}()

	// for-select 루프는 여러 채널 이벤트를 동시에 기다립니다.
	for {
		select {
		case <-ctx.Done():
			// 컨텍스트가 취소되면(예: 부모 고루틴이 종료될 때) Pinger를 종료합니다.
			return
		case newInterval := <-reset:
			// reset 채널에 새로운 간격 값이 들어오면 타이머를 재설정합니다.
			// 먼저 현재 타이머를 멈춥니다.
			if !timer.Stop() {
				// 만약 타이머가 이미 만료되어 Stop()이 false를 반환하면,
				// 만료된 타이머의 채널을 비워줍니다.
				<-timer.C
			}
			if newInterval > 0 {
				interval = newInterval // 0보다 큰 경우에만 간격을 업데이트합니다.
			}
		case <-timer.C:
			// 타이머가 만료되면 "ping" 메시지를 씁니다.
			if _, err := w.Write([]byte("ping")); err != nil {
				// 쓰기 작업에 실패하면(예: 연결이 끊긴 경우) Pinger를 종료합니다.
				return
			}
		}

		// 다음 이벤트를 위해 현재 간격(interval)으로 타이머를 리셋합니다.
		_ = timer.Reset(interval)
	}
}

이 코드는 연결 데드라인(deadline)과 주기적인 핑(ping)을 함께 사용하는 TCP 상호작용을 테스트합니다. 

서버는 `reset timer` 채널에 설정되는 인터벌마다 "ping"을 보내는 Pinger를 실행하며, 5초 동안 데이터를 받지 못하면 연결이 끊기는 데드라인을 설정합니다. 클라이언트는 4개의 "ping"을 받은 후 서버에 "PONG" 메시지를 보냅니다. 서버는 이 "PONG" 메시지를 수신하면 데드라인을 다시 5초로 초기화합니다. 클라이언트는 두 번째 for 루프에서 "ping" 메시지를 계속 수신합니다.