Socket: 프로세스를 네트워크에 연결하는 통신 종단점
소켓(Socket)은 운영체제가 제공하는 네트워크 통신 종단점이다. 애플리케이션은 파일처럼 소켓을 열고 데이터를 읽고 쓰며, 전송 계층(Transport Layer)의 TCP 또는 UDP를 통해 다른 프로세스와 통신한다. TCP 소켓은 연결과 신뢰성을 제공하고, UDP 소켓은 연결 없이 데이터그램을 빠르게 전달한다.
By 송민성
1. 개념
소켓(Socket)은 프로세스가 네트워크를 통해 데이터를 주고받을 때 사용하는 운영체제 자원이다. 웹 서버와 브라우저처럼 서로 다른 컴퓨터의 프로세스뿐 아니라 같은 컴퓨터의 프로세스도 소켓으로 통신할 수 있다.
인터넷 소켓은 주로 다음 정보로 식별된다.
- 인터넷 프로토콜 주소(IP address): 통신할 호스트
- 포트 번호(Port number): 호스트 안의 프로세스 또는 서비스
- 전송 프로토콜(Transport protocol): TCP 또는 UDP
TCP 연결은 다음 네 값의 조합으로 구분된다.
출발지 IP, 출발지 포트, 목적지 IP, 목적지 포트
같은 서버 포트에도 여러 클라이언트가 동시에 접속할 수 있는 이유다. 각 연결의 출발지 정보가 다르므로 운영체제가 연결을 별도로 관리한다.
대표 소켓 유형은 다음과 같다.
- 스트림 소켓(Stream socket): TCP 사용, 바이트 스트림 제공
- 데이터그램 소켓(Datagram socket): UDP 사용, 독립된 메시지 단위로 전달
- 유닉스 도메인 소켓(Unix domain socket): 같은 운영체제 안의 프로세스 간 통신에 사용
2. 왜 사용하는가
소켓은 응용 계층(Application Layer)과 운영체제의 네트워크 스택 사이에 공통 인터페이스를 제공한다. 애플리케이션은 이더넷 프레임 생성, IP 패킷 분할, TCP 재전송 같은 작업을 직접 구현하지 않아도 된다.
웹 개발에서 사용하는 HTTP, 웹소켓(WebSocket), 데이터베이스 프로토콜도 최종적으로 TCP 또는 UDP 소켓 위에서 동작한다.
예를 들어 브라우저가 웹 서버에 접속하면 다음 계층을 거친다.
HTTP 메시지 → TLS 레코드 → TCP 바이트 스트림 → IP 패킷 → 네트워크 장치
애플리케이션은 주로 소켓에 바이트를 쓰고 읽는다. 패킷 전송, 순서 제어, 재전송은 운영체제와 전송 프로토콜이 처리한다.
3. 동작 원리
TCP 서버
TCP 서버는 다음 순서로 동작한다.
socket: 소켓 생성bind: 로컬 IP 주소와 포트 연결listen: 연결 요청 대기 상태로 전환accept: 클라이언트 연결 수락recv,send: 데이터 수신과 송신close: 연결과 소켓 해제
accept는 통신용 연결 소켓을 새로 반환한다. 처음 만든 리스닝 소켓(Listening socket)은 이후 연결 요청을 계속 받는 데 사용된다.
클라이언트 서버
| |
| ---- 연결 요청 --------> | listen
| <--- 연결 수락 ---------- | accept
| ---- 데이터 -----------> | recv
| <--- 데이터 ------------- | send
| ---- 연결 종료 --------> | closeTCP는 바이트 스트림(Byte stream)이다. 한 번의 send와 한 번의 recv가 일대일로 대응하지 않는다. 송신자가 100바이트를 한 번에 보내도 수신자는 40바이트와 60바이트로 나눠 받을 수 있다.
따라서 메시지 경계를 애플리케이션 프로토콜이 정의해야 한다. 대표 방식은 다음과 같다.
- 고정 길이
- 구분자 사용
- 길이 접두사(Length prefix) 사용
- 연결 종료까지 읽기
UDP 통신
UDP는 연결 설정 없이 데이터그램(Datagram)을 보낸다.
sendto: 목적지 주소와 함께 데이터 전송recvfrom: 데이터와 송신자 주소 수신
UDP는 메시지 경계를 유지하지만 전달, 순서, 중복 방지를 보장하지 않는다. 필요하면 애플리케이션이 직접 처리해야 한다.
버퍼와 블로킹
운영체제는 소켓마다 송신 버퍼와 수신 버퍼를 관리한다.
send는 보통 데이터를 송신 버퍼에 복사한 뒤 반환한다.recv는 수신 버퍼에 데이터가 없으면 대기할 수 있다.- 상대가 느리면 송신 버퍼가 가득 차
send도 대기할 수 있다.
이처럼 작업이 끝날 때까지 호출한 실행 흐름을 멈추는 방식을 블로킹 입출력(Blocking I/O)이라고 한다. 많은 연결을 다룰 때는 논블로킹 입출력(Non-blocking I/O), 입출력 다중화(I/O multiplexing), 비동기 입출력(Asynchronous I/O)을 사용한다.
4. 코드 예제
다음 코드는 길이 접두사를 사용하는 TCP 에코 서버와 클라이언트다. 메시지 앞에 4바이트 길이를 넣어 TCP 바이트 스트림에서 메시지 경계를 복원한다.
TCP 서버
import socket
import struct
HOST = "127.0.0.1"
PORT = 5000
HEADER_SIZE = 4
MAX_MESSAGE_SIZE = 1024 * 1024
def receive_exactly(connection: socket.socket, size: int) -> bytes:
chunks = bytearray()
while len(chunks) < size:
chunk = connection.recv(size - len(chunks))
if not chunk:
raise ConnectionError("메시지를 모두 받기 전에 연결이 종료됐습니다.")
chunks.extend(chunk)
return bytes(chunks)
def receive_message(connection: socket.socket) -> bytes:
header = receive_exactly(connection, HEADER_SIZE)
message_size = struct.unpack("!I", header)[0]
if message_size > MAX_MESSAGE_SIZE:
raise ValueError("허용된 메시지 크기를 초과했습니다.")
return receive_exactly(connection, message_size)
def send_message(connection: socket.socket, message: bytes) -> None:
header = struct.pack("!I", len(message))
connection.sendall(header + message)
def main() -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
server_socket.setsockopt(
socket.SOL_SOCKET,
socket.SO_REUSEADDR,
1,
)
server_socket.bind((HOST, PORT))
server_socket.listen()
print(f"{HOST}:{PORT}에서 연결을 기다립니다.")
while True:
connection, client_address = server_socket.accept()
with connection:
print(f"클라이언트 연결: {client_address}")
try:
message = receive_message(connection)
print(f"수신: {message.decode('utf-8')}")
send_message(connection, message)
except (ConnectionError, ValueError, UnicodeDecodeError) as error:
print(f"처리 실패: {error}")
if __name__ == "__main__":
main()TCP 클라이언트
import socket
import struct
HOST = "127.0.0.1"
PORT = 5000
HEADER_SIZE = 4
def receive_exactly(connection: socket.socket, size: int) -> bytes:
chunks = bytearray()
while len(chunks) < size:
chunk = connection.recv(size - len(chunks))
if not chunk:
raise ConnectionError("응답을 모두 받기 전에 연결이 종료됐습니다.")
chunks.extend(chunk)
return bytes(chunks)
def main() -> None:
message = "소켓 통신 확인".encode("utf-8")
packet = struct.pack("!I", len(message)) + message
with socket.create_connection((HOST, PORT), timeout=5) as connection:
connection.sendall(packet)
header = receive_exactly(connection, HEADER_SIZE)
response_size = struct.unpack("!I", header)[0]
response = receive_exactly(connection, response_size)
print(response.decode("utf-8"))
if __name__ == "__main__":
main()서버와 클라이언트를 서로 다른 터미널에서 실행한다.
python server.pypython client.pysendall은 모든 바이트를 운영체제에 전달할 때까지 내부적으로 반복한다. 반면 send는 일부 바이트만 처리하고 반환할 수 있다. recv도 요청한 크기보다 적은 바이트를 반환할 수 있으므로 receive_exactly처럼 반복해서 읽어야 한다.
5. 시간 복잡도 또는 성능 특성
길이가 n바이트인 데이터를 애플리케이션 버퍼로 복사하는 작업의 시간복잡도는 O(n)이다. 다만 실제 지연 시간은 복사 비용만으로 결정되지 않는다.
주요 성능 요소는 다음과 같다.
- 왕복 시간(Round-Trip Time, RTT): 요청과 응답이 네트워크를 왕복하는 시간
- 대역폭(Bandwidth): 단위 시간에 전송할 수 있는 데이터 양
- 시스템 호출(System call): 사용자 공간과 커널 공간을 오가는 비용
- 버퍼 크기: 처리량과 메모리 사용량에 영향
- 연결 설정: TCP는 통신 전에 핸드셰이크(Handshake)가 필요
- 동시 연결 수: 연결마다 파일 디스크립터(File descriptor)와 커널 상태 사용
블로킹 서버가 연결 하나를 처리하는 동안 다른 연결을 받지 못하면 동시성이 낮아진다. 여러 연결을 처리하는 대표 방법은 다음과 같다.
- 연결마다 스레드 생성
- 프로세스 여러 개 사용
select,poll,epoll,kqueue기반 입출력 다중화- 비동기 런타임 사용
연결 수가 커질수록 연결마다 스레드를 만드는 방식은 스레드 메모리와 문맥 교환(Context switch) 비용이 커질 수 있다. 이벤트 루프(Event loop)는 적은 수의 실행 흐름으로 많은 소켓의 준비 상태를 감시한다.
6. 실무 사용 사례
웹 서버
HTTP/1.1과 HTTP/2는 일반적으로 TCP 연결 위에서 동작한다. HTTPS는 TCP 소켓 위에 전송 계층 보안(TLS)을 추가한다. HTTP/3는 UDP 기반 QUIC를 사용한다.
데이터베이스 연결
애플리케이션과 PostgreSQL 서버는 TCP 소켓 또는 유닉스 도메인 소켓으로 통신한다. 연결 생성 비용을 줄이기 위해 연결 풀(Connection pool)을 사용한다.
실시간 통신
채팅, 알림, 협업 편집은 장시간 유지되는 웹소켓(WebSocket) 연결을 사용할 수 있다. 웹소켓은 HTTP로 시작한 뒤 양방향 메시지 통신으로 전환된다.
내부 프로세스 통신
같은 서버 안의 프로세스끼리 통신할 때 유닉스 도메인 소켓을 사용할 수 있다. 네트워크 주소 대신 파일 시스템 경로를 사용하며 로컬 통신에 적합하다.
DNS와 미디어 전송
DNS 질의는 주로 UDP를 사용하며 상황에 따라 TCP도 사용한다. 실시간 음성·영상은 지연을 줄이기 위해 UDP 기반 프로토콜을 사용할 수 있다.
7. 주의할 점
TCP에는 메시지 경계가 없다
recv(1024)는 메시지 하나를 받는다는 뜻이 아니다. 최대 1024바이트를 읽는다는 뜻이다. 고정 길이, 구분자, 길이 접두사 같은 프레이밍(Framing) 규칙이 필요하다.
네트워크 바이트 순서를 사용한다
여러 바이트로 된 정수는 시스템마다 바이트 순서(Byte order)가 다를 수 있다. 네트워크 프로토콜에서는 빅 엔디언(Big-endian)인 네트워크 바이트 순서를 사용한다. Python의 struct 형식 문자열에서 !가 이를 지정한다.
타임아웃을 설정한다
상대가 응답하지 않으면 블로킹 호출이 오래 대기할 수 있다. 외부 서비스와 통신하는 클라이언트는 연결과 읽기 작업에 타임아웃(Timeout)을 설정해야 한다.
입력 크기를 제한한다
길이 접두사를 그대로 신뢰하면 공격자가 큰 크기를 보내 과도한 메모리 할당을 유도할 수 있다. 헤더를 읽은 뒤 허용 가능한 최대 크기인지 검사해야 한다.
연결 종료를 정상 상태로 처리한다
recv가 빈 바이트열 b""을 반환하면 상대가 연결을 정상적으로 종료했다는 뜻이다. 일시적으로 데이터가 없다는 뜻이 아니다.
SO_REUSEADDR의 의미를 구분한다
SO_REUSEADDR은 종료 직후 같은 주소와 포트로 서버를 다시 실행할 때 도움이 된다. 이미 실행 중인 서버와 무조건 같은 포트를 공유하게 만드는 옵션은 아니다.
8. 핵심 정리
- 소켓(Socket)은 프로세스가 네트워크 스택을 사용하는 통신 종단점이다.
- TCP 소켓은 신뢰성 있는 바이트 스트림을 제공하지만 메시지 경계를 제공하지 않는다.
- UDP 소켓은 메시지 경계를 유지하지만 전달과 순서를 보장하지 않는다.
- TCP 서버의 기본 흐름은
socket → bind → listen → accept → recv/send → close다. send와recv는 일부 바이트만 처리할 수 있으므로 반복 처리와 프레이밍이 필요하다.- 실무에서는 타임아웃, 메시지 크기 제한, 연결 종료, 동시성, 자원 해제를 함께 설계한다.