Story 4. 프로토콜 스택에 메시지 송신을 의뢰한다.

Posted by yunki kim on July 13, 2022

데이터 송/수신 동작의 개요

IP 주소를 찾으면 엑세스 대상 웹 서버에 메시지를 송신하기 위해 OS 내부에 있는 프로토콜 스택에 의뢰한다. 이 때, 송수신 하는 데이터는 디지털 데이터이며 웹 뿐만 아니라 네트워크를 사용하는 모든 애플리케이션의 공통 동작이다. DNS를 통해 IP를 찾는 것 또한 모든 네트워크에 연결된 애플리케이션에 공통 동작이다.
데이터를 송/수신하는 과정 역시 소켓 라이브러리를 사용한다. 이 과정은 복수의 함수를 결정된 순번대로 호출해야 하므로 조금 복잡하다.
이 과정을 도식화하면 다음과 같다.

  위 그림에 존재하는, 클라이언트의 소켓과 서버 소켓을 연결하는 파이프를 만들기 위해선 양자를 파이프로 연결하는 동작이 필요하다. 이 파이프에 존재하는 두 출구를 소켓이라 한다. 서버의 소켓이 만들어 지고 클라이언트가 파이프를 연결하길 기다린다. 그러면 클라이언트에서도 소켓을 만들고 소켓에서 파이프를 늘려 서버의 소켓에 연결한다. 그 후, 데이터 송/수신 동작이 시작된다.
  송/수신 동작이 끝나면 파이프가 분리된다. 이때는 파이프 연결과 달리 어느쪽에서도 파이프를 끊어도 된다.
  데이터 송/수신 동작을 요약하면 다음과 같다.
    1. 소켓을 만든다(소켓 작성 단계).
    2. 서버측의 소켓에 파이프를 연결한다(접속 단계).
    3. 데이터를 송/수신 한다(송/수신 단계).
    4. 파이프를 분리하고 소켓을 말소한다(연결 끊기 단계).
  위 네 가지 동작은 OS 내분의 프로토콜 스택에서 실행된다. 즉, 애플리케이션은 자체적으로 파이프를 연결개 데이터를 쏟아 붇지 않고 프로토콜 스택에 의뢰한다. 소켓에 존재하는 데이터 송/수신용 라이브러리는 애플리케이션에서 호출한 내용을 그대로 프로토콜 스택에 전달하는 역할만 한다.

소켓의 작성 단계

  데이터 송/수신을 의뢰하는 애플리케이션 프로그램의 동작 흐름은 다음과 같다.

  동작 1. 준비에서는 socket()을 호출해서 socket 내부로 제어가 넘어간다. 그 뒤 소켓을 만드는 동작이 실행되고 끝나면 애플리케이션으로 제어가 돌아온다. 소켓이 생성되면 디스크립터가 반환되고 애플리케이션은 이를 메모리에 기록한다. 디스크립터는 소켓을 식별하기 위해 사용된다.

파이프를 연결하는 접속 단계

  이제 만든 소켓을 서버측의 소켓에 접속하게 하기 위해 connect()을 사용해 프로토콜 스택에 의뢰한다. 이 때, connect()는 디스크립터, 서버의 IP와 port를 인자로 받는다. connect()는 인자로 전달받은 디스크립터를 프로토콜 스택에 통지한다. 그러면 프로토콜 스택은 디스크립터를 통해 서버측 소켓에 접속할 소켓을 특정짓는다. IP는 DNS로 부터 조회하며 송/수신 상대의 IP이다. Port 역시 송/수신 상대의 IP이다. IP와 Port를 알아야 접속할 컴퓨터와 소켓을 특정지을 수 있다.

  IP와 Port가 아니라 접속 측의 디스크립터를 사용하면 좋지 않냐는 생각이 들 수 있다. 하지만 디스크립터는 컴퓨터 한 대 내에서 소켓을 여는 애플리케이션이 사용하도록 고안된 것이다. 따라서 클라이언트는 서버의 디스크립터를 알 수 없다. 정리하면 포트는 접속 상대 측의 소켓을 식별하는 용도고, 디스크립터는 로컬에서 소켓을 식별하는 용도다.

  반대로 서버가 클라이언트으 소켓을 식별할 필요도 있다. 클라이언트 port는 소켓을 만들 때 프로토콜 스택이 자동으로 할당한다. 그러고 프로토콜 스택이 접속 동작을 실행할 때 서버에 통지한다.

메시지를 주고받는 송/수신 단계

송신 과정:

  1. 애플리케이션은 송싱 데이터를 메모리에 준비한다. HTTP 리퀘스트 메시지가 여기에 해당한다.

  2. write를 호출해 디스크립터와 송신 데이터를 지정한다. (write(<디스크립터>, <송신 데이터>, <송신 데이터 길이>))

  3. 프로토콜 스택이 송신 데이터를 서버에게 송신한다. 소켓에는 연결된 상대사 기록되 있으므로 디스크립터로 소켓을 지정하면 연결된 상대가 판명되 데이터를 송신한다.

  4. 서버는 수신동작을 해서 받은 데이터의 내용을 조사하고 처리를 해서 응답 메시지를 응답한다.

수신 과정:

  1. Socket 라이브러리에서 read()를 사용해 프로토콜 스택에 수신 동작을 의뢰한다.

  2. 수신한 응답 메시지를 저장하기 위한 메모리 영역을 지정한다. 이 영역을 수신 버퍼라 한다. 이 메모리를 애플리케이션 내부에 마련되 있다.

  3. 응답 메시지가 클라이언트로 오면, read가 이를 수신 버퍼에 저장한다.

  4. 저장한 뒤 메시지를 애플리케이션에 건네준다.

연결 끊기 단계에서 송/수신이 종료된다

  데이터를 송/수신하는 동작이 끝나면 Socket의 close()를 호출해 연결을 끊는다. 그러면 소켓 사이를 연결한 파이프가 분리되고 소켓도 말소된다. 상세한 과정은 다음과 같다.

  1. HTTP는 응답 메시지 송신을 완료했을 때 웹 서버가 연결 끊기 동작을 실행한다.

  2. 이것이 클라이언트에 전달되 클라이언트 소켓도 연결 끊기 단계로 들어간다.

  3. 브라우저의 read()가 송/수신이 완료되 연결이 끊겼다는 사실을 브라우저에 통지한다.

  4. 브라우저가 close()를 호출해 연결 끊기 단계로 들어간다.

  HTTP는 위와 같이 어떤 데이터 하나를 송/수신 할 때 마다 접속, request message 송신, response message 수신, 연결 끊기 과정을 반복한다. 따라서 복수의 데이터를 전송해야 하는 과정에서 비효율이 발생한다. HTTP 1.1 부터는 이를 개선하기 위해 한번 접속하고 연결을 끊지 않고 복수의 request와 response를 주고 받는 방법이 존재한다. 이 경우 요청할 데이터가 없어진 상태에서 브라우저가 연결 끊기 동작으로 들어간다.

TCP/IP 송수신 과정 구현해보기

  위에서 언급한 과정을 실제 코드로 작성하면 다음과 같다. 아래코드는 클라이언트가 서버에 접속했을 떄 "hello world!"를 응답하는 간단한 예시다. 해당 예시를 실행해 보기 위해선 다음과 같은 과정이 필요하다.

1
2
3
4
gcc -g client.c -o client
gcc -g server.c -o server
./server {server_port_number}
./client {server_ip} {server_port_number}
cs

클라이언트 코드:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
 
typedef struct sockaddr_in SocketAddressInternet;
typedef struct sockaddr SocketAddress;
 
void errorHandling(char* message);
 
int main(int argc, char* argv[]) {
    int socketDescriptor;
    SocketAddressInternet serverAddress;
    char message[30];
    int stringLength;
 
    if (argc != 3) {
        printf("Usage: %s <IP> <port>\n", argv[0]);
        exit(1);
    }
 
    const char* serverIP = argv[1];
    const char* serverPort = argv[2];
 
    // 소켓을 만든다.
    // socket(<IPv4>, <소켓의 타입>, <통신에 사용되는 프로토콜>);
    // PF_INET은 IPv4 인터넷 프로토콜 체계를 의미
    // SOCK_STREAM은 TCP 소켓을 의미
    socketDescriptor = socket(PF_INET, SOCK_STREAM, 0);
    if (socketDescriptor == -1) {
        errorHandling("socket() error");
    }
 
    // 서버의 IP와 Port
    memset(&serverAddress, 0sizeof(serverAddress));
    // AF_INET은 IPv4 주소 체계를 의미
    serverAddress.sin_family = AF_INET;
    serverAddress.sin_addr.s_addr = inet_addr(serverIP);
    serverAddress.sin_port = htons(atoi(serverPort));
 
    // 서버측 소켓에 접속
    // connect(<디스크립터>, <서버의 IP 주소와 port 번호>, sockaddr_in의 크기)
    if (connect(socketDescriptor, (SocketAddress*)& serverAddress, sizeof(serverAddress)) == -1) {
        errorHandling("conenct() error");
    }
 
    // read(<디스크립터>, <수신 버퍼>, <버퍼 사이즈>)
    // read() 로 프로토콜 스택에 수신 동작을 의뢰한다.
    // 수신한 메시지를 저장한다.
    stringLength = read(socketDescriptor, message, sizeof(message) - 1);
    if (stringLength == -1) {
        errorHandling("connect() error!");
    }
 
    printf("Message from server: %s \n", message);
    // close(<디스크립터>)
    // close() 를 호출해 연결을 끊는다.
    close(socketDescriptor);
    return 0;
}
 
void errorHandling(char* message) {
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
 
cs

서버 코드:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
 
typedef struct sockaddr_in socketAddressInternet;
typedef struct sockaddr socketAddress;
typedef socklen_t socketArgLength;
 
void errorHandling(char* message);
 
int main(int argc, char* argv[]) {
    int serverSocketDescriptor;
    int clientSocketDescriptor;
    socketAddressInternet serverAddress;
    socketAddressInternet clientAddress;
    socketArgLength clientAddressSize;
 
    char message[] = "Hello world!";
 
    if (argc != 2) {
        printf("Usage: %s <port> \n", argv[0]);
        exit(1);
    }
    char* serverPort = argv[1];
 
    // 서버측 소켓을 만든다
    // socket(<IPv4>, <소켓의 타입>, <통신에 사용되는 프로토콜>);
    // PF_INET은 IPv4 인터넷 프로토콜 체계를 의미
    // SOCK_STREAM은 TCP 소켓을 의미
    serverSocketDescriptor = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (serverSocketDescriptor == -1) {
        errorHandling("socket() error");
    }
    memset(&serverAddress, 0sizeof(serverAddress));
    serverAddress.sin_family = AF_INET;
    serverAddress.sin_addr.s_addr = htonl(INADDR_ANY);
    serverAddress.sin_port = htons(atoi(serverPort));
 
    // 소켓 주소 할당
    if (bind(serverSocketDescriptor, (socketAddress*&serverAddress, sizeof(serverAddress)) == -1) {
        errorHandling("bind() error");
    }
 
    // 연결 요청 대기 상태
    // listen(<디스크립터>, <backlog>)
    // backlog는 연결 요청 대기 큐이다.
    // backlog가 5이면 클라이언트 요청을 5개 까지 대기시킬 수 있다.
    if (listen(serverSocketDescriptor, 5== -1) {
        errorHandling("listen() error");
    }
 
    clientAddressSize = sizeof(clientAddress);
    // 클라이언트의 연결 요청을 수락한다.
    // accept(<서버 디스크립터>, <클라이언트의 IP 주소와 port 번호>, sockaddr_in의 크기)
    clientSocketDescriptor = accept(serverSocketDescriptor, (socketAddress*)& clientAddress, &clientAddressSize);
    if (clientSocketDescriptor == -1) {
        errorHandling("accept() error");
    }
 
    // write() 를 호출해 디스크립터와 송신 데이터를 지정한다.
    // write(<디스크립터>, <송신 데이터>, <송신 데이터 길이>);
    write(clientSocketDescriptor, message, sizeof(message));
 
    // close(<디스크립터>)
    // close() 를 호출해 연결을 끊는다.
    close(clientSocketDescriptor);
    close(serverSocketDescriptor);
    return 0;
}
 
void errorHandling(char* message) {
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
 
cs

 

출처 - 성공과 실패를 결정하는 1%의 네트워크 원리