golang 비동기. tcp 서버(1). golang net 패키지로 기본적인 tcp 서버 구현하기
해당 아티클은 다음 수준의 지식을 요구합니다.
- 고루틴을 사용해본 경험
다음 실행 환경에서 동작합니다.
- wsl2 Ubuntu-22.04
- go1.23 linux/amd64 ← golang 버전업했습니다!!
이번에는 비동기 처리를 이용한 대표적인 구현으로 golang의 tcp 서버를 구현해보도록 하겠습니다. 이번 아티클에서는 단순하게 net 패키지를 이용하여 golang에서 운영체제에 관계없이 쉽게 구현할 수 있는 단일 파일로 tcp서버 스크립트를 작성합니다. golang에서 tcp서버를 이용하기 위해 어떤 작업을 진행해야 하는지 알아봅시다.
golang tcp서버 단순 구현과 POSIX socket api
먼저 golang에서 가장 단순한 형태의 tcp서버 구현을 알아보고 POSIX socket tcp workflow와 어떻게 대응되는지 살펴보겠습니다.
단순 echo에 대한 tcp 서버 전체 구현은 다음과 같습니다. 여기서 각 코드가 무슨 역할을 하는지 살펴보겠습니다.
package main
import (
"fmt"
"net"
)
func main() {
// 1. listen으로 서버 호스팅
listener, err := net.Listen("tcp", ":4999")
if err != nil {
fmt.Println("listen error", err)
return
}
// 2. accept으로 클라이언트 요청 수락 대기
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("accept error", err)
continue
}
// 3. 고루틴으로 클라이언트 통신에 대한 비동기 요청 처리
go func(conn net.Conn) {
b := make([]byte, 1024)
for {
r, err := conn.Read(b)
if err != nil {
fmt.Println("read error", err)
break
}
_, err = conn.Write(b[:r])
if err != nil {
fmt.Println("write error", err)
break
}
}
}(conn)
}
}
해당 코드를 살펴보면 크게 세 부분으로 나눌 수 있습니다.
- net.Listen을 호출하여 tcp 서버를 호스팅하는 부분
- accept을 호출하여 클라이언트의 연결을 대기하는 부분
- 고루틴을 이용하여 연결된 클라이언트 접속을 비동기로 R/W하는 부분
한편, tcp 연결의 workflow는 POSIX socket api에서 다음과 같이 구성됩니다.

해당 api 호출 파이프라인을 보면 다음 단계를 따릅니다. 위의 일부 syscall은 인자로 fd라는 값을 활용하는데 여기서는 이런 syscall이 있다는 정도로 보겠습니다. 이후의 아티클에서 해당 syscall을 golang에서 활용하여 서버를 구성하는 방법도 소개해보겠습니다.
- socket(): 서버측 소켓을 생성합니다.
- bind(): 서버측 소켓에 서버 주소를 바인딩합니다.
- listen(): 소켓 상태를 연결을 받아들이도록 변경합니다.
- accept(): 클라이언트측 소켓 연결을 수신합니다.
- read(): 클라이언트측 소켓으로부터 데이터를 읽습니다.
- write(): 클라이언트측 소켓에 데이터를 씁니다.
- close(): 소켓을 종료합니다.
그럼 해당하는 tcp 서버를 열기 위한 POSIX syscall이 어떻게 golang에서 위의 tcp 구현에서 적용되는지 확인해보겠습니다.
먼저 첫 번째 listen 부분입니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/dial.go#L806
// ...
listener, err := net.Listen("tcp", ":4999")
if err != nil {
fmt.Println("listen error", err)
return
}
// ...
net.Listen은 4계층 프로토콜과 주소를 인자로 지정하면 해당 인자에 맞는 net.Listener 인터페이스를 리턴합니다. Listen 함수는 위에서 설명한 POSIX syscall의 socket(), bind(), listen()을 추상화합니다. net.Listen을 호출하면 서버측 소켓을 생성하고 입력한 주소에 소켓을 바인딩하며, 소켓 상태를 연결을 받아들일 수 있는 상태로 변경합니다.
다음은 accept 부분입니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/tcpsock.go#L323
// ...
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("accept error", err)
continue
}
// ...
}
listener의 Accept을 호출하여 연결된 클라이언트 소켓을 net.Conn 인터페이스로 추상화하여 리턴합니다. golang을 사용하여 tcp 서버를 구현하는 개발자는 이 net.Conn 인터페이스의 메서드를 호출하여 클라이언트와 통신을 시도할 수 있습니다.
다음은 accept 내부의 고루틴을 선언하여 비동기로 R/W하는 부분입니다.
// ...
go func(conn net.Conn) {
b := make([]byte, 1024)
for {
r, err := conn.Read(b)
if err != nil {
fmt.Println("read error", err)
break
}
_, err = conn.Write(b[:r])
if err != nil {
fmt.Println("write error", err)
break
}
}
}(conn)
여기서 net.Conn 인터페이스를 인자로 받아 버퍼 b에 클라이언트로부터 수신할 데이터가 존재할 때까지 conn.Read(b)에서 고루틴을 멈추고 대기합니다. 데이터가 존재할 때까지 Read에서 블로킹하므로 해당 conn을 고루틴을 생성하여 호출합니다. 따라서 부모 컨텍스트의 루프는 바로 다시 Accept로 돌아가서 다른 커넥션 연결을 대기할 수 있습니다. 만약 클라이언트가 소켓에 데이터를 썼다면 conn.Read(b)의 블로킹이 해제되고 r에는 b에 저장된 데이터의 크기가 리턴됩니다.
해당 코드를 telnet으로 연결하여 요청을 시도할 수 있습니다.
telnet localhost 4999
한편, 클라이언트 연결은 golang에서 다음과 같이 할 수 있습니다.
// https://github.com/golang/go/blob/a10e42f219abb9c5b/src/net/dial.go#L399
func main() {
conn, err := net.Dial("tcp", "localhost:4999")
// ...
}
여기서 Dial은 해당 프로토콜로 주소에 연결을 요청하고 연결된 경우 net.Conn 인터페이스를 리턴합니다. 이 conn은 서버와 같은 인터페이스입니다. 연결 후 해당 conn에 R/W를 할 수 있습니다. net.Dial 함수는 POSIX의 socket()과 connect()를 추상화합니다.
golang의 net패키지를 이용한 tcp 구현의 특징
그럼 지금까지 tcp 서버를 단순하게 구현한 과정에서 tcp 구현의 특징을 살펴보면 다음을 확인할 수 있습니다.
- Listen, Accept를 호출하여 서버를 호스팅하고 클라이언트 연결을 대기합니다.
- Accept이후 net.Conn인터페이스 처리를 비동기로 넘기고 다시 Accept에서 반복하여 블로킹합니다.
- 따라서 클라이언트 소켓 연결 수락에 맞춰 고루틴 개수가 증가해야 합니다.
고루틴의 개수를 연결마다 증가시켜 구현한다는 점이 다소 찝찝합니다. golang이 경량 스레드로 불린다고는 하지만 연결마다 고루틴이 생성됩니다. 일단 찝찝함을 뒤로 하며 이 정도 특징만 정리하고 다음에는 직접 POSIX syscall을 호출하여 서버를 구현해봅시다. POSIX syscall을 이용한 직접 구현에서는 소켓을 애플리케이션 계층에서 어떻게 다루는지 살펴보겠습니다.
앞으로 다뤄보고 싶은 대상입니다.
- POSIX call을 직접 이용한 golang tcp 서버 구현
- epoll을 활용한 golang tcp 서버 구현
- netpoller 내부 동작 원리
그럼 조금씩 tcp와 관련된 주제를 시작해보겠습니다.
코드입니다.
https://github.com/atgane/syncgo/blob/8dbca4de2b52e4933edcab7c03cfdca332c4e2a4/02/main.go#L1
참고자료
Leave a comment