소켓과 전송 계층 (1) - 소켓의 탄생과 추상화 - soo:bak
작성일 :
프로세스 간 통신의 역사
1970년대 Unix 운영체제에서 프로세스들은 서로 통신해야 했습니다. 같은 컴퓨터에서 실행되는 프로세스끼리 데이터를 주고받는 것을 IPC(Inter-Process Communication)라고 합니다.
Unix는 여러 IPC 메커니즘을 제공했습니다.
1
2
3
4
파이프(Pipe):
ls | grep ".txt"
ls의 출력 ──► [파이프] ──► grep의 입력
파이프는 한 프로세스의 출력을 다른 프로세스의 입력으로 연결합니다. 단방향이고, 부모-자식 관계의 프로세스 사이에서만 작동합니다.
이 외에도 메시지 큐(프로세스 간 메시지 전달), 공유 메모리(같은 메모리 영역 접근), 세마포어(동기화) 같은 메커니즘이 있었습니다.
하지만 이것들은 모두 같은 컴퓨터 안에서만 작동했습니다. 커널이 관리하는 자원을 공유하는 방식이므로, 같은 커널 위에서 실행되는 프로세스끼리만 사용할 수 있었습니다.
1970년대 후반, ARPANET이 성장하면서 새로운 문제가 생겼습니다.
다른 컴퓨터에 있는 프로세스와 통신하려면 어떻게 해야 하는가?
기존 IPC로는 불가능했습니다. 네트워크를 통한 통신을 위한 새로운 추상화가 필요했습니다.
Berkeley Sockets의 등장
1983년, UC Berkeley의 BSD 4.2가 발표되었습니다. Bill Joy가 이끄는 팀이 DARPA의 지원을 받아 개발한 이 운영체제에는 새로운 네트워크 API가 포함되어 있었습니다. Berkeley Sockets입니다.
Berkeley Sockets의 핵심 철학: “네트워크 통신도 파일처럼 다루자.”
Unix에서는 하드디스크, 프린터, 키보드도 파일처럼 다룹니다. 열고(open), 읽고(read), 쓰고(write), 닫습니다(close). 이것이 Unix의 “모든 것은 파일이다(Everything is a file)” 철학입니다.
네트워크 연결도 같은 방식으로 다룰 수 있다면, 개발자는 새로운 API를 배울 필요 없이 익숙한 파일 I/O 개념으로 네트워크 프로그래밍을 할 수 있습니다.
1
2
3
4
5
6
7
공통점: 열고 → 읽고/쓰고 → 닫는다
파일: open() → read()/write() → close()
소켓: socket() → 연결 설정 → read()/write() → close()
│
├─ 클라이언트: connect()
└─ 서버: bind() → listen() → accept()
이 설계 덕분에 프로그래머는 네트워크의 복잡한 세부사항을 알 필요가 없었습니다.
이미 익숙한 파일 I/O 개념으로 네트워크 프로그래밍을 할 수 있었습니다.
Berkeley Sockets는 빠르게 표준이 되었습니다.
BSD Unix뿐 아니라 System V Unix, Linux, Windows(Winsock), macOS 등 거의 모든 운영체제가 이 API를 채택했습니다.
40년이 지난 지금도 네트워크 프로그래밍의 기본 인터페이스입니다.
소켓이란 무엇인가
“소켓”이라는 용어는 혼란을 일으키기 쉽습니다. 많은 사람들이 소켓을 “IP 주소와 포트의 조합”이라고 이해합니다.
1
192.168.1.100:8080 ← 이것이 소켓?
IP:포트는 소켓을 식별하는 데 사용되지만, 소켓 자체는 아닙니다. 같은 IP:포트라도 연결 상태, 버퍼에 쌓인 데이터, 타이머 값이 다를 수 있습니다. 이런 정보들을 모두 포함하는 것이 소켓입니다.
소켓은 네트워크 연결의 한쪽 끝을 나타내는 커널 객체입니다.
전화 통화에 비유하면, 전화번호는 상대방을 식별하지만 통화 자체는 아닙니다. 통화가 연결되면 통화 시간, 음성 데이터, 연결 상태 등이 관리됩니다. 소켓은 이런 “통화 세션”에 해당합니다. 주소(IP:포트)로 식별되지만, 그 안에는 연결에 필요한 모든 정보가 담겨 있습니다.
애플리케이션이 socket() 함수를 호출하면, 운영체제 커널이 이 데이터 구조를 생성합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
커널 내 소켓 구조 (단순화)
┌─────────────────────────────────┐
│ 누구와 통신하는가? │
│ ├─ 프로토콜 (TCP/UDP) │
│ ├─ 로컬 IP:포트 │
│ └─ 원격 IP:포트 │
├─────────────────────────────────┤
│ 현재 연결 상태는? │
│ └─ LISTEN, ESTABLISHED, ... │
├─────────────────────────────────┤
│ 주고받는 데이터는? │
│ ├─ 송신 버퍼 (보낼 데이터) │
│ └─ 수신 버퍼 (받은 데이터) │
├─────────────────────────────────┤
│ TCP 제어 정보 │
│ └─ 시퀀스 번호, 타이머, 옵션... │
└─────────────────────────────────┘
IP:포트는 이 구조의 일부일 뿐입니다. 소켓은 이 모든 정보를 포함하는 전체 객체입니다.
socket() 함수는 이 구조에 대한 파일 디스크립터(File Descriptor)를 반환합니다.
파일 디스크립터는 커널이 관리하는 자원을 가리키는 정수입니다. Unix에서 파일을 열면 번호를 받고, 이 번호로 읽기/쓰기/닫기를 합니다. 소켓도 마찬가지입니다.
1
2
3
4
5
6
7
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// sockfd = 3 (파일 디스크립터 번호)
// 이 번호로 모든 작업 수행
write(sockfd, data, len); // 데이터 전송
read(sockfd, buffer, len); // 데이터 수신
close(sockfd); // 소켓 닫기
앞서 살펴본 “네트워크 통신도 파일처럼 다루자”는 철학이 여기서 구현됩니다. 애플리케이션은 소켓 내부 구조를 직접 다루지 않습니다. 파일 디스크립터(번호)만 알면 됩니다.
소켓은 통신 끝점(Endpoint)의 추상화입니다.
네트워크 연결의 한쪽 끝을 나타내는 커널 객체이며, 애플리케이션은 파일 디스크립터를 통해 이 객체와 상호작용합니다.
5-Tuple: 연결의 고유 식별
웹 서버에 1000명이 동시에 접속하면, 커널은 1000개의 연결을 어떻게 구분할까요?
5-tuple로 식별합니다. 다섯 가지 값의 조합이 하나의 연결을 고유하게 만듭니다.
1
5-tuple = (프로토콜, 로컬 IP, 로컬 포트, 원격 IP, 원격 포트)
1
2
3
4
5
서버: 192.168.1.100:8080
연결 1: (TCP, 192.168.1.100, 8080, 10.0.0.1, 52001)
연결 2: (TCP, 192.168.1.100, 8080, 10.0.0.1, 52002) ← 포트만 다름
연결 3: (TCP, 192.168.1.100, 8080, 10.0.0.2, 52001) ← IP만 다름
세 연결 모두 같은 서버(192.168.1.100:8080)에 접속했지만, 클라이언트의 IP 또는 포트가 다르므로 별개의 연결입니다.
왜 5개가 필요한가?
각 요소가 빠지면 연결을 구분할 수 없는 상황이 생깁니다.
| 요소 | 없으면 구분 불가한 상황 |
|---|---|
| 프로토콜 | TCP와 UDP가 같은 포트를 쓸 때 |
| 로컬 IP | 서버에 여러 네트워크 인터페이스가 있을 때 |
| 로컬 포트 | 같은 서버에서 여러 서비스를 운영할 때 |
| 원격 IP | 다른 클라이언트들의 연결 |
| 원격 포트 | 같은 클라이언트가 여러 연결을 맺을 때 |
5개 모두 있어야 모든 연결을 고유하게 식별할 수 있습니다.
하나의 서버 포트, 수천 개의 연결
웹 서버는 80번 포트 하나로 수천 개의 동시 연결을 처리합니다. 어떻게 가능할까요?
1
2
3
4
5
6
7
서버: 192.168.1.100:80
서버 측 (고정) 클라이언트 측 (가변)
연결 1: 192.168.1.100:80 ←→ 203.0.113.1:50001 (클라이언트 A)
연결 2: 192.168.1.100:80 ←→ 203.0.113.1:50002 (클라이언트 A, 2번째)
연결 3: 192.168.1.100:80 ←→ 198.51.100.5:49000 (클라이언트 B)
연결 4: 192.168.1.100:80 ←→ 192.0.2.10:55555 (클라이언트 C)
서버 측 주소는 모두 같지만, 클라이언트 측 주소가 다르므로 5-tuple이 달라집니다. 커널은 패킷이 도착하면 5-tuple을 확인하여 어느 소켓으로 전달할지 결정합니다.
클라이언트 포트(50001, 50002, …)는 어디서 왔을까요?
서버는 포트 번호가 고정되어야 합니다. 클라이언트가 “80번 포트로 접속”할 수 있으려면 서버가 항상 80번에서 대기해야 합니다.
반면 클라이언트는 포트 번호가 뭐든 상관없습니다. 서버가 클라이언트의 포트 번호를 미리 알 필요가 없기 때문입니다. 연결이 맺어지면 서버는 응답을 보낼 주소를 패킷에서 알 수 있습니다.
그래서 클라이언트가 connect()를 호출하면, 운영체제가 비어 있는 포트 번호를 자동으로 할당합니다. 이를 임시 포트(Ephemeral Port)라고 합니다. 연결이 끝나면 운영체제에 반환됩니다. 보통 49152~65535 범위를 사용합니다.
소켓 API의 설계
앞에서 소켓이 무엇인지, 5-tuple로 어떻게 식별되는지 살펴봤습니다. 이제 실제로 소켓을 다루는 API를 살펴봅니다.
socket(): 소켓 생성
1
2
3
4
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// │ │ └─ 프로토콜 (0 = 자동 선택)
// │ └─ 타입: SOCK_STREAM(TCP), SOCK_DGRAM(UDP)
// └─ 주소 체계: AF_INET(IPv4), AF_INET6(IPv6)
앞에서 설명한 것처럼, socket()은 커널에 소켓 데이터 구조를 생성하고 파일 디스크립터를 반환합니다.
이 시점에서 소켓의 5-tuple은 대부분 비어 있습니다. 프로토콜(TCP)만 정해졌고, IP와 포트는 아직 없습니다. 이후 bind()나 connect()로 채워집니다.
bind(): 주소 할당
1
2
3
4
bind(sockfd, 주소, 주소길이);
// 예시: 8080 포트에 바인딩
bind(sockfd, "0.0.0.0:8080", ...); // 실제 코드는 구조체 사용
bind()는 소켓에 로컬 주소(IP와 포트)를 할당합니다. 5-tuple에서 로컬 IP와 로컬 포트가 채워집니다.
1
2
3
socket() 후: (TCP, ???, ???, ???, ???)
bind() 후: (TCP, 192.168.1.100, 8080, ???, ???)
└─ 로컬 IP ─┘ └─ 로컬 포트
서버는 bind()가 필수입니다.
클라이언트가 서버에 접속하려면 서버의 주소를 알아야 합니다. “google.com의 443번 포트로 접속”처럼요. 서버가 매번 다른 포트를 쓰면 클라이언트는 어디로 접속해야 할지 모릅니다. 그래서 서버는 bind()로 포트를 고정합니다.
클라이언트는 bind()를 호출하지 않습니다.
서버가 클라이언트의 주소를 미리 알 필요가 없습니다. 클라이언트가 먼저 연결을 시작하고, 서버는 그 연결 요청 패킷에서 클라이언트 주소를 알 수 있습니다. 그래서 클라이언트 포트는 아무 번호나 써도 됩니다. connect() 시 커널이 비어 있는 포트를 자동으로 할당합니다. 앞서 설명한 임시 포트입니다.
listen(): 연결 대기
1
listen(sockfd, 128); // 최대 128개의 대기 연결 허용
bind()로 주소를 할당한 소켓은 아직 연결을 받을 준비가 안 됐습니다. listen()을 호출해야 “이 소켓은 연결을 기다리는 중”이라고 커널에 알립니다.
listen() 후 이 소켓은 직접 데이터를 주고받지 않습니다. 연결 요청을 받아서 대기열에 쌓아두는 역할만 합니다. 실제 통신은 accept()가 반환하는 새 소켓에서 일어납니다.
1
2
3
4
클라이언트 연결 요청 → [대기열] → accept()로 꺼냄
│
backlog = 128
(최대 128개 대기 가능)
backlog는 대기열 크기입니다. 서버가 바빠서 accept()를 늦게 호출하면 연결 요청이 대기열에 쌓입니다. 대기열이 가득 차면 새 연결 요청이 거부됩니다.
accept(): 연결 수락
1
2
int conn_fd = accept(listen_fd, &client_addr, &len);
// └─ 새 소켓 └─ listen 소켓
accept()는 대기열에서 연결을 꺼내고, 새로운 소켓을 생성합니다.
왜 새 소켓이 필요할까요? listen 소켓은 특정 클라이언트와 연결된 게 아닙니다. 모든 클라이언트의 연결 요청을 받는 “접수 창구”입니다. 실제 통신은 각 클라이언트마다 별도의 소켓이 필요합니다.
1
2
3
5-tuple 변화:
listen 소켓: (TCP, 192.168.1.100, 8080, ???, ???) ← 원격 주소 없음
accept() 후: (TCP, 192.168.1.100, 8080, 10.0.0.1, 52001) ← 5-tuple 완성
accept()가 반환한 새 소켓은 5-tuple이 완성된 상태입니다. 이 소켓으로 해당 클라이언트와 데이터를 주고받습니다.
1
2
3
4
5
6
7
listen_fd: 계속 새 연결을 받음 (포트 8080)
│
├── accept() → conn_fd_1: 클라이언트 A와 통신
│
├── accept() → conn_fd_2: 클라이언트 B와 통신
│
└── accept() → conn_fd_3: 클라이언트 C와 통신
connect(): 연결 시작 (클라이언트)
1
connect(sockfd, "192.168.1.100:8080", ...); // 실제 코드는 구조체 사용
connect()는 클라이언트가 서버로 연결을 시작합니다. 이 함수가 TCP 3-Way Handshake를 수행합니다.
앞에서 클라이언트는 bind()를 호출하지 않는다고 했습니다. connect()가 두 가지 일을 동시에 합니다:
- 로컬 주소 자동 할당 (임시 포트)
- 서버로 연결 시작
1
2
3
4
5-tuple 변화 (클라이언트):
socket() 후: (TCP, ???, ???, ???, ???)
connect() 후: (TCP, 10.0.0.1, 52001, 192.168.1.100, 8080)
└─ 자동 할당 ─┘ └─── 서버 주소 ───┘
connect()가 성공하면 5-tuple이 완성됩니다. 이제 read()/write()로 데이터를 주고받을 수 있습니다.
전체 흐름
TCP (SOCK_STREAM)
TCP는 연결 지향 프로토콜입니다. 데이터를 보내기 전에 먼저 연결을 설정합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
서버 클라이언트
│ │
socket() → (TCP, ?, ?, ?, ?) socket() → (TCP, ?, ?, ?, ?)
│ │
bind() → (TCP, 서버IP, 8080, ?, ?) │
│ │
listen() → 연결 대기 시작 │
│ │
│←──────── 연결 요청 ─────────────── connect() 시작
│ │
│ (3-Way Handshake) │
│ │
│─────────── 연결 수락 ──────────────→ connect() 완료
│ │
│ → (TCP, 클라IP, 52001, 서버IP, 8080)
│ │
accept() → 새 소켓 생성 │
→ (TCP, 서버IP, 8080, 클라IP, 52001)│
│ │
read()/write() ←─── 데이터 통신 ───→ read()/write()
│ │
close() close()
TCP의 특성:
- 연결 설정 필요: 3-Way Handshake로 연결을 맺은 후 통신
- 신뢰성 보장: 패킷 손실 시 재전송, 순서 보장
- 5-tuple 고정: 연결 후 한 상대와만 통신
- 바이트 스트림: 메시지 경계 없이 연속된 바이트로 전달
사용 사례: HTTP, 파일 전송, 이메일 등 데이터 손실이 허용되지 않는 경우
UDP (SOCK_DGRAM)
UDP는 비연결 프로토콜입니다. 연결 설정 없이 바로 데이터를 보냅니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
서버 클라이언트
│ │
socket(SOCK_DGRAM) socket(SOCK_DGRAM)
│ │
bind() → (UDP, 서버IP, 8080, ?, ?) │ (bind 생략 가능)
│ │
│ sendto(서버IP:8080, "요청")
│ │ → 커널이 임시 포트 할당
│ │ → (UDP, 클라IP, 52001, ?, ?)
│←─────────── "요청" ───────────────── │
│ │
recvfrom() │
│ → 송신자 주소 획득 (클라IP:52001) │
│ │
sendto(클라IP:52001, "응답") │
│─────────── "응답" ─────────────────→ recvfrom()
│ │
close() close()
TCP와 달리 연결이 없으므로 서버는 recvfrom()으로 데이터를 받을 때 송신자 주소도 함께 받습니다. 이 주소로 응답을 보냅니다.
UDP의 특성:
- 연결 설정 없음:
listen(),accept(),connect()불필요 - 신뢰성 없음: 패킷 손실, 순서 뒤바뀜 가능. 필요하면 애플리케이션이 처리
- 5-tuple 고정 안 됨: 매번
sendto()에 목적지 지정. 하나의 소켓으로 여러 상대와 통신 가능 - 데이터그램: 메시지 경계 유지. 보낸 단위 그대로 받음
사용 사례: DNS 조회, 실시간 게임, 영상 스트리밍 등 속도가 중요하고 일부 손실이 허용되는 경우
TCP vs UDP 요약:
| TCP | UDP | |
|---|---|---|
| 연결 | 필요 (3-Way Handshake) | 불필요 |
| 신뢰성 | 보장 (재전송, 순서 보장) | 없음 |
| 오버헤드 | 큼 (연결 설정 + 헤더 20바이트) | 작음 (헤더 8바이트) |
| 데이터 단위 | 바이트 스트림 | 데이터그램 (메시지 단위) |
왜 이 추상화가 중요한가
Berkeley Sockets가 40년간 표준으로 유지된 이유는 무엇일까요?
복잡성 은닉
앞에서 살펴본 것처럼 TCP는 연결 설정, 신뢰성 보장, 순서 보장 등 복잡한 동작을 합니다. 하지만 개발자는 이 세부사항을 몰라도 됩니다. read()/write()만 호출하면 커널이 모든 것을 처리합니다.
프로토콜 독립
TCP를 UDP로 바꾸고 싶으면 socket() 호출 시 타입만 변경하면 됩니다.
1
2
socket(AF_INET, SOCK_STREAM, 0); // TCP
socket(AF_INET, SOCK_DGRAM, 0); // UDP
나머지 코드 구조는 거의 같습니다.
운영체제 독립
Linux, Windows, macOS에서 거의 동일한 코드가 동작합니다. 1983년 BSD에서 만들어진 API가 지금도 전 세계 운영체제에서 사용됩니다.
파일 I/O 통합
소켓도 파일 디스크립터입니다. 파일을 다루는 것과 같은 방식으로 네트워크를 다룰 수 있습니다. 앞에서 살펴본 “네트워크 통신도 파일처럼 다루자”는 철학이 이 모든 것을 가능하게 했습니다.
다음 글
이 글에서는 소켓이 무엇인지, 어떻게 사용하는지 살펴봤습니다. connect()와 accept()가 “연결을 맺는다”고 했는데, 그 안에서는 어떤 일이 일어날까요?
Part 2에서는 TCP 연결의 상태 머신를 살펴봅니다. 3-Way Handshake의 각 단계, TCP의 11가지 상태, TIME_WAIT가 왜 필요한지 알아봅니다.
관련 글
시리즈
- 소켓과 전송 계층 (1) - 소켓의 탄생과 추상화 (현재 글)
- 소켓과 전송 계층 (2) - TCP 연결의 상태 머신
- 소켓과 전송 계층 (3) - 멀티플렉싱과 패킷 흐름