layered

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

네트워크/열혈 TCP IP 소켓 프로그래밍

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

스윗푸들 2023. 4. 27. 16:13

과제하면서 공부하는 거라 틀린 내용 있을 수 있음..

 

소켓 Socket


서로 다른 두 컴퓨터가 데이터를 주고받을 수 있게 하려면 어떻게 해야 할까?

일단 두 컴퓨터를 어떤 식으로든 연결해 줘야 하는데 이는 인터넷이라는 거대한 네트워크가 있으므로 해결이 된다.

그리고 이를 기반으로 데이터를 송수신하는 소프트웨어적인 방법을 고민해야 하는데, 이것도 운영체제에서 소켓이라는 것을 제공하므로 쉽게 풀린다.

 

그냥 네트워크 상에서 데이터를 주고받기 위해 필요한 것이 소켓이다! 정도로 이해하면 될 것 같다.

 

참고) 소켓이라는 표현은 전기를 공급받기 위해 소켓을 꽂는 것처럼, 데이터의 송수신을 위해 인터넷이라는 네트워크 망에 연결한다는 의미이다. 또는 네트워크를 통한 두 컴퓨터의 연결 자체를 뜻하기도 한다.

 

전화 받는 소켓의 구현


소켓에 대한 이해를 돕기 위해 간략하게 전화기에 빗대어 설명하는 부분이다.

일단 소켓을 다루기 위한 헤더 파일 하나를 불러 놓고 시작해 보자.

 

#include <sys/socket.h> // 리눅스
#include <WinSock2.h> // 윈도우

 

집에 전화를 하나 놓으려고 한다. 어떻게 해야 할까?

 

1 일단 전화기가 필요하다. 

소켓도 마찬가지이다. 일단 데이터를 받아주는 도구가 필요하다!

 

int socket(int domain, int type, int protocol); // 성공: 파일 디스크립터, 실패: -1
int socket(int af, int type, int protocol); // 성공: 새 소켓에 대한 참조(= 핸들), 실패: INVALID_SOCKET

 

뭔지는 잘 모르겠지만,일단 헤더 파일을 불러와서 소켓을 생성했다는 것 정도는 코드를 보고 짐작할 수 있다.

또한 윈도우에서는 소켓을 객체로 다루기 때문에, 반환값으로 '핸들 Handle'이라는 것을 이용한다(리눅스의 파일 디스크립터처럼 단순한 정수값이 아니라 SOCKET 자체이다).

 

윈도우에서 파일들을 다루기 위해 사용하는 핸들과, 이 소켓 핸들은 완전히 구분된다.

 

2 전화번호도 필요하다

구입만 해 놓으면 그냥 기계덩어리일 뿐이니까 일단 전화번호를 부여받아야 한다.

전화기는 이런 걸 통신사에서 해 주지만 소켓은 직접 해야 한다!

 

int bind(int sockfd, struct sockarrd *myaddr, socklen_t addrlen); // 성공: 0, 실패: -1
int bind(SOCKET s, const sockaddr *name, int namelen); // 성공: 0, 실패: SOCKET_ERROR

 

바운드되지 않은 소켓과, 주소를 저장하는 sockaddr 구조체를 이용해 소켓에 주소 정보를 할당한다.

 

3 전화기에 케이블을 연결해야 한다

전화번호를 받아서 나름 특별한 전화기가 되었지만, 여전히 기계덩어리이다. 걸려오는 전화를 받으려면 케이블을 연결해 줘야 한다.

마찬가지로 소켓도 연결 요청이 가능하게끔 상태를 설정해 줘야 한다.

 

int listen(int sockfd, int backlog); // 성공: 0, 실패: -1
int listen(SOCKET s, int backlog); // 성공: 0, 실패: SOCKET_ERROR

 

말그대로 소켓을 들을 수 있는 상태에 배치한다는 뜻이다.

 

4 전화가 걸려 오면 수화기를 들어야 한다

사람에게는 자연스러운 거겠지만 컴퓨터는 동작을 하나하나 정해 줘야 제대로 돌아간다는 것을 알 것이다.

따라서 요청을 받았을 때 응답하도록 함수를 설정해 줘야 한다.

 

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); // 성공: 파일 디스크립터, 실패: -1
SOCKET accept(SOCKET s, sockaddr *addr, int *addrlen); // 성공: 핸들, 실패: INVALID_SOCKET

 

연결 요청을 받았을 때 수락하는 함수이다.

 

이렇게 전화를 받는 것처럼 연결 요청을 수락하는 기능의 프로그램을 가리켜 '서버 Server'라고 한다. 서버에 사용되는 소켓은 '서버 소켓' 또는 '리스닝 소켓'이라고 한다.

 

연결을 요청하는 프로그램은 '클라이언트 Client'라고 한다. 클라이언트에서는 socket을 이용한 소켓의 생성과, 연결을 요청하는 connect 과정만 존재한다.

 

int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen); // 성공: 0, 실패: -1
int connect(SOCKET s, const sockaddr *name, int namelen); // 성공: 0, 실패: SOCKET_ERROR

 

상당수의 프로젝트에서는 서버를 리눅스 계열의 운영체제 기반으로, 클라이언트는 윈도우 기반으로 개발하는 경우가 많다.

따라서 윈도우와 리눅스를 서로 변경해야 하는 상황이 종종 발생하기 때문에 둘 다 공부할 필요가 있다.

 

1-2 리눅스 기반 파일 조작하기


리눅스에서의 소켓은 파일을 조작하는 것과 동일하게 간주된다.

음... 리눅스에서 파일 입출력을 해본 적이 없어서 일단은 간단하게만 알고 넘어가려 한다.

 

파일 디스크립터 File Descriptor

유닉스 시스템은 모든 것을 파일로 다룬다. 디렉터리도 파일이고 디바이스도 파일이고 소켓도 파일이다. 그래서 파일에 정수를 부여해서 관리하는데, 이것이 파일 디스크립터이다.

 

일반적으로 파일과 소켓은 생성해야 파일 디스크립터가 할당되지만, 밑의 세 가지는 프로그램이 실행되면 자동으로 할당된다.

 

파일 디스크립터 대상
0 표준입력: Standard Input
1 표준출력: Standard Output
2 표준에러: Standard Error

 

파일 열기

 

int open(const char *path, ing flag); // 성공: 파일 디스크립터, 실패: -1

 

path는 파일 이름을 나타내는 문자열의 주소값이고, flag는 파일의 오픈 모드 정보이다.

 

파일 닫기

파일 디스크립터를 인자로 전달하면 해당 파일이 종료된다. 소켓을 닫을 때에도 동일하게 적용된다.

 

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-3 윈도우 기반으로 구현하기


윈도우를 기반으로 소켓(이하 윈속)을 다루기 위해서는 먼저 두 가지를 해야 한다.

 

1 WinSock2.h 포함

소켓에 대한 여러 기능들이 들어 있는 헤더 파일이다.

 

2 Ws2_32.lib 링크

소켓을 다루는 대부분의 함수들은 Ws2_32.dll 내에 들어 있는데, 이는 실행 중에 링크되는 동적 라이브러리이다.

따라서 dll을 찾게 해 주는 정적 라이브러리를 링크해 줘야 하는데 이것이 Ws2_32.lib이다.

 

#include <WinSock2.h>
#pragma comment(lib, "Ws2_32.lib")

 

그다음 프로그램에서 요구하는 윈도우 소켓의 버전을 알리고, 해당 버전을 지원하는 라이브러리의 초기화 작업을 진행해야 한다.

 

int WSAStartup(WORD wVersionRequestd, LPWSADATA lpWSAData);

 

WSA는 Window Socket API로, 하나하나 살펴보면 이렇다.

 

WSAStartup

WinSock2.h에 들어 있는 함수로 애플리케이션 또는 dll에 의해 가장 먼저 호출되는 함수이다.

 

WORD wVersionRequestd

WORD는 CPU가 한 번에 처리할 수 있는 데이터의 양으로, 간편하게 WORD형을 만들어 주는 MAKEWORD 매크로 함수와 함께 minwindef.h에 정의되어 있다.

 

typedef unsigned short WORD;
#define MAKEWORD(a, b) ((WORD)(((BYTE)(((DWORD_PTR)(a)) & 0xff)) | ((WORD)((BYTE)(((DWORD_PTR)(b)) & 0xff))) << 8))

 

매개변수의 이름에서 알 수 있듯이 사용할 소켓의 버전 정보를 WORD로 전달한다. 버전은 주 버전과 부 버전으로 나뉘는데, 2.1의 경우 2가 주 버전이고 1이 부 버전이다. 0x0102나 MAKEWORD(2, 1)와 같이 전달하면 된다.

현재 버전은 2.2이다!

0x0202
MAKEWORD(2, 2)

 

LPWSADATA plWSAData

WSADATA는 소켓에 대한 정보가 포함되어 있는 구조체이고, LPWSADATA는 구조체를 가리키는 포인터이다.

 

찾아 보니 LP는 Long Pointer로, 현재 세그먼트의 외부에 있는 주소를 참조할 때 사용한다고 하는데 요즘은 그냥 P로 다 통일한다고 하는 말도 있어서 더 알아봐야겠다!

 

typedef struct WSAData {
        WORD wVersion; // dll에서 사용하는 버전
        WORD wHighVersion; // dll에서 지원하는 가장 높은 버전
#ifdef _WIN64
        unsigned short iMaxSockets;
        unsigned short iMaxUdpDg;
        char FAR * lpVendorInfo;
        char szDescription[WSADESCRIPTION_LEN+1];
        char szSystemStatus[WSASYS_STATUS_LEN+1];
#else
        char szDescription[WSADESCRIPTION_LEN+1];
        char szSystemStatus[WSASYS_STATUS_LEN+1];
        unsigned short iMaxSockets;
        unsigned short iMaxUdpDg;
        char FAR * lpVendorInfo;
#endif
} WSADATA, FAR * LPWSADATA;

 

이렇게 매개변수를 주고 함수를 호출할 때 호출자가 요청한 버전과 Winsock dll에서 지원하는 버전이 호환되면 0을 반환하고, 그렇지 않으면 에러코드를 직접 반환한다.

 

메모

음... 그러니까 소켓을 사용하기에 앞서 전처리를 해 주는 것 같다. 헤더 파일과 라이브러리를 포함시키고 초기화해 준다.

WSAStartup 함수는 언뜻 복잡해 보이지만, 그냥 소켓에 대한 정보를 담은 구조체와 버전 정보를 넘겨주는 게 끝이다.

그다음에 (내부적으로 작동하는 원리는 모르겠지만) 이 변수들을 가지고 구조체의 버전 필드를 초기화하는 것 같다.

 

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

 

int closesocket(SOCKET s); // 성공: 0, 실패: SOCKET_ERROR

 

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


윈도우는 소켓과 파일을 따로 다루기 때문에 관련된 입출력 함수도 별도로 구분된다.

 

int send(SOCKET s, const char *buf, int len, int flags); // 성공: 전송된 바이트 수, 실패: SOCKET_ERROR

 

buf: 전송할 데이터를 저장하고 있는 버퍼의 주소값

len: 전송할 바이트 수

flags: 데이터 전송 시 적용할 옵션

 

int recv(SOCKET s, const char *buf, int len, int flags);

 

buf: 수신된 데이터를 저장할 버퍼의 주소값

len: 수신할 수 있는 최대 바이트 수

flags: 데이터 수신 시 적용할 옵션

 

추가할 것

Ws2_32.dll과 WinSock.dll의 구분...?

SOCKET_ERRORINVALID_SOCKET