SW 사관학교 정글(Jungle)/컴퓨터 시스템(CSAPP)

[CSAPP] 11.4 소켓 인터페이스

jinsang-2 2024. 9. 16. 21:28

소켓 인터페이스는 네트워크 응용을 만들기 위한 Unix I/O 함수들과 사용되는 함수들의 집합이다. 

전형적인 클라이언트-서버 트랜잭션의 문맥에서 소켓 인터페이스의 개요를 보여준다. 이 그림만 보면 막막하지 않은가? 이해를 돕기 위해 전화를 비유를 들어 설명해보겠다. 

 

이 그림은 네트워크 프로그램에서 클라이언트(Client)서버(Server)가 서로 통신하는 과정을 보여준다. 간단히 말해, 클라이언트서버에 연결을 요청하고, 서버는 그 요청을 받아들이며 서로 데이터를 주고받는 구조를 나타낸다. 

🤔클라이언트와 서버가 대화를 나누는 장면을 상상해보기!💭

  1. 주소 찾기:
    • `클라이언트`는 우선 누구랑 대화할지(즉, 서버의 주소)를 찾아야 합니다. 이 단계가 `getaddrinfo`입니다. 예를 들어, 클라이언트가 "어디에 있는 누구랑 이야기해야 하지?"라고 물어보는 단계라고 생각할 수 있어요.
      • 클라이언트는 자기 전화번호를 등록하는 것이 아니고, 통신할 서버의 주소(전화번호)를 알아내는 겁니다.
    • `서버`도 마찬가지로 자신의 주소를 설정하고(즉, 자신이 대화를 받을 준비를 합니다), 이 단계가 역시 getaddrinfo입니다. 
    • 유의할 점 : getaddrinfo는 클라이언트와 서버 모두 누군가에게 전화를 걸거나 받을 준비를 할 때 주소(전화번호)를 미리 확인하거나 준비하는 과정이에요. 전화번호를 알아내는 과정이지, 전화번호를 정식으로 등록하는 단계는 아닙니다.
  2. 연결 준비하기:
    • 클라이언트는 전화기를 준비하는 것처럼 연결을 위한 소켓을 만듭니다(socket).
    • 서버도 같은 방식으로 자기 전화기를 준비하고(socket), 서버의 전화번호를 등록해둡니다(bind). 이 후에, 전화기를 들고 "연락 와도 돼"라고 기다리는 단계가 listen입니다.
      • bind : 서버가 bind를 호출하면, 서버의 전화번호를 정식으로 등록하는 겁니다. 즉, "이 서버는 이 전화번호(주소)로 전화를 받을 거야!"라고 리눅스 커널에게 알려주는 단계
  3. 전화 걸기:
    • 이제 클라이언트는 서버에게 전화를 걸어요(connect). 이걸 "연결 요청" 이라고 부르죠.
    • 서버는 그 요청을 받습니다(accept). 즉, 서버가 "전화 받았어요" 하는 단계입니다.
  4. 대화 시작:
    • 서로 연결되었으니 이제 대화를 주고받습니다.
      • 클라이언트가 rio_writen을 통해 메시지를 보내면, 서버는 rio_readlineb로 읽습니다. 마치 "여보세요" 하고 말을 건네면 상대가 그걸 듣는 것과 같아요.
      • 반대로, 서버가 rio_writen으로 답을 보내면, 클라이언트는 rio_readlineb로 읽습니다.
  5. 전화 끊기:
    • 대화가 끝나면, 클라이언트서버 모두 close를 통해 전화를 끊습니다.
  6. EOF(End Of File): 전화가 끊어진 후, 더 이상 대화할 수 없다는 신호입니다.

쉽게 말해, 클라이언트는 서버에 "전화 걸고", 서로 대화한 후 "전화를 끊는" 장면을 그림으로 표현한 거예요! 

 

💡소켓 주소 구조체

  • 리눅스 커널의 관점에서 보면, `소켓은 통신을 위한 끝점`이다.
    • 끝점은 마치 전화기의 전화번호와 같다 생각해보자.
    • 이 말은 "통신이 일어나는 곳"을 뜻한다. 소켓이 없으면 통신도 할 수 없다. 마치 전화번호가 있어야 누군가에게 전화를 걸 수 있는 것처럼, 소켓이 있어야 통신을 시작할 수 있다.
    • 소켓은 A와 B가 대화하는 끝점. 마치 전화기에서 A와 B가 각각 전화번호를 가지고 그 번호를 통해 통신하는 것처럼, 소켓은 통신할 주소를 설정
    • 즉, 리눅스 커널에서 소켓은 "어디로 전화를 걸고 받을지" 정해주는 그 연결의 끝점이라고 생각하면 되는 것
  • Unix 프로그램의 관점에서 보면 `소켓은 해당 식별자를 가지는 열린 파일`이다. 
    • 파일을 열다는 말의 의미
      • 우리가 컴퓨터에서 문서 파일을 열어 내용을 보고 수정하는 것처럼, 소켓도 파일처럼 열어서 그 안에서 통신 데이터를 주고받는 것
    • 쉽게 말해, 소켓은 특정 식별자(번호)를 가지는데, 이 소켓은 마치 파일처럼 읽고 쓰는 통로이다. 즉, Unix 프로그램에서는 소켓을 열고 데이터를 주고받는 것이 파일을 읽고 쓰는 것과 매우 비슷함을 뜻한다.
typedef struct sockadder SA; 

/* sockaddr _in 접미사는 internet을 뜻함*/
// IPv4 주소를 나타내는데 사용
struct sockaddr_in {
	uintl6_t sin_family;  /* 어떤 프로토콜을 사용할지 지정, 여기서는 항상 AF_INET으로 설정(IPv4)*/
	uintl6_t sin_port;  /* 포트번호, 이 값은 네트워크에서 바이트 오더(빅 엔디언)로 저장 */
	struct in_addr sin_addr; /*IP 주소 저장, 네트워크 바이트 오더로 변환된 상태로 저장 */
	unsigned char sin.zero [8]; /* 구조체 크기를 맞추기 위한 패딩 */
};

/* 소켓 주소를 일반적으로 나타낼 때 사용 */ 
// struct sockaddr IPv4뿐 아니라 다른 프로토콜에서도 사용할 수 있는 범용 소켓 주소 구조체이다. 
struct sockaddr { 
	uintl6_t sa_family; /* 이 필드는 사용할 프로토콜을 지정하는데, 여기서도 AF_INET 등과 같은 값을 넣어 네트워크 종류를 지정 */
	char sa_data[14] ; /* 이 필드는 실제 주소 데이터를 담고 있지만, 길이가 고정된 14바이트이므로 sockaddr_in 같은 구조체에 비해 덜 구체적 */ 
};
  • 인터넷 소켓 주소는 sockaddr_in 타입의 16바이트 구조체에 저장
  • 인터넷 응용
    • sin_family 필드 : AF_INET(IPv4)
    • sin_port 필드 : 16비트 포트 번호
    • sin_addr 필드 : 32비트 IP 주소
    • IP주소와 포트 번호는 항상 네트워크 바이트 순서(빅 엔디안)로 저장
  • connect, bind, and accept 함수 
    • 프로토콜에 특화된 소켓 주소 구조체를 가리키는 포인터를 필요로 한다. 
    • 소켓 인터페이스를 설계한 사람들이 당면한 문제는 어떻게 이 함수들을 정의해서 어떤 종류의 소켓 주소 구조체라도 받아들일 수 있도록 하는가였음
    • 오늘날 우리는 포괄적인 void * 포인터를 사용하면 됬지만 이것은 그 시절의 C에는 존재하지 않았다. 
    • 그들의 해결책은.. 소켓 함수를 원천 sockaddr 구조체로의 포인터를 기대하도록 하고, 그 후에 응용이 프로토콜에 특화된 구조체로의 모든 포인터를 이 포괄적인 구조체로 캐스팅하도록 정의하는 것이었다. 

 

👉socket 함수

클라이언트와 서버는 소켓 식별자를 생성하기 위해서 socket 함수를 사용한다. 

소켓을 끝점으로 만들고 싶을 때, 다음과 같이 하드코드된 인자로 socket함수를 호출하면 된다.

clientfd = Socket(AF_INET, SOCK_STREAM, 0);

 

여기서 AF_INET은 우리가 32비트 IP주소를 사용하고 있다는 것을 나타내고 SOCK_STREAM은 소켓이 인터넷 연결의 끝점이 될 것이라는 것을 나타낸다. 

가장 좋은 습관은 이 매개변수들을 자동으로 생성해서 코드가 프로토콜에 무관하게 되도록 getaddrinfo를 이용하는 것이다.

socket에 의해 리턴된 clientfd 식별자는 겨우 부분적으로 열린 것이며, 아직은 읽거나 쓸 수 없다. 

소켓의 오픈 과정을 어떻게 완료하는지는 클라이언트인지 서버인지에 따라 다르다.

 

👉connect 함수

클라이언트인 경우 소켓 오픈

  • connect 함수는 소켓 주소 addr의 서버와 인터넷 연결을 시도
  • addrlen은 sizeof(sockaddr_in)이 된다. 
  • connect 함수는 연결이 성공할 때까지 블록되어 있거나 에러가 발생한다. 
  • 성공이라면 clientfd 식별자는 이제 읽거나 쓸 준비가 되었으며, 이 연결은 다음과 같은 소켓 쌍으로 규정된다.
    • (x:y, addr.sin_addr:addr.sin_port)
    • x는 클라이언트 IP 주소, y는 클라이언트 호스트의 클라이언트 프로세스를 유일하게 식별하는 단기 포트

👉bind 함수

남아 있는 소켓 함수 bind, listen, accept 는 서버가 클라이언트와 연결을 수립하기 위해 사용

  • bind함수는 커널에게 addr에 있는 서버의 소켓 주소를 소켓 식별자 sockfd와 연결하라고 물어본다. 
  • addrlen 인자는 sizeof(sockaddr_int)이다.

👉listen 함수

`클라이언트`는 연결 요청을 개시하는 `능동적 개체`이다.`서버`는 클라이언트로부터의 연결 요청을 기다리는 `수동적 개체`이다. 기본적으로 커널은 socket 함수가 만든 식별자는 한 연결의 클라이언트 쪽 끝에서 존재하는 능동 소켓에 대응된다. 

서버는 listen 함수를 호출해서 이 식별자를 클라이언트 대신에 서버가 사용하게 될 것이라고 알려준다. 

  • listen 함수는 sockfd를 능동 소켓에서 듣기 소켓으로 변환한다.
  • 듣기 소켓은 클라이언트로부터의 연결 요청을 승락할 수 있다. 
  • backlog 인자는 커널이 요청들을 거절하기 전에 큐에 저장해야 하는 연결의 수에 대한 정보를 제공한다. 대개 1024와 같은 큰 값으로 설정

👉accept 함수

서버는 accept 함수를 호출해서 클라이언트로부터 연결 요청을 기다린다. 

  • accept 함수는 클라이언트로부터의 연결 요청이 듣기 식별자 listenfd에 도달하기를 기다리고, 그 후에 addr 내의 클라이언트의 소켓 주소를 채우고, Unix I/O 함수들을 사용해서 클라이언트와 통신하기 위해 사용될 수 있는 연결 식별자를 리턴한다. 
  • 듣기 식별자는 클라이언트 연결 요청에 대해 끝점으로서의 역할을 한다.
    • 한 번 생성되며, 서버가 살아있는 동안 계속 존재한다.
  • 연결 식별자는 클라이언트와 서버 사이에 성립된 연결의 끝점이다. 
    • 서버가 연결 요청을 수락할 때마다 생성되며, 서버가 클라이언트에 서비스하는 동안에만 존재한다. 

 

1. 서버가 accept에서 대기

  • 서버는 `listenfd` 라는 리스닝 소켓을 열어 놓고, 클라이언트의 연결 요청을 기다린다.
  • 서버는 `accept()` 함수에서 대기상태에 있다. 즉 클라이언트가 연결 요청을 보낼 때까지 아무것도 하지 않고 기다리고 있다.
  • listenfd(3)에서 숫자 3은 파일 디스크립터 번호를 의미한다. 이 디스크립터를 통해 서버는 연결 요청을 받을 준비를 하고 있다. 

2. 클라이언트가 연결 요청

  • 클라이언트는 서버에 연결 요청을 보내기 위해 `connect()`함수를 호출, 이 때 클라이언트는 `clientfd`는 소켓 디스크립터를 사용
  • `connect()` 함수가 호출되면 클라이언트는 연결 요청을 보내고, 서버가 응답할 때까지 기다린다.
  • 서버는 클라이언트의 연결 요청을 `listenfd`를 통해 수신하게 된다. 

3. 연결이 수락된다.

  • 서버는 클라이언트의 연결 요청을 accept()로 수락한다. 이 때 새로운 소켓 디스크립터 `connfd(4)` 가 생성된다.
    • `connfd(4) `는 서버가 클라이언트와 연결을 유지하기 위해 사용하는 새로운 소켓 디스크립터
  • 클라이언트는 connect() 함수에서 빠져나와 연결이 성공적으로 수립되었다는 신호를 받는다.
  • 이제 clientfd(클라이언트 측) connfd(서버 측) 사이에 연결이 확립되었다. 이를 통해 두 측이 데이터를 주고 받을 수 있게 된다. 

정리

  1. 서버는 `listen()`과 `accept()`를 통해 클라이언트의 연결 요청을 기다립니다.
  2. 클라이언트는 `connect()`를 호출하여 서버에 연결 요청을 보냅니다.
  3. 서버가 클라이언트의 연결 요청을 받아들이면, 양측은 각각의 소켓 디스크립터를 통해 통신을 시작합니다.
  1.