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

회고하는 글에서도, 윈도우즈 시스템 프로그래밍 글에서도 이야기했던 TCP/IP 소켓 프로그래밍을 정리해보려고 합니다.

매번 정리하겠다 해놓고 이제서야 복습 차원에서 정리를 하게 되었습니다.

내용은 계속 정리를 하겠지만 학원을 다니기 시작하면 예전처럼 글이 1~2일 간격으로 올라오긴 어려울 것 같습니다.

[공부하기에 앞서]

공부에 사용한 서적은 '윤성우의 열혈 TCP/IP 소켓 프로그래밍'입니다.

 

책 링크

 

윤성우의 열혈 TCP/IP 소켓 프로그래밍

눅스와 윈도우 기반에서의 소켓 프로그래밍을 함께 설명하며, 초보자에게 적절한 설명과 예제를 통해서 소켓 프로그래밍에 대한 재미를 안겨주고자 한 책이다. 2003년도에 출간된 <열혈강의 TCP/I

www.aladin.co.kr

 

그리고 강의는 저자가 운영하는 카페에서 무료로 볼 수 있으니 따로 들어가서 보시는 것을 추천드립니다.

 

네이버 카페 링크

 

윤성우의 프로그래밍 스터디그룹 [C/... : 네이버 카페

윤성우의 스터디 공간입니다. C와 JAVA를 공부하시는 분들은 모두 들어오세요. ^^

cafe.naver.com

 

네트워크 프로그래밍을 시작하기 전에 반드시 알아야 할 내용이 좀 있습니다.

C언어를 기반으로 진행하기 때문에 못해도 C언어 책을 한 권은 다 읽었다는 가정 하에 진행을 하게 됩니다.

모르는 부분이 있으면 그 부분에 대해서는 따로 찾아보시고 이해하고 넘어가시는 것을 적극 권장드립니다.

그리고 책에서 진행했던 것과 마찬가지로 리눅스와 윈도우 양쪽 다 진행할 생각입니다.

리눅스와 관련된 내용을 먼저 다루면 윈도우는 덤으로 가져가는 느낌이기 때문에 같이 해도 큰 문제는 없습니다.

리눅스나 윈도우를 잘 다뤄야하지 않을까 하는 걱정이 있을 수도 있습니다.

컴파일만 할 줄 알면 되니까 문제가 될 부분은 크게 없습니다.

막상 진행하다보면 시스템 프로그래밍에 대해서 다루기도 합니다.

시스템 프로그래밍을 몰라도 괜찮습니다.

책에서 대략적인 부분은 다루고 있기 때문에 같이 배워나가면 됩니다.

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

C언어를 배우면서 초반부에서는 printf나 scanf를 통한 콘솔 입출력을 배우게 됩니다.

뒤에 가서는 파일 입출력을 배우는데 여기서 fopen과 fclose와 같은 함수를 사용하는 것도 아실거에요.

C언어를 제대로 공부를 하셨다고 한다면 콘솔 입출력과 파일 입출력은 상당히 유사하다는 것을 느끼실겁니다.

네트워크 프로그래밍도 어떤 의미에서 보면 파일 입출력과 굉장히 유사합니다.

그래서 실제로 공부를 해보면 큰 어려움이 없이 접할 수 있는 내용입니다.

[네트워크 프로그래밍]

"네트워크 프로그래밍"이라는 것은 간단하게 요약하면 다음과 같습니다.

 

"네트워크로 연결된 서로 다른 두 컴퓨터가 데이터를 주고 받는 일"

 

실제로 하는 일도 그렇고, 복잡한 개념이 아니라는 것입니다.

그럼 네트워크 프로그래밍을 하기 위해서 필요한 것들을 한 번 생각해봅시다.

 

1. 물리적인 연결이 필요(H/W)

실제로 인터넷과 같은 네트워크에 물리적으로 연결이 되어있어야 합니다.

요즘 인터넷 선 꽂을 곳이 없는 집은 없습니다.

심지어 와이파이도 어지간한 곳이면 다 터지는 세상이고요.

인터넷이 안되는 곳은 없다고 보면 되니까 이 부분은 크게 신경 쓸 부분은 아닙니다.

 

2. 소프트웨어(S/W)적인 데이터의 송수신 방법이 필요

이 부분도 사실 우리가 크게 고민을 할 필요는 없는 부분입니다.

OS에서 '소켓(Socket)'이라는 수단을 제공하고 있습니다.

그래서 H/W적 측면에서는 랜 케이블을, S/W적 측면에서는 소켓을 이용하면 데이터를 주고 받을 수 있습니다.

데이터가 송수신되는 원리를 정확하게는 모르더라도 소켓을 통해서 데이터를 주고 받을 수 있다는 것만 알면 됩니다.

이처럼 네트워크 프로그래밍은 소켓을 기반으로 하기 때문 '소켓 프로그래밍'이라고 부르기도 합니다.

[소켓(Socket)]

앞서 말했다시피 소켓(Socket)OS에서 제공하는 네트워크를 연결하기 위한 S/W적인 도구입니다.

여기서도 TCP를 사용할 것인지, UDP를 사용할 것인지에 따라 소켓의 종류가 나뉠 수도 있습니다.

일단 이런 세세한 부분은 나중에 생각하기로 하고 TCP를 기준으로 먼저 설명을 해볼까 합니다.

 

소켓이라는 것은 쉽게 비유하면 '전화기나 스마트폰'을 생각할 수 있습니다.

전화기는 전화선으로 물리적인 연결을 하고, 전화망을 통해 음성이라는 데이터를 주고 받는 도구입니다.

스마트폰도 통신사의 네트워크에 연결이 되고, 통신망을 통해서 음성 외에 다양한 데이터를 주고 받죠.

그래서 소켓S/W적으로 만들어진 하나의 전화기 또는 스마트폰이라고 생각하면 됩니다.

 

일반적으로 전화기나 스마트폰은 전화를 거는 것과 받는 것이 동시에 가능합니다.

그렇지만 소켓은 연락을 거는 용도의 소켓이 따로, 연락을 받는 용도의 소켓이 따로 있습니다.

만들어지는 과정도 약간의 차이가 있고요.

우선은 연락을 받기 위한 소켓을 만드는 과정을 먼저 설명하려고 합니다.

 

[연락요청 받는 소켓 만들기]

1. socket()

"소켓을 만든다 == 전화기 또는 폰을 하나 장만한다"

소켓을 만드는 것 자체는 전화기나 스마트폰을 하나 장만하는 것과 같습니다.

일단 전화기나 폰이 있어야 통화를 받던 걸던 할 수 있을거 아니겠습니까?

소켓을 만드는 함수는 다음과 같습니다.

 

지금은 "socket이라는 함수를 사용하면 소켓을 생성한다" 정도로만 일단 이해하시면 됩니다.

함수에 전달되는 인자나 반환값에 대한 부분은 이후 글을 다루면서 추가로 다룰 예정입니다.

2. bind()

"소켓에 주소를 할당한다 == 기기에 전화번호를 부여한다"

기기를 장만했으면 다음은 번호를 받아야겠죠?

개통할 때 전화번호를 부여받는 것처럼 여기서도 소켓에 전화번호와 마찬가지로 주소 정보를 부여해야 합니다.

주소 정보는 IP와 Port라는 것이 있습니다.

 

"bind라는 함수를 사용하면 주소정보를 소켓에 할당한다"로 이해하면 됩니다.

3. listen()

"소켓을 연결요청이 가능한 상태로 만든다 == 기기가 망에 연결되어 연락가능한 상태가 된다"

이제 번호까지 부여를 받았으니 누군가가 연락을 하면 받을 수 있는 상태가 되었습니다.

소켓도 주소정보까지 모두 부여가 되었으니 이제 누군가가 연결 요청을 하면 연결을 할 수 있는 상태로 만들어줘야 합니다.

 

listen이라는 함수를 통해 소켓에 할당된 IP와 Port번호로 연결요청이 가능한 상태로 만들게 됩니다.

4. accept()

"연결요청이 들어오면 수락한다 == 전화를 받는다"

전화를 받는다는 것은 내 전화기나 폰으로 통화를 걸었다는 것을 의미합니다.

소켓도 마찬가지로 누군가에게 연결요청이 들어오면?

그 연결요청을 수락해서 통신을 시작하면 됩니다.

 

연결요청을 수락하는 함수는 accept 함수입니다.

이 함수가 호출된 이후에는 데이터의 양방향 송수신이 가능합니다.

그리고 accept 함수는 연결요청이 있을 때에만 반환을 하는 함수라는 것까지 알아두시면 됩니다.

[과정 요약]

지금까지 설명한 내용은 네트워크 프로그래밍에서 연결요청을 허용하는 소켓을 만드는 과정이었습니다.

이제 이걸 간략하게 요약해보겠습다.

 

Step1) 소켓 생성 - socket()

Step2) IP주소와 Port 번호 할당 - bind()

Step3) 연결요청 가능한 상태로 변경 - listen()

Step4) 연결요청 수락 - accept()

 

이와 같은 순서로 연결요청을 허용하는 소켓을 만들게 됩니다.

소켓을 만드는 순서는 앞으로도 계속 이렇게 진행하게 되므로 잘 기억해두시길 바랍니다.

이 흐름을 잘 이해한다면 앞으로 소켓을 어떻게 만들어야 하는지에 대한 대략적인 밑그림이 그려지게 됩니다.

["Hello World!"를 출력하는 Server 프로그램 구현]

일반적으로 연결요청을 받고 대기하며, 요청을 수락하는 쪽 '서버(Server)'라고 합니다.

이제 앞에서 요약한 내용을 토대로 간단한 예제 코드를 보일까 합니다.

연결요청을 수락하면 클라이언트에 "Hello World!"라고 응답하는 간단한 서버입니다.

 

[hello_server.c]

/*
* TCP/IP Socket Programming - 네트워크 프로그래밍과 소켓의 이해
* 파일명: hello_server.c
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: 간단한 소켓 프로그래밍 예제 - Server
* 이전 버전 내용:
*/

#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);
}

 

앞에서 함수의 인자나 다른 함수들에 대해서 자세한 설명을 하지 않았습니다.

그래서 이 코드를 그대로 이해하기는 어려울 것입니다.

일단은 socket - bind - listen - accept의 흐름을 따라서 소켓을 만든다는 사실만 기억해두시길 바랍니다.

이처럼 서버 프로그램에서 생성하는 소켓을 '서버 소켓' 또는 '리스닝 소켓'이라고 합니다.

그리고 accept 함수는 연결요청이 들어오기 전까지는 반환하지 않는 블로킹 함수라는 사실만 추가로 기억해두면 됩니다.

[연락요청을 하는 소켓 만들기]

소켓은 연락을 거는 용도의 소켓과 연락을 받는 용도의 소켓이 따로 있다고 했습니다.

위에서는 연락을 받는 용도의 소켓을 만들었으니, 이제 연락을 거는 용도의 소켓을 만들어야겠죠?

연락을 받는 쪽을 서버라고 했으니, 연락을 거는 쪽은 뭐라고 할까요?

'클라이언트(Client)'라고 합니다.

그리고 클라이언트 프로그램에서 생성하는 소켓'클라이언트 소켓'이라고 합니다.

이걸 만드는 과정을 알아보겠습니다.

여기서 추가로 알아야하는 함수는 connect 함수입니다.

 

이건 앞의 비유를 사용한다면 전화기(또는 폰)을 이용해 전화를 거는 상황으로 볼 수 있습니다.

서버 소켓을 만드는 과정socket - bind - listen - accept의 과정을 거쳤었죠.

클라이언트 소켓을 만드는 과정을 위의 과정보다 훨씬 간단합니다.

socket - connect의 과정만 거치면 되거든요.

이제 클라이언트 소켓을 만드는 예제코드를 확인해보겠습니다.

 

[hello_client.c]

/*
* TCP/IP Socket Programming - 네트워크 프로그래밍과 소켓의 이해
* 파일명: hello_client.c
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: 간단한 소켓 프로그래밍 예제 - Client
* 이전 버전 내용:
*/

#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);
}

 

서버 소켓을 만들던 코드와 마찬가지로 전체적인 내용을 지금 이해할 필요는 없습니다.

지금은 socket-connect의 과정을 통해서 클라이언트 소켓이 만들어진다는 것만 기억해두시면 됩니다.

참고로 위의 두 예제코드(hello_server.c, hello_client.c)는 리눅스 환경에서 컴파일을 하고 실행을 해야합니다.

가상 머신을 활용해서 리눅스 환경을 구성하셔도 좋고, 리눅스 환경을 사용하고 있다면 그대로 사용하시면 됩니다.

 

gcc 컴파일러를 이용해서 소스파일을 다음과 같이 컴파일하면 됩니다.

 

그리고 생성한 파일은 다음과 같이 실행하면 됩니다.

프로그램의 실행은 서버-클라이언트 순서로 하면 됩니다.

[리눅스 기반 파일 조작하기]

소켓을 이야기하는 중에 갑자기 파일 조작 이야기가 왜 나오나 싶을겁니다.

리눅스에서는 '소켓 조작 == 파일 조작'으로 동일하게 간주합니다.

다시 말해서 리눅스에서 소켓은 파일의 일종으로 본다는 것이죠.

그렇기 때문에 파일 입출력 함수를 소켓 입출력에 사용하는 것이 가능합니다.

이걸 바꿔서 말하면 파일 입출력 함수를 네트워크 상에서의 데이터 송/수신에 사용할 수 있다는 것입니다.

참고로 윈도우에서는 리눅스와 다르게 파일과 소켓을 달리 구분하고 있습니다.

그래서 윈도우는 별도로 데이터 송수신 함수를 사용해야 되며 이는 뒤에서 설명하겠습니다.

[저 수준 파일 입출력(Low-level File Access)과 파일 디스크립터(File Descriptor)]

저 수준 파일 입출력이라고 해서 무슨 어셈블리와 같이 어려운 개념일거라는 생각이 들 수도 있습니다.

여기서 말하는 '저 수준''표준에 상관없이 OS에서 독립적으로 제공하는~'이라고 생각하면 됩니다.

다시 말하면 앞으로 설명하게 될 함수들은 '리눅스에서 제공하는 함수'이며, ANSI C 표준 함수가 아닙니다.

 

우리가 알고 있는 C언어에서의 파일 입출력 함수는 fopen, fclose와 같은 함수들입니다.

이런게 ANSI C 표준 함수입니다.

ANSI C 표준 함수는 윈도우나 리눅스, 유닉스등 운영체제에서 다 사용할 수 있는 함수들입니다.

엄밀히 따지면 ANSI C 표준 함수도 OS에서 제공하는 시스템 함수를 사용하게 됩니다.

그래서 다른 OS에서 해당 OS에 맞춰서 동작하기 때문에 ANSI C 표준 함수를 동일하게 사용할 수 있는 겁니다.

 

이제 다시 본론으로 넘어와서 리눅스 쪽 이야기로 넘어오겠습니다.

우리가 ANSI C 표준 파일 입출력 함수를 사용할 때에는 파일 포인터(FILE*)를 사용했습니다.

그런데 리눅스에서 제공하는 파일 입출력 함수를 사용하기 위해서는 파일 디스크립터(File Descriptor)를 사용합니다.

여기서 파일 디스크립터란, OS가 만든 파일(소켓)을 구분하기 위해서 부여한 정수입니다.

콘솔 입출력을 하면서 사용한 표준 입출력과 에러(stdin, stdout, stderr)도 리눅스에서는 파일 디스크립터를 할당합니다.

 

일반적으로 파일과 소켓은 생성의 과정을 거쳐야 파일 디스크립터가 할당이 됩니다.

위의 셋은 별도의 생성과정을 거치지 않더라도 프로그램이 실행되면 자동으로 할당이 된다는 것만 알아두시면 됩니다.

[파일 열기]

리눅스에서 데이터를 읽거나 쓰기 위해서 파일을 열 때 사용하는 함수를 보도록 하겠습니다.

ANSI C 표준 파일 입출력에서 fopen을 썼던 것을 생각하면 크게 어렵지는 않습니다.

 

open이라는 함수를 사용하며, 두 개의 인자를 전달받습니다.

첫 번째 인자에는 대상이 되는 파일의 이름 또는 경로 정보를 넣습니다.

그리고 두 번째 인자에는 파일의 오픈 모드 정보를 전달하는데, 사용할 수 있는 flag는 다음과 같습니다.

 

[파일 닫기]

ANSI C 표준 파일 입출력 함수를 사용하면서도 fopen을 하면 fclose를 통해 파일을 닫았습니다.

리눅스의 시스템 함수도 마찬가지로 파일을 개방했으면 닫아줘야 합니다.

 

close 함수를 사용하며, 여기서 전달하는 인자는 open을 통해 생성된 파일 디스크립터를 전달하면 됩니다.

[파일에 데이터 쓰기]

ANSI C 표준 파일 입출력 함수에서는 파일에 데이터를 쓸 때 fwrite라는 함수를 썼습니다.

그럼 여기서는? write 함수를 사용하여 파일에 데이터를 출력(전송)하게 됩니다.

 

이제 파일을 열고 닫는 방법과 파일에 데이터를 쓰는 방법까지는 알게 되었습니다.

간단한 예제를 통해서 리눅스에서 파일을 작성하고 데이터를 쓰는 방법을 확인해보도록 하겠습니다.

 

[low_open.c]

/*
* TCP/IP Socket Programming - 네트워크 프로그래밍과 소켓의 이해
* 파일명: low_open.c
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: 간단한 파일 디스크립터 예제
* 이전 버전 내용:
*/

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

int main(int argc, char* argv[])
{
	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);
}

 

코드 자체는 굉장히 단순한 예제입니다.

그나마 눈여겨 볼만한 부분은 open 함수의 flag 인자로 |(OR) 연산자를 통해 묶어서 전달할 수 있다는 것입니다.

[파일에 저장된 데이터 읽기]

이제 ANSI C 표준 파일 입출력 함수의 fread와 대응하는 함수만 남았습니다.

눈치가 빠르시다면 이미 함수 이름도 짐작하셨을거라 생각합니다.

맞습니다! 리눅스에서는 read 함수를 통해 파일의 데이터를 읽어들이는 것이 가능합니다.

 

이번에는 read 함수를 이용해서 생성한 파일을 읽어보는 예제를 보도록 하겠습니다.

 

[low_read.c]

/*
* TCP/IP Socket Programming - 네트워크 프로그래밍과 소켓의 이해
* 파일명: low_read.c
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: 간단한 파일 디스크립터 예제(2) - read
* 이전 버전 내용:
*/

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

#define BUF_SIZE 100

void error_handling(char* message);

int main(int argc, char* argv[])
{
	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);
}

 

별도의 코드 분석이 필요가 없을 만큼 간단한 예제이기 때문에 더 설명을 하지는 않겠습니다.

[파일 디스크립터와 소켓]

리눅스에서 지원하는 파일 입출력 함수에 대해서는 설명이 끝났습니다.

이번에는 파일과 소켓을 생성하고 반환되는 파일 디스크립터의 값을 확인하는 예제를 보겠습니다.

 

[fd_seri.c]

/*
* TCP/IP Socket Programming - 네트워크 프로그래밍과 소켓의 이해
* 파일명: fd_seri.c
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: 간단한 파일 디스크립터 예제(3) - 파일과 소켓 모두 생성
* 이전 버전 내용:
*/

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

int main(int argc, char* argv[])
{
	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;
}

 

결과는 아마 3, 4, 5의 순서대로 출력이 되었을 겁니다.

물론 다르게 나온 분들도 있을텐데, 1씩 순서대로 커지는 것은 다르지 않을 것입니다.

만약 3, 4, 5의 순서로 나왔다면 왜 3부터 시작하는지 의문이 생길겁니다.

표준 입출력과 에러에 0~2까지 파일 디스크립터가 먼저 할당되었기 때문에 그 이후의 번호가 할당되었기 때문입니다.

[윈도우 기반으로 구현하기]

이제 리눅스 기반에서의 이야기는 마치고, 윈도우 기반에서 소켓 프로그래밍에 대한 이야기를 해볼까 합니다.

Windows Socket, 이하 WinSock('윈속'이라고 하겠습니다.)은 BSD 계열 Unix 소켓을 참고하여 만들어졌습니다.

다시 말하면, 윈속의 많은 부분이 리눅스의 소켓과 유사하다는 것입니다.

그래서 리눅스 기반의 네트워크 프로그램의 일부를 변경하면  윈도우에서 실행할 수 있습니다.

실제로 책에서도 리눅스 기반으로 먼저 설명하고, 그 다음 윈도우와 관련된 부분을 설명합니다.

[WinSock 프로그래밍을 위한 헤더와 라이브러리 설정]

윈속 기반의 프로그램을 개발하기 위해서는 기본적으로 두 가지의 작업이 선행되어야 합니다.

 

1) 라이브러리를 링크시켜야 한다.

2) 헤더파일 WinSock2.h를 포함시킨다.

 

아마 윈도우즈 시스템 프로그래밍의 DLL 부분을 보고 오신 분이라면 왜 이렇게 해야하는 건지 아실겁니다.

그리고 라이브러리를 링크시키는 방법은 여기서 별도로 다뤘습니다.

[라이브러리의 활용] 부분을 보시면 되며, 본인에게 맞는 방법을 사용하면 됩니다.

저는 매번 속성 창에 들어가서 라이브러리를 추가로 링크하는 과정이 귀찮아서 코드에 추가하는 방식을 사용했습니다.

 

https://sevenshards.tistory.com/79

 

[Windows System Programming] Dynamic Linking Library

'뇌를 자극하는 윈도우즈 시스템 프로그래밍'의 마지막 장입니다. DLL은 아마 게임을 하면서도, 프로그래밍을 하면서도 종종 보셨을 놈입니다. '~~.dll이 없습니다'라는 오류문구를 보면 짜증도 나

sevenshards.tistory.com

라이브러리를 포함했으면 다음에는 헤더파일을 포함해야 합니다.

그 이유는 간략하게 설명하자면, 라이브러리에는 윈속 프로그래밍을 할 수 있는 바이너리 코드가 포함되어 있습니다.

그리고 이 라이브러리에 있는 함수를 갖다 쓰기 위해서는 함수의 선언이 있는 헤더 파일이 필요합니다.

그래서 WinSock2.h라는 헤더파일을 추가하는 것입니다.

[WinSock의 초기화]

라이브러리와 헤더파일까지 추가를 했습니다.

이제 윈속 프로그래밍을 하기 위해서 반드시 해야할 일이 있습니다.

바로 윈속을 초기화하는 것입니다.

여기서 사용되는 함수는 다음과 같습니다.

 

WSAStartup이라는 함수를 통해서 윈속을 초기화하는 과정을 거쳐야 합니다.

그런데 '윈속을 초기화한다'라는 말이 이해가 잘 안될 수 있습니다.

'윈속을 초기화한다'는 말은 윈속 함수 호출을 위한 라이브러리를 메모리로 LOAD하는 것을 뜻합니다.

다시 말해서 이 과정을 거치지 않으면 라이브러리를 링크하고 헤더파일을 추가해도 아무런 의미가 없다는 말입니다.

 

위는 윈속을 초기화하는 예시입니다.

여기서 첫 번째 인자로는 윈속의 버전정보를 전달한다고 하는데, MAKEWORD(2, 2)라는 값이 들어가 있습니다.

실제로 첫 번째 인자에는 0x0201, 0x0202와 같은 값을 전달해야 합니다.

윈속에도 버전이 따로 있는데, 만약 1.2 버전을 사용한다고 하면 1이 주 버전, 2가 부 버전이 됩니다.

그래서 전달하는 인자는 0x0201이 됩니다.

만약 2.2 버전을 사용한다고 하면 0x0202가 되겠죠.

이처럼 상위 8비트에는 부 버전 정보를 하위 8비트에는 주 버전 정보를 표시해서 인자로 전달하게 됩니다.

그런데 매번 이렇게 입력하는 것은 좀 번거롭습니다.

이를 간편하게 하기 위해 정의된 매크로 함수가 바로 MAKEWORD입니다.

만약 1.2버전을 사용한다면 MAKEWORD(1, 2), 2.2버전을 사용한다면 MAKEWORD(2, 2)를 전달하면 됩니다.

앞으로도 윈속 2.2버전을 기준으로 진행할 것이기 때문에 MAKEWORD(2, 2)를 전달하면 됩니다.

 

두 번째 인자는 WSADATA 구조체 변수의 주소값을 인자로 전달하게 됩니다.

WSADATA 구조체에는 초기화된 라이브러리의 정보가 채워지게 됩니다.

구조체에 있는 데이터를 사용할 일은 극히 드물다고 합니다.

제가 책을 끝까지 읽으면서도 이 데이터를 다룰 일은 없었습니다.

하지만 윈속 초기화 과정에서는 반드시 필요하기 때문에 공식처럼 사용된다고 생각하면 됩니다.

 

이제 윈속을 초기화하는 방법을 알았으니 초기화된 라이브러리를 해제하는 함수를 알아보겠습니다.

 

WSACleanup이라는 함수를 사용하면 윈속 라이브러리를 해제하게 됩니다.

이 함수가 호출되면 할당된 윈속 라이브러리는 OS에 반환이 되면서, 윈속 관련 함수를 더 이상 호출할 수 없습니다.

그래서 위 함수를 호출할 때는 프로그램이 종료되기 직전에 호출하는 것이 일반적입니다.

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

리눅스 기반의 소켓 관련 함수에 대응하는 윈도우 기반의 소켓, 윈속 함수들을 알아보겠습니다.

그런데 실제로 리눅스 기반의 소켓 관련 함수와 큰 차이가 없기 때문에 자세한 설명은 하지 않겠습니다.

직접 보시면 그 이유를 아실겁니다.

[윈도우 기반 소켓관련 함수]

1. socket()

 

소켓 생성에 사용되는 socket 함수입니다.

리눅스와 동일하며, 다른 점은 반환형이 SOCKET이라는 것입니다.

윈도우즈 시스템 프로그래밍을 공부하고 오신 분들이라면 소켓 역시 커널 오브젝트라는 것을 아실겁니다.

그래서 리눅스는 파일 디스크립터라는 정수가 반환된다면 윈도우는 핸들(HANDLE)이라는 값을 반환하게 됩니다.

2. bind()

 

보다시피 리눅스에서 사용하는 bind 함수와 완전히 동일합니다.

3. listen()

 

마찬가지로 리눅스의 listen과 동일합니다.

4. accept()

 

연락요청을 수락하는 accept 함수 역시 차이가 없습니다.

5. connect()

 

연락요청을 보내는 함수인 connect 함수 또한 리눅스와 차이가 없습니다.

6. closesocket()

 

리눅스의 close 함수와 대응되는 함수인 closesocket 함수입니다.

뒤에서도 설명할 내용이지만, 리눅스에서는 파일과 소켓을 동일하게 취급합니다.

그래서 close라는 함수를 통해 파일이나 소켓을 똑같이 종료하는게 가능합니다.

하지만 윈도우에서는 파일과 소켓을 달리 취급합니다.

그래서 소켓의 종료를 위한 closesocket이라는 함수가 따로 있는 것입니다.

OS에서 취급하는 방식이 다르기 때문에 이름만 다를 뿐, 크게 다른 부분은 없습니다.

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

위의 함수들을 보면서 아마 느끼셨겠지만, 리눅스와 윈도우에서 사용되는 함수의 차이는 거의 없습니다.

그나마 차이라면 리눅스에서는 파일 디스크립터를, 윈도우는 핸들을 사용한다는 차이가 전부입니다.

여기서 핸들(HANDLE)에 대한 개념을 좀 정리하려고 합니다.

앞에서 리눅스는 파일과 소켓을 동일하게 취급한다고 했습니다.

반면에 윈도우에서는 파일과 소켓을 달리 취급한다고 했죠.

리눅스와 유사하게 윈도우에서는 시스템 함수의 호출을 통해 핸들이라는 것을 반환하게 됩니다.

그리고 파일과 소켓을 따로 구분한다는 차이점이 있습니다.

좀 더 알고 싶으신 분들은 아래의 글을 참고하시면 됩니다.

https://sevenshards.tistory.com/45

 

[Windows System Programming] 커널 오브젝트와 오브젝트 핸들

[커널 오브젝트에 대한 이해] 저를 포함해서 운영체제와 관련된 공부를 하면서 아마 '커널(Kernel)'이라는 단어를 한 번쯤은 들어봤을겁니다. 우리가 지금 쓰는 운영체제는 기본적인 요소 외에도

sevenshards.tistory.com

[윈도우 기반 서버, 클라이언트 예제]

앞에서 윈속에서 사용되는 소켓관련 함수들에 대해서 소개를 했습니다.

그리고 간략하게나마 윈도우에서 사용되는 핸들에 대한 개념도 소개를 했고요.

이제 윈도우 기반에서 구현할 때는 리눅스와 어떤 차이가 있는지 예제를 통해서 확인해보겠습니다.

 

[hello_server_win.c]

/*
* TCP/IP Socket Programming - 네트워크 프로그래밍과 소켓의 이해
* 파일명: hello_server_win.c
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: 간단한 소켓 프로그래밍 예제 - Server, Windows, MBCS기반
* 이전 버전 내용:
*/

#pragma comment(lib, "ws2_32.lib")

#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>

void ErrorHandling(const 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) // WinSock 라이브러리 초기화, 소켓 버전 2.2
		ErrorHandling("WSAStartup() error!");

	hServSock = socket(PF_INET, SOCK_STREAM, 0); // 리눅스의 socket 함수와 동일, 소켓 (핸들)생성
	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) // 리눅스의 bind와 동일, 소켓에 주소 정보 할당
		ErrorHandling("bind() error");

	if (listen(hServSock, 5) == SOCKET_ERROR) // 리눅스의 listen과 동일, 소켓 통신 대기 상태
		ErrorHandling("listen() error");

	szClntAddr = sizeof(clntAddr);
	hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &szClntAddr); // 리눅스의 accept와 동일, 소켓 통신 수락
	if (hClntSock == INVALID_SOCKET)
		ErrorHandling("accept() error");

	send(hClntSock, message, sizeof(message), 0); // 리눅스의 write
	closesocket(hClntSock); // 리눅스에서는 파일과 소켓이 동일하므로 close
	closesocket(hServSock); // Windows에서는 파일과 소켓은 다르므로 closesocket이 따로 있음. CloseHandle과 비슷하다고 보면 될 듯.
	WSACleanup(); // WinSock 라이브러리 해제, OS에 반환

	return 0;
}

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

 

[hello_client_win.c]

/*
* TCP/IP Socket Programming - 네트워크 프로그래밍과 소켓의 이해
* 파일명: hello_client_win.c
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-15
* 이전 버전 작성 일자:
* 버전 내용: 간단한 소켓 프로그래밍 예제 - Client, Windows, MBCS기반
* 이전 버전 내용:
*/

#pragma comment(lib, "ws2_32.lib")

#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
#include <WS2tcpip.h>

void ErrorHandling(const 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]);
	// 현재 iner_addr은 deprecated되어 곧 쓰지 않을 수 있음
	// 따라서 inet_pton() 또는 InetPton()을 사용할 것을 권장 -> header파일로 WS2tcpip.h 추가 필요
	// pton == pointer to network / ntop == network to pointer
	// #define _WINSOCK_DEPRECATED_NO_WARNINGS를 사용해도 되지만 여기서는 권장하는 방법을 사용해보려고 함
	// 반환값은 정수형
	inet_pton(AF_INET, argv[1], &servAddr.sin_addr);
	servAddr.sin_port = htons(atoi(argv[2]));

	// 리눅스에서의 connect
	if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
		ErrorHandling("connect() error!");

	// 리눅스에서의 read, 여기서는 recv(receive)
	strLen = recv(hSocket, message, sizeof(message) - 1, 0);
	if (strLen == -1)
		ErrorHandling("recv() error!");

	printf("Message from server: %s\n", message);

	closesocket(hSocket);
	WSACleanup();
	return 0;
}


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

 

여기서 따로 설명을 안했던 부분이 있는데, inet_pton을 쓰는 부분이 있습니다.

주석으로 설명을 추가로 달아놓았으니 한 번 읽어보시기 바랍니다.

이 부분은 리눅스에서도 똑같이 사용이 가능하므로 앞으로 작성하는 코드에서는 동일하게 통일하여 사용할 것입니다.

그리고 별도로 차이가 있는 부분은 WSAStartup과 WSACleanup 함수가 호출이 되는 부분이 있습니다.

또 하나는 read와 write가 아닌 send와 recv라는 함수가 사용된 것을 볼 수 있습니다.

[윈도우 기반 입출력 함수]

 

 

마지막으로 언급했던 차이점인 read와 write가 아닌 send와 recv라는 함수를 사용한 것을 확인하셨을겁니다.

이처럼 read와 write가 아닌 send와 recv를 쓰는 이유는 앞서 설명했던 리눅스와 윈도우의 차이입니다.

리눅스에서는 파일과 소켓을 동일하게 취급하기 때문에 파일 입출력 함수를 써도 상관이 없습니다.

하지만 윈도우에서는 파일과 소켓을 별도로 취급합니다.

그렇기 때문에 파일 입출력 함수와 소켓 기반의 입출력 함수를 따로 사용하게 되는 것입니다.

 

read와 write와 비교했을 때에는 함수의 이름이 다른 것.

그리고 네 번째 인자인 flags가 추가로 붙었다는 것 외에는 큰 차이는 없습니다.

우선 지금 시점에서 예제를 보면 flags에 0을 넣은 것을 보실 수 있는데, 일단 0을 넣는다고 알고 있으면 됩니다.

이에 대한 내용은 추후에 다루게 됩니다.

 

추가로 알아둬야 하는 것이 하나 더 있는데, send와 recv 함수는 윈도우에만 존재하는 함수가 아닙니다.

리눅스에도 send와 recv 함수가 있습니다.

앞서 윈속은 BSD 소켓을 기원으로 만들어졌다고 했었죠?

바꿔서 말하면 send와 recv는 BSD 소켓을 기원으로 하는 함수라는 것입니다.

엄밀히 따지면 리눅스의 소켓도 BSD 소켓이라고 볼 수 있다는 것입니다.

이 책에서는 리눅스에서는 파일과 소켓을 동일하게 처리한다는 것을 중점으로 두기 때문에 read와 write를 씁니다.

하지만 윈도우에서는 파일과 소켓을 동일하게 처리하지 않기 때문에 불가피하게 send와 recv를 쓴다는 것입니다.

그래서 절대로 리눅스의 read, write가 윈도우의 send, recv에 대응된다고 생각하면 안됩니다.

sevenshards