[Golang] Simple UDP server and client
golang, udp, network ·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 에서 자세히 설명합니다.) 들을 사용할 수 있기 때문입니다.
Dial과 DialUDP는 리턴 타입 외에도 내부적인 동작에 차이점이 있습니다. 사실, ‘UDP’ 와 ‘Dial’ 이라는 개념은 언뜻 잘 어울리지 않아 보입니다. UDP 전송의 기본적인 개념은, 패킷 헤더에 IP address, Port 를 기록하여 network line 을 통해 전송하되, 패킷이 목적지까지 잘 도착했는지까지는 신경쓰지 않습니다. “보내고 땡” 인 거죠. TCP 의 경우 통신할 상대방과 ‘connection’ 을 맺는 과정이 있어 ‘Dial’ 과정을 거치는 것이 자연스럽지만, UDP 는 그렇지 않습니다. 그렇다면, Dial과 DialUDP 의 차이는 무엇일까요?
- TCP
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)
- UDP
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
Dial과 UDPDial 모두 결국에는 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.
실제로 Dial과 DialUDP호출시 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 을 이용하여 서버를 구현하였습니다.