정글사관학교 개발일지/웹 서버

정글사관학교 49일차 TIL: 파일 디스크립터, 소켓 프로그래밍

Woonys 2021. 12. 20. 22:05
반응형

파일 디스크립터(File descriptor)

 

파일 디스크립터란 운영체제가 특정 파일에 할당해준 정수값을 말한다. 컴퓨터 프로그래밍 시 운영체제가 파일에 접근하기 쉽게 번호로 추상화시켰다고 보면 된다. 예컨대 3학년 8반 5번 김철수라는 아이를 일일이 김철수라 부르지 않고 "3-8반 5번"으로 부른다고 생각하면 된다.

 

우리가 자주 사용하는 표준 입출력 역시 이미 파일 디스크립터 값이 할당되어 있는데, 값은 아래와 같다.

 

stdin(표준입력): 0

stdout(표준출력): 1

stderr(표준에러): 2

 

위처럼 0-2까지는 이미 파일 디스크립터 값이 할당되어 있으니, 우리가 사용할 수 있는 건 3 이상부터다. 하지만 이 번호는 OS에서 알아서 매겨주기 때문에 신경 쓸 필요는 없다.

 

파일 디스크립터 값 할당

바로 위에서 OS가 알아서 파일 디스크립터 값을 할당한다고 했다. 그럼 우리는 이를 어떻게 사용할 수 있을까? 사용자 지정 파일을 읽거나 쓸 때, open() 함수를 이용해 파일 디스크립터 값을 할당 받을 수 있다.

 

int fd = open("file.txt", O_WRONLY); // 쓰기 전용으로 file.txt 파일을 연다는 의미.

 

open()함수는 파일명, 읽거나 쓸 때 사용할 옵션 두 가지를 인자로 받아 고유한 fd 값을 반환한다. 이후부터는 이 fd 변수를 이용해 일일이 "file.txt"라고 입력하지 않고도 file.txt 파일에 쉽게 접근이 가능하다.

 

소켓 프로그래밍을 정리하는데 이걸 왜 적냐, 위의 file.txt와 같은 데이터를 담고 있는 파일과 마찬가지로 소켓에서도 fd 값을 이용해 네트워크 간에 통신한다. 위에서는 open() 함수로 fd 값을 할당받았던 것처럼, 소켓에서는 이후에 소개할 socket(), accept() 함수를 통해 할당받을 수 있다. 

 

소켓 프로그래밍

소켓이란?

리눅스는 거의 모든 것을 파일로 표현하고 파일로 다룬다. 이는 UNIX 역시 마찬가지. 네트워크를 포함한 모든 UNIX I/O 디바이스는 파일이므로, 소켓 역시 네트워크 상의 다른 프로세스와 통신하는 역할을 하는 파일로 볼 수 있다. 소켓은 리눅스 커널 관점에서는 통신에서의 끝점(endpoint)라고 정의하는 반면, 리눅스 응용 프로그램 관점에서는 하나의 디스크립터와 대응하는 열린 파일이라고 본다. Endpoint라고 하는 개념은 통상적으로 인터페이스, 파이프, 노드 등 어떤 특정 영역의 끝단을 지칭하는 일반적인 워딩이고, 그 중 소켓은 네트워크 상에서의 끝단이라고 생각하면 될 듯. 그럼 어디의 끝단이냐? 이를 알기 위해서는 소켓 인터페이스라는 개념을 뜯어볼 필요가 있다.

 

소켓 인터페이스

소켓 인터페이스는 네트워크 어플리케이션을 설계하기 위해 UNIX 입출력(I/O) 함수와 연결하는데 쓰이는 함수의 집합이다. 소켓 인터페이스를 사용하면 전송 계층(TCP/IP)나 네트워크 계층의 복잡한 구조를 몰라도 쉽게 네트워크 프로그램을 작성할 수 있다. 처럼 클라이언트에서 TCP/IP로 넘어가기 전에 시스템 콜을 하는 영역이 바로 소켓 인터페이스로, 클라이언트에서 연결 요청을 하면 클라이언트의 소켓 주소에 들어가는 포트는 커널에서 자동으로 배정한다.

 

 

 

 

소켓 주소 구조체

(소켓 주소 = IP 주소 + 포트 번호)로, 한 소켓에는 클라이언트/서버 각각의 IP 주소와 각각의 포트 번호가 들어간다. 이렇게 클라이언트/서버 둘이 짝지어져 만들어지는 소켓 주소의 짝을 socket pair라고 하며, 이는 아래와 같은 튜플로 구성된다.

 

(cliaddr:cliport, servaddr:servport)

 

이 소켓은 소켓 주소 구조체(socket address struct) 형식을 이용해 클라이언트와 서버가 서로 소켓 주소를 주고 받는다. 어떻게 주고받을까? 대략적인 흐름은 아래 도식과 같다. 아래는 클라이언트-서버 트랜잭션에서 소켓 인터페이스의 개요를 나타내는 그림이다. 여기서는 클라이언트에서 socket->connect-> connection request, 서버에서 socket, bind, listen, accpet까지 각 개별 함수의 기능에 대해 중점적으로 살펴본다. 하나씩 찬찬히 뜯어보자. 

 

 

 

 

1. 소켓 생성

socket(): 클라이언트/서버가 소켓(소켓 판별자)를 만들기 위해 사용하는 함수

#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

//ex)int clientfd = Socket(AF_INET, SOCK_STREAM, 0);

// Returns: nonnegative descriptor if OK, −1 on error

socket() 함수를 이용해 소켓을 생성할 수 있다. 위 함수의 인자를 살펴보자. 소켓 함수에는 총 3가지 인자가 들어가며, 운영체제에서 파일 디스크립터 번호를 할당(실패시 -1)해 반환한다. 즉, 예시에서 clientfd라는 변수에는 소켓 함수에서 반환하는 파일 디스크립터 정수값을 가진다.

 

1. 도메인: 네트워크 주소 체계를 알려준다. 여기서는 AF_INET이므로 32비트 IPv4 주소를 사용한다는 뜻이다.

2. 소켓 타입: 이 소켓이 어떤 타입(TCP인지 UDP인지 등)인지를 알려준다. 예시의 SOCK_STREAM은 TCP 프로토콜을 사용한 통신 소켓이라는 뜻이다.

3. 프로토콜 정보: 프로토콜 정보의 경우, 앞서 1, 2번 인자만으로 프로토콜을 하나로 특정지을 수 없을 떄만 따로 명시해주면 된다. 그렇지 않으면 위와 같이 0으로 처리해도 무방하다.

 

2. 소켓 주소 할당

socket() 함수로 fd 값을 할당 받고, 소켓 유형까지 지정 완료했다. 이제 이 서벗 소켓은 다른 네트워크 어딘가의 클라이언트와 통신해야 한다. 그러면 이 소켓에 추가적으로 정의되어야 하는 정보는 무엇이 있을까? 바로 IP 주소와 PORT 번호이다. 맨 위에서 설명했던 것처럼, 소켓에는 두 주소가 할당되어야 한다. IP 주소로 컴퓨터를 특정짓고 port 번호로 프로세스를 특정지을 수 있어야 하기 때문이다. 소켓 주소를 할당하기 위해서는 이를 담는 구조체를 정의할 필요가 있다. 아래를 살펴보자.

 

generic 소켓 주소 구조체 sockaddr

connect, bind, accept 함수의 인자로 넣어주기 위한 16바이트 구조체이다. 예전 C언어에는 (void *)형이 없어서 인자를 캐스팅해주기 위해 필요한 구조체였다.

 

generic 소켓 주소 구조체 sockaddr

struct sockaddr { 
  uint16_t  sa_family;    /* Protocol family */ 
  char      sa_data[14];  /* Address data.  */ 
};

sockaddr의 구조를 살펴보자. 아래와 같이 2칸은 이 소켓의 종류가 무엇인지를 말해준다. 예컨대 이 소켓이 TCP인지 UDP인지 IPv6인지 등이다. IPv4는 없는데, 이는 워낙 많이 쓰다보니 아래와 같이 sockaddr_in이라고 해서 전용 구조체를 따로 가진다.

 

IPv4 인터넷 소켓 주소 구조체 sockaddr_in

인터넷에서 활용되는 인터넷 소켓 주소는 더 구체적인 정보를 담고 있는 16바이트 구조체에 저장된다. 이는 가장 많이 쓰는 IP 주소인 IPv4 소켓에서 사용되는 종류이다.

 

IPv4 인터넷 소켓 주소 구조체 sockaddr_in

  /* IP socket address structure */
  struct sockaddr_in  {
code/netp/netpfragments.c
     uint16_t        sin_family;  /* Protocol family (always AF_INET) */
    uint16_t        sin_port;    /* Port number in network byte order */
    struct in_addr  sin_addr;    /* IP address in network byte order */
    unsigned char   sin_zero[8]; /* Pad to sizeof(struct sockaddr) */
};

 

  • sin_family: AF_INET(32비트 IPv4 주소를 뜻함)를 사용한다는 것을 나타냄. 이 구조체는 IPv4를 위한 소켓 주소 구조체이므로 항상 AF_INET이다.
  • sin_port: 16비트 port 번호(0~65535). 네트워크 바이트 순서로 저장된다.
  • sin_addr: 32비트 IP 주소. 네트워크 바이트 순서로 저장된다.
  • sin_zero: sockaddr 구조체와 사이즈를 맞추기 위한 패딩. 위의 제네릭 구조체는 14바이트의 sa_data 할당 영역을 통해 IP 주소와 port 번호를 특정하는데, IPv4의 경우 14바이트를 채우지 않는다. 따라서 나머지 바이트를 0으로 채우는 것.

 

3. 서버의 시스템 콜(bind, listen, accept)

socket() 함수는 자신이 클라이언트든 서버든 간에 자기 자신을 항상 클라이언트라고 생각한다. 다시 말해, 자기가 항상 요청을 주는 쪽이라고 생각하고 그에 따라 소켓을 생성한다. 그래서 서버 단에서는 이를 서버 역할(요청을 받고 응답해주는) 바꾸기 위해 시스템을 호출해 아래 세 가지 함수 bind(), listen(), accept()를 호출한다.

 

bind(): 소켓에 주소를 할당하는 함수

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// Returns: 0 if OK, −1 on error

bind 함수에는 3개의 인자를 전달해 소켓에 주소를 할당한다. 앞서 socket() 함수로부터 반환받은 디스크립터 값이 있을텐데, 이 디스크립터 파일에 해당하는 소켓에 주소를 할당하겠다는 뜻이다.

 

1. sockfd: socket() 함수를 통해 배정받은 디스크립터 번호

2. *addr: IP주소와 PORT 번호를 지정한 sockaddr 구조체를 가리키는 포인터

3. 주소 정보를 담은 변수 길이

 

bind() 함수는 성공시 0, 실패시 -1을 반환한다.

listen(): 연결 요청을 대기하는 함수

위에서 bind()를 통해 하나의 소켓에 IP 주소와 port 번호까지 할당했으니, 클라이언트가 해당 소켓에 연결할 수 있도록 요청을 대기하는 상태로 만들어주어야 한다. 이 과정을 담당하는 함수가 listen() 함수다. 즉, listen() 함수가 호출된 후부터 클라이언트에서 connect() (연결을 요청하는 함수)를 호출할 수 있게 된다.

 

#include <sys/socket.h>
int listen(int sockfd, int backlog);
//Returns: 0 if OK, −1 on error

 

1. sockfd: 소켓 디스크립터 번호

2. backlog: 연결 요청을 대기하는 큐의 크기

 

즉, 지정한 디스크립터의 소켓이 listen() 소켓이 되며 backlog만큼의 큐 공간을 갖는다. (큐(queue)란, 우리가 아는 그 queue와 동일한 개념으로, 연결 요청을 대기시킨다. 클라이언트로부터 도착한 연결 요청을 대기시키는 공간이라고 생각하면 된다.)

 

accept(): 연결 요청을 수락하는 함수

마지막으로, 대기 중인 클라이언트의 요청을 차례로 수락함으로써 데이터를 주고받을 수 있게 된다. accept 함수가 바로 연결 요청을 수락하는 함수이다.

 

#include <sys/socket.h>
int accept(int listenfd, struct sockaddr *addr, int *addrlen);

//Returns: nonnegative connected descriptor if OK, −1 on error

여기서 주의해야 할 점은, accept 반환값은 성공/실패에 대한 정수값이 아닌 새로운 디스크립터 번호라는 점이다. 

 

우리가 앞서 사용하던 서버 소켓(리스닝 소켓)은 연결 요청을 대기시키는 과정까지를 담당하며 accept() 함수를 통해 새로 할당받은 소켓을 이용해 데이터 송수신을 할 수 있는 것이다.

 

accept()에서 받는 3개의 인자는 아래와 같다.

 

1. listenfd: 서버 소켓(리스닝 소켓)의 디스크립터 번호

2. *addr: 대기 큐를 참조해 얻은 클라이언트의 주소 정보

3. addrlen: addr 변수의 크기

 

위의 순서대로 bind(), listen(), accept() 세 가지 함수를 순차적으로 호출함으로써 서버 측에서의 데이터 송수신 준비를 마칠 수 있다. 최종적으로는 클라이언트 측에서 connect() 함수를 통해 연결한 뒤, write() 함수로 실제 데이터를 출력한다. 데이터 송수신을 완료한 후, 이용한 소켓을 완전히 소멸시킬 때는 close() 시스템 콜을 호출해준다. 그러면 커널이 해당 소켓의 자원을 모두 시스템에서 제거한다.

 

 

4. 클라이언트의 시스템콜(connect)

서버에서는 socket() 함수로 소켓 디스크립터를 할당받은 뒤, bind() -> listen() -> accept() 시스템콜을 차례로 호출해 클라이언트와 연결을 맺을 수 있다. 반대로, 클라이언트에서는 대기 중인 서버에 연결 요청(connect)을 함으로써 서버-클라이언트 간 연결이 생성되는데 이때 사용하는 시스템콜이 connect()이다.

 

socket(): 클라이언트의 소켓 생성 및 주소 할당

클라이언트의 경우도 마찬가지로 서버와 소켓 통신을 하기 위해 소켓을 생성해야 한다. socket() 함수를 통해 커널로부터 디스크립터 번호를 할당받아 저장한다. socket을 생성했으니 다음으로 연결을 요청할 서버의 주소를 소켓에 할당한다.

 

이때, 서버와 달리 클라이언트는 본인의 IP 주소와 PORT 번호를 따로 저장할 필요가 없는데, 연결을 요청하는 시점(소켓 인터페이스)에서 커널이 알아서 모두 지정해주기 때문이다. 이에 대해서는 나중에 getaddrinfo라는 함수를 정리하며 알아볼 것.

 

connect(): 클라이언트의 연결 요청

어느 서버에 연결을 요청할지 모두 결정했으니, 실제로 connect() 함수를 이용해 연결을 요청한다.

 

#include <sys/socket.h>
int connect(int clientfd, const struct sockaddr *addr,
            socklen_t addrlen);
// Returns: 0 if OK, −1 on error

1. clientfd: 클라이언트의 소켓 디스크립터 번호

2. *addr: 연결 요청할 서버의 주소 구조체

3. addrlen: 서버 주소 구조체 변수의 길이

 

클라이언트에서 connect() 시스템 콜을 호출하면 listen() 혹은 accept()로 대기 중이던 서버에 연결이 요청되며, 서버는 accept() 함수를 정상적으로 리턴해 두 호스트 간 연결이 성립된다.

 

이 connect부터 accept 까지가 우리가 말하는 TCP 3-way-hadnshaking 과정이라고 볼 수 있다.

 

Reference

소켓프로그래밍- 소켓의 생성과 주소할당

소켓프로그래밍- 서버의 시스템 콜( bind, listen, accept )

소켓프로그래밍 - 클라이언트의 시스템콜(connect)

03_Socket_Interface

반응형