네트워크 프로그래밍과 소켓의 이해

소켓의 전화기에의 비유

OS의 소켓은 전화기에 비유할 수 있다.

서버

비유 함수 반환
전화기의 장만 int socket(int domain, int type, int protocol) 성공 시 파일 디스크립터, 실패 시 -1
전화번호의 부여 int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen) 성공 시 0, 실패 시 -1
전화 케이블에 연결 int listen(int sockfd, int backlog) 성공 시 0, 실패 시 -1
수화기를 들기 int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) 성공 시 파일 디스크립터, 실패 시 -1

클라이언트

         비유          함수                  반환                 
전화 걸기 int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen) 성공 시 0, 실패 시 -1

서버 프로그램의 구현

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int serv_sock;
    int clnt_sock;

    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t clnt_addr_size;

    char message[]="Hello World!";

    if(argc != 2)
    {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    if(serv_sock == -1)
        error_handling("socket() error");

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(atoi(argv[1]));

    if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr)) == -1)
        error_handling("bind() error");

    if(listen(serv_sock, 5) == -1)
        error_handling("listen() error");

    clnt_addr_size = sizeof(clnt_addr);
    clnt_sock= accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
    if(clnt_sock == -1)
        error_handling("accept() error");

    write(clnt_sock, message, sizeof(message));
    close(clnt_sock);
    close(serv_sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

클라이언트 프로그램의 구현

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char* argv[])
{
    int sock;
    struct sockaddr_in serv_addr;
    char message[30];
    int str_len;

    if(argc != 3)
    {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    sock = socket(PF_INET, SOCK_STREAM, 0);
    if(sock == -1)
        error_handling("socket() error");

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_addr.sin_port = htons(atoi(argv[2]));

    if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
        error_handling("connect() error!");

    str_len = read(sock, message, sizeof(message) - 1);
    if(str_len == -1)
        error_handling("read() error!");

    printf("Message from server : %s \n", message);
    close(sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

Linux기반 파일(소켓) 조작하기

💡 리눅스는 “모든 것은 파일이다”라는 철학을 가지고 있어서, 파일과 소켓을 포함하여 다양한 자원을 파일로 취급함.

리눅스에서는 소켓을 파일처럼 다루기 때문에, 파일 입출력과 동일하게 조작할 수 있다.

명칭

  • 리눅스 : 파일 디스크립터
  • 윈도우 : 파일 핸들

표준 입출력 및 표준 에러 파일 디스크립터

파일 디스크립터 대 상
0 표준입력: Standard Input
1 표준출력: Standard Output
2 표준에러: Standard Error
기능 함수 반환
파일 열기 int open(const char *path, int flag) 성공 시 파일 디스크립터, 실패 시 -1
파일 닫기 int close(int fd) 성공 시 0, 실패 시 -1
데이터 쓰기 ssize_t write(int fd, const void * buf, size_t nbytes) 성공 시 전달한 바이트 수, 실패 시 -1
데이터 읽기 ssize_t read(int fd, void *buf, size_t nbytes) 성공 시 수신한 바이트 수, 실패 시 -1

flag에 전달할 수 있는 값은 다음과 같다.

오픈 모드 의 미
O_CREAT 필요하면 파일을 생성
O_TRUNC 기존 데이터 전부 삭제
O_APPEND 기존 데이터 보존하고, 뒤에 이어서 저장
O_RDONLY 읽기 전용으로 파일 오픈
O_WRONLY 쓰기 전용으로 파일 오픈
O_RDWR 읽기, 쓰기 겸용으로 파일 오픈

윈도우 기반으로 구현하기

윈도우 소켓은 BSD 게열 유닉스 소켓을 참고하여 설계되었으므로 리눅스 소켓과 유사하다. 따라서 리눅스 기반으로 구현된 네트워크 프로그램의 일부만 변경하면 윈도우에서의 실행이 가능하다.

윈도우 소켓을 위한 헤더와 라이브러리 설정

필요사항

  • 헤더파일 winsock2.h를 포함한다.
  • ws2_32.lib 라이브러리를 링크시켜야 한다.

윈도우 소켓 초기화

윈도우 소켓 프로그래밍을 할 때에는 반드시 WSAStartup 함수를 호출해서, 프로그램에서 요구하는 윈도우 소켓의 버전을 알리고, 해당 버전을 지원하는 라이브러리의 초기화 작업을 진행해야 한다.

#include <winsock2.h>

int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
  • wVersionRequested : 프로그래머가 사용할 윈도우 소켓의 버전정보 전달
  • lpWSAData : WSADATA라는 구조체 변수의 주소 값 전달

윈도우 소켓에는 몇몇 버전이 존재하는데, 사용할 소켓의 버전정보를 WORD형으로 구성해서 첫 번째 인자로 전달해야 한다. 만약에 사용할 소켓의 버전이 1.2라면, 1이 주버전이고 2가 부 버전이므로 0x0201을 인자로 전달해야 한다.

이렇듯 상위 8비트에는 부 버전 정보를, 하위 8비트에는 주 버전 정보를 표시해서 인자로 전달하게 되는데, 우리는 버전 2.2를 기반으로 프로그래밍을 하기 때문에 0x0202를 인자로 전달하면 된다. 그런데 바이트 단위로 쪼개서 버전정보를 설정하는 것이 조금 번거롭게 느껴질 것이다. 그래서 매크로 함수인 MAKEWORD가 제공되고 있다. 이 함수를 사용하면 다음과 같이 간단히 WORD형 버전정보를 구성할 수 있다.

MAKEWORD(1, 2); // 주 버전 1, 부 버전 2, 0x0201 반환
MAKEWORD(2, 2); // 주 버전 2, 부 버전 2, 0x0202 반환

두 번째 매개변수에는 WSADATA 구조체 변수의 주소 값을 인자로 전달해야 한다(LPWSADATAWSADATA의 포인터 형이다). 그러면 함수호출이 완료되고 난 다음에 해당 변수에는 초기화된 라이브러리의 정보가 채워진다. 특별히 큰 의미를 지니지는 않지만, 함수호출을 위해서는 반드시 WSADATA 구조체 변수의 주소 값을 전달해야 한다. 그럼 코드의 앞부분에 등장하는 WSAStartup 함수의 호출과정을 간단히 보이겠다. 참고로 아래의 코드는 윈도우 소켓 기반의 프로그래밍에서는 거의 공식과 같이 등장한다.

int main(int argc, char* argv[])
{
    WSADATA wsaData;
    . . . .
    if(WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
        ErrorHandling("WSAStartup() error!");
    . . . .
    return 0;
}

초기화된 라이브러리의 해제 방법

#include <winsock2.h>

int WSACleanup(void);   // 성공 시 0, 실패 시 SOCKET_ERROR 반환

위 함수를 호출하면, 할당된 윈도우 소켓 라이브러리는 윈도우 운영체제에 반환이 되면서, 윈도우 소켓 관련 함수의 호출이 불가능해진다. 따라서 더 이상 윈도우 소켓 관련 함수의 호출이 불필요할 때, 위 함수를 호출하는 것이 원칙이나 프로그램이 종료되기 직전에 호출하는 것이 보통이다.


윈도우 기반의 소켓 관련 함수와 예제

서버

리눅스의 socket 함수와 동일한 기능

#include <winsock2.h>

SOCKET socket(int af, int type, int protocol); 
// 성공 시 소켓 핸들, 실패 시 INVALID_SOCKET 반환

리눅스의 bind 함수와 동일한 기능

#include <winsock2.h>

int bind(SOCKET s, const struct sockaddr * name, int namelen); 
// 성공 시 0, 실패 시 SOCKET_ERROR 반환

리눅스의 listen 함수와 동일한 기능

#include <winsock2.h>

int listen(SOCKET s, int backlog);
// 성공 시 0, 실패 시 SOCKET_ERROR 반환

리눅스의 accept 함수와 동일한 기능

#include <winsock2.h>

SOCKET accept(SOCKET s, struct sockaddr * addr, int * addrlen);
// 성공 시 소켓 핸들, 실패 시 INVALID_SOCKET 반환

클라이언트

리눅스의 connect 함수와 동일한 기능

#include <winsock2.h>

int connect(SOCKET s, const struct sockaddr * name, int namelen);
// 성공 시 0, 실패 시 SOCKET_ERROR 반환

리눅스의 close 함수와 동일한 기능

#include <winsock2.h>

int closesocket(SOCKET s);
// 성공 시 0, 실패 시 SOCKET_ERROR 반환

리눅스에서는 파일을 닫을 때도, 소켓을 닫을 때도 close함수를 호출 하지만, 윈도우에서는 소켓을 닫을 때 호출하는 별도의 함수가 있다.

윈도우에서의 파일 핸들과 소켓 핸들

윈도우에서의 핸들은 리눅스에서의 파일 디스크립터에 비교될 수 있다. 그런데 윈도우는 리눅스와 달리 파일 핸들과 소켓 핸들을 구분하고 있다.

윈도우 기반 서버, 클라이언트 예제의 작성

hello_server_win.c

#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
void ErrorHandling(char *message);

int main(int argc, char *argv[])
{
    WSADATA wsaData;
    SOCKET hServSock, hClntSock;
    SOCKADDR_IN servAddr, clntAddr;

    int szClntAddr;
    char message[]="Hello World!";

    if(argc != 2)
    {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    if(WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
        ErrorHandling("WSAStartup() error!");

    hServSock = socket(PF_INET, SOCK_STREAM, 0);
    if(hServSock == INVALID_SOCKET)
        ErrorHandling("socket() error");

    memset(&servAddr, 0, sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servAddr.sin_port = htons(atoi(argv[1]));

    if(bind(hServSock, (SOCKADDR*) &servAddr, sizeof(servAddr)) == SOCKET_ERROR)
        ErrorHandling("bind() error");

    if(listen(hServSock, 5) == -1)
        ErrorHandling("listen() error");

    szClntAddr = sizeof(clntAddr);
    hClntSock= accept(hServSock, (SOCKADDR*)&clntAddr, &szClntAddr);
    if(hClntSock == INVALID_SOCKET)
        ErrorHandling("accept() error");

    send(hClntSock, message, sizeof(message), 0);
    closesocket(hClntSock);
    closesocket(hServSock);
    WSACleanup();
    return 0;
}

void ErrorHandling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

hello_client_win.c

#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
void ErrorHandling(char *message);

int main(int argc, char* argv[])
{
    WSADATA wsaData;
    SOCKET hSocket;
    SOCKADDR_IN servAddr;
    
    char message[30];
    int strLen;
    if(argc != 3)
    {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }
    
    if(WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
        ErrorHandling("WSAStartup() error!");
    
    hSocket=socket(PF_INET, SOCK_STREAM, 0);
    if(hSocket == INVALID_SOCKET)
        ErrorHandling("socket() error");
    
    memset(&servAddr, 0, sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_addr.s_addr = inet_addr(argv[1]);
    servAddr.sin_port = htons(atoi(argv[2]));
    
    if(connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
        ErrorHandling("connect() error!");
    
    strLen = recv(hSocket, message, sizeof(message)-1, 0);
    if(strLen == -1)
        ErrorHandling("read() error!");
    printf("Message from server: %s \n", message);
    
    closesocket(hSocket);
    WSACleanup();
    return 0;
}

void ErrorHandling(char* massage)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

윈도우 기반 입출력 함수

리눅스는 소켓도 파일로 간주하기 때문에, 파일 입출력 함수인 read와 write를 이용해서 데이터를 송수신할 수 잇다. 그러나 윈도우는 상황이 좀 다르다. 파일 입출력 함수와 소켓 입출력 함수가 엄연히 구분되기 때문이다. 따라서 이번에는 윈도우 소켓 기반의 데이터 입출력 함수를 소개하겠다.

#include <winsock2.h>

int send(SOCKET s, const char * buf, int len, int flags);
// 성공 시 전송된 바이트 수, 실패 시 SOCKET_ERROR 반환
  • s : 데이터 전송 대상과의 연결을 의미하는 소켓의 핸들 값 전달
  • buf : 전송할 데이터를 저장하고 있는 버퍼의 주소 값 전달
  • len : 전송할 바이트 수 전달
  • flags : 데이터 전송 시 적용할 다양한 옵션 정보 전달

위 함수를 리눅스의 write 함수와 비교해보면, 마지막 매개변수 flags가 존재하는 것 이외에는 차이가 없음을 알 수 있다. 참고로 send함수의 마지막 매개변수에 대해서는 나중에 언급할 것이니, 그때까지 아무런 옵션을 설정하지 않는다는 의미로 0을 전달한다.

리눅스에도 send 함수가 존재하지만 파일 입출력과 소켓 입출력의 동일함을 강조하기 위해 포스팅에서는 사용하지 않는다.

#include <winsock2.h>

int recv(SOCKET s, const char * buf, int len, int flags);
// 성공 시 수신한 바이트 수(단 EOF 전송 시 0), 실패 시 SOCKET_ERROR 반환
  • s : 데이터 수신 대상과의 연결을 의미하는 소켓의 핸들 값 전달
  • buf : 수신된 데이터를 저장할 버퍼의 주소 값 전달
  • len : 수신할 수 있는 최대 바이트 수 전달
  • flags : 데이터 수신 시 적용할 다양한 옵션 정보 전달