[Golang] Simple UDP server and client


Sending and Receiving UDP datagram in Go

Go 의 UDP networking 에 대해 알아보다가 API 를 깊게 파헤쳐놓은, 매우 좋은 페이지가 있어 약간의 번역을 곁들여 소개합니다. 원본의 내용을 읽어보며 필요한 부분만을 발췌, 번역한 내용이므로 가급적 원본의 full article 을 읽어 보시길 권합니다.

원본 출처 : A UDP server and client in Go - Getting from Golang’s net package down to the Linux kernel methods invoked when UDP messages are sent.

UDP networking overview

UDP 는 기본적으로 connection-oriented 방식이 아닙니다. 따라서, 클라이언트가 보내는 메시지를 서버가 받으리라는 보장이 없고, 서버의 echo 메시지를 클라이언트가 받으리라는 보장 역시 마찬가지로 없습니다. 이상적인 경우 (양 방향 전송이 모두 성공하는 경우) 의 흐름도는 아래와 같습니다.

TIME    DESCRIPTION

t0      client and server exist

          client                         server
          10.0.0.1                       10.0.0.2


t1      client sends a message to the server

          client      ------------>      server
          10.0.0.1         msg           10.0.0.2
                       (from:10.0.0.1)
                       (to:  10.0.0.2)


t2      server receives the message, then it takes
        the address of the sender and then prepares
        another message with the same contents and
        then writes it back to the client

          client      <------------      server
          10.0.0.1         msg2          10.0.0.2
                       (from:10.0.0.1)
                       (to:  10.0.0.2)


t3      client receives the message

          client                         server
          10.0.0.1                       10.0.0.2

           thxx!! :D :D

ps.: ports omitted for brevity

Sending UDP packets using Go

먼저, UDP packet 을 전송하는 클라이언트의 코드부터 보겠습니다.

// client function
// reader (io.Reader) 의 입력 내용을 address (string) 주소의 서버에 UDP packet 으로 전송하고,
// 서버로부터 UDP packet 을 전송받아 그 내용을 출력한다.
// 일정 시간 이상 응답이 없을 경우 err (error) 를 반환.
func client(ctx context.Context, address string, reader io.Reader) (err error) {
	// ResolveUDPAddr() 을 이용하여 *UDPAddr 구조체를 얻는다.
	raddr, err := net.ResolveUDPAddr("udp", address)
	if err != nil {
		return
	}

	// DialUDP 는 반환된 소켓(혹은 connection)이 지정된 주소로만 전송/수신이 가능하도록 강제하는 역할.
	conn, err := net.DialUDP("udp", nil, raddr)
	if err != nil {
		return
	}
	// 잊지말자 defer
	defer conn.Close()

	doneChan := make(chan error, 1)

	go func() {
		// io.Copy(), reader 로 들어온 input 을 output (conn) 으로 그대로 복사.
		n, err := io.Copy(conn, reader)
		if err != nil {
			doneChan <- err
			return
		}
		fmt.Printf("packet-written: bytes=%d\n", n)

		buffer := make([]byte, maxBufferSize)

		// 서버로부터의 응답을 대기할 deadline 설정.
		// conn 에 대한 I/O 가 deadline 시간 동안 blocking 되어 있을 경우
		// blocking 상태를 해제하고 error 를 반환한다.
		deadline := time.Now().Add(time.Second * 5)
		err = conn.SetDeadline(deadline)
		if err != nil {
			doneChan <- err
			return
		}

		nRead, addr, err := conn.ReadFrom(buffer)
		if err != nil {
			doneChan <- err
			return
		}

		fmt.Printf("packet-received: bytes=%d from=%s\n", nRead, addr.String())
		// 정상 종료
		doneChan <- nil
	}()

	select {
	// Context 로 인한 종료 케이스. 여기에서는 context.Background() 를 사용했으므로,
	// must not reached here.
	case <-ctx.Done():
		fmt.Println("Cancelled by context")
		err = ctx.Err()
	// 위 goroutine 이 보낸 종료 신호를 받아서 err 를 리턴
	case err = <-doneChan:
	}
	return
}

TCP Dialing vs UDP Dialing

Go 에서 TCP 를 이용하여 통신할 때는 보통 net.Dial 을 사용하지만, UDP 의 경우에는 net.DialUDP method 를 사용합니다. Dial method 를 이용할 경우 Conn interface 가 반환되는데, DialUDP가 반환하는 UDPConn을 이용하는 편이 UDP 에 관련한 다양한 method (특히 ReadFrom, 이는 아래 server 에서 자세히 설명합니다.) 들을 사용할 수 있기 때문입니다.

DialDialUDP는 리턴 타입 외에도 내부적인 동작에 차이점이 있습니다. 사실, ‘UDP’ 와 ‘Dial’ 이라는 개념은 언뜻 잘 어울리지 않아 보입니다. UDP 전송의 기본적인 개념은, 패킷 헤더에 IP address, Port 를 기록하여 network line 을 통해 전송하되, 패킷이 목적지까지 잘 도착했는지까지는 신경쓰지 않습니다. “보내고 땡” 인 거죠. TCP 의 경우 통신할 상대방과 ‘connection’ 을 맺는 과정이 있어 ‘Dial’ 과정을 거치는 것이 자연스럽지만, UDP 는 그렇지 않습니다. 그렇다면, DialDialUDP 의 차이는 무엇일까요?

net.Dial("tcp", "1.1.1.1:53")
// strace -f -e trace=network ./main
//  [pid  4891] socket(
        AF_INET,
 -----> SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK,
        IPPROTO_IP) = 3
//  [pid  4891] connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("1.1.1.1")}, 16) = -1 EINPROGRESS (Operation now in progress)
net.Dial("udp", "1.1.1.1:53")
// strace -f -e trace=network ./main
// [pid  5517] socket(
        AF_INET,
 -----> SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK,
        IPPROTO_IP) = 3
// [pid  5517] connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("1.1.1.1")}, 16) = 0

DialUDPDial 모두 결국에는 system call 을 통하여 소켓을 생성하고 connect(2)를 호출합니다. 유일하게 다른 부분은 socket 을 생성할 때 주는 option 이 각각 SOCK_STREAM / SOCK_DGRAM 으로 다릅니다. 일반적으로, C 에서 UDP client / server 를 구현할 때, 클라이언트에서 connect(2)를 호출하는 경우는 별로 없습니다. SOCK_DGRAM속성이 지정된 소켓에 대하여 connect를 호출할 경우에는, TCP (SOCK_STREAM)의 경우에서처럼 실제 네트워크를 통해 패킷을 주고받으며 connection 을 설정하는 것이 아니라 해당 소켓이 지정한 address 의 상대방과’만’ 통신하도록 설정됩니다. 아래는 connect(2)의 man page 발췌입니다.

If the socket sockfd is of type SOCK_DGRAM, then addr is the address to which datagrams are sent by default, and the only address from which datagrams are received.

If the socket is of type SOCK_STREAM or SOCK_SEQPACKET, this call attempts to make a connection to the socket that is bound to the address specified by addr.

실제로 DialDialUDP호출시 kernel function call stack 이나, iptables의 로깅 기능을 이용하여 network 패킷을 추적해 보았을 때, TCP 의 경우는 실제로 connection 을 맺기 위한 패킷이 오가는 것이 확인되지만, UDP 는 그러한 과정이 발생하지 않음을 확인할 수 있습니다. (원문의 TCP Dialing vs UDP Dialing 부분을 참고)

A UDP Server in Go

// server function
// simple echo server
// receive -> send 의 순서를 제외하고는, 클라이언트와 거의 다를 것이 없다.
func server(ctx context.Context, address string) (err error) {
	// net.ResolveUDPAddr + ListenUDP 조합 대신
	// ListenPacket (PacketConn 을 반환) 을 사용했다.
	pc, err := net.ListenPacket("udp", address)
	if err != nil {
		return
	}

	defer pc.Close()

	doneChan := make(chan error, 1)
	buffer := make([]byte, maxBufferSize)

	go func() {
		for {
			n, addr, err := pc.ReadFrom(buffer)
			if err != nil {
				doneChan <-err
				return
			}

			fmt.Printf("packet-received: bytes=%d from=%s\n", n, addr.String())

			deadline := time.Now().Add(time.Second * 5)
			err = pc.SetWriteDeadline(deadline)
			if err != nil {
				doneChan <-err
				return
			}

			n, err = pc.WriteTo(buffer[:n], addr)
			if err != nil {
				doneChan <-err
				return
			}

			fmt.Printf("packet-written: bytes=%d to=%s\n", n, addr.String())
		}
	}()

	select {
	case <-ctx.Done():
		fmt.Println("Cancelled by context")
		err = ctx.Err()
	case err = <-doneChan:
	}

	return
}

클라이언트의 메시지를 받아서 그대로 다시 클라이언트에 돌려주는 echo server 입니다. TCP 의 경우 accept가 반환해주는 connection 을 이용하여 클라이언트에 메시지를 전송할 수 있지만, UDP 의 경우는 그렇지 못합니다. 또한 Conn 인터페이스에서 제공하는 Read method 만으로는 수신한 메시지의 발신자 주소를 알아낼 수 없기 때문에 반드시 ReadFrom method 를 제공하는 UDPConn을 사용해야 합니다. 여기에서는 PacketConn 인터페이스 - packet-oriented network connection을 지원 - 를 반환하는 net.ListenPacket 을 이용하여 서버를 구현하였습니다.