독서/TCPIP 소켓 프로그래밍

TCP 소켓을 이용한 간단한 서버/클라이언트 프로그램 구현

studylida 2023. 6. 9. 21:00

책에서는 TCP/IP 서버/클라이언트 프로그램 구현이라는 제목의 장이었지만, 코드 보니까 TCP/IP 하나도 몰라도 되는 정도의 코드라서 TCP/IP는 다음에 조금 더 심화된 내용의 코드가 나오면 그때 따로 다루도록 하자.

 

위키백과에 따르면 소켓이란 컴퓨터 네트워크를 경유하는 프로세스 간 통신의 종착점이라고 설명되어 있다. 이렇게 들어도 무슨 말인지 잘 이해가 가지 않을 수 있는데, 앞으로 공부하다 보면 자연스럽게 알게 될테니 급하게 알려하지 않아도 된다. 그저 우리가 네트워크 프로그래밍을 할 수 있도록 도와주는 API 정도로 이해하면 된다. 이 책에서는 앞으로 쭉 소켓의 도움을 받아서 프로그램을 작성할 것이다. 소켓에 데이터를 쓰고, 소켓으로부터 데이터를 읽을 것이다.

 

소켓에는 TCP 소켓과 UDP 소켓이 있는데(둘의 차이는 아직 몰라도 된다), 일단 이번 게시글에서는 TCP 소켓을 사용할 것으로 전제하고 글을 이어가겠다. 어떻게 통신 프로그램을 작성하는지 알아보기 전에 TCP 소켓을 이용한 서버/클라이언트 프로그램의 기본적인 동작부터 알아보고 가자.

 

TCP 소켓을 이용한 통신은 전화통화와 비슷한 과정을 거친다.

전화 소켓
1. 한쪽에서 연결을 시도하고, 다른 쪽에서는 대기한다. 1. 한쪽에서는 연결을 시도하고, 다른 쪽에서는 연결을 받아들인다.
2. 연결이 이루어진 후 통화를 한다. 2. connect()와 accept()를 통해 연결이 된 후에 통신이 이루어진다.
3. 연결을 종료한다. 3. 통신을 종료한다.

 

  1. 일단 전화를 하기 위해서는 전화기가 필요하듯이, 네트워크 통신을 하기 위해서는 소켓이 필요하다. 때문에 프로그램의 도입 부분에서는 소켓을 생성하게 된다. 이후에 일어나는 연결, 통신, 그리고 종료는 모두 이 소켓을 통해서 이루어진다. socket() 함수가 이를 담당한다.
  2. 그리고 전화기에 대응되는 전화번호가 있어야 하듯이, 소켓에도 그러한 역할을 하는 것이 필요하다. 소켓에서는 IP 주소가 그 역할을 담당한다. bind() 함수가 이를 담당한다.
  3. 개통된 전화기가 있다면 이제 통신이 가능하다! 통신을 위해서는 한 쪽에서 전화를 걸고, 다른 한 쪽에서는 전화를 받아야 한다. 네트워킹을 위해서도 마찬가지로 한 쪽에서는 연결을 받아줄 것을 요청하고, 한 쪽에서는 연결을 기다리고, 연결을 받는다. 전화와 살짝 다른 점이라면 이쪽은 준비를 하지 않으면 연결이 와도 알 수 없다는 점이다. 이를 굳이 전화를 이용한 통신에 비유하자면 휴대폰의 전원을 켜는 것이 될 것이다. 전원이 꺼져있으면 전화를 받지 못하니 말이다. listen(), connect(), 그리고 accept() 함수가 이를 담당한다.
  4. 전화가 걸렸다면 이제 대화를 한다. 네트워킹에서도 마찬가지이다. write(), read(), recvfrom() 등의 함수가 이를 담당한다.
  5. 모든 대화를 마쳤다면 이제 전화를 끊어야 한다. 네트워킹에서도 마찬가지이다. close() 함수가 이를 담당한다.

 

하여 지금까지 전화에 비유하여 소켓을 이용한 서버/클라이언트 프로그램의 기본적인 동작원리에 대해 배워보았다. 이제 이를 바탕으로 간단한 서버/클라이언트 프로그램을 구현해보자.

 

일단 서버 프로그램의 동작 순서를 알아보자.

  1. 소켓 생성(socket() 함수)
  2. 생성된 소켓에 인터넷 주소 부여(bind() 함수)
  3. 데이터 수신 대기(listen() 함수)
  4. 데이터 수신(accept 함수)
  5. 통신(read(), write() 함수)
  6. 현재 연결된 클라이언트와 통신이 끝났다면 종료(close() 함수)
  7. 3번으로 돌아가서 다른 데이터 수신 대기(listen() 함수)

이처럼 서버 프로그램은 수동적인 입장에서 클라이언트 프로그램의 접속을 기다리고 클라이언트 프로그램이 접속을 하면 데이터를 수신한 후에 처리한 결과 데이터를 다시 클라이언트에게 전송하는 역할을 한다.

 

다음은 클라이언트 프로그램의 동작 순서를 알아보자. 이는 클라이언트 프로그램이 서버에 접속하기 위한 순서이다.

  1. 소켓 생성(socket() 함수)
  2. 서버에 연결(connect() 함수)
  3. 통신(write(), read() 함수)
  4. 연결 종료(close() 함수)

클라이언트 프로그램은 서버 프로그램과 다르게 요청을 기다리는 것이 아니라 서버에 연결한 다음에 자신이 필요로 하는 요청을 보내고 원하는 데이터를 받는 역할만 하면 된다. 그렇다고 무조건 이렇게 만들라는 건 아니다 상황 따라 다르다!

 

클라이언트 프로그램에서 신경써야 하는 부분은 서버 프로그램에서의 많은 연결을 처리하기 위한 알고리즘이나 구조가 아니라 사용자에게 얼마나 더 편리한 GUI를 제공할 것인지가 될 것이다(물론 이 말을 했다고 해서 이 게시물에서 GUI를 만들거나 하진 않을 거다).

 

이번에 책에서 나오는 프로그램은 클라이언트가 서버와 연결되면 서버에게서 "I like you!"라는 응답을 받게 되는 프로그램이다.

 

프로그램을 만들기 전에 구조를 먼저 생각해보면,

 

서버 프로그램의 경우

  1. socket() 함수를 이용해 소켓을 생성한다
  2. bind() 함수를 이용해 어떤 프로토콜을 사용할지, 어떤 주소에서 들어오는 요청을 받아들이고, 어떤 포트를 사용할지와 같은 것들을 정한다.
  3. listen() 함수를 통해 클라이언트의 접속을 기다린다(수신대기). listen() 함수를 이용하면 bind() 함수에서 지정했던 주소에서 들어오는 접속만을 주시하게 된다.
  4. 클라이언트가 서버에 접속하면 accept() 함수가 이를 감지하고, 새로운 소켓을 리턴한다. 리턴된 소켓은 접속한 클라이언트와 연결을 이룬다.
  5. write()와 accept() 함수가 리턴한 소켓을 이용해 통신한다(지금과 같은 경우에는 "I like you!"를 보낸다).
  6. 소켓을 닫고, 다른 클라이언트를 기다린다.

클라이언트 프로그램의 경우

  1. socket() 함수를 이용해 소켓을 생성한다.
  2. connect() 함수를 이용해 서버 프로그램이 실행되어 있는 컴퓨터의 특정 포트(서버프로그램에서 bind() 함수가 정한 주소와 포트)에 접속한다.
  3. 서버와 통신한다.
  4. 소켓을 닫는다.

이를 바탕으로 작성한 프로그램은 다음과 같다.

 

서버 프로그램

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

#define MAXBUF 256

int main(void) {
	int ssock, csock;
	socklen_t clen;
	struct sockaddr_in client_addr, server_addr;
	char buf[MAXBUF] = "I like you!";
	/*
	 * create server socket.
	 * 
	 * The socket() function returns a value less than zero
	 * if an error occurs. 
	 */
	if((ssock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0 ) {
		perror("socket error: ");
		exit(1);
	}
	
	/*
	 * Later, the accept() function requires the socket information of
	 * the other party and the length of the socket information as parameters.
	 */ 
	clen = sizeof(client_addr);

	memset(&server_addr, 0, sizeof(server_addr));
	server_addr.sin_family = AF_INET;
	server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	server_addr.sin_port = htons(3317);

	if(bind(ssock, (struct sockaddr *)&server_addr
				, sizeof(server_addr)) < 0) {
		perror("bind error: ");
		exit(1);
	}

	if(listen(ssock, 8) < 0) {
		perror("listen error: ");
		exit(1);
	}

	while(1) {
		csock = accept(ssock, (struct sockaddr *)&client_addr, &clen);

		if(write(csock, buf, MAXBUF) <= 0)
			perror("write error: ");

		close(csock);
	}

	return 0;
}

클라이언트 프로그램

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

#define MAXBUF 256

int main(void) {
	int ssock;
	socklen_t clen;
	struct sockaddr_in server_addr;
	char buf[MAXBUF];

	if((ssock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
		perror("socket error: ");
		exit(1);
	}

	clen = sizeof(server_addr);

	memset(&server_addr, 0, sizeof(server_addr));
	server_addr.sin_family = AF_INET;
	server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
	server_addr.sin_port = htons(3317);

	if(connect(ssock, (struct sockaddr *)&server_addr, clen) < 0) {
		perror("connect error: ");
		exit(1);
	}

	memset(buf, 0, MAXBUF);

	if(read(ssock, buf, MAXBUF) <= 0) {
		perror("read error: ");
		exit(1);
	}

	close(ssock);

	printf("\nread: %s\n\n", buf);

	return 0;
}

 

책에는 clen의 자료형이 int로 되어있는데 int로 하니까 warning 떠서 socklen_t로 자료형을 바꿨다.

 

프로그램의 등장하는 구조체, 매크로 변수와 각 함수의 리턴값, 매개변수, 역할(기능)에 대해서 알아보자.

 

sockaddr_in

 

socket()

 

perror()

 

PF_INET

SOCK_STREAM

IPPROTO_TCP

 

htnol()

htnos()

 

bind()

 

listen()

connect()

accept()

 

write()

read()

 

close()