본문 바로가기
School/GNPS

[GNPS] 챕터 4 TCP 데이터 전송하기

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


핵심 인터페이스: net.Conn

Go 네트워크 프로그래밍의 중심에는 net.Conn 인터페이스가 있습니다. listener.Accept()net.Dial()을 통해 반환되는 연결 객체는 모두 이 인터페이스를 구현합니다.

그중 Read와 Write 메서드는 가장 유용하게 사용할 수 있습니다. 해당 메서드는 io.Reader, io.Writer 인터페이스를 구현한 메서드로 io 인터페이스를 사용하는 코드를 활용하여 네트워크 프로그래밍을 할 수 있습니다.

이로 인해 io 패키지에서 제공하는 다양한 유틸리티(예: io.Copy, bufio.Scanner)를 네트워크 코드에 직접 활용할 수 있습니다.

  • Close(): 네트워크 연결을 종료합니다. 정상 종료 시 nil, 그 외에는 error를 반환합니다.
  • SetDeadline(t time.Time): Read 또는 Write 작업에 대한 데드라인을 설정합니다. 특정 시간이 지나면 해당 I/O 작업은 error를 반환하며 타임아웃 처리됩니다.

데이터 송수신 프로토콜 설계

TCP는 데이터의 경계를 구분하지 않는 연속적인 바이트 스트림(Stream)입니다. 즉, 송신 측에서 Write를 여러 번 호출하더라도 수신 측에서는 이를 논리적인 메시지 단위로 구분할 수 없습니다. 따라서 애플리케이션 레벨에서 메시지의 시작과 끝을 구분하는 프로토콜이 반드시 필요합니다.

고정 버퍼를 사용한 읽기

io.Reader 인터페이스를 구현한 TCP 연결은 네트워크 연결로부터 데이터를 읽을 수 있습니다. 이때 데이터를 읽기 위해선 네트워크 연결의 Read 메서드에 버퍼를 매개변수로 제공해야 합니다.

package main

import (
	"crypto/rand"
	"io"
	"net"
	"testing"
)

func TestReadIntoBuffer(t *testing.T) {
	payload := make([]byte, 1<<24)  // 2^24 = 16MB
	_, err := rand.Read(payload)

	if err != nil {
		t.Fatal(err)
	}

	listener, err := net.Listen("tcp", "127.0.0.1:")
	if err != nil {
		t.Fatal(err)
	}

	go func() {
		conn, err := listener.Accept()  // server accept

		if err != nil {
			t.Log(err)
			return
		}
		defer conn.Close()

		_, err = conn.Write(payload)
		if err != nil {
			t.Error(err)
		}
	}()

	conn, err := net.Dial("tcp", listener.Addr().String())  // client dial
	if err != nil {
		t.Fatal(err)
	}

	buf := make([]byte, 1<<19)  // 16KB

	for {
		n, err := conn.Read(buf)  // read from server
		if err != nil {
			if err != io.EOF {
				t.Error(err)
			}
			break
		}
		t.Logf("read %d bytes", n)
	}
	conn.Close()
}

 

16MB의 랜덤 페이로드를 서버에서 클라이언트로 작성합니다. 클라이언트의 512KB 버퍼에서 읽을 수 있는 양보다 많아 for 루프에서 반복적으로 읽어 들입니다. 클라이언트는 EOF 혹은 에러가 반환될 때까지 데이터를 읽고 연결을 종료합니다.

이때 유의할 점은 conn.Read(buf)가 버퍼 buf가 가득 찰 때까지 블로킹(Blocking)되지 않는다는 것입니다. Read 메서드는 OS의 TCP 수신 버퍼에 데이터가 도착하는 즉시, buf의 크기를 한도로 하여 읽을 수 있는 만큼의 데이터를 반환합니다. Read는 OS의 TCP 수신 버퍼에 데이터가 도착하는 즉시, buf의 크기를 한도로 하여 읽을 수 있는 만큼의 데이터만 반환합니다. 16MB의 데이터를 전송해도, 512KB 버퍼로 읽을 때 n은 512KB가 아닌 64KB, 1500바이트 등 다양한 값일 수 있습니다.

 

bufio.Scanner와 구분자(Delimiter) 활용

TCP는 데이터를 연속적인 바이트 흐름(stream)으로 취급합니다. 이는 송신 측 애플리케이션에서 데이터를 여러 번 나누어 보내더라도, 수신 측에서는 그저 하나의 끊이지 않는 데이터 흐름으로 보인다는 의미입니다.

이러한 TCP의 특성 때문에, 수신 측에서는 '메시지의 논리적인 시작과 끝', 즉 메시지 경계(Message Boundary)를 명확히 구분할 수 있어야 합니다. 이 문제를 해결하는 대표적인 두 가지 방법이 있습니다.

  1. 데이터 길이 명시 (Length Prefixing)
    • 가장 직관적인 방법입니다. 실제 보낼 데이터(Payload) 앞에, "다음에 올 데이터는 50바이트"와 같이, 고정된 길이의 헤더에 데이터 크기를 명시해서 함께 전송합니다.
    • 수신 측은 이 헤더를 먼저 읽고, 명시된 크기만큼의 버퍼를 준비해 정확히 하나의 완전한 메시지를 읽어낼 수 있습니다.
  2. 구분자 사용 (Delimiter)
    • 메시지의 끝을 알리는 특별한 문자(열)(예: 줄 바꿈 문자 \n)을 약속하고, 메시지 끝마다 이를 붙여서 보내는 방식입니다.

 

bufio.Scanner가 필요한 이유

'데이터 길이 명시' 방식은 비교적 간단하지만, '구분자' 방식은 수신 측에서 구현하기가 조금 더 까다롭습니다. 고정된 크기의 버퍼로 데이터를 읽을 때, 다음과 같은 여러 엣지 케이스가 발생하기 때문입니다.

  • 버퍼 안에 아직 구분자가 도착하지 않았을 경우
  • 버퍼 안에 정확히 하나의 구분자가 포함된 경우
  • 버퍼 안에 여러 개의 구분자가 한꺼번에 포함된 경우

이처럼 복잡하고 다양한 '구분자 기반 데이터 읽기' 문제를 아주 간편하게 해결해주는 것이 바로 Go의 표준 라이브러리 bufio.Scanner입니다.

bufio.Scanner는 io.Reader 인터페이스를 매개변수로 받습니다. 그리고 Go에서 네트워크 연결을 나타내는 net.Conn 타입이 이 io.Reader 인터페이스를 구현하고 있습니다.

package main

import (
	"bufio"
	"net"
	"reflect"
	"testing"
)

const payload = "The bigger the interface, the weaker the abstraction."

func TestScanner(t *testing.T) {
	listener, err := net.Listen("tcp", "127.0.0.1:")
	if err != nil {
		t.Fatal(err)
	}

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

		defer conn.Close()

		_, err = conn.Write([]byte(payload))
		if err != nil {
			t.Error(err)
		}
	}()

	conn, err := net.Dial("tcp", listener.Addr().String())
	if err != nil {
		t.Fatal(err)
	}
	defer conn.Close()

	// io.Reader 인터페이스를 받는 Scanner 구조체 생성
	// default seperator : \n
	sc := bufio.NewScanner(conn)
	sc.Split(bufio.ScanWords) // Scanwords space sepereated word

	var words []string
	for sc.Scan() {
		words = append(words, sc.Text())
	}

	// sc.Scan() 이 false 를 반환하며 종료된 후
	// io.EOF 가 아닌 다른 에러 발생한 경우
	// sc.Err() 함수에서 Scan 동안 발생한 에러를 리턴
	err = sc.Err()

	if err != nil {
		t.Error(err)
	}

	expected := []string{"The", "bigger", "the", "interface,", "the", "weaker", "the", "abstraction."}

	if !reflect.DeepEqual(words, expected) {
		t.Fatal("inaccurate scanned word list")
	}

	t.Logf("Scanned words: %#v", words)
}

 

for 루프에서 scanner.Scan()이 호출되면, 스캐너는 내부적으로 네트워크 연결(conn)로부터 설정된 구분자를 찾을 때까지 필요한 만큼 반복해서 데이터를 읽습니다(Read()). 이 과정은 블로킹(blocking) 방식으로 동작합니다.

구분자를 성공적으로 찾으면, Scan() 메서드는 true를 반환합니다. 이때 scanner.Text() 또는 scanner.Bytes() 메서드를 호출하여 구분자를 제외한 실제 데이터 청크를 문자열이나 바이트 슬라이스로 가져올 수 있습니다.

루프가 끝난 후에는 scanner.Err() 메서드를 호출하여 에러 발생 여부를 반드시 확인해야 합니다. 스트림의 끝(io.EOF)에 도달한 정상적인 종료라면 nil을 반환하고, 그 외의 읽기 에러가 발생했다면 해당 에러를 반환합니다.

동적 버퍼 할당과 TLV 프로토콜

 

구분자 방식은 편리하지만, 만약 데이터 자체에 구분자로 사용될 문자가 포함될 수 있다면 어떻게 해야 할까요? 더 정교하고 구조화된 데이터 전송이 필요할 때, 동적 버퍼 할당 방식이 좋은 대안이 될 수 있습니다.

이러한 방식의 대표적인 인코딩 체계가 바로 TLV(Type-Length-Value)입니다. TLV는 이름 그대로 세 가지 요소의 조합으로 데이터를 표현합니다.

  • Type (T): 어떤 종류의 데이터인지 나타내는 고정된 길이의 코드입니다. (예: 1은 사용자 이름, 2는 메시지 내용)
  • Length (L): 뒤따라올 값(Value)의 길이를 나타냅니다. 이 정보를 바탕으로 수신 측은 정확한 크기의 버퍼를 할당할 수 있습니다.
  • Value (V): 실제 전송하고자 하는 가변 길이의 데이터(Payload)입니다.

이처럼 TLV는 Type과 Length라는 명확한 헤더 정보를 통해 데이터의 경계를 확실히 정의합니다. 이를 통해 바이너리 데이터를 안전하게 전송할 수 있으며, 필요한 만큼만 정확하게 메모리를 할당하고 읽을 수 있어 낭비 없이 효율적인 데이터 처리가 가능해집니다.

package main

import (
	"errors"
	"fmt"
	"io"
)

const (
	BinaryType uint8 = iota + 1
	StringType

	MaxPayloadSize uint32 = 10 << 20
)

var ErrMaxPayloadSize = errors.New("maximum payload size exceeded")

type Payload interface {
	fmt.Stringer
	io.ReaderFrom
	io.WriterTo
	Bytes() []byte
}

String과 Binary 타입으로 메시지 타입을 나타내는 상수를 정의합니다. 타입별로 구현해야 할 Payload interface를 정의하며, 각 String, Binary 타입별로 인터페이스에 정의된 Bytes, String, ReadFrom, WriteTo 메서드를 구현합니다.

type Binary []byte

func (b Binary) Bytes() []byte  { return b }
func (b Binary) String() string { return string(b) }

func (b Binary) WriteTo(w io.Writer) (int64, error) {
	err := binary.Write(w, binary.BigEndian, BinaryType)
	if err != nil {
		return 0, err
	}
	var n int64 = 1

	err = binary.Write(w, binary.BigEndian, uint32(len(b)))
	if err != nil {
		return n, err
	}

	n += 4
	o, err := w.Write(b)
	return n + int64(o), err
}

func (b *Binary) ReadFrom(r io.Reader) (int64, error) {
	var typ uint8
	err := binary.Read(r, binary.BigEndian, &typ)
	if err != nil {
		return 0, err
	}

	var n int64 = 1
	if typ != BinaryType {
		return n, errors.New("invalid Binary")
	}

	var size uint32
	err = binary.Read(r, binary.BigEndian, &size)
	if err != nil {
		return n, err
	}

	n += 4
	if size > MaxPayloadSize {
		return n, ErrMaxPayloadSize
	}

	*b = make([]byte, size)
	o, err := r.Read(*b)

	return n + int64(o), err

}

type String string

func (s String) Bytes() []byte  { return []byte(s) }
func (s String) String() string { return string(s) }

func (s String) WriteTo(w io.Writer) (int64, error) {
	err := binary.Write(w, binary.BigEndian, StringType)
	if err != nil {
		return 0, err
	}
	var n int64 = 1
	err = binary.Write(w, binary.BigEndian, uint32(len(s)))

	if err != nil {
		return n, err
	}

	n += 4
	o, err := w.Write([]byte(s))
	return n + int64(o), err
}

func (s *String) ReadFrom(r io.Reader) (int64, error) {
	var typ uint8
	err := binary.Read(r, binary.BigEndian, &typ)
	if err != nil {
		return 0, err
	}

	var n int64 = 1
	if typ != StringType {
		return n, errors.New("invalid String")
	}

	var size uint32
	err = binary.Read(r, binary.BigEndian, &size)
	if err != nil {
		return n, err
	}

	n += 4

	buf := make([]byte, size)
	o, err := r.Read(buf)
	if err != nil {
		return n, err
	}
	*s = String(buf)
	return n + int64(o), nil
}

 

Binary 타입은 byte 타입의 슬라이스이며, Bytes 메서드는 자신을 반환하고 String 메서드는 자신을 string으로 캐스팅하여 반환합니다.

WriteTo 메서드는 데이터를 쓸 때, 가장 먼저 1바이트를 작성하여 타입을 명시합니다. 그 후, Binary 인스턴스의 길이(uint32)를 4바이트 크기에 맞춰 writer에 작성하고, 마지막으로 전체 페이로드를 writer에 씁니다.

반대로 ReadFrom 메서드는 reader로부터 1바이트를 읽어들여 type을 확인한 뒤, 이어서 4바이트(uint32)를 읽어 데이터 사이즈를 확인합니다. 마지막으로 이 크기만큼 Binary 인스턴스의 바이너리 슬라이스를 읽어 들입니다. 이 과정에서 최대 페이로드 크기에 제한을 두어, 너무 큰 용량의 Read 요청으로 인한 메모리 소비를 방지하고 악의적인 사용자로부터 시스템을 보호하며 합리적인 크기만 관리합니다.

String 타입 또한 이와 비슷한 구현을 가집니다. WriteTo 메서드는 1바이트의 String Type 값을 writer를 통해 쓰며, 문자열을 바이트 배열로 형변환하여 전송한다는 점을 제외하고는 Binary 타입의 WriteTo와 동일합니다. ReadFrom 역시 String type을 먼저 검증하고, byte 배열(buf)을 통해 데이터를 읽어와 string으로 변환합니다.

이러한 두 타입 중 하나를 사용해서 데이터를 읽어오는 함수로 decode 메서드가 있습니다.

func decode(r io.Reader) (Payload, error) {
	var typ uint8
	err := binary.Read(r, binary.BigEndian, &typ)
	if err != nil {
		return nil, err
	}

	var payload Payload
	switch typ {
	case BinaryType:
		payload = new(Binary)
	case StringType:
		payload = new(String)
	default:
		return nil, errors.New("unknown type")
	}

	_, err = payload.ReadFrom(io.MultiReader(bytes.NewReader([]byte{typ}), r))
	if err != nil {
		return nil, err
	}
	return payload, nil
}

decode 메서드는 io.Reader를 매개변수로 받아, Binary 타입과 String 타입을 확인하고 ReadFrom 메서드를 이용해 reader에서 읽은 바이너리 데이터를 payload 변수로 디코딩합니다.

하지만 decode 메서드 내부에서는 이미 타입 추론을 위해 1바이트를 먼저 읽어들인 상태입니다. 이 때문에, 이미 읽은 바이트와 그 이후에 올 동적 길이 및 페이로드 데이터를 함께 읽어오기 위해 MultiReader 함수를 사용해야 합니다.

이렇게 이미 읽은 바이트를 다음에 읽을 바이트와 연결하는 용도로 MultiReader를 사용하는 것은 이 함수의 최적의 방법은 아닙니다. 타입을 알아내기 위한 첫 바이트를 읽는 행위 자체를 decode 함수에서 제거하고, ReadFrom 메서드가 그 책임을 지도록 수정하는 것이 더 올바른 접근 방식입니다.


io 패키지를 이용하여 안정적인 네트워크 애플리케이션 만들기

Go 언어에서는 io.Reader와 io.Writer처럼 흔하게 사용하는 인터페이스 이외에도, 안정적인 네트워크 애플리케이션 개발을 위한 다양한 유틸리티와 함수가 존재합니다.

이번에는 io.Copy, io.MultiWriter, io.TeeReader 함수를 사용하여 방화벽이 있는 경우 데이터를 프락시 하는 방법과 네트워크 트래픽을 로깅하는 방법을 학습해 봅시다!

네트워크 연결 간 데이터 프락시

func Copy(dst Writer, src Reader) (written int64, err error)

io.Copy 함수는 Go에서 데이터 스트림을 처리하는 강력한 유틸리티입니다. 이 함수의 시그니처는 func Copy(dst Writer, src Reader) (written int64, err error)이며, src에서 EOF에 도달하거나 오류가 발생할 때까지 데이터를 읽어 dst로 복사합니다.

Copy 함수는 복사된 총 바이트 수와 복사 중 발생한 첫 번째 오류를 반환합니다. 효율성을 위해 내부적으로 최적화를 시도합니다. 만약 src가 WriteTo 메서드를 구현하고 있다면, src.WriteTo(dst)를 호출하여 복사를 구현합니다. 반대로 dst가 ReadFrom 메서드를 구현한 경우, dst.ReadFrom(src)를 호출하여 복사를 처리합니다.

이 기능을 활용하면 두 노드 간의 연결 및 트래픽 프락시를 하는 코드를 매우 간결하게 작성할 수 있습니다. 또한, 활용성이 넓은 io.Reader와 io.Writer 인터페이스를 매개변수로 받는 함수를 작성하여 유연성을 높일 수도 있습니다.

package proxy

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

func proxy(from io.Reader, to io.Writer) error {
	fromWriter, fromIsWriter := from.(io.Writer)
	toReader, toIsReader := to.(io.Reader)

	if toIsReader && fromIsWriter {
		go func() {
			_, _ = io.Copy(fromWriter, toReader)
		}()
	}
	_, err := io.Copy(to, from)
	return err
}

func TestProxy(t *testing.T) {
	var wg sync.WaitGroup

	server, err := net.Listen("tcp", "127.0.0.1:")
	if err != nil {
		t.Fatal(err)
	}
	wg.Add(1)
	go func() {
		defer wg.Done()

		for {
			conn, err := server.Accept()
			if err != nil {
				return
			}

			// ping 이 오면 pong 을 쓰는 서버
			go func(c net.Conn) {
				defer c.Close()
				for {
					buf := make([]byte, 1024)
					n, err := c.Read(buf)

					if err != nil {
						if err != io.EOF {
							t.Error(err)
						}
						return
					}
					switch msg := string(buf[:n]); msg {
					case "ping":
						_, err = c.Write([]byte("pong"))
					default:
						_, err = c.Write(buf[:n])
					}

					if err != nil {
						if err != io.EOF {
							t.Error(err)
						}
						return
					}
				}
			}(conn)
		}
	}()

	proxyServer, err := net.Listen("tcp", "127.0.0.1:")

	if err != nil {
		t.Fatal(err)
	}
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			conn, err := proxyServer.Accept()
			if err != nil {
				return
			}

			go func(from net.Conn) {
				defer from.Close()

				to, err := net.Dial("tcp", server.Addr().String())
				if err != nil {
					t.Error(err)
					return
				}
				defer to.Close()

				err = proxy(from, to)
				if err != nil && err != io.EOF {
					t.Error(err)
				}
			}(conn)
		}
	}()

	conn, err := net.Dial("tcp", proxyServer.Addr().String())
	if err != nil {
		t.Fatal(err)
	}
	msgs := []struct{ Message, Reply string }{
		{"ping", "pong"},
		{"pong", "pong"},
		{"echo", "echo"},
		{"ping", "pong"},
	}

	for i, m := range msgs {
		_, err = conn.Write([]byte(m.Message))
		if err != nil {
			t.Fatal(err)
		}

		buf := make([]byte, 1024)
		n, err := conn.Read(buf)
		if err != nil {
			t.Fatal(err)
		}
		actual := string(buf[:n])
		t.Logf("%q -> proxy -> %q", m.Message, actual)
		if actual != m.Reply {
			t.Errorf("%d: expected reply: %q; actual: %q", i, m.Reply, actual)
		}
	}

	_ = conn.Close()
	_ = proxyServer.Close()
	_ = server.Close()

	wg.Wait()
}

<proxy_conn.go, proxy_test.go>

테스트를 수행할 때는 race condition (교착 상태)을 감지하기 위해 -race 플래그를 사용하여 교착 상태 여부를 확인할 수 있습니다.

 

네트워크 연결 모니터링

io 패키지의 io.MultiWriter와 io.TeeReader 인터페이스를 사용하면 TCP 리스너에서 발생하는 모든 네트워크 트래픽을 로깅하는 코드를 작성할 수 있습니다.

  • io.MultiWriter: 하나의 쓰기 작업을 여러 개의 io.Writer로 브로드캐스트 하는 역할을 합니다. 여러 io.Writer를 입력으로 받아, 동일한 데이터를 모든 Writer에게 순서대로 씁니다.
  • io.TeeReader: io.Reader에서 데이터를 읽으면서 동시에 io.Writer에도 쓰는 기능을 합니다. 'Tee'라는 이름은 배관 공사에서 사용되는 'T'자형 파이프에서 유래했으며, 데이터 흐름을 두 갈래로 나누어 Reader와 Writer에 모두 전달하는 것을 표현합니다. io.TeeReader는 io.Reader와 io.Writer를 입력으로 받아, Reader에서 읽은 데이터를 Writer에 쓴 후, Read를 호출한 쪽으로 해당 데이터를 반환합니다.

두 인터페이스 모두 Writer로 데이터를 쓰는 행위가 내부적으로 구현되어 있는데, 이때 Writer에 데이터를 쓰는 동안 블로킹(blocking)될 수 있습니다. 만약 Writer가 네트워크 연결에 데이터를 쓰는 중이라면 레이턴시가 발생하여 오랫동안 블로킹되지 않도록 주의해야 합니다.

또한, Writer에 작성하는 중 발생한 오류는 io.TeeReader나 io.MultiWriter에도 동일하게 오류를 발생시켜 네트워크 흐름을 중단시킬 수 있으므로 주의가 필요합니다.

package tcpdata

import (
	"io"
	"log"
	"net"
	"os"
	"testing"
)

type Monitor struct {
	*log.Logger
}

func (m *Monitor) Write(p []byte) (int, error) {
	return len(p), m.Output(2, string(p))
}

func TestMonitor(t *testing.T) {
	monitor := &Monitor{Logger: log.New(os.Stdout, "monitor: ", 0)}
	listen, err := net.Listen("tcp", "127.0.0.1:")
	if err != nil {
		monitor.Fatal(err)
	}
	done := make(chan struct{})

	go func() {
		defer close(done)

		conn, err := listen.Accept()
		if err != nil {
			return
		}
		defer conn.Close()

		b := make([]byte, 1024)
		r := io.TeeReader(conn, monitor)

		n, err := r.Read(b)
		if err != nil && err != io.EOF {
			monitor.Println(err)
			return
		}

		w := io.MultiWriter(conn, monitor)
		_, err = w.Write(b[:n])
		if err != nil && err != io.EOF {
			monitor.Println(err)
			return
		}
	}()

	conn, err := net.Dial("tcp", listen.Addr().String())

	if err != nil {
		monitor.Fatal(err)
	}

	_, err = conn.Write([]byte("Test\n"))
	if err != nil {
		monitor.Fatal(err)
	}

	_ = conn.Close()
	<-done
}

Go의 TCPConn 객체

대부분의 경우 net.Conn 인터페이스만 사용해도 네트워크 연결에 필요한 충분한 기능을 사용할 수 있습니다. 하지만 읽기/쓰기 버퍼 크기 수정, 킵얼라이브(keepalive) 메시지 활성화, 또는 연결 종료 시 대기 중인 데이터 처리와 같이 세밀한 제어가 필요한 작업의 경우 net.TCPConn 객체에 직접 접근해야 합니다. net.TCPConn은 net.Conn 인터페이스를 구현한 세부 객체입니다.

net.Conn에 타입 어설션(type assertion)을 사용하여 net.TCPConn으로 변환할 수 있습니다.

tcpConn, ok := conn.(*net.TCPConn)

서버 측에서는 listener의 AcceptTCP() 메서드를 통해 TCPConn을 얻을 수 있고, 클라이언트 측에서는 net.DialTCP 함수를 통해 net.TCPConn을 얻을 수 있습니다.

킵얼라이브(Keepalive) 메시지 제어

킵얼라이브(keepalive)는 네트워크 연결의 무결성을 확인하기 위한 메시지입니다. 수신자로부터 메시지가 정상적으로 도달했는지 확인을 요청하며, 만약 승인되지 않은 킵얼라이브 메시지가 일정 횟수 이상 도달하면 운영체제는 해당 네트워크 연결을 종료합니다.

net.TCPConn 객체는 TCP 세션에 킵얼라이브를 사용할지 여부를 선택하고, 얼마나 자주 메시지를 보낼 것인지 제어하는 메서드를 제공합니다.

// keep alive 사용 여부
err := tcpConn.SetKeepAlive(true)

// keep alive 전송 간격 정의
err := tcpConn.SetKeepAlivePeriod(time.Minute)

 

연결 종료 시 보류 중인 데이터 처리

 

net.Conn 객체에 데이터를 썼지만 해당 데이터가 실제로 전송되지 못했거나, 확인 패킷(ACK)을 수신하지 못한 상태에서 네트워크 연결이 끊어지는 경우가 있습니다. 이때 운영체제는 기본적으로 백그라운드에서 남아있는 데이터 전송을 시도합니다. (이것이 데이터 전송을 보장하지는 않습니다.)

이러한 기본 동작을 변경하고 싶다면 net.TCPConn 객체의 SetLinger 메서드를 사용할 수 있습니다. Linger를 사용하지 않으면 네트워크 연결 종료 시, 서버는 클라이언트가 마지막으로 보낸 데이터와 함께 FIN 패킷을 같이 받게 됩니다.

SetLinger 함수에는 음수, 0, 양수를 전달하여 동작을 제어합니다.

  • 음수 (예: -1): 운영체제의 기본 동작을 수행합니다. (백그라운드에서 전송 시도)
    err := tcpConn.SetLinger(-1)
    

     

  • 0: Close 메서드 호출 시 RST 패킷을 보내 즉시 연결을 중단합니다. 전송되지 않은 데이터는 즉시 버려지며, 정상적인 종료 절차(FIN/ACK)를 무시합니다.
    err := tcpConn.SetLinger(0)
    

     

  • 양수 (예: 10): Close 메서드 호출 시, 최대 n초(예제에서는 10초) 동안 블로킹되며 남은 데이터 전송을 시도합니다. 이 시간이 지나면 운영체제는 남아있는 모든 데이터를 버리고 연결을 종료합니다.
    err := tcpConn.SetLinger(10)

 

기본 송수신 버퍼 오버라이딩

운영체제는 코드상에서 네트워크 연결을 만들 때마다 기본값으로 읽기 버퍼와 쓰기 버퍼를 할당합니다. 대부분의 경우 운영체제가 생성한 값으로 충분하지만, 더 큰 버퍼를 할당하는 등 성능 최적화가 필요하다면 TCPConn을 사용하여 버퍼 크기를 직접 설정할 수 있습니다.

if err := tcpConn.SetReadBuffer(212992); err != nil {
	return err
}

if err := tcpConn.SetWriteBuffer(212992); err != nil {
	return err
}