본문으로 바로가기
반응형

소켓(Socket)?

네트워크로 연결되어있는 두 컴퓨터가 데이터를 주고 받을 수 있게 하는 것이 네트워크 프로그래밍이다.

그렇다면 네트워크로 연결되어있는 두 컴퓨터가 데이터를 주고받기 위해서는 물리적인 연결이 필요하다.

 

그런데 현재는 대부분의 컴퓨터가 인터넷이라는 거대한 네트워크로 연결되어있으니 물리적인 연결을 신경쓰지 않아도 된다. 때문에 SW를 통한 데이터 송수신 방법만 고민하면된다.

 

그런데 운영체제에서 '소켓(Socket)' 이라는 것을 제공하기 때문에 이 역시 고민할 필요가 없다.

소켓은 물리적으로 연결된 네트워크상에서 데이터 송수신에 사용할 수 있는 소프트웨어적인 장치를 의미한다.

 

그래서 데이터 송수신의 원리는 몰라도 소켓을 이용하면 데이터를 주고받을 수 있다. 이때 네트워크 프로그래밍을 소켓 프로그래밍이라고도 한다.

 

데이터를 주고받아야하는데 컴퓨터간의 거리가 멀리 떨어져있다면 인터넷이라는 네트워크 망에 연결해야한다. 이때 소켓은 네트워크 망의 연결에 사용되는 도구이다.

 

연결이라는 의미가 담긴 소켓은 네트워크를 통한 두 컴퓨터의 연결을 의미하기도 한다.

 

소켓(Socket)의 구현

소켓은 크게 두가지로 나뉘는데, 그 중 한가지인 TCP 소켓은 전화기에 비유할 수 있다.

 

1. 집에 전화를 놓으려면 먼저 전화기를 구입해야한다.(전화기)    => 소켓 생성

2. 전화기를 장만했으니 전화번호를 부여받아야한다.(전화번호)   => IP주소와 Port 번호 할당

3. 전화번호가 있으니 전화가 걸려오기를 기다린다. (개통)         => 연결 요청을 할 수 있는 상태

4. 전화가 걸려오면 수화기를 들어서 전화를 받는다. (통화)        => 연결요청에 대한 수락

 

대략 전화기로 소켓에 대한 비유를 들어봤는데, 전화기는 거는 것과 받는 것이 동시에 가능하지만, 소켓은 거는 용도의 소켓을 완성하는 방식과 받는 용도의 소켓을 완성하는 방식에 차이는 있을 것이다.

 

전화기를 놓는 과정처럼 네트워크 프로그래밍에서 연결요청을 허용하는 소켓의 생성과정은 다음과 같이 정리할 수 있다.

1. 소켓 생성                          => socket 함수 호출

2. IP주소와 Port 번호 할당        => bind 함수 호출

3. 연결 요청 가능상태로 변경    => listen 함수 호출

4. 연결 요청에 대한 수락          => accept 함수 호출

 

각 함수들을 정의하자면 다음과 같다.

#include <sys/socket.h>
int socket(int domain, int type, int protocol);     // 성공시 파일 디스크럽터, 실패시 -1 반환
int bind(int sockfd, struct sockadd *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 반환

각 함수가 어떻게 쓰이는지, 매개변수는 무엇인지는 코드를 직접 쳐보면서 차차 알아보도록하자.

 

 

"Hello World!" 서버 프로그램 구현

연결 요청을 수락하는 기능의 프로그램을 가리켜 '서버(server)' 라 한다. 앞에서 봤던 함수의 호출과정을 확인하기 위해 연결 수락 시 "Hello World!" 라고 응답해주 서버 프로그램을 리눅스에서 작성해보자.

이 예제는 이해하지말고 어자피 하나씩 알아갈테니 소켓관련 함수의 호출을 코드상으로 확인만 해보는데 만족하자.

저번 포스팅에서 설치했던 VMware에 우분투를 실행해서 로그인하고, Work/Socket 디렉터리에서 nano 편집기로 해당 코드를 작성하면된다.

 

서버 코드 작성

hello_server.c
// hello_server.c
#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);
}


 

코드를 작성한 후 현재 터미널창에서

gcc hello_server.c -o hserver   => hello.server.c 파일을 컴파일해서 hserver라는 이름의 실행파일을 만드는 문장

를 입력해서 hserver라는 이름의 실행파일을 만들어준다. 실행파일이 만들어진 후에

./hserver 9190    => 현재 디렉터리에있는 hserver라는 이름의 파일을 실행시키라는 의미

를 입력해서 생성한 파일을 실행시켜준다.

이때 파일을 실행하면 아무런 반응없이 멈춰있는 상태가된다. 이 상태가 서버를 만들고 연결요청을 기다리는 상태가 되는 것이다. 이제 나한테 전화를 걸 코드를 작성해보자.

파일을 실행하면 이대로 멈춘 상태가 된다

 

 

전화거는 소켓의 구현

방금 서버 프로그램에서 생성한 소켓을 '서버 소켓' 또는 '리스닝 소켓' 이라고 한다.

반면에 이번에 작성할 소켓은 연결요청을 진행하는 '클라이언트 소켓'이다.

클라이언트 소켓의 생성과정은 서버 소켓의 생성과정에 비해 상대적으로 간단하다.

 

전화를 거는 기능의 함수는 connect 함수이다.

conncet 함수는는 클라이언트 소켓을 대상으로 호출하는 함수이기때문에 위에서 설명을 하지 않았다.

#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen);
// 성공 시 0, 실패 시 -1 반환

클라이언트 프로그램은 socket 함수 호출을 통한 소켓의 생성과 connect 함수 호출을 통한 서버에게 연결 요청 과정만 존재한다. 코드를 작성하고 실행해보자.

 

클라이언트 코드 작성

nano hello.client.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.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);
}

 

클라이언트 코드까지 작성했으면 방금 서버 소켓의 코드를 실행해서 서버에 연결요청을 기다리는 상태로 만들고,

새로운 터미널창을 띄워서 새 터미널창에 클라이언트 코드 파일을 만드는 실행문을 작성한다.

gcc hello_client.c -o hclient 

를 입력해서 hclient 실행파일을 만들어주고 실행시켜주면 서버 메세지로부터 Hello World가 출력된다.

127.0.0.1 은 컴퓨터의 IP 주소를 뜻한다

 

이로써 대략적으로 클라이언트 프로그램의 메세지 수신을 확인했는데, 메세지를 수신하면 서버 프로그램과 클라이언트 프로그램은 종료되는 것을 확인할 수 있다. 

 

 

 

리눅스 기반 파일 조작하기

소켓을하다가 갑자기 파일 언급이 뜬금없을 수 있는데, 리눅스에서의 소켓조작은 파일조작과 동일하게 간주되기 때문에 파일에 대해서 자세히 알 필요가 있다. 리눅스는 소켓을 파일의 일종으로 구분한다.

따라서 파일 입출력 함수를 소켓 입출력에, 즉 네트워크상에서의 데이터 송수신에 사용할 수 있다.

 

리눅스에서 제공하는 파일 입출력 함수를 사용하려면 파일 디스크립터에 대한 개념을 알아야한다.

파일 디스크립터란 시스템으로부터 할당 받은 파일 또는 소켓에 부여된 정수를 의미한다.

참고로 C에서 표준 입출력 및 표준 에러에도 파일 디스크립터를 할당하고 있다.

 

0   => 표준 입력 : Standard Input

1   => 표준 출력 : Standard Output

2   => 표준 에러 : Standard Error

 

이 세가지 입출력 대상은 별도의 생성과정을 거치지 않아도 프로그램이 실행되면 자동으로 할당되는 파일 디스크립터이다. input, output, error를 숫자로 편하게 지칭한 것처럼 운영체제가 만든 파일 또는 소켓의 지칭을 편하게 하기위한 숫자를 파일 디스크립터라고 한다.

참고로 파일 디스크립터를 파워 핸들이라고도한다. (윈도우에서 주로 사용하는 용어이다)

이제 포스팅을할때 윈도우 기반 설명에는 '핸들', 리눅스 기반의 설명은 '디스크립터'라는 표현으로 설명하겠다.

 

 

파일 열기

데이터를 읽거나 쓰기위해 사용하는 함수가 있다. 이 함수는 두 개의 인자를 전달 받는다.

첫번째 인자는 대상이 되는 파일의 이름 및 경로 정보

두번째 인자는 파일의 오픈 모드 정보(파일의 특성 정보)를 전달한다.

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *path, int flag);  // 성공시 파일 디스크립터, 실패시 -1 반환
// path : 파일 이름을 나타내는 문자열의 주소 값 전달
// flag : 파일의 오픈 모드 정보 전달

두번째 매개변수 flag에 전달 할 수 있는 값과 그 의미는 다음과 같다. 

- O_CREAT : 필요하면 파일을 생성

- O_TRUNC : 기존 데이터 전부 삭제

- O_APPEND : 기존 데이터를 보존하고, 그 뒤에 이어서 저장

- O_RDONLY : 읽기 전용으로 파일 오픈

- O_WRONLY : 쓰기 전용으로 파일 오픈

- O_RDWR :  읽기, 쓰기, 겸용으로 파일 오픈

이 flag 인자들은 하나 이상의 정보를 비트 OR 연산자로 묶어서 전달이 가능하다.

 

파일 닫기

C나 C++에서 봤듯이, 파일 열기를했으면 사용후 반드시 닫아줘야한다. 

#include <unistd.h>
int close(int fd);   // 성공 시 0, 실패시 -1 반환
// fd : 닫고자 하는 파일 또는 소켓의 파일 디스크립터 전달.

위 함수를 호출하면서 파일 디스크립터를 인자로 전달하면 해당 파일은 닫히게(종료하게) 된다.

그런데 이 함수는 파일뿐만아니라 소켓을 닫을때에도 사용된다. 이는 파일과 소켓을 구분하지 않는 리눅스 운영체제의 특성을 확인할 수 있는 대목이라고 볼 수 있다.

 

 

파일에 데이터 쓰기

write 함수는 파일에 데이터를 출력(전송)하는 함수이다. 

리눅스에서는 파일과 소켓을 동이랗게 취급하므로, 소켓을 통해 다른 컴퓨터에 데이터를 전송할 때도 이 함수를 사용할 수 있다. 앞서 했던 Hello World! 을 출력하는 예제도 이 함수를 사용했었다.

#include <unistd.h>
ssize_t write(int fd, const void * buf, size_t nbytes); // 성공시 전달한 바이트 수, 실패시 -1 반환
// fd : 데이터 전송대상을 나타내는 파일 디스크립터 전달
// buf : 전송할 데이터가 저장된 버퍼의 주소 값 전달
// nbytes : 전송할 데이터의 바이트 수 전달

size_t는 typedef 선언을 통한 unsigned int로 정의되어있다.

ssize_t의 경우 size_t 앞에 s가 하나 더 붙어있는데, 이는 signed를 의미한다.

 

앞서 설명한 파일에 관련된 함수를 활용해보자.

가상머신 터미널창에 파일의 생성 및 데이터의 저장을하는 low_open.c 코드를 작성해보자.

// low_open.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
void error_handling(char *message);

int main(void)
{
   int fd;
   char buf[] = "Let's go!\n";

   fd = open("data.txt", O_CREAT|O_WRONLY|O_TRUNC);
   if(fd == -1)
      error_handling("open() error!");
   printf("file descriptor : %d\n ", fd);

   if(write(fd, buf, sizeof(buf)) == -1)
      error_handling("write() error!");
   close(fd);
   return 0;
}

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

 

코드 작성후, gcc로 컴파일후에 파일을 생성해보자.

lopen 이름을 가진 파일을 실행해주면 data.txt 파일이 생성된다.

이때 리눅스 명령어 cat을 통해 생성된 파일의 내용을 읽어보면 low_open.c 코드에 buf[]에 저장된 문자열이 출력된다.

data.txt 의 출력내용을 확인함인해 파일에 데이터가 제대로 전송되었음을 보이고 있다.

 

 

 

파일에 저장된 데이터 읽기

앞서 설명한 write 함수의 상대적인 기능을 제공하는 read 함수는 데이터를 입력(수신)하는 기능의 함수이다.

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);  // 성공시 수신한 바이트 수(단, 파일의 끝이면 0), 실패 시 -1 반환
// fd     : 데이터 수신대상을 나타내는 파일 디스크립터 전달
// buf    : 수신한 데이터를 저장할 버퍼의 주소 값 전달
// nbytes : 수신할 최대 바이트 수 전달

 

이번에는 앞서 생성한 data.txt에 저장된 데이터를 read 함수를 이용해 데이터를 읽어 들이는 코드를 작성해보자.

// low_read.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#define BUF_SIZE 100
void error_handling(char *message);

int main(void)
{
   int fd;
   char buf[BUF_SIZE];

   fd = open("data.txt", O_RDONLY);
   if( fd == -1)
      error_handling("open() error!");
   printf("file descriptor : %d \n", fd);

   if(read(fd, buf, sizeof(buf)) == -1)
      error_handling("read() error!");
   printf("file data : %s", buf);
   close(fd);
   return 0;
}

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

해당 코드를 작성하고 gcc 컴파일을 통해 파일을 생성해보자.
생성된 lread 파일을 실행하면 data.txt 파일의 데이터를 읽어들인다.

data.txt 데이터를 읽어들인다

 

 

파일 디스크립터와 소켓

이번에는 파일을 생성하면서 소켓도 같이 생성하는 코드를 작성해보자.

그리고 반환되는 파일 디스크립터의 값을 정수형태로 비교해보자.

// fd_seri.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/socket.h>

int main(void)
{
   int fd1, fd2, fd3;
   fd1 = socket(PF_INET, SOCK_STREAM, 0);
   fd2 = open("test.dat", O_CREAT|O_WRONLY|O_TRUNC);
   fd3 = socket(PF_INET, SOCK_DGRAM, 0);

   printf("file descriptor 1 : %d \n", fd1);
   printf("file descriptor 2 : %d \n", fd2);
   printf("file descriptor 3 : %d \n", fd3);
   close(fd1);
   close(fd2);
   close(fd3);

   return 0;
}

 

마찬가지로 gcc 컴파일을 통해 파일을 생성해주고, 생성된 파일을 실행해보자.

출력된 디스크립터 정수 값을 비교해보면 일련의 순서대로 넘버링이 되는 것을 알 수 있다.

참고로 파일 디스크립터가 3부터 시작하는 이유는 0, 1, 2는 표준 입출력(stdin, stdout, stderr => 입력, 출력, 에러)에 이미 할당되었기 때문이다.

 

 

윈도우 기반으로 소켓 구현

윈도우 소켓은 리눅스 소켓과 많은 부분이 유사하다. 따라서 리눅스 기반으로 구현된 네트워크 프로그램의 일부만 변경해주면 윈도우에서의 실행이 가능하다.

 

상당수의 프로젝트에서는 서버를 리눅스 계열의 운영체제 기반으로 개발한다.

반대로 클라이언트 프로그램의 경우 윈도우 기반의 개발이 절대적이다.

 

뿐만아니라 리눅스 기반으로 구현되어 있는 서버프로그램을 윈도우 기반으로 변경하거나

               윈도우 기반으로 구현되어 있는 서버 프로그램을 리눅스 기반으로 변경해야 하는 상황도 종종 발생한다.

때문에 소켓 프로그래밍에 대해서는 리눅스와 윈도우 둘다 개발이 가능하도록 공부하는게 좋을 것이다.

 

빡세고 머리아플것 같지만, 이 둘은 서로 매우 유사하기때문에 유사한 부분을 묶어서 동시에 공부하는 것이 효과적이다.

하나만 잘 이해하면 나머지 하나는 차이점만 확인하는 정도로 쉽게 익힐 수 있다.

 

 

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

지금까지 가상머신을 통해 리눅스에서 코드를 실행하고 확인했는데, 이번엔 윈도우에서 소켓 프로그래밍을 작성해보자.

 

일단 윈도우 기반으로 프로그램을 개발하기 위해서는 다음 두가지를 진행해야한다.

1. 헤더파일 winsock2.h 를 포함한다.

2. ws2_32.lib 라이브러리를 링크시켜야한다.

 

일단 sw2_32.lib 의 프로젝트 단위의 링크해보자. 

비주얼 스튜디오 프로젝트 파일 속성 (또는 ALT + F7)  (버전에따라 속성 -> 입력 -> 추가 종속성 일수도 있다)

-> 구성 속성 -> 링커 -> 입력 -> 추가 종속성 -> 편집 -> ws2_32.lib 를 입력하고 적용 버튼 클릭

프로젝트 우클릭 -> 속성
추가 종속성에 sw2_32.lib  를 추가해주고 적용해준다

프로젝트 단위에 라이브러리를 추가했으므로, 소스코드를 작성할때 헤더파일만 추가시키면 윈도우 소켓과 관련된 함수를 호출할 수 있는 상태가 되었다.

 

 

윈도우 소켓(winsock, 윈속)의 초기화

윈속 프로그래밍을 할때는 SWAStartup 함수를 호출해서 프로그램에서 요구하는 윈도우 소켓의 버전을 알려주고,

해당 버전을 지원하는 라이브러리의 초기화 작업을 진행해야 한다.

#include <winsock2.h>
int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData); // 성공시 0, 실패 시 0이 아닌 에러코드 값 반환
// wVersionRequested : 프로그래머가 사용할 윈속의 버전정보 전달
// lpWSAData : SWADATA라는 구조체 변수의 주소 값 전달

윈도우 소켓에는 몇몇 버전이 존재하는데, 사용할 소켓의 정보를 WORD형으로 구성해서 함수의 첫번째 매개변수 wVersionRequested 로 전달한다.

만약 사용할 소켓의 버전이 1.2라면 1이 주 버전, 2가 부 버전이므로 0x0201을 인자로 전달한다.

 

근데 0x0201로 굳이 1.2를 해체해서 보내기는 귀찮으니 MAKEWORD라는 매크로 함수를 사용한다.

버전이 1.2 버전이면 MAKEWORD(1, 2); 로 함수를 호출하면 간단히 WORD형 버전 정보를 구성할 수 있다.

 

두번째 매개변수 lpWSAData에는 SWADATA 구조체 변수의 주소 값을 인자로 전달한다.

(LPWSADATA 는 WSADATA의 포인터형이다)

특별히 큰 의미를 지니지 않지만, 함수 호출을 위해서 반드시 SWADATA 구조체 변수의 주소 값을 전달해야한다.

 

갑자기 모르는 함수와 단어들이 나왔지만 모든 윈도우 소켓 기반 프로그래밍에 공식처럼 사용된다.

대략적인 예제 코드를 살펴보자.

int main(int argc, char* arvg[])
{
	SWADATA swaData;
	...
		if (WSAStartup(MAKEWORD(2, 2), &swaData) != 0)
			ErrorHandling("SWAStartup() error!");
	...
	return 0;
}

예제 코드는 살펴보기만 하도록하고, 지금까지 윈도우 소켓 관련 라이브러리의 초기화 방법에 대해서 설명하였으니, 이번에는 초기화된 라이브러리의 해제 방법을 알아보자.

 

WSACleanup 함수를 통해 윈도우 소켓 라이브러리를 해제할 수 있다.

#include <winsock2.h>
int WSACleanup(void); // 성공 시 0, 실패 시 SOCKET_ERROR 반환

 

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

 

 

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

앞서 설명한 리눅스 소켓 함수에 대응하는 윈소우 소켓 함수들을 알아보자.

또 새로운 함수가 나타나서 짜증날 수 있지만 리눅스 기반의 소켓 함수와 윈도우 기반의 소켓 함수에 큰 차이점이 없음을 느낄 수 있을것이다.

 

윈도우 기반 소켓관련 함수들

리눅스의 socket 함수와 동일한 기능을하는 함수를 알아보자.

#include <winsock.h>
SOCKET socket(int af, int type, int protocol); // 성공 시 소켓 핸들, 실패시 INVALID_SOCKET 반환

 

다음 함수는 리눅스의 bind 함수와 동일한 기능을 제공한다.

즉, IP주소와 PORT 번호의 할당을 목적으로 호출되는 함수이다.

#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 반환

 

이로써 윈도우 기반의 소켓 함수들에 대해 살펴봤는데, 반환형과 매개변수형에는 차이가 있지만, 기능별로 함수의 이름은 동일함을 알 수 있을 것이다. 

 

 

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

리눅스는 내부적으로 소켓도 파일로 취급하기때문에 파일이든 소켓이든 둘 다 생성하면 파일 디스크립터가 반환된다.

(반환되는 파일 디스크립터의 값도 순서대로 넘버링 되는것을 앞의 예제를 통해 확인하였다)

 

마찬가지로 윈도우에서도 시스템 함수의 호출을 통해 파일을 생성할때 '핸들(handle)' 이라는 것을 반환한다.

즉, 윈도우에서 핸들은 리눅스에서의 파일 디스크립터에 비교될 수 있다.

그런데 윈도우는 리눅스와 달리 파일 핸들과 소켓 핸들을 구분하고 있다. 때문에 파일 핸들 기반의 함수와 소켓 핸들 기반의 함수에 차이가 있다. 이점이 파일 디스크립터와 다른 점이라고 볼 수 있다.

 

 

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

리눅스 기반 서버와 클라이언트 예제를 윈도우 기반으로 변경해서 작성해보자.

코드는 리눅스할때와 마찬가지로 서버를 열고, 클라이언트가 서버와 연결되면 "Hello World" 를 출력하는 코드이다.

 

참고로 지금 이 코드를 전부 이해하는것은 무리가 있으니 전체적으로 소켓 관련 함수가 호출되는 부분이 어디인지,

소켓 라이브러리의 초기화와 해제가 진행되는 부분을 확인하는 정도로만 코드를 작성해보자.

(서버 코드, 클라이언트 코드를 각각 다른 프로젝트에 코드를 작성해주자)

// hello_server.win.c
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#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("WSAStarup() 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) == SOCKET_ERROR)
		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
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#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("WSAStarup() 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* message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

두가지 코드를 각각 다른 프로젝트의 파일로 생성하고 작성해준다

두 프로젝트를 컴파일해주자.

이제 cmd 창에서 hello_server_win.c 를 실행해서 서버를 열고

hello_client.c를 실행해서 Hello World가 출력되는지 확인할 것이다.

 

리눅스와는 다르게 코드의 실행파일인 .exe를 실행해야하기때문에 실행파일이 어딨는지 경로를 알아야한다. 

 

작성한 프로젝트의 솔루션에 우클릭으로부터 시작해서 실행파일이 있는 경로를 찾아보자.

솔루션 우클릭 -> 파일 탐색기에서 폴더 열기 -> (폴더가 떴으면) Debug 폴더에 들어간다.

 

 

cmd창에서 실행할 .exe파일은 Debug 폴더에 저장되어있다.

 

이제 윈도우키 + R에 cmd를 입력해서 cmd창을 띄우고, Debug가 있는 경로로 접속해보자.

 

필자는 D 드라이브에 프로젝트 파일이 있기때문에 C드라이브에서 실행창에 D: 를 입력해서 이동해주어야한다.

그리고 Debug 디렉터리까지 이동해야하므로 cd(체인지 디렉터리) 명령어를 이용해 이동해준다.

 

Debug 디렉터리에서 서버와 클라이언트 실행파일이 있는지 확인해보자. 

리눅스에서는 ls 명령어인데, 윈도우 cmd창에서는 dir 명령어를 입력한다.

Debug 디렉터리 안에 서버, 클라이언트 실행 파일이 있는지 확인

 

이제 hello_server.exe를 실행해서 서버를 열고, hello_client.exe 를 실행해서 Hello World 출력을 확인해보자.

(cmd창에서 파일 실행은 .\(파일이름) 으로 할 수 있다)

Hello World 출력

 

 

윈도우 기반 입출력 함수

리눅스는 소켓도 파일로 간주하기 때문에 파일 입출력 함수인 read와 write를 이용해서 데이터를 송수신 할 수 있다.

그러나 윈도우는 파일 입출력 함수와 소켓 입출력 함수가 구분된다. 

따라서 이번에는 윈도우 소켓 기반의 데이터 입출력 함수를 알아보자.

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

send 함수를 리눅스의 write 함수와 비교해보면, 마지막 매개변수 flags 가 존재하는 것 외에는 차이가 없다.

참고로 flags 자리에는 나중에 언급되기때문에 일단은 아무런 옵션을 설정하지 않는 의미로 0을 전달하면된다.

 

그런데 여기서 주의할 점이 하나 있는것이 send 함수가 리눅스에도 이름이 동일한 함수가 존재한다.

따라서 헷갈리지 않게 리눅스에서는 당분간 read, write 함수만 사용하도록 하자.

이는 리눅스의 파일 입출력과 소켓 입출력의 동일함을 강조하기 위함이다.

 

그러나 윈도우 기반에서는 리눅스의 read, write 함수를 사용할 수 없으므로 recv 함수를 알아보자.

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

 

 

반응형