코승호딩의 메모장

[데이터 전송하기] 본문

네트워크 프로그래밍

[데이터 전송하기]

코승호딩 2023. 9. 27. 11:54

01 응용 프로그램 프로토콜과 데이터 전송

TCP 서버-클라이언트의 기본 구조는 정해져 있으며 기본 구조와 핵심 함수를 기반으로 다른 기능을 추가하여 재사용할 수 있다. 즉 뼈대가 되는 코드를 그대로 복사하여 유용한 여러 기능을 만들 수 있다. 응용 프로그램의 고유 기능을 결정하는 부분은 데이터 처리 부분이다. 데이터를 어떤 형식으로 주고 받을지, 어떻게 처리할지는 네트워크 애플리케이션 개발자가 해야 할 가장 중요한 작업이다. 

 

  • 응용 프로그램 프로토콜 : 응용 프로그램 수준에서 주고받는 데이터 형식과 의미, 처리 방식을 정의한 프로토콜

 

다음과 같이 자신과 상대방의 화면에 동시에 그림을 그리는 기능을 제공하는 네트워크 그림판 프로그램이 있다고 하자. 해당 프로그램이 주고받아야 할 정보는 직선의 시작과 끝점, 선의 두께와 색상일 것이다. 이를 구조체로 표현해보자.

struct DrawingMessage1
{
	int x1, y1; 	// 직선의 시작점
	int x2, y2; 	// 직선의 끝점
	int width; 	// 선의 두께
	int color; 	// 선의 색상
}

이렇게 구조체로 묶어서 정의한 데이터를 수신한 쪽에서도 송신한 쪽과 똑같이 위 구조체와 같은 타입으로 처리해야 할 것이다. 이렇게 데이터를 보내는 쪽과 받는 쪽이 정보를 해석하고 처리하도록 합의한 약속들의 모임을 응용 프로그램 프로토콜(애플리케이션 프로토콜)이라고 정의할 수 있다.

 

그렇다면 만약 곡선이 아니라 원도 네트워크 프로그램을 통해 통신하고 싶다면 두 개의 구조체를 정의해야 할 것이다. 그러나 곡선과 원 두 개의 형식을 정의하면 받은 쪽에서는 어떤 타입인지 구분할 수 없으므로 타입을 나타내는 필드를 추가한다.

struct DrawingMessage1
{
	int type; 	// LINE
	int x1, y1; 	// 직선의 시작점
	int x2, y2; 	// 직선의 끝점
	int width; 	// 선의 두께
	int color; 	// 선의 색상
}

struct DrawingMessage2
{
	int type; 	// CIRCLE
	int x, y; 	// 원 중심 좌표
    	int r; 		// 원 반지름
    	int fillColor; 	// 내부 채우기 색상
	int width; 	// 테두리 두께
	int color; 	// 테두리 색상
}

이런식으로 맨 앞부분의 32비트 정수를 읽으면 직선인지, 원 데이터인지 알 수 있다.


메시지를 주고받을 때 시작과 끝을 이용해서 주고받을 필요가 있는데 이를 어떻게 구현하냐에 따라 4가지 방식으로 구별할 수 있다. 

 

  • 송신자는 항상 고정 길이 데이터를 보내고, 수신자도 항상 고정 길이 데이터를 읽는다. 예를 들어 로그인 ID와 패스워드는 고정 길이이므로 이 경우 사용할 수 있고 주고 받을 때 길이 값이 같거나 변동폭이 크지 않을 때 좋다. 그리고 구현하기 쉽다는 장점이 있다. 그러나 만약 가변 길이를 읽을 경우 낭비되는 부분이 나올 수 있는 단점이 있다.
  • 송신자는 가변 길이 데이터를 보내고 끝부분에 EOR(End Of Record)를 붙인다. 수신자는 EOR이 나올 때까지 데이터를 읽는다. 대표적으로 메신저나 도메인 네임을 처리할 때 도메인 네임의 길이가 항상 다르므로 끝에 EOR을 붙여서 처리하는 것과 같은 경우 사용한다. 그러나 EOR이 언제 나올지 모르기 때문에 데이터를 시작과 끝까지 전부 읽어야 하기 때문에 속도 저하의 우려가 있다. 생성 데이터의 길이를 미리 알 수 없을 때 적합하다.
  • 송신자는 보낼 데이터 크기를 고정 길이 데이터로 보내고, 이어서 가변 길이 데이터를 보낸다. 수신자는 고정 길이 데이터를 읽어서 뒤따라올 가변 데이터의 길이를 알아내고, 이 길이만큼 데이터를 읽는다. 이 방식은 가장 많이 사용되는 방식으로 보낼 때 데이터의 크기를 미리 알려주는 것이다. 오버헤드가 적으며 효율이 좋다고 알려졌다. 예를 들어 100바이트 정보를 보내고자 할 때, 앞의 고정 길이 데이터를 2바이트로 정했다면 2바이트 안에 100바이트라는 정보를 넣어 알려주는 것이다. 그리고 100바이트의 실제 데이터를 이어 붙이는 것이다. EOR이 있는지 파악할 필요가 없기 때문에 앞의 2바이트 정도는 무시할만 하다. 사용 예로 받아야할 바이트 크기를 알고 있기 때문에 퍼센테이지를 계산하여 UI로 사용할 수 있다. 생성 데이터의 길이를 미리 알고 있는 상황에서 구현이 쉽고 처리 효율성이 높다.
  • 송신자는 가변 길이 데이터 전송한 후 연결을 정상 종료한다. 수신자는 recv의 리턴값이 0이 될 때까지 데이터를 읽는다. 이 방법은 예외적인 방법으로 한 쪽에서 데이터를 일방적으로 보내고 끝낼 때 사용한다. 상당히 비효율적으로 동작한다. 일반 송수신이 빈번히 일어날 때는 적합하지 않다. 

#pragma pack(1) // 구조체 멤버 맞춤 기준을 1바이트 경계로 변경
struct MyMessage
{
    int a; 	// 4바이트
    char b; 	// 1바이트
    int c; 	// 4바이트
    char d; 	// 1바이트
}
#pragma pack() // 구조체 멤버 맞춤을 기본값으로 되돌림

구조체 멤버 맞춤은 32비트 주소 체계의 컴퓨터이기 때문에 사용한다. 32비트 주소 체계의 컴퓨터는 4바이트 단위로 메모리를 구분하기 때문에 속도가 빠르다. 따라서 b와 d가 1바이트이더라도 4바이트 크기를 잡아 사용하는 것이다. 따라서 실제 담아서 보내는 데이터는 10바이트이지만 메모리 상에서 데이터는 16바이트를 차지하게 되는 것이다. 일종의 패딩 개념이다. 이렇게 되면 네트워크를 타고 이동하는 데이터가 늘어나고 시간이 걸리며 서버에서 처리하는데 시간이 걸리기 때문에  #pragma pack 을 사용하면 빈 칸을 없앨 수 있다. 


02 다양한 데이터 전송 방식

다음으로 클라이언트가 보낼 데이터는 testdata의 내용이며 앞서 살펴본 네 가지 방식을 적용해보자.

 

[TCP 서버-클라이언트]

01 TCP 서버-클라이언트 구조 왼쪽 그림은 서버와 클라이언트의 동작하는 모습이다. 클라이언트는 사용자가 입력한 주소를 해석하여 접속 대기 중인 서버에 접속한다. 다음 HTTP를 이용하여 요청

suengho2257.tistory.com

위 코드를 기반으로 데이터 전송 종류에 따라 조금씩만 수정하도록 한다.


고정 길이 데이터 전송

고정 길이 데이터 전송은 서버와 클라이언트가 모두 크기가 같은 버퍼를 정의하여 데이터를 주고 받으면 된다.

 

#define BUFSIZE 50 // 50바이트 고정 길이 버퍼를 사용
// ...
char buf[BUFSIZE + 1]; // 1바이트 널 문자를 고려하여 BUFSIZE+1 크기 버퍼를 선언한다.

while(1) {
	//...
    while(1) {
    	//...
    	retval = recv(client_sock, buf, BUFSIZE, MSG_WAITALL); 
        // recv 마지막 인자에 MSG_WAITALL 옵션을 주어 항상 BUFSIZE만큼 데이터를 읽는다.
	//...
	}
}

위 코드는 서버쪽에서 고정 길이 데이터 수신을 위해 50바이트 사이즈를 사용한 것이다. 중요한 부분은 MSG_WAITALL이다.

 

#define BUFSIZE 50 // 50바이트 고정 길이 버퍼를 사용
// ...
// 데이터 통신에 사용할 변수
char buf[BUFSIZE];
const char *testdata[] = {
	"안녕하세요",
	"반가워요",
	"오늘따라 할 이야기가 많을 것 같네요",
	"저도 그렇네요",
};
for (int i = 0; i < 4; i++) {
	// 데이터 입력(시뮬레이션)
	memset(buf, '#', sizeof(buf));
	strncpy(buf, testdata[i], strlen(testdata[i]));

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

위 코드는 클라이언트 쪽에서 고정 길이 데이터 전송을 보여준다. 전송 여부 확인을 위해 buf를 #으로 밀고 보낸다. 문자열 길이와 상관 없기 때문에 send 함수에서 BUFSIZE만큼 데이터를 보낸다.

 

실행해보면 다음과 같이 클라이언트에서 어떠한 문자열을 보내든 50바이트에 맞춰 보내고 서버에서도 50바이트 버퍼를 받는다.


가변 길이 데이터 전송

가변 길이 데이터 전송은 EOR로 사용할 데이터 패턴을 정해야 한다. 구현 코드에서는 '\n'을 EOR로 간주하도록 한다.

while(1) {
    // 소켓 수신 버퍼에서 1바이트 데이터를 읽는다.
    // 읽은 데이터가 '\n'이 아니면 응용 프로그램 버퍼에 저장한다.
    // 읽은 데이터가 '\n'이면 루프를 빠져나온다.
}
// 응용 프로그램 버퍼에 저장된 데이터를 사용한다.

이와 같은 형태로 동작은 할테지만 1바이트 데이터를 읽는 부분에서 성능이 떨어진다. 따라서 소켓 수신 버퍼에서 데이터를 한꺼번에 읽어 내부에 저장해두고 읽기 요청을 할 때마다 1바이트씩 리턴해주는 함수를 만들어 사용해야 한다. 

 

// 내부 구현용 함수
int _recv_ahead(SOCKET s, char *p)
{
	__declspec(thread) static int nbytes = 0;
	__declspec(thread) static char buf[1024];
	__declspec(thread) static char *ptr;

	// 소켓 수신 버퍼에서 읽어들인 데이터가 아직 없거나 리턴하여 모두 소진한 경우
	if (nbytes == 0 || nbytes == SOCKET_ERROR) {
    	// 새로 읽어 buf 배열에 저장해두고 포인터 변수 ptr이 맨 앞쪽 바이트를 가리키게 한다. 
		nbytes = recv(s, buf, sizeof(buf), 0);
		if (nbytes == SOCKET_ERROR) {
			return SOCKET_ERROR;
		}
		else if (nbytes == 0)
			return 0;
		ptr = buf;
	}
	
    // 남은 바이트 수를 1감소시키고 포인터 변수 ptr이 가리키는 데이터를 p가 가리키는 메모리 영역에 복사하고 리턴한다.
	--nbytes;
	*p = *ptr++;
	return 1;
}

위 함수는 소켓 수신 버퍼에서 데이터를 한꺼번에 읽어 내부에 저장해두고 읽기 요청이 있을 때마다 1바이트씩 리턴해주는 사용자 함수이다. 함수가 리턴해도 값을 유지해야 하기 때문에 static 변수로 선언했다. __declspec(thread)는 멀티스레드 환경에서 필요하기 때문에 생략해도 상관 없다. 

 

// 사용자 정의 데이터 수신 함수
int recvline(SOCKET s, char *buf, int maxlen)
{
	int n, nbytes;
	char c, *ptr = buf;

	for (n = 1; n < maxlen; n++) {
		nbytes = _recv_ahead(s, &c);
		if (nbytes == 1) {
			*ptr++ = c;
			if (c == '\n')
				break;
		}
		else if (nbytes == 0) {
			*ptr = 0;
			return n - 1;
		}
		else
			return SOCKET_ERROR;
	}

	*ptr = 0;
	return n;
}

위 함수는 '\n'이 나올 때까지 데이터를 읽어들이는 함수이다. 소켓 s에서 데이터를 1바이트씩 읽어 buf가 가리키는 메모리 영역에 복사하되, '\n'이 나오거나 최대 길이에 도달하면 '\0'을 붙여 리턴한다. 

 

while(1) {
	//...
    while(1) {
    	//...
    	retval = recvline(client_sock, buf, BUFSIZE + 1); 
	//...
	}
}

마지막으로 서버에서 데이터를 받을 때 recv가 아닌 사용자 함수 recvline 함수를 사용하면 된다.

 

#define BUFSIZE 50 // 50바이트 버퍼를 사용하여 데이터를 보낸다.
// ...
// 데이터 통신에 사용할 변수
char buf[BUFSIZE];
const char *testdata[] = {
	"안녕하세요",
	"반가워요",
	"오늘따라 할 이야기가 많을 것 같네요",
	"저도 그렇네요",
};
int len; // 문자열 길이를 계산한 결과를 담는 변수

// 서버와 데이터 통신
for (int i = 0; i < 4; i++) {
	// 데이터 입력(시뮬레이션)
	len = (int)strlen(testdata[i]);
	strncpy(buf, testdata[i], len);
	buf[len++] = '\n';

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

위 코드는 클라이언트에서 문자열 길이에 따라 send 함수를 불러 서버에 전송한 코드이다.

 

실행해보면 다음과 같이 클라이언트에서 보낸 데이터의 사이즈가 각각 다르다는 것을 알 수 있다.


고정 길이 + 가변 길이 데이터 전송

송신 측에서 가변 길이 데이터 크기를 미리 계산할 수 있다면 이 방법이 효과적이다. 수신 측에서 1) 고정 길이 데이터 수신, 2) 가변 길이 데이터 수신, 두 번의 데이터 읽기 작업으로 데이터 경계를 구분하여 읽을 수 있다.

 

#define BUFSIZE 512 // 512바이트 버퍼를 사용하여 데이터를 읽는다. 실제 데이터는 이보다 작다고 가정
// ...
int len; // 고정 길이 데이터
char buf[BUFSIZE + 1]; // 가변 길이 데이터의 버퍼

while(1) {
	//...
    while(1) {
    	//...
    	retval = recv(client_sock, (char *)&len, sizeof(int), MSG_WAITALL);
	//...
    	retval = recv(client_sock, buf, len, MSG_WAITALL);
	//...
	}
}

위 코드는 서버에서 클라이언트가 송신한 고정 길이 데이터와 가변 길이 데이터를 수신하는 코드이다. 먼저 32비트 사이즈 만큼 앞의 고정 길이 데이터를 len에 받아준다. 그리고 또 recv를 통해 buf에 가변 길이 데이터를 받아준다. 두 번 recv 모두 사이즈를 알고 있는 상태이기 때문에 MSG_WAITALL을 사용한다. 

 

// 데이터 통신에 사용할 변수
char buf[BUFSIZE];
const char *testdata[] = {
	"안녕하세요",
	"반가워요",
	"오늘따라 할 이야기가 많을 것 같네요",
	"저도 그렇네요",
};
int len;

// 서버와 데이터 통신
for (int i = 0; i < 4; i++) {
	// 데이터 입력(시뮬레이션)
	len = (int)strlen(testdata[i]);
	strncpy(buf, testdata[i], len);

	// 데이터 보내기(고정 길이)
	retval = send(sock, (char *)&len, sizeof(int), 0);
	if (retval == SOCKET_ERROR) {
		err_display("send()");
		break;
	}

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

// ...

위 코드는 클라이언트가 고정 길이와 가변 길이의 데이터를 두 번 send한 코드이다.

 

실행해보면 다음과 같이 클라이언트에서 보낸 데이터에 고정 길이 + 가변 길이 사이즈인 것을 알 수 있다.


데이터 전송 후 종료

위 방식은 EOR로 특별한 데이터 패턴 대신 연결 종료를 사용하는 일종의 가변 길이 데이터 전송 방식이다. 

 

#define BUFSIZE 1024 // 1024바이트 버퍼를 사용하여 데이터를 읽는다. 실제 데이터는 이보다 작다고 가정
// ...
char buf[BUFSIZE + 1]; // 가변 길이 데이터의 버퍼

while(1) {
	//...
    while(1) {
    	//...
    	retval = recv(client_sock, buf, BUFSIZE, MSG_WAITALL);
	//...
	}
}

위 코드는 서버가 recv 함수에서 MSG_WAITALL 옵션을 통해 데이터를 최대한 읽는 코드이다. 서버는 클라이언트가 데이터를 모두 전송한 후 접속을 종료한다고 가정한다.

 

// 데이터 통신에 사용할 변수
char buf[BUFSIZE];
const char *testdata[] = {
	"안녕하세요",
	"반가워요",
	"오늘따라 할 이야기가 많을 것 같네요",
	"저도 그렇네요",
};
int len;

// 서버와 데이터 통신
for (int i = 0; i < 4; i++) {
	// 소켓 생성
	SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);
	if (sock == INVALID_SOCKET) err_quit("socket()");

	// connect()
	retval = connect(sock, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
	if (retval == SOCKET_ERROR) err_quit("connect()");

	// 데이터 입력(시뮬레이션)
	len = (int)strlen(testdata[i]);
	strncpy(buf, testdata[i], len);

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

	// 소켓 닫기
	closesocket(sock);
}
// ...

위 코드는 클라이언트가 서버에게 데이터를 send할 때마다 연결을 끊고 다시 연결을 하는 모습이다. 매 루프마다 새 소켓을 생성하고 서버에 연결한 다음 데이터를 보낸다.

 

실행해보면 다음과 같이 서버에서 클라이언트가 접속한 후 데이터를 출력하고 바로 접속을 종료한다. 이후에 다시 클라이언트가 접속을 하며 데이터 출력 후 접속을 종료하는 것을 반복하는 모습을 볼 수 있다.


03 동영상 파일 전송

다음으로 효율적인 전송 방식인 고정 길이 + 가변 길이 데이터 전송 방식으로 클라이언트에서 가지고 있는 동영상(.avi) 파일을 서버 측에 넘겨서 서버에서 정상적으로 동영상을 실행할 수 있는 프로그램을 작성해보도록 한다. 위 프로그램의 요구사항은 다음과 같다.

 

  • 전송할 파일명은 명령행 인수로 처리한다.
  • 릴리즈 모드에서 실행한다.
  • 파일을 수신받는 서버 콘솔창에서 전송률을 %로 표시하되 scroll up 되지 않도록 한다.
  • 고정 길이 + 가변 길이 데이터 전송 방식을 사용한다.
  • 서버와 클라이언트의 동영상 파일 이름은 같아야 한다.

위를 해결하기 위해서는 클라이언트가 서버에 넘겨줄 데이터는 동영상 파일 뿐만 아니라 동영상 파일의 이름도 필요할 것이고 전송률 계산을 위한 동영상 파일의 총 크기도 함께 넘겨줘야 할 것이다. 따라서 우선 파일 입출력을 통해 파일 사이즈를 알아내고 문자열에 파일 이름과 파일 사이즈를 추가하여 함께 넘겨주도록 하자. 참고로 .avi 파일은 바이너리 파일로 받아야 한다. 

 

// Client.cpp

//... 파일 입출력 코드
std::string fileName = argv[1];
std::ifstream in{fileName, std::ios::binary};
size_t fileSize = std::filesystem::file_size(fileName);
std::string fileNameSize = fileName + '\n' + std::to_string(fileSize) + '\n';
// ...

이 코드에서는 파일 입출력을 바이너리 모드로 진행하고 파일 사이즈와 파일 이름을 합친 문자열을 저장한다.

 

우선 클라이언트가 최대 1024바이트씩 서버에 데이터를 보낸다고 설계한다. (이 방식은 효율성이 좋지 않다. send를 여러 번 불러야 하며 낭비이기 때문이다.) 따라서 만약 파일 전체 사이즈가 102'400바이트일 경우 102'400 / 1024 = 100이므로 총 100번에 걸쳐 데이터를 보내는 것이다.

// Client.cpp
#define BUFSIZE    1024

//...
char buf[BUFSIZE]; // 데이터 통신에 사용할 변수
int bufSize; // 한번에 보낼 버퍼의 크기
int bufCount = fileSize / BUFSIZE; // 총 데이터를 보낼 횟수
//...

따라서 최대 버퍼 사이즈인 BUFSIZE를 1024로 정의하고 매번 보낼 버퍼의 사이즈가 달라질 수 있기 때문에 유동적인 버퍼의 사이즈를 bufSize를 데이터를 보낼때마다 변경한다. 그리고 버퍼를 보내야 하는 횟수인 bufCount를 저장한다.

 

 

 

 

 

 

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

[멀티스레드]  (0) 2023.10.11
[TCP 서버-클라이언트]  (0) 2023.09.26
[소켓 주소 구조체]  (0) 2023.09.12
[소켓 시작하기]  (0) 2023.09.10
[네트워크와 소켓 프로그래밍]  (0) 2023.09.09