코승호딩의 메모장

[TCP 서버-클라이언트] 본문

네트워크 프로그래밍

[TCP 서버-클라이언트]

코승호딩 2023. 9. 26. 13:13

01 TCP 서버-클라이언트 구조

왼쪽 그림은 서버와 클라이언트의 동작하는 모습이다. 클라이언트는 사용자가 입력한 주소를 해석하여 접속 대기 중인 서버에 접속한다. 다음 HTTP를 이용하여 요청 메시지를 보내고 서버는 받은 메시지를 분석 후 HTTP를 이용하여 응답 메시지를 보내준다. 클라이언트는 웹 서버에서 받은 데이터를 처리하여 화면에 표시한다. TCP는 연결 설정을 필요로 하는 연결형 프로토콜이기 때문에 클라이언트와 서버가 연결이 되어야 한다. 그렇다면 핵심 동작을 자세히 살펴보자

  1. 서버는 먼저 실행하여 클라이언트가 접속하길 기다린다(listen).
  2. 클라이언트는 서버에 접속(connect)하여 데이터를 보낸다(send).
  3. 서버는 클라이언트 접속을 수용하고(accept), 클라이언트가 보낸 데이터를 받아서(recv) 처리한다.
  4. 서버는 처리한 데이터를 클라이언트에 보낸다(send).
  5. 클라이언트는 서버가 보낸 데이터를 받아서(recv) 처리한다.
  6. 데이터를 주고받는 과정이 끝났다면 접속을 끊는다(closesocket).

위 그림은 TCP 서버와 클라이언트가 연결되어 통신되는 과정이다. 각 동작 원리는 다음과 같다.

  • (a) 서버는 소켓을 생성하여 클라이언트가 접속하기를 기다린다. 소켓은 특정 포트 번호와 결합되어 있으며 이 포트 번호로 접속하는 클라이언트만 수용할 수 있다. 이때 서버는 Up and Running 상태임.
  • (b) 클라이언트가 서버에 접속한다. 이때 TCP 프로토콜 수준에서 연결 설정을 위한 패킷 교환이 일어난다. TCP 연결 시 세 개의 패킷을 주고 받는데, SYN, SYN/ACK, ACK라고 부른다.
  • (c) 연결 절차가 끝나면, 서버는 접속한 클라이언트와 통신할 수 있는 새로운 소켓을 생성한다. 이 소켓을 이용해 클라이언트와 데이터를 주고받는다. 기존 소켓은 새로운 클라이언트 접속을 수용하는데 사용한다.
  • (d) 두 클라이언트가 접속한 후의 상태로 서버는 총 세 개의 소켓이 존재하며 두 개의 소켓은 두 클라이언트 통신 용도이다.

다음으로 IPv4 기반 간단한 TCP 서버-클라이언트를 구현한다. 이 예제의 동작은 다음과 같다.

  • 서버 : 클라이언트가 send한 데이터를 recv하여 화면에 출력하고 받은 데이터를 변경 없이 다시 클라이언트에게 send한다. 이와 같이 받은 데이터를 그대로 다시 보내는 서버를 에코 서버라고 부른다.
  • 클라이언트 : 사용자가 키보드로 입력한 문자열을 서버에 send한다. 서버가 데이터를 그대로 돌려보내면 클라이언트는 이를 recv하여 화면에 출력한다. 에코 서버와 통신한다는 의미로 에코 클라이언트라고 부른다. 
// 대기 소켓 생성
SOCKET listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock == INVALID_SOCKET) err_quit("socket()");

// 소켓의 지역 IP 주소와 지역 포트 번호를 결정하는 bind 함수
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons(SERVERPORT);
retval = bind(listen_sock, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR) err_quit("bind()");

// 서버가 클라이언트의 접속을 기다리기 위한 listen 함수
retval = listen(listen_sock, SOMAXCONN);
if (retval == SOCKET_ERROR) err_quit("listen()");

// 데이터 통신에 사용할 변수
SOCKET client_sock;
struct sockaddr_in clientaddr;
int addrlen;
char buf[BUFSIZE + 1];

while (1) {
		// 클라이언트의 접속을 수용하기 위한 accept 함수
		addrlen = sizeof(clientaddr);
		client_sock = accept(listen_sock, (struct sockaddr *)&clientaddr, &addrlen);
		if (client_sock == INVALID_SOCKET) {
			err_display("accept()");
			break;
		}

		// 접속한 클라이언트 정보 출력
		char addr[INET_ADDRSTRLEN];
		inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));
		printf("\n[TCP 서버] 클라이언트 접속: IP 주소=%s, 포트 번호=%d\n",
			addr, ntohs(clientaddr.sin_port));

		// 클라이언트와 데이터 통신
		while (1) {
			// 데이터 받기
			retval = recv(client_sock, buf, BUFSIZE, 0);
			if (retval == SOCKET_ERROR) {
				err_display("recv()");
				break;
			}
			else if (retval == 0)
				break;

			// 받은 데이터 출력
			buf[retval] = '\0';
			printf("[TCP/%s:%d] %s\n", addr, ntohs(clientaddr.sin_port), buf);

			// 데이터 보내기
			retval = send(client_sock, buf, retval, 0);
			if (retval == SOCKET_ERROR) {
				err_display("send()");
				break;
			}
		}

		// 소켓 닫기
		closesocket(client_sock);
		printf("[TCP 서버] 클라이언트 종료: IP 주소=%s, 포트 번호=%d\n",
			addr, ntohs(clientaddr.sin_port));
	}

// 소켓 닫기
closesocket(listen_sock);

위 코드는 에코 서버를 구현한 내용이다. 우선 접속을 위한 대기 소켓을 생성하고 bind를 통해 소켓의 지역 IP 주소와 지역 포트 번호를 결정한다. 다음 listen을 통해 대기 소켓을 클라이언트 접속 수용을 위한 Up and Running 상태로 변경한다. 이후 클라이언트와 데이터 통신을 위한 소켓을 새롭게 정의하여 accept를 통해 클라이언트의 접속을 수용하고 접속한 클라이언트의 정보를 출력한다. 다음으로 클라이언트와 데이터를 통신하기 위해 recv를 통해 데이터를 받고 받은 데이터를 출력 후 send를 통해서 다시 보낸다.

 

// 소켓 생성
SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == INVALID_SOCKET) err_quit("socket()");

// connect()
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET, SERVERIP, &serveraddr.sin_addr);
serveraddr.sin_port = htons(SERVERPORT);
retval = connect(sock, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR) err_quit("connect()");

// 데이터 통신에 사용할 변수
char buf[BUFSIZE + 1];
int len;

// 서버와 데이터 통신
while (1) {
	// 데이터 입력
	printf("\n[보낼 데이터] ");
	if (fgets(buf, BUFSIZE + 1, stdin) == NULL)
		break;

	// '\n' 문자 제거
	len = (int)strlen(buf);
	if (buf[len - 1] == '\n')
		buf[len - 1] = '\0';
	if (strlen(buf) == 0)
		break;

	// 데이터 보내기
	retval = send(sock, buf, (int)strlen(buf), 0);
	if (retval == SOCKET_ERROR) {
		err_display("send()");
		break;
	}
	printf("[TCP 클라이언트] %d바이트를 보냈습니다.\n", retval);

	// 데이터 받기
	retval = recv(sock, buf, retval, MSG_WAITALL);
	if (retval == SOCKET_ERROR) {
		err_display("recv()");
		break;
	}
	else if (retval == 0)
		break;

	// 받은 데이터 출력
	buf[retval] = '\0';
	printf("[TCP 클라이언트] %d바이트를 받았습니다.\n", retval);
	printf("[받은 데이터] %s\n", buf);
}

// 소켓 닫기
closesocket(sock);

위 코드는 에코 클라이언트를 구현한 내용이다. 우선 소켓을 생성하고 서버에 접속하기 위해 connect를 이용하여 소켓을 넘겨준다. 이후 버퍼에 문자열을 입력한 후 send를 통해서 데이터를 보내준다. 그 다음 recv를 통해서 데이터를 받고 소켓을 종료한다. 만약 다른 컴퓨터에서 진행한다면 SERVERIP를 해당 서버의 IP 주소로 변경해야 한다. 


다음은 TCP 서버를 실행한 모습이다. 초기 아무것도 출력되지 않는다.

 

명령 프롬프트를 실행하여 위 명령어를 실행한다. netstat은 네트워크 연결과 통계 정보를 표시하는 명령어이다. 뒤에 findstr 명령은 포트 번호 9000을 포함하는 줄만 골라내 주는 기능을 한다. 따라서 netstat 명령의 출력에 9000을 포함하는 줄만 골라내어 출력하는 의미이다. 따라서 현재 TCP 서버만 실행하였으므로 포트 번호 9000이 LISTENING인 상태이다.

 

TCP 클라이언트를 실행한 다음 다시 명령 프롬프트에서 netstat 명령을 실행한 모습이다. 하나는 LISTENING 상태이고 다른 두 개는 ESTABLISHED(연결됨) 상태이다. 53800 포트 번호는 9000과 달리 매 실행마다 달라지는 숫자다. 오른쪽 그림은 현재 상태를 도식화한 상태이다. 서버 측 소켓은 모두 같은 포트 번호 9000을 사용하는 것을 알 수 있다.

 

이제 서버와 클라이언트가 연결된 상태이므로 클라이언트에서 문자열을 입력하면 서버 측에서 출력하게 된다. 그리고 엔터 키를 누르면 클라이언트를 종료하게 된다.

 

연결 후 명령어를 출력한 다음 netstat을 사용한 모습이다. 53800 포트 번호 한 개가 TIME_WAIT인 것을 볼 수 있다. 이는 같은 포트 번호를 가진 소켓 생성을 일정 시간 금지한 것이다.

 

결과적으로 클라이언트에서 문자열을 입력하여 엔터 키를 누르면 서버에서 출력이 된다. 이 과정을 자세히 보면

  • 클라이언트 접속을 수용하는 서버 측 TCP 소켓은 LISTENING 상태에 계속 머물러 있다.
  • 클라이언트가 접속하면 서버와 클라이언트에 ESTABLISHED 상태 TCP 소켓이 각각 존재한다. 서버 측 ESTABLISHED 상태 소켓은 새로운 클라이언트가 접속할 때마다 새로 만들어진다. 반면, 클라이언트 측 ESTABLISHED 상태 소켓은 한 개만 존재한다.
  • 클라이언트가 접속을 끊으면 서버 측 ESTABLISHED 상태 소켓은 사라지고 클라이언트 측 ESTABLISHED 상태 소켓은 TIME_WAIT 상태가 된다.
  • TIME_WAIT 상태 소켓은 일정 시간이 지나면 사라진다. 이 상태는 같은 포트 번호를 가진 소켓 생성을 일정 시간 금지하여, 이전 연결에서 발생했다가 늦게 도착한 데이터를 잘못 수신하는 상황을 막아준다.

02 TCP 서버-클라이언트 분석

위 그림은 TCP 서버-클라이언트가 소켓을 이용하여 통신할 때 운영체제가 관리하는 정보를 보여준다. 클라이언트가 서버에 연결 요청을 할 때, 서버의 주소 정보를 알아야 한다. 그리고 상대방 주소 뿐만 아니라 자신의 주소도 함께 필요하다. 대기 소켓에서 연결요청을 받았을 때 클라이언트의 IP와 포트 번호(서버입장에서 원격주소)를 읽어와서 저장을 하고 연결하면 주소 정보를 가지고 데이터를 이동할 수 있다.

  • 지역(Local) IP 주소, 포트 번호 : 서버 또는 클라이언트 자신의 주소
  • 원격(Remote) IP 주소, 포트 번호 : 서버 또는 클라이언트가 통신하는 상대의 주소

 

  • socket : 소켓을 생성하여 사용할 프로토콜을 결정한다.
  • bind : 소켓의 지역 IP 주소와 포트 번호를 결정한다.
  • listen : 소켓의 TCP 상태를 LISTENING로 변경한다.
  • accept : 클라이언트 접속을 수용하고, 접속한 클라이언트와 통신할 새로운 소켓을 생성한다. 이때 원격 IP 주소와 포트 번호가 결정된다.
  • send, recv : 데이터 전송 함수로 클라이언트와 통신을 수행한다.
  • closesocket : 소켓을 닫는다. 

위 과정은 TCP 서버의 소켓 함수를 호출하는 순서이다. 대기 소켓을 생성하고 bind, listen 함수를 통해 대기 소켓을 LISTENING 상태로 바꾼다. 대기 소켓이 LISTENING 상태여야 받을 수 있다. accept는 수용하는 기능이며, 연결 설정이 끝나면 recv, send 함수를 통해 데이터를 주고 받는다.  더이상 받을 것이 없다면 closesocket을 사용한다. 새로운 클라이언트 접속이 들어올 때마다 4~5를 반복한다.


다음으로 핵심 서버 함수인 bind, listen, accept 함수를 자세히 살펴보자.

 

  • bind 함수 : 묶는다는 뜻으로 소켓의 IP 주소와 포트 번호를 결정한다. 이 함수의 리턴 타입이 int인 것을 볼 수 있는데 성공 0, 실패 SOCKET_ERROR(-1)을 리턴한다. 첫 번째 인자에는 서버가 클라이언트의 접속을 수용하기 위해 생성한 대기 소켓이다. 두 번째는 서버의 지역 IP 주소와 포트 번호를 담은 소켓 주소 구조체이다.
  • listen 함수 : 소켓을 LISTENING 상태로 변경한다. 첫 번째 인자로 대기 소켓을 넣어주고 두 번째는 backlog 버퍼의 크기 이다. 동시에 접속하는 클라이언트가 있을 때 모두 빠꾸 시킬 수 없으니까 대기 표를 나눠주듯이 버퍼에 담아두는 것이다. 버퍼의 크기가 하드웨어에 따라 다를 수 있기 때문에 SOMAXCONN 값을 사용하면 운영체제가 최적의 사이즈를 잡아서 할당한다.
  • accept 함수 : 서버가 LISTENING 상태에 있을 때, 클라이언트 접속을 수용하며 클라이언트와 통신을 할 수 있는 새로운 소켓을 생성하여 리턴한다. 대기 소켓, 주소 구조체, 접속을 요청한 클라이언트의 주소 정보를 담아준다, 새로 생성한 소켓으로 원격 IP, 포트 번호(클라이언트의 정보)가 넘어간다. 서버는 이 함수를 호출함으로써 해당 클라이언트와 데이터를 송수신할 수 있는 준비를 마친 것이다.


다음으로 핵심 클라이언트 함수인 connect 함수를 자세히 살펴보자. 클라이언트는 서버와 통신을 위한 소켓을 생성하고 서버의 접속 요청을 위해 connect 함수를 호출한다. 클라이언트는 마치 편지를 보내듯이 자신의 지역 IP 주소와 포트 번호, 서버의 원격 IP 주소와 포트 번호를 결정하여 사용한다. 

 

  • connect 함수 : 서버에 접속하기 위해 필요한 함수로 첫 인자에 클라이언트가 서버와의 연결을 위해 생성한 소켓이며 두 번째 인자에는 서버의 주소 정보(원격 주소 정보), 소켓 주소 구조체이다. connect 함수를 호출하면 클라이언트가 지역, 원격 주소 정보를 모두 결정하는데, 사실 지역 IP 주소와 포트 번호를 결정할 때 서버에서 bind 함수를 사용하였는데 클라이언트에서는 bind 함수를 사용하지 않았다. 이유는 connect 함수에서 자동적으로 운영체제에서 클라이언트의 지역 IP 주소와 포트 번호를 결정하기 때문이다. 즉 클라이언트에서는 connect 함수가 bind 함수를 대리한다. 

마지막으로 TCP에서 데이터를 송수신할 때, 필요한 데이터 전송 함수 send, recv에 대해서 살펴보자. 데이터 전송을 위해 send recv를 사용하는데 이를 변형하여 UDP에서는 sendto, recvfrom을 사용한다. 그리고 윈도우 전용 함수는 WSASend, WSARecv를 사용하며 리눅스 전용 함수는 write, read 함수를 사용한다.

 

프로그래머가 직접 작업을 해야 하는 공간은 당연히 응용 프로그램 구간이다. 운영체제에서 로컬 IP와 포트 번호, 리모트 (클라이언트) IP와 포트 번호를 관리한다. 서버와 클라이언트 각각의 호스트는 운영체제에서 데이터 송수신을 위해 수신 버퍼와 송신 버퍼를 운영한다. send 함수를 호출하면 데이터가 바로 서버로 이동하는 것이 아닌 클라이언트 측 운영체제가 관리하는 송신 버퍼에 복사가 되는 것이다. 즉, 클라이언트가 호출한 send 함수가 리턴했다는 뜻은 서버에서 데이터를 받은 것이 아닌 클라이언트 호스트의 운영체제에서 관리하는 송신 버퍼에 복사가 끝났음을 의미한다. 송신 버퍼로 복사된 데이터는 운영체제, TCP/IP 프로토콜에 의거하여 목적지를 찾아 이동하고 찾았을 때, 목적지의 수신 버퍼로 복사된다. 이후 서버에서 수신 버퍼에 데이터가 있다고 확신하면 recv 함수를 호출하여 수신 버퍼로 들어온 데이터를 서버 응용 프로그램으로 이동한다.

 

UDP 프로토콜은 수신 버퍼만 운영한다. 간단한 차이는 TCP 프로토콜이 송신 버퍼를 필요로 하는 이유는 신뢰성 있는 데이터 전송을 담보하기 위함이다. 애플리케이션에서 송신 버퍼로 데이터가 복사가 되고 목적지까지 이동을 하는데 이동 과정에서 데이터가 손실이 일어나면 목적지에서 데이터가 제대로 받았는지를 확인하는 과정에서 문제를 파악하게 될 것이다. 수신 측에서는 손실을 확인하고 다시 보내달라고 요청을 하는데 이때, 송신 버퍼에 있는 데이터를 다시 보낸다. 즉, 송신 버퍼로 이동시킨 데이터는 수신 측에서 데이터를 확인할때까지 지우지 않고 보관을 한다. 이렇게 보관을 함으로써 문제가 생겼을 때 재송신할 수 있다. 이 부분이 UDP와 TCP의 차이점이다.

 

  • send 함수 : 애플리케이션에서 데이터 전송을 위해 운영체제의 송신 버퍼에 데이터를 복사한다. 첫 인자는 통신할 대상과 연결된 소켓을 넣어준다. 두 번째는 보내고자 하는 데이터를 담고 있는 애플리케이션 버퍼의 주소 값이다. 세 번째는 버퍼의 크기이며 마지막은 거의 0을 사용한다고 보면 된다. 데이터를 보낼 때, 특별한 작업을 하지 않는 이상 블로킹 소켓으로 사용되는데, 블로킹 소켓이란 가로 막는다는 뜻으로 보내고자 하는 송신 버퍼의 크기가 데이터보다 더 작으면 여유 공간이 생길때까지 기다리는 것이다. 이 경우 보내고자 하는 데이터 사이즈가 크고 송신 버퍼의 크기가 충분하지 않으면 send 함수가 바로 리턴을 할 수 없고 렉이 생기게 된다. 이럴 땐 송수신 버퍼의 크기를 늘릴 수 있고 해당 소켓을 논 블로킹 소켓으로 처리할 수 있다.

 

  • recv 함수 : 운영체제의 수신 버퍼에 도착한 데이터를 애플리케이션 버퍼에 복사한다. 첫 인자는 통신할 대상과 연결된 소켓이다. 두 번째는 받은 데이터를 저장할 애플리케이션의 버퍼이다. 세 번째 인자는 수신 버퍼로부터 복사할 데이터의 최대 크기 값인데 두 번째 인자인 버퍼의 크기보다 작아야 한다. 마지막 인자는 받아야할 데이터의 크기를 알고 있다면 MSG_WAITALL값을 넣어서 사용하면 된다. TCP는 데이터 경계 구분을 하지 않는다. 예를 들어 10 바이트 데이터를 보내면 한 번에 오는 것이 아니라 5, 5 또는 5, 3, 2 처럼 나눠져서 온다. 이 때, recv 함수를 한 번만 부르면 전체를 수신 못받기 때문에 받을 데이터의 크기를 알고 있다면 위 flag를 사용하여 전체를 받을때 까지 기다리도록 한다. recv 함수는 수신 버퍼에 데이터가 도달한 경우와 접속이 정상 종료한 경우 성공적 리턴을 할 수 있다.

'네트워크 프로그래밍' 카테고리의 다른 글

[멀티스레드]  (0) 2023.10.11
[데이터 전송하기]  (0) 2023.09.27
[소켓 주소 구조체]  (0) 2023.09.12
[소켓 시작하기]  (0) 2023.09.10
[네트워크와 소켓 프로그래밍]  (0) 2023.09.09