[CS] 컴퓨터 네트워크 TCP(실습)

재오·2023년 4월 9일
4

CS

목록 보기
8/35
post-thumbnail

소켓

네트워크 프로그래밍을 소켓 프로그래밍이라고도 한다. 소켓은 흔히 전화기에 비유를 많이 하는데 소켓 함수를 호출하면 소켓이 생성된다. 클라이언트의 소켓과 서버의 소켓의 생성 방법은 다르다. 전화기에 전화번호가 부여되듯이 소켓에도 주소정보가 할당된다. 소켓의 주소정보는 bind 함수를 통해 IPPORT번호를 할당한다.

Server Socket

클라이언트 소켓과 서버의 소켓이 다르다고 했는데 서버의 소켓은 listen 함수를 사용해서 연결 요청이 가능한 상태로 만들어줘야 한다. 클라이언트가 연결 요청을 했을 때 그 연결 요청을 서버에서 받아주는 함수는 accept 함수이다. 이 함수의 return 값은 새로운 socket인데 처음 생성한 socket은 처음에 받는 용도로만 쓰이고 직접 클라이언트와 소통하는 소켓은 이 리턴 된 값이다. 이렇게 서버가 먼저 실행이 되어야 클라이언트와 연결이 될 수 있다.

gcc hello_server.c -o hserver // hello_server.c라는 파일을 컴파일하여 실행파일로 만들어 준다.
./hserver 9190 // 서버에서 실행할 port Number 9190을 적어준다.

Client Socket

클라이언트는 socket을 만들고 bind()함수까지 이용하는 것은 똑같다. 보통 클라이언트는 자동으로 현재 쓰고 있는 IP주소를 할당해준다. 이제는 서버로 연결 요청을 하는데 이때 connect 함수를 이용한다. -1를 리턴하게 된다면 오류가 발생한 것이다.

gcc hello_client.c -o hclient // 서버와 같이 실행파일 생성
./hclient 127.0.0.1 9190 // 서버의 IP 주소 + 서버의 port Number

파일 디스크립터

운영체제가 만든 파일(소켓)을 구분하기 위한 일종의 숫자를 뜻한다.

예를 들어 5번 디스크립터로 파일을 가져오고 5번 디스크립터에 파일을 작성한다. 디스크립트 0,1,2는 이미 정해져 있다. 0은 표준입력, 1은 표준출력, 2는 표준에러를 의미한다. open 함수를 호출하면 파일 디스크립터 값이 반환되고 그 디스크립터를 이용하여 파일 입출력이 진행된다. close 함수 안에는 파일 디스크립터 값을 인자로 넣어 파일을 닫아준다.

write 함수 는 인자로 write(디스크립터 값, 데이터 주소 값, 전송할 데이터 바이트 수)가 들어간다. 파일에 저장된 데이터는 read 함수 로 읽는다. 인자로는 read(디스크립터 값, 저장할 주소 값, 수신할 최대 바이트 수)가 들어간다.

프로토콜

소켓을 생성할 때

int socket = socket(사용할 프로토콜 정보, 데이터 전송 방식, 통신에 사용되는 프로토콜 정보);

첫번째 인자로 들어가는 사용할 프로토콜 정보에는 버전4인 PF_INET 이 들어간다. 두번째 인자는 TCP 소켓을 만들 때 (SOCK_STREAM)을 써주고, UDP 소켓을 만들 때에는 (SOCK_DGRAM)을 쓴다. 하지만 우리는 SOCK_STREAM을 많이 쓸 것이다.

연결 지향형 소켓(TCP 소켓)의 특징은 데이터의 경계가 존재하지 않는 것소켓과 소켓이 반드시 1대 1의 구조를 지닌다는 점이다.

첫번째, 두번째 인자로 전달된 정보를 통해서 소켓의 프로토콜이 사실상 결정되기 때문에 세번째 인자로 0을 전달해도 된다.

int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

PORT 번호

한 컴퓨터에 네트워크를 사용하는 어플리케이션을 구별하기 위해서 부여하는 번호이다.

구조체 sockaddr_in 활용

bind()함수를 살펴보면 첫번째 인자로는 socket의 디스크립터가 들어가게 되고 두번째 인자로는 그 디스크립터에 들어갈 PORT와 IP 정보가 들어간다. 그 PORT와 IP의 정보가 담겨있는 구조체가 sockaddr_in이다.

struct sockaddr_in
{
	sa_family_t   sin_family; // (주소체계 정보 저장)
	unit16_t   sin_port; // (PORT 번호 저장)
	struct in_addr   sin_addr; // (32비트 IP주소)
}

struct in_addr
{
	in_addr_t   s_addr;
}	

sin_family에는 AF_INET이 들어가게 된다. sin_port에는 16비트 PORT번호가 저장되고 sin_addr에는 32비트 IP주소 정보가 저장된다. sin_addr은 구조체이기 때문에 그 안에는 또 s_addr이 있고 거기 안에 IP주소를 넣는 것이다.

서버에서는 IP주소와 PORT넘버가 담긴 것을 클라이언트에 넘겨준다. 그 두개의 정보는 구조체 안에 담기게 되고 그 구조체를 매개변수로 하여 connect함수 안에 넣어주면 된다.

바이트 순서와 변환

빅 엔디안

상위 바이트를 작은 번지수부터 적어준다. 예를 들어 정수 0x12345678을 0x20번지에는 12를, 0x21번지에는 34를 저장하는 방식이다.

리틀 엔디안

하위 바이트를 작은 번지수부터 적어준다, 예를 들어 정수 0x12345678을 0x20번지에는 78을, 0x21번지에는 56을 저장하는 방식이다.

인터넷 소켓 프로그래밍에서는 정수를 주고받을 때 빅 엔디안 방식을 이용하기로 통일 하였다. htons() 함수안에 넣어주기만 하면 된다.

바이트 순서의 정수로 변환

inet_addr() 을 사용하게 된다면 원래 IP주소가 문자열인데 이를 32비트 정수형으로 알아서 변환하게 해준다.

#client
serv_addr.sin_addr.s_addr = inet_addr(argv[1]); // 1번째인 IP주소를 32비트 정수형으로 반환
serv_addr.sin_port = htons(atoi(argv[2])); // 2번째 PORT넘버를 htons() 이용하여 빅 엔디안 방식으로 변환

인터넷 주소의 초기화

클라이언트와 서버 둘다 초기화를 할 수 있다. 서버는 bind()를 이용하여 IP와 PORT넘버를 지정해서 구조체에 넣어서 여기로 데이터 다 보내! 이런 의미이고 클라이언트는 굳이 안정하더라도 알아서 자기 IP와 남는 PORT넘버가 할당이 된다.

#server
addr.sin_addr.s_addr = htonl(INADDR_ANY);

서버는 주소값에 특정 IP주소를 적지 않고 INADDR_ANY 를 적는다. 보통 서버 프로그램의 구현에서 자주 사용하는데, 이는 특정 IP말고도 여러 IP에 들어온 데이터들도 달라고 할 수 있다.

TCP 서버의 함수호출 순서

  1. socket() = 소켓을 생성한다.
  2. bind() = 소켓의 주소(IP, PORT)를 할당한다.
  3. listen() = 연결 요청 대기 상태이다.
  4. accept() = 연결을 허용한다.
  5. read() / write() = 데이터를 송신, 수신 한다.
  6. close() = 연결을 종료한다.

연결 요청 대기 상태로의 진입(Listen)

소켓은 일반 소켓이 있고 서버 소켓이 존재한다. 서버 소캣은 SYN이 오면 SYN + ACK을 보내는 역할을 한다. 일반 소캣은 그 역할을 하지 못한다. 일반 소켓을 서버 소켓으로 만들어 주는 것을 이 listen 함수가 한다. listen 함수 가 호출된 이후에 SYN+ACK이 보내지게 된다.

listen 함수 는 매개변수로 두개가 필요하다. 첫번째로는 소켓번호(파일 디스크립터 값)이고 두번째는 연결요청 대기 큐의 크기가 들어간다. SYN+ACK을 보내고 클라이언트로부터 ACK이 들어올 때까지의 대기시간 동안 요청을 했던 클라이언트가 대기 큐 안에 들어가있는다. 실제 저 대기 시간은 무척 짧다.

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

마지막 ACK이 오게된다면 연결요청 대기 큐에 있던 클라이언트를 accept 함수 가 끄집어 와서 새로운 소켓을 만들게 된다. accept 함수의 return 값은 새로운 소켓이다. 그 새로운 소켓을 통해서 read()write() 한다. 서버 소켓은 연결 요청을 받아주는 역할만 하고 데이터 송수신은 새로운 소켓이 한다. accep 함수는 첫번째 인자로 소켓번호(파일 디스크립터 값)이 들어가고 두번째 인자는 클라이언트의 주소를 담을 구조체가 들어간다. 클라이언트와 서버는 네트워크 넘버로 데이터를 넘겨주기 때문에 반드시 host Number 로 바꿔줘서 읽어야 한다.

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

  1. socket() = 소켓을 생성한다.
  2. connect() = 연결 요청을 한다.(SYN을 보내는 역할을 한다.)
  3. read() / write() = 데이터를 송신, 수신 한다.
  4. close() = 연결을 종료한다.

연결요청을 하는 함수(Connect)

connect 함수 는 첫번째 인자로 클라이언트 소켓번호(파일 디스크립터 값)이, 두번째 인자로는 서버의 주소가 담긴 구조체가 담긴다. 클라이언트는 connect함수가 IP주소와 남는 PORT 넘버를 자동적으로 할당해준다.

⭐️ 중요한 점은 서버의 listen() 함수 호출 이후에야 클라이언트의 connect() 함수호출이 유효하다는 점이다. 이유는 서버가 먼저 listen 함수를 호출하여 대기모드로 전환을 하고 그 다음에 클라이언트가 connect함수를 호출하여 연결을 요청해야 하기 때문이다.

Iterative 서버의 구현

서버는 계속 살아있고 하나의 클라이언트가 아니라 여러 개의 클라이언트와 연결을 할 수 있게끔 하는 것이 Iterative 서버이다. echo server는 Iterative 서버이다.

에코 클라이언트의 문제점

TCP에는 데이터의 경계가 존재하지 않기 때문에 서버가 전송한 문자열의 일부만 읽혀질 수도 있다.

Flow Control

SYN을 보내고 SYN+ACK을 받고 이러한 방식은 Stop&Wait 방식이다. 이러한 방식은 신뢰성을 보장하지만 너무 비효율적이다. rwnd는 어플리케이션이 가져가는 속도에 따라서 달라진다.

Silly Window Syndrome

송신 측에서 발생하는 신드롬

보내는 데이터에 비해서 해더의 크기가 지나치게 큰 경우를 의미한다. 기본적으로 TCP, IP헤더가 꼭 필요한데 이것의 헤더는 기본 40바이트를 가지고 있다.(각 20 바이트)

→ Nagle 알고리즘

위와 같은 문제를 해결하기 위해 등장한 것이 Nagle 알고리즘 이다. 우선 첫 데이터는 그냥 작더라도 보낸다. 하지만 해당 데이터에 대한 ACK이 올 때까지의 시간동안 다음 나갈 데이터들을 다 모아둔다. 하지만 패켓의 Maximum 사이즈까지 꽉 차게 된다면 그냥 바로 보낸다. 대기 시간 안에도 꽉 안찬다면 그냥 모아둔 데이터들을 보낸다.

수신 측에서 발생하는 신드롬

꽉 찼는데 어플리케이션에서 데이터를 가져가는 속도가 너무 느리게 되었을 때 rwnd 가 1이 되어서 데이터 크기가 1인 것을 보낼 수밖에 없을 때가 수신 측에서 발생하는 신드롬이다.

→ Clark 해결 방법

패켓 전체가 다 비게 되거나 반이 비었을 때에만 응답을 보내주는 방법이다. 그 외에는 무조건 윈도우의 크기를 0으로 보낸다. 강제적인 STOP이다.

→ 확인응답의 지연

이전에 rwnd 를 보냈더라면 그 이후에는 ACK을 보내지 않는다. 이전에 보냈던 것에 대해서만 데이터를 받는다. 얼만큼의 기준이 생긴다면 ACK을 보내는 것이다.

SYN Flooding

5개를 순차적으로 iterative 하는 서버이다. 위는 echo server이다.

클라이언트가 악의적으로 IP주소와 PORT 넘버를 바꿔서 보내주면 문제가 발생한다. 연결 요청 대기 큐 안에는 넣어두고 SYN+ACK을 보내주지만 원래 주소와 다르기 때문에 ACK이 오지 않는다. 그렇게 연결 요청 대기 큐에 소켓이 계속 쌓이게 된다면 제대로 된 소켓을 꺼낼 수 없기 때문에 SYN Flooding이 발생한다.

Normal Operation(ACK을 최소화 하자)

Rule1

데이터를 받아서 ACK을 보내려고 할 때 sending buffer에 보낼 데이터가 있으면 ACK이랑 같이 보낸다.

Rule2

sending buffer에 보낼 데이터가 없으면 우선 50ms 정도 기다려본다. 그래도 보낼 데이터가 없다면 그 때 ACK만 보낸다.

Rule3

데이터가 없는 50ms 정도 기다리는 와중에 또 패켓이 오게 된다면 그때는 더이상 기다리지 않고 ACK을 보낸다. 따라서 2개당 ACK 하나가 나간다.

다중 접속 서버의 구현 방법들

동시에 여러명의 클라이언트가 접속이 가능한, 즉 다중 접속 서버를 이용 가능하게 구현하는 방법이 3가지가 있다.

멀티프로세스 기반 서버

멀티플렉싱 기반 서버

멀티쓰레딩 기반 서버

fork 함수의 호출을 통한 프로세스 생성

fork 함수 가 호출되면, 호출한 프로세스가 복사되어 fork 함수 호출 이후를 각각의 프로세스가 독립적으로 실행하게 된다. 두 코드 다 똑같은 코드를 실행하는데 return 값이 다르다. 부모 프로세스는 자식 프로세스의 ID를 return 하고, 자식 프로세스는 0을 return 하게 된다.

시그널

특정 상황이 되었을 때 운영체제가 프로세스에게 해당 상황이 발생했음을 알리는 일종의 메시지를 뜻한다.

signal(SIGCHLD, mychild) 는 자식 프로세스가 종료되면 mychild 함수를 호출해 달라는 의미를 담고 있다.

프로세스 기반 다중접속 서버 모델

에코 클라이언트1은 부모 프로세스에 연결 요청을 하고 부모 프로세스는 자식 프로세스를 생성하여 에코 클라이언트와 연결을 한다. 에코 클라이언트2, 3…도 마찬가지의 원리이다.

부모 프로세스는 계속 동작한다. 우선 새로운 연결 요청을 accept() 하여서 새로운 소켓을 만들고, 자식 프로세스를 만든다. 이는 모든 정보가 공유 된다. 자식 프로세스는 상대방으로부터 read하고 write하는 역할을 하고 fin이 오면 close 하는 원리이다. 부모는 무한 루프 안에서 상대방 연결 요청을 확인하고, 연결 요청 대기 큐 안에있는 데이터를 끌어와서 새로운 소켓을 만들고, 자식 프로세스를 생성해서 그 소켓 안에서 read, write하게끔 해주는 것이다. 그리고 다시 accept 쪽으로 와서 대기한다.

부모 프로세스가 소켓을 생성할 때 OS가 가지고 있는 소켓을 디스크립터 값으로 가져오는 것이다. fork() 를 하게되더라도 디스크립터 값을 가져오는 것이지, 소켓을 가져오는 것은 아니다. 소켓은 OS가 관리하는 것이다. 부모 프로세스는 OS가 가지고 있는 소켓을 디스크립터 값으로 접근할 수 있는 것이다.

보통 close() 함수를 실행하면 FIN이 보내진다라고 했지만 엄밀히 따지면 하나의 소켓을 여러개의 디스크립터 파일이 가리키고 있어서 close()를 호출할 때마다 소켓당 가리키던 디스크립터 파일 개수(디스크립터 카운터)가 하나씩 줄어들어 0이 된다면 FIN을 보내는 구조이다. 복사되었던 디스크립터 파일 모두가 종료해야 해당 소켓이 소멸 된다.

입출력 루틴 분할의 이점

소켓의 양방향 통신이 가능하다.

⭐️ TCP fair

R은 1과 2가 통신하는 용량의 크기이다. 예를 들어 100Mbps라고 하자. 그리고 우상향 그래프는 1의 throughput 값인 x와 2의 throughput 값인 y가 같은 지점을 연결한 것이다. 우하향 그래프는 x와 y의 합이 R인 지점이다. 한마디로 여기서는 x+y=100인 지점을 연결한 것이다.

예를 들어 (x,y)의 좌표가 (45,10)이라고 하자. RTT마다 cwnd 는 1씩 증가한다. x와 y가 각각 1씩 증가하는 것이므로 45도의 기울기로 증가한다. 그러다가 R의 선을 넘어가게 된다면 packet loss가 되어 x와 y의 좌표를 각각 반으로 줄인다. 그렇게 위의 그림처럼 왔다 갔다 하더라도 처음의 차이는 그렇게 많이 났지만 결국에는 x=y 쪽으로 수렴하게 된다. 그래서 fair하다라고 볼 수 있다.

위 그림과 같이 RTT가 다르면 fair 하지 않다. 또한 커넥션 두개 중에서 하나는 두개를 보내고 하나는 하나를 보내는 네트워크에서도 fair 하지 않다.

profile
블로그 이전했습니다

0개의 댓글