[소켓 #15] 소켓과 표준 입출력

이석환·2023년 6월 11일

Socket Programming

목록 보기
16/18

1. 표준 입출력 함수의 장점

이번 Chapter에서는 포준 입출력 함수를 이용한 데이터 송수신 방법에 대해 소개한다.

1-1. 표준 입출력 함수의 두 가지 장점

  • 표준 입출력 함수는 이식성(Portability)이 좋음
  • 표준 입출력 함수는 버퍼링을 통한 성능 향상에 도움이 됨

    첫 번째 내용에 대해 설명해보겠다.
    ANSI C 기반의 표준 입출력 함수는 모든 컴파일러에서 지원을 하기 때문에 이식성이 좋다.
    이는 사실 모든 표준 함수가 동일하다.

    두 번째 내용에 대해 설명해보겠다.
    표준 입출력 함수를 사용할 경우 추가적으로 입출력 버퍼를 제공받는다.
    이 얘기를 하면 앞에서 했던 내용과 혼동될 수 있다.
    왜냐하면 소켓을 생성하면 기본적으로 OS에서 입출력 버퍼가 생성되기 때문이다.
    맞는 말이다. 이는 TCP 프로토콜을 진행하는데 매우 중요한 역할을 한다.
    이와는 별도로 표준 입출력 함수를 사용하게 되면, 하나의 버퍼를 더 제공받는다.
  • 표준 입출력 함수를 이용해서 데이터를 전송할 경우 그림과 같이 소켓의 입출력 버퍼 이외의 버퍼를 통해서 버퍼링이 된다.

예를 들어 fputs 함수를 통해서 "Hello" 문자열을 전송할 경우, 입출력 함수 버퍼에 데이터가 전달된다.
그 후, 소켓의 출력버퍼로 이동하고 상대방에게 문자열이 전송된다.

버퍼는 기본적으로 성능의 향상을 목적으로 한다.
하지만 소켓과 관련된 버퍼는 TCP 의 구현을 위한 목적이 강하다.
예를 들어 TCP의 경우 데이터가 분실되면 재전송을 한다. 그러기 위해서 사용되는 목적이 강하다.
반면에 표준 입출력 함수의 버퍼는 성능 향상만을 목적으로 제공된다.

버퍼링의 성능은 다음과 같은 관점에서 좋다.
1. 전송하는 데이터의 양
2. 출력버퍼로의 데이터 이동 횟수

1byte짜리 데이터를 총 열 번에 걸쳐(열 개의 패킷)에 보내는 경우와
이를 버퍼링해서 10byte짜리 패킷 하나로 묶어서 보내는 것은 다르다.
데이터의 전송을 위한 패킷에는 헤더정보라는 것이 추가된다.
헤더 정보와 패킷의 수를 곱해서 생각해보자.
추가로 소켓의 출력버퍼로 데이터를 이동시키는 데도 시간이 제법 많이 소모가 된다.
10회 이동시키는 것이 빠르겠는가 1회 이동시키는 것이 빠르겠는가
충분히 설명이 됐다고 생각하고 넘어가겠다.

1-2. 표준 입출력 함수와 시스템 함수의 성능 비교

시스템 함수와 표준 입출력 함수를 이용하여 비교하여 보여주겠다.
시스템 함수

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#define BUF_SIZE 3

int main(int argc, char *argv[])
{
	int fd1, fd2, len;
	char buf[BUF_SIZE];

	fd1=open("news.txt", O_RDONLY);
	fd2=open("cpy.txt", O_WRONLY|O_CREAT|O_TRUNC);

	while((len=read(fd1, buf, sizeof(buf)))>0)
		write(fd2, buf, len);

	close(fd1);
	close(fd2);
	return 0;
}

표준 입출력 함수

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

#define BUF_SIZE 3

int main(int argc, char *argv[])
{
	FILE * fp1;
	FILE * fp2;
	char buf[BUF_SIZE];
	
	fp1=fopen("news.txt", "r");
	fp2=fopen("cpy.txt", "w");

	while(fgets(buf, BUF_SIZE, fp1)!=NULL)
		fputs(buf, fp2); 

	fclose(fp1);
	fclose(fp2);
	return 0;
}
  • 시스템 함수를 이용한 파일 복사
    버퍼링 없는 파일 복사
    open(), read(), write() 함수
  • 표준 입출력 함수를 이용한 파일 복사
    버퍼링 기반의 파일 복사
    fopen(), fgets(), fputs() 함수

    300Mbytes 이상의 파일을 대상으로 테스트 시 속도차이가 매우 크다는 것을 알 수 있다.

추가적으로 파일 복사를 통해 속도 차이를 알아보겠다

시스템 함수 기반의 파일 복사

#include <stdio.h>
#include <fcntl.h>
#include <time.h>
#include <unistd.h>

#define BUF_SIZE 3

int main(int argc, char *argv[])
{
	int fd1, fd2;
	int len;
	char buf[BUF_SIZE];
	unsigned long nano = 1000000000;
	unsigned long t1,t2;
	struct timespec start, end;
	
	if(argc != 3)
	{
		printf("Usage: %s <src_file> <dest_file>\n", argv[0]);
		return -1;
	}
	
	fd1 = open(argv[1], O_RDONLY);
	fd2 = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC);
	
	clock_gettime(CLOCK_REALTIME, &start);
	t1 = start.tv_nsec + start.tv_sec * nano;

	while((len = read(fd1, buf, sizeof(buf))) > 0)
		write(fd2, buf, len);
	
	clock_gettime(CLOCK_REALTIME, &end);
	t2 = end.tv_nsec + end.tv_sec * nano;
	
	printf("syscpy elapsed time : %ld milliseconds\n", (t2-t1)/1000000);

	close(fd1);
	close(fd2);
	return 0;
}

시스템 함수 기반의 파일 복사

#include <stdio.h>
#include <fcntl.h>
#include <time.h>
#include <unistd.h>

#define BUF_SIZE 3

int main(int argc, char *argv[])
{
    FILE *f1;
    FILE *f2;
    char buf[BUF_SIZE];
    unsigned long nano = 1000000000;
    unsigned long t1,t2;
    struct timespec start, end;
    
    if(argc != 3)
    {
        printf("Usage: %s <src_file> <dest_file>\n", argv[0]);
        return -1;
    }
    
    f1 = fopen(argv[1], "r");
    f2 = fopen(argv[2], "w");
    
    clock_gettime(CLOCK_REALTIME, &start);
    t1 = start.tv_nsec + start.tv_sec * nano;

    while(fgets(buf,BUF_SIZE, f1) != NULL)
        fputs(buf, f2);
    
    clock_gettime(CLOCK_REALTIME, &end);
    t2 = end.tv_nsec + end.tv_sec * nano;
    
    printf("Stdcpy elapsed time : %lld milliseconds\n", (t2-t1)/1000000);

    fclose(f1);
    fclose(f2);
    return 0;
}
  • 실행결과
    시간을 측정하는 함수를 사용하여 구현하였다.
    실행 결과는 30.5MB 파일을 대상으로 복사했을 때 약 26.5배의 차이가 발생하였다.

1-3. 표준 입출력 함수의 사용에 있어서 불편사항

무조건 좋은 점만 있는 것은 아니다. 다음과 같은 단점이 있다.
1. 양방향 통신이 쉽지 않다.
2. 상황에 따라서 fflush 함수의 호출이 빈번히 등장할 수 있다.
3. 파일 디스크립터를 FILE 구조체의 포인터로 반환해야한다.

  • fopen 함수 호출 시 반환되는 File 구조체의 포인터를 대상으로 입출력을 진행할 경우
    읽고 쓰기가 동시에 가능하려면 r+, w+, a+ 모드로 파일을 열어야 한다.
    이 때 버퍼링 문제로 인해서 읽기에서 쓰기로, 쓰기에서 읽기로 작업의 형태를 바꿀 때마다 fflush 함수를 호출해야 한다.
    이런 경우에는 표준 입출력 함수의 장점인 버퍼링 기반의 성능향상에 영향을 미친다.

  • 표준 입출력함수의 사용을 위해서는 FILE 포인터가 필요하다. 표준 C 함수에서 요구하는 것은 FILE 구조체의 포인터이다.
    하지만 기본적으로 소켓은 생성시에 파일 디스크립터를 반환한다.
    즉, 파일 디스크립터를 FILE 구조체의 포인터로 변환해야 한다.

2. 표준 입출력 함수 사용하기

위에서 파일 디스크립터를 FILE 포인터로 변환해야 함을 알았다.
이번에는 그 방법을 설명하겠다.

2-1. fdopen 함수를 이용한 FILE 구조체 포인터로의 변환

소켓의 생성으로 반환된 파일 디스크립터를 FILE 포인터로 변환하는 일은 fdopen 함수를 사용한다.

#include <stdio.h>

FILE* fdopen(int fildes, const char * mode);
// 성공 시 변환된 FILE 구조체 포인터, 실패시 NULL 반환

/*
fildes : 변환할 파일 디스크립터를 인자로 전달
mode : 생성할 FILE 구조체 포인터의 모드(mode)정보 전달
*/

두 번째 매개 변수는 fopen과 동일하게 읽기 모드인 "w"와 쓰기 모드인 "w" 등이 있다.

#include <stdio.h>
#include <fcntl.h>

int main(void)
{
	FILE *fp;
	int fd=open("data.dat", O_WRONLY|O_CREAT|O_TRUNC);
	if(fd==-1)
	{
		fputs("file open error", stdout);
		return -1;
	}

	fp=fdopen(fd, "w");
	fputs("Network C programming \n", fp);
	fclose(fp);
	return 0;
}
  • open 함수르 사용해서 파일을 생성헀으므로 파일 디스크립터가 반환
  • fdopen 함수 호출을 통해서 파일 디스크립터를 FILE 포인터로 변환, 이 때 "w"가 전달되었으니, 출력모드의 FILE 포인터가 반환
  • 위에서 얻은 포인터를 기반으로 표준 출력 함수인 fputs 함수를 호출
  • FILE 포인터를 이용해서 파일으 닫음. 이 경우 파일 자체가 완전히 종료되기 때문에 파일 디스크립터를 이용해서 또 다시 종료할 필요는 없다. fclose 함수 호출 이후로는 파일 디스크립터도 아무 의미 없는 정수가 된다.
  • 실행 결과

2-2. fileno 함수를 이용한 파일 디스크립터로의 변환

이번에는 fdopen 함수의 반대 기능을 제공하는 함수이다.

#include <stdio.h>

int fileno(FILE * stream);
// 성공 시 변환된 파일 디스크립터, 실패시 -1 반환

인자로 FILE 포인터를 전달하면 해당 파일의 파일 디스크립터가 반환된다.

#include <stdio.h>
#include <fcntl.h>

int main(void)
{
	FILE *fp;
	int fd=open("data.dat", O_WRONLY|O_CREAT|O_TRUNC);
	if(fd==-1)
	{
		fputs("file open error", stdout);
		return -1;
	}
	
	printf("First file descriptor: %d \n", fd); 
	fp=fdopen(fd, "w");
	fputs("TCP/IP SOCKET PROGRAMMING \n", fp);
  	printf("Second file descriptor: %d \n", fileno(fp));
	fclose(fp);
	return 0;
}
  • open 함수로 얻은 파일 디스크립터를 fdopen을 통해 FILE 포인터로 변환하였다
    그 후 fileno 함수를 통해 다시 파일 디스크립터로 변환한 후 정수 값을 출력하였다.
  • 실행 결과

    같은 값이 출력되는 것으로 적절히 변환되었음을 증명하였다.

3. 소켓 기반에서의 표준 입출력 함수 사용

위에서 공부한 내용으로 소켓에 적용해보겠다.
이전에 구현했던 에코 서버와 에코 클라이언트를 표준 입출력 함수를 이용해서 데이터가 송수신하도록 변경하였다.

3-1. Serv

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    char message[BUF_SIZE];
    int str_len, i;
    
    struct sockaddr_in serv_adr;
    struct sockaddr_in clnt_adr;
    socklen_t clnt_adr_sz;
    FILE * readfp;
    FILE * writefp;
    
    if(argc!=2) {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }
    
    serv_sock=socket(PF_INET, SOCK_STREAM, 0);
    if(serv_sock==-1)
        error_handling("socket() error");
    
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family=AF_INET;
    serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
    serv_adr.sin_port=htons(atoi(argv[1]));

    if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
        error_handling("bind() error");
    
    if(listen(serv_sock, 5)==-1)
        error_handling("listen() error");
    
    clnt_adr_sz=sizeof(clnt_adr);

    for(i=0; i<5; i++)
    {
        clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
        if(clnt_sock==-1)
            error_handling("accept() error");
        else
            printf("Connected client %d \n", i+1);
    
        readfp=fdopen(clnt_sock, "r");
        writefp=fdopen(clnt_sock, "w");
    
        while(!feof(readfp))
        {
            fgets(message, BUF_SIZE, readfp);
            fputs(message, writefp);
            fflush(writefp);
        }
        fclose(readfp);
        fclose(writefp);
    }
    close(serv_sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
  • 입력용, 출력용 FILE 구조체 포인터를 각각 생성해야 한다.
  • 표준 C 입출력 함수를 사용할 경우 소켓의 버퍼 이외에 버퍼링이 되기 때문에, 필요하다면 fflush 함수를 직접 호출해야 한다.
  • 반복문에서 fgets, fputs, fflush를 주목해보자
    표준 입출력 함수는 성능향상을 목적으로 버퍼링을 하기 때문에 fflush 함수를 호출하지 않으면 당장에 클라이언트로 데이터가 전송된다고 보장할 수 없다.

일반적인 순서
1. 파일 디스크립터를 FILE 구조체 포인터로 변환
2. 표준 입출력 함수 호출
3. 함수 호출 시 fflush 함수 호출을 통해 버퍼를 비움

3-2. Client

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	int str_len;
	struct sockaddr_in serv_adr;
	FILE * readfp;
	FILE * writefp;

	if(argc!=3) {
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_STREAM, 0);   
	if(sock==-1)
		error_handling("socket() error");
	
	memset(&serv_adr, 0, sizeof(serv_adr));
	serv_adr.sin_family=AF_INET;
	serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
	serv_adr.sin_port=htons(atoi(argv[2]));
	
	if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
		error_handling("connect() error!");
	else
		puts("Connected...........");

	readfp=fdopen(sock, "r");
	writefp=fdopen(sock, "w");	

	while(1) 
	{
		fputs("Input message(Q to quit): ", stdout);
		fgets(message, BUF_SIZE, stdin);
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))
			break;

		fputs(message, writefp);
		fflush(writefp);
 		fgets(message, BUF_SIZE, readfp);
		printf("Message from server: %s", message);
	}	
	fclose(writefp);
	fclose(readfp);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}
  • 기존에 시스템 함수를 사용하였을 때는 데이터의 마지막에 0을 삽입(message[strlen] = 0;)하여 수신된 데이터를 문자열로 구성하는 과정이 필요하였지만 표준 입출력 함수의 사용으로 문자열 단위로 데이터를 송수신하기 때문에 생략되었음을 알 수 있다.
  • 실행 결과

참고 : 윤성우의 열혈 TCP/IP 소켓 프로그래밍
Git : https://github.com/im2sh/Socket_Programming/tree/main/lab11

profile
반갑습니다.

0개의 댓글