서버 구현 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()
입출력 버퍼의 특성
- 입출력 버퍼는 TCP 소켓 각각에 별도로 존재.
- 입출력 버퍼는 소켓 생성 시 자동으로 생성
- 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송이 이뤄짐.
- 소켓을 닫으면 입력버퍼에 남아있던 데이터는 소멸.
입출력 버퍼와 write()
- TCP 내부에는 슬라이딩 윈도우라는 프로토콜이 존재
- 이를 통해 버퍼보다 큰 데이터를 보내도 용량을 알아서 조절.
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"