열렬히.뛰기

5. TCP 기반 서버 (2)

네트워크 > 소켓 프로그래밍 > 소켓 프로그래밍 > 5. TCP 기반 서버 (2)

서버 구현 1 - 에코서버

제대로 된 echo 서버 구현에 집중해보자.

문제점

앞의 4단원에서 구현한 서버의 문제점은 다음과 같았다.

client의 write() → server의 read()로 가는 과정에서 문자열의 일부만 읽힐 수 있다. server의 write() → client의 read() 과정에서도 문자열의 일부만 전해진다. 그래서 문자열이 짤려서 client에게 올 수 있다.

c
/* echo_client.c */

// write()를 통해 sock에 message를 쓴다. (echo 서버로 보내짐.)
write(sock, message, strlen(message));
----------------------------------------------------------------
/* echo_server.c */

// read()를 통해 message를 읽는다. 이때 메시지가 짤릴 수 있다.
// write()를 통해 message를 작성. 소켓을 통해 다시 보낸다.
// 메시지가 안 오면(=클라이언트가 q를 누르면) str_len == 0으로 인식하고 종료
while ((str_len = read(clnt_sock, message, BUF_SIZE)) != 0)
	write(clnt_sock, message, str_len);
---------------------------------------------------------------
/* echo_client.c */

// read()를 통해 서버가 적은 sock의 문자열을 읽는다.
str_len = read(sock, message, BUF_SIZE - 1);

// 해당 문자열 출력
message[str_len] = 0;
printf("message from server : %s", message);

해결책

client의 read() 과정을 수정: 문자열 데이터를 전부 수신할 때까지 계속 read()를 실행

c
/* echo_client.c */

// read()를 통해 서버가 적은 sock의 문자열을 읽는다.
str_len = read(sock, message, BUF_SIZE - 1);
c
/* echo_client.c */

// read()를 통해 서버가 적은 sock의 문자열을 읽는다.
// 단, 모든 문자열이 들어올 때까지 계속 읽는다.
while (recv_len < str_len)
{
	str_len = read(sock, message, BUF_SIZE - 1);
	recv_len += str_len;
}

서버 구현 2 - 계산기

계산기 기능을 가진 서버를 구현해보자. 이번에는 client가 write()하는 길이가 어디인지 확실히 알 수 없다.

숫자는 int로 받아 char로 전환. 연산기호는 char로 받기.

이를 char 배열에 넣고, 언제까지 받아야 할지를 int와 char의 관계를 이용해 write()할 길이를 측정.

c
/* op_client.c */

// 피연산자 입력하기
for (int i = 0; i < opnd_cnt; i++)
{
	printf("operand %d: ", i + 1);
	// char형 배열에 int형 정수를 저장하기 위한 부분
	// int = 4바이트, 따라서 index를 4씩 늘림.
	scanf("%d", (int *)&opmsg[i * OP_SIZE + 1]);
}

// 버퍼에 남아있는 '\n' 삭제를 위해 fgetc() 이용 
fgetc(stdin);

// 연산자 입력
fputs("Operator : ", stdout);
scanf("%c", &opmsg[opnd_cnt * OP_SIZE + 1]);

// client -> server로 전송
write(sock, opmsg, opnd_cnt * OP_SIZE + 2);
----------------------------------------------------------
/* op_server.c */

opnd_cnt = 0;
clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_size);
// 피연산자 갯수 확인
read(clnt_sock, &opnd_cnt, 1);

recv_len = 0;
// 피연산자 갯수 -> char 단위로 변환
while (recv_len < (opnd_cnt * OP_SIZE + 1))
{
	// 배열에 담고, 길이를 반환
	recv_cnt = read(clnt_sock, &opinfo[recv_len], BUF_SIZE - 1);
	recv_len += recv_cnt;
}

// 계산 후 전송
result = calculate(opnd_cnt, (int *)opinfo, opinfo[recv_len - 1]);
write(clnt_sock, (char *)&result, sizeof(result));
close(clnt_sock);
----------------------------------------------------------
/* op_client.c */

read(sock, &result, RLT_SIZE);
printf("operation result: %d \n", result);

TCP 이론 심화

TCP 소켓에 존재하는 입출력 버퍼

TCP 소켓의 데이터 송수신에는 경계가 없다.

  • 주고 받는 데이터가 40바이트라고 가정하자.
  • 서버가 write()를 1번 해서 40바이트를 한번에 보낸다고 보자.
  • 이때 클라이언트는 read()를 4번 해서 한번에 10바이트씩 받을 수 있다.

1회차에 클라이언트가 10바이트씩 받는다면, 남은 30은 어디에 있는가?

  • 바로 입력/출력 버퍼에 있다.
  • write()가 작동하면 데이터는 write() → 출력버퍼
  • 출력버퍼 → 입력버퍼 : 적절하게 데이터를 잘라서 이동
  • read()가 작동하면 데이터는 입력버퍼 → read()

입출력 버퍼의 특성

  1. 입출력 버퍼는 TCP 소켓 각각에 별도로 존재.
  2. 입출력 버퍼는 소켓 생성 시 자동으로 생성
  3. 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송이 이뤄짐.
  4. 소켓을 닫으면 입력버퍼에 남아있던 데이터는 소멸.

입출력 버퍼와 write()

  1. TCP 내부에는 슬라이딩 윈도우라는 프로토콜이 존재
  2. 이를 통해 버퍼보다 큰 데이터를 보내도 용량을 알아서 조절.

TCP 내부 동작원리

크게 3가지로 작동을 나눌 수 있다. 역시 네트워크 이론 파트에서 설명한 부분.

1. 상대 소켓과의 연결

  • 이 과정에서 일어나는 것이 3-way handshake
  • TCP 헤더 속 “코드비트” 속 6가지 값 중 SYN, ACK를 조정.
  • 이를 통해 데이터의 손실을 막음.

2. 상대 소켓과의 데이터 송수신

  • SEQ와 ACK를 반복해서 보내면서 얼마나, 어떻게 데이터가 전송되었는지 서로 알려줌.
  • 이때 ACK 번호는 SEQ 번호 * 전송된 바이트 크기 + 1 로 결정한다.
mermaid
sequenceDiagram
    host1->>host2: "SEQ 1200 100byte data"
		host2->>host1: "ACK 1301"
    host1->>host2: "SEQ 1301 100byte data"
		host2->>host1: "ACK 1402"
  • 만약 제대로 전송하지 못했다면, time-out되었을 때 패킷을 재전송함.

3. 상대 소켓과의 연결종료

  • FIN과 ACK를 반복해서 보내면서 종료 요청을 서로 보냄.
mermaid
sequenceDiagram
    host1->>host2: "FIN / SEQ 5000 / ACK -"
		host2->>host1: "ACK / SEQ 7500 / ACK 5001"
    host1->>host2: "FIN / SEQ 7501 / ACK 5001"
		host2->>host1: "ACK / SEQ 5001 / ACK 7502"