macOS 환경에서 작성된 글입니다.
Socket이란?
소켓은 내부 프로세스간 통신(IPC) 또는 컴퓨터(Network) 간에 데이터를 주고 받기 위한 엔드포인트를 뜻한다.
데이터를 주고 받을 수 있게 해주는 소프트웨어 인터페이스로, 서로 다른 시스템 간에 데이터 통신을 위한 메커니즘을 제공한다.
쉽게 말하자면 컴퓨터의 안팎에서 프로세스끼리 통신하기 위해 필요한 게 소켓
이고,
물리적인 통신을 구축하기 위해 이더넷이 필요하듯이 소프트웨어적인 통신을 구축하기 위해선 소켓이 필요하다.
또한 소켓은 여러 환경에서 사용될 수 있도록 설계되어 있기 때문에 프로토콜에 독립적인 특성을 갖는다.
전화로 이해하는 Socket
- 전화기 구입(소켓 생성 - socket)
- 전화번호 할당(소켓에 IP 주소, 포트 등 할당 - bind)
- 케이블 연결 및 전화 기다림(연결 요청 대기 - listen)
- 전화벨이 울리면 전화를 받기(연결 요청 수락 - accept)
- 대화 주고받기(데이터 송수신 - read / write)
- 통화 종료(연결 끊기 - close)
Socket 생성하기
살짝 low하게 맛보기 위해 C언어로 예제를 확인해보자.
#include <sys/socket.h>
// C언어에서 소켓 생성 함수. 실패시 -1 리턴.
int socket(int domain, int type, int protocol);
소켓 생성엔 3가지 요소(domain, type, protocol)가 필요하며 각각의 목적은 다음과 같다.
Domain
생성할 소켓이 통신을 하기 위해 사용할 프로토콜 체계(PF, Protocol Family)를 의미한다.
- PF_INET : IPv4
- PF_INET6 : IPv6
- PF_LOCAL : Local에서 프로세스 간 통신(IPC)을 위한 UNIX 프로토콜
- PF_UNIX : PF_LOCAL과 동의어
- PF_PACKET : Low level socket을 위한 인터페이스
- PF_IPX, PF_NETLINK 등등..
Type
소켓이 데이터를 전송할 때 사용하는 전송 방식이다.
SOCK_STREAM
연결 지향형 소켓.
- 신뢰성 - 에러나 데이터의 손실 없이 무사히 전달된다. 데이터가 반드시 전달된다는 것을 보장받을 수 있다.
- 순차적인 바이트 기반 - 전송하는 순서대로 데이터가 전달된다.
- 전송되는 데이터의 경계(Boundary)가 존재하지 않는다.
두 번의 write 함수 호출을 통해 데이터를 전송했다 하더라도, 수신측 호스트의 버퍼가 넉넉하다면 한번의 read 함수 호출을 통해서 모든 데이터를 수신할 수 있다. 반대로 한번의 write 함수 호출을 통해 데이터가 전송되었다 하더라도, 서너 번의 read 함수 호출을 통해 데이터를 조금씩 나누어 수신할 수도 있다.
SOCK_DGRAM
비연결 지향형 소켓.
- 전송되는 순서에 상관없이 가장 빠른 전송을 지향한다.
- 전송되는 데이터는 손실될 수도 있고 에러가 발생할 수도 있다.
- 전송되는 데이터의 경계(Boundary)가 존재한다.
데이터를 전송하는 호스트가 세 번의 함수 호출을 통해서 데이터를 전송했다면, 수신하는 호스트도 반드시 세 번의 함수 호출을 거쳐야 데이터를 완전히 수신할 수 있게 된다. - 한번에 전송되는 데이터의 크기는 제한된다(나눠서 보내야 한다).
이외에도 SOCK_SEQPACKET, SOCK_RAW, SOCK_RDM 등이 있다.
Protocol
호스트 간 통신에 사용될 특정 프로토콜을 지정하기 위해 사용된다.
domain과 type만으로도 충분히 원하는 소켓의 정보를 전달할 수 있기 때문에 값으로 0을 넣어 줘도 알아서 우리가 원하는 소켓을 생성해 준다.
프로토콜 체계가 PF_INET(IPv4)인 경우 다음과 같은 값이 올 수 있다.
- IPPROTO_TCP : TCP를 기반으로 하는 소켓 생성
- IPPROTO_UDP : UDP를 기반으로 하는 소켓 생성
// tcp-and-udp.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);
int main(int argc, char **argv)
{
int tcp_socket;
int udp_socket;
tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
if(tcp_socket == -1)
error_handling("TCP 소켓 생성 실패");
udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
if(udp_socket == -1)
error_handling("UDP 소켓 생성 실패");
printf("생성된 TCP 소켓의 file descriptor : %d\n", tcp_socket);
printf("생성된 UDP 소켓의 file descriptor : %d\n", udp_socket);
close(tcp_socket);
close(udp_socket);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
(참고) 프로토콜 종류별 설정
종류 | domain | type | protocol |
---|---|---|---|
TCP | PF_INET (IPv4) | SOCK_STREAM | 0 |
TCP | PF_INET6 (IPv6) | SOCK_STREAM | 0 |
UDP | PF_INET (IPv4) | SOCK_DGRAM | 0 |
UDP | PF_INET6 (IPv6) | SOCK_DGRAM | 0 |
X.25 | PF_X25 | SOCK_STREAM | 0 |
Local | PF_UNIX | SOCK_STREAM | 0 |
Local | PF_LOCAL | SOCK_STREAM | 0 |
(참고) PF_* 와 AF_*
PF(Protocl Family)는 말그대로 프로토콜 체계를, AF(Address Family)는 주소 체계를 나타낸다.
과거에 BSD 소켓이 개발됐을 땐 하나의 프로토콜 체계 안에서 여러 주소 체계가 사용될 수 있을 것이라 예상하고, 주소 체계를 독립적으로 설정할 수 있게 만들었다. 그래서 코드 내에서도 그 개념이 명확히 인지되도록 프로토콜 체계
와 주소 체계
를 구분시켰다.
결과적으로 그런 일은 없었고(ㅎㅎ) 오늘날 프로토콜 체계 안에서 존재하는 주소 체계는 하나뿐이다. 때문에 실질적으로 PF와 AF의 구분은 무의미해졌다. 예를 들어, PF_INET과 AF_INET은 같은 상수값이기 때문에 프로토콜 체계를 나타낼 때 AF_INET을 사용해도 문제는 없다.
그럼 어떻게 사용하는 게 좋을까?
- 이 포스팅의 줄기가 됐던 교재에서는 설계 의도대로 구분짓는 편이 낫다고 한다.
그렇다고 해서 주소 정보 체계를 설정하는 부분에 PF_INET을 사용하는 것은 그리 좋은 습관은 아니다. 프로토콜 체계를 설정할 때는 PF_로 시작하는 상수를 사용하고 주소 체계를 설정하는 부분에서는 AF_로 시작하는 상수를 사용하자.
- 반면 리눅스 맨 페이지에서는 어디서든 AF를 사용하길 권장한다.
(이미 BSD 맨 페이지에서 프로토콜 체계와 주소 체계가 일반적으로 같다고 언급되기 때문)
The manifest constants used under 4.x BSD for protocol families are PF_UNIX, PF_INET, and so on, while AF_UNIX, AF_INET, and so on are used for address families. However, already the BSD man page promises:
"The protocol family generally is the same as the address family", and subsequent standards use AF_* everywhere.
개인적으로는 초기 설계 의도와는 달라진 결과가 있다면 그 결과에 맞춰 사용하는 게 더 낫다고 본다.
동일한 동작 결과가 나올 뿐더러 애초에 구분짓는 게 의미가 없어졌다면 AF_로 통일하는 편이 더 코드 컨벤션을 준수하기 쉬워지니까.
소켓 생성 함수(socket()
) 이외에도 IP 주소와 포트를 할당하는 함수(bind()
), 연결 요청을 대기하는 함수(listen()
), 연결 요청을 수락하는 함수(accept()
) 등도 있지만 node.js에서 확인해보는 게 목적이기 때문에 C언어 코드는 생략하고 바로 node.js 코드로 확인해보자.
node.js에서 소켓 통신
통신 예제의 국룰인 echo 기능을 구현해보자.
net 모듈
node.js에서 SOCK_STREAM은 net 모듈로 구현할 수 있다.
공식문서에서도 stream 기반이며 TCP나 IPC 서버를 생성하는 데 사용된다고 설명한다.
The node:net module provides an asynchronous network API for creating stream-based TCP or IPC servers (net.createServer()) and clients (net.createConnection()).
// server.js
import net from 'node:net';
const server = net.createServer(); // 생성 - socket()
server.on('connection', (socket) => { // 수락 - accept()
console.log('connected');
socket.on('data', (data) => { // 읽기 - read() / recv()
console.log(data.toString());
socket.write(data); // 쓰기 - write() / send()
}).on('close', () => {
console.log('disconnected');
});
}).listen(4000, () => { // 할당 및 대기 - bind(), listen()
console.log(netServer.address());
// { address: '::', family: 'IPv6', port: 4000 }
});
// client.js
import net from 'node:net';
const client = net.createConnection({ host: 'localhost', port: 4000 }) // 생성 및 연결 - socket(), connect()
client.on('connect', () => {
console.log('connected');
netClient.write('Hello'); // 쓰기 - write() / send()
})
.on('data', (data) => { // 읽기 - read() / recv()
console.log(data.toString());
netClient.end(); // 종료 - close()
})
.on('close', () => {
console.log('disconnected');
});
dgram 모듈
SOCK_DGRAM은 dgram 모듈로 구현할 수 있다.
공식문서에서는 dgram 모듈을 아래 문구로 정의한다.
The node:dgram module provides an implementation of UDP datagram sockets.
// server.js
import dgram from 'node:dgram';
const server = dgram
.createSocket('udp4') // 생성 - socket()
.on('message', (message, remoteInfo) => { // 수락 및 읽기 - accept(), read() / recv()
console.log(message.toString());
server.send(message, remoteInfo.port, remoteInfo.address); // 쓰기 - write() / send()
})
.on('listening', function () { // 대기 - listen()
console.log(server.address());
// { address: '0.0.0.0', family: 'IPv4', port: 4001 }
})
.on('close', () => {
console.log('socket closed');
})
.bind(4001); // 할당 - bind()
// client.js
import dgram from 'node:dgram';
const client = dgram
.createSocket('udp4') // 생성 - socket()
.on('connect', () => {
console.log('connected');
client.send('Hello'); // 쓰기 - write() / send()
})
.on('message', (message) => { // 읽기 - read() / recv()
console.log(message.toString());
client.close(); // 종료 - close()
})
.on('close', () => {
console.log('disconnected');
});
client.connect(4001, 'localhost'); // 연결 - connect()