네트워크의 이해(1) - TCP/IP 4계층, Socket과 File Descriptor, Echo 서버 구현

100·2025년 5월 2일
3

C언어

목록 보기
5/6

TCP/IP 개념 잡고 가기

Ethernet과 Wi-Fi는 LAN(Local Area Network) 기술로, 실제 데이터를 전기 신호나 무선 전파로 변환해 전송하는 역할을 한다. MAC 주소를 기반으로 프레임이 전달되며, 전송 매체와 방식에 따라 성능 특성이 달라진다.

항목EthernetWi-Fi
전송 매체유선 (UTP, 광케이블 등)무선 (2.4GHz, 5GHz 등 전파)
연결 방식Point-to-Point (스위치 기반)공유 채널 (AP 기반)
전송 속도일반적으로 1Gbps 이상최대 수 Gbps (Wi-Fi 6 이상)
지연 및 안정성낮고 안정적간섭에 취약하고 변동 있음
충돌 제어CSMA/CD (사실상 미사용)CSMA/CA (충돌 회피 방식)
보안물리적 접근 제약으로 안전WPA2/WPA3 암호화 필요

2. 인터넷 계층 (Internet Layer)

인터넷 계층은 데이터를 목적지까지 전달하기 위한 주소 지정과 라우팅을 담당한다. 핵심 프로토콜은 IP이며, 데이터를 패킷 단위로 전송한다. 연결을 설정하지 않고 전송하는 비신뢰성 기반 방식이며, 전송 중 손실되거나 순서가 바뀔 수 있다. 이러한 한계를 전송 계층이 보완한다.

3. 전송 계층 (Transport Layer)

전송 계층은 애플리케이션 간 종단 간(end-to-end) 데이터 전송을 담당한다. 대표적인 프로토콜은 TCP와 UDP다.

항목TCPUDP
연결 방식연결 지향 (3-way handshake)비연결 지향
신뢰성있음 (순서 보장, 재전송 등)없음 (손실/중복 가능)
전송 단위바이트 스트림메시지 단위 (Datagram)
흐름/혼잡 제어있음없음
속도느리지만 안정적빠르지만 손실 가능
헤더 크기20바이트 이상8바이트
대표 용도웹, 이메일, 파일 전송실시간 영상, 게임, DNS 등

4. 애플리케이션 계층 (Application Layer)

애플리케이션 계층은 사용자가 직접 마주하는 영역이다. 웹 브라우징, 이메일, 파일 전송 등 고수준 애플리케이션이 위치하며, 여기서는 DNS와 HTTP를 중심으로 살펴본다.

항목DNSHTTP
역할도메인 이름 → IP 주소 해석리소스 요청 및 전송
프로토콜 유형요청-응답 (단일 질의)요청-응답 (다양한 리소스 요청)
전송 프로토콜UDP (기본), TCP (fallback)TCP (HTTP), TLS 기반 TCP (HTTPS)
사용 시점통신 시작 전IP 확보 후 리소스 요청 시
연결 지속성없음있음 (keep-alive 지원)
보안 확장DNSSEC, DoH, DoT 등HTTPS (TLS 기반 암호화)

전체 통신 흐름 요약

  1. 사용자가 브라우저에 www.example.com을 입력한다.
  2. 애플리케이션 계층에서 DNS 질의를 수행해 도메인 이름을 IP 주소로 변환한다.
  3. 전송 계층에서 TCP 연결을 수립한다 (3-way handshake).
  4. HTTP 요청을 보내 웹 리소스를 요청한다.
  5. 서버가 HTTP 응답을 반환한다.
  6. 인터넷 계층(IP)이 경로를 지정하고,
  7. 링크 계층(Ethernet/Wi-Fi)이 실제 물리 전송을 수행한다.

핵심적인 크기 개념

항목설명관여 계층주체성능에 미치는 영향현실 비유
MTU(Maximum Transmission Unit)하나의 IP 패킷이 전송될 수 있는 최대 크기 (IP 헤더 포함)네트워크 계층 (IP)네트워크 장비 (라우터, NIC 등)너무 작으면 IP fragmentation 증가, 전송 효율 감소도로 제한 높이
MSS(Maximum Segment Size)하나의 TCP 세그먼트에서 데이터 영역의 최대 크기 (헤더 제외)전송 계층 (TCP)송신자 (TCP 스택)작으면 전송 단위 증가 → 헤더 오버헤드 증가트럭 짐칸 크기
TCP Window Size(rwnd: 수신 윈도)수신자가 한 번에 받아들일 수 있는 총 데이터 양전송 계층 (TCP)수신자 (애플리케이션 or 커널)작으면 송신자 정지, Throughput 감소목적지 창고 용량
Congestion Window(cwnd)혼잡 제어에 따라 송신자가 한 번에 보낼 수 있는 최대 양전송 계층 (TCP)송신자 (TCP 스택)네트워크 혼잡도 반영. 손실 발생 시 줄어들고 서서히 증가트럭 출발 빈도 제한 (정체 고려)
Effective Window Size실질 전송 가능 크기: min(cwnd, rwnd)전송 계층양측실제 전송량 결정창고 용량 vs 도로 상황 중 좁은 쪽
Send Buffer송신 애플리케이션이 write한 데이터를 커널이 보관하는 버퍼소켓 계층 (OS 커널)송신자 (커널/OS)작으면 write()가 막힘. 커널 레벨 처리량 제약트럭 출발 전 대기 장소
Receive Buffer수신한 데이터를 read() 전까지 보관하는 커널 버퍼소켓 계층 (OS 커널)수신자 (커널/OS)작으면 rwnd 작아짐 → 송신 제어 발생창고 내부에 쌓인 짐
  • MTU > MSS
    ⇒ IP 계층에서는 MTU 기준으로 패킷 전송, TCP는 MSS 기준으로 세그먼트 생성
    ⇒ 일반적으로 MSS = MTU - 40 (TCP/IP 헤더 합)
  • MSS ↓ → 세그먼트 수 ↑ → 헤더 오버헤드 증가 → 전송 비효율
  • rwnd ↓ or cwnd ↓ → 송신 중단 → Throughput 제한
  • rwnd는 수신자의 버퍼 여유 공간
  • cwnd는 네트워크 혼잡 상황을 고려한 송신자의 자기 조절 크기
  • Send/Receive Buffer는 OS 커널 레벨에서의 물리적 버퍼이며
    TCP 윈도 크기에 영향을 주거나, 애플리케이션 처리 지연 시 병목의 원인이 된다.

TCP는 MSS 단위로 나눠진 데이터를 MTU에 맞게 포장해서, rwnd와 cwnd의 합의점을 고려하며, Send/Receive 버퍼의 상태에 따라 유동적으로 전송량을 조절한다. 이 전 과정은 지연(latency), 손실률(loss rate), 대역폭(bandwidth)을 모두 고려하는 동적인 최적화 과정이며, TCP의 위대함은 바로 이 조율 능력에 있다.


UNIX에서 모든 것은 파일이다?

파일 디스크립터(File Descriptor)는 유닉스 및 리눅스 운영체제에서 프로세스가 입출력 자원(I/O resource)에 접근하기 위해 사용하는 정수형 핸들이다. 이것은 커널 내부의 파일 디스크립터 테이블에서 해당 자원을 가리키는 인덱스 역할을 하며, 파일뿐 아니라 소켓, 파이프, 디바이스, 표준 입출력 등 다양한 자원을 추상화하여 다룰 수 있게 해준다. 예를 들어 open() 시스템 호출을 통해 파일을 열면 커널은 내부에서 파일 구조체를 생성하고, 해당 파일을 가리키는 FD를 사용자 프로그램에 정수값으로 반환한다. 이후 이 FD를 통해 read(), write(), close() 같은 시스템 호출을 사용할 수 있다.

이러한 구조를 바탕으로 유닉스 철학에서는 흔히 “유닉스에서는 모든 것이 파일이다”라고 표현한다. 이는 단순히 일반적인 텍스트 파일뿐 아니라, 소켓, 디바이스 파일(/dev/null 같은 특수 파일), 파이프, 터미널, 심지어 네트워크 연결까지도 모두 파일로 취급된다는 의미다. 이러한 일관된 추상화 덕분에 프로그래머는 다양한 입출력 자원을 동일한 방식으로 접근하고 조작할 수 있다. 실제로 네트워크 프로그래밍에서 socket()으로 생성된 소켓도 내부적으로는 FD로 표현되며, write()나 read()와 같은 함수로 데이터를 송수신할 수 있다.

리눅스 시스템에서는 표준 입력, 출력, 에러 스트림도 각각 FD 0, 1, 2로 관리된다. stdin은 0번, stdout은 1번, stderr는 2번으로, 이 값들을 통해 셸과 프로그램 간의 입력 및 출력 흐름을 제어할 수 있다. 또한 이러한 FD는 프로세스의 복제(fork()), 복사(dup()), 이벤트 기반 I/O(select(), poll() 등)에서 핵심적인 역할을 하며, 유닉스의 강력한 프로세스와 I/O 모델의 기반이 된다.

결과적으로, 파일 디스크립터는 유닉스 시스템의 강력한 I/O 추상화를 가능하게 하는 핵심 개념이며, “모든 것은 파일”이라는 철학은 이 FD 기반 구조 덕분에 구현된다고 할 수 있다.


클라이언트-서버 모델과 소켓 기반 통신 구조

모든 네트워크 애플리케이션은 클라이언트-서버 모델에 기반한다. 이 모델에서 애플리케이션은 서버와 하나 이상의 클라이언트로 구성되며, 서버는 자원을 관리하고 클라이언트에게 서비스를 제공한다. 이 과정은 클라이언트의 요청(request)서버의 응답(response)으로 이루어진 트랜잭션(transaction) 단위로 진행된다.

클라이언트와 서버는 인터넷이라는 글로벌 네트워크를 통해 통신하며, 프로그래머의 관점에서는 인터넷을 다음과 같은 성질을 지닌 전 세계적인 호스트 집합으로 볼 수 있다:

  • 각 인터넷 호스트는 고유한 32비트 IP 주소를 가진다.
  • 이 IP 주소는 도메인 이름과 매핑된다.
  • 서로 다른 호스트에 있는 프로세스끼리 연결을 통해 통신할 수 있다.

이러한 연결(connection)을 만들기 위해 클라이언트와 서버는 소켓(sockets) 인터페이스를 사용한다.
소켓은 연결의 종단점(end point)으로, 애플리케이션에게는 파일 디스크립터 형태로 제공된다.
소켓 인터페이스는 소켓 디스크립터를 열고 닫는 함수들과, 이 디스크립터를 통해 읽고 쓰는 함수들을 제공하여 클라이언트와 서버 간 통신을 가능하게 한다.


네트워크 소켓과 파일 디스크립터: 커널과 사용자 공간의 통신 인터페이스

소켓(Socket)은 네트워크 통신을 위한 논리적 엔드포인트(endpoint)로, 서로 다른 컴퓨터 혹은 같은 컴퓨터 내의 프로세스 간에 데이터를 교환할 수 있도록 운영체제가 제공하는 인터페이스다. 보통 TCP나 UDP 같은 전송 계층 프로토콜 위에서 작동하며, 프로그래머는 이를 통해 마치 파일을 읽고 쓰듯 네트워크 통신을 수행할 수 있다. 실제로 유닉스 시스템에서는 소켓도 하나의 파일처럼 간주되며, read(), write() 같은 시스템 호출을 통해 다룰 수 있다. 이러한 구조는 "유닉스에서는 모든 것이 파일"이라는 설계 철학을 그대로 반영한다.

socket() 함수를 호출하면 운영체제는 커널 내부에 새로운 소켓 객체를 생성하고, 사용자 공간의 애플리케이션에는 정수형 파일 디스크립터(File Descriptor)를 반환한다. 이 디스크립터는 커널의 기술자 테이블 내에 있는 해당 소켓 객체에 대한 포인터 역할을 하며, 애플리케이션은 이 디스크립터를 통해 커널에 작업을 요청한다. 파일과 소켓 모두 같은 기술자 테이블을 공유하며, 예를 들어 파일을 먼저 열면 3번 디스크립터가 할당되고, 그 다음 생성된 소켓은 4번을 사용할 수 있다. 단, 이 번호는 프로세스 내부에서만 고유하면 되기 때문에, 다른 프로세스에서 같은 번호를 사용하는 것은 문제가 되지 않는다.

커널은 각 소켓에 대해 송신 버퍼(send buffer)와 수신 버퍼(receive buffer)를 유지한다. 프로그램이 write()로 데이터를 보내면, 그 데이터는 먼저 커널의 송신 버퍼에 저장되고, 커널이 비동기적으로 네트워크를 통해 전송한다. 반대로 외부에서 도착한 데이터는 수신 버퍼에 임시 저장되며, 애플리케이션이 read()를 호출할 때 이 버퍼에서 데이터를 읽는다. 이처럼 소켓의 I/O는 비동기적으로 동작하며, 속도 차이가 큰 네트워크와 프로세스 간의 데이터 흐름을 안정적으로 중계해준다. 특히 TCP는 이 버퍼를 기반으로 슬라이딩 윈도우, 흐름 제어, 혼잡 제어 등의 메커니즘을 통해 높은 신뢰성을 보장한다.

파일 디스크립터는 유닉스/리눅스 시스템에서 열린 파일, 소켓, 파이프 등을 추상화하여 다루기 위한 정수형 식별자다. 각 프로세스는 자체적인 파일 디스크립터 테이블을 가지며, 그 안에서만 유효한 지역적 식별자로 사용된다. 관례적으로, 운영체제는 프로세스를 시작할 때 표준 입력(0), 표준 출력(1), 표준 에러(2)의 세 가지 스트림을 미리 열어 두기 때문에, 이후 open(), socket() 등을 통해 생성되는 디스크립터는 보통 3번부터 시작하게 된다.

하지만 이 번호는 절대적인 규칙이 아니라 운영체제 구현과 프로세스 상태에 따라 달라질 수 있다. 예를 들어, 어떤 파일 디스크립터가 먼저 닫히면 그 번호가 재활용될 수 있고, dup() 같은 호출을 통해 번호를 직접 조작할 수도 있다. 따라서 올바른 표현은 “일반적으로 3번부터 시작하지만, 항상 그런 것은 아니다”라는 것이다.

한편, 네트워크 관점에서 보면 포트 번호(port number)는 시스템 전체에서 전역적으로 고유한 식별자다. 하나의 컴퓨터에서 여러 소켓이 동시에 존재할 수 있기 때문에, TCP/IP 계층은 포트 번호를 기준으로 수신된 데이터를 어떤 응용 프로그램으로 전달할지를 결정한다. 운영체제는 이 포트 번호에 해당하는 소켓 디스크립터를 찾아서 해당 프로세스에 데이터를 전달한다. 요약하면, 소켓 번호(FD)는 프로세스 내부에서만 의미 있는 지역 식별자, 반면 포트 번호는 네트워크 상에서 프로세스를 식별하기 위한 전역적 식별자다. 이 두 체계를 연결하여 커널은 네트워크와 프로세스 간 안정적이고 유연한 데이터 통신을 중개한다.

소켓 쌍(socket pair)은 TCP 연결을 고유하게 식별하기 위한 주소 쌍으로, 항상 (클라이언트 IP:포트, 서버 IP:포트)(cliaddr:cliport, servaddr:servport) 형태의 튜플로 구성된다. 이 네 가지 요소를 결합한 소켓 주소 쌍은 각각의 연결을 구분하는 유일한 식별자로 작용하며, 커넥션 소켓 쌍(connection socket pair) 또는 소켓 엔드포인트 쌍이라고도 불린다. TCP는 이러한 쌍을 기준으로 수많은 연결을 동시에 관리하고 추적할 수 있으며, 이를 통해 동일한 서버 포트로 들어오는 여러 클라이언트 요청을 충돌 없이 구분할 수 있다.


소켓 인터페이스

소켓 인터페이스(Socket interface)는 네트워크 애플리케이션을 만들기 위해 UNIX I/O 함수들과 함께 사용되는 함수들의 집합이다. 각각의 함수들에 대한 설명은 CS:APP 책의 순서를 따라 작성했지만, 구조 그림을 먼저 이해하고 이게 client쪽 함수인지, server쪽 함수인지 인지한 상태로 학습하는 게 좋을 것이다.

함수 이름사용 위치주요 역할
getaddrinfo클라이언트/서버호스트명과 포트명을 소켓 주소 정보로 변환하는 함수
socket클라이언트/서버소켓(통신 엔드포인트)을 생성하는 함수
connect클라이언트서버에 연결 요청을 보내는 함수
bind서버소켓에 IP 주소와 포트번호를 할당하는 함수
listen서버소켓을 연결 요청 수신 대기 상태로 전환하는 함수
accept서버클라이언트의 연결 요청을 수락하고 통신 소켓을 생성하는 함수
rio_writen클라이언트/서버데이터를 소켓을 통해 전송하는 함수
rio_readlineb클라이언트/서버소켓에서 한 줄씩 데이터를 읽는 함수
close클라이언트/서버소켓 연결을 종료하는 함수
open_clientfd클라이언트클라이언트 소켓을 생성하고 서버에 연결하는 헬퍼 함수 (getaddrinfo → socket → connect)
open_listenfd서버서버용 소켓을 생성하고 수신 대기 상태로 설정하는 헬퍼 함수 (getaddrinfo → socket → bind → listen)

소켓 주소 구조체

/* IP socket address structure */
struct sockaddr_in {
    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) */
};

/* Generic socket address structure (for connect, bind, and accept) */
struct sockaddr {
    uint16_t sa_family; /* Protocol family */
    char sa_data[14]; /* Address data */
};
  • sockaddr_in은 IPv4용 주소 구조체, sockaddr은 범용 인터페이스용
  • connect, bind, accept는 sockaddr 를 인자로 받기 때문에 sockaddr_in 을 캐스팅해서 사용
  • 보통 typedef struct sockaddr SA;로 단순화해서 사용

socket 함수

#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
// Returns: nonnegative descriptor if OK, −1 on error
  • 소켓을 생성하고 식별자(fd)를 반환
  • 인자로 주소 체계(AF_INET), 타입(SOCK_STREAM), 프로토콜(0)을 설정
  • 이 함수는 소켓만 만들 뿐 연결은 하지 않음

connect 함수

#include <sys/socket.h>
int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
// Returns: 0 if OK, −1 on error
  • 클라이언트가 서버로 연결 요청을 보냄
  • 호출이 성공하면 해당 소켓을 통해 데이터 송수신 가능
  • 연결 식별자는 (클라이언트 IP:포트, 서버 IP:포트)로 구성됨

bind 함수

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// Returns: 0 if OK, −1 on error
  • 소켓에 로컬 주소(IP와 포트)를 명시적으로 할당
  • 서버는 bind를 통해 자신이 어떤 포트에서 요청을 받을지 지정함
  • 포트 충돌 방지를 위해 SO_REUSEADDR 옵션을 사용하는 경우도 많음

listen 함수

서버가 listen()을 호출하면 커널은 두 개의 큐를 생성한다. 첫 번째는 incomplete connection queue로, 아직 3-way handshake가 완료되지 않은 클라이언트들의 SYN 요청을 임시 저장한다. 클라이언트가 SYN을 보내면 서버는 자동으로 SYN-ACK를 응답하고, 클라이언트가 최종 ACK를 보내 3-way handshake가 완료되면 해당 연결은 completed connection queue (accept queue)로 이동된다. 이후 서버가 accept()를 호출하면, 이 큐에서 가장 오래된 연결을 꺼내고, 이 연결을 위한 새로운 소켓 디스크립터를 반환한다. 이 새 소켓은 고유한 송수신 버퍼를 커널 공간에 가지며, 클라이언트와의 실제 데이터 전송은 이 디스크립터를 통해 이루어진다.

#include <sys/socket.h>
int listen(int sockfd, int backlog);
// Returns: 0 if OK, −1 on error
  • bind()로 로컬 주소에 연결된 소켓을 수신 대기 상태로 전환
  • sockfd: socket()으로 생성한 소켓의 파일 디스크립터
  • backlog 인자는 커널이 동시에 큐에 저장할 수 있는 연결 요청 수 지정
    (이 값은 completed queue의 크기에 영향을 주며, 일반적으로 SYN queue와 합쳐서 내부적으로 조절되기도 함)
  • 이 호출 이후에야 클라이언트의 요청을 받을 수 있음

accept 함수

(1) 서버: accept() 호출로 블로킹 중
	listenfd는 리스닝 소켓으로 클라이언트의 연결 요청을 기다린다.
	그림에서 listenfd(3)은 파일 디스크립터 번호 3번으로 열려 있음.
(2) 클라이언트: connect() 호출
	클라이언트는 clientfd를 통해 서버의 IP와 포트로 SYN을 전송한다.
	서버 커널은 해당 요청을 **incomplete queue(SYN 큐)**에 저장한다.
(3) 3-way handshake 완료 후
	클라이언트가 마지막 ACK를 보내고, 서버는 이 연결을 completed queue로 이동시킨다.
	이제 accept()는 대기 중이던 연결을 수락하고, **connfd(4)**라는 새 파일 디스크립터를 반환한다.
	connfd는 클라이언트와의 연결을 위한 전용 소켓이다.

블로킹은 어떤 함수 호출이 결과가 준비될 때까지 멈춰있는 상태를 의미한다.
이 그림에서 accept() 함수는 블로킹 호출이다.
즉, 클라이언트가 연결 요청을 보내기 전까지는 accept()가 리턴되지 않고 기다린다. 연결 요청이 오고 3-way handshake가 끝나야만 accept()가 연결된 소켓 디스크립터를 반환하면서 깨어난다.

#include <sys/socket.h>
int accept(int listenfd, struct sockaddr *addr, int *addrlen);
// Returns: nonnegative connected descriptor if OK, −1 on error
  • 클라이언트의 연결 요청을 수락하고, 새로운 소켓을 생성해 반환
  • 리스닝 소켓과는 별개로 통신 전용 소켓(connfd)을 만들어줌
  • 요청이 없으면 차단 상태로 대기함

호스트와 서비스 변환 함수

getaddrinfo

getaddrinfo() 함수는 문자열 기반의 호스트명(예: "www.google.com")과 서비스명(예: "http")을 받아 내부적으로 DNS 프로토콜을 통해 해당 도메인의 IP 주소 정보를 조회한다. 이 과정에서 glibc 라이브러리의 내부 함수들이 DNS 질의를 수행하고, 응답을 파싱한 뒤, 결과를 표현할 수 있도록 struct addrinfo 구조체들의 연결 리스트를 동적으로 생성한다.

이 구조체들은 malloc()을 통해 사용자 프로세스의 heap 영역에 동적으로 할당되며, 이 메모리는 가상 주소 공간 상에 위치한다. 운영체제 커널은 이 가상 주소 공간을 실제 물리 메모리(RAM)의 페이지에 매핑함으로써 사용자 프로그램이 해당 주소를 통해 안전하게 접근할 수 있도록 한다. 따라서 getaddrinfo()로 얻은 결과는 DNS 프로토콜 기반으로 조회된 IP 정보가 heap에 저장된 구조체로 변환된 형태이며, 사용이 끝난 후에는 반드시 freeaddrinfo()로 메모리를 해제해야 한다.

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

int getaddrinfo(const char *host, const char *service, const struct addrinfo *hints, struct addrinfo **result);
// Returns: 0 if OK, nonzero error code on error
struct addrinfo {
    int              ai_flags;      // 힌트 플래그
    int              ai_family;     // 주소 체계 (AF_INET 등)
    int              ai_socktype;   // 소켓 타입 (SOCK_STREAM 등)
    int              ai_protocol;   // 프로토콜 (보통 0)
    size_t           ai_addrlen;    // ai_addr의 크기
    struct sockaddr *ai_addr;      // 실제 주소 정보
    char            *ai_canonname;  // 호스트의 정식 이름
    struct addrinfo *ai_next;       // 다음 항목 포인터 (연결 리스트)
};
  • 문자열 기반의 호스트명(host)과 서비스명(service)을 소켓 주소 구조체 리스트로 변환하는 함수
  • hints를 통해 주소 체계, 소켓 타입 등의 조건을 설정할 수 있음
  • result에는 연결 리스트 형태의 주소 목록이 반환됨
  • 반드시 freeaddrinfo()로 메모리 해제 필요
  • 재진입 가능(Reentrant), IPv4/IPv6, TCP/UDP 지원

freeaddrinfo

void freeaddrinfo(struct addrinfo *result);
// Returns: nothing
  • getaddrinfo로 생성된 주소 리스트의 메모리를 해제하는 함수

gai_strerror

const char *gai_strerror(int errcode);
// Returns: error message
  • getaddrinfo, getnameinfo 등에서 발생한 에러 코드를 문자열 메시지로 변환해줌

getnameinfo

#include <sys/socket.h>
#include <netdb.h>
int getnameinfo(const struct sockaddr *sa, socklen_t salen, char *host, size_t hostlen, char *service, size_t servlen, int flags);
// Returns: 0 if OK, nonzero error code on error
  • getaddrinfo의 반대 역할 수행: 소켓 주소 → 호스트명 및 서비스명 문자열로 변환
  • flags에 따라 IP 문자열 또는 포트 숫자를 그대로 반환 가능 (예: NI_NUMERICHOST, NI_NUMERICSERV)
  • 재진입 가능, 프로토콜 독립적

소켓 인터페이스를 위한 도움 함수들

open_clientfd

#include "csapp.h"
int open_clientfd(char *hostname, char *port);
// Returns: descriptor if OK, −1 on error

int open_clientfd(char *hostname, char *port) {
    int clientfd;
    struct addrinfo hints, *listp, *p;

    /* Get a list of potential server addresses */
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM; /* Open a connection */
    hints.ai_flags = AI_NUMERICSERV; /* ... using a numeric port arg. */
    hints.ai_flags |= AI_ADDRCONFIG; /* Recommended for connections */
    Getaddrinfo(hostname, port, &hints, &listp);

    /* Walk the list for one that we can successfully connect to */
    for (p = listp; p; p = p->ai_next) {
        /* Create a socket descriptor */
        if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
        continue; /* Socket failed, try the next */

        /* Connect to the server */
        if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1)
            break; /* Success */
        Close(clientfd); /* Connect failed, try another */
    }

    /* Clean up */
    Freeaddrinfo(listp);
    if (!p) /* All connects failed */
    	return -1;
    else /* The last connect succeeded */
    	return clientfd;
}
  • 클라이언트 측에서 서버로의 TCP 연결을 수행하는 고수준 함수
  • 내부 동작:
    1. getaddrinfo로 주소 리스트 획득
    2. 각 주소에 대해 socket 생성 후 connect 시도
    3. 성공 시 연결된 소켓 디스크립터 반환
    4. 실패 시 −1 반환
  • 여러 주소 중 하나라도 연결되면 성공 처리
  • 포트는 숫자 문자열로 입력해야 함 (AI_NUMERICSERV)

open_listenfd

#include "csapp.h"
int open_listenfd(char *port); // Returns: descriptor if OK, −1 on error

int open_listenfd(char *port) {
    struct addrinfo hints, *listp, *p;
    int listenfd, optval=1;

    /* Get a list of potential server addresses */
    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_socktype = SOCK_STREAM; /* Accept connections */
    hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* ... on any IP address */
    hints.ai_flags |= AI_NUMERICSERV; /* ... using port number */
    Getaddrinfo(NULL, port, &hints, &listp);

    /* Walk the list for one that we can bind to */
    for (p = listp; p; p = p->ai_next) {
        /* Create a socket descriptor */
        if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
        	continue; /* Socket failed, try the next */

        /* Eliminates "Address already in use" error from bind */
        Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (const void *)&optval , sizeof(int));

        /* Bind the descriptor to the address */
        if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0)
        	break; /* Success */
        Close(listenfd); /* Bind failed, try the next */
    }

    /* Clean up */
    Freeaddrinfo(listp);
    if (!p) /* No address worked */
        return -1;

    /* Make it a listening socket ready to accept connection requests */
    if (listen(listenfd, LISTENQ) < 0) {
        Close(listenfd);
        return -1;
    }
    return listenfd;
}
  • 서버 측에서 수신 대기 상태로 만들 수 있는 TCP 리스닝 소켓 생성 함수
  • 내부 동작:
    1. getaddrinfo로 모든 수신 가능한 주소 목록 획득 (host는 NULL, AI_PASSIVE)
    2. 각 주소에 대해 socket 생성 및 bind 시도
    3. SO_REUSEADDR 옵션 설정으로 주소 재사용 가능
    4. 성공적으로 바인딩된 경우 listen 호출
    5. 리스닝 소켓 디스크립터 반환 또는 실패 시 −1 반환
  • IP 버전 독립적 (IPv4/IPv6)

요약 비교

함수명역할내부 구성 함수특징
getaddrinfo도메인/포트 → sockaddr 구조체없음멀티 프로토콜, 재진입 가능
getnameinfosockaddr → 문자열 변환없음역변환, 플래그로 세부 제어 가능
open_clientfd클라이언트 연결 시도getaddrinfo → socket → connect실패 시 fallback 주소 시도
open_listenfd서버 리스닝 소켓 설정getaddrinfo → socket → bind → listen주소 재사용, 수신 전용 설정

Echo Client and Server 만들기

정글에서 docker를 통해 작업을 하고 있다면 환경 구축에 있어 아래의 글이 도움이 될 것이다.
at_this_moment님이 작성하신 글

CS:APP을 따라 작성한 코드와 각각의 코드에 대한 설명은 아래와 같다.

// echo.c

#include "csapp.h" // 표준 라이브러리와 에러 처리, IO 처리를 포함하는 사용자 정의 헤더

void echo(int connfd)
{
    size_t n;
    char buf[MAXLINE]; // 클라이언트로부터 읽어올 데이터를 저장할 버퍼 (최대 MAXLINE 바이트)
    rio_t rio; // Robust I/O 처리를 위한 구조체 변수 (연결된 파일 디스크립터, 내부적으로 사용할 읽기 버퍼, 현재 버퍼 내 위치, 남은 바이트 수 등 저장)

    Rio_readinitb(&rio, connfd);
    // connfd에 연결된 소켓을 rio 객체에 연결해서, 그걸로 안전하게 읽겠다는 준비 작업임임
    // rio 구조체를 초기화, 어떤 소켓 디스크립터(connfd)에서 읽을지 설정
    // 이 함수를 호출한 이후, rio를 통해 connfd에서 데이터를 읽을 수 있음. Rio_readlineb() 같은 함수들이 이 rio 구조체를 활용해서 읽기를 수행

    while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) {
        // 클라이언트로부터 한 줄씩 입력을 읽음
        // 0을 반환하면 EOF (연결 종료)

        printf("server received %zu bytes\n", n);
        // 몇 바이트를 수신했는지 출력 (디버깅용)

        Rio_writen(connfd, buf, n);
        // 받은 내용을 클라이언트에게 그대로 다시 전송 (에코)
    }
}

// rio를 쓰는 이유
// 일반적인 read() 호출은 버퍼링이 없고 부분 읽기(partial read)가 발생할 수 있어 번거로움
// RIO는 내부 버퍼를 활용해 라인 단위 또는 지정된 바이트 수만큼 안정적으로 읽을 수 있도록 도와줌
// 줄 단위로 데이터를 주고받는 TCP 서버를 만들 때 매우 유용
// echoclient.c

#include "csapp.h" // CS:APP에서 제공하는 래퍼 함수들과 상수, 자료구조가 정의된 헤더 파일 포함

int main(int argc, char **argv)
{
    int clientfd;                  // 서버와의 연결에 사용할 소켓 디스크립터
    char *host, *port, buf[MAXLINE]; // 서버 호스트명, 포트번호, 입출력 버퍼
    rio_t rio;                     // 버퍼링된 입출력용 구조체

    if (argc != 3) // 명령행 인자가 3개가 아닌 경우 (프로그램 이름 + 호스트 + 포트), 사용법 출력
    {
        fprintf(stderr, "usage: %s <host> <port>\n", argv[0]);
        exit(0);
    }

    host = argv[1]; // 첫 번째 인자는 접속할 서버의 호스트 주소
    port = argv[2]; // 두 번째 인자는 접속할 포트 번호

    clientfd = Open_clientfd(host, port); // 서버에 연결하고, 연결된 소켓의 디스크립터를 clientfd에 저장, 실패 시 −1 반환 (내부적으로 getaddrinfo, socket, connect 사용)
    Rio_readinitb(&rio, clientfd); // clientfd를 기반으로 버퍼링된 읽기 구조체 rio를 초기화, 이후 Rio_readlineb()로 안전한 라인 기반 읽기가 가능함

    while (Fgets(buf, MAXLINE, stdin) != NULL)  // 표준 입력으로부터 한 줄 입력을 받아 buf에 저장 (ex. 사용자 타이핑)
    {

        Rio_writen(clientfd, buf, strlen(buf));  // buf 내용을 서버에 전송 (clientfd는 서버와 연결된 소켓), strlen으로 정확한 바이트 수만큼 전송
        Rio_readlineb(&rio, buf, MAXLINE);  // 서버로부터 한 줄 응답을 읽어 buf에 저장, rio는 내부적으로 버퍼를 사용하여 read 호출을 최적화함
        Fputs(buf, stdout);   // 서버로부터 받은 응답을 표준 출력에 출력 (즉, 사용자에게 보여줌)
    }

    Close(clientfd);  // 모든 작업이 끝난 후 소켓을 닫아 리소스를 정리
    exit(0);          // 프로그램 정상 종료
}
// echoserveri.c

#include "csapp.h" // csapp 라이브러리에는 에러 처리 및 입출력 관련 유틸 함수가 정의되어 있음

void echo(int connfd); // 클라이언트와 연결된 소켓을 통해 echo 동작을 수행하는 함수 (정의는 echo.c에 있음)

int main(int argc, char **argv) // argc : argument count(인자의 개수), argv : argument vector(입력된 인자 문자열 배열)
{
    int listenfd, connfd;
    // listenfd: 리스닝 소켓의 파일 디스크립터(File Descriptor) - 서버에서 단 하나, 클라이언트의 연결 요청을 기다림
    //           서버가 socket() → bind() → listen()을 통해 생성, 클라이언트의 연결 요청을 기다리는 수동 소켓(passive socket), 단 하나만 만들어서 accept()에 계속 넘겨줌
    // connfd: accept() 호출 결과로 반환되는 클라이언트와 연결된 소켓 디스크립터 - 클라이언트마다 하나씩, 각각의 연결을 위해 따로 생성됨
    //         이 소켓은 특정 클라이언트와의 통신 전용으로 사용됨, read(), write() 또는 echo() 함수 내부에서 메시지를 주고받을 때 사용
    // 이 두 개는 둘 다 소켓이며, 내부적으로는 유닉스 커널이 관리하는 파일 디스크립터임. 네트워크 연결도 마치 "파일"처럼 다룰 수 있다는 UNIX 철학
    socklen_t clientlen; // clientaddr의 구조체 크기를 담는 변수, accept()에서 클라이언트 주소를 받기 위해 주소 크기를 미리 알려주는 용도
    struct sockaddr_storage clientaddr; // 클라이언트의 주소 정보를 담을 범용 구조체(sockaddr_in, sockaddr_in6 모두 수용), 나중에 getnameinfo()로 IP 주소나 도메인명을 얻는 데 사용됨
    char client_hostname[MAXLINE], client_port[MAXLINE]; // 클라이언트의 호스트 이름(또는 IP 문자열)과 클라이언트가 접속한 포트 번호 문자열을 저장하는 버퍼, getnameinfo()를 통해 clientaddr에서 추출됨

    if (argc != 2)  // 포트 번호를 명령행 인자로 받지 않으면 사용법 출력 후 종료
    {
        fprintf(stderr, "usage: %s <port>\n", argv[0]);
        exit(0);
    }
    // ./echoserveri 8080라는 명령을 받으면, 인자가 2개임("./echoserveri"(argv[0]), "8080"(argv[1]))

    listenfd = Open_listenfd(argv[1]);  // 해당 포트에 바인딩된 리스닝 소켓을 생성하고, 그 디스크립터를 반환함(클라이언트 연결 요청을 받는 데 사용)
    while (1) 
    {
        clientlen = sizeof(struct sockaddr_storage);  // 클라이언트 주소 정보를 받을 구조체 크기 설정
        connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);  // 클라이언트 연결 수락. connfd는 클라이언트와 통신할 소켓
        Getnameinfo((SA *) &clientaddr, clientlen, client_hostname, MAXLINE, client_port, MAXLINE, 0);  // 클라이언트의 IP 주소와 포트 번호를 문자열로 변환
        printf("Connected to (%s, %s)\n", client_hostname, client_port);  // 클라이언트 정보 출력
        echo(connfd); // echo 서비스 수행 (받은 내용을 그대로 돌려줌)
        Close(connfd); // 클라이언트 연결 종료
    }
    exit(0);
}

// 단일 프로세스 기반의 TCP 에코 서버
// 클라이언트와 연결을 수락한 후, 해당 연결에 대해 echo() 함수를 실행하고, 종료되면 연결을 닫고 다음 연결을 수락
// Accept() → echo() → Close()의 순환을 무한 반복하는 구조
profile
멋있는 사람이 되는 게 꿈입니다

0개의 댓글