TCP 서버-클라이언트 분석

bolee·2022년 4월 6일
0

여기에서는 해당 링크에 있는 코드를 분석하고 관련 소켓 함수를 공부할 것이다.

TCP/IP 관점에서의 소켓

응용프로그램 관점에서 소켓은 운영체제의 TCP/IP 구현에서 제공하는 데이터 구조체를 참조하기 위한 매개체다. 아래 그림은 TCP 서버-클라이언트가 소켓을 이용해 통신할 때 운영체제가 관리하는 데이터 구조체다.

(4-17 그림)

응용 프로그램이 통신하려면 다음과 같은 요소가 결정되어야 한다.

  • 프로토콜: 통신 규약으로, 소켓을 생성할 때 결정한다.
  • 지역(local) IP 주소와 지역 포트 번호: 서버 또는 클라이언트 자신의 주소와 포트 번호
  • 원격(remote) IP 주소와 원격 포트 번호: 서버 또는 클라이언트가 통신하는 상대의 주소와 포트 번호

소켓 함수 - TCP 서버 함수

일반적으로 TCP 서버는 순서로 소켓 함수를 호출한다.

  1. socket() 함수로 소켓을 생성함으로써 사용할 프로코톨결정
  2. bind() 함수로 지역 IP 주소와 지역 포트 번호를 결정
  3. listen() 함수로 TCP를 LISTENING 상태로 변경
  4. accept() 함수로 자신에게 접속한 클라이언트와 통신할 수 있는 새로운 소켓 생성. 이때 원격 IP 주소와 원격 포트 번호가 결정된다.
  5. send(), recv() 등의 데이터 전송 함수로 클라이언트와 통신을 수행한 후, closesocket() 함수로 소켓을 닫는다.
  6. 새로운 클라이언트 접속이 들어올 때 마다 4 ~ 5 과정을 반복한다.

(4-18 그림)

bind()

bind() 함수는 소켓의 지역 IP 주소와 지역 포트 번호를 결정한다.

// 성공: 0, 실패: SOCKET_ERROR
int bind(
	SOCKET s,
    const struct sockaddr *name,
    int namelen
);
  • s: 클라이언트 접속을 수용할 목적으로 만든 소켓. 지역 IP 주소와 지역 포트 번호가 아직 결정되지 않은 상태이다.
  • name: 소켓 주소 구조체(TCP/IP의 경우 SOCKADDR_IN 또는 SOCKADDR_IN6)를 지역 IP 주소와 지역 포트 번호로 초기화하여 전달
  • namelen: 소켓 주소 구조체의 길이(바이트 단위)

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()");
  • 053: 소켓 주소 구조체 변수 선언
  • 054: 0으로 초기화 (C 표준 함수인 memset(&serveraddr, 0, sizeof(serveraddr))을 사용해도 된다.)
  • 055: 인터넷 주소 체계(IPv4)를 사용한다는 의미로 AF_INET 대입
  • 056: 서버의 지역 IP 설정. 서버의 경우 특정 IP 주소 대신 INADDR_ANY(0으로 정의됨) 값을 사용하는 것이 바람직한다. 서버가 IP 주소를 2개 이상 보유한 경우(multihomed host)에 INADDR_ANY 값을 지역 주소로 설정하면, 클라이언트가 어느 IP 주소로 접속하든 받아들일 수 있다.
  • 057: 서버의 지역 포트 번호 설정. htons() 함수를 이용해 네트워크 바이트 정렬로 변경한 값을 대입해야 한다.
  • 058: bind() 함수 호출. 두 번째 인자는 항상 (SOCKADDR *) 형으로 변환해야 한다.
  • 059: bind() 함수 오류 처리

listen()

listen() 함수는 소켓의 TCP 포트 상태를 LISTENING으로 바꾼다. 이는 클라이언트 접속을 받아들일 수 있는 상태가 된다는 것을 의미한다.

// 성공: 0, 실패: SOCKET_ERROR
int listen(
	SOCKET s,
    int backlog
);
  • s: 클라이언트 접속을 수용할 목적으로 만든 소켓으로, bind() 함수로 지역 IP 주소와 지역 포트 번호를 설정한 상태이다.
  • backlog: 서버가 당장 처리하지 않더라도 접속 가능한 클라이언트의 개수이다. 클라이언트의 접속 정보는 연결 큐(connection queue)에 저장되는데, backlog는 이 연결 큐의 길이를 나타낸다. 하부 프로토콜에서 지원 가능한 최댓값을 사용하려면 SOMAXCONN 값을 대입한다.
    (backlog 값을 바꾸려면 언제든지 listen() 함수를 호출하면 된다.)

TCPServer.cpp에서 listen() 함수를 사용한 부분은 다음과 같다.

063 	retval = listen(listen_sock, SOMAXCONN);
064 	if (retval == SOCKETERROR)
065			err_quit("listen()");
  • 063: backlog를 최댓값으로 하여 listen()함수 호출
  • 064 ~ 065: listen() 함수 오류 처리

accept()

accept() 함수는 접속한 클라이언트와 통신할 수 있도록 새로운 소켓을 생성해 리턴한다. 또한 접속한 클라이언트의 주소 정보(서버 입장에서는 원격 IP 주소과 원격 포트 번호, 클라이언트 입장에서는 지역 IP 주소와 지역 포트 번호)도 알려준다.
(클라이언트가 접속했다는 것은 TCP 프로토콜 수준에서 연결 설정이 성공적으로 이루어졌음을 의미한다.)

// 성공: 새로운 소켓, 실패: INVALID_SOCKET
SOCKET accept(
	SOCKET s,
    struct sockaddr *addr,
    int *addrlen
);
  • s: 클라이언트 접속을 수용할 목적으로 만든 소켓으로 bind() 함수로 지역 IP 주소와 지역 포트 번호를 설정하고 listen() 함수로 TCP 포트 상태를 LISTENING으로 변경한 상태다.
  • addr: 소켓 주소 구조체를 전달하면 접속한 클라이언트의 주소 정보(IP 주소와 포트 번호)로 채워진다. (클라이언트의 IP 주소와 포트 번호를 알 필요가 없다면 NULL값을 전달하면 된다.)
  • addrlen: 정수형 변수를 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		}
  • 068: accept() 함수의 리턴 값을 저장할 소켓 선언
  • 069: accept() 함수의 두 번째 인자로 사용한다. accept() 함수가 리턴하면 접속한 클라이언트의 IP 주소와 포트 번호가 여기에 저장된다.
  • 070: accept() 함수의 세 번째 인자로 사용한다.
  • 073: 일반적으로 서버는 계속 클라이언트 요청을 처리해야 하므로 무한 루프를 돈다.
  • 076: accept() 함수의 세 번째 인자로 전달할 정수형 변수 addrlen을 소켓 주소 구조체 변수(clientaddr)의 크기로 초기화한다.
  • 077 ~ 082: accept() 함수를 호출하고 오류를 처리한다. 이전에 사용한 소켓 함수와 달리 오류가 발생하면 err_display() 함수를 이용해 하면에 구체적인 오류 메세지를 표시하고 무한 루프를 탈출한다.
  • 085: 접속한 클라이언트의 IP 주소와 포트 번호를 화면에 출력한다.
  • 088 ~ 111: accept() 함수가 리턴한 소켓을 이용해 클라이언트와 통신한다.
  • 114 ~ 115: 클라이언트와 통신을 마치면 소켓을 닫고, 접속을 종료한 클라이언트와 IP 주소와 포트 번호를 화면에 출력한다.

TCP 클라이언트 함수

일반적으로 TCP 클라이언트는 다음과 같은 순서로 소켓 함수를 호출한다.

  1. socket() 함수로 소켓을 생성함으로써 사용할 프로토콜을 결정한다.
  2. connect() 함수로 서버에 접속한다. 이때 원격 IP 주소와 원격 포트 번호는 물론, 지역 IP 주소와 지역 포트 번호도 결정된다.
  3. send(), recv() 등의 데이터 전송 함수로 서버와 통신한 후, closesocket() 함수로 소켓을 닫는다.

(4-20 그림)

connect()

connect() 함수는 TCP 프로토콜 수준에서 서버와 논리적 연결을 설정한다.

// 성공: 0, 실패: SOCKET_ERROR
int connect(
	SOCKET s,
    const struct sockaddr *name,
    int namelen
);
  • s: 서버와 통실할 목적으로 만든 소켓
  • name: 소켓 주소 구조체(TCP/IP 의 경우 SOCKADDR_IN 또는 SOCKADDR_IN6)를 서버 주소(즉, 원격 IP 주소와 원격 포트 번호)로 초기화하여 전달
  • namelen: **소켓 주소 구조체의 길이(바이트 단위)다.

일반적으로 클라이언트는 서버와 달리 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()");
  • 075 ~ 079: 소켓 구조체 변수를 0으로 초기화 하고, IP 주소와 포트 번호를 대입한다.
  • 080 ~ 082: connect() 함수를 호출하고 오류를 처리한다.

TCP 데이터 전송 함수

데이터 전송 함수는 크게 데이터를 보내는 함수와 데이터를 받는 함수로 구분할 수 있다.

데이터를 보내는 함수

  • send()
  • sendto()
  • WSASend*()

데이터를 받는 함수

  • recv()
  • recvfrom()
  • WSARecv*()

여기에서는 예제에서 사용된 send()recv() 함수만 살펴볼 것이다.

(4-21 그림)

데이터 전송 함수를 다루기 전에 소켓 데이터 구조체를 다시 살펴보자.
그림의 TCP 소켓과 연관된 데이터 구조체에서 지역/원격 주소 정보 외에 데이터 송수신 버퍼가 있음을 알 수 있다.

  • 송신 버퍼(send buffer): 데이터를 전송하기 전에 임시로 저장해두는 영역
  • 수신 버퍼(receive buffer): 받은 데이터를 응용 프로그램이 처리하기 전까지 임시로 저장해두는 영역
  • 소켓 버퍼(socket buffer): 송신 버퍼 + 수신 버퍼

여기에서 살펴볼 send()recv() 함수는 소켓 버퍼에 접근할 수 있게 만든 함수라고 보면 된다.

send()

send() 함수는 응용 프로그램 데이터를 운영체제의 송신 버퍼에 복사함으로써 데이터를 전송한다.
send() 함수는 데이터 복사가 성공하면 곧바로 리턴한다. 따라서 send() 함수가 리턴했다고 실제 데이터가 전송된 것은 아니며, 일정 시간이 지나야만 하부 프로토콜을 통해 전송이 완료된다.

// 성공: 보낸 바이트 수, 실패: SOCKET_ERROR
int send(
	SOCKET s,
    const char *buf,
    int len,
    int flags
);
  • s: 통신할 대상과 연결된(connected) 소켓
  • buf: 보낼 데이터를 담고 있는 응용 프로그램 버퍼의 주소
  • len: **보낼 데이터 크기(바이트 단위)
  • flags: send() 함수의 동작을 바꾸는 옵션으로 대부분 0을 사용하면 된다. 사용 가능한 값으로 MSG_DONTROUTE(윈속에서는 사용하더라도 무시됨)와 MSG_OOB(거의 사용 안함)이 있다.

send() 함수는 첫 번째 인자로 전달하는 소켓의 특성에 따라 다음과 같이 두 종류의 성공적인 리턴을 할 수 있다.

  1. 블로킹 소켓(blocking socket)
    • 지금까지 생성한 소켓은 모두 블로킹 소켓
    • 블로킹 소켓을 대상으로 send() 함수를 호출하면, 송신 버퍼의 여유 공간이 send() 함수의 세 번째 인자인 len보다 작을 경우 해당 프로세스는 대기 상태가 된다.
    • 송신 버퍼에 충분한 공간이 생기면 프로세스는 깨어나고 len 크기만큼 데이터 복사가 일어난 후 send() 함수가 리턴한다. 이 경우 send() 함수의 리턴 값은 len과 같다.
  2. 넌블로킹 소켓(nonblocking socket)
    • ioctlsocket() 함수를 사용하면 블로킹 소켓은 넌블로킹 소켓으로 바꿀 수 있다.
    • 넌블로킹 소켓을 대상으로 send() 함수를 호출하면, 송신 버퍼의 여유 공간만큼 데이터를 복사한 후 실제 복사한 바이트 수를 리턴한다. 이 경우 send() 함수의 리턴값은 최소 1, 최대 len이다.

recv()

recv() 함수는 운영체제의 수신 버퍼에 도착한 데이터를 응용 프로그램 버퍼에 복사한다.

// 성공: 받은 바이트 수 또는 0(연결 종료 시), 실패: SOCKET_ERROR
int recv(
	SOCKET s,
    char *buf,
    int len,
    int flags
);
  • s: 통신한 대상의 연결된(connected) 소켓
  • buf: 받은 데이터를 저장할 응용 프로그램 버퍼의 주소
  • len: 운영체제의 수신 버퍼로부터 복사할 최대 데이터 크기(바이트 단위)이다. 이 값은 buf가 가리키는 으용 프로그램 버퍼보다 크지 않아야 한다.
  • flags: recv() 함수의 동작을 바꾸는 옵션으로, 대부분 0을 사용하면 된다. 사용 가능한 값으로 MSG_PEEKMSG_OOB(거의 사용안함)이 있다. 기본 동작의 경우 수신 버퍼의 데이터를 응용 프로그램 버퍼에 복사한 후 해당 데이터를 수신 버퍼에서 삭제하지만 MSG_PEEK 옵션을 사용하면 수신 버퍼에 데이터가 계속 남는다.

recv()는 다음 두 종류의 성공적인 리턴을 할 수 있다.
1. 수신 버퍼에 데이터가 도달한 경우
- recv() 함수의 세 번째 인자인 len보다 크지 않은 범위에서 가능하면 많은 데이터를 응용 프로그램 버퍼에 복사한 후 실제 복사한 바이트 수를 리턴한다. 이 경우 recv() 함수의 리턴 값은 최소 1, 최대 len이다.

  1. 접속이 정상 종료한 경우
    • 상대편 응용 프로그램이 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() 함수가 성공한 경우이므로 leftptr 변수를 갱신한다.

  • 057: 읽은 바이트 수를 리턴한다. 오류가 발생하거나 상대가 접속을 종료한 경우가 아니면 left 변수는 항상 0이므로 리턴값은 len이된다.

    (4-22 그림)

send(), recv() 사용

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		}
  • 085: 보낼 데이터 또는 받은 데이터를 저장할 버퍼
  • 086: 사용자가 입력한 문자열 데이터의 길이를 계산할 때 사용할 변수
  • 092 ~ 094: fget() 함수를 사용해 사용자로부터 문자열을 입력 받음
  • 097 ~ 099: '\n' 문자 제거. 데이터 출력 시 줄바꿈 여부 혹은 줄바꿈 방식을 서버가 결정하게 하기 위함
  • 100 ~ 101: '\n' 문자를 제거한 후 문자열 길이가 0이면, 사용자가 글자를 입력하지 않고 곧바로 Enter 키를 눌렸다는 뜻이다. 이 경우 루프를 빠져나가고 closesocket() 함수를 호출해 접속을 정상 종료한다.
  • 104 ~ 110: send() 함수를 호출하고 오류를 처리한다. 블로킹 소켓을 사용하고 있기 때문에 send() 함수의 리턴 값은 strlen(buf) 값과 같을 것이다.
  • 113 ~ 120: recvn() 함수를 호출하고 오류를 처리한다. 서버로부터 받은 데이터의 크기를 이미 알고 있기 때문에 사용자 정의 함수 recvn()을 사용했다.
  • 123 ~ 125: 받은 데이터 끝에 '\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 루프의 끝
  • 071: 받은 데이터를 저장할 응용 프로그램 버퍼
  • 088: recv() 함수의 리턴 값이 0(정상 종료) 또는 SOCKET_ERROR(오류 발생)가 될 때까지 계속 루프를 돌며 데이터를 수신
  • 091 ~ 098: recv() 함수를 호출하고 오류를 처리한다. 클라이언트로부터 받을 데이터 크기를 미리 알 수 없으므로 사용자 정의 함수 recvn() 함수는 사용할 수 없다.
  • 101 ~ 102: 받은 데이터 끝에 '\0'을 추가해 화면에 출력
  • 105 ~ 110: send() 함수를 호출하고 오류 처리

참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018

0개의 댓글