study/NETWORK PROGRAMMING

04-1. [Linux/Ubuntu] 네트워크 프로그래밍 - TCP 기반 서버/클라이언트

김팥빵_ 2025. 3. 28. 16:20

TCP 프로토콜 스택

  • TCP/IP 프로토콜 스택
    • 인터넷 기반의 데이터 송수신을 목적으로 설계된 스택
    • 7계층으로 세분화 되며, 4계층으로 표현함
      •            [ Application 계층 ]
      •              │                      │
      • [ TCP 계층 ]              [ UDP 계층 ]
      •         └――――┬――――┘
      •                    [ IP 계층 ]
      •                           │
      •                  [ LINK 계층 ]

 

  • TCP/UDP 소켓의 프로토콜 스택 비교
    • TCP 소켓의 스택 Flow   ->   sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
    • UDP 소켓의 스택 Flow   ->   sock = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
    • ip계층도 헤더를 달리 전달하기 위해 달리한다.

 

Link & IP 계층

  • Link 계층의 기능과 역할 (TCP/IP 프로토콜)
    • LAN, WNA, MAN 등과 같은 물리적인 네트워크 표준 관련 프로토콜이 정의된 영역
    • MAC 주소(비정상적인 경로로 LAN을 구성하지 않는 이상 회사마다 고유한 번호를 갖기 때문에 절대 중복x)를 사용해 Frame 단위로 데이터 전송
    • 오류제어, 흐름제어
  • IP 계층의 기능과 역할
    • Internet Protocol
    • "경로 전송"과 관련이 있는 프로토콜   ->  목적지로 데이터를 전송하기 위해 어떤 경로를 거쳐갈 것인가?
    • 오류 발생에 대한 해결 방법이 없음

 

TCP/UDP 계층

  • TCP/UDP 계층의 기능과 역할
    • 실제 데이터의 송수신과 관련 있는 계층 : 전송계층(Transport layer) 이라고도 함
    • TCP
      • 데이터의 전송을 보장하는 프로토콜 = 신뢰성있는 프로토콜
      • UDP에 비해 복잡하다
      • 데이터 전송 중 "확인 과정"(그림 참고)을 거침 -> 신뢰성 보장
      • 데이터가 전송되지 못하면, 일정 시간 이후 재전송 함.
    • UDP
      • 신뢰성을 보장하지 않는 프로토콜

TCP 프로토콜 역할

참고) Wireshark 설치

  • windows에서는 ubuntu에서 명령어로 바로 설치가능
  • 전송 계층, 링크 계층 등 관련해서 디버깅할 때 유용
  • 필터링해서 포트마다 데이터 송수신 패킷들 확인 가능!

 

Application 계층

  • Application 계층
    • 프로그래머에 의해서 완성되는 계층
    • 응용 프로그램의 프로토콜을 구성하는 계층
    • 소켓을 기반으로 프로토콜의 설계 및 구현
    • 소켓을 생성하면 Link, IP, TCP/UDP 계층에 대한 내용은 감춰진다.
    • 응용 프로그래머는 Application 계층의 완성도에 집중한다.

 

TCP 헤더

TCP 헤더

  • Source port (송신측 포트번호)
    • (설정하지 않을 경우), 랜덤 번호로 자동 할당됨
    • ex. client의 포트번호 (명시하지 않아도 자동할당 되기 때문에 코드에 넣지 않아도 무방)
  • Destination port (수신측 포트번호)
    • 명확하게 입력함.
    • ex. 9190 (server 포트 번호)
  • Sequence number*
    • 전송하는 데이터의 순서를 표시
    • 수신측은 데이터의 순서를 파악하고 재조립함
  • Acknowledgement number* (if ACK set)
    • sequence number의 응답으로 보내는 아이 
    • 데이터를 받은 수신자가 다음 시퀀스 번호를 할당해서 전송( = 응답 메세지)
  • Flags (NS~FIN)
    • SYN: 연결 시작을 위해 사용
    • FIN: 상대방과의 연결 종료를 요청
    • PSH: 송수신 데이터가 버퍼에 다 찰 때까지 기다리지 않고 즉시 전송(Push)

TCP Header

 

TCP 서버의 함수호출 순서

  • TCP 서버의 기본적인 함수호출 순서
    • Server
      • socket()          : 소켓 생성
      •    
      • bind()              : 주소 할당 ->  bind() 함수까지 호출이 되면 주소가 할당된 소켓을 얻음
      •     
      • listen()            : 연결 대기 ->  listen() 함수의 호출을 통해서 연결요청이 가능한 상태가 됨
      •     
      • accept()          : 연결 허용
      •     
      • read()/write()   : 데이터 송수신
      •     
      • close()             : 연결 종료

 

  • 연결 요청 대기 상태로의 진입
    • listen() 함수
#include <sys/socket.h>

int listen(int sock, int backlog);
-> 성공 시 0, 실패 시 -1 반환
  • sock
    • 클라이언트의 연결 요청을 처리하기 위해 "대기 상태에 두는 소켓 디스크립터"
    • 서버 소켓(리스닝 소켓)
  • backlog
    • 연결 요청 대기 큐(queue)의 "크기"
    • 큐의 크기가 5로 설정하면, 클라이언트의 연결 요청을 5개까지 대기시킬 수 있음

※ 참고!)

  • 연결 요청도 일종의 데이터 전송이다!
  • 연결 요청을 받아들이기 위해 하나의 소켓이 필요함
  • 이 소켓을 서버 소켓 또는 리스닝 소켓이라고 함( 그래서 서버 에 socket()으로 받아온 fd를 리스닝 소켓으로 쓰고, 그 뒤에 accept으로 받아온 fd(파일 디스크립터)를 따로 받아와서 client의 연결용 소켓으로 쓰는 거.
  • listen() 함수 호출로 소켓ㅇ르 리스닝 소켓이 되게 함.

 

  • socket.h 파일 확인
    • socket.h 파일 찾기(Ubuntu 기준)
$ find /usr/include -name socket.h
/usr/include/linux/socket.h
/usr/include/asm-generic/socket.h
/usr/include/x86_64-linux-gnu/bits/socket.h
/usr/include/x86_64-linux-gnu/asm/socket.h
/usr/include/x86_64-linux-gnu/sys/socket.h
  •  listen()의 backlog값 확인
    • /usr/include/x86_64-linux-gnu/bits/socket.h에 정의되어 있음
    • " #define SOMAXCONN = 4096 " (리눅스 시스템에 따라 다름)
      • /sbin/sysctl –a | grep somaxconn 명령어로 확인 가능
$ sudo /sbin/sysctl -a | grep somaxconn
[sudo] password for	xxx:
net.core.somaxconn = 4096

 

PF_INET vs. AF_INET 비교

  • PF(Protocol Family) : 프로토콜 체계 중 하나
  • AF(Address Family) : 주소 체계 중 하나
  • 서로 다른 의미지만 같은 상수값을 가지기 때문에 같이 써도 무방하다.

 

서버 : 클라이언트의 연결 요청 수락(Accept)

  • accept() 함수
    • 연결 요청 정보를 참조하여 / 클라이언트와 통신을 위한 별도의 소켓을 생성함
    • 생성된 소켓을 이용하여 데이터 송수신이 진행
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
-> 성공 시 소켓 디스크립터, 실패 시 -1 반환
  • IP Header(ip 주소 존재) / TCP Header(포트 번호) / Data
  • addr
    • 연결 요청을 한 클라이언트의 주소 정보를 담을 변수의 주소
  • addrlen
    • 두 번째 매개변수 addr의 크기(바이트 단위)

accept() 함수 진행과정

  • accept() 함수는 연결 요청 정보를 참조하여 client와 통신을 위한 별도의 소켓을 생성
  • 이렇게 생성된 소켓을 이용해 데이터 송수신이 진행됨(read / write)

 

TCP 기반 서버, 클라이언트의 함수 호출 관계

서버의 listen() 함수 호출 이후에 클라이언트의 connect()함수 호출이 유효함

  • connect()
    • 클라이언트의 경우 소켓을 생성한 다음, 서버와 연결을 위해 connect() 함수 호출
    • connect()함수를 호출할 때, serv_addr 주소값 변수를 통해 서버의 주소 정보 전달
  • 서버의 listen()함수 호출 이후에 / client의 connect()함수 호출이 유효함

 

Iterative 서버의 구현

  • Iterative 서버 (반복 서버)
    • 여러 클라이언트의 연결 요청 수락을 위해 어떤 방식으로 서버를 구현해야 될 것인가?
    • 서버가 반복적으로 accept() 함수 호출
      • 계속된 클라이언트의 연결 요청을 수락할 수 있음
    • 단, 최대 단점은 / 동시에 둘 이상의 클라이언트에게 서비스를 제공할 수 없다는 거.
    • 기존 클라이언트 소켓을 close()하고 다른 클라이언트를 accept() 하는 형태가 된다.
      • 한 순간에 하나의 클라이언트에게만 서비스 제공

 

  • 다음은 참고하여 공부하고 있는 책의 제공된 소스코드를 보며 적은 것임.
  • *서버 (echo_server.c)
    • server에서 while문을 빠져나오는 조건: strlen에 0이 저장되면 빠져나온다. -> 0이되는 조건은 상대방이 close()함수를 호출하면 "0" -> 소켓을 닫았을 때
    • read()함수일 때는 상대방이 연결을 끊었을 때 0을 리턴(상대방이 close를 호출한 경우)
    • server의 serv_sock은 accept용도 / write(clnt_sock~) : clnt_sock는 데이터 통신용도
    • 결국엔 한명하고만 통신하고 백로그의 크기를 고려해 이쪽의 통신이 끊기면 다른 클라이언트에 연결해서 통신을 한다.

 

  • *클라이언트 (echo_client.c)
    • 화면에서 입력받은것에서 write(sock, message, strlen(message));를 이용해 서버로 전송
    • 그리곤 다시 read함수를 호출한다. ->server에선 while문으로 그대로 되돌려줌. -> client가 바로 read()함수로 받음
    • 그러니까 << client (write()) -"hello! world!"-> (read()) server (write()) - "hello! world!" -> (read()) client >> 순으로 그대로 되돌려주는 식
    • 되돌려주는 걸 echo라고 한다.
    • read()함수는 문자 그대로를 가져온다. -> 따라서 read()로 받아온 후에 message[str_len] = 0;으로 문자열 끝에 쓰레기값 출력되는 걸 막는다.

 

 Echo client의 문제점

  • 문제점
    1. TCP
      • 한 번의 read() 함수 호출로 앞서 전송된 문자열 전체를 읽을 수 있다고 가정
      • 서버가 전송한 문자열의 일부분만 읽혀질 수도 있음
    2. 전송할 데이터의 크기가 큰 경우
      • OS(kernel)은 내부적으로 여러 조각으로 나누어서 클라이언트에 전송(MTU)
  • 문제점 확인하기
  • Echo server의 코드 
while((str_len=read(clnt_sock,	message, BUF_SIZE))!=0)
	write(clnt_sock, message, str_len);
  • 서버는 데이터의 경계를 구분하지 않고, 수신한 데이터를 그대로 전송
    • write() 함수 호출 횟수와 무관하게 수신한 데이터를 전송하면 됨
    • 두 번의 write() 함수 호출을 통해 데이터를 전송하거나, 세 번의 write()함수 호출을 통해 데이터를 전송하는 것은 문제가 되지 않음.
  • Echo client의 코드
write(sock,	message,	strlen(message));
str_len=read(sock,	message,	BUF_SIZE-1);
  • read() 함수 호출을 통해 자신이 전송한 문자열을 한 번에 수신하기를 원함
    • 데이터의 경계를 구분해야 됨 -> 이런 데이터 송수신 방식은 문제가 됨.
    • TCP의 read(), write() 함수 호출은 데이터의 경계를 구분하지 않기 때문

 

  • 해결책(다시보기)
    • recv_len : 실제 수신된 바이트 수를 누적, read함수의 리턴값을 받아온다. ->  client 왈, "내가 보낸 것만큼 체크해라!"라는 뜻
    • write 함수로 보낸 데이터의 길이를 리턴한 str_len 변수만큼 읽어들여야 한다.
    • 전송한 바이트 수만큼 데이터를 수신할 때까지 반복해야 함.
    • 데이터가 엄청 클 때, 이 방식을 통해 체크 필요

 

TCP의 동작 원리

  • TCP 소켓에 존재하는 입출력 버퍼
    • 입출력 버퍼는 TCP 소켓 각각에 대해 별도로 존재한다.
    • 입출력 버퍼는 소켓 생성시 자동으로 생성된다.
    • 소켓을 닫아도 출력 버퍼에 남아있는 데이터는 계속해서 전송이 이뤄진다.
    • 소켓을 닫으면 입력 버퍼에 남아있는 데이터는 소멸되어버린다.

TCP 소켓 입출력 예시

  • TCP의 동작 원리 #1: 연결 설정 단계
    • 연결 설정 단계 : Three-way handshaking
    • (SYN -> SYN+ACK -> ACK)

SYN - ACK 동작 과정

  • TCP의 동작 원리 #2: 데이터 송수신

연결 설정 단계와 달리 "전송된 바이트 수"를 함께 계산한다.

 

  • TCP의 동작 원리 #3: 상대 소켓과의 연결 종료
    • Four-way handshaking
    • FIN(host  A) - ACK(host  B) - FIN(host B) - ACK(host A)
    • Q) Four-way handshaking 과정을 거쳐서 연결을 종료하는 이유
      • A) 일방적 종료로 인한 데이터의 손실을 막기 위함

연결 종료 시나리오
연결 종료 요청 (수정 : 마지막 FIN -> ACK로 수정)