🗓️ 2023. 08. 31
⏱️ 12

Socket

소통해요

macOS 환경에서 작성된 글입니다.

Socket이란?

소켓내부 프로세스간 통신(IPC) 또는 컴퓨터(Network) 간에 데이터를 주고 받기 위한 엔드포인트를 뜻한다.
데이터를 주고 받을 수 있게 해주는 소프트웨어 인터페이스로, 서로 다른 시스템 간에 데이터 통신을 위한 메커니즘을 제공한다.

쉽게 말하자면 컴퓨터의 안팎에서 프로세스끼리 통신하기 위해 필요한 게 소켓이고,
물리적인 통신을 구축하기 위해 이더넷이 필요하듯이 소프트웨어적인 통신을 구축하기 위해선 소켓이 필요하다.

또한 소켓은 여러 환경에서 사용될 수 있도록 설계되어 있기 때문에 프로토콜에 독립적인 특성을 갖는다.

전화로 이해하는 Socket

  1. 전화기 구입(소켓 생성 - socket)
  2. 전화번호 할당(소켓에 IP 주소, 포트 등 할당 - bind)
  3. 케이블 연결 및 전화 기다림(연결 요청 대기 - listen)
  4. 전화벨이 울리면 전화를 받기(연결 요청 수락 - accept)
  5. 대화 주고받기(데이터 송수신 - read / write)
  6. 통화 종료(연결 끊기 - 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);
}

(참고) 프로토콜 종류별 설정

종류domaintypeprotocol
TCPPF_INET (IPv4)SOCK_STREAM0
TCPPF_INET6 (IPv6)SOCK_STREAM0
UDPPF_INET (IPv4)SOCK_DGRAM0
UDPPF_INET6 (IPv6)SOCK_DGRAM0
X.25PF_X25SOCK_STREAM0
LocalPF_UNIXSOCK_STREAM0
LocalPF_LOCALSOCK_STREAM0

(참고) PF_* 와 AF_*

PF(Protocl Family)는 말그대로 프로토콜 체계를, AF(Address Family)는 주소 체계를 나타낸다.

과거에 BSD 소켓이 개발됐을 땐 하나의 프로토콜 체계 안에서 여러 주소 체계가 사용될 수 있을 것이라 예상하고, 주소 체계를 독립적으로 설정할 수 있게 만들었다. 그래서 코드 내에서도 그 개념이 명확히 인지되도록 프로토콜 체계주소 체계를 구분시켰다.

결과적으로 그런 일은 없었고(ㅎㅎ) 오늘날 프로토콜 체계 안에서 존재하는 주소 체계는 하나뿐이다. 때문에 실질적으로 PF와 AF의 구분은 무의미해졌다. 예를 들어, PF_INET과 AF_INET은 같은 상수값이기 때문에 프로토콜 체계를 나타낼 때 AF_INET을 사용해도 문제는 없다.

그럼 어떻게 사용하는 게 좋을까?

  1. 이 포스팅의 줄기가 됐던 교재에서는 설계 의도대로 구분짓는 편이 낫다고 한다.

그렇다고 해서 주소 정보 체계를 설정하는 부분에 PF_INET을 사용하는 것은 그리 좋은 습관은 아니다. 프로토콜 체계를 설정할 때는 PF_로 시작하는 상수를 사용하고 주소 체계를 설정하는 부분에서는 AF_로 시작하는 상수를 사용하자.

  1. 반면 리눅스 맨 페이지에서는 어디서든 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()

참고

돌아가기
© 2024 VERYCOSY.