여기에서는 해당 링크에 있는 코드를 분석하고 관련 소켓 함수를 공부할 것이다.
응용프로그램 관점에서 소켓은 운영체제의 TCP/IP 구현에서 제공하는 데이터 구조체를 참조하기 위한 매개체다. 아래 그림은 TCP 서버-클라이언트가 소켓을 이용해 통신할 때 운영체제가 관리하는 데이터 구조체다.
(4-17 그림)
응용 프로그램이 통신하려면 다음과 같은 요소가 결정되어야 한다.
일반적으로 TCP 서버는 순서로 소켓 함수를 호출한다.
socket()
함수로 소켓을 생성함으로써 사용할 프로코톨결정bind()
함수로 지역 IP 주소와 지역 포트 번호를 결정listen()
함수로 TCP를 LISTENING
상태로 변경accept()
함수로 자신에게 접속한 클라이언트와 통신할 수 있는 새로운 소켓 생성. 이때 원격 IP 주소와 원격 포트 번호가 결정된다.send()
, recv()
등의 데이터 전송 함수로 클라이언트와 통신을 수행한 후, closesocket()
함수로 소켓을 닫는다.(4-18 그림)
bind()
함수는 소켓의 지역 IP 주소와 지역 포트 번호를 결정한다.
// 성공: 0, 실패: SOCKET_ERROR
int bind(
SOCKET s,
const struct sockaddr *name,
int namelen
);
SOCKADDR_IN
또는 SOCKADDR_IN6
)를 지역 IP 주소와 지역 포트 번호로 초기화하여 전달TCPServer.cpp
에서 bind() 함수를 사용한 부분은 다음과 같다.
053 SOCKADDR_IN serveraddr;
054 ZeroMemory(&serveraddr, sizeof(serveraddr));
055 serveraddr.sin_family = AF_INET;
056 serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
057 serveraddr.sin_port = htons(SERVERPORT);
058 retval = bind(listen_sock, (SOCKADDR *) &serveraddr, sizeof(serveraddr));
059 if (retval == SOCKET_ERROR) err_quit("bind()");
INADDR_ANY
(0으로 정의됨) 값을 사용하는 것이 바람직한다. 서버가 IP 주소를 2개 이상 보유한 경우(multihomed host)에 INADDR_ANY
값을 지역 주소로 설정하면, 클라이언트가 어느 IP 주소로 접속하든 받아들일 수 있다.htons()
함수를 이용해 네트워크 바이트 정렬로 변경한 값을 대입해야 한다.bind()
함수 호출. 두 번째 인자는 항상 (SOCKADDR *)
형으로 변환해야 한다.bind()
함수 오류 처리listen()
함수는 소켓의 TCP 포트 상태를 LISTENING
으로 바꾼다. 이는 클라이언트 접속을 받아들일 수 있는 상태가 된다는 것을 의미한다.
// 성공: 0, 실패: SOCKET_ERROR
int listen(
SOCKET s,
int backlog
);
bind()
함수로 지역 IP 주소와 지역 포트 번호를 설정한 상태이다.SOMAXCONN
값을 대입한다.listen()
함수를 호출하면 된다.)TCPServer.cpp
에서 listen()
함수를 사용한 부분은 다음과 같다.
063 retval = listen(listen_sock, SOMAXCONN);
064 if (retval == SOCKETERROR)
065 err_quit("listen()");
listen()
함수 호출listen()
함수 오류 처리accept()
함수는 접속한 클라이언트와 통신할 수 있도록 새로운 소켓을 생성해 리턴한다. 또한 접속한 클라이언트의 주소 정보(서버 입장에서는 원격 IP 주소과 원격 포트 번호, 클라이언트 입장에서는 지역 IP 주소와 지역 포트 번호)도 알려준다.
(클라이언트가 접속했다는 것은 TCP 프로토콜 수준에서 연결 설정이 성공적으로 이루어졌음을 의미한다.)
// 성공: 새로운 소켓, 실패: INVALID_SOCKET
SOCKET accept(
SOCKET s,
struct sockaddr *addr,
int *addrlen
);
bind()
함수로 지역 IP 주소와 지역 포트 번호를 설정하고 listen()
함수로 TCP 포트 상태를 LISTENING
으로 변경한 상태다.NULL
값을 전달하면 된다.)addr
이 가리키는 소켓 주소 구조체의 크기로 초기화한 후 전달한다. accept()
함수가 리턴하면 addrlen 변수는 accept()
함수가 채워넣은 주소 정보의 크기(바이트 단위)를 갖게 된다. (클라이언트의 IP 주소와 포트 번호를 알 필요가 없다면 NULL
값을 전달하면 된다.)접속한 클라이언트가 없을 경우 accept()
함수는 서버를 대기 상태(wait state
또는 suspended state
)로 만든다. 이때 CPU 사용률을 확인하면 0으로 표시된다.
클라이언트가 접속하면 서버는 깨어나고 accept()
함수는 비로소 리턴하게 된다.
TCPServer.cpp
예제에서 accept()
함수를 사용한 부분은 다음과 같다.
068 SOCKET client_sock;
069 SOCKADDR_IN clientaddr;
070 int addrlen;
071 char buf[BUFSIZE + 1];
072
073 while (1)
074 {
075 // accept()
076 addrlen = strlen(clientaddr);
077 client_sock = accept(listen_sock, (SOCKADDR *) &client_sock, &addrlen);
078 if (client_sock == INVALID_SOCKET)
079 {
080 err_display("accept()");
081 break;
082 }
083
084 // 접속한 클라이언트 정보 출력
085 printf("\n[TCP 서버] 클라이언트 접속: IP 주소=%s, 포트 번호=%d\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
086
087 // 클라이언트와 데이터 통신
088 while (1)
089 {
...
111 }
112
113 // closesocket()
114 closesocket(client_sock);
115 printf("[TCP 서버] 클라이언트 종료: IP 주소=%s, 포트 번호=%d\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
116 }
accept()
함수의 리턴 값을 저장할 소켓 선언accept()
함수의 두 번째 인자로 사용한다. accept()
함수가 리턴하면 접속한 클라이언트의 IP 주소와 포트 번호가 여기에 저장된다.accept()
함수의 세 번째 인자로 사용한다.accept()
함수의 세 번째 인자로 전달할 정수형 변수 addrlen
을 소켓 주소 구조체 변수(clientaddr
)의 크기로 초기화한다.accept()
함수를 호출하고 오류를 처리한다. 이전에 사용한 소켓 함수와 달리 오류가 발생하면 err_display()
함수를 이용해 하면에 구체적인 오류 메세지를 표시하고 무한 루프를 탈출한다.accept()
함수가 리턴한 소켓을 이용해 클라이언트와 통신한다.일반적으로 TCP 클라이언트는 다음과 같은 순서로 소켓 함수를 호출한다.
socket()
함수로 소켓을 생성함으로써 사용할 프로토콜을 결정한다.connect()
함수로 서버에 접속한다. 이때 원격 IP 주소와 원격 포트 번호는 물론, 지역 IP 주소와 지역 포트 번호도 결정된다.send()
, recv()
등의 데이터 전송 함수로 서버와 통신한 후, closesocket()
함수로 소켓을 닫는다.(4-20 그림)
connect()
함수는 TCP 프로토콜 수준에서 서버와 논리적 연결을 설정한다.
// 성공: 0, 실패: SOCKET_ERROR
int connect(
SOCKET s,
const struct sockaddr *name,
int namelen
);
SOCKADDR_IN
또는 SOCKADDR_IN6
)를 서버 주소(즉, 원격 IP 주소와 원격 포트 번호)로 초기화하여 전달일반적으로 클라이언트는 서버와 달리 bind()
함수를 호출하지 않는다. bind()
함수를 호출하지 않은 상태에서 connect()
함수를 호출하면 운영체제가 자동으로 지역 IP 주소와 지역 포트 번호를 할당해준다. 이때 자동으로 할당되는 포트 번호는 윈도우 버전에 따라 다를 수 있다.
TCPClient.cpp
에서 connect()
함수를 사용한 부분은 다음과 같다.
075 SOCKADDR_IN serveraddr;
076 ZeroMemory(&serveraddr, sizeof(serveraddr));
077 serveraddr.sin_family = AF_INET;
078 serveraddr.sin_addr.s_addr = inet_addr(SERVERIP);
079 serveraddr.sin_port = htons(SERVERPORT);
080 retval = connect(sock, (SOCKADDR *) &serveraddr, sizeof(serveraddr));
081 if (retval == SOCKET_ERROR)
082 err_quit("connect()");
connect()
함수를 호출하고 오류를 처리한다.데이터 전송 함수는 크게 데이터를 보내는 함수와 데이터를 받는 함수로 구분할 수 있다.
데이터를 보내는 함수
send()
sendto()
WSASend*()
데이터를 받는 함수
recv()
recvfrom()
WSARecv*()
여기에서는 예제에서 사용된 send()
와 recv()
함수만 살펴볼 것이다.
(4-21 그림)
데이터 전송 함수를 다루기 전에 소켓 데이터 구조체를 다시 살펴보자.
그림의 TCP 소켓과 연관된 데이터 구조체에서 지역/원격 주소 정보 외에 데이터 송수신 버퍼가 있음을 알 수 있다.
여기에서 살펴볼 send()
와 recv()
함수는 소켓 버퍼에 접근할 수 있게 만든 함수라고 보면 된다.
send()
함수는 응용 프로그램 데이터를 운영체제의 송신 버퍼에 복사함으로써 데이터를 전송한다.
send()
함수는 데이터 복사가 성공하면 곧바로 리턴한다. 따라서 send()
함수가 리턴했다고 실제 데이터가 전송된 것은 아니며, 일정 시간이 지나야만 하부 프로토콜을 통해 전송이 완료된다.
// 성공: 보낸 바이트 수, 실패: SOCKET_ERROR
int send(
SOCKET s,
const char *buf,
int len,
int flags
);
send()
함수의 동작을 바꾸는 옵션으로 대부분 0을 사용하면 된다. 사용 가능한 값으로 MSG_DONTROUTE
(윈속에서는 사용하더라도 무시됨)와 MSG_OOB
(거의 사용 안함)이 있다.send()
함수는 첫 번째 인자로 전달하는 소켓의 특성에 따라 다음과 같이 두 종류의 성공적인 리턴을 할 수 있다.
send()
함수를 호출하면, 송신 버퍼의 여유 공간이 send()
함수의 세 번째 인자인 len
보다 작을 경우 해당 프로세스는 대기 상태가 된다.len
크기만큼 데이터 복사가 일어난 후 send()
함수가 리턴한다. 이 경우 send()
함수의 리턴 값은 len
과 같다.ioctlsocket()
함수를 사용하면 블로킹 소켓은 넌블로킹 소켓으로 바꿀 수 있다.send()
함수를 호출하면, 송신 버퍼의 여유 공간만큼 데이터를 복사한 후 실제 복사한 바이트 수를 리턴한다. 이 경우 send()
함수의 리턴값은 최소 1, 최대 len
이다.recv()
함수는 운영체제의 수신 버퍼에 도착한 데이터를 응용 프로그램 버퍼에 복사한다.
// 성공: 받은 바이트 수 또는 0(연결 종료 시), 실패: SOCKET_ERROR
int recv(
SOCKET s,
char *buf,
int len,
int flags
);
buf
가 가리키는 으용 프로그램 버퍼보다 크지 않아야 한다.recv()
함수의 동작을 바꾸는 옵션으로, 대부분 0을 사용하면 된다. 사용 가능한 값으로 MSG_PEEK
과 MSG_OOB
(거의 사용안함)이 있다. 기본 동작의 경우 수신 버퍼의 데이터를 응용 프로그램 버퍼에 복사한 후 해당 데이터를 수신 버퍼에서 삭제하지만 MSG_PEEK
옵션을 사용하면 수신 버퍼에 데이터가 계속 남는다.recv()
는 다음 두 종류의 성공적인 리턴을 할 수 있다.
1. 수신 버퍼에 데이터가 도달한 경우
- recv()
함수의 세 번째 인자인 len
보다 크지 않은 범위에서 가능하면 많은 데이터를 응용 프로그램 버퍼에 복사한 후 실제 복사한 바이트 수를 리턴한다. 이 경우 recv()
함수의 리턴 값은 최소 1, 최대 len
이다.
closesocket()
함수를 호출해 접속을 종료하면, TCP 프로토콜 수준에서 접속 종료를 위한 패킷 교환 절차가 일어난다. 이 경우 recv()
함수는 0을 리턴한다.recv()
함수의 리턴 값이 0인 경우 정상 종료(normal close 또는 graceful close)라고 부른다.recv() 함수 사용 시 특히 주의할 점은 세 번째 인자인 len
으로 지정한 크기보다 적은 데이터가 응용 프로그램 버퍼에 복사될 수 있다는 사실이다. 이는 TCP가 데이터 경계를 구분하지 않는다는 특성에 기인한다. 따라서 자신이 받은 데이터의 크기를 미리 알고 있다면 그만큼 받을 때까지 recv()
함수를 여러 번 호출해야 한다.
TCPClient().cpp
에서 존재하는 사용자 정의 함수 recvn()
함수를 분석하면 다음과 같다.
040 int recvn(SOCKET s, char *buf, int len, int flags)
041 {
042 int received;
043 char *ptr = buf;
044 int left = len;
045
046 while (left > 0)
047 {
048 received = recv(s, ptr, left, flags);
049 if (received == SOCKET_ERROR)
050 return (SOCKET_ERROR);
051 else if (received == 0)
052 break;
053 left -= received;
054 ptr += received;
055 }
056
057 return (len - left);
058 }
040: recvn()
함수는 recv()
함수와 형태가 같다. 따라서 recv()
함수로 구현한 기존 코드를 손쉽게 recvn()
함수로 대체할 수 있다.
042: 내부적으로 호출하는 recv()
함수의 리턴 값을 저장할 변수
043: 포인터 변수 ptr
은 응용 프로그램 버퍼의 시작 주소를 가리킨다. 데이터를 읽을 때마나 ptr
값은 증가한다.
044: left
변수는 아직 읽지 않은 데이터 크기다. 데이터를 읽을 때마다 left
값은 감소한다.
046: 아직 읽지 않은 데이터가 있으면 계속 루프를 돈다.
048 ~ 050: recv()
함수를 호출해 오류가 발생하면 곧바로 리턴한다.
051 ~ 052: recv()
함수의 리턴 값이 0이면(정상 종료), 상대가 데이터를 더 보내지 않을 것이므로 루프를 빠져나간다.
053 ~ 054: recv()
함수가 성공한 경우이므로 left
와 ptr
변수를 갱신한다.
057: 읽은 바이트 수를 리턴한다. 오류가 발생하거나 상대가 접속을 종료한 경우가 아니면 left
변수는 항상 0이므로 리턴값은 len
이된다.
(4-22 그림)
TCPClient.cpp
에서 send()
와 recv()
함수를 사용한 부분은 다음과 같다.
084 // 데이터 통신에 사용할 변수
085 char buf[BUFSIZE + 1];
086 int len;
087
088 // 서버와 데이터 통신
089 while (1)
090 {
091 // 데이터 입력
092 printf("\n[보낼 데이터] ");
093 if (fgets(buf, BUFSIZE + 1, stdin) == NULL)
094 break;
095
096 // '\n' 문자 제거
097 len = strlen(buf);
098 if (buf[len - 1] == '\n')
099 buf[len - 1] = '\0';
100 if (strlen(buf) == 0)
101 break;
102
103 // 데이터 보내기
104 retval = send(sock, buf, strlen(buf), 0);
105 if (retval == SCOKET_ERROR)
106 {
107 err_display("send()");
108 break;
109 }
110 printf("[TCP 클라이언트] %d 바이트를 보냈습니다.\n", retval);
111
112 // 데이터 받기
113 retval = recvn(sock, buf, retval, 0);
114 if (retval == SCOKET_ERROR)
115 {
116 err_display("recv()");
117 break;
118 }
119 else if (retval == 0)
120 break;
121
122 // 받은 데이터 출력
123 buf[retval] == '\0';
124 printf("[TCP 클라이언트] %d 바이트를 받았습니다.\n", retval);
125 printf("[받은 데이터] %s\n", buf);
126 }
fget()
함수를 사용해 사용자로부터 문자열을 입력 받음'\n'
문자 제거. 데이터 출력 시 줄바꿈 여부 혹은 줄바꿈 방식을 서버가 결정하게 하기 위함'\n'
문자를 제거한 후 문자열 길이가 0이면, 사용자가 글자를 입력하지 않고 곧바로 Enter
키를 눌렸다는 뜻이다. 이 경우 루프를 빠져나가고 closesocket()
함수를 호출해 접속을 정상 종료한다.send()
함수를 호출하고 오류를 처리한다. 블로킹 소켓을 사용하고 있기 때문에 send()
함수의 리턴 값은 strlen(buf)
값과 같을 것이다.recvn()
함수를 호출하고 오류를 처리한다. 서버로부터 받은 데이터의 크기를 이미 알고 있기 때문에 사용자 정의 함수 recvn()
을 사용했다.'\0'
을 추가하여 화면에 출력한다.TCPServer.cpp
에서 send()
와 recv()
함수를 사용한 부분은 다음과 같다. 여기에서 사용한 소켓(client_sock
)은 accept()
함수의 리턴값으로 생성된 소켓이다.
071 char bur[BUFSIZE + 1];
072
073 while (1)
{
...
087 // 클라이언트와 데이터 통신
088 while (1)
089 {
090 // 데이터 받기
091 retval = recv(client_sock, buf, BUFSIZE, 0);
092 if (retval == SOCKET_ERROR)
093 {
094 err_display("recv()");
095 break;
096 }
097 else if (retval == 0)
098 break;
099
100 // 받은 데이터 출력
101 buf[retval] = '\0';
102 printf("[TCP/%s:%d] %s\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port), buf);
103
104 // 데이터 보내기
105 retval = send(client_sock, buf, retval, 0);
106 if (retval == SOCKET_ERROR)
107 {
108 err_display("send()");
109 break;
110 }
111 } // 안쪽 while 루프의 끝
...
116 } // 바깥쪽 while 루프의 끝
recv()
함수의 리턴 값이 0(정상 종료) 또는 SOCKET_ERROR
(오류 발생)가 될 때까지 계속 루프를 돌며 데이터를 수신recv()
함수를 호출하고 오류를 처리한다. 클라이언트로부터 받을 데이터 크기를 미리 알 수 없으므로 사용자 정의 함수 recvn()
함수는 사용할 수 없다.'\0'
을 추가해 화면에 출력send()
함수를 호출하고 오류 처리참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018