TCP/UDP 소켓 프로그래밍 — net 패키지
Go의 net 패키지는 TCP, UDP, Unix 도메인 소켓을 하나의 일관된 API로 제공합니다. 고루틴과 결합하면 수천 개의 동시 연결을 처리하는 고성능 네트워크 서버를 쉽게 구현할 수 있습니다.
net 패키지 기초
핵심 인터페이스와 타입
// net.Conn: 네트워크 연결을 표현하는 인터페이스
// io.Reader + io.Writer + io.Closer 역할 수행
type Conn interface {
Read(b []byte) (n int, err error)
Write(b []byte) (n int, err error)
Close() error
LocalAddr() Addr // 로컬 주소
RemoteAddr() Addr // 원격 주소
SetDeadline(t time.Time) error // 읽기+쓰기 데드라인
SetReadDeadline(t time.Time) error // 읽기 데드라인
SetWriteDeadline(t time.Time) error // 쓰기 데드라인
}
// net.Addr: 네트워크 주소 인터페이스
type Addr interface {
Network() string // "tcp", "udp", "unix" 등
String() string // "192.168.1.1:8080" 형태
}
기본 연결 패턴
package main
import (
"fmt"
"net"
"time"
)
func main() {
// 기본 TCP 연결 (Dial)
conn, err := net.Dial("tcp", "example.com:80")
if err != nil {
fmt.Println("연결 실패:", err)
return
}
defer conn.Close()
fmt.Println("연결 성공")
fmt.Println("로컬 주소:", conn.LocalAddr())
fmt.Println("원격 주소:", conn.RemoteAddr())
// 타임아웃 있는 연결 (실제 사용 시 권장)
conn2, err := net.DialTimeout("tcp", "example.com:80", 5*time.Second)
if err != nil {
fmt.Println("타임아웃 연결 실패:", err)
return
}
defer conn2.Close()
// 데드라인 설정: 읽기/쓰기 타임아웃
conn2.SetDeadline(time.Now().Add(10 * time.Second))
}
TCP 서버 — 멀티 클라이언트 처리
package main
import (
"bufio"
"fmt"
"net"
"strings"
"time"
)
// handleClient: 개별 클라이언트 연결 처리 (고루틴으로 실행)
func handleClient(conn net.Conn) {
defer conn.Close()
remoteAddr := conn.RemoteAddr().String()
fmt.Printf("[%s] 새 클라이언트 연결\n", remoteAddr)
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Text()
fmt.Printf("[%s] 수신: %s\n", remoteAddr, line)
// 에코 응답 (대문자로 변환)
response := strings.ToUpper(line) + "\n"
_, err := conn.Write([]byte(response))
if err != nil {
fmt.Printf("[%s] 쓰기 실패: %v\n", remoteAddr, err)
return
}
// "quit" 명령 처리
if strings.ToLower(line) == "quit" {
fmt.Printf("[%s] 클라이언트 종료 요청\n", remoteAddr)
return
}
}
if err := scanner.Err(); err != nil {
fmt.Printf("[%s] 읽기 오류: %v\n", remoteAddr, err)
}
fmt.Printf("[%s] 연결 종료\n", remoteAddr)
}
func startTCPServer(addr string) error {
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("Listen 실패: %w", err)
}
defer listener.Close()
fmt.Printf("TCP 서버 시작: %s\n", addr)
for {
// Accept: 새 클라이언트 연결 대기 (블로킹)
conn, err := listener.Accept()
if err != nil {
// 리스너가 닫히면 오류 반환됨
return fmt.Errorf("Accept 실패: %w", err)
}
// 각 클라이언트를 별도 고루틴으로 처리
go handleClient(conn)
}
}
func main() {
// 서버를 고루틴으로 실행
go func() {
if err := startTCPServer(":9000"); err != nil {
fmt.Println("서버 오류:", err)
}
}()
// 잠시 대기 후 테스트 클라이언트 실행
time.Sleep(100 * time.Millisecond)
// 테스트 클라이언트
conn, err := net.Dial("tcp", ":9000")
if err != nil {
fmt.Println("클라이언트 연결 실패:", err)
return
}
defer conn.Close()
messages := []string{"hello", "world", "quit"}
for _, msg := range messages {
fmt.Fprintf(conn, "%s\n", msg)
buf := make([]byte, 1024)
n, _ := conn.Read(buf)
fmt.Printf("응답: %s", string(buf[:n]))
}
}
TCP 에코 서버 + 클라이언트 완전 구현
package main
import (
"bufio"
"fmt"
"io"
"net"
"sync"
"time"
)
// TCPServer: 연결 관리가 있는 TCP 서버
type TCPServer struct {
addr string
listener net.Listener
wg sync.WaitGroup
quit chan struct{}
}
func NewTCPServer(addr string) *TCPServer {
return &TCPServer{
addr: addr,
quit: make(chan struct{}),
}
}
func (s *TCPServer) Start() error {
ln, err := net.Listen("tcp", s.addr)
if err != nil {
return err
}
s.listener = ln
fmt.Printf("서버 시작: %s\n", s.addr)
s.wg.Add(1)
go s.acceptLoop()
return nil
}
func (s *TCPServer) Stop() {
close(s.quit)
s.listener.Close()
s.wg.Wait()
fmt.Println("서버 종료 완료")
}
func (s *TCPServer) acceptLoop() {
defer s.wg.Done()
for {
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.quit:
return // 정상 종료
default:
fmt.Println("Accept 오류:", err)
continue
}
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleConn(conn)
}()
}
}
func (s *TCPServer) handleConn(conn net.Conn) {
defer conn.Close()
fmt.Printf("연결: %s\n", conn.RemoteAddr())
// io.Copy를 이용한 간단한 에코
io.Copy(conn, conn)
fmt.Printf("종료: %s\n", conn.RemoteAddr())
}
// TCPClient: 에코 서버에 메시지를 보내는 클라이언트
func runEchoClient(addr string, messages []string) {
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
if err != nil {
fmt.Println("연결 실패:", err)
return
}
defer conn.Close()
reader := bufio.NewReader(conn)
for _, msg := range messages {
// 서버로 전송
fmt.Fprintf(conn, "%s\n", msg)
// 에코 수신
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
resp, err := reader.ReadString('\n')
if err != nil {
fmt.Println("수신 오류:", err)
return
}
fmt.Printf("전송: %q → 수신: %q\n", msg, resp)
}
}
func main() {
server := NewTCPServer(":9001")
if err := server.Start(); err != nil {
fmt.Println("서버 시작 실패:", err)
return
}
defer server.Stop()
time.Sleep(50 * time.Millisecond)
runEchoClient(":9001", []string{"안녕하세요", "에코 테스트", "Go 네트워킹"})
}
UDP 소켓 프로그래밍
UDP는 연결 설정 없이 패킷을 전송합니다. 실시간 게임, DNS, 동영상 스트리밍 같은 지연 시간이 중요한 애플리케이션에 적합합니다.
package main
import (
"fmt"
"net"
"time"
)
// UDP 서버
func startUDPServer(addr string) {
pc, err := net.ListenPacket("udp", addr)
if err != nil {
fmt.Println("UDP 서버 시작 실패:", err)
return
}
defer pc.Close()
fmt.Printf("UDP 서버 시작: %s\n", addr)
buf := make([]byte, 1024)
for {
// 패킷 수신 (발신자 주소 포함)
n, addr, err := pc.ReadFrom(buf)
if err != nil {
fmt.Println("ReadFrom 오류:", err)
return
}
msg := string(buf[:n])
fmt.Printf("UDP 수신 [%s]: %s\n", addr, msg)
// 에코 응답
response := "에코: " + msg
pc.WriteTo([]byte(response), addr)
}
}
// UDP 클라이언트
func runUDPClient(serverAddr string) {
conn, err := net.Dial("udp", serverAddr)
if err != nil {
fmt.Println("UDP 연결 실패:", err)
return
}
defer conn.Close()
messages := []string{"첫 번째 메시지", "두 번째 메시지"}
buf := make([]byte, 1024)
for _, msg := range messages {
conn.Write([]byte(msg))
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
n, err := conn.Read(buf)
if err != nil {
fmt.Println("UDP 수신 실패:", err)
continue
}
fmt.Printf("UDP 응답: %s\n", string(buf[:n]))
}
}
func main() {
go startUDPServer(":9002")
time.Sleep(50 * time.Millisecond)
runUDPClient(":9002")
time.Sleep(100 * time.Millisecond)
}
Unix 도메인 소켓
같은 호스트 내 프로세스 간 통신에서 TCP보다 빠르고 파일 시스템 권한으로 접근을 제어할 수 있습니다.
package main
import (
"bufio"
"fmt"
"net"
"os"
"time"
)
func main() {
socketPath := "/tmp/myapp.sock"
os.Remove(socketPath) // 이전 소켓 파일 정리
// Unix 소켓 서버
go func() {
ln, err := net.Listen("unix", socketPath)
if err != nil {
fmt.Println("Unix 소켓 Listen 실패:", err)
return
}
defer ln.Close()
defer os.Remove(socketPath)
fmt.Println("Unix 소켓 서버 시작:", socketPath)
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
fmt.Println("Unix 수신:", scanner.Text())
conn.Write([]byte("응답: " + scanner.Text() + "\n"))
}
}()
time.Sleep(50 * time.Millisecond)
// Unix 소켓 클라이언트
conn, err := net.Dial("unix", socketPath)
if err != nil {
fmt.Println("Unix 소켓 연결 실패:", err)
return
}
defer conn.Close()
fmt.Fprintf(conn, "프로세스 간 통신 테스트\n")
buf := make([]byte, 256)
n, _ := conn.Read(buf)
fmt.Println("Unix 응답:", string(buf[:n]))
}
실전 예제 1 — 간단한 채팅 서버 (TCP 멀티 클라이언트)
package main
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"time"
)
// ChatServer: 멀티 클라이언트 채팅 서버
type ChatServer struct {
clients map[net.Conn]string // conn -> 닉네임
mu sync.Mutex
broadcast chan string
}
func NewChatServer() *ChatServer {
return &ChatServer{
clients: make(map[net.Conn]string),
broadcast: make(chan string, 100),
}
}
func (s *ChatServer) addClient(conn net.Conn, nick string) {
s.mu.Lock()
defer s.mu.Unlock()
s.clients[conn] = nick
}
func (s *ChatServer) removeClient(conn net.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.clients, conn)
}
// broadcastMessage: 모든 클라이언트에게 메시지 전송
func (s *ChatServer) broadcastMessage(msg string, exclude net.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
for conn := range s.clients {
if conn != exclude {
fmt.Fprint(conn, msg)
}
}
}
func (s *ChatServer) handleClient(conn net.Conn) {
defer func() {
nick := ""
s.mu.Lock()
nick = s.clients[conn]
s.mu.Unlock()
s.removeClient(conn)
conn.Close()
s.broadcastMessage(fmt.Sprintf("*** %s 님이 퇴장했습니다 ***\n", nick), nil)
}()
// 닉네임 요청
conn.Write([]byte("닉네임을 입력하세요: "))
scanner := bufio.NewScanner(conn)
if !scanner.Scan() {
return
}
nick := strings.TrimSpace(scanner.Text())
if nick == "" {
nick = "익명"
}
s.addClient(conn, nick)
s.broadcastMessage(fmt.Sprintf("*** %s 님이 입장했습니다 ***\n", nick), conn)
conn.Write([]byte(fmt.Sprintf("환영합니다, %s 님!\n", nick)))
for scanner.Scan() {
msg := scanner.Text()
if msg == "" {
continue
}
formatted := fmt.Sprintf("[%s] %s: %s\n",
time.Now().Format("15:04:05"), nick, msg)
fmt.Print(formatted) // 서버 콘솔 출력
s.broadcastMessage(formatted, conn) // 다른 클라이언트에게 전송
conn.Write([]byte("(전송됨)\n")) // 발신자에게 확인
}
}
func (s *ChatServer) Listen(addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer ln.Close()
fmt.Printf("채팅 서버 시작: %s\n", addr)
for {
conn, err := ln.Accept()
if err != nil {
return err
}
go s.handleClient(conn)
}
}
func main() {
server := NewChatServer()
fmt.Println("채팅 서버를 시작합니다...")
fmt.Println("telnet localhost 9003 으로 접속하세요")
// 실제 실행 시 아래 주석 해제
// server.Listen(":9003")
// 데모: 서버를 고루틴으로 실행 후 2개 클라이언트로 테스트
go server.Listen(":9003")
time.Sleep(50 * time.Millisecond)
// 클라이언트 1
c1, _ := net.Dial("tcp", ":9003")
defer c1.Close()
c1.Read(make([]byte, 100)) // 프롬프트 수신
fmt.Fprintf(c1, "Alice\n")
time.Sleep(50 * time.Millisecond)
// 클라이언트 2
c2, _ := net.Dial("tcp", ":9003")
defer c2.Close()
c2.Read(make([]byte, 100))
fmt.Fprintf(c2, "Bob\n")
time.Sleep(50 * time.Millisecond)
// Alice가 메시지 전송
fmt.Fprintf(c1, "안녕하세요!\n")
time.Sleep(100 * time.Millisecond)
// Bob이 수신 확인
buf := make([]byte, 256)
c2.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, _ := c2.Read(buf)
fmt.Printf("Bob이 받은 메시지: %s", string(buf[:n]))
}
실전 예제 2 — 포트 스캐너
package main
import (
"fmt"
"net"
"sort"
"sync"
"time"
)
// scanPort: 단일 포트 스캔 (열려 있으면 true 반환)
func scanPort(host string, port int, timeout time.Duration) bool {
addr := fmt.Sprintf("%s:%d", host, port)
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return false
}
conn.Close()
return true
}
// scanPorts: 지정 범위의 포트를 병렬 스캔
func scanPorts(host string, startPort, endPort int, timeout time.Duration, workers int) []int {
var (
openPorts []int
mu sync.Mutex
wg sync.WaitGroup
)
// 워커 풀 패턴으로 동시성 제한
ports := make(chan int, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for port := range ports {
if scanPort(host, port, timeout) {
mu.Lock()
openPorts = append(openPorts, port)
mu.Unlock()
fmt.Printf(" [열림] 포트 %d\n", port)
}
}
}()
}
// 스캔할 포트 전송
for port := startPort; port <= endPort; port++ {
ports <- port
}
close(ports)
wg.Wait()
sort.Ints(openPorts)
return openPorts
}
func main() {
host := "localhost"
start, end := 1, 1024
timeout := 100 * time.Millisecond
workers := 100
fmt.Printf("포트 스캔 시작: %s (%d~%d)\n", host, start, end)
startTime := time.Now()
openPorts := scanPorts(host, start, end, timeout, workers)
elapsed := time.Since(startTime)
fmt.Printf("\n스캔 완료 (소요 시간: %v)\n", elapsed)
fmt.Printf("열린 포트 수: %d\n", len(openPorts))
if len(openPorts) > 0 {
fmt.Printf("열린 포트: %v\n", openPorts)
}
}
고수 팁
항상 타임아웃을 설정하세요: net.Dial 대신 net.DialTimeout을 사용하고, 연결 후에도 SetDeadline으로 읽기/쓰기 타임아웃을 설정하세요. 타임아웃 없는 네트워크 코드는 장애 시 고루틴이 영원히 블록될 수 있습니다.
defer conn.Close() 패턴: 연결을 열면 반드시 defer conn.Close()를 바로 다음 줄에 작성하세요. Accept 루프에서는 클로저로 고루틴에서 닫아야 합니다.
UDP의 패킷 크기 제한: UDP 페이로드는 이론상 65507 바이트지만, 실제로 1472 바이트(이더넷 MTU - 헤더) 이하로 유지해야 단편화를 피할 수 있습니다.
SO_REUSEADDR: 서버를 재시작할 때 address already in use 오류가 발생하면 net.ListenConfig로 소켓 옵션을 설정할 수 있습니다.