[CS] 소켓(Socket)
TCP/IP 네트워크 프로그래밍을 공부하다 보면 반드시 마주치는 개념이 소켓(Socket)이다. 소켓은 프로세스 간 통신(IPC)의 핵심 메커니즘으로, 네트워크 통신뿐만 아니라 같은 머신 내 프로세스 간 통신에도 사용된다. 이 글에서는 소켓의 기본 개념부터 분류 체계, 물리적 실체, 통신 메커니즘까지 살펴본다.
TL;DR
- 소켓: 프로세스 간 통신을 위한 엔드포인트, 본질적으로 파일 디스크립터
- 분류 기준: 도메인(어디서) × 타입(어떻게) 조합
- 도메인:
AF_UNIX(로컬),AF_INET/AF_INET6(네트워크), 기타(AF_NETLINK,AF_PACKET등) - 타입:
SOCK_STREAM(연결 지향),SOCK_DGRAM(비연결),SOCK_RAW(저수준),SOCK_SEQPACKET(연결+메시지) - 물리적 실체: 파일 시스템 상의 소켓 파일은 가상 파일이며, 실제 데이터는 커널 메모리 소켓 버퍼에 존재
- 주요 조합: TCP(
AF_INET+SOCK_STREAM), UDP(AF_INET+SOCK_DGRAM), Unix Stream(AF_UNIX+SOCK_STREAM)
배경 지식
소켓을 이해하려면 먼저 TCP와 UDP를 알아야 한다. 소켓의 분류와 동작 방식을 설명할 때 이 프로토콜을 기반으로 한 소켓이 계속 등장하기 때문이다.
TCP (Transmission Control Protocol)
TCP는 전송(Transmission)을 제어(Control)하는 프로토콜이다. 데이터가 올바른 순서로, 손실 없이 도착하도록 연결을 관리하고 흐름을 제어한다.
특징
| 특징 | 설명 |
|---|---|
| 연결 지향 | 통신 전 연결 수립 과정 필요 (3-way handshake) |
| 순서 보장 | 데이터가 보낸 순서대로 도착 |
| 신뢰성 | 손실 시 재전송 |
| 속도 | 상대적으로 느림 (신뢰성 보장을 위한 오버헤드) |
3-Way Handshake
TCP 연결 수립 시 3-way handshake 과정이 필요하다.
Client Server
| |
| 1. SYN (seq=x) |
|--------------------------->|
| |
| 2. SYN-ACK (seq=y,ack=x+1)
|<---------------------------|
| |
| 3. ACK (ack=y+1) |
|--------------------------->|
| |
Connection ESTABLISHED
- 클라이언트가 SYN 보내고
- 서버가 SYN-ACK 보내고
- 클라이언트가 ACK 보내면 연결 완료
실제 코드에서는 이 과정이 추상화되어 있다.
// 클라이언트
conn, _ := net.Dial("tcp", "example.com:80")
// 서버
listener, _ := net.Listen("tcp", ":8080")
conn, _ := listener.Accept()
Dial(): 이 한 줄 안에서 3-way handshake가 완료된다. handshake가 끝나야 반환되므로 블로킹이 발생한다.Accept(): handshake가 완료된 연결을 반환한다. 반환 시점에 이미 연결 완료 상태다.
위 코드에서 보듯이, TCP 연결 수립(3-way handshake)은 커널의 TCP 스택에서 처리되기 때문에 개발자가 직접 다룰 일은 없다. Go뿐 아니라 Python의 socket.connect(), Java의 Socket(), C의 connect() 등 모든 언어에서 동일하다.
사용 사례
데이터가 반드시 도착해야 하는 경우에 사용한다.
- HTTP/HTTPS
- SSH
- 파일 전송 (FTP, SFTP)
- 이메일 (SMTP)
UDP (User Datagram Protocol)
UDP는 데이터그램(Datagram)을 그대로 전달하는 프로토콜이다. 연결 수립이나 순서 보장 없이, 개별 패킷(데이터그램)을 독립적으로 전송한다.
특징
| 특징 | 설명 |
|---|---|
| 비연결 | 연결 수립 없이 바로 전송 |
| 순서 비보장 | 도착 순서가 보낸 순서와 다를 수 있음 |
| 재전송 없음 | 손실되어도 재전송 안 함 |
| 속도 | 빠름 (오버헤드 적음) |
사용 사례
속도가 중요하고 약간의 손실이 허용되는 경우에 사용한다.
- DNS
- 실시간 스트리밍 (비디오, 오디오)
- 온라인 게임
- VoIP
Stream vs Datagram
TCP와 UDP는 Stream과 Datagram의 차이로도 이해할 수 있다.
용어 정의
- Stream (스트림): 연속적인 바이트 흐름. 데이터의 시작과 끝 경계가 없고, 물 흐르듯 연속적으로 전달된다.
- Datagram (데이터그램): 독립적인 메시지 단위. 각 패킷이 개별적으로 전송되며, 메시지 경계가 보존된다.
비교
| 구분 | Stream (SOCK_STREAM) | Datagram (SOCK_DGRAM) |
|---|---|---|
| 데이터 단위 | 바이트 스트림 (경계 없음) | 메시지 단위 (경계 있음) |
| 연결 | 연결 지향 | 비연결 |
| 순서 | 보장 | 비보장 |
| 신뢰성 | 신뢰성 있음 | 신뢰성 없음 |
| 비유 | 전화 통화 (연결하고 대화) | 우편 (그냥 보내기만) |
| 대표 프로토콜 | TCP | UDP |
소켓 기본 개념
정의
소켓은 프로세스 간 통신(IPC)을 위한 엔드포인트다.
- 프로세스 간: 네트워크 너머 원격 프로세스일 수도 있고, 같은 머신 내 로컬 프로세스일 수도 있음
- 엔드포인트: 통신의 끝점, 데이터를 보내고 받는 지점
네트워크 통신이든 로컬 통신이든, 데이터를 주고받을 수 있도록 추상화된 인터페이스다.
소켓도 파일이다
Unix/Linux의 “Everything is a file” 철학에 따라 소켓도 파일이다 ([CS] Everything is a File 참고).
ls -l 출력에서 첫 번째 문자가 s이면 소켓 파일이다.
| 기호 | 종류 | 설명 | 예시 |
|---|---|---|---|
- |
일반 파일 | 텍스트, 바이너리, 이미지 등 | -rw-r--r-- file.txt |
d |
디렉토리 | 폴더 | drwxr-xr-x /home/ |
l |
심볼릭 링크 | 다른 파일을 가리키는 링크 | lrwxrwxrwx rtc -> rtc0 |
c |
문자 장치 | 한 바이트씩 전송 | crw-rw-rw- /dev/null |
b |
블록 장치 | 블록 단위 전송 | brw-rw---- /dev/sda |
s |
소켓 | 프로세스 간 통신 | srwxrwxrwx /run/systemd/notify |
p |
파이프 | 프로세스 간 데이터 전달 | Named pipe |
본질: 파일 디스크립터
소켓은 본질적으로 파일 디스크립터다. 따라서 read()/write() 시스템 콜로 다룰 수 있다.
| 대상 | read() | write() | close() |
|---|---|---|---|
| 일반 파일 | 데이터 읽기 | 데이터 쓰기 | fd 해제 |
| 소켓 | 데이터 받기 | 데이터 보내기 | 연결 종료 + fd 해제 |
소켓 타입별로 read()/write()/close()의 의미는 다음과 같다.
| 소켓 타입 | read() | write() | close() |
|---|---|---|---|
| TCP | 상대방이 보낸 데이터 받기 | 상대방에게 데이터 보내기 | 4-way handshake로 연결 종료 |
| Unix Domain | 다른 프로세스가 보낸 데이터 받기 | 다른 프로세스에게 데이터 보내기 | 소켓 파일 연결 해제 |
| UDP | 누군가 보낸 패킷 받기 | 목적지에 패킷 보내기 | fd 해제 (연결 개념 없음) |
소켓 전용 I/O: send()/recv()
소켓은 파일 디스크립터이므로 read()/write()로 다룰 수 있지만, 소켓 전용 시스템 콜도 있다.
| 시스템 콜 | 대상 | 특징 |
|---|---|---|
read()/write() |
모든 fd | 범용 I/O |
send()/recv() |
소켓 전용 | 플래그 옵션 제공 |
sendto()/recvfrom() |
소켓 전용 | 주소 지정 (UDP용) |
close() |
모든 fd | fd 해제 (소켓은 연결 종료) |
shutdown() |
소켓 전용 | 방향별 종료 (half-close 가능) |
// 범용 파일 I/O
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
// 소켓 전용 (flags 파라미터 추가)
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// UDP용 (주소 지정)
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
// 연결 종료
int close(int fd); // 양방향 종료 + fd 해제
int shutdown(int sockfd, int how); // 방향별 종료 (SHUT_RD, SHUT_WR, SHUT_RDWR)
send()/recv()는 flags 파라미터로 추가 동작을 지정할 수 있다.
| 플래그 | 설명 |
|---|---|
MSG_PEEK |
데이터를 버퍼에서 제거하지 않고 읽기 |
MSG_DONTWAIT |
논블로킹으로 동작 |
MSG_OOB |
Out-of-band 데이터 처리 |
플래그 없이 사용하면 read()/write()와 동일하게 동작한다.
read() 반환값과 연결 상태
TCP 소켓에서 read()의 반환값은 연결 상태를 나타낸다.
| 반환값 | 의미 | 원인 |
|---|---|---|
> 0 |
정상 수신 | 데이터 도착 |
0 (EOF) |
정상 종료 | 상대방이 close() 또는 shutdown(SHUT_WR) 호출 (FIN 전송) |
-1 + ECONNRESET |
비정상 종료 | RST 패킷 수신, 네트워크 끊김 |
-1 + EAGAIN |
데이터 없음 | 논블로킹 모드에서 버퍼 비어있음 |
주요 에러/상태 코드
| 코드 | 의미 | 발생 상황 |
|---|---|---|
| EOF (0 반환) | End of File | 상대방이 정상 종료 (FIN 전송) |
| EPIPE | Broken Pipe | 이미 닫힌 연결에 write() 시도 |
| ECONNRESET | Connection Reset | 상대방이 RST 패킷 전송 (비정상 종료) |
| EAGAIN / EWOULDBLOCK | 재시도 필요 | 논블로킹 모드에서 데이터 없음 |
| ECONNREFUSED | Connection Refused | 상대방이 연결 거부 (포트 안 열림) |
| ETIMEDOUT | Timeout | 연결 또는 응답 타임아웃 |
참고: Half-Close
close()는 양방향 종료지만,shutdown(fd, SHUT_WR)은 송신만 종료하고 수신은 유지한다 (half-close). 이때 상대방의read()는 EOF를 반환하지만, 상대방은 여전히 데이터를 보낼 수 있다.
생성 방법: socket() 시스템 콜
소켓은 도메인, 타입, 프로토콜을 조합해서 생성한다.
int socket(int domain, int type, int protocol);
| 파라미터 | 의미 | 예시 |
|---|---|---|
domain |
어디서 통신하냐 | AF_INET, AF_UNIX |
type |
어떻게 통신하냐 | SOCK_STREAM, SOCK_DGRAM |
protocol |
어떤 프로토콜 | 보통 0 (domain+type 기본 프로토콜) |
생성 과정
socket() 호출 시 내부적으로 다음이 일어난다.
- 커널에서 소켓 구조체 생성: 커널 메모리에 소켓을 위한 자료구조 할당
struct socket: 소켓 메타데이터struct sock: 송신/수신 버퍼, 프로토콜 상태 정보
- 파일 디스크립터 반환
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // sockfd = 3 (예시) → 이후 read/write/close 등에 사용- 성공 시: 음이 아닌 정수(파일 디스크립터) 반환
- 실패 시: -1 반환 +
errno설정
- 생성만 된 상태: 통신 준비는 아직 안 됨
- 소켓이 프로세스 간 통신을 위한 엔드포인트이므로, 엔드포인트만 생성된 것
- 실제 통신하려면 추가 설정 필요
다음 단계
소켓 생성 후 역할에 따라 다른 단계가 필요하다.
| 소켓 역할 | 다음 단계 |
|---|---|
| 서버 (TCP) | bind() → listen() → accept() |
| 클라이언트 (TCP) | connect() |
| UDP | bind() (선택) → sendto()/recvfrom() |
| Unix Domain | 위와 동일한 패턴 |
소켓 분류 체계
소켓은 도메인(어디서 통신)과 타입(어떻게 통신)의 조합으로 분류된다.
도메인 (Domain)
“어디서” 통신하냐를 결정한다. Domain은 ‘관할 영역’, ‘지배 범위’라는 의미다.
| 도메인 | 범위 | 주소 형식 | 설명 |
|---|---|---|---|
AF_UNIX |
같은 호스트 (로컬) | 파일 경로 | Unix Domain Socket |
AF_INET |
네트워크 (IPv4) | IP:Port | 인터넷 소켓 |
AF_INET6 |
네트워크 (IPv6) | IP:Port | 인터넷 소켓 (IPv6) |
AF_NETLINK |
커널-유저 공간 | - | 커널과 통신 |
AF_PACKET |
링크 레벨 | - | raw 패킷 접근 |
AF_BLUETOOTH |
블루투스 범위 | Bluetooth MAC | 블루투스 통신 |
AF_CAN |
차량 내부 | - | CAN 버스 통신 |
용어 참고: “Unix Domain Socket”의 “Domain”도 같은 맥락이다. 통신 범위(domain)가 Unix 시스템 내부로 한정되기 때문에 그렇게 불린다. 즉, 소켓 API의 domain 파라미터는 분류 기준이고, Unix Domain Socket이라는 이름은 해당 소켓의 범위를 설명한다.
AF_UNIX (Unix Domain Socket)
같은 호스트 내 프로세스 간 통신에 사용한다.
// 서버
listener, _ := net.Listen("unix", "/tmp/app.sock")
// 클라이언트
conn, _ := net.Dial("unix", "/tmp/app.sock")
- 주소: 파일 경로 (
/tmp/app.sock) - 특징: 네트워크 스택을 거치지 않아 빠름
- 용도: Docker, MySQL, systemd 등 로컬 IPC
AF_INET / AF_INET6 (Internet Socket)
네트워크를 통한 통신에 사용한다.
// TCP
conn, _ := net.Dial("tcp", "192.168.1.100:8080")
// UDP
conn, _ := net.Dial("udp", "192.168.1.100:9000")
- 주소: IP 주소 + 포트 번호
- 용도: HTTP, SSH, DNS 등 네트워크 통신
타입 (Type)
“어떻게” 통신하냐를 결정한다.
| 타입 | 특징 | 예시 | 비유 |
|---|---|---|---|
SOCK_STREAM |
연결 지향, 순서 보장, 신뢰성 | TCP, Unix Stream | 전화 통화 |
SOCK_DGRAM |
비연결, 순서 비보장, 빠름 | UDP, Unix Datagram | 우편 |
SOCK_RAW |
IP 레벨 직접 접근 | ping (ICMP), 패킷 분석 | - |
SOCK_SEQPACKET |
연결 지향, 메시지 경계 보존 | SCTP | - |
도메인 × 타입 조합
모든 조합이 가능한 것은 아니다. 의미 있는 조합만 지원된다.
자주 사용하는 조합
// TCP 소켓 (90% 이상 사용)
socket(AF_INET, SOCK_STREAM, 0)
// UDP 소켓
socket(AF_INET, SOCK_DGRAM, 0)
// Unix Domain Stream 소켓 (로컬 IPC)
socket(AF_UNIX, SOCK_STREAM, 0)
// Unix Domain Datagram 소켓
socket(AF_UNIX, SOCK_DGRAM, 0)
특수 목적 조합
// ping 구현 (ICMP)
socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)
// tcpdump, wireshark (패킷 캡처)
socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))
// ip 명령어 (라우팅 테이블 조작)
socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE)
// SCTP (통신 시스템)
socket(AF_INET, SOCK_SEQPACKET, IPPROTO_SCTP)
전체 조합표
| 도메인 | 타입 | 가능 | 프로토콜 | 용도 |
|---|---|---|---|---|
| AF_UNIX | SOCK_STREAM | O | - | Docker, MySQL 소켓 |
| AF_UNIX | SOCK_DGRAM | O | - | systemd 로깅 |
| AF_UNIX | SOCK_SEQPACKET | O | - | 메시지 경계 보존 IPC |
| AF_UNIX | SOCK_RAW | X | - | 의미 없음 |
| AF_INET / AF_INET6 | SOCK_STREAM | O | TCP | HTTP, SSH |
| AF_INET / AF_INET6 | SOCK_DGRAM | O | UDP | DNS, 스트리밍 |
| AF_INET / AF_INET6 | SOCK_SEQPACKET | O | SCTP | 통신 시스템 |
| AF_INET / AF_INET6 | SOCK_RAW | O | ICMP/IP | ping, traceroute |
| AF_NETLINK | SOCK_STREAM | X | - | 의미 없음 |
| AF_NETLINK | SOCK_DGRAM | O | NETLINK_ROUTE | 라우팅 테이블 |
| AF_NETLINK | SOCK_SEQPACKET | X | - | 의미 없음 |
| AF_NETLINK | SOCK_RAW | O | NETLINK_* | 커널 이벤트 수신 |
| AF_PACKET | SOCK_STREAM | X | - | 의미 없음 |
| AF_PACKET | SOCK_DGRAM | O | ETH_P_* | 헤더 제거된 패킷 |
| AF_PACKET | SOCK_SEQPACKET | X | - | 의미 없음 |
| AF_PACKET | SOCK_RAW | O | ETH_P_ALL | tcpdump, wireshark |
불가능한 조합과 이유
AF_UNIX + SOCK_RAW: 로컬 IPC는 네트워크를 거치지 않아 raw하게 접근할 패킷 계층이 없다.
socket(AF_UNIX, SOCK_RAW, 0) // X
AF_NETLINK + SOCK_STREAM: 커널 메시지는 스트림이 아니라 메시지 단위로 전달된다.
socket(AF_NETLINK, SOCK_STREAM, 0) // X
AF_PACKET + SOCK_STREAM: 링크 계층에는 연결(connection) 개념이 없다.
socket(AF_PACKET, SOCK_STREAM, 0) // X
소켓의 물리적 실체
소켓 파일은 가상 파일이다. 실제 데이터를 담고 있지 않다.
가상 파일
- 정의: 실제 디스크에 데이터를 저장하지 않고, 커널 메모리의 자료 구조를 가리키는 파일
- 특징:
- 파일은 존재함
- 디스크 블록을 차지하지 않음
- inode는 있지만 데이터 블록은 없음
- 단지 네임스페이스 상의 이름일 뿐
Unix Domain Socket: 파일 시스템에 보임
Unix Domain Socket은 파일 시스템에 소켓 파일로 나타난다. 하지만 가상 파일이다.
소켓 파일을 생성한다.
listener, _ := net.Listen("unix", "/tmp/test.sock")
ls -la로 파일 타입과 크기를 확인한다. 파일 타입은 s(소켓)이고, 크기는 0이다.
$ ls -la /tmp/test.sock
srwxr-xr-x 1 user user 0 Feb 3 10:00 /tmp/test.sock # 크기 0 바이트
du로 실제 디스크 사용량을 확인한다. 0이다.
$ du -h /tmp/test.sock
0 /tmp/test.sock
stat으로 블록 할당을 확인한다. 디스크 블록이 0개다.
$ stat /tmp/test.sock
File: /tmp/test.sock
Size: 0 Blocks: 0 IO Block: 4096 socket # 디스크 블록 0개
ls -i로 inode 번호를 확인한다. inode는 존재한다 (파일 시스템 엔트리는 있음).
$ ls -i /tmp/test.sock
1234567 /tmp/test.sock
- 타입:
s(소켓) - 크기: 0 바이트
- 블록: 0개
- 역할: 커널 소켓 구조체를 찾는 “이름표”
TCP Socket: 파일 시스템에 안 보임
TCP 소켓은 파일 시스템에 아예 나타나지조차 않는다. 완전히 커널 메모리에만 존재한다.
conn, _ := net.Dial("tcp", "example.com:80")
// 파일 시스템에 아무것도 안 생김!
/proc을 통해서만 확인할 수 있다.
# 프로세스의 파일 디스크립터 목록
$ ls -l /proc/self/fd/3
lrwx------ 1 user user 64 Feb 3 10:00 3 -> socket:[45678] # 45678: inode 번호
# 소켓 연결 정보
$ ss -tn
State Local Address:Port Peer Address:Port
ESTAB 192.168.1.100:54321 93.184.216.34:80
실제 데이터 위치: 커널 버퍼
소켓의 실제 데이터는 커널 메모리의 소켓 버퍼에 있다.
커널 메모리 구조
핵심은 버퍼가 연결 리스트로 연결되어 있다는 점이다. 패킷이 도착하면 sk_buff 구조체에 담겨 큐에 연결되고, read()하면 큐에서 꺼내서 유저 공간으로 복사한다.
struct socket (VFS)
└─► struct sock (Protocol)
├─► sk_receive_queue ─► [sk_buff] ─► [sk_buff] ─► ... (수신 버퍼)
└─► sk_write_queue ─► [sk_buff] ─► [sk_buff] ─► ... (송신 버퍼)
| 구조체 | 레벨 | 역할 |
|---|---|---|
struct socket |
VFS | 파일 디스크립터와 연결, 소켓 타입 저장 |
struct sock |
Protocol | 버퍼 큐 관리, 연결 상태, 버퍼 크기 제한 |
sk_buff |
Data | 실제 패킷 데이터 (연결 리스트로 체이닝) |
커널 자료구조
struct socket {
struct sock *sk; // 프로토콜별 정보
struct file *file; // 파일 디스크립터 연결
...
}
struct sock {
struct sk_buff_head receive_queue; // 수신 버퍼
struct sk_buff_head write_queue; // 송신 버퍼
int state; // TCP 상태 등
...
}
| 구성요소 | 위치 | 설명 |
|---|---|---|
struct socket |
커널 힙 (kmalloc) | VFS와의 연결 담당 |
struct sock |
커널 객체 캐시 | 프로토콜별 전용 캐시에서 할당 |
sk_buff |
커널 객체 캐시 | skbuff_head_cache에서 할당 |
| 실제 패킷 데이터 | 커널 페이지 | alloc_pages() 또는 kmalloc()으로 할당 |
소켓 확인 방법
Unix Domain Socket
# ss 명령어로 확인 (-x: unix)
$ ss -x | grep test.sock
u_str ESTAB 0 0 /tmp/test.sock
# lsof로 확인
$ lsof -U
TCP Socket
# ss 명령어로 확인 (-t: tcp, -n: 숫자로 표시)
$ ss -tn
State Local Address:Port Peer Address:Port
ESTAB 127.0.0.1:8080 127.0.0.1:54321
# lsof로 확인
$ lsof -i
결론: 소켓 파일에 데이터가 없다
# 소켓 파일을 cat으로 읽어도 의미 없음
$ cat /tmp/test.sock
# 아무것도 안 나옴 또는 에러
소켓 파일은 통신 채널을 가리키는 이름표일 뿐이다.
- 파일 시스템 경로(Unix Domain)나 FD는 핸들일 뿐
- 실제 데이터는 커널 메모리 소켓 버퍼에 있음
- “Everything is a file” 철학의 추상화 덕분에 동일한 인터페이스로 접근 가능
소켓 통신 메커니즘
소켓의 Read/Write
소켓 타입에 따라 Write()와 Read()의 데이터 경로가 다르다.
TCP
네트워크를 거쳐 데이터가 전달된다.
Sender Kernel Network Kernel Receiver
| | | | |
| Write() | | | |
|-------------->| | | |
| [Send Buffer] | | |
| | "Hello" | | |
| |------------->| | |
| | TCP Packet | |
| | |------------->| |
| | | [Recv Buffer] |
| | | | "Hello" |
| | | |<-------------|
| | | | Read() |
Write 과정: 커널 송신 버퍼에 복사 → TCP 패킷화 → 네트워크 전송 → 수신 버퍼 도착
Read 과정: 커널 수신 버퍼에서 복사 (버퍼가 비어있으면 blocking)
Unix Domain Socket
TCP/IP 프로토콜 스택을 거치지 않고, VFS를 통해 커널 내에서 직접 버퍼 간 복사가 일어난다.
Sender Kernel Receiver
| | |
| Write() | |
|-------------->| |
| [Send Buffer] |
| | |
| [Recv Buffer] |
| | "Hello" |
| |--------------->|
| | Read() |
차이점: TCP/IP 스택을 거치지 않아 프로토콜 오버헤드가 없다. 같은 호스트 내 통신에서 TCP보다 빠르다.
송신자와 수신자가 서로 찾는 방법
주소 체계
| 소켓 타입 | 주소 형식 | 네임스페이스 | 찾는 방법 |
|---|---|---|---|
| Unix Domain | 파일 경로 | 파일 시스템 | 파일 시스템 → inode → 소켓 |
| TCP/UDP | IP:Port | 네트워크 | 네트워크 패킷 → 포트 테이블 → 소켓 |
Unix Domain Socket: 파일 시스템 경로로 찾기
// 서버: 특정 경로에 소켓 생성
listener, _ := net.Listen("unix", "/tmp/app.sock")
conn, _ := listener.Accept()
// 클라이언트: 같은 경로로 연결
conn, _ := net.Dial("unix", "/tmp/app.sock")
connect("/tmp/app.sock") 내부 동작:
- VFS(Virtual File System)로 경로 전달
- 파일 시스템에서 inode 검색
- inode 타입 확인 (
S_IFSOCK) - inode → socket 구조체 포인터 획득
- listening socket 찾음
TCP Socket: IP:Port와 3-way Handshake로 찾기
// 서버: IP:Port에 바인딩
listener, _ := net.Listen("tcp", "127.0.0.1:8080")
conn, _ := listener.Accept()
// 클라이언트: 같은 IP:Port로 연결
conn, _ := net.Dial("tcp", "127.0.0.1:8080")
connect(IP:Port) 내부 동작:
- TCP/IP 스택으로 전달
- 포트 번호로 해시 테이블 검색
- listening socket 찾음
- 3-way handshake 수행
- 연결 수립
UDP Socket: Connectionless
// UDP 서버
addr, _ := net.ResolveUDPAddr("udp", ":9000")
conn, _ := net.ListenUDP("udp", addr)
buf := make([]byte, 1024)
n, clientAddr, _ := conn.ReadFromUDP(buf)
// UDP 클라이언트
addr, _ := net.ResolveUDPAddr("udp", "192.168.1.100:9000")
conn, _ := net.DialUDP("udp", nil, addr)
conn.Write([]byte("Hello")) // 그냥 쏨! 연결 없음!
UDP는 connectionless다:
Dial()시 실제 연결 수립 없음- 소켓 생성 + 기본 목적지 주소 저장만 함
Write()시 바로 패킷 전송- 3-way handshake 없음, 응답 확인 없음
소켓 상태
“열려 있다”의 의미
일반 파일과 소켓에서 “열려 있다”의 의미가 다르다.
| 구분 | 일반 파일 | 소켓 (TCP) |
|---|---|---|
| 조건 | FD 유효 + 파일 시스템 리소스 접근 가능 | FD 유효 + 연결 활성 + 상대방도 유지 |
| 범위 | 로컬 리소스 | 네트워크 너머 상대방 존재 |
| 제어 | open(), close()로 완전 제어 |
내 쪽 FD만으론 부족 |
소켓이 열려 있다: 여러 레벨의 조건
- FD 레벨 (모든 소켓 공통)
- 프로세스가 소켓 FD 보유
close()미호출read()/write()시스템 콜 가능
- 연결 상태 레벨 (TCP 등 연결 지향 소켓)
- 3-way handshake 완료 (ESTABLISHED)
- 커널 TCP 상태 머신에서 연결 유지
- 양쪽 peer가 연결 유지 중
- 버퍼 레벨
- 송신/수신 버퍼 할당됨
- 데이터 송수신 준비 완료
소켓 타입별 “열려 있다”
Unix Domain Socket
# 확인 명령
$ ss -x | grep test.sock
- FD 유효
- 파일 시스템에 소켓 파일 존재
- 양쪽 프로세스가 연결 유지
- 네트워크 경로 검증 불필요 (로컬)
TCP Socket
# 확인 명령
$ ss -tn | grep ESTABLISHED
- FD 유효
- TCP 상태 ESTABLISHED
- 양쪽 peer 연결 유지
- 네트워크 경로 정상
UDP Socket
# 확인 명령
$ ss -u # 상태 없음
- FD 유효
- 연결 개념이 약함 (connectionless)
connect()호출해도 실제 연결 수립 안 됨- 그냥 기본 목적지 설정만 함
연결 끊김 감지
연결 지향 소켓의 경우, 연결 끊김이 자동으로 감지되지 않는다. 능동적으로 확인해야 한다.
conn, _ := net.Dial("tcp", "example.com:80")
// 이 시점에 상대방이 연결을 끊어도 모름 - fd는 여전히 유효
실제로 Read()나 Write()를 시도할 때 비로소 연결 끊김을 알게 된다. 앞서 설명한 대로 정상 종료면 EOF, 비정상 종료면 에러가 반환된다.
n, err := conn.Read(buf)
if err == io.EOF {
// 정상 종료: 상대방이 close() 호출
}
if errors.Is(err, syscall.ECONNRESET) {
// 비정상 종료: RST 패킷 수신
}
n, err := conn.Write(data)
if errors.Is(err, syscall.EPIPE) {
// 상대방이 이미 끊음 (broken pipe)
}
감지 방법
| 방법 | 감지 시점 | 비용 |
|---|---|---|
| Read/Write 시도 | 실제 I/O 할 때 | 낮음 |
| TCP Keepalive | 주기적 (커널) | 중간 |
| Read Timeout | Read 호출 시 | 낮음 |
| App-level Ping | 주기적 (앱) | 높음 |
TCP Keepalive
conn, _ := net.Dial("tcp", "example.com:80")
tcpConn := conn.(*net.TCPConn)
// Keepalive 활성화
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(30 * time.Second)
// 이제 30초마다 커널이 자동으로 확인
// 상대방이 응답 안 하면 연결 끊김 감지
Read Timeout
conn, _ := net.Dial("tcp", "example.com:80")
// 5초 안에 데이터 안 오면 타임아웃
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf)
if err != nil {
// timeout 또는 연결 끊김
}
참고: Broken Pipe (EPIPE) 에러
서버 개발을 하다 보면 종종 “broken pipe” 에러를 마주치는 경우가 있다. 이 경우는 클라이언트가 타임아웃 등으로 먼저 연결을 종료했는데, 서버가 뒤늦게 응답을 보내려 할 때 발생한다. 위에서 언급했듯이 연결 끊김은 자동 감지되지 않으므로,
Write()시점에 비로소 알게 되는 것이다.
- 오래 걸리는 API 응답 중 클라이언트가 탭 닫음
- 클라이언트 타임아웃 설정(예: 5분)이 응답 전에 먼저 발동
대응 방법:
- 에러를 graceful하게 처리 (패닉 대신 로깅 후 연결 정리)
- 서버 측에도 적절한 타임아웃 설정
- 오래 걸리는 작업은 진행 상황을 주기적으로 전송해서 연결 유지
정리
소켓의 핵심 개념을 정리하면 다음과 같다.
| 개념 | 설명 |
|---|---|
| 소켓 정의 | 프로세스 간 통신을 위한 엔드포인트 |
| 소켓 본질 | 파일 디스크립터 (Everything is a file) |
| 도메인 | 어디서 통신하냐 (AF_UNIX, AF_INET, …) |
| 타입 | 어떻게 통신하냐 (SOCK_STREAM, SOCK_DGRAM, …) |
| TCP 특징 | 연결 지향, 순서 보장, 신뢰성, 3-way handshake |
| UDP 특징 | 비연결, 순서 비보장, 빠름 |
| 물리적 실체 | 소켓 파일은 가상 파일, 실제 데이터는 커널 버퍼 |
| 주소 찾기 | Unix Domain (파일 경로), TCP/UDP (IP:Port) |
| 연결 끊김 | 자동 감지 안 됨, Read/Write나 Keepalive로 확인 |
참고 자료
추후 더 확인해 보면 좋을 참고 자료 목록을 정리해 둔다.
- [CS] Everything is a File - Unix/Linux의 파일 추상화 철학
- Beej’s Guide to Network Programming - 네트워크 프로그래밍 가이드
- Linux Socket Programming - socket(7) 매뉴얼 페이지
- RFC 793 - Transmission Control Protocol - TCP 원본 명세 (1981)
- RFC 9293 - Transmission Control Protocol - TCP 최신 통합 명세 (2022)
- RFC 768 - User Datagram Protocol - UDP 명세 (1980)
- Linux tcp(7) man page - TCP 소켓 옵션 매뉴얼
- Linux udp(7) man page - UDP 소켓 옵션 매뉴얼
댓글남기기