Network

소켓 프로그래밍

dev-rootable 2024. 7. 8. 15:11

🤔 소켓이란

 

네트워크에서 데이터를 송수신할 수 있도록 네트워크 환경에 연결할 수 있게 만들어진 연결부

 

네트워크에 연결하기 위한 소켓은 정해진 규약, 즉 통신을 위한 프로토콜에 맞게 만들어져야 한다. 보통 OSI 7 계층 중 4 계층인 TCP 상에서 동작하는 소켓을 주로 사용하며, 이를 TCP 소켓 또는 TCP/IP 소켓이라고 부른다. 마찬가지로 UDP에서 동작하는 소켓을 UDP 소켓이라고 부른다.

 

소켓은 크게 프로토콜, IP 주소, 포트로 정의된다.

 

🚩 용어 정리

프로토콜(Protocol)
컴퓨터 사이에서 메시지를 주고 받는 데 필요한 양식, 약속이나 규약

IP 주소(IP Address)
각 장치(호스트)를 식별하기 위한 고유 주소. 송신자와 수신자를 식별

포트(Port)
IP 주소를 통해 도착한 호스트에서 어떤 프로세스와 통신할지에 대한 정보. 즉, 프로세스를 구분하는 번호

 

🔌 소켓 프로그래밍

 

소켓 프로그래밍 개요

 

데이터를 주고 받기 위해서는 소켓의 연결 과정이 선행되어야 하고, 그 과정에서 연결 요청과 수락이 각각 클라이언트 소켓과 서버 소켓의 역할이다.

 

클라이언트 소켓IP 주소와 포트 번호를 통해 서버 소켓에게 연결을 시도하고, 서버 소켓어떤 연결 요청(포트 번호로 식별)을 받아들일지 미리 시스템에 등록하여 요청이 수신되었을 때 해당 요청을 처리한다.

 

소켓 연결이 완료된 후 클라이언트 소켓과 서버 소켓은 데이터를 주고받는데, 직접 데이터를 주고받는다고 것이 아니라 서버 소켓은 클라이언트 소켓의 연결 요청을 받아들이는 역할만 수행할 뿐이다. 직접적인 데이터 송수신은 서버 소켓의 연결 요청 수락 결과로 만들어지는 새로운 소켓을 통해 처리된다.

 

💬 Creating a socket

 

데이터를 송수신하기 위한 통로(소켓) 생성

 

소켓 통신을 하기 위해서는 먼저 소켓을 생성해야 한다.

 

#incldue <sys/socket.h>

int socket(int domain, int type, int protocol);

 

위와 같은 socket 함수를 통해 소켓을 생성한다.

 

🚩 socket 함수 파라미터

domain
통신에 사용되는 protocol family를 지정한다.

protocol family
소켓을 생성할 때 이 소켓이 어떤 프로토콜을 사용해 통신을 할지 정하는 정보

출처: https://seongmok.com/43

type
SOCK_STREAM은 TCP 통신 체계에서 사용되고, SOCK_DGRAM은 UDP 통신 체계에서 사용된다.
두 가지 타입을 가장 많이 사용하며, 각 방식은 해당 통신 체계의 성질과 호환된다.

protocol
해당 소켓에서 사용되는 별도의 protocol이 필요할 경우 protocol 파라미터로 지정해 주면 된다. 필요하지 않을 경우 보통 0을 전달한다.

 

socket()은 system call로 반환 값은 socket descriptor이다. 이때 소켓 생성에 실패하면 -1을 반환한다.

 

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>

int sockfd;

if ((sockfd = socket(PF_INET, SOCK_STREAM, 0)) < 0) {
    //print "socket error + the error message"
    perror("socket error");
    exit(1);
}

 

최초 소켓이 생성되는 시점에는 연결 대상에 대한 어떠한 정보도 담겨 있지 않다. 따라서 연결 대상(IP:Port)을 지정하고 연결 요청을 전달하기 위해서는 connect() API를 호출해야 한다.

 

💬 Binding the local address ( bind() )

 

소켓에 주소를 할당하기 위한 시스템 콜

 

시스템에서 네트워크 관련 프로세스가 TCP 또는 UDP와 같은 프로토콜을 사용한다면 각 소켓은 시스템이 관리하는 포트(0 ~ 65535) 중 하나의 포트 번호를 사용하게 된다. 여기서 서로 다른 소켓이 같은 포트 번호를 사용하는 일이 생길 수 있어 운영체제는 내부적으로 포트 번호와 소켓 연결 정보를 관리한다. 그리고 bind() API는 해당 소켓이 지정된 포트 번호를 사용할 것이라는 것을 운영체제에 요청하는 역할을 한다. 만약 지정된 포트 번호를 다른 소켓이 사용하고 있다면 bind() API는 에러를 리턴한다.

 

서버 소켓은 고정된 포트 번호를 사용하고 그 포트 번호로 클라이언트의 연결 요청을 받아들인다. 그래서 운영체제가 특정 포트 번호를 서버 소켓이 사용하도록 만들기 위해 소켓과 포트 번호를 결합해야 하는데 이 때 사용하는 API가 bind()인 것이다.

 

✅ bind API

 

int bind(int sockfd, struct sockaddr *addr, int addr_len)

 

🔎 파라미터 설명

int sockfd: socket() 결과로 반환된 socket descriptor
struct sockaddr *addr: socket의 구조체 포인터 (자신의 address + port number)
int addr_len: 구조체의 길이

 

성공 시 0을 리턴, 실패 시 -1을 리턴

 

✅ 소켓 구조체 (sockaddr)

 

#define <netinet/in.h>

struct sockaddr {
    u_char sa_len; //length : used in kernel
    u_short sa_family; //address family
    char sa_data[14]; //address
}

 

✅ sockaddr_in

 

sa_data [14]를 조금 더 세분화해서 나눈 sockaddr_in

 

//<netinet/in.h> 헤더 파일에 정의되어 있음

struct sockaddr_in {
    u_char sin_len; //length
    u_short sin_family; //AF_INET
    u_short sin_port; //port number (2 byte)
    struct in_addr sin_addr; //IP address (4 byte)
    char sin_zero[8]; //unused (8byte), fill the zero
}

struct in_addr {
    u_long s_addr; //32 bit IP address
}

 

출처: https://seongmok.com/43

 

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MYPORT 50000

int sockfd;
struct sockaddr_in my_addr; //socket 구조체 선언

if ((sockfd = socket(PF_INET, SOCK_STREAM, 0)) < 0) { //소켓 생성
    //print "socket error + the error message"
    perror("socket error");
    exit(1);
}

memset(&my_addr, 0, sizeof(my_addr)); //socket 구조체 초기화

my_addr.sin_family = AF_INET; //address family(PF_INET과 동일)

/*
자신의 포트 번호를 htons를 통해 변환
htons는 host에서 사용하는 number를 network에서 사용가능하도록 short하게 변환함
*/
my_addr.sin_port = htons(MYPORT);

/*
INADDR_ANY는 현재 나의 주소 반환
htonl은 host에서 사용하는 주소를 network에서 사용가능하도록 long하게 변환함
*/
my_addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(sockfd, (struct sockaddr *)&my_addr, sizeof(my_addr)) < 0) {
    perror("bind error");
    exit(1);
}

 

🔎 htonl(), htons(), ntohl(), ntohs()

htons() - Host to Network Short
htonl() - Host to Network Long
ntohs() - Network to Host Short
ntohl() - Network to Host Long

소켓을 통해 다른 기종 간에 데이터를 송수신할 때, Host System의 Byte Order에 맞게 데이터를 변환해 주기 위해 사용된다. 반드시 네트워크에 보내기 전에 네트워크 바이트 순서로 바꿔서 보내야 한다.

sin_port, sin_addr은 네트워크 바이트 순서로 기록하지만 sin_family는 그렇지 않다. sin_port와 sin_addr은 캡슐화되어 네트워크로 전송되어야 하는 변수인 것이다. 따라서 Network-Byte-Order(Big Endian)이어야 한다. 반면, sin_family는 시스템 내부에서 커널에 의해서만 사용되는 변수이며 네트워크로 전송되지 않으므로 Host-Byte Order(Little Endian)로 기록되어야 하는 것이다.

htonl 함수
32 bit Little Endian ➡ Big Endian (in TCP/IP)로 변환하는 함수이다.
sockaddr_in 구조체에 저장되기 전에 32 bit로 처리되는 IP Address를 Big Endian으로 변환하는데 주로 사용된다.

htons 함수
32 bit Little Endian ➡ Big Endian (in TCP/IP) 로 변환하는 함수이다.
sockaddr_in 구조체에 저장되기 전에 32 bit로 처리되는 Port Number를 Big Endian으로 변환하는데 주로 사용된다.

 

💬 Name-to-Address Conversion

 

INADDR_ANY와 같이 주소를 바로 넘겨줄 수 있지만, 이름만 알고 있을 때 그 이름을 주소로 변경하는 과정이 필요하다. 또한 반대로 주소를 호스트 이름으로 변경할 수도 있다.

 

  • gethostbyname(): hostname ➡ address
  • gethostbyaddr(): address ➡ hostname

 

💬 IP Address Manipulation

 

위 함수를 통해 hostname을 binary한 주소로 바꿨다면 우리가 알아볼 수 있게 변환할 수 있어야 할 것이다. 이를 Dotted decimal이라고 한다. (ex. 11111111.00000000.00000000.00000001 ➡ 255.0.0.1)

 

char *inet_ntoa(struct in_addr address); //IP address -> dotted decimal

/*
dotted decimal -> IP address (return 0 if error)
req: *cp <- dotted decimal
res: *inp <- IP address
*/
char inet_aton(const char *cp, struct in_addr *inp);

u_long inet_addr(char *dottedAddress); //dotted decimal -> directly return IP address (return -1 if error)

 

💬 connect

 

서버 소켓으로 연결 요청

 

connect() API는 IP 주소와 포트 번호로 식별되는 대상으로 연결 요청을 보낸다. connect() API는 Block 방식으로 동작하기 때문에 연결 요청에 대한 결과가 결정되기 전에는 실행이 끝나지 않는다.

 

#include <sys/socket.h>

int connect(int sockfd, struct sockaddr *addr, int addr_len);

 

성공 시 0, 실패 시 -1을 리턴한다.

 

3-way-handshaking 과정을 통한 커넥션을 요청하기 때문에 TCP에 사용된다.

 

아주 가끔 UDP에도 사용된다고 한다.
sendto() 함수를 사용할 때 목적지 정보를 매번 넣기 번거로워 최초 connect()를 하여 얻은 정보를 계속 사용한다. 이때 실제 데이터 전송은 없다.

 

🔎 소켓 기술자(socket descriptor)

UNIX에서 파일을 열면(open)하면 int 타입의 정수를 리턴하는데 이를 파일 기술자(file descriptor)라고 하며, 파일 기술자를 통해 open된 파일에 접근할 수 있다. 즉, 해당 파일에 대한 포인터를 가지는 것이다.

출처: http://jkkang.net/unix/netprg/chap2/net2_1.html

파일 기술자는 기술자 테이블의 index 번호다. 기술자 테이블이란 현재 open되어 있는 파일의 정보들로 구성된 구조체를 가리키는 포인터들로 구성된 테이블이다.

프로그램에서 소켓을 개설하면 파일 기술자와 똑같은 기능을 하는 소켓 기술자가 리턴된다.

 

API 호출이 성공하면 send() / recv() API를 통해 데이터를 주고받을 수 있다.

 

#include <string.h>
#include <sys/types.h>
#include <netinet/in.h>
#define DEST_IP "10.12.110.57"
#define DEST_PORT 23

int sockfd;
struct sockaddr_in dest_addr;
if ((sockfd = socket(PF_INET, SOCK_STREAM, 0)) < 0) {
    /* error */
}

memset(&dest_addr, 0, sizeof(dest_addr)); //init memory to 0
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(DEST_PORT);
dest_addr.sin_addr.s_addr = inet_addr(DEST_IP);

if(connect(sockfd, (struct sockaddr *)&dest_addr, sizeof(dest_addr)) < 0) {
    close(sockfd);
    return -1;
}

 

💬 Listen

 

클라이언트 연결 요청 대기

 

서버 소켓에 포트 번호를 결합(bind)하고 나면, 서버 소켓을 통해 클라이언트의 연결 요청 수신을 기다리게 되는데 이 역할을 listen() API가 수행한다. 연결 요청이 수신되면 대기 상태를 종료하고 리턴한다.

 

int listen(int sd, int backlog);

 

  • sd: socket descriptor
  • backlog: connection 할 때 Q에 몇 개까지의 요청을 pending 상태로 놓을 것인지에 대한 사이즈 (usually 5)

 

성공 시 0을, 실패 시 -1 리턴

 

연결 요청을 기다리는 함수이기 때문에 TCP에만 사용된다.

 

listen() API 가 성공하더라도 요청 성공 또는 실패에 대한 정보만 있고 요청에 대한 정보는 들어 있지 않다. 대신 클라이언트 연결 요청에 대한 정보는 시스템 내부적으로 관리되는 Queue에 쌓이게 되는데, 이 시점에서 클라이언트와의 연결은 아직 완전히 연결되지 않은 대기 상태이다. 대기 중인 연결 요청을 Queue로부터 꺼내와서 연결을 완료하기 위해서는 accept() API를 호출해야 한다.

 

💬 Accept

 

최종 연결 요청을 받아들이는 역할

 

연결 요청을 Q로부터 꺼내와서 소켓 간 연결을 수립하는데, 이 때 데이터 통신을 위해 연결되는 소켓은 앞 과정에서 사용한 소켓이 아니라 accept() API 내부에서 새로 만들어진 소켓이다.

 

새로 만들어진 소켓과 포트 번호를 바인딩하고 클라이언트의 요청을 대기하기 위해 생성된 요청 대기 큐에 쌓여 있는 첫 번째 연결 요청이 매핑된다.

 

여기까지가 서버 소켓의 역할이고, 서버 소켓의 남은 일은 다른 연결 요청을 처리하기 위해 다시 대기(listen)하거나 서버 소켓을 닫는(close) 것뿐이다.

 

int accept(int sd, struct sockaddr *addr, int *addrlen);

 

성공 시 새로운 socket descriptor 반환, 실패 시 -1 반환

 

  • sd: 생성된 소켓의 식별 번호
  • addr: accept 성공 시, 연결된 클라이언트의 주소 정보가 담기는 구조체
  • addrlen: sockaddr 구조체 크기

 

반환 값은 socket 함수로 받은 듣기 소켓과는 전혀 다른 별개의 소켓이다.

 

  • 듣기 소켓: 연결 요청을 확인하는(듣는) 역할만 수행, 연결 요청을 받으면 즉시 수신 대기열로 넘겨 다음 요청을 대기
  • 연결 소켓: 실제 클라이언트와 통신

 

즉, 연결을 받아들이는 과정과 통신하는 과정이 분리된다. 따라서, 듣기 소켓과 연결소켓의 구분을 통해, 통신하는 중에도 다른 클라이언트의 연결 요청을 받아들일 수 있다.

 

이를 구현하기 위해 주로 멀티 프로세스나 멀티 스레딩 프로그래밍이 사용된다.

 

출처: https://codingfarm.tistory.com/538

 

🔎< socket() ~ accept()> server code

#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MYPORT 50000

int main()
{
    int sockfd;
    struct sockaddr_in server_addr, client_addr;
    int sin_size;
    
    sockfd = socket(PF_INET, SOCK_STREAM, 0); //socket()
    
    //setting server_addr
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(MYPORT);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    
    bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)); //bind()
    
    listen(sockfd, 5); //listen()
    
    sin_size = sizeof(client_addr);
    
    //change a socket
    new_fd = accept(sockfd, (struct sockaddr *)&client_addr, &sin_size); //accept()
    return 0;
}​

 

💬 데이터 송수신 ( send() / recv() ) - TCP

 

연결된 소켓을 통해 데이터를 보낼 때는 send(), 데이터를 받을 때는 recv() API를 사용한다. 두 API도 Block 방식으로 동작한다. 그래서 0을 리턴하면 상대방 측에서 커넥션을 close 한 것이다.

 

int send(int sockfd, const void *msg, int len, int flag)

 

write()와 동일 (flag는 보통 0으로 쓴다.)

 

성공 시 writing 한 바이트 수를 리턴, 실패 시 -1 리턴

 

int recv(int sockfd, void *buf, int len, unsigned int flag)

 

read()와 동일 (flag는 보통 0으로 쓴다.)

 

성공 시 읽은 바이트 수를 리턴, 실패 시 -1 리턴

 

read ↔ recv, write ↔ send 사이에 차이는 없다. 자신의 OS에서 지원해 주는 함수를 사용하면 된다.

 

send()의 경우 데이터를 보내는 주체가 자기 자신이기 때문에 얼마만큼의 데이터를 보낼 것인지를 알 수 있다. 하지만 데이터를 수신하는 경우 통신 대상이 언제, 어떤 데이터를 보낼 것인지를 특정할 수 없기 때문에 recv() API가 한번 실행되면 언제 끝날지 모르는 상태가 된다.

 

따라서 데이터 수신을 위한 recv() API는 별도의 스레드에서 실행한다. 소켓의 생성과 연결이 완료된 후, 새로운 스레드를 하나 만든 다음 그곳에서 recv()를 실행하고 데이터가 수신되길 기다리는 것이다.

 

💬 close and shutdown

 

데이터 송수신이 완료되고 더 이상 송수신이 필요 없게 되면 close() API를 통해 소켓을 닫는다.

 

소켓 연결이 종료된 후 다시 데이터를 주고받고 싶다면 또 한 번의 소켓 생성과 연결 과정을 통해 소켓 데이터를 송수신할 수 있는 상태가 되어야 한다.

 

int close(int sockfd)

 

TCP에서 클라이언트가 연결을 끊고 싶을 때 사용한다.

 

클라이언트가 close() 상태일 때

  • 서버에서 recv() ➡ return 0
  • 서버에서 send() ➡ return -1 (error)
    • Client error signal: SIGPIPE
    • Server error signal: EPIPE

 

int shutdown(int sockfd, int how)

 

연결을 끊는 것이 아닌 보내거나 받는 기능만 닫는 것이다. 이는 how에서 판단한다.

 

how에 올 수 있는 값은 다음과 같다.

 

출처: https://seongmok.com/43

 

close()와 SHUT_RDWR의 차이는 소켓의 생존 여부다. close는 소켓 정보가 소멸하지만 SHUT_RDWR는 소켓 정보가 유지된다.

 

References:

 

https://velog.io/@dltmdrl1244/%EC%86%8C%EC%BC%93%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D1-%EC%86%8C%EC%BC%93%EC%9D%98-%EC%9D%B4%ED%95%B4%EC%99%80-%EA%B8%B0%EB%B3%B8-%EB%BC%88%EB%8C%80#%EC%86%8C%EC%BC%93%EC%9D%98-%EC%A3%BC%EC%86%8C-%ED%95%A0%EB%8B%B9-%EB%B0%8F-%EC%97%B0%EA%B2%B0--bind-

 

https://velog.io/@dogfootbirdfoot/SocketProgramming#4-2-%EC%84%9C%EB%B2%84-%EC%86%8C%EC%BC%93-%EB%B0%94%EC%9D%B8%EB%94%A9--bind-

 

https://seongmok.com/43

 

http://jkkang.net/unix/netprg/chap2/net2_1.html

 

https://blog.naver.com/lovinghc/30031089847

 

https://codingfarm.tistory.com/538