TCP/IP 소켓 프로그래밍 - 네트워크 프로그래밍의 시작 1

mingsso·2023년 3월 19일
0

CS

목록 보기
27/30
post-thumbnail
post-custom-banner

네트워크 프로그래밍과 소켓의 이해

네트워크 프로그래밍

서로 다른 두 컴퓨터가 데이터를 주고받을 수 있도록 하는 것

소켓

  • 네트워크로 연결되어 있는 컴퓨터들 사이에서 데이터를 주고받을 때 사용하는 소프트웨어 장치 = 데이터를 주고 받는 창구 역할!
  • 네트워크 프로그래밍 = 소켓 프로그래밍
  • 역할에 따라 서버 소켓, 클라이언트 소켓으로 구분됨 → 주로 서버는 리눅스 운영체제 기반, 클라이언트는 윈도우 운영체제 기반으로 개발함


소켓 구현 시 사용되는 함수

  • socket() = 소켓 생성, 성공 시 파일 디스크립터 반환(윈도우는 소켓 핸들 반환)
  • bind() = 소켓에 IP 주소와 포트 번호 할당 (서버 소켓)
  • listen() = 소켓을 연결 요청을 받을 수 있는 상태로 변경
  • connect() = 연결을 요청함 (클라이언트 소켓)
  • accept() = 연결 요청을 수락 (서버 소켓)



리눅스 기반 파일 조작하기

파일 디스크립터

소켓과 운영체제가 만든 파일의 지칭을 편하게 하기 위해 부여된 숫자(별칭)
리눅스에서는 '파일 디스크립터', 윈도우에서는 '파일 핸들'이라고 지칭함 → 리눅스는 파일과 소켓을 동일하게 취급하고, 윈도우는 파일 핸들과 소켓 핸들을 구분한다!

파일과 소켓은 생성 이후에 파일 디스크립터가 할당되지만, 생성하지 않아도 프로그램이 실행되면 자동으로 할당되는 파일 디스크립터가 있음

  • 0=표준입력, 1=표준출력, 2=표준에러
  • 자동으로 할당되는 위의 3가지 파일 디스크립터 때문에, 사용자가 파일/소켓 생성하면 파일 디스크립터는 3부터 할당됨


파일 관련 함수들

  • open() = 파일 열기, 성공 시 파일 디스크립터 반환
  • close() = 파일 닫기 (파일은 사용 후 반드시 닫아줘야 함) → 리눅스에서는 소켓을 닫을 때도 사용되며, 윈도우에서 소켓을 닫는 함수는 closesocket()
  • write() = 파일에 데이터를 전송
  • read() = 파일이 데이터를 수신


윈도우에서만 사용되는 소켓 함수

  • WSAStartup() = 윈도우 소켓의 버전을 알리고, 해당 버전을 지원하는 라이브러리를 초기화함
  • WSACleanup() = 프로그램 종료 시, 초기화된 라이브러리를 해제함
  • send() = 소켓에 데이터를 전송 (= 리눅스의 write())
  • recv() = 소켓이 데이터를 수신 (=리눅스의 read())



소켓의 프로토콜과 그에 따른 데이터 전송 특성

프로토콜

컴퓨터 상호 간의 대화에 필요한 통신 규약 (서로 데이터를 주고 받기 위해 정의해놓은 약속)

int socket(int domain, int type, int protocol)
// SOCKET socket(int af, int type, int protocol) -> 윈도우의 경우

앞 부분에 언급한 소켓 생성 함수를 자세히 살펴보면,
① domain - 프로토콜 체계(종류)
② type - 소켓의 타입
③ protocol - 소켓이 사용할 프로토콜 정보


매개변수 각각을 자세히 살펴보자
① domain

  • PF_INET = IPv4 인터넷 프로토콜 체계
  • PF_INET6 = IPv6 인터넷 프로토콜 체계
  • PF_LOCAL = 로컬 통신을 위한 UNIX 프로토콜 체계
  • PF_PACKET = Low Level 소켓을 위한 프로토콜 체계
  • PF_IPX = IPX 노벨 프로토콜 체계


② type
소켓의 타입은 소켓의 데이터 전송 방식을 가리킴

타입1. 연결 지향형 소켓(SOCK_STREAM)

  • 중간에 데이터가 소멸되지 않고 목적지로 전송됨
  • 전송 순서대로 데이터가 수신됨
  • 전송된 데이터는 버퍼(바이트 배열)에 저장되므로, write 함수의 호출 횟수와 read 함수의 호출 횟수는 큰 관련이 없음(여러 번에 걸쳐 전송한 데이터를 한번에 읽어들이는 등)
  • 소켓 간의 연결은 반드시 1대 1이어야 함

타입2. 비 연결 지향형 소켓(SOCK_DGRAM)

  • 전송된 순서를 신경쓰지 않고 가장 빠른 전송을 지향함
  • 전송된 데이터는 손실과 파손의 우려가 있음
  • write 함수의 호출 횟수와 read 함수의 호출 횟수가 같아야 함
  • 한 번에 전송할 수 있는 데이터의 크기가 제한됨


③ protocol
하나의 프로토콜 체계 안에 전송방식이 동일한 프로토콜이 둘 이상 존재하는 경우를 위해 필요한 매개변수
그냥 0을 넘겨줘도 됨!

int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP(or 0))   // TCP소켓 생성
int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP(or 0))   // UDP소켓 생성



소켓에 할당되는 IP주소와 포트 번호

IP 주소

인터넷 상에서 데이터를 송수신할 목적으로 컴퓨터에게 부여하는 값
(우편을 보낼 때 집 주소가 필요하듯 인터넷에도 주소가 필요함! 123.123.123.123)

  • inet_addr() = "211.214.107.99"와 같은 IP 주소를 네트워크 바이트 순서에 따르는 32비트 정수로 변환해줌
  • inet_aton() = inet_addr 함수와 기능적으로 동일
  • inet_ntoa() = 32비트 정수형 IP주소를 우리가 쉽게 인식할 수 있는 문자열로 바꿔줌(위의 두 함수의 반대 역할)

포트번호

컴퓨터에게 부여하는 값이 아닌, 프로그램 상에서 생성되는 소켓을 구분하기 위해 소켓에 부여되는 번호
포트번호는 16비트로 표현되며(0~65535), 0번부터 1023번까지는 'Well-known PORT'로 특정 프로그램에 할당하기로 예약되어 있기 때문에 이 범위 값을 제외한 다른 값을 할당해야 함

라우터/스위치

네트워크를 구성하기 위해, 외부로부터 수신된 데이터를 호스트에게 전달하고, 호스트가 전달하는 데이터를 외부로 송신해주는 물리적 장치
라우터보다 기능적으로 더 작은 것을 스위치라 하지만, 사실상 둘은 같은 의미로 사용됨

int bind(int sockfd, struct sockaddr *myaddr, sickle_t addrlen);

bind 함수를 자세히 살펴보면,
① sockfd - 주소정보(IP 주소 및 포트번호)를 할당할 소켓의 파일 디스크립터
② myaddr - 할당하고자 하는 주소정보를 지니는 구조체 변수의 주소값
③ addrlen - 두 번째 인자로 전달된 구조체 변수의 길이정보

②번의 구조체 변수는 다음과 같다

struct sockaddr_in {
   sa_family_t  sin_family;   // 1) 주소체계(IPv4 or IPv6?)
   unit16_t  sin_port;    // 2) 16비트 TCP/UDP PORT 번호
   struct in_addr  sin_addr   // 3) 32비트 IP주소
   char  sin_zero[8];   // 4) 사용되지 않음(크기 일치를 위해 0으로 채우는 역할)
};

네트워크 바이트 순서와 인터넷 주소 변환

CPU의 종류에 따라서 4바이트 정수 1을 메모리 공간에 저장하는 방식이 다름
→ 00000000 00000000 00000000 00000001 순서 그대로 저장하거나, 00000001 00000000 00000000 00000000 처럼 거꾸로 저장하는 CPU도 있음

전자의 상위 바이트의 값을 작은 번지수에 저장하는 방식을 빅 엔디안 방식, 후자의 상위 바이트의 값을 큰 번지수에 저장하는 방식을 리틀 엔디안 방식이라고 함

호스트 바이트 순서(CPU의 데이터 저장방식)가 다른 두 CPU가 데이터를 주고 받으면 문제가 생길 수 있으므로
빅 엔디안 방식으로 통일하기로 약속하였고, 이 약속을 가리켜 '네트워크 바이트 순서'라고 함
네트워크 바이트 순서로 변환하는 함수 = htons(), ntohs(), htonl(), ntohl()



TCP 기반 서버/클라이언트

TCP/IP 프로토콜 스택


1. LINK 계층
물리적인 영역 정의 → LAN, WAN, MAN과 같은 네트워크 표준과 관련된 프로토콜 정의


2. IP 계층
목적지로 데이터를 전송하기 위해 어떤 경로를 거쳐갈 것인지 결정
IP 프로토콜 사용 → 비연결지향적이며, 신뢰할 수 없음(데이터 전송 도중에 경로 상의 문제가 발생하더라도 데이터 손실 문제는 신경쓰지 않음)


3. TCP/UCP 계층(전송 계층)
IP 계층에서 알려준 경로를 바탕으로 데이터의 실제 송수신을 담당함
TCP - 신뢰성 있는 데이터의 전송을 담당하는 프로토콜, IP에 의해 전송된 데이터를 확인하고 재전송을 요청함(신뢰성 없는 IP에 신뢰성 부여)


4. APPLICATION 계층
프로그래머가 실제로 설계 및 구현하는 계층



TCP 서버의 함수 호출 순서

  • socket() = 소켓 생성
  • bind() = 소켓 주소 할당
  • listen() = 연결 요청 대기 상태 → 이 함수가 호출된 후 클라이언트가 연결요청을 할 수 있는 상태가 됨
  • accept() = 연결 허용(성공 시 소켓 생성, 연결요청을 한 클라이언트 소켓과 자동으로 연결됨) → '연결 요청 큐'에서 대기 중인 클라이언트의 연결 요청을 수락함
  • read()/write() = 데이터 송수신
  • close() = 연결 종료



TCP 클라이언트의 함수 호출 순서

  • socket() = 소켓 생성
  • connect() = 연결 요청
  • read()/write() = 데이터 송수신
  • close() = 연결 종료



Iterative 기반의 서버, 클라이언트 구현

에코 서버
클라이언트가 전송하는 문자열 데이터를 그대로 재전송(echo)하는 서버


Iterative 서버의 구현
한 클라이언트의 요청에만 응답하고 종료되어버리는 서버가 아닌, 계속해서 들어오는 클라이언트의 요청을 수락하기 위해서는 반복문을 삽입해서 accept 함수를 반복 호출하면 됨 → 일단은 은행 창구처럼 한 순간에 하나의 클라이언트에만 서비스를 제공할 수 있는 서버 구현

  • socket()
  • bind()
  • listen()
  • { accept()
  • read()/write()
  • close(client) = accept 함수의 호출과정에서 생성된 소켓 종료 } → 반복
  • close(server)



TCP의 이론적인 이야기!

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

write 함수가 호출되는 순간 데이터는 출력버퍼로 이동하고, read 함수가 호출되는 순간 입력버퍼에 저장된 데이터를 읽어들이게 됨

  • 입출력 버퍼는 TCP 소켓 각각에 대해 별도로 존재함
  • 입출력 버퍼는 소켓생성시 자동으로 생성됨
  • 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송이 이루어짐
  • 소켓을 닫으면 입력버퍼에 남아있는 데이터는 소멸되어버림
  • 입력버퍼의 크기를 초과하는 분량의 데이터 전송은 발생하지 않음!(슬라이딩 윈도우)



TCP의 내부 동작원리1: 상대 소켓과의 연결

TCP 소켓은 연결설정 과정에서 총 3번의 대화를 주고 받고, 이를 Three-way Handshaking이라고 함

① SEQ 1000 → "내가 지금 보내는 이 패킷에 1000이라는 번호를 부여하니, 잘 받았다면 다음에는 1001번 패킷을 전달하라고 내게 말해줘"
처음 연결요청에 사용되는 메시지이기 때문에 SYN(Synchronization=동기화 메시지)이라 함

② SEQ 2000 → "내가 지금 보내는 이 패킷에 2000이라는 번호를 부여하니, 잘 받았다면 다음에는 2001번 패킷을 전달하라고 내게 말해줘" (클라이언트가 잘 받는지 확인)
ACK 1001 → "아까 전송한 SEQ가 1000인 패킷 잘 받았고, 다음엔 SEQ가 1001인 패킷을 전송해줘"(응답 메시지)

③ ACK 2001 → "좀 전에 전송한 SEQ가 2000인 패킷은 잘 받았으니, 다음 번에는 SEQ가 2001인 패킷을 전송해줘"

이렇듯 패킷에 번호를 부여하고 확인하는 절차를 거치기 때문에 손실된 데이터를 확인하고 재전송을 할 수 있음!



TCP의 내부 동작원리 2: 상대 소켓과의 데이터 송수신

Three-way Handshaking을 통해 데이터 송수신 준비는 끝났고, 본격적으로 데이터 송수신을 진행함

호스트 A가 호스트 B에게 200바이트를 두 번에 나눠서 전송하는 과정
① 호스트 A가 100바이트의 데이터를 하나의 패킷에 실어 전송하고, 패킷의 SEQ를 1200으로 부여함

② 호스트 B는 패킷이 제대로 수신되었음을 알리기 위해, ACK 1301 메시지를 담은 패킷을 호스트 A에 전송함
ACK 번호를 전송된 바이트 크기만큼 추가로 증가시켰기 때문에 1201이 아니라 1301임 → 이렇게 하지 않으면, 패킷에 담긴 100바이트가 전부 전송되었는지 확인 불가
ACK 번호 = SEQ 번호 + 전송된 바이트 크기 + 1


오른쪽 그림처럼 중간에 패킷이 소멸되는 경우,

③ SEQ 패킷이 제대로 전달되지 못했을 경우, 호스트 A는 일정시간이 지나도 ACK 메시지를 받지 못하기 때문에 재전송을 진행함
데이터의 손실에 대한 재전송을 위해 TCP 소켓은 ACK 응답을 요구하는 패킷 전송 시에 타이머를 동작시킴 → 타이머가 Time-out! 되었을 때 패킷을 재전송함



TCP의 내부 동작원리 3: 상대 소켓과의 연결종료

연결을 뚝 끊어버리면, 상대방이 전송할 데이터가 남아있을 때 문제가 되기 때문에 상호간에 연결종료 합의과정을 거치게 됨 → Four-way Handshaking

① 클라이언트 A가 종료 메시지 FIN을 서버 B에게 전달함

② 서버 B는 해당 메시지의 수신을 클라이언트 A에게 알림

③ 서버 B가 종료 메시지 FIN을 클라이언트 A에게 전달함, ACK 5001은 앞서 전송한 ACK 메시지가 수신된 이후 메시지 수신이 없었기 때문에 재전송됨

④ 클라이언트 A는 해당 메시지의 수신을 서버 B에게 알리며 종료의 과정을 마치게 됨



UDP 기반 서버/클라이언트

UDP 기반 서버/클라이언트의 구현

  • UDP 서버/클라이언트는 TCP와 같이 연결된 상태로 데이터를 송수신하지 않음 → 연결 설정의 과정이 필요 없으며, listen 함수와 accept 함수의 호출도 불필요함
  • 소켓과 소켓의 관계가 일대일이었던 TCP와 달리, UDP는 서버든 클라이언트든 하나의 소켓만 있으면 됨 (하나의 UDP 소켓으로 둘 이상의 호스트와 통신이 가능함)

UDP 소켓은 연결 상태를 유지하지 않으므로(우체통의 역할만 하므로), 데이터를 전송할 때마다 반드시 목적지의 주소정보를 별도로 추가해야 함

  • sento() = 주소정소를 써넣으면서 데이터를 전송하는 함수
  • recvfrom() = 발신자 정보를 얻으면서 데이터를 받는 함수

UDP 프로그램에서는 sendto 함수호출 이전에 해당 소켓에 주소정보가 할당되어 있어야 하므로, 이전에 bind 함수를 호출해서 주소 정보를 할당함

만약 sendto 함수호출 시까지 주소정보가 할당되지 않았다면, sendto 함수가 처음 호출되는 시점에 해당 소켓에 IP와 포트번호가 자동으로 할당됨
→ 한번 주소가 할당되면 프로그램이 종료될 때까지 주소정보가 그대로 유지되기 때문에 다른 UDP 소켓과도 데이터를 주고받을 수 있음



connected UDP 소켓, unconnected UDP 소켓

sendto 함수호출을 통한 데이터의 전송 과정은 크게 세 단계로 나눌 수 있음

  • 1단계 - UDP 소켓에 목적지의 IP와 포트번호 등록
  • 2단계 - 데이터 전송
  • 3단계 - UDP 소켓에 등록된 목적지 정보 삭제


sendto 함수가 호출될 때마다 위 과정이 반복되며, 목적지의 주소정보가 계속해서 변경되기 때문에 하나의 UDP 소켓을 이용해 다양한 목적지로 데이터 전송이 가능함

목적지 정보가 등록되어 있지 않은 소켓을 unconnected 소켓, 목적지 정보가 등록되어 있는 소켓을 connected 소켓이라고 함

하나의 호스트와 오랜 시간 데이터를 송수신해야 한다면, UDP 소켓을 connected 소켓으로 만드는 것이 효율적임
→ 송수신 대상이 정해지면 sendto, recvfrom 함수가 아닌 write, read 함수의 호출로도 데이터를 송수신할 수 있음



TCP 기반의 Half-close

일방적인 연결 종료의 문제점

리눅스의 close 함수 호출과 윈도우의 closesocket 함수 호출은 완전 종료를 의미함 → 데이터 송수신 모두 불가능하게 됨

만약 호스트 A가 close 함수를 호출한 경우, 호스트 A는 호스트 B가 전송한 호스트 A가 반드시 수신해야 할 데이터를 수신하지 못하며, 데이터는 소멸돼버림
→ 이러한 문제의 해결을 위해 데이터의 송수신에 사용되는 스트림의 일부만 종료(Half-close=전송 가능+수신 불가능 or 전송 불가능+수신 가능) 할 수 있음



소켓과 스트림

소켓을 통해서 두 호스트가 연결되면 스트림이 형성됨
소켓의 스트림은 물의 흐름처럼 한쪽 방향으로만 데이터의 이동이 가능하기 때문에 양방향 통신을 위해서는 두 개의 스트림이 필요함

Half-close는 두 스트림 중 하나의 스트림만 끊는 것!

// Half-close에 사용되는 함수
int shutdown(int sock, int howto)

두번째 매개변수에 전달되는 인자에 따라 종료의 방법이 결정됨

  • SHUT_RD = 입력 스트림 종료 (데이터 수신 불가, 입력 버퍼 지워짐, 입력 관련 함수 호출 불가)
  • SHUT_WR = 출력 스트림 종료 (데이터 전송 불가, 출력 버퍼에 아직 전송되지 못한 데이터는 목적지로 전송됨)
  • SHUT_RDWR = 입출력 스트림 종료 (SHUT_RD 호출 + SHUT_WR 호출)
profile
🐥👩‍💻💰
post-custom-banner

0개의 댓글