네트워크 통신은 본질적으로 서로 다른 두 호스트에서 실행 중인 process 간의 데이터 교환이다. 하지만 브라우저나 웹 서버 같은 application이 물리적인 네트워크 인터페이스 카드(NIC)를 직접 제어하여 패킷을 쏘는 것은 불가능하다. 운영체제는 하드웨어 자원을 보호하고 다중 process 환경을 안정적으로 통제하기 위해 메모리를 user space와 kernel space로 철저히 분리해 두었기 때문이다.
우리가 흔히 아는 TCP/IP stack과 같은 복잡한 네트워크 프로토콜 구현체 및 State Machine은 바로 kernel space에 구현되어 있다. 따라서 user space에서 동작하는 application이 네트워크를 통해 데이터를 주고받으려면, 반드시 system call을 호출하여 커널에게 작업을 위임해야만 한다.
이때 application과 커널 내부의 네트워크 스택을 이어주는 표준화된 인터페이스가 필요하다. UNIX 계열 운영체제는 "모든 것은 파일이다(Everything is a file)"라는 철학 아래, 네트워크 통신조차 일반적인 파일 입출력과 동일한 방식으로 다룰 수 있도록 강력한 추상화를 제공한다. 이 추상화된 연결의 끝점이자 인터페이스가 바로 Socket이다.
이제 이 기본적인 시스템 아키텍처에 대한 이해를 바탕으로, BSD Socket API를 기준으로 client side에서부터 서버까지의 실제 통신 과정을 살펴보겠다.
사용자가 브라우저 주소창에 google.com을 입력했다고 가정해 보자. 서버와 연결하기 위해서는 대상의 IP 주소가 필요하지만, google.com은 사람이 읽기 쉬운 도메인 이름이므로 이를 IP로 변환하는 과정이 선행되어야 한다.
우선 OS 내 캐시에 해당 도메인에 대한 IP가 저장되어 있는지 확인하고, 없다면 외부로 요청을 보낸다. 이를 DNS(Domain Name System)라고 한다. DNS는 관점에 따라 거대한 하나의 분산 시스템으로 볼 수도 있고, 특정 계층의 local 저장소로 볼 수도 있다.
하지만 외부 네트워크와 통신하려면 내 컴퓨터 자체의 네트워크 설정부터 되어 있어야 한다. 컴퓨터가 처음 물리적으로 네트워크에 연결되면, Broadcast방식으로 네트워크상에 패킷을 쏜다. 그러면 공유기(Router)가 이를 수신하고 내 컴퓨터에 IP 주소, Subnet Mask, Default Gateway, 그리고 Local DNS 서버의 IP 주소를 할당해 준다. 이 일련의 동적 할당 과정을 DHCP(Dynamic Host Configuration Protocol)라고 한다.
여기서 각 요소의 역할은 다음과 같다.
-
Local DNS 서버: 도메인과 IP를 매핑하는 통신사 측 서버.
-
Subnet Mask: 내 컴퓨터가 목적지 IP와 통신할 때, 이것이 내부 네트워크인지 외부 네트워크인지 판별하는 기준점. 목적지 IP 주소와 서브넷 마스크를 Bitwise AND 연산하여 추출된 Network ID가 내 컴퓨터의 Network ID와 다르면 외부 네트워크로 판단한다.
-
Default Gateway: 외부 네트워크로 나가기 위해 거쳐야 하는 공유기(라우터)의 IP 주소.
이러한 네트워크 기반 위에서 대상 서버의 IP 주소를 최종적으로 받아오는 함수가 getaddrinfo()이다. 목적지를 알았으니 통신을 위한 Socket을 생성해야 하는데, Socket이라는 용어는 혼용되기 쉬우므로 여기서 명확히 정의하고 넘어가겠다.
- API 측면: BSD Socket. User Space(유저 공간)의 application이 네트워크 기능을 사용하기 위해 호출하는 C 언어 함수들의 인터페이스.
- Implementation 측면: Stream Socket(TCP) / Datagram Socket(UDP). 실제 Kernel Space(커널 공간)에 할당되는 자료구조 및 State Machine(상태 머신).
따라서 앞으로 다룰 socket() 함수는 BSD Socket API의 일부이며, 이 함수가 커널 내부에 실제 소켓 객체를 만들어준다는 사실을 인지해야 한다. 본 글에서는 TCP 연결에 집중하여 설명한다.
본격적으로 getaddrinfo() 이후에 호출되는 socket() 함수에 대해 살펴보자.
브라우저는 화면 렌더링 등 처리해야 할 작업이 많기 때문에, 직접 네트워크 통신을 관리하지 않고 Socket에 수신 작업을 위임한다. 브라우저가 다른 작업에 집중하는 동안 데이터가 유실되지 않도록 Socket의 Receive Buffer에 데이터를 쌓아두는 구조를 취한다. 특히 TCP는 신뢰성 있는 연결을 보장하므로 누락되거나 순서가 뒤섞인 패킷을 OS 커널 레벨에서 알아서 재조립해 준다.
하지만 Socket과 그 버퍼들은 Kernel Space에서 관리되는 자원이다. 브라우저(User Application)가 커널 메모리에 직접 접근하는 것은 보안 붕괴나 커널 패닉을 유발할 수 있는 매우 위험한 행위다. 따라서 OS는 File Descriptor(파일 디스크립터)라는 추상화 계층을 제공한다. File Descriptor는 프로세스가 열어둔 입출력 객체들의 커널 메모리 주소를 매핑해 놓은 정수형 인덱스 배열이다. socket() 함수를 호출하면 OS는 실제 커널 영역에 Socket 객체를 생성한 뒤 그 포인터를 내부 테이블에 저장하고, application에는 그 배열의 인덱스만을 반환한다. Application은 이 안전한 정수값을 사용해 간접적으로 통신한다.
socket()으로 File Descriptor를 얻었다면, connect() 함수를 호출한다. 이는 클라이언트의 로컬 포트를 바인딩함과 동시에, 목적지 서버로 TCP 연결 요청(SYN)을 보내는 역할을 한다.
서버 측의 흐름은 클라이언트와 비슷하면서도 다르다. 서버 역시 getaddrinfo()로 주소 정보를 세팅하고, socket()을 통해 패킷을 수신할 버퍼와 커널 객체를 생성한다.
이후 명시적으로 bind() 함수를 호출하여 "나는 특정 포트로 들어오는 요청만 받겠다"라고 문을 연다. 이 포트로 들어오는 TCP 패킷들은 해당 Socket의 Receive Buffer로 향하게 된다.
그다음 listen() 함수를 호출하여 Socket을 외부 연결을 대기하는 상태로 전환한다. listen()이 호출되면 커널 내부에서는 두 개의 Connection Queue가 생성된다.
- SYN Queue: 클라이언트로부터 최초의 연결 요청(SYN 패킷)이 들어왔을 때, 즉 TCP 3-way Handshake가 아직 완료되지 않은 연결들이 잠시 대기하는 공간이다.
- Accept Queue: 서버가 SYN-ACK를 보내고 클라이언트로부터 최종적으로 ACK를 받아 3-way Handshake가 완전히 끝난, 즉시 통신 가능한 연결들이 대기하는 공간이다.
서버의 메인 루프 안에는 accept() 함수가 대기하고 있다. accept()는 Accept Queue를 관찰하다가 완성된 연결이 들어오면 이를 하나씩 꺼내어 새로운 Socket File Descriptor를 할당한다.
기존의 Listening Socket을 그대로 쓰지 않고 새로운 Socket을 만드는 이유는 클라이언트와의 1:1 독립적인 정보 교환(세션)을 보장하기 위함이다. 새로 생성된 Socket들은 커널 내부의 Hash Table(해시 테이블)에 등록된다. 이후 서버의 네트워크 인터페이스로 수많은 패킷이 쏟아져 들어올 때, 커널은 각 패킷 헤더의 4-Tuple(출발지 IP, 출발지 포트, 목적지 IP, 목적지 포트)을 해시 키로 사용하여 이 패킷이 어느 Socket의 버퍼로 들어가야 할지 정확하게 라우팅해 준다.