안그래보이지만 개발자/백엔드

[C] 소켓 통신과 파일 디스크립터 및 지원 함수

자네트 2023. 12. 13. 14:12
반응형

 

 

파일 디스크립터

 

C 소켓 프로그래밍에서 파일 디스크립터(File Descriptor)는 네트워크 소켓을 나타내는데 사용되는 정수 값입니다. 파일 디스크립터는 커널이 열린 파일과 소켓을 추적하는 데 사용되며, 소켓 통신에서 데이터를 읽고 쓰는 데에도 활용됩니다.

 

기본적으로 파일 디스크립터는 정수로 표현되며, 0, 1, 2는 표준 입력(stdin), 표준 출력(stdout), 표준 에러(stderr)를 나타냅니다. 이러한 파일 디스크립터는 각각 파일 또는 소켓에 대한 작업을 수행하기 위해 사용됩니다.

 

파일 디스크립터는 주로 다음과 같은 상황에서 정의됩니다.

  1. 프로세스 실행 중에 시스템이 할당:
    • 프로세스가 실행되면 운영 체제는 프로세스에 대해 여러 자원을 할당하게 됩니다. 이 중에 하나가 파일 디스크립터입니다. 특히, 표준 입력(stdin), 표준 출력(stdout), 표준 에러(stderr)에 대한 파일 디스크립터는 프로세스가 시작될 때 자동으로 할당됩니다.
  2. 파일을 열 때:
    • 파일을 열면 해당 파일에 대한 파일 디스크립터가 반환됩니다. 이 파일 디스크립터는 파일을 나타내며, 프로그램은 이 파일 디스크립터를 사용하여 파일과 상호 작용합니다.
  3. 소켓을 생성할 때:
    • 네트워크 프로그래밍에서 소켓을 생성하면, 해당 소켓에 대한 파일 디스크립터가 반환됩니다. 이 파일 디스크립터를 사용하여 프로그램은 네트워크를 통한 통신을 수행합니다.
  4. 다른 리소스에 대한 접근 시:
    • 표준 입력(stdin), 표준 출력(stdout), 표준 에러(stderr) 외에도 프로그램이 사용하는 다양한 리소스에 대한 파일 디스크립터가 할당될 수 있습니다. 이는 파일 디스크립터를 사용하여 프로그램이 다양한 입출력 작업을 수행할 수 있도록 합니다.

파일 디스크립터는 정수 값으로 표현되며, 일반적으로 0, 1, 2부터 시작합니다. 이 값은 각각 표준 입력(stdin), 표준 출력(stdout), 표준 에러(stderr)를 나타내며, 이후에는 프로그램이 열거나 생성하는 파일이나 소켓에 따라 증가합니다. 파일 디스크립터의 정확한 값은 운영 체제와 상황에 따라 다를 수 있습니다.

 

파일 디스크립터는 일반적으로 소켓이나 파일을 열 때마다 1씩 증가하는 값으로 할당됩니다. 즉, 소켓을 여러 번 열면 파일 디스크립터 값이 계속해서 증가하게 됩니다.

 

예를 들어, 프로그램이 처음으로 소켓을 열면 파일 디스크립터 값이 3이 할당될 수 있습니다. 그 다음에 또 다른 소켓을 열면 그 소켓에는 파일 디스크립터 값 4가 할당될 것입니다. 계속해서 소켓을 열면 파일 디스크립터 값은 계속해서 증가하게 됩니다.

 

소켓을 다루는 함수들은 이러한 파일 디스크립터를 사용하여 소켓을 식별하고 제어합니다. 프로그램이 소켓을 열고 사용한 후에는 close() 함수를 사용하여 해당 파일 디스크립터를 닫아야 합니다. 파일 디스크립터를 닫으면 해당 소켓이나 파일에 대한 자원이 해제되고 해당 파일 디스크립터는 다른 리소스에 다시 사용될 수 있습니다.

 

파일 디스크립터 값이 어떻게 할당되고 증가하는지에 대한 구체적인 세부사항은 운영 체제에 따라 다를 수 있습니다.

 

소켓 통신에서 주로 사용되는 함수 중 일부에서 파일 디스크립터가 사용됩니다.

 

1. socket 함수

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

이 함수는 새로운 소켓을 생성하고, 그 소켓에 대한 파일 디스크립터를 반환합니다.

 

2. bind 함수

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

이 함수는 소켓에 주소를 할당합니다. sockfd는 파일 디스크립터로, 이 소켓에 주소를 할당하는 데 사용됩니다.

 

3. listen 함수

int listen(int sockfd, int backlog);

이 함수는 서버 소켓을 연결을 수신할 수 있도록 대기 상태로 만듭니다. sockfd는 소켓을 나타내는 파일 디스크립터입니다.

 

4. accept 함수

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

이 함수는 연결을 수락하고, 새로운 소켓을 생성하여 클라이언트와의 통신에 사용될 파일 디스크립터를 반환합니다.

 

5. connect 함수

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

클라이언트 측에서 서버에 연결하기 위해 사용되는 함수로, sockfd는 클라이언트 소켓을 나타내는 파일 디스크립터입니다.

 

6. read, write 함수

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

데이터를 읽거나 쓰기 위해 사용되는 함수로, 파일 디스크립터 fd를 통해 특정 소켓에 데이터를 읽고 쓸 수 있습니다.

 

파일 디스크립터는 소켓 통신에서 중요한 역할을 합니다. 프로그램에서는 이 파일 디스크립터를 사용하여 소켓에 데이터를 보내거나 받고, 소켓을 닫거나 연결을 수락하는 등의 작업을 수행합니다.

 

 

 

파일 디스크립터 집합

 

파일 디스크립터 집합(File Descriptor Set)은 select(), pselect(), poll(), epoll()과 같은 I/O 다중화(Multiplexing) 함수들에서 사용되는 자료구조입니다. 이 집합은 입출력 가능한 소켓들의 집합으로, 어떤 소켓에 데이터가 도착했는지를 추적하고 해당 소켓들을 선택적으로 처리할 수 있게 해줍니다.

 

주로 비동기적인 소켓 통신 환경에서 활용되며, 여러 개의 소켓 중에서 어떤 소켓에 이벤트가 발생했는지를 추적하는데 사용됩니다.

 

파일 디스크립터 집합은 다음과 같은 함수들과 함께 사용됩니다.

 

1. FD_ZERO

void FD_ZERO(fd_set *set);

주어진 파일 디스크립터 집합(set)을 비웁니다. 초기화 작업을 위해 사용됩니다.

 

2. FD_SET

void FD_SET(int fd, fd_set *set);

주어진 파일 디스크립터(fd)를 파일 디스크립터 집합(set)에 추가합니다.

 

3. FD_CLR

void FD_CLR(int fd, fd_set *set);

주어진 파일 디스크립터(fd)를 파일 디스크립터 집합(set)에서 제거합니다.

 

4. FD_ISSET

int FD_ISSET(int fd, fd_set *set);

주어진 파일 디스크립터(fd)가 파일 디스크립터 집합(set)에 속해 있는지 확인합니다. 속해 있다면 0이 아닌 값을 반환합니다.

 

 

 

소켓 통신 예제

 

select 함수는 다중 소켓 통신에서 입출력 가능한 소켓들을 감지하는 데 사용되는 시스템 콜입니다. 주로 비동기적인 소켓 통신에서 활용되며, 여러 개의 소켓 중에서 어떤 소켓에 데이터가 도착했는지를 감지하고, 해당 소켓들을 선택적으로 처리할 수 있게 해줍니다. 이는 I/O 다중화(Multiplexing)의 한 형태로 볼 수 있습니다.

 

다음은 select 함수를 사용한 간단한 서버-클라이언트 통신의 예시입니다.

 

1. 서버 코드

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>

int main() {
    // 서버 소켓 생성
    int server_socket = socket(AF_INET, SOCK_STREAM, 0);

    // 서버 소켓 주소 설정
    struct sockaddr_in server_address;
    memset(&server_address, 0, sizeof(server_address));
    server_address.sin_family = AF_INET;
    server_address.sin_addr.s_addr = htonl(INADDR_ANY);
    server_address.sin_port = htons(12345);

    // 바인딩
    bind(server_socket, (struct sockaddr*)&server_address, sizeof(server_address));

    // 리스닝
    listen(server_socket, 5);

    fd_set readfds;  // 파일 디스크립터 집합

    while (1) {
        // 파일 디스크립터 집합 초기화
        FD_ZERO(&readfds);
        FD_SET(server_socket, &readfds);

        // select 함수 호출
        select(server_socket + 1, &readfds, NULL, NULL, NULL);

        // 서버 소켓에 이벤트가 발생했는지 확인
        if (FD_ISSET(server_socket, &readfds)) {
            // 클라이언트 연결 수락
            int client_socket = accept(server_socket, NULL, NULL);

            // 클라이언트와 통신
            char buffer[1024];
            recv(client_socket, buffer, sizeof(buffer), 0);
            printf("Received from client: %s\n", buffer);

            // 클라이언트 소켓 닫기
            close(client_socket);
        }
    }

    // 서버 소켓 닫기
    close(server_socket);

    return 0;
}

 

2. 클라이언트 코드

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>

int main() {
    // 클라이언트 소켓 생성
    int client_socket = socket(AF_INET, SOCK_STREAM, 0);

    // 서버 소켓 주소 설정
    struct sockaddr_in server_address;
    memset(&server_address, 0, sizeof(server_address));
    server_address.sin_family = AF_INET;
    server_address.sin_addr.s_addr = inet_addr("127.0.0.1");
    server_address.sin_port = htons(12345);

    // 서버에 연결
    connect(client_socket, (struct sockaddr*)&server_address, sizeof(server_address));

    // 데이터 전송
    char message[] = "Hello, Server!";
    send(client_socket, message, sizeof(message), 0);

    // 클라이언트 소켓 닫기
    close(client_socket);

    return 0;
}

 

이 예제에서 select 함수는 서버 소켓에 대한 이벤트를 감지하고, 클라이언트와의 통신을 처리합니다. select 함수는 파일 디스크립터 집합을 사용하여 감지하고자 하는 소켓들을 설정하며, FD_ISSET 매크로를 사용하여 각 소켓에 대한 이벤트를 확인합니다. 이를 통해 여러 소켓을 동시에 다룰 수 있습니다.

 

 

파일 디스크립터를 사용하는 방식과 사용하지 않는 방식의 차이는 다음과 같습니다.

  1. 다중 클라이언트 지원:
    • 파일 디스크립터 사용: 파일 디스크립터를 사용하는 경우 select(), poll(), 또는 epoll()과 같은 다중 입출력(I/O) 함수를 통해 여러 소켓을 모니터링하고, 각 소켓에서 발생하는 이벤트를 처리할 수 있습니다. 이를 통해 여러 클라이언트와 동시에 통신이 가능합니다.
    • 파일 디스크립터 미사용: 반복문 안에서 직접 accept()를 호출하는 경우, 하나의 클라이언트와만 통신이 가능하며, 다른 클라이언트의 연결 요청이 들어오면 대기 중인 accept()가 블록되어 다른 작업을 처리할 수 없습니다.
  2. 비동기적 이벤트 처리:
    • 파일 디스크립터 사용: 다중 입출력 함수를 사용하면 비동기적으로 여러 소켓에서 발생하는 이벤트를 처리할 수 있습니다. 이벤트가 발생한 소켓에 대해 작업을 수행하고 다른 작업으로 전환할 수 있습니다.
    • 파일 디스크립터 미사용: 반복문 안에서 직접 accept()를 호출하는 경우, 블록되어 있는 동안 다른 이벤트를 처리할 수 없습니다. 따라서 여러 클라이언트와의 통신이 동시에 처리되지 않습니다.
  3. 효율성:
    • 파일 디스크립터 사용: 다중 입출력 함수를 사용하는 경우, 커널이 소켓 이벤트를 감지하고 해당 이벤트를 애플리케이션에 알려주기 때문에 효율적인 이벤트 감지 및 처리가 가능합니다.
    • 파일 디스크립터 미사용: 반복문 안에서 직접 accept()를 호출하는 경우, 폴링이나 이벤트 알림 메커니즘이 없어서 불필요한 CPU 리소스를 소비할 수 있습니다.

일반적으로 파일 디스크립터를 사용하는 방식이 효율적이며, 다중 클라이언트와의 통신을 위해 널리 사용되고 있습니다. 파일 디스크립터를 사용하지 않는 방식은 단일 클라이언트와의 간단한 통신에 적합할 수 있지만, 다중 클라이언트를 지원하고 효율적인 이벤트 처리가 필요한 경우에는 비효율적입니다.

 

 

 

select() 대체 함수

 

select 함수는 여전히 사용되고 있지만, 대규모 네트워크 프로그래밍이나 다중 I/O 이벤트 처리를 위해선 제한적인 점이 있어 대안으로 poll, epoll, 혹은 kqueue 등이 더 많이 사용됩니다. 이러한 함수들은 select의 몇 가지 제한을 극복하고 더 효율적인 이벤트 처리를 가능하게 합니다.

 

select의 주요 단점은 다음과 같습니다.

  1. 파일 디스크립터의 제한: 일반적으로 select는 파일 디스크립터의 개수에 제한이 있습니다. 이 한계 때문에 대규모 네트워크 프로그램에서는 사용이 어려울 수 있습니다.
  2. 매번 파일 디스크립터 집합 복사: select는 이벤트 발생 시마다 관심 있는 파일 디스크립터들을 담은 집합을 매번 복사합니다. 이는 큰 규모의 프로그램에서 성능에 영향을 줄 수 있습니다.
  3. 블로킹 호출: select 함수는 이벤트가 발생할 때까지 블로킹됩니다. 이는 대규모 서버에서 여러 클라이언트를 동시에 처리할 때 문제가 될 수 있습니다.

poll, epoll, kqueue는 이러한 단점을 극복하기 위해 설계되었으며, 특히 대규모 네트워크 애플리케이션에서 더 선호되는 선택지가 되고 있습니다. 예를 들어, 리눅스에서는 epoll이 높은 성능을 제공하므로 많은 프로그래머들이 select 대신 epoll을 사용합니다.

 

다음은 poll()을 사용하는 예제입니다.

 

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <poll.h>

#define MAX_EVENTS 5

int main() {
    struct pollfd fds[MAX_EVENTS];
    int nfds = 1; // 현재는 stdin만 모니터링
    int timeout = 5000; // 타임아웃(ms)

    // stdin을 모니터링하도록 설정
    fds[0].fd = STDIN_FILENO;
    fds[0].events = POLLIN; // 읽기 가능한지 확인

    while (1) {
        int result = poll(fds, nfds, timeout);

        if (result == -1) {
            perror("poll");
            exit(EXIT_FAILURE);
        } else if (result > 0) {
            // 이벤트가 발생한 파일 디스크립터를 찾아 처리
            for (int i = 0; i < nfds; ++i) {
                if (fds[i].revents & POLLIN) {
                    if (fds[i].fd == STDIN_FILENO) {
                        // stdin에서 데이터를 읽어옴
                        char buffer[256];
                        ssize_t bytesRead = read(STDIN_FILENO, buffer, sizeof(buffer));
                        if (bytesRead > 0) {
                            buffer[bytesRead] = '\0';
                            printf("Read from stdin: %s", buffer);
                        }
                    }
                }
            }
        } else {
            // 타임아웃 발생
            printf("Timeout occurred.\n");
        }
    }

    return 0;
}

 

 

 

 

 

이번 포스팅에서는 소켓 통신과 파일 디스크립터에 대한 기초적인 내용을 살펴보았습니다. 소켓 통신은 네트워크 프로그래밍에서 핵심적인 개념으로, 클라이언트와 서버 간의 통신을 가능케 합니다. 파일 디스크립터는 운영체제에서 열린 파일이나 소켓을 식별하는데 사용되며, 이를 통해 입출력 작업이 이루어집니다.

 

뿐만 아니라, select 함수를 이용한 다중 I/O 이벤트 처리와 그 한계를 넘어선 poll 함수에 대한 이해도 함께 살펴보았습니다. poll 함수는 대규모 네트워크 애플리케이션에서 select의 한계를 극복하기 위한 대안으로 자주 사용되며, 이를 통해 효율적인 이벤트 관리가 가능합니다.

 

이러한 기초적인 개념을 바탕으로 적절한 함수의 선택과 활용은 안정적이고 빠른 네트워크 애플리케이션 개발에 중요한 역할을 합니다. 각 함수의 특징을 이해하고 활용하는 것은 네트워크 프로그래밍에 있어서 핵심적인 스킬 중 하나입니다.

반응형