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

UDP는 신뢰성이 없는 프로토콜이기 때문에 애플리케이션에서 신뢰성과 관련한 처리를 해야 합니다.
이전 장에서 UDP의 기본 기능을 살펴봤다면, 이번에는 애플리케이션 계층 프로토콜에서 이를 어떻게 활용하는지 예시를 통해 알아보겠습니다.
TFTP는 UDP로 신뢰성 있는 데이터 전송을 가능하게 하는 애플리케이션 프로토콜입니다. 클라이언트가 파일을 다운로드만 할 수 있는 TFTP 서버를 구현하면서 신뢰성 있는 UDP 통신의 기초적인 방법을 알아보겠습니다.
TFTP 서버는 클라이언트로부터 읽기 요청을 수락하고, 데이터 패킷을 전송하며, 필요시 에러 패킷을 송신한 후 클라이언트로부터 확인 패킷을 수신합니다. 이를 위해 다음과 같은 타입 정의가 필요합니다.
타입 선언
package udpreliable
import (
"bytes"
"encoding/binary"
"errors"
"strings"
)
const (
DataGramSize = 516
BlockSize = DataGramSize - 4
)
type OpCode uint16 // 첫 2바이트는 Op(Operation) 을 나타내는 코드
const (
OpRRQ OpCode = iota + 1
_ // WRQ 지원 x
OpData
OpAck
OpErr
)
type ErrCode uint16
const (
ErrUnknown ErrCode = iota
ErrNotFound
ErrAccessViolation
ErrDiskFull
ErrIllegalOp
ErrUnknownID
ErrFileExists
ErrNoUser
)
type ReadReq struct {
FileName string
Mode string
}
기본 상수와 타입을 정의합니다. 데이터그램 크기는 516바이트로 설정되어 있으며, 실제 데이터 블록은 4바이트 헤더를 제외한 512바이트입니다.
OpCode는 읽기 요청(RRQ), 데이터 전송(Data), 확인 응답(Ack), 에러(Err) 등의 작업 유형을 구분합니다. WRQ(Write Request)는 코드에서 지원하지 않기 때문에 _(blank)로 표현했습니다.
ErrCode는 파일을 찾을 수 없음, 접근 거부, 디스크 용량 부족 등 다양한 에러 상황을 표현합니다.
ReadReq 구조체는 파일 이름과 전송 모드를 담아 파일 읽기 요청을 나타냅니다.
모든 패킷은 encoding.BinaryMarshaler 인터페이스와 encoding.BinaryUnmarshaler 인터페이스를 구현하기 때문에, 모든 패킷 타입에 대해 두 가지 메서드를 구현합니다.
읽기 요청 패킷
클라이언트가 다운로드 요청을 하면 서버는 읽기 요청을 수신합니다. 서버는 데이터 패킷 혹은 에러 패킷으로 응답해야 합니다. 서버의 패킷 응답은 클라이언트에게 서버가 요청을 정상적으로 수신했음을 확인해 주는 역할을 합니다.
읽기 요청 패킷은 2바이트의 OpCode, n 바이트의 파일명, 1 바이트의 null 문자, n 바이트의 모드 정보, 1 바이트의 null 문자로 구성됩니다.
읽기 요청을 바이트로 마샬링하는 코드와 바이트를 읽기 요청으로 변환하는 언마샬링 코드는 아래와 같습니다.
Marshaling (RRQ -> Bytes)
func (q ReadReq) MarshalBinary() ([]byte, error) {
mode := "octet"
if q.Mode != "" {
mode = q.Mode
}
// OP code + filename + 0 byte + mode + 0 byte
cap := 2 + 2 + len(q.FileName) + 1 + len(q.Mode) + 1
b := new(bytes.Buffer)
b.Grow(cap)
err := binary.Write(b, binary.BigEndian, OpRRQ) // OP 코드 쓰기
if err != nil {
return nil, err
}
_, err = b.WriteString(q.FileName) // 파일 이름 쓰기
if err != nil {
return nil, err
}
err = b.WriteByte(0) // 0 바이트 쓰기
if err != nil {
return nil, err
}
_, err = b.WriteString(mode) // 모드 정보 쓰기
if err != nil {
return nil, err
}
err = b.WriteByte(0) // 0 바이트 쓰기
if err != nil {
return nil, err
}
return b.Bytes(), nil
}
RRQ 패킷을 만들기 위해 패킷을 구성하는 정보를 차례대로 작성합니다. 여기서 0 바이트 null 문자란 C 스타일의 문자열 종료를 표시하는 \0 또는 값 0인 1 바이트 문자입니다. 일반적으로 문자열의 끝을 나타냅니다.
Unmarshaling (Bytes -> RRQ)
func (q ReadReq) UnmarshalBinary(p []byte) error {
r := bytes.NewBuffer(p)
var code OpCode
err := binary.Read(r, binary.BigEndian, &code)
if err != nil {
return err
}
if code != OpRRQ {
return errors.New("invalid RRQ")
}
q.FileName, err = r.ReadString(0)
if err != nil {
return errors.New("invalid RRQ")
}
q.FileName = strings.TrimRight(q.FileName, "\x00")
if len(q.FileName) == 0 {
return errors.New("invalid RRQ")
}
q.Mode, err = r.ReadString(0)
if err != nil {
return errors.New("invalid RRQ")
}
q.Mode = strings.TrimRight(q.Mode, "\x00")
if len(q.Mode) == 0 {
return errors.New("invalid RRQ")
}
actual := strings.ToLower(q.Mode)
if actual != "octet" {
return errors.New("only binary transfers supported")
}
return nil
}
UnmarshalBinary 메서드는 주어진 바이트 슬라이스가 읽기 요청 패킷의 구조와 일치해야만 에러 없이 종료됩니다. 정상적으로 모든 데이터를 읽으면 메서드는 nil을 반환하며, ReadReq 인스턴스에 정보가 입력됩니다. 이 인스턴스를 이용해 클라이언트가 요청한 파일을 읽어옵니다.
데이터 패킷
클라이언트는 RRQ에 대한 응답으로 데이터 패킷을 수신합니다. 서버는 데이터 패킷으로 파일을 전송하며, 각 데이터 패킷은 1부터 시작하는 증가된 블록 번호를 갖습니다. 이 블록 번호는 클라이언트가 수신된 데이터를 정렬하고 중복 데이터를 처리하는 데 사용됩니다. 클라이언트는 서버에서 데이터 패킷을 받으면 일정 시간 내에 수신 확인 패킷을 보내야 합니다. 서버가 일정 시간 내에 수신 확인 패킷을 받지 못하면, 직전에 보낸 데이터 패킷을 다시 전송합니다.
마지막 패킷을 제외한 모든 데이터 패킷은 타입 선언에서 정의한 512 바이트의 페이로드를 갖습니다. 데이터 전송 과정에서 서버와 클라이언트 모두 에러가 발생하면 에러 패킷을 반환할 수 있으며, 에러 패킷을 받으면 즉시 데이터 전송이 중단됩니다. 512 바이트보다 작은 페이로드를 가지는 마지막 데이터 패킷이 전송되면 전체 전송이 완료됩니다.
데이터 패킷은 2 바이트의 OpCode, 2 바이트의 블록 번호, n 바이트의 페이로드로 구성됩니다.
Marshaling (Data Packet -> Bytes)
type Data struct {
Block uint16
Payload io.Reader
}
// instance -> bytes
func (d *Data) MarshalBinary() ([]byte, error) {
b := new(bytes.Buffer)
b.Grow(DataGramSize)
d.Block++
err := binary.Write(b, binary.BigEndian, OpData)
if err != nil {
return nil, err
}
err = binary.Write(b, binary.BigEndian, d.Block)
if err != nil {
return nil, err
}
// BlockSize 만큼만 쓰기
_, err = io.CopyN(b, d.Payload, BlockSize)
if err != nil && err != io.EOF {
return nil, err
}
return b.Bytes(), nil
}
전체적인 코드 구조는 앞선 RRQ의 Marshaling과 비슷합니다. bytes.Buffer를 데이터 패킷 크기만큼 할당하고 버퍼에 데이터를 쓴 후 b.Bytes() 메서드를 호출하여 바이트 슬라이스를 반환합니다. 이때 io.CopyN 메서드를 이용하여 정확히 N 바이트만큼만 데이터를 작성하며, 이는 앞서 상수로 정의한 BlockSize가 됩니다.
Data 타입의 구조체는 현재 블록 번호와 데이터 원본을 포함합니다. 데이터 원본은 바이트 슬라이스 타입이 아닌 io.Reader 인터페이스로 정의되어, 페이로드를 파일, 네트워크 등 여러 곳에서 가져올 수 있습니다. io.Reader에서 데이터를 읽는 경우 남은 바이트를 자동으로 추적하기 때문에 직접 바이트 추적 관련 코드를 작성하지 않아도 되어 코드 복잡도가 줄어듭니다.
이 코드에서 Block Number는 16비트 양의 정수로 선언되어 있습니다. 이는 언젠가 블록 번호가 오버플로우 될 수 있음을 의미합니다. 서버는 오버플로우 시에도 문제없이 데이터를 전송하지만, 클라이언트는 오버플로우에 대처하기 어려울 수 있습니다. 이 부분은 개발 시 인지하고 클라이언트가 큰 페이로드를 수신할 수 있는지 확인하거나, 프로토콜을 변경하거나, 파일 크기를 제한해 오버플로우를 처리해야 합니다.
Unmarshaling (Bytes -> Data Packet)
func (d *Data) UnmarshalBinary(p []byte) error {
if l := len(p); l < 4 || l > DataGramSize {
return errors.New("invalid DATA")
}
var opCode OpCode
err := binary.Read(bytes.NewReader(p[:2]), binary.BigEndian, &opCode)
if err != nil || opCode != OpData {
return errors.New("invalid Data")
}
err = binary.Read(bytes.NewReader(p[2:4]), binary.BigEndian, &d.Block)
if err != nil {
return errors.New("invalid Data")
}
d.Payload = bytes.NewBuffer(p[4:])
return nil
}
OpCode와 Block Number를 검증한 후, 남은 바이트를 새로운 버퍼에 넣어 Data 인스턴스의 Payload 값에 할당합니다.
수신 확인 패킷
수신 확인 패킷은 앞서 설명한 데이터 패킷을 받은 클라이언트가 보내는 패킷입니다. 클라이언트는 데이터 패킷을 받을 때마다 일정 시간 내에 서버로 수신 확인 패킷을 보내야 합니다.
수신 확인 패킷은 총 4 바이트 길이를 가지며, 2 바이트의 OpCode와 2 바이트의 블록 번호로 구성됩니다. 여기서 블록 번호는 클라이언트가 수신한 마지막 블록 번호입니다.
type Ack uint16
func (a Ack) marshalBinary() ([]byte, error) {
cap := 2 + 2
b := new(bytes.Buffer)
b.Grow(cap)
err := binary.Write(b, binary.BigEndian, OpAck)
if err != nil {
return nil, err
}
err = binary.Write(b, binary.BigEndian, a)
if err != nil {
return nil, err
}
return b.Bytes(), nil
}
func (a *Ack) UnmarshalBinary(p []byte) error {
var code OpCode
r := bytes.NewReader(p)
err := binary.Read(r, binary.BigEndian, &code)
if err != nil {
return err
}
if code != OpAck {
return errors.New("invalid ACK")
}
return binary.Read(r, binary.BigEndian, a)
}
에러 패킷
에러 패킷은 2 바이트의 OpCode, 2 바이트의 에러 코드, n 바이트의 에러 메시지와 메시지를 종료하는 null 문자로 구성됩니다.
type Err struct {
Error ErrCode
Message string
}
func (e Err) MarshalBinary() ([]byte, error) {
cap := 2 + 2 + len(e.Message) + 1
b := new(bytes.Buffer)
b.Grow(cap)
err := binary.Write(b, binary.BigEndian, OpErr)
if err != nil {
return nil, err
}
err = binary.Write(b, binary.BigEndian, e.Error)
if err != nil {
return nil, err
}
_, err = b.WriteString(e.Message)
if err != nil {
return nil, err
}
err = b.WriteByte(0)
if err != nil {
return nil, err
}
return b.Bytes(), nil
}
func (e *Err) UnmarshalBinary(p []byte) error {
r := bytes.NewBuffer(p)
var code OpCode
err := binary.Read(r, binary.BigEndian, &code)
if err != nil {
return err
}
if code != OpErr {
return errors.New("invalid ERROR")
}
err = binary.Read(r, binary.BigEndian, &e.Error)
if err != nil {
return err
}
e.Message, err = r.ReadString(0)
e.Message = strings.TrimRight(e.Message, "\x00")
return err
}
전체적인 코드 구성은 앞선 패킷들과 동일하게 진행됩니다.
TFTP 서버
네트워크 인터페이스와 구현된 타입을 연결하는 서버 코드를 작성합니다.
package udpreliable
import (
"errors"
"log"
"net"
"time"
)
type Server struct {
Payload []byte
Retries uint8
Timeout time.Duration
}
func (s Server) ListenAndServe(addr string) error {
conn, err := net.ListenPacket("udp", addr)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
log.Printf("Listening on %s...\n", conn.LocalAddr())
return s.Serve(conn)
}
func (s *Server) Serve(conn net.PacketConn) error {
// server setup
if conn == nil {
return errors.New("nil connection")
}
if s.Payload == nil {
return errors.New("payload is required")
}
if s.Retries == 0 {
s.Retries = 10
}
if s.Timeout == 0 {
s.Timeout = 6 * time.Second
}
// read request
var rrq ReadReq
for {
buf := make([]byte, DataGramSize)
_, addr, err := conn.ReadFrom(buf)
if err != nil {
return err
}
err = rrq.UnmarshalBinary(buf)
if err != nil {
log.Printf("[%s] bad request: %v", addr, err)
continue
}
go s.handle(addr.String(), rrq)
}
}
서버는 네트워크 연결로부터 516 바이트의 데이터를 읽고 ReadReq로 언마샬링합니다. UnmarshalBinary 메서드에서 OpCode를 검증하기 때문에 ReadRequest에 대해서만 처리함을 보장합니다. 언마샬링 된 ReadRequest 객체와 함께 서버의 핸들러로 요청을 전달합니다.
읽기 요청 처리 from Server
핸들러는 클라이언트로부터 읽기 요청을 수락하고 서버의 페이로드로 응답합니다. 핸들러는 하나의 데이터 패킷을 전송하고 다음 데이터 패킷을 보내기 전에 클라이언트로부터 수신 확인 패킷을 대기합니다. 일정 시간 내에 클라이언트로부터 수신 확인 패킷 응답이 오지 않으면, 동일한 블록 번호의 데이터 패킷을 재전송합니다.
// Server 의 필드값에 접근 필요 -> not function but method
func (s Server) handle(clientAddr string, rrq ReadReq) {
log.Printf("[%s] requested file: %s", clientAddr, rrq.FileName)
conn, err := net.Dial("udp", clientAddr)
if err != nil {
log.Printf("[%s] dial: %v", clientAddr, err)
return
}
defer conn.Close()
var (
ackPkt Ack
errPkt Err
dataPkt = Data{Payload: bytes.NewReader(s.Payload)}
buf = make([]byte, DataGramSize)
)
NEXTPACKET:
for n := DataGramSize; n == DataGramSize; {
data, err := dataPkt.MarshalBinary()
if err != nil {
log.Printf("[%s] preparing data packet: %v", clientAddr, err)
return
}
RETRY:
for i := s.Retries; i > 0; i-- {
// 루프 순회시 패킷의 크기를 확인하는데 사용하는 변수인 n 을 계속해서 업데이트 한다.
n, err = conn.Write(data)
if err != nil {
log.Printf("[%s] write: %v", clientAddr, err)
return
}
_ = conn.SetReadDeadline(time.Now().Add(s.Timeout))
// client 로 부터 수신 확인 패킷을 읽어온다.
_, err = conn.Read(buf)
if err != nil {
if nErr, ok := err.(net.Error); ok && nErr.Timeout() {
continue RETRY
}
log.Printf("[%s] waiting for ACK: %v", clientAddr, err)
return
}
// 클라이언트로부터 읽은 바이트를 언마샬링하여 응답 패킷 코드를 확인한다.
switch {
case ackPkt.UnmarshalBinary(buf) == nil:
if uint16(ackPkt) == dataPkt.Block {
continue NEXTPACKET
}
case errPkt.UnmarshalBinary(buf) == nil:
log.Printf("[%s] received error: %v", clientAddr, errPkt.Message)
return
default:
log.Printf("[%s] bad packet", clientAddr)
}
}
log.Printf("[%s] exgausted retries", clientAddr)
return
}
log.Printf("[%s] went %d blocks", clientAddr, dataPkt.Block)
}
net.Dial 함수를 사용하여 클라이언트와 연결을 맺습니다. UDP 통신에서는 Read 함수를 통해 클라이언트로부터 패킷을 수신합니다. NEXTPACKET 루프는 데이터 패킷의 크기가 516 바이트인 경우 계속해서 데이터를 전송합니다.
데이터 객체를 바이트 슬라이스로 마샬링한 후 retry 횟수만큼 반복하여 패킷을 전송합니다. n, err = conn.Write(data) 코드에서 계속해서 n 값을 경신하면서 516 바이트 데이터 패킷 크기를 확인합니다.
데이터 패킷을 전송하고 클라이언트로부터 수신 확인 패킷을 기다립니다. 수신 확인 패킷 혹은 에러 패킷이 도착하면 해당 패킷을 Ack나 Err로 언마샬링을 시도하여 에러 여부를 확인합니다. Err로 확인되는 경우 핸들러 메서드를 즉시 종료합니다. Ack 패킷인 경우 Block Number를 확인하여 현재 데이터 패킷에 해당하는 블록 번호를 검증하고 NEXTPACKET 루프를 계속 실행합니다.
서버 시작하기

서버 실행을 위한 코드를 작성하고 해당 파일과 동일한 디렉토리에 위 이미지인 gopher.png를 위치시킵니다.
package main
import (
"flag"
"log"
"os"
"xxxxxx/tftp"
)
var (
address = flag.String("a", "127.0.0.1:69", "listen address")
payload = flag.String("p", "gopher.png", "file to serve to client")
)
func main() {
flag.Parse()
p, err := os.ReadFile(*payload)
if err != nil {
log.Fatal(err)
}
s := tftp.Server{Payload: p}
log.Fatal(s.ListenAndServe(*address))
}
tftp 포트는 69로 매핑했습니다. sudo 권한이 필요한 경우 sudo go run tftp.go 명령어로 서버를 실행할 수 있습니다.
서버를 실행하면 로컬 루프백 IP의 69 포트로 tftp 서버가 실행됩니다. macOS의 tftp 클라이언트를 사용해서 다음과 같은 명령어를 순차적으로 실행할 수 있습니다.
tftp 클라이언트를 실행한 디렉토리에 gopher.png 파일이 저장되어 있는 것을 확인할 수 있습니다.
tftp 클라이언트를 실행한 디렉토리에 gopher.png 파일이 저장되어 있는 것을 확인할 수 있습니다.

'School > GNPS' 카테고리의 다른 글
| [GNPS] 챕터 8 HTTP 클라이언트 작성 (0) | 2025.11.11 |
|---|---|
| [GNPS] 챕터 7 유닉스 도메인 소켓! (0) | 2025.11.08 |
| [GNPS] 챕터 5 신뢰성 없는 UDP 통신 (0) | 2025.11.06 |
| [GNPS] 챕터 4 TCP 데이터 전송하기 (0) | 2025.11.05 |
| [GNPS] 챕터 2 리소스의 위치와 트래픽 라우팅 (0) | 2025.10.23 |