[JUNGLE] TIL_49. CSAPP 11장

모깅·2025년 10월 31일

JUNGLE

목록 보기
50/56
post-thumbnail

11. 네트워크 애플리케이션 (Network Applications)

웹 브라우징, 이메일 전송, 온라인 게임 등 네트워크 애플리케이션은 어디에나 있습니다. 흥미롭게도, 모든 네트워크 애플리케이션은 동일한 기본 프로그래밍 모델, 유사한 전체 논리 구조, 그리고 동일한 프로그래밍 인터페이스를 기반으로 합니다.

네트워크 애플리케이션은 이미 시스템 공부에서 배운 많은 개념(예: 프로세스, 신호, 바이트 순서(byte ordering), 메모리 매핑, 동적 저장소 할당)에 의존합니다.

물론 마스터해야 할 새로운 개념도 있습니다.

  • 기본적인 클라이언트-서버 프로그래밍 모델을 이해해야 합니다.
  • 인터넷이 제공하는 서비스를 사용하는 클라이언트-서버 프로그램을 작성하는 방법을 배워야 합니다.

마지막에는 이 모든 아이디어를 종합하여, 실제 웹 브라우저에 텍스트와 그래픽으로 정적(static) 및 동적(dynamic) 콘텐츠를 모두 제공할 수 있는, 작지만 기능적인 웹 서버를 개발할 것입니다.

11.1 클라이언트-서버 프로그래밍 모델 (The Client-Server Programming Model)

모든 네트워크 애플리케이션은 클라이언트-서버 모델을 기반으로 합니다. 이 모델에서 애플리케이션은 하나의 서버 프로세스(server process)와 하나 이상의 클라이언트 프로세스(client process)로 구성됩니다.

  • 서버: 특정 리소스(resource)를 관리하며, 그 리소스를 조작하여 클라이언트에게 서비스를 제공합니다.
    • 예 (웹 서버): 클라이언트를 대신하여 디스크 파일 세트를 검색하고 실행합니다.
    • 예 (FTP 서버): 클라이언트를 위해 디스크 파일 세트를 저장하고 검색합니다.
    • 예 (이메일 서버): 클라이언트를 위해 스풀 파일(spool file)을 읽고 업데이트합니다.

클라이언트-서버 트랜잭션 (Client-Server Transaction)

클라이언트-서버 모델의 기본 동작은 트랜잭션(transaction)입니다 (그림 11.1). 트랜잭션은 4단계로 구성됩니다.

  1. 클라이언트 요청 (Request)
    • 클라이언트가 서비스가 필요할 때, 서버에 요청을 보내 트랜잭션을 시작합니다.
    • (예: 웹 브라우저가 웹 서버에 파일을 요청)
  2. 서버 처리 (Process)
    • 서버는 요청을 수신하고, 해석하며, 리소스를 적절한 방식으로 조작(manipulate)합니다.
    • (예: 웹 서버가 디스크 파일을 읽음)
  3. 서버 응답 (Response)
    • 서버는 클라이언트에게 응답을 보낸 후, 다음 요청을 기다립니다.
    • (예: 웹 서버가 파일을 클라이언트에게 다시 보냄)
  4. 클라이언트 처리 (Process)
    • 클라이언트는 응답을 수신하고 처리(manipulate)합니다.
    • (예: 웹 브라우저가 응답으로 받은 페이지를 화면에 표시)

중요: 프로세스 vs 머신

클라이언트와 서버는 (이 문맥에서 종종 호스트(host)라고 불리는) 머신(machine)이 아니라 프로세스(process)라는 것을 인지하는 것이 중요합니다.

  • 하나의 호스트(머신)는 동시에 많은 다른 클라이언트와 서버를 실행할 수 있습니다.
  • 클라이언트와 서버 트랜잭션은 동일한 호스트 또는 서로 다른 호스트에서 발생할 수 있습니다.
  • 클라이언트와 서버가 호스트에 어떻게 매핑(mapping)되는지와 관계없이, 클라이언트-서버 모델은 동일합니다.

11.2 네트워크 (Networks)

클라이언트와 서버는 종종 분리된 호스트에서 실행되며, 컴퓨터 네트워크의 하드웨어 및 소프트웨어 자원을 사용해 통신합니다. 네트워크는 정교한 시스템이며, 여기서는 프로그래머의 관점에서 작동 가능한 mental model을 제공하는 것이 목표입니다.

네트워크 = I/O 장치

호스트에게 네트워크는 데이터를 위한 소스(source)이자 싱크(sink) 역할을 하는 또 하나의 I/O 장치일 뿐입니다 (그림 11.2).

  • I/O 버스의 확장 슬롯에 꽂힌 어댑터(adapter)가 네트워크로의 물리적 인터페이스를 제공합니다.
  • 네트워크에서 수신된 데이터는 어댑터에서 I/O 및 메모리 버스를 거쳐 메모리로 복사됩니다 (일반적으로 DMA 전송).
  • 반대로, 데이터는 메모리에서 네트워크로 복사될 수도 있습니다.

물리적 계층 구조

물리적으로 네트워크는 지리적 근접성에 따라 구성된 계층적 시스템입니다.

1. LAN (Local Area Network)

  • 가장 낮은 수준은 건물이나 캠퍼스에 걸친 LAN(근거리 통신망)입니다.
  • 가장 널리 쓰이는 LAN 기술은 이더넷(Ethernet)입니다. (3Mb/s에서 10Gb/s까지 발전)

2. 이더넷 세그먼트 (Ethernet Segment)

  • 구성: 이더넷 세그먼트는 몇 개의 와이어(wire)와 허브(hub)라는 작은 상자로 구성됩니다 (그림 11.3).
  • 허브 (Hub): 허브는 한 포트에서 수신한 모든 비트를 다른 모든 포트로 맹목적으로 복사합니다. 따라서 모든 호스트가 모든 비트를 보게 됩니다.
  • 프레임 (Frame):
    • 각 이더넷 어댑터는 고유한 48비트 주소(MAC 주소)를 가집니다.
    • 호스트는 프레임(frame)이라는 비트 덩어리를 세그먼트 내의 다른 호스트로 보낼 수 있습니다.
    • 프레임은 [헤더 (송/수신 주소, 길이), 페이로드 (데이터)]로 구성됩니다.
    • 모든 호스트 어댑터가 프레임을 보지만, 오직 목적지 호스트만이 그 프레임을 실제로 읽습니다.
  • Q. 이더넷이란? 이더넷(Ethernet)은 유선 근거리 통신망(LAN)을 구축하기 위한 가장 보편적인 기술 표준입니다. 우리가 흔히 "랜선"이라고 부르는 케이블을 사용하여 컴퓨터, 프린터, 스위치 등의 장치들을 서로 연결하고 데이터를 주고받을 수 있도록 하는 하드웨어 명세와 통신 규칙(프로토콜)의 집합입니다.

    1. 물리적 구성 (하드웨어)

    이더넷은 데이터를 전송하기 위한 물리적인 매체와 연결 장치를 정의합니다.
    • 케이블: 주로 UTP(Unshielded Twisted Pair, 꼬임쌍선) 케이블을 사용합니다. (이것이 우리가 "랜선"이라고 부르는 것입니다.)

    • 네트워크 어댑터 (랜 카드): 모든 호스트(PC, 노트북 등)에 장착되며, 데이터를 프레임으로 만들거나 해석합니다.

    • 연결 장치 (허브 vs 스위치):
      - 허브 (Hub): 11.2에서 언급된 구형 장치입니다. 한 포트에서 들어온 데이터를 모든 포트로 맹목적으로 복사하여 전송합니다. (비효율적)
      - 스위치 (Switch): 현재의 표준 장치입니다. 11.2의 브리지(Bridge)와 유사하게 작동하며, 각 포트에 연결된 장치의 MAC 주소를 학습합니다. 데이터가 들어오면 목적지 MAC 주소가 연결된 포트로만 프레임을 선택적으로 전송하여 효율이 매우 높습니다.


      2. 프로토콜 (규칙)

      이더넷은 LAN 내부에서 장치들이 "어떻게" 통신할지 정의합니다.

    • MAC 주소 (Media Access Control Address)

      • 본문에서 "고유한 48비트 주소"로 언급된 것입니다.
      • 전 세계 모든 네트워크 어댑터(랜 카드)를 고유하게 식별하는 하드웨어 주소입니다.
      • LAN(로컬 네트워크) 내부에서 통신할 때 "누가 누구에게" 보내는지 정확히 식별하는 주소로 사용됩니다.
    • 프레임 (Frame)
      - 이더넷에서 데이터를 주고받는 기본 패키지 단위입니다.
      - 본문 11.2에서 설명한 것처럼 [헤더 + 페이로드] 구조를 가집니다.
      - 이더넷 헤더에는 목적지 MAC 주소출발지 MAC 주소가 포함됩니다.
      - (참고: 11.2의 캡슐화 예시처럼, 인터넷 통신을 할 때는 IP 패킷이 이 이더넷 프레임의 '페이로드' 부분에 담겨서 전송됩니다.)

      요약하자면, 이더넷은 "같은 LAN 안에서, MAC 주소를 사용해, 프레임이라는 패키지로, 스위치를 통해 데이터를 전달하는 유선 통신 표준"입니다.

  • Q. 스위치의 MAC 주소 학습 방법 스위치는 '우편물 분류기'처럼 행동하며, 포트로 들어오는 트래픽(프레임)을 엿보는 방식으로 MAC 주소를 학습합니다. 스위치 내부에는 'MAC 주소 테이블'(MAC Address Table)이라는 메모리 공간이 있으며, 이 테이블은 [MAC 주소 | 포트 번호]를 한 쌍으로 저장합니다. 이 학습과 처리 과정은 두 부분으로 나뉩니다.

    1. 학습: 출발지 주소로 배운다 (Learning)

    스위치는 수동적으로 학습합니다. 누군가 스위치에 "A는 1번 포트에 있다"고 미리 알려주지 않습니다.
    1. 네트워크의 컴퓨터 A(MAC 주소: AA-AA)가 스위치의 1번 포트에 연결되어 있다고 가정합니다.

    2. A가 컴퓨터 B에게 데이터를 보내기 위해 프레임(frame)을 생성합니다. 이 프레임의 헤더에는 [출발지: AA-AA | 목적지: BB-BB]가 기록됩니다.

    3. 프레임이 1번 포트로 들어오는 순간, 스위치는 프레임 헤더를 엿봅니다.

    4. 스위치는 '출발지(Source) MAC 주소'AA-AA를 확인합니다.

    5. 스위치는 "아, AA-AA라는 MAC 주소는 1번 포트에 연결되어 있구나!"라는 사실을 알게 됩니다.

    6. 이 정보를 자신의 MAC 주소 테이블에 즉시 기록(갱신)합니다.

      MAC 주소포트 번호
      AA-AA1

      2. 전달: 목적지 주소로 보낸다 (Forwarding)

      학습의 목적은 데이터를 효율적으로 전달하기 위함입니다.

    7. 이제 컴퓨터 B(MAC 주소: BB-BB, 포트: 2번)가 A에게 답장을 보낸다고 가정합니다.

    8. B가 보낸 프레임 [출발지: BB-BB | 목적지: AA-AA]가 2번 포트로 들어옵니다.

    9. (학습) 스위치는 출발지 BB-BB를 보고 "아, BB-BB는 2번 포트에 있구나!"라고 테이블에 기록합니다.

    10. (전달) 스위치는 '목적지(Destination) MAC 주소'AA-AA를 봅니다.

    11. 스위치가 자신의 MAC 주소 테이블을 검색합니다.

    12. 테이블에 AA-AA는 1번 포트에 있다고 기록되어 있습니다.

    13. 스위치는 이 프레임을 다른 모든 포트가 아닌 오직 1번 포트로만 정확하게 전송(Forwarding)합니다. (허브처럼 모든 곳에 뿌리지 않습니다.)


      만약 목적지를 모른다면? (Flooding)

      맨 처음, 스위치의 MAC 주소 테이블은 비어있습니다.

      이때 A(AA-AA)가 B(BB-BB)에게 프레임을 보내면, 스위치는 AA-AA가 1번 포트에 있음을 학습합니다.

      하지만 목적지인 BB-BB가 테이블에 없습니다. 스위치는 이 프레임을 어디로 보내야 할지 모릅니다. 이때 스위치는 허브(Hub)처럼 작동합니다.

      프레임이 들어온 1번 포트를 제외한 모든 포트로 이 프레임을 복제해서 뿌립니다 (Flooding).

      B를 제외한 모든 컴퓨터는 "이거 내 거 아니네" 하고 프레임을 무시하지만, B는 "내 거다!" 하고 프레임을 받습니다. 그리고 B가 답장을 보내는 순간, 스위치는 B의 위치(BB-BB는 2번 포트)를 학습하게 됩니다.

      이 MAC 주소 테이블에 대해 더 궁금한 점이나, IP 주소와 MAC 주소가 함께 작동하는 방식(ARP)에 대해 이어서 설명해 드릴까요?

  • Q. IP 주소와 MAC 주소가 함께 작동하는 방식 : ARP ARP는 Address Resolution Protocol (주소 결정 프로토콜)의 약자입니다. 가장 간단하게 말해서, 같은 동네(로컬 네트워크)에서 특정 IP 주소를 가진 컴퓨터의 MAC 주소가 무엇인지 물어보는 "탐정" 같은 역할을 합니다.

    IP 주소 → MAC 주소 찾는 프로토콜


    ARP가 왜 필요한가요? (IP와 MAC의 관계)

    이전 대화에서 스위치는 MAC 주소를 기반으로 데이터를 전달한다고 배웠습니다. MAC 주소는 하드웨어의 "물리적 주소"입니다. 하지만 사용자는 보통 192.168.0.10 같은 IP 주소를 사용합니다. 이것은 "논리적 주소"입니다. 문제가 발생합니다:
    제 컴퓨터(A)가 192.168.0.10(B)에게 데이터를 보내고 싶은데, 스위치에게 데이터를 전달하려면 B의 MAC 주소를 알아야만 이더넷 프레임 [헤더]에 적어 보낼 수 있습니다. ARP의 역할:
    이때 컴퓨터 A가 "IP 주소 192.168.0.10을 쓰시는 분, MAC 주소가 어떻게 되시나요?"라고 물어보는 데 사용하는 프로토콜이 바로 ARP입니다.

    ARP 동작 방식 (Request & Reply)

    ARP는 "요청(Request)"과 "응답(Reply)" 두 단계로 작동합니다. [ 시나리오: PC A (192.168.0.5)가 PC B (192.168.0.10)에게 데이터를 보내려 함 ]
    1. ARP 캐시 확인 (Cache Check)

      • PC A는 먼저 자신의 ARP 캐시(최근에 알아낸 IP-MAC 매핑 정보가 저장된 작은 메모리)를 확인합니다.
      • "내가 192.168.0.10의 MAC 주소를 이미 알고 있나?"
      • (알고 있다면): 바로 그 MAC 주소를 사용해 데이터를 전송합니다. (ARP 과정 끝)
      • (모른다면): 2단계로 진행합니다.
    2. ARP Request (Broadcast)

      • PC A가 브로드캐스트(Broadcast) 패킷을 만듭니다.
      • 내용: "IP 192.168.0.10을 가진 분, 당신의 MAC 주소를 192.168.0.5(나)에게 알려주세요."
      • 목적지 MAC 주소: FF:FF:FF:FF:FF:FF (이것이 "모두에게"라는 뜻의 특수 주소입니다.)
      • 스위치는 이 브로드캐스트 프레임을 받으면, 프레임이 들어온 포트를 제외한 연결된 모든 포트로 이 요청을 뿌립니다(Flooding).
    3. 수신 및 응답 결정

      • 네트워크상의 모든 컴퓨터(PC B, C, D...)가 이 요청을 받습니다.
      • PC C, D: "이거 내 IP 주소(192.168.0.10) 찾는 거 아니네." ➔ 요청을 무시(discard)합니다.
      • PC B: "어, 내 IP 주소(192.168.0.10)를 찾네!" ➔ 응답(Reply)을 준비합니다.
    4. ARP Reply (Unicast)

      • PC B가 유니캐스트(Unicast) 패킷을 만듭니다. (브로드캐스트가 아님)
      • 내용: "네, 제가 192.168.0.10이고, 제 MAC 주소는 BB-BB-BB-BB-BB-BB입니다."
      • 목적지 MAC 주소: PC A의 MAC 주소 (요청 패킷의 '출발지 MAC'을 보고 이미 알고 있음)
      • 스위치는 이 유니캐스트 프레임을 받으면, 테이블을 참조하여 오직 PC A가 연결된 포트로만 정확히 전달합니다.
    5. 캐시 저장 및 통신 시작
      - PC A는 응답을 받고, 자신의 ARP 캐시[192.168.0.10 = BB-BB-BB-BB-BB-BB] 라고 기록합니다.
      - 이제 PC A는 PC B의 MAC 주소를 알았으므로, 원래 보내려던 데이터를 이더넷 프레임에 담아 (목적지 MAC: BB-BB...) 전송을 시작합니다.
      - PC B 역시 PC A의 MAC 주소를 ARP 테이블에 캐싱합니다.


      ARP 캐시 (ARP Cache)

    • ARP는 매번 물어보면 비효율적이므로, 한 번 알아낸 IP-MAC 매핑 정보를 메모리에 임시 저장합니다. 이것이 ARP 캐시입니다.
    • 이 정보는 일정 시간(몇 분 정도)이 지나면 자동으로 삭제(timeout)됩니다. (혹시 B 컴퓨터의 랜카드가 바뀌어 MAC 주소가 바뀔 수도 있으니까요.)
    • (윈도우 cmd나 터미널에서 arp -a 명령어를 치면 현재 내 PC의 ARP 캐시 목록을 볼 수 있습니다.)
  • Q. 두 개 이상의 라우터를 거친 통신 방식은? 가장 중요한 두 가지 원칙을 먼저 기억해 주세요.
    1. L3 (IP) 헤더는 불변: 패킷이 최종 목적지에 도착할 때까지 Source IP (A)Destination IP (B)절대 변하지 않습니다.

    2. L2 (MAC) 헤더는 가변: 이더넷 프레임의 L2(MAC) 헤더는 각 라우터를 통과(hop)할 때마다 매번 벗겨지고 새로 포장됩니다. L2 헤더는 오직 '다음 홉(next hop)'까지만 유효합니다.


      새로운 시나리오

      PC A가 인터넷을 거쳐 PC B에게 데이터를 보냅니다.

    • 경로: PC AR1 (A의 게이트웨이) → R2 (중간 라우터) → R3 (B의 게이트웨이) → PC B

    • IP 패킷 (L3): [Source: A-IP | Dest: B-IP] (이것은 절대 변하지 않음)


      1️⃣ 홉 1: PC AR1 (LAN A 내부)

    1. PC A의 판단: "목적지 B-IP는 내 LAN(A)에 없다. 무조건 게이트웨이 R1에게 보내야지."

    2. 필요한 정보: R1의 MAC 주소

    3. ARP #1 (LAN A): PC A가 LAN A에 브로드캐스트. "IP R1-IP 가진 분, MAC 주소 뭐예요?"

    4. R1의 응답: "제 MAC은 R1-MAC-A입니다."

    5. PC A의 프레임 포장:

      • L2 헤더: [Src: A-MAC | Dest: R1-MAC-A]
      • L3 헤더: [Src: A-IP | Dest: B-IP]
    6. PC A가 이 프레임을 LAN A로 전송합니다.


      2️⃣ 홉 2: R1R2 (라우터 간)

    7. R1의 수신: R1이 프레임을 받고, L2 헤더(목적지가 R1-MAC-A임)를 벗겨냅니다.

    8. R1의 판단 (L3): L3 헤더를 봅니다. "최종 목적지는 B-IP구나."

    9. 라우팅 테이블 조회: "내 라우팅 테이블을 보니, B-IP로 가려면 '다음 홉'은 R2이네."

    10. 필요한 정보: R2의 MAC 주소

    11. ARP #2 (R1-R2망): R1R2와 연결된 망에 ARP 요청. "IP R2-IP 가진 분, MAC 주소 뭐예요?"

    12. R2의 응답: "제 MAC은 R2-MAC입니다."

    13. R1의 새 프레임 포장:

    14. R1이 이 새로운 프레임을 R2로 전송합니다.


      3️⃣ 홉 3: R2R3 (라우터 간)

      2번 과정과 완전히 동일한 로직이 반복됩니다.

    15. R2의 수신: 프레임을 받고, L2 헤더(목적지가 R2-MAC임)를 벗겨냅니다.

    16. R2의 판단 (L3): L3 헤더를 봅니다. "최종 목적지는 B-IP."

    17. 라우팅 테이블 조회: "내 테이블을 보니, B-IP로 가려면 '다음 홉'은 R3이네."

    18. 필요한 정보: R3의 MAC 주소

    19. ARP #3 (R2-R3망): R2R3와 연결된 망에 ARP 요청. "IP R3-IP 가진 분, MAC 주소 뭐예요?"

    20. R3의 응답: "제 MAC은 R3-MAC입니다."

    21. R2의 새 프레임 포장:

    22. R2가 이 새로운 프레임을 R3으로 전송합니다.


      4️⃣ 홉 4: R3PC B (LAN B 내부)

      마지막 단계입니다. 여기서 '다음 홉'은 라우터가 아닌 최종 목적지 PC B가 됩니다.

    23. R3의 수신: 프레임을 받고, L2 헤더(목적지가 R3-MAC임)를 벗겨냅니다.

    24. R3의 판단 (L3): L3 헤더를 봅니다. "최종 목적지는 B-IP."

    25. 라우팅 테이블 조회: "내 테이블을 보니, B-IP가 속한 네트워크(LAN B)는 나에게 직접 연결되어 있네!"

    26. R3의 결론: "이제 내가 이 패킷을 PC B에게 직접 전달하면 끝이다."

    27. 필요한 정보: PC B의 MAC 주소

    28. ARP #4 (LAN B): R3LAN B에 브로드캐스트. "IP B-IP 가진 분, MAC 주소 뭐예요?"

    29. PC B의 응답: "저예요! 제 MAC은 B-MAC입니다."

    30. R3의 마지막 프레임 포장:

    31. R3이 이 프레임을 LAN B로 전송하고, PC B가 최종적으로 수신합니다.

      요약

    • 라우터는 L3(IP) 주소를 보고 최종 목적지를 확인한 뒤, 라우팅 테이블을 통해 '다음 홉(Next Hop)'을 결정합니다.
    • 그 '다음 홉'이 또 다른 라우터(R2, R3)이든, 최종 목적지(PC B)이든 상관없이, 라우터는 '다음 홉'의 MAC 주소를 알아내기 위해 ARP를 사용합니다.
    • 이 과정에서 L2 헤더는 계속 새롭게 포장(re-encapsulation)됩니다.
  • Q. ARP 과정에서 테이블에 없다면 Gateway에 데이터를 보내야 한다. 이때 Gateway의 MAC 주소를 알아야 한다. 그럴려면 Gateway IP 주소를 알고 있어야 할텐데 어떻게 알고 있을까? 결론적으로 PC는 "기본 게이트웨이(Default Gateway)" 주소로 미리 알고 있습니다. 이 "기본 게이트웨이" IP 주소는 PC A가 네트워크에 연결되는 시점에 자신의 IP 주소 및 서브넷 마스크와 함께 설정됩니다.

    IP 주소는 어떻게 설정되는가?

    PC A가 R1-IP를 알게 되는 방법(즉, 네트워크 설정)은 크게 두 가지입니다.

    1. 수동 설정 (Static)

    • 가장 간단한 방식입니다.

    • 사용자가 직접 PC의 [네트워크 설정]에 들어가서 IP 정보를 모두 키보드로 입력합니다.
      - IP 주소: 192.168.0.5
      - 서브넷 마스크: 255.255.255.0
      - 기본 게이트웨이: 192.168.0.1 (이것이 바로 R1-IP입니다)

      2. 자동 설정 (DHCP) - 가장 일반적인 방식

    • 우리가 집이나 카페에서 Wi-Fi에 연결할 때 자동으로 IP를 받는 방식입니다.

    • DHCP (Dynamic Host Configuration Protocol) 라는 프로토콜이 사용됩니다.

    • 과정:
      1. PC A가 네트워크에 연결되면, "저 IP 주소 필요해요!"라고 DHCP Discover 브로드캐스트를 보냅니다.
      2. 일반적으로 라우터(R1)가 이 DHCP 서버 역할을 겸합니다.
      3. R1이 PC A에게 "DHCP Offer"를 보냅니다. 이 안에는 다음 정보가 모두 들어있습니다.
      - IP 주소: "넌 192.168.0.5를 쓰렴."
      - 서브넷 마스크: "네트워크 울타리는 255.255.255.0이야."
      - 기본 게이트웨이: "그리고 모르는 곳(외부망)으로 갈 땐, 나(192.168.0.1)에게 보내렴." (이것이 R1-IP입니다.)
      - (부가 정보: DNS 서버 주소 등)


      요약

    1. PC A는 부팅 시 DHCP를 통해 R1-IP"기본 게이트웨이" 주소로 미리 저장해 둡니다.
    2. PC A가 B-IP로 통신을 시도합니다.
    3. 서브넷 마스크 계산 결과, B-IP외부망임을 확인합니다.
    4. PC A는 "아, 외부망이니 '기본 게이트웨이'로 보내야지"라고 판단합니다.
    5. PC A는 저장해 둔 '기본 게이트웨이' IP 주소 (R1-IP)를 꺼냅니다.
    6. 바로 이 시점에 사용자님이 질문한 ARP가 시작됩니다: "좋아, R1-IP로 보내야 하는데... 혹시 R1-IPMAC 주소 아는 사람?" (ARP 브로드캐스트)
  • Q. 프레임이란?
  • Q. 프레임에서의 오류체크는 어떻게 할까?

3. 브리지 이더넷 (Bridged Ethernets)

  • 구성: 여러 이더넷 세그먼트는 브리지(bridge)라는 작은 상자들을 사용해 더 큰 LAN(브리지 이더넷)으로 연결될 수 있습니다 (그림 11.4).
  • 브리지 (Bridge): 브리지는 허브보다 똑똑합니다.
    • 영리한 분산 알고리즘을 사용해, 시간이 지남에 따라 어떤 호스트가 어느 포트에서 도달 가능한지 자동으로 학습합니다.
    • 그 후 필요할 때만 프레임을 선택적으로 복사합니다.
    • (예: A가 같은 세그먼트의 B에게 보내면, 브리지는 그 프레임을 버려 다른 세그먼트의 대역폭을 절약합니다. A가 다른 세그먼트의 C에게 보내면, 브리지는 C가 연결된 포트로만 프레임을 복사합니다.)
  • (이후 LAN 그림은 허브와 브리지를 하나의 수평선으로 단순화하여 그릴 것입니다 - 그림 11.5)
  • Q. 허브는 스위치인가? 아니요, 허브는 스위치의 "더 이전 세대" 혹은 "더 단순한" 버전이라고 볼 수 있습니다.
    • 허브 (Hub) - Layer 1 (물리 계층)

      • 허브는 "멍청한(dumb)" 장치입니다. MAC 주소나 IP 주소를 전혀 이해하지 못합니다.
      • 한 포트에서 전기 신호(데이터)가 들어오면, 그 신호를 그대로 복사해서 나머지 모든 포트로 재전송(broadcast)합니다.
      • 네트워크의 모든 장치가 동일한 "충돌 도메인"에 속하게 되어, 트래픽이 많아지면 충돌이 빈번해지고 속도가 급격히 저하됩니다. (그래서 "더미 허브"라고도 부릅니다.)
    • 스위치 (Switch) - Layer 2 (데이터 링크 계층)
      - 스위치는 "영리한(smart)" 장치입니다. (제시해주신 텍스트의 브리지와 동일한 역할을 합니다.)
      - 스위치는 각 포트에 연결된 장치의 MAC 주소를 학습합니다.
      - A가 C에게 데이터를 보낼 때, 스위치는 해당 데이터를 C가 연결된 포트로만 선택적으로 전송합니다.
      - 현대의 스위치(Switch)는 사실상 '멀티 포트 브리지(multi-port bridge)'입니다. 브리지의 기능을 여러 포트에서 고속으로 처리하도록 만든 장치입니다.

      결론: 허브는 모든 포트에 소리치는 확성기(broadcast)이고, 스위치(브리지)는 특정 대상에게만 귓속말(unicast)을 전달하는 교환원입니다.

  • Q. Bridge가 라우터인가?

    1. 브리지는 라우터인가? (A: 다릅니다)

    아니요, 브리지와 라우터는 OSI 모델의 서로 다른 계층(Layer)에서 작동합니다.
    • 브리지 (Bridge) - Layer 2 (데이터 링크 계층)

      • 브리지는 MAC 주소 (물리적 주소)를 기반으로 작동합니다.
      • 주된 목적은 동일한 네트워크(LAN)를 여러 세그먼트(충돌 도메인)로 분리하여 불필요한 트래픽을 차단하는 것입니다.
      • 브리지는 "이 MAC 주소는 이쪽 포트에 있다"라는 것을 학습합니다.
      • IP 주소라는 개념 자체를 이해하지 못합니다.
    • 라우터 (Router) - Layer 3 (네트워크 계층)
      - 라우터는 IP 주소 (논리적 주소)를 기반으로 작동합니다.
      - 주된 목적은 서로 다른 네트워크(예: 내 집 LAN과 인터넷 WAN)를 연결하고, 데이터 패킷이 목적지까지 갈 수 있는 최적의 경로를 찾는(routing) 것입니다.

      결론: 브리지는 '하나의 큰 집(LAN)'을 여러 방(segment)으로 나누는 문지기 역할이고, 라우터는 '우리 집(LAN)'과 '다른 집(Another LAN/WAN)'을 연결하는 대문이자 우체부 역할입니다.

  • Q. Bridge는 스위치인가? 네, 현대적인 관점에서 보면 스위치(Switch)는 사실상 '멀티 포트 브리지(Multi-port Bridge)'라고 할 수 있습니다. 둘은 OSI 7계층의 2계층(데이터 링크 계층)에서 동작하며, MAC 주소를 기반으로 프레임을 선별적으로 전달(forwarding)한다는 핵심 기능이 동일합니다. 다만, 역사적으로나 기술적인 뉘앙스에서 약간의 차이가 있습니다.
    • 브리지 (Bridge):

      • 역사: 초창기에는 주로 두 개의 네트워크 세그먼트(예: 허브 2개)를 연결하는 장치였습니다. (포트 수가 적음)
      • 처리: 종종 소프트웨어 기반으로 포워딩 결정을 처리하여 스위치보다 느릴 수 있었습니다.
    • 스위치 (Switch):
      - 현재: 브리지의 개념을 여러 포트(multi-port)로 확장한 장치입니다. (포트 수가 4, 8, 24개 등으로 많음)
      - 처리: ASIC (주문형 반도체)라는 전용 하드웨어를 사용하여 MAC 주소 테이블 조회와 포워딩을 "와이어 스피드(wire speed, 회선 최고 속도)"로 매우 빠르게 처리합니다.

      결론:
      오늘날 우리가 "스위치"라고 부르는 장비는 브리지의 기본 원리를 계승하여 훨씬 더 빠르고, 더 많은 포트를 가진 형태로 발전시킨 것입니다. 따라서 "스위치는 브리지의 현대적이고 고성능 버전이다"라고 이해하시면 정확합니다.


4. 인터넷 (Internet)

  • 구성: 더 높은 계층에서, 서로 호환되지 않는 여러 LAN이 라우터(router)라는 특수한 컴퓨터에 의해 연결되어 인터넷(internet, 상호연결된 네트워크)을 형성합니다.
  • 라우터 (Router): 각 라우터는 연결된 모든 네트워크에 대한 어댑터(포트)를 가집입니다.
  • WAN (Wide Area Network): 라우터는 LAN보다 더 넓은 지역을 포괄하는 WAN(광역 통신망)(예: 고속 전화 연결)도 연결할 수 있습니다.

  • (예: 그림 11.6은 3대의 라우터로 연결된 LAN과 WAN의 조합을 보여줍니다.)

상호연결된 네트워크 (internet)

internet(상호연결된 네트워크)의 결정적인 속성은 서로 급진적으로 다르고 호환되지 않는 기술의 LAN과 WAN으로 구성될 수 있다는 점입니다. 모든 호스트가 다른 모든 호스트와 물리적으로 연결된 것은 아닌데, 어떻게 출발지 호스트가 이 모든 호환되지 않는 네트워크를 거쳐 목적지 호스트로 데이터 비트를 보낼 수 있을까요?

해결책: 프로토콜 소프트웨어

해결책은 각 호스트와 라우터에서 실행되는 프로토콜 소프트웨어(protocol software) 계층이 서로 다른 네트워크 간의 차이점을 '없애주는(smoothes out)' 것입니다.

이 소프트웨어는 호스트와 라우터가 데이터를 전송하기 위해 어떻게 협력해야 하는지를 규율하는 프로토콜(protocol)을 구현합니다. 이 프로토콜은 두 가지 기본 기능을 제공해야 합니다.

  1. 이름 지정 체계 (Naming scheme)
    • 서로 다른 LAN 기술들은 호스트 주소를 할당하는 방식이 제각기 다르고 호환되지 않습니다.
    • 인터넷 프로토콜은 호스트 주소를 위한 통일된 형식을 정의하여 이러한 차이점을 없앱니다.
    • 각 호스트는 자신을 고유하게 식별하는 하나 이상의 인터넷 주소를 할당받습니다.
  2. 전달 메커니즘 (Delivery mechanism)
    • 서로 다른 네트워킹 기술들은 비트를 인코딩하고 프레임으로 포장하는 방식이 다릅니다.
    • 인터넷 프로토콜은 데이터 비트를 패킷(packet)이라는 개별 청크로 묶는 통일된 방식을 정의하여 이러한 차이점을 없앱니다.
    • 패킷(Packet): [헤더 (패킷 크기, 송/수신 호스트 주소), 페이로드 (실제 데이터 비트)]로 구성됩니다.

예제: LAN 간 데이터 전송 (그림 11.7)

두 개의 LAN이 하나의 라우터로 연결된 인터넷 예시입니다. LAN1의 호스트 A(클라이언트)가 LAN2의 호스트 B(서버)로 데이터를 전송하는 8단계입니다.

  1. 호스트 A (클라이언트): 시스템 콜을 호출하여 데이터를 커널 버퍼로 복사합니다.
  2. 호스트 A (프로토콜): 데이터에 인터넷 헤더LAN1 프레임 헤더를 붙여 LAN1 프레임을 만듭니다.
    • 인터넷 헤더의 목적지: (최종 목적지) 호스트 B
    • LAN1 프레임 헤더의 목적지: (다음 단계) 라우터
    • (이것이 캡슐화(encapsulation)의 핵심입니다. LAN1 프레임의 페이로드는 인터넷 패킷입니다.)
  3. 호스트 A (어댑터): 프레임을 네트워크(LAN1)로 복사(전송)합니다.
  4. 라우터 (LAN1 어댑터): 프레임을 수신하여 프로토콜 소프트웨어로 넘깁니다.
  5. 라우터 (프로토콜):
    • (De-capsulation) LAN1 프레임 헤더를 벗겨냅니다.
    • 인터넷 패킷 헤더를 읽어 최종 목적지(호스트 B)를 확인합니다.
    • 라우팅 테이블을 참조하여 이 패킷을 LAN2로 전달해야 함을 결정합니다.
    • (Re-capsulation) 새로운 LAN2 프레임 헤더를 생성하여 (목적지: 호스트 B) 패킷 앞에 붙입니다.
  6. 라우터 (LAN2 어댑터):새로운 프레임을 네트워크(LAN2)로 복사(전송)합니다.
  7. 호스트 B (어댑터): 프레임을 수신하여 프로토콜 소프트웨어로 넘깁니다.
  8. 호스트 B (프로토콜): LAN2 프레임 헤더와 인터넷 패킷 헤더를 모두 벗겨내고, 서버가 데이터를 읽는 시스템 콜을 호출할 때 최종 데이터를 서버의 가상 주소 공간으로 복사합니다.

(물론 네트워크 최대 프레임 크기 문제, 라우팅 테이블 결정 방식, 패킷 손실 등 많은 어려운 문제를 생략했지만) 이 예제가 인터넷 아이디어의 본질을 담고 있으며, 캡슐화(Encapsulation)가 그 핵심입니다.

11.3 글로벌 IP 인터넷 (The Global IP Internet)

글로벌 IP 인터넷은 internet(상호연결된 네트워크)의 가장 유명하고 성공적인 구현체입니다. 1969년부터 어떤 형태로든 존재해왔습니다.

  • 인터넷의 내부 아키텍처는 복잡하고 끊임없이 변하지만, 클라이언트-서버 애플리케이션의 구성은 1980년대 초부터 놀라울 정도로 안정적이었습니다.

  • [그림 11.8]은 인터넷 클라이언트-서버 애플리케이션의 기본 하드웨어 및 소프트웨어 구성을 보여줍니다.

TCP/IP 프로토콜

  • 각 인터넷 호스트는 거의 모든 최신 컴퓨터 시스템이 지원하는 TCP/IP 프로토콜 (Transmission Control Protocol / Internet Protocol) 소프트웨어를 실행합니다.
  • 인터넷 클라이언트와 서버는 소켓 인터페이스(Sockets interface) 함수와 Unix I/O 함수를 혼합하여 통신합니다. (소켓 인터페이스는 11.4절에서 설명)
  • 소켓 함수는 일반적으로 커널 모드로 트랩(trap)하여 TCP/IP 커널 함수를 호출하는 시스템 콜로 구현됩니다.

TCP/IP 프로토콜 제품군 (Family)

TCP/IP는 실제로는 각기 다른 기능을 제공하는 프로토콜들의 제품군입니다.

  • IP (Internet Protocol):
    • 기본 이름 지정 체계(naming scheme)를 제공합니다.
    • 패킷을 한 호스트에서 다른 호스트로 보낼 수 있는 전달 메커니즘을 제공합니다.
    • 이 IP 메커니즘은 네트워크에서 데이터그램이 손실되거나 중복되더라도 복구하려 노력하지 않는다는 의미에서 신뢰할 수 없습니다(unreliable).
  • UDP (Unreliable Datagram Protocol):
    • IP를 약간 확장하여, 데이터그램이 호스트 대 호스트가 아닌 프로세스 대 프로세스로 전송될 수 있게 합니다.
  • TCP (Transmission Control Protocol):
    • IP를 기반으로 구축된 복잡한 프로토콜입니다.
    • 프로세스 간에 신뢰할 수 있는(reliable) 전이중(full duplex) 연결을 제공합니다.

(논의를 단순화하기 위해, 이 책에서는 TCP/IP를 하나의 단일 프로토콜로 취급할 것입니다. UDP는 논의하지 않습니다.)


프로그래머의 관점

프로그래머의 관점에서, 인터넷은 다음과 같은 속성을 가진 전 세계적인 호스트의 집합으로 생각할 수 있습니다.

  • 호스트 집합은 32비트 IP 주소 집합에 매핑됩니다.
  • IP 주소 집합은 인터넷 도메인 이름(Internet domain names)이라는 식별자 집합에 매핑됩니다.
  • 한 인터넷 호스트의 프로세스연결(connection)을 통해 다른 인터넷 호스트의 프로세스와 통신할 수 있습니다.

이어지는 섹션에서 이러한 기본적인 인터넷 아이디어를 더 자세히 논의합니다.

11.3.1 IP 주소 (IP Addresses)

IP 주소는 부호 없는 32비트 정수입니다. 네트워크 프로그램은 [그림 11.9]에 보이는 IP 주소 구조체에 IP 주소를 저장합니다.

스칼라(scalar) 주소를 구조체에 저장하는 것은 소켓 인터페이스 초기 구현의 불행한 유물입니다. IP 주소를 위한 스칼라 타입을 정의하는 것이 더 합리적이었겠지만, 이미 설치된 수많은 애플리케이션 기반 때문에 지금 바꾸기에는 너무 늦었습니다.

네트워크 바이트 순서 (Network Byte Order)

인터넷 호스트들은 서로 다른 호스트 바이트 순서(host byte order)를 가질 수 있기 때문에, TCP/IP는 패킷 헤더에 실려 네트워크를 가로지르는 IP 주소와 같은 모든 정수 데이터 항목에 대해 통일된 네트워크 바이트 순서(network byte order) (즉, 빅 엔디안(big-endian))를 정의합니다.

IP 주소 구조체(struct in_addr) 내의 주소는, 설령 호스트 바이트 순서가 리틀 엔디안(little-endian)일지라도, 항상 (빅 엔디안) 네트워크 바이트 순서로 저장됩니다.

유닉스는 호스트와 네트워크 바이트 순서를 변환하기 위해 다음 함수들을 제공합니다.

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong); // Host to Network Long
uint16_t htons(uint16_t hostshort); // Host to Network Short
    // 반환 값: 네트워크 바이트 순서의 값

uint32_t ntohl(uint32_t netlong); // Network to Host Long
uint16_t ntohs(uint16_t netshort); // Network to Host Short
    // 반환 값: 호스트 바이트 순서의 값
  • htonl: 부호 없는 32비트 정수를 호스트 바이트 순서에서 네트워크 바이트 순서로 변환합니다.
  • ntohl: 부호 없는 32비트 정수를 네트워크 바이트 순서에서 호스트 바이트 순서로 변환합니다.
  • htons, ntohs: 부호 없는 16비트 정수에 대해 동일한 변환을 수행합니다. (64비트 값에 대한 함수는 없습니다.)

점 표기법 (Dotted-Decimal Notation)

IP 주소는 일반적으로 점 표기법(dotted-decimal notation)이라는 형태로 사람들에게 표시됩니다. 이는 각 바이트를 10진수 값으로 나타내고 점(.)으로 구분하는 방식입니다.

  • (예: 128.2.194.2420x8002c2f2의 점 표기법입니다.)

리눅스 시스템에서는 hostname -i 명령을 사용해 자신의 호스트 주소를 확인할 수 있습니다.

애플리케이션 프로그램은 inet_ptoninet_ntop 함수를 사용해 IP 주소와 점 표기법 문자열 간의 변환을 수행할 수 있습니다.

#include <arpa/inet.h>

int inet_pton(AF_INET, const char *src, void *dst);
    // 반환 값: 성공 시 1, src가 유효하지 않으면 0, 오류 시 -1

const char *inet_ntop(AF_INET, const void *src, char *dst, socklen_t size);
    // 반환 값: 성공 시 점 표기법 문자열 포인터, 오류 시 NULL
  • (함수 이름에서 "n"은 network(네트워크)를, "p"는 presentation(표현)을 의미합니다.)
  • 이 함수들은 32비트 IPv4 주소(AF_INET)나 128-bit IPv6 주소(AF_INET6)를 조작할 수 있습니다.

inet_pton 함수는 점 표기법 문자열(src)을 네트워크 바이트 순서의 바이너리 IP 주소(dst)로 변환합니다. inet_ntop 함수는 네트워크 바이트 순서의 바이너리 IP 주소(src)를 해당하는 점 표기법 문자열로 변환하고, 그 결과를 dst에 복사합니다.

11.3.2 인터넷 도메인 이름 (Internet Domain Names)

인터넷 클라이언트와 서버는 IP 주소를 사용해 통신하지만, 사람은 32비트 정수(IP 주소)를 기억하기 어렵습니다. 따라서 인터넷은 도메인 이름(domain names)이라는 더 인간 친화적인 이름 집합과, 이 도메인 이름을 IP 주소로 매핑(mapping)하는 메커니즘을 정의합니다.

  • (예: whaleshark.ics.cs.cmu.edu)

도메인 이름 계층 (Hierarchy)

도메인 이름 집합은 계층(hierarchy)을 형성하며, 이는 트리(tree)로 표현됩니다 (그림 11.10).

  • 루트(Root): 이름 없는 루트 노드가 최상위에 있습니다.
  • 1단계 (First-level): ICANN(국제인터넷주소관리기구)이라는 비영리 단체가 정의하는 com, edu, gov, org, net 같은 1단계 도메인입니다.
  • 2단계 (Second-level): cmu.edu와 같이 ICANN의 공인된 에이전트가 (선착순으로) 할당하는 2단계 도메인입니다.
  • 하위 도메인 (Subdomain): 2단계 도메인을 할당받은 조직은 cs.cmu.edu와 같이 자신의 하위 도메인 내에서 자유롭게 새 도메인 이름을 생성할 수 있습니다.

매핑: DNS (Domain Name System)

도메인 이름과 IP 주소 간의 매핑은 1988년까지는 HOSTS.TXT라는 단일 텍스트 파일로 관리되었습니다.

  • 이후, 이 매핑은 DNS(Domain Name System)라는 전 세계적인 분산 데이터베이스에서 유지 관리됩니다.
  • DNS 데이터베이스는 수백만 개의 호스트 항목(host entry)으로 구성되며, 각 항목은 도메인 이름 집합과 IP 주소 집합 간의 매핑을 정의합니다. (수학적으로는, 도메인 이름과 IP 주소의 동치 클래스(equivalence class)로 생각할 수 있습니다.)

DNS 매핑의 속성 (nslookup 예시)

리눅스 nslookup 프로그램으로 DNS 매핑의 속성을 탐색할 수 있습니다.

  1. localhost:Bash

    • 모든 호스트는 localhost라는 로컬 도메인 이름을 가지며, 이는 항상 루프백 주소(loopback address) 127.0.0.1로 매핑됩니다.
    • (동일 머신에서 실행되는 클라이언트/서버 디버깅에 유용합니다.)
    linux> nslookup localhost
    Address: 127.0.0.1
  2. 1:1 매핑 (One-to-one):Bash

    • 가장 단순한 경우, 하나의 도메인 이름이 하나의 IP 주소로 매핑됩니다.
    linux> nslookup whaleshark.ics.cs.cmu.edu
    Address: 128.2.210.175
  3. 다:1 매핑 (Many-to-one):Bash

    • 여러 도메인 이름이 동일한 하나의 IP 주소로 매핑될 수 있습니다.
    linux> nslookup cs.mit.edu
    Address: 18.62.1.6
    linux> nslookup eecs.mit.edu
    Address: 18.62.1.6
  4. 다:다 매핑 (Many-to-many):Bash

    • 가장 일반적인 경우, 여러 도메인 이름이 여러 개의 IP 주소 집합으로 매핑될 수 있습니다.
    linux> nslookup www.twitter.com
    Address: 199.16.156.6
    Address: 199.16.156.70
    Address: 199.16.156.102
    Address: 199.16.156.230
  5. 매핑 없음 (No mapping):Bash

    • eduics.cs.cmu.edu처럼, 유효한 도메인 이름임에도 불구하고 IP 주소에 매핑되지 않는 경우도 있습니다.
    linux> nslookup edu
    *** Can’t find edu: No answer

11.3.3 인터넷 연결 (Internet Connections)

인터넷 클라이언트와 서버는 연결(connections)을 통해 바이트 스트림을 주고받으며 통신합니다. 연결은 다음과 같은 속성을 가집니다.

  • 점대점 (Point-to-point): 한 쌍의 프로세스를 연결합니다.
  • 전이중 (Full duplex): 데이터가 양방향으로 동시에 흐를 수 있습니다.
  • 신뢰성 (Reliable): (케이블 절단과 같은 치명적인 장애를 제외하고) 출발지 프로세스가 보낸 바이트 스트림이 결국 목적지 프로세스에 전송된 순서와 동일한 순서로 수신됩니다.

소켓 (Socket)

  • 소켓(Socket)은 연결의 종단점(end point)입니다. 각 소켓은 인터넷 주소(IP)16비트 정수 포트(port)로 구성된 고유한 소켓 주소(socket address)를 가지며, address:port 표기법으로 나타냅니다.
  • 클라이언트 포트: 클라이언트가 연결을 요청할 때 커널에 의해 자동으로 할당되며, 임시 포트(ephemeral port)라고 알려져 있습니다.
  • 서버 포트: 일반적으로 해당 서비스와 영구적으로 연관된 잘 알려진 포트(well-known port)입니다.
    • (예: 웹 서버는 80번, 이메일 서버는 25번 포트를 사용합니다.)
  • 잘 알려진 서비스 이름: 잘 알려진 포트에는 http(웹), smtp(이메일)와 같이 대응하는 잘 알려진 서비스 이름이 있습니다. (/etc/services 파일에 매핑이 포함됩니다.)

소켓 페어 (Socket Pair)

연결은 두 종단점의 소켓 주소에 의해 고유하게 식별됩니다. 이 소켓 주소 쌍을 소켓 페어(socket pair)라고 하며, 다음과 같은 튜플(tuple)로 표기합니다.

(cliaddr:cliport, servaddr:servport)

(cliaddr: 클라이언트 IP, cliport: 클라이언트 포트, servaddr: 서버 IP, servport: 서버 포트)

  • 예시 (그림 11.11): 웹 클라이언트와 웹 서버 간의 연결

    • 클라이언트 소켓 주소: 128.2.194.242:51213 (51213은 커널이 할당한 임시 포트)
    • 서버 소켓 주소: 208.216.181.15:80 (80은 웹 서비스의 잘 알려진 포트)
    • 이 연결을 식별하는 소켓 페어:
      (128.2.194.242:51213, 208.216.181.15:80)

11.4 소켓 인터페이스 (The Sockets Interface)

소켓 인터페이스Unix I/O 함수들과 함께 네트워크 애플리케이션을 구축하는 데 사용되는 함수들의 집합입니다.

  • 이는 모든 유닉스 변종은 물론, 윈도우와 매킨토시 시스템을 포함한 대부분의 현대 시스템에 구현되어 있습니다.
  • [그림 11.12]는 일반적인 클라이언트-서버 트랜잭션의 관점에서 소켓 인터페이스의 개요를 보여줍니다.
  • (이어지는 개별 함수들을 논의할 때 이 그림을 로드맵으로 사용해야 합니다.)

11.4.1 소켓 주소 구조체 (Socket Address Structures)

  • 리눅스 커널의 관점에서, 소켓은 통신의 종단점(end point)입니다.
  • 리눅스 프로그램의 관점에서, 소켓은 해당 디스크립터를 가진 열린 파일입니다.

인터넷 소켓 주소는 [그림 11.13]에 보이는 sockaddr_in 타입의 16바이트 구조체에 저장됩니다. 인터넷 애플리케이션의 경우, 필드 값은 다음과 같습니다.

  • sin_family: AF_INET (프로토콜 패밀리)
  • sin_port: 16비트 포트 번호 (네트워크 바이트 순서)
  • sin_addr: 32비트 IP 주소 (네트워크 바이트 순서)

제네릭 sockaddr 구조체

connect, bind, accept 함수들은 프로토콜에 특화된(protocol-specific) 소켓 주소 구조체에 대한 포인터를 요구합니다.

소켓 인터페이스 설계자들의 과제는 이 함수들이 어떤 종류의 소켓 주소 구조체든(예: IPv4, IPv6 등) 받아들일 수 있도록 정의하는 것이었습니다. (오늘날 C라면 제네릭 void * 포인터를 사용했겠지만, 당시에는 void *가 존재하지 않았습니다.)

그들의 해결책은 소켓 함수들이 제네릭 sockaddr 구조체([그림 11.13])에 대한 포인터를 받도록 정의한 다음, 애플리케이션이 sockaddr_in 같은 프로토콜별 구조체 포인터를 이 제네릭 sockaddr 구조체로 캐스트(cast)하도록 요구하는 것이었습니다.

(코드 예제를 단순화하기 위해, 스티븐스(Stevens)의 선례를 따라 다음 타입을 정의합니다:

typedef struct sockaddr SA;

이후 sockaddr_in 구조체를 제네릭 sockaddr 구조체로 캐스트해야 할 때마다 이 SA 타입을 사용할 것입니다.)

11.4.2 The socket Function

클라이언트와 서버는 socket 함수를 사용하여 소켓 디스크립터를 생성합니다.

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

int socket(int domain, int type, int protocol);
  • 반환 값: 성공 시 0 이상의 디스크립터, 오류 시 -1

만약 이 소켓이 연결(connection)의 종단점이 되기를 원한다면, 다음과 같이 하드코딩된 인자들로 socket을 호출할 수 있습니다:

clientfd = Socket(AF_INET, SOCK_STREAM, 0);

  • AF_INET: 우리가 32비트 IP 주소를 사용함을 나타냅니다.
  • SOCK_STREAM: 이 소켓이 연결(connection)의 종단점이 될 것임을 나타냅니다.

하지만, 코드가 프로토콜에 독립적(protocol-independent)이 되도록 getaddrinfo 함수(11.4.7절)를 사용하여 이 파라미터들을 자동으로 생성하는 것이 최선의 방법입니다. (11.4.8절에서 getaddrinfosocket 함수와 함께 사용하는 방법을 보여줄 것입니다.)

socket에 의해 반환된 clientfd 디스크립터는 부분적으로만 열린(partially opened) 상태이며, 아직 읽기/쓰기에 사용될 수 없습니다.

소켓 열기를 완료하는 방법은 우리가 클라이언트인지 서버인지에 따라 다릅니다. 다음 섹션은 클라이언트인 경우 소켓 열기를 완료하는 방법을 설명합니다.

11.4.3 The connect Function

클라이언트는 connect 함수를 호출하여 서버와 연결을 수립합니다.

#include <sys/socket.h>

int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
  • 반환 값: 성공 시 0, 오류 시 -1

connect 함수는 소켓 주소 addr에 있는 서버와 인터넷 연결을 시도합니다 (여기서 addrlensizeof(sockaddr_in)입니다).

connect 함수는 연결이 성공적으로 수립되거나 오류가 발생할 때까지 블록(blocks)됩니다.

성공하면, clientfd 디스크립터는 이제 읽기/쓰기가 준비된 상태가 되며, 이 연결은 다음 소켓 페어(socket pair)로 식별됩니다.

(x:y, addr.sin_addr:addr.sin_port)

(여기서 x는 클라이언트의 IP 주소이고, y는 클라이언트 호스트에서 클라이언트 프로세스를 고유하게 식별하는 임시 포트(ephemeral port)입니다.)

socket 함수와 마찬가지로, connect의 인자들을 제공하기 위해 getaddrinfo를 사용하는 것이 최선의 방법입니다 (11.4.8절 참조).

11.4.4 The bind Function

bind, listen, accept 등 나머지 소켓 함수들은 서버가 클라이언트와 연결을 수립하기 위해 사용됩니다.

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 반환 값: 성공 시 0, 오류 시 -1

bind 함수는 커널에게 addr에 있는 서버의 소켓 주소를 소켓 디스크립터 sockfd연결(associate)하도록 요청합니다. addrlen 인자는 sizeof(sockaddr_in)입니다.

socket, connect와 마찬가지로, bind의 인자들을 제공하기 위해 getaddrinfo를 사용하는 것이 최선의 방법입니다 (11.4.8절 참조).

11.4.5 The listen Function

클라이언트는 연결 요청을 시작하는(initiate) 능동적인 주체입니다. 서버는 클라이언트의 연결 요청을 기다리는(wait) 수동적인 주체입니다.

기본적으로 커널은 socket 함수로 생성된 디스크립터가 (클라이언트 측의) 능동 소켓(active socket) 이라고 가정합니다. 서버는 listen 함수를 호출하여, 해당 디스크립터가 클라이언트가 아닌 서버에 의해 사용될 것임을 커널에 알립니다.

#include <sys/socket.h>

int listen(int sockfd, int backlog);
  • 반환 값: 성공 시 0, 오류 시 -1

listen 함수는 sockfd를 능동 소켓에서, 클라이언트의 연결 요청을 받을 수 있는 수신 소켓(listening socket)으로 변환합니다.

backlog 인자는 커널이 요청을 거부하기 시작하기 전에 대기열(queue)에 쌓아둘 보류 중인 연결 요청의 수에 대한 힌트(hint)입니다. (backlog의 정확한 의미는 TCP/IP에 대한 이해가 필요하므로 이 책의 범위를 벗어납니다. 일반적으로 1,024와 같은 큰 값으로 설정합니다.)

  • Q. backlog란? backlog 인자에 대해 더 자세히 설명해 드릴게요. 이 큐가 왜 필요하고, 커널 내부에서 실제로 어떻게 동작하는지 알려면 TCP 3-Way Handshake 과정과 커널의 두 가지 큐(Queue)를 이해해야 합니다. backlog는 서버가 accept()를 호출하여 연결을 가져갈 때까지, 이미 3-Way Handshake를 완료한 연결들을 잠시 보관하는 '완료 연결 큐'의 최대 크기를 지정하는 힌트입니다.

    1. SYN Queue (미완료 연결 큐)

    클라이언트가 서버에 접속을 시도하면(3-Way Handshake 시작), 다음과 같이 동작합니다.
    1. 클라이언트 → 서버: SYN
      • 클라이언트가 "연결 요청합니다"라는 SYN 패킷을 보냅니다.
    2. 커널의 처리:
      • 서버 커널은 이 SYN을 받으면, 해당 연결 정보를 SYN Queue에 넣습니다.
      • 이 큐의 연결들은 "아직 핸드셰이크가 완료되지 않은" (half-open) 상태입니다.
    3. 서버 → 클라이언트: SYN-ACK
      • 커널은 SYN-ACK로 응답하며 클라이언트의 마지막 ACK를 기다립니다.
    • SYN Queue의 크기는 backlog 값이 아니라, 리눅스 시스템의 net.ipv4.tcp_max_syn_backlog 같은 시스템 전역 설정에 의해 제어됩니다.

      2. Accept Queue (완료 연결 큐)

    1. 클라이언트 → 서버: ACK

      • 클라이언트가 SYN-ACK를 받고, "알겠습니다"라는 마지막 ACK를 서버에 보냅니다.
    2. 커널의 처리:
      - 서버 커널이 이 마지막 ACK를 받으면, 3-Way Handshake가 성공적으로 완료된 것입니다.
      - 커널은 이 연결을 SYN Queue에서 꺼내어, Accept Queue옮깁니다.
      - 이 큐의 연결들은 "완전히 연결되었으며, accept() 함수가 자신을 가져가기만 기다리는" 상태입니다.


      backlog의 정확한 역할

      listen(sockfd, backlog)에서 backlog 인자는 바로 이 2번 큐, 즉 Accept Queue의 최대 크기를 지정합니다.

    • listen(sockfd, 1024)를 호출하면, 커널은 "핸드셰이크가 완료된 연결을 최대 1024개까지 Accept Queue에 보관할 수 있다"고 설정합니다.

      "힌트(hint)"라고 부르는 이유

      본문에서 "힌트"라고 표현한 이유는, 프로그래머가 backlog 값을 1024로 설정하더라도 커널이 그 값을 그대로 사용하지 않을 수 있기 때문입니다.

    • 커널에는 시스템 전체에서 허용하는 Accept Queue의 최대 크기 제한(net.core.somaxconn)이 따로 있습니다.

    • 만약 net.core.somaxconn이 512로 설정되어 있는데 프로그래머가 backlog를 1024로 요청하면, 커널은 이 값을 512로 조정(clamp)합니다.

    • 따라서 backlog는 "이만큼 필요합니다"라는 요청(힌트)이며, 실제 크기는 커널의 정책에 따라 결정됩니다.

      만약 Accept Queue가 꽉 차면?

      서버 프로그램이 accept()를 충분히 빨리 호출하지 못해서 Accept Queuebacklog 크기만큼 꽉 차버리면 어떻게 될까요?

    • 이때 클라이언트로부터 마지막 ACK가 도착해도, 커널은 이 연결을 Accept Queue에 넣을 수 없습니다.

    • 커널은 이 ACK일단 무시(drop)합니다. (연결을 SYN Queue에 그대로 둡니다.)

    • 클라이언트 입장에서는 ACK가 유실된 것으로 보이므로, 잠시 후 ACK를 재전송합니다.

    • 이것이 바로 Accept Queue가 가득 찼을 때 서버가 새로운 연결 수립을 지연시키는 방식입니다. (SYN Queue까지 가득 차면, 그때는 SYN 패킷 자체를 무시하여 클라이언트에게 "Connection refused"가 발생합니다.)

      요약: backlog는 서버 프로그램이 accept()로 처리하는 속도(소비 속도)와 클라이언트가 연결을 요청하는 속도(생산 속도) 사이의 버퍼(buffer) 크기를 지정하는 중요한 값입니다.

11.4.6 The accept Function

서버는 accept 함수를 호출하여 클라이언트의 연결 요청을 기다립니다.

#include <sys/socket.h>

int accept(int listenfd, struct sockaddr *addr, int *addrlen);
  • 반환 값: 성공 시 0 이상의 연결된 디스크립터(connected descriptor), 오류 시 -1

accept 함수는 수신 디스크립터(listening descriptor) listenfd로 클라이언트의 연결 요청이 도착하기를 기다립니다.
요청이 도착하면, addr에 클라이언트의 소켓 주소를 채워 넣습니다.
그런 다음, Unix I/O 함수를 사용해 클라이언트와 통신하는 데 사용할 수 있는 연결된 디스크립터를 반환합니다.


수신 디스크립터 vs 연결 디스크립터

  • 수신 디스크립터(Listening Descriptor)연결된 디스크립터(Connected Descriptor)의 구분은 많은 학생들을 혼란스럽게 합니다.
  • 수신 디스크립터 (Listening Descriptor)
    • 클라이언트 연결 요청을 위한 종단점 (일종의 '접수 창구') 역할을 합니다.
    • 일반적으로 서버의 생명주기 동안 한 번만 생성되어 계속 존재합니다.
  • 연결된 디스크립터 (Connected Descriptor)
    • 클라이언트와 서버 간에 수립된 실제 연결의 종단점 (일종의 '전용 통화선')입니다.
    • 서버가 연결 요청을 수락(accept)할 때마다 새로 생성됩니다.
    • 서버가 해당 클라이언트를 서비스하는 동안에만 존재합니다.

연결 과정 (그림 11.14)

  1. 서버는 listenfd (예: 디스크립터 3 - 0~2는 표준 파일용)에 대해 accept를 호출하고 대기(wait)합니다.
  2. 클라이언트는 connect 함수를 호출하여, 서버의 listenfd로 연결 요청을 보냅니다.
  3. accept 함수는:
    • 새로운 연결된 디스크립터 connfd (예: 디스크립터 4)를 엽니다.
    • clientfdconnfd 간의 연결을 수립합니다.
    • connfd (4)를 서버 애플리케이션에 반환합니다.
  4. 클라이언트도 connect 함수에서 반환됩니다. 이 시점부터 클라이언트와 서버는 각각 clientfdconnfd에 데이터를 읽고 쓰면서 통신할 수 있습니다.

11.4.7 호스트 및 서비스 변환 (Host and Service Conversion)

리눅스는 getaddrinfogetnameinfo라는 강력한 함수들을 제공합니다.

이 함수들은 바이너리 소켓 주소 구조체(binary socket address structures)와, 호스트 이름, 호스트 주소, 서비스 이름, 포트 번호의 문자열 표현(string representations) 간의 상호 변환을 수행합니다.

이 함수들을 소켓 인터페이스와 함께 사용하면, IP 프로토콜의 특정 버전에 독립적인(protocol-independent) 네트워크 프로그램을 작성할 수 있습니다.

리눅스는 바이너리 소켓 주소 구조체와, 호스트 이름/주소, 서비스 이름/포트 번호의 문자열 표현 간의 상호 변환을 수행하는 getaddrinfogetnameinfo 함수를 제공합니다.

이 함수들은 소켓 인터페이스와 함께 사용될 때, IP 프로토콜의 특정 버전에 독립적인(protocol-independent) 네트워크 프로그램을 작성할 수 있게 해줍니다.


The getaddrinfo Function

getaddrinfo 함수는 호스트 이름, 호스트 주소, 서비스 이름, 포트 번호의 문자열 표현을 소켓 주소 구조체로 변환합니다.

  • 이는 더 이상 사용되지 않는(obsolete) gethostbyname, getservbyname 함수를 대체하는 현대적인 함수입니다.
  • getaddrinfo재진입 가능(reentrant)하며(12.7.2절 참조) 모든 프로토콜(IPv4/IPv6)과 함께 작동합니다.
#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);
    // 반환 값: 성공 시 0, 오류 시 0이 아닌 에러 코드

void freeaddrinfo(struct addrinfo *result);
    // 반환 값: 없음 (할당된 메모리 해제)

const char *gai_strerror(int errcode);
    // 반환 값: 에러 메시지 문자열

getaddrinfohost(호스트)와 service(서비스)를 입력받아, resultaddrinfo 구조체의 연결 리스트(linked list)를 가리키도록 반환합니다. 이 리스트의 각 노드는 hostservice에 해당하는 소켓 주소 구조체를 가리킵니다 (그림 11.15).

  • 클라이언트: getaddrinfo 호출 후, socketconnect가 성공할 때까지 이 리스트를 순회하며 각 소켓 주소를 시도합니다.
  • 서버: socketbind가 성공할 때까지 이 리스트를 순회하며 각 소켓 주소를 시도합니다.
  • 메모리 누수 방지: 애플리케이션은 작업 완료 후 반드시 freeaddrinfo를 호출하여 리스트 메모리를 해제해야 합니다.
  • 오류 처리: getaddrinfo가 0이 아닌 에러 코드를 반환하면, gai_strerror를 호출하여 메시지 문자열로 변환할 수 있습니다.

getaddrinfo 인자 상세

  • host: 도메인 이름 (www.google.com) 또는 숫자 주소 (128.2.194.242)가 될 수 있습니다. (NULL 가능)

  • service: 서비스 이름 (http) 또는 10진수 포트 번호 (80)가 될 수 있습니다. (NULL 가능)

  • (단, hostservice 중 적어도 하나는 지정되어야 합니다.)

  • hints (옵션):

    • getaddrinfo가 반환할 소켓 주소 리스트를 제어하기 위한 addrinfo 구조체입니다 ([그림 11.16] 참조).
    • hints 인자로 사용할 때는 ai_family, ai_socktype, ai_protocol, ai_flags 필드만 설정할 수 있습니다. (나머지는 0 또는 NULL이어야 함)
    • (실제로는 memset으로 구조체 전체를 0으로 초기화한 뒤, 필요한 필드만 설정합니다.)
  • hints의 주요 필드 설정:

    • ai_family:
      • 기본값: IPv4와 IPv6 주소 모두 반환.
      • AF_INET: 리스트를 IPv4 주소로 제한.
      • AF_INET6: 리스트를 IPv6 주소로 제한.
    • ai_socktype:
      • 기본값: 각 주소마다 최대 3개(connection, datagram, raw)의 addrinfo 구조체를 반환할 수 있음.
      • SOCK_STREAM: 리스트를 연결(connection)의 종단점으로 사용될 수 있는 소켓 주소(즉, TCP)로 제한합니다. (이 책의 모든 예제 프로그램에 해당)
    • ai_flags: (비트 마스크)
      • AI_ADDRCONFIG: (연결 사용 시 권장) 로컬 호스트가 IPv4로 설정된 경우에만 IPv4 주소를 반환합니다 (IPv6도 마찬가지).
      • AI_CANONNAME: 설정 시, 리스트의 첫 번째 addrinfo 구조체의 ai_canonname 필드가 host의 공식(canonical) 이름을 가리키도록 합니다.
      • AI_NUMERICSERV: service 인자가 서비스 이름("http")이 아닌 반드시 포트 번호 문자열("80")이어야 하도록 강제합니다.
      • AI_PASSIVE: (서버용) getaddrinfo가 (클라이언트의 connect용) 능동 소켓 주소 대신, (서버의 bind용) 수신 소켓 주소를 반환하도록 지시합니다.
        • 이 플래그 사용 시, host 인자는 NULL이어야 합니다.
        • 결과로 반환되는 소켓 주소 구조체 내의 주소는 와일드카드 주소(wildcard address)가 됩니다. 이는 서버가 이 호스트의 모든 IP 주소로 오는 요청을 수락하겠다는 의미입니다. (이 책의 모든 서버 예제에 해당)

결과 (Result) 및 프로토콜 독립성

getaddrinfo가 생성하는 addrinfo 구조체 리스트의 각 필드는 다음과 같습니다.

  • ai_addr: 소켓 주소 구조체(sockaddr_in 등)를 가리킵니다.
  • ai_addrlen: ai_addr이 가리키는 구조체의 크기입니다.
  • ai_next: 리스트의 다음 addrinfo 노드를 가리킵니다.

getaddrinfo의 강력한 점은, 반환된 addrinfo 구조체의 필드들이 애플리케이션 코드의 추가 조작 없이 소켓 인터페이스 함수에 직접 전달될 수 있다는 것입니다.

  • 예: ai_family, ai_socktype, ai_protocolsocket() 함수에 직접 전달
  • 예: ai_addr, ai_addrlenconnect() 또는 bind() 함수에 직접 전달

이 강력한 속성 덕분에, 우리는 IP 프로토콜의 특정 버전에 독립적인(protocol-independent) 클라이언트와 서버를 작성할 수 있습니다.

The getnameinfo Function

getnameinfo 함수는 getaddrinfo의 반대입니다. 이 함수는 소켓 주소 구조체(sockaddr)를 해당하는 호스트(host) 및 서비스(service) 이름 문자열로 변환합니다.

  • 이는 더 이상 사용되지 않는(obsolete) gethostbyaddr, getservbyport 함수를 대체하는 현대적인 함수입니다.
  • getnameinfo재진입 가능(reentrant)하며 프로토콜에 독립적(protocol-independent)입니다.
#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);
  • 반환 값: 성공 시 0, 오류 시 0이 아닌 에러 코드

getnameinfo 인자 상세

  • sa (입력): salen 바이트 크기의 소켓 주소 구조체 포인터입니다.
  • salen (입력): sa 구조체의 크기입니다.
  • host (출력): hostlen 바이트 크기의 버퍼이며, 변환된 호스트 이름 문자열이 이곳에 복사됩니다.
  • hostlen (입력): host 버퍼의 크기입니다.
  • service (출력): servlen 바이트 크기의 버퍼이며, 변환된 서비스 이름 문자열이 이곳에 복사됩니다.
  • servlen (입력): service 버퍼의 크기입니다.
  • (오류가 발생하면 gai_strerror를 사용해 에러 코드를 문자열로 변환할 수 있습니다.)
  • (호스트 이름이 필요 없으면 hostNULL로, hostlen을 0으로 설정할 수 있습니다. service도 마찬가지이며, 단 둘 다 NULL일 수는 없습니다.)
  • flags (입력): 기본 동작을 수정하는 비트 마스크입니다.
    • NI_NUMERICHOST: (Domain Name ➔ IP String)
      • 기본적으로 getnameinfohost에 도메인 이름을 반환하려 시도합니다.
      • 이 플래그를 설정하면, DNS 조회를 하지 않고 숫자 주소 문자열(예: "128.2.194.242")을 반환하도록 강제합니다.
    • NI_NUMERICSERV: (Service Name ➔ Port String)
      • 기본적으로 getnameinfo/etc/services 파일을 참조하여 포트 번호 대신 서비스 이름(예: "http")을 반환하려 시도합니다.
      • 이 플래그를 설정하면, 이 조회를 건너뛰고 단순히 포트 번호 문자열(예: "80")을 반환하도록 강제합니다.

hostinfo 예제 프로그램 (그림 11.17)

getaddrinfogetnameinfo를 함께 사용하여, 도메인 이름을 IP 주소로 매핑하는 간단한 프로그램 예시입니다. (nslookup과 유사)

  1. 먼저 hints 구조체를 초기화하여 getaddrinfo가 원하는 주소(IPv4, TCP)를 반환하도록 설정합니다.
  2. getaddrinfo를 호출하여 addrinfo 연결 리스트를 받습니다.
  3. 이 리스트를 순회하면서, 각 노드의 소켓 주소(p->ai_addr)를 getnameinfo 함수에 전달합니다.
  4. getnameinfo가 소켓 주소를 점 표기법(dotted-decimal) 문자열로 변환하여 출력합니다.
  5. freeaddrinfo로 리스트 메모리를 해제합니다.
  6. 실행 결과, twitter.com이 4개의 IP 주소로 매핑되는 것을 보여주며, 이는 nslookup 결과와 일치합니다.
linux> ./hostinfo [twitter.com](http://twitter.com/)
199.16.156.102
199.16.156.230
199.16.156.6
199.16.156.70

11.4.8 소켓 인터페이스 헬퍼 함수 (Helper Functions for the Sockets Interface)

getaddrinfo 함수와 소켓 인터페이스는 처음 배울 때 다소 위협적으로(daunting) 보일 수 있습니다.

이것들을 상위 수준의 헬퍼 함수(helper functions)로 감싸는 것이 편리하며, 여기서는 클라이언트와 서버가 서로 통신하고자 할 때 사용할 수 있는 open_clientfdopen_listenfd라는 헬퍼 함수를 호출할 것입니다.

The open_clientfd Function

클라이언트는 open_clientfd를 호출하여 서버와 연결을 수립합니다.

#include "csapp.h"

int open_clientfd(char *hostname, char *port);
  • 반환 값: 성공 시 디스크립터, 오류 시 -1

open_clientfd 함수는 hostname 호스트에서 실행 중이고 port 번호에서 연결 요청을 수신 대기 중인 서버와 연결을 수립합니다. 이 함수는 Unix I/O 함수를 사용한 입출력이 준비된 열린 소켓 디스크립터를 반환합니다.

[그림 11.18]은 open_clientfd의 코드를 보여줍니다.

  1. getaddrinfo를 호출하여 addrinfo 구조체의 리스트를 반환받습니다. (이 리스트의 각 노드는 서버와 연결을 수립하는 데 적합한 소켓 주소 구조체를 가리킵니다.)
  2. socketconnect 호출이 성공할 때까지, 이 리스트를 순회하며 각 항목을 차례로 시도합니다.
  3. connect가 실패하면, 다음 항목을 시도하기 전에 close를 호출하여 소켓 디스크립터를 닫습니다.
  4. connect가 성공하면, 리스트 메모리를 freeaddrinfo로 해제하고, 소켓 디스크립터를 클라이언트에게 반환합니다.
  5. 클라이언트는 이 디스크립터를 즉시 Unix I/O에 사용할 수 있습니다.

코드 어디에도 특정 IP 버전에 대한 의존성이 없다는 점에 주목하십시오. socketconnect의 인자들은 getaddrinfo에 의해 자동으로 생성되며, 이는 코드를 깔끔하고 이식성 있게(portable) 만들어 줍니다.

The open_listenfd Function

서버는 open_listenfd 함수를 호출하여 연결 요청을 받을 준비가 된 수신 디스크립터(listening descriptor)를 생성합니다.

#include "csapp.h"

int open_listenfd(char *port);
  • 반환 값: 성공 시 디스크립터, 오류 시 -1

open_listenfd 함수는 port 포트에서 연결 요청을 받을 준비가 된 수신 디스크립터를 반환합니다. [그림 11.19]는 이 코드를 보여줍니다.

  • open_clientfd와 스타일이 비슷합니다. getaddrinfo를 호출한 다음, socketbind가 성공할 때까지 반환된 리스트를 순회합니다.
  • (20행에서) setsockopt 함수(여기서 설명하진 않음)를 사용하여, 서버가 종료되고 재시작되었을 때 즉시 연결 요청을 수락할 수 있도록 설정합니다.
    • (기본적으로, 재시작된 서버는 약 30초 동안 클라이언트의 연결 요청을 거부하며, 이는 디버깅을 심각하게 방해합니다.)
  • getaddrinfoAI_PASSIVE 플래그와 NULL host 인자로 호출했기 때문에, 각 소켓 주소 구조체의 주소 필드는 와일드카드 주소(wildcard address)로 설정됩니다.
    • (이는 커널에게 이 서버가 이 호스트의 모든 IP 주소로 오는 요청을 수락할 것임을 알려줍니다.)
  • 마지막으로, listen 함수를 호출하여 listenfd를 수신 디스크립터로 변환한 후 호출자에게 반환합니다. (listen이 실패하면, 반환하기 전에 디스크립터를 close하여 메모리 누수를 방지합니다.)

11.4.9 예제: 에코 클라이언트와 서버 (Example Echo Client and Server)

소켓 인터페이스를 배우는 가장 좋은 방법은 예제 코드를 연구하는 것입니다.

에코 클라이언트 (Echo Client) - [그림 11.20]

  1. open_clientfd를 호출하여 서버와 연결을 수립합니다.
  2. 루프(loop)에 진입하여 다음을 반복합니다.
    • 표준 입력(stdin)에서 텍스트 한 줄을 읽습니다. (fgets)
    • 텍스트 라인을 서버로 전송합니다. (Rio_writen)
    • 서버로부터 메아리(echo) 라인을 다시 읽습니다. (Rio_readlineb)
    • 결과를 표준 출력(stdout)에 인쇄합니다. (Fputs)
  3. fgets가 표준 입력에서 EOF (Ctrl+D 또는 파일 끝)를 만나면 루프가 종료됩니다.
  4. 루프가 종료되면, 클라이언트는 디스크립터를 닫습니다 (Close(clientfd)).
    • close 호출은 서버에게 EOF 알림을 전송하게 됩니다.
    • (서버는 rio_readlineb가 0을 반환할 때 이 EOF를 감지합니다.)
  5. (프로세스가 종료될 때 커널이 자동으로 모든 디스크립터를 닫으므로 24행의 close가 필수는 아니지만, 명시적으로 닫는 것이 좋은 프로그래밍 습관입니다.)

에코 서버 (Echo Server) - [그림 11.21]

  1. open_listenfd를 호출하여 수신 디스크립터를 엽니다.
  2. 무한 루프에 진입합니다.
  3. 각 반복(iteration)마다 다음을 수행합니다.
    • accept를 호출하여 클라이언트의 연결 요청을 기다립니다.
    • 연결된 클라이언트의 도메인 이름과 포트를 출력합니다. (Getnameinfo 사용)
    • 클라이언트를 서비스하는 echo 함수를 호출합니다.
    • echo 루틴이 반환되면 (클라이언트가 연결을 끊으면), 연결된 디스크립터(connfd)를 닫습니다. (Close(connfd))
  • clientaddr (9행) 변수는 accept에 전달됩니다. acceptclientaddr를 클라이언트의 소켓 주소로 채웁니다.
  • struct sockaddr_storage: clientaddr의 타입을 sockaddr_in이 아닌 sockaddr_storage로 선언한 것에 주목하십시오.
    • sockaddr_storage 구조체는 모든 타입의 소켓 주소(IPv4, IPv6 등)를 담을 수 있을 만큼 충분히 크도록 정의되어 있습니다.
    • 이는 코드를 프로토콜 독립적(protocol-independent)으로 유지해 줍니다.
  • 이 간단한 에코 서버는 한 번에 오직 하나의 클라이언트만 처리할 수 있습니다. 이렇게 한 번에 한 클라이언트씩 반복 처리하는 서버를 반복 서버(iterative server)라고 부릅니다. (12장에서 여러 클라이언트를 동시에 처리하는 동시성 서버를 배웁니다.)

echo 루틴 (Function) - [그림 11.22]

  • rio_readlineb 함수가 (클라이언트의 close로 인한) EOF를 만나 0을 반환할 때까지 (10행), 텍스트 라인을 반복적으로 읽고 다시 써넣습니다(echo).

보충: 연결에서의 EOF는 무엇을 의미하는가? (Aside: What does EOF on a connection mean?)

EOF의 개념은 (특히 인터넷 연결 문맥에서) 종종 혼란스러울 수 있습니다.

  • EOF 문자라는 것은 존재하지 않습니다.
  • EOF는 커널(kernel)에 의해 감지되는 '상태(condition)'입니다.
  • 애플리케이션은 read 함수로부터 0 (zero)의 반환 값을 받았을 때 EOF 상태임을 알게 됩니다.

EOF가 발생하는 시점은 대상이 무엇이냐에 따라 다릅니다.

  • 디스크 파일: 현재 파일 위치가 파일 길이를 초과할 때 EOF가 발생합니다.
  • 인터넷 연결: 한 프로세스가 자신의 연결 종단을 닫을 때(close) EOF가 발생합니다.

연결의 다른 쪽 끝에 있는 프로세스는 (상대방이 연결을 닫은 후) 스트림의 마지막 바이트를 지나서 read를 시도할 때, read가 0을 반환하면서 이 EOF 상태를 감지하게 됩니다.

11.5.1 웹 기본 (Web Basics)

웹 클라이언트와 서버는 HTTP (Hypertext Transfer Protocol)라는 텍스트 기반 애플리케이션 수준 프로토콜을 사용하여 상호 작용합니다.

  • HTTP의 동작:
    1. 웹 클라이언트(브라우저)가 서버로의 인터넷 연결을 엽니다.
    2. 콘텐츠를 요청(Request)합니다.
    3. 서버는 요청된 콘텐츠로 응답(Response)한 다음, 연결을 닫습니다.
    4. 브라우저는 콘텐츠를 읽어 화면에 표시합니다.
  • 웹 vs FTP (차이점):
    • 웹 서비스가 FTP와 같은 기존 파일 검색 서비스와 구별되는 주된 차이점은 웹 콘텐츠가 HTML (Hypertext Markup Language)로 작성될 수 있다는 점입니다.
  • HTML (Hypertext Markup Language):
    • HTML 프로그램(페이지)은 페이지 내의 다양한 텍스트와 그래픽 객체를 어떻게 표시할지 브라우저에 알려주는 태그(tags)를 포함합니다.
    • (예: <b> Make me bold! </b>는 텍스트를 굵게 표시하라고 지시합니다.)
    • HTML의 진정한 힘은, 페이지가 인터넷 호스트 어디에나 저장된 콘텐츠를 가리키는 포인터(하이퍼링크, hyperlinks)를 포함할 수 있다는 점입니다.
    • (예: <a href="http://www.cmu.edu/index.html">Carnegie Mellon</a>)
    • 사용자가 하이퍼링크(Carnegie Mellon)를 클릭하면, 브라우저는 CMU 서버에 해당 HTML 파일을 요청하고 표시합니다.

11.5.2 웹 콘텐츠 (Web Content)

웹 클라이언트와 서버에게 콘텐츠(content)는 연관된 MIME(Multipurpose Internet Mail Extensions) 타입([그림 11.23])을 가진 바이트의 시퀀스입니다.

웹 서버는 두 가지 다른 방식으로 클라이언트에게 콘텐츠를 제공합니다.

  • 정적 콘텐츠 (Static Content): 디스크 파일을 가져와서 그 내용을 클라이언트에게 반환합니다.
  • 동적 콘텐츠 (Dynamic Content): 실행 파일을 실행하고 그 출력(output)을 클라이언트에게 반환합니다.

URL (Universal Resource Locator)

웹 서버가 반환하는 모든 콘텐츠는 URL(Universal Resource Locator)이라는 고유한 이름을 가진 파일과 연관됩니다.

  • 예 (정적): http://www.google.com:80/index.html
    • www.google.com 호스트의 80번 포트에서 수신 대기 중인 웹 서버가 관리하는 /index.html 파일을 식별합니다. (포트 80은 HTTP의 잘 알려진 포트이며 생략 가능합니다.)
  • 예 (동적): http://bluefish.ics.cs.cmu.edu:8000/cgi-bin/adder?15000&213
    • /cgi-bin/adder라는 실행 파일을 식별하며, 15000213이라는 두 문자열 인자와 함께 호출될 것입니다.
    • ? 문자는 파일 이름과 인자를 구분합니다.
    • & 문자는 각 인자를 구분합니다.

URL의 사용

클라이언트와 서버는 트랜잭션 동안 URL의 서로 다른 부분을 사용합니다.

  • 클라이언트: http://www.google.com:80 같은 접두사(prefix)를 사용하여, 어떤 종류의 서버에, 어디로, 무슨 포트로 접속할지 결정합니다.
  • 서버: /index.html 같은 접미사(suffix)를 사용하여, 자신의 파일 시스템에서 파일을 찾고, 이 요청이 정적인지 동적인지 결정합니다.

서버의 URL 접미사 해석 규칙

서버가 URL의 접미사를 해석하는 방법에 대해 이해해야 할 몇 가지 사항이 있습니다.

  • 표준 규칙이 없음: URL이 정적 콘텐츠를 가리키는지 동적 콘텐츠를 가리키는지 결정하는 표준 규칙은 없습니다. 각 서버는 자신이 관리하는 파일에 대해 고유한 규칙을 가집니다. (고전적인 접근 방식은 cgi-bin처럼 모든 실행 파일이 있어야 하는 디렉터리 집합을 식별하는 것입니다.)
  • /는 루트 디렉터리가 아님: 접미사의 첫 번째 /는 리눅스 루트 디렉터리를 의미하지 않습니다. 대신, 요청되는 콘텐츠 종류에 대한 홈 디렉터리를 나타냅니다. (예: 서버는 정적 콘텐츠는 /usr/httpd/html에, 동적 콘텐츠는 /usr/httpd/cgi-bin에 저장하도록 구성될 수 있습니다.)
  • 최소 접미사 /: 최소 URL 접미사는 / 문자이며, 모든 서버는 이를 /index.html과 같은 기본 홈 페이지로 확장합니다. (이것이 브라우저에 도메인 이름만 입력해도 사이트의 홈 페이지를 가져올 수 있는 이유입니다. 브라우저는 빠진 /를 URL에 추가하고, 서버는 이 /를 기본 파일 이름으로 확장합니다.)

11.5.3 HTTP 트랜잭션 (HTTP Transactions)

HTTP는 인터넷 연결을 통해 텍스트 라인을 기반으로 하므로, 리눅스 telnet 프로그램을 사용하여 인터넷의 모든 웹 서버와 트랜잭션을 수행할 수 있습니다.

telnet 프로그램은 (원격 로그인 도구로서는 ssh에 의해 대체되었지만) 텍스트 라인으로 통신하는 서버를 디버깅하는 데 매우 유용합니다.

[그림 11.24]는 telnet을 사용해 AOL 웹 서버의 홈 페이지를 요청하는 예시입니다.

  1. (1행) telnet을 실행하여 AOL 웹 서버(www.aol.com 80)로의 연결을 엽니다.
  2. telnet은 터미널에 3줄을 출력하고, 연결을 연 다음, 사용자가 텍스트를 입력하기를 기다립니다 (5행).
  3. 사용자가 텍스트 라인을 입력하고 엔터 키를 칠 때마다, telnet은 그 라인을 읽어 \r\n (캐리지 리턴 및 라인 피드) 문자를 덧붙여 서버로 전송합니다.
    • (이는 모든 HTTP 텍스트 라인이 \r\n 쌍으로 종료되어야 한다는 HTTP 표준과 일치합니다.)
  4. 트랜잭션을 시작하기 위해 HTTP 요청(request)을 입력합니다 (5-7행).
  5. 서버는 HTTP 응답(response)으로 답장합니다 (8-17행).
  6. 그런 다음 서버가 연결을 닫습니다 (18행).

HTTP 요청 (HTTP Requests)

HTTP 요청은 요청 라인(request line)(5행), 0개 이상의 요청 헤더(request headers)(6행), 그리고 헤더 목록을 종료하는 빈 텍스트 라인(7행)으로 구성됩니다.

1. 요청 라인 (Request Line)

요청 라인은 다음과 같은 형식을 가집니다.

method URI version

  • method (메서드):
    • HTTP는 GET, POST, OPTIONS, HEAD, PUT, DELETE, TRACE 등 여러 메서드를 지원합니다.
    • 이 책에서는 HTTP 요청의 대다수를 차지하는 GET 메서드만 다룹니다.
    • GET 메서드는 서버에게 URI로 식별되는 콘텐츠를 생성하고 반환하라고 지시합니다.
  • URI (Uniform Resource Identifier):
    • 해당 URL의 접미사(suffix)이며, 파일 이름과 선택적 인자들을 포함합니다.
  • version (버전):
    • 요청이 준수하는 HTTP 버전을 나타냅니다 (예: HTTP/1.1, HTTP/1.0).
    • HTTP/1.1: 캐싱, 보안 등 고급 기능을 위한 추가 헤더와, 클라이언트/서버가 동일한 지속적 연결(persistent connection)을 통해 여러 트랜잭션을 수행할 수 있는 메커니즘을 정의합니다.
    • (HTTP/1.0과 HTTP/1.1은 호환됩니다. 1.0 버전은 알 수 없는 1.1 헤더를 무시합니다.)

[그림 11.24]의 5행 요약: 서버에 /index.html 파일을 가져와 반환하도록 요청하며, 나머지 요청은 HTTP/1.1 형식임을 알립니다.

2. 요청 헤더 (Request Headers)

요청 헤더는 브라우저 이름이나 브라우저가 이해하는 MIME 타입 같은 부가적인 정보를 서버에 제공합니다.

header-name: header-data

  • Host 헤더 (6행):
    • 우리가 신경 써야 할 유일한 헤더입니다.
    • HTTP/1.1 요청에는 필수이지만, HTTP/1.0에는 필수가 아닙니다.
    • 이 헤더는 프록시 캐시(proxy caches)에 의해 사용됩니다.
    • Host 헤더의 데이터(원본 서버의 도메인 이름)는 프록시가 요청된 콘텐츠의 캐시된 복사본을 로컬에 가지고 있는지 판별할 수 있게 해줍니다.

3. 빈 줄 (Empty Line)

  • 헤더의 끝을 알립니다 (7행).
  • (telnet 예제에서는 키보드로 엔터 키를 치는 것에 해당합니다.)
  • 이 빈 줄은 서버에게 요청된 HTML 파일을 전송하라고 지시하는 신호가 됩니다.

HTTP 응답 (HTTP Responses)

HTTP 응답은 HTTP 요청과 유사합니다. HTTP 응답은 다음과 같이 구성됩니다.

  • 응답 라인 (response line) (8행)
  • 0개 이상의 응답 헤더 (response headers) (9-13행)
  • 헤더를 종료하는 빈 줄 (empty line) (14행)
  • 응답 본문 (response body) (15-17행)

1. 응답 라인 (Response Line)

응답 라인은 다음과 같은 형식을 가집니다.

version status-code status-message

  • version: 응답이 준수하는 HTTP 버전입니다.
  • status-code: 요청의 처리 상태를 나타내는 3자리 양의 정수입니다.
  • status-message: 상태 코드에 해당하는 영어 메시지입니다.
  • ([그림 11.25]는 일반적인 상태 코드와 메시지 목록을 보여줍니다.)


2. 응답 헤더 (Response Headers)

응답 헤더(9-13행)는 응답에 대한 부가적인 정보를 제공합니다. 우리에게 가장 중요한 두 헤더는 다음과 같습니다.

  • Content-Type (12행): 클라이언트에게 응답 본문에 포함된 콘텐츠의 MIME 타입을 알려줍니다.
  • Content-Length (13행): 응답 본문의 크기(바이트)를 나타냅니다.

3. 빈 줄 및 응답 본문

응답 헤더를 종료하는 빈 텍스트 라인(14행) 뒤에는, 요청된 실제 콘텐츠가 포함된 응답 본문(response body)이 이어집니다.

11.5.4 동적 콘텐츠 제공 (Serving Dynamic Content)

서버가 클라이언트에게 동적 콘텐츠를 어떻게 제공하는지 생각해보면 몇 가지 질문이 생깁니다.

  • 클라이언트는 프로그램 인자(arguments)를 서버에 어떻게 전달하는가?
  • 서버는 이 인자들을 (자신이 생성한) 자식 프로세스에 어떻게 전달하는가?
  • 서버는 콘텐츠 생성에 필요한 다른 정보를 자식에게 어떻게 전달하는가?
  • 자식 프로세스는 자신의 출력을 어디로 보내는가?

이러한 질문들은 CGI (Common Gateway Interface)라는 사실상의 표준(de facto standard)에 의해 해결됩니다.


클라이언트가 프로그램 인자를 서버에 어떻게 전달하는가?

  • GET 요청의 인자들은 URI에 담겨 전달됩니다.
  • ? 문자가 파일 이름과 인자들을 구분합니다.
  • & 문자가 각 인자를 구분합니다.
  • 인자에는 공백이 허용되지 않으며, %20 문자열로 표현(인코딩)되어야 합니다. (다른 특수 문자에도 유사한 인코딩이 존재합니다.)

서버는 인자를 자식에게 어떻게 전달하는가?

서버가 다음과 같은 요청을 받으면:

GET /cgi-bin/adder?15000&213 HTTP/1.1

  1. 서버는 fork를 호출하여 자식 프로세스를 생성합니다.
  2. 그런 다음 execve를 호출하여 자식 프로세스의 컨텍스트에서 /cgi-bin/adder 프로그램을 실행합니다.
  3. adder와 같이 CGI 표준 규칙을 따르는 프로그램을 CGI 프로그램이라고 합니다.
  4. execve를 호출하기 전에, 자식 프로세스는 CGI 환경 변수(environment variable)QUERY_STRING의 값을 15000&213으로 설정합니다.
  5. adder (CGI) 프로그램은 실행 시점에 리눅스의 getenv 함수를 사용하여 이 QUERY_STRING 변수를 참조할 수 있습니다.

서버는 다른 정보를 자식에게 어떻게 전달하는가?

CGI는 CGI 프로그램이 실행될 때 설정될 것으로 기대할 수 있는 여러 다른 환경 변수들을 정의합니다. [그림 11.26]은 이 변수들의 일부를 보여줍니다.


자식은 자신의 출력을 어디로 보내는가?

CGI 프로그램은 자신의 동적 콘텐츠를 표준 출력(standard output)으로 보냅니다.

  1. 자식 프로세스는 CGI 프로그램을 로드하고 실행하기 전에, 리눅스 dup2 함수를 사용합니다.
  2. dup2를 이용해, 자식 프로세스 자신의 표준 출력(FD 1)을 클라이언트와 연결된 연결 디스크립터(connfd)리디렉션(redirection)시킵니다.
  3. 결과적으로, CGI 프로그램이 표준 출력으로 쓰는(printf 등) 모든 것즉시 클라이언트에게로 직접 전송됩니다.

부모(서버)는 자식이 생성할 콘텐츠의 타입이나 크기를 모르기 때문에, Content-typeContent-length 응답 헤더, 그리고 헤더를 종료하는 빈 줄을 생성하는 것은 자식(CGI 프로그램)의 책임입니다.

[그림 11.27]은 두 인자를 합산하고 그 결과가 담긴 HTML 파일을 클라이언트에게 반환하는 간단한 CGI 프로그램(adder)을 보여줍니다. [그림 11.28]은 이 adder 프로그램의 동적 콘텐츠를 제공하는 HTTP 트랜잭션 예시를 보여줍니다.

11.6 종합: Tiny 웹 서버 (The Tiny Web Server)

네트워크 프로그래밍에 대한 논의를 Tiny라는 작지만 작동하는 웹 서버를 개발하며 마무리합니다.

  • Tiny는 프로세스 제어, Unix I/O, 소켓 인터페이스, HTTP와 같이 우리가 배운 많은 아이디어들을 단 250줄의 코드로 결합한 흥미로운 프로그램입니다.
  • 실제 서버의 기능, 견고성, 보안은 부족하지만, 정적 및 동적 콘텐츠를 실제 웹 브라우저에 제공할 만큼은 강력합니다.

Tiny main 루틴 (The Tiny main Routine)

[그림 11.29]는 Tiny의 main 루틴을 보여줍니다.

  • Tiny는 명령줄 인자로 전달된 포트에서 연결 요청을 수신 대기하는 반복 서버(iterative server)입니다.
  • open_listenfd 함수를 호출하여 수신 소켓을 연 후, Tiny는 전형적인 무한 서버 루프를 실행합니다.
  • 이 루프는 다음 작업을 반복합니다.
    1. (32행) 연결 요청을 수락하고 (accept)
    2. (36행) 트랜잭션을 수행하며 (doit)
    3. (37행) 연결의 서버 측 종단을 닫습니다 (close).

read_requesthdrs (헤더 읽기 및 무시)

이 함수의 목적은 HTTP 요청 메시지에서 요청 라인(request line) 뒤에 따라오는 헤더들 (예: User-Agent: ..., Accept: ... 등)을 읽되, 실제로 사용하지 않고 그냥 넘겨버리는(무시하는) 것입니다.

  • Rio_readlineb(rp, buf, MAXLINE); (5행): 루프에 진입하기 전, 첫 번째 헤더 라인을 읽습니다.
  • while(strcmp(buf, "\r\n")) { ... } (6행): 이것이 핵심입니다.
    • HTTP 명세상, 헤더 부분의 끝은 내용이 없는 빈 줄로 표시되며, 이 빈 줄은 \r\n (CRLF) 문자로만 구성됩니다.
    • strcmp(buf, "\r\n")buf의 내용이 \r\n다르면 0이 아닌 값(true)을 반환하고, 같으면 0(false)을 반환합니다.
    • 따라서 이 while 루프는 "읽어들인 줄(buf)이 빈 줄(\r\n)이 아닌 동안" 계속 반복 실행됩니다.
  • 루프 내부 (7, 8행):
    • Rio_readlineb(...): 다음 헤더 줄을 읽습니다. (이전 헤더 내용은 덮어써짐)
    • printf(...): 읽은 헤더를 (파싱하지 않고) 서버의 콘솔에 그냥 출력합니다. (디버깅 목적)
  • 루프가 종료되면 (\r\n을 만나면) 함수가 return됩니다.

결론: read_requesthdrs는 서버가 당장 처리할 필요가 없는 HTTP 헤더들을 모두 읽어서 소켓 버퍼에서 비우는(consume) 역할을 합니다.


clienterror (오류 응답 전송)

이 함수는 함께 올려주신 텍스트 설명 그대로, 클라이언트에게 완전한 HTTP 오류 응답을 생성하여 전송하는 역할을 합니다.

이전 질문에서 보신 if (strcasecmp(method, "GET")) { ... } 코드 블록이 이 함수를 호출하는 예시입니다.

  • HTML 본문 생성 (7~11행):
    • sprintf를 반복 사용해 body 버퍼에 오류 메시지를 담은 간단한 HTML 페이지를 만듭니다.
  • HTTP 응답 전송 (14~20행):
    • 단순히 HTML만 보내는 것이 아니라, HTTP 프로토콜 형식에 맞춰 상태 라인헤더를 먼저 보냅니다.
    • sprintf(buf, "HTTP/1.0 %s %s\r\n", ...): 상태 라인 (예: HTTP/1.0 404 Not Found\r\n)
    • sprintf(buf, "Content-type: text/html\r\n"): 응답 본문이 HTML임을 알리는 헤더
    • sprintf(buf, "Content-Length: %d\r\n\r\n", ...): 응답 본문의 길이를 알리는 헤더 (뒤에 \r\n이 두 번 붙어 헤더의 끝을 알림)
    • Rio_writen(fd, body, ...): 실제 HTML body 내용을 전송합니다.

결론: clienterror는 서버가 요청을 처리할 수 없을 때(예: 404 Not Found, 501 Not Implemented), HTTP 규칙에 맞는 응답을 만들어 클라이언트에게 전송하는 함수입니다.

parse_uri

parse_uri 함수는 클라이언트가 요청한 URI를 분석(parsing)하여, 이것이 정적 컨텐츠(Static Content) 요청인지 동적 컨텐츠(Dynamic Content) 요청인지 구분하는 역할을 합니다.

분석한 결과를 filenamecgiargs라는 두 개의 출력 변수에 저장하고, 정적/동적 여부를 정수(1 또는 0)로 반환합니다.

함께 제공된 텍스트의 설명처럼, 이 함수는 "URI에 cgi-bin이 포함되어 있으면 동적, 아니면 정적"이라는 규칙을 따릅니다.


1. 정적 컨텐츠 (Static Content) 처리 (5-12행)

  • 조건: if (!strstr(uri, "cgi-bin"))
    • strstr 함수는 문자열에서 "cgi-bin"을 찾습니다.
    • ! (NOT)이 붙었으므로, URI에 cgi-bin이라는 문자열이 없을 때 이 블록이 실행됩니다.
  • 동작:
    • strcpy(cgiargs, "");: 정적 컨텐츠는 CGI 인자가 필요 없으므로 cgiargs를 빈 문자열로 초기화합니다.
    • strcpy(filename, "."); strcat(filename, uri);: filename. (현재 디렉토리)와 uri를 합쳐 (예: ./index.html) 상대 경로로 만듭니다.
    • if (uri[strlen(uri)-1] == '/'): 만약 URI가 /로 끝난다면 (예: /), 기본 파일명인 home.htmlfilename 뒤에 덧붙입니다.
  • 반환: return 1;
    • 호출한 함수에게 "이것은 정적 컨텐츠 요청이다"라고 알리기 위해 1을 반환합니다.

2. 동적 컨텐츠 (Dynamic Content) 처리 (13-24행)

  • 조건: else
    • URI에 cgi-bin 문자열이 있을 때 실행됩니다.
  • 동작:
    • ptr = index(uri, '?');: index 함수(오래된 strchr 함수)를 사용해 ? 문자를 찾습니다. ?는 실행 파일 경로와 CGI 인자를 구분하는 문자입니다.
    • 만약 ?가 있다면 (인자가 있다면):
      • strcpy(cgiargs, ptr+1);: ? 문자 다음부터 끝까지의 모든 문자열(예: a=1&b=2)을 cgiargs 변수에 복사합니다.
      • ptr = '\0';: (핵심) 원본 uri 문자열에서 ?가 있던 위치를 \0 (NULL 문자)로 덮어써서 문자열을 강제로 끝내버립니다.
    • 만약 ?가 없다면 (인자가 없다면):
      • strcpy(cgiargs, "");: cgiargs를 빈 문자열로 초기화합니다.
    • strcpy(filename, "."); strcat(filename, uri);: filename.와 (이제는 ? 뒤가 잘려나간) uri를 합쳐 (예: ./cgi-bin/adder) 실행 파일 경로를 만듭니다.
  • 반환: return 0;
    • 호출한 함수에게 "이것은 동적 컨텐츠 요청이다"라고 알리기 위해 0을 반환합니다.
입력 urifilename (출력)cgiargs (출력)반환 값
"/favicon.ico""./favicon.ico"""1 (정적)
"/""./home.html"""1 (정적)
"/cgi-bin/adder""./cgi-bin/adder"""0 (동적)
"/cgi-bin/adder?a=1&b=2""./cgi-bin/adder""a=1&b=2"0 (동적)

serve_static 함수

이 함수는 HTTP 응답의 전체 과정을 처리합니다. 즉, HTTP 헤더를 만들고, 요청된 파일의 내용을 읽어 클라이언트에게 전송합니다.

주요 동작 단계

  1. 파일 타입 결정 (7행)
    • get_filetype(filename, filetype);
    • get_filetype 헬퍼 함수를 호출하여 전송할 파일의 Content-Type (예: "text/html", "image/gif")을 결정합니다.
  2. HTTP 응답 헤더 생성 및 전송 (8-13행)
    • sprintf를 사용해 buf 버퍼에 HTTP 응답 헤더를 만듭니다.
    • HTTP/1.0 200 OK: 성공 상태 코드를 보냅니다.
    • Server: Tiny Web Server: 서버 이름을 알립니다.
    • Connection: close: 응답이 끝나면 연결을 닫겠다고 알립니다.
    • Content-length: %d: 전송할 파일의 크기(filesize)를 보냅니다.
    • Content-type: %s: get_filetype으로 알아낸 파일 타입(MIME 타입)을 보냅니다.
    • 헤더의 끝을 알리는 빈 줄(\r\n)까지 포함하여 Rio_writen으로 클라이언트에게 전송합니다.
  3. HTTP 응답 본문(파일 내용) 전송 (18-22행)
    • 여기가 이 코드의 핵심입니다. 파일을 효율적으로 전송하기 위해 mmap을 사용합니다.
    • srcfd = Open(...): 전송할 파일을 read-only로 엽니다.
    • srcp = Mmap(...): (중요) mmap 시스템 콜을 사용해, srcfd 파일의 filesize 바이트만큼을 이 프로세스의 가상 메모리 영역에 직접 매핑합니다. srcp는 해당 메모리 영역의 시작 주소를 가리키는 포인터가 됩니다.
    • Close(srcfd): 파일이 메모리에 매핑되었으므로, 파일 디스크립터(fd)는 더 이상 필요 없어 바로 닫아줍니다. (리소스 누수 방지)
    • Rio_writen(fd, srcp, filesize): 메모리에 매핑된 파일의 내용 (srcp가 가리키는 곳)을 filesize만큼 읽어서 클라이언트 소켓(fd)으로 직접 전송합니다.

      (참고) read()로 버퍼에 읽고 write()로 소켓에 쓰는 방식보다, 커널 버퍼에서 유저 버퍼로의 복사 단계를 줄일 수 있어 더 효율적입니다.

    • Munmap(srcp, filesize): 파일 전송이 완료되면 mmap으로 할당했던 가상 메모리 영역을 해제합니다. (메모리 누수 방지)

2. get_filetype 함수

이 함수는 serve_static에서 호출하는 헬퍼 함수입니다.

주요 동작

  • strstr 함수를 사용해 filename 문자열에 특정 파일 확장자가 포함되어 있는지 확인합니다.
  • .html이 있으면 filetype에 "text/html"을 복사합니다.
  • .gif가 있으면 "image/gif"를 복사합니다.
  • .png.jpg도 마찬가지로 처리합니다.
  • 아무것에도 해당하지 않으면 기본값인 "text/plain"을 사용합니다.

serve_dynamic 함수는 'Tiny' 서버가 CGI(Common Gateway Interface) 표준을 사용해 동적 컨텐츠(예: 스크립트 실행 결과)를 처리하는 방법을 보여줍니다.

핵심 아이디어는 새로운 자식 프로세스를 생성(fork)하여 클라이언트의 요청을 처리할 외부 프로그램을 실행(execve)시키는 것입니다.


1. 1차 응답 전송 (5-9행)

  • fork를 호출하기 전에, 서버(부모 프로세스)는 먼저 클라이언트에게 HTTP 응답의 첫 부분을 보냅니다.
  • "HTTP/1.0 200 OK\r\n" (성공 상태 라인)
  • "Server: Tiny Web Server\r\n" (서버 정보 헤더)
  • 중요: 이 함수는 헤더의 끝을 알리는 \r\n\r\n이나 Content-Type 같은 헤더를 보내지 않습니다. 나머지 응답을 채우는 것은 전적으로 실행될 CGI 프로그램의 책임입니다. (함께 올려주신 텍스트 설명에도 이 부분이 언급됩니다.)

2. 자식 프로세스 생성 및 실행 (11-17행)

이 부분이 이 함수의 핵심입니다.

  • if (Fork() == 0) (11행):
    • 새로운 자식 프로세스를 생성합니다. Fork()는 자식 프로세스에게는 0을 반환하므로, if 블록 안의 코드는 자식 프로세스만 실행합니다.
  • setenv(...) (13행):
    • 클라이언트 요청 URI에서 파싱한 인자(cgiargs, 예: "a=1&b=2")를 QUERY_STRING이라는 환경 변수로 설정합니다. CGI 프로그램은 이 환경 변수를 읽어서 입력을 받습니다.
  • Dup2(fd, STDOUT_FILENO) (14행):
    • 가장 중요한 부분입니다. Dup2 함수를 사용해 자식 프로세스의 표준 출력(Standard Output, STDOUT_FILENO)의 대상(destination)을 클라이언트와 연결된 소켓(fd)으로 재지정(redirect)합니다.
  • Execve(...) (15행):
    • filename (예: ./cgi-bin/adder)으로 지정된 CGI 프로그램을 실행합니다.
    • execve가 호출되면, 현재 실행 중인 자식 프로세스(Tiny 서버의 복사본)의 코드는 filename의 코드로 완전히 대체됩니다.
    • 결과: 이제 CGI 프로그램이 printf 같은 표준 출력 함수를 사용해 무언가를 출력하면, 그 데이터는 (14행의 Dup2 때문에) 콘솔이 아닌 클라이언트 소켓으로 바로 전송됩니다.
  • Wait(NULL) (17행):
    • 이 코드는 if 블록 밖, 즉 부모 프로세스(서버)에 의해 실행됩니다.
    • 부모 프로세스는 자식 프로세스가 종료될 때까지 여기서 대기(block)합니다.
    • 자식이 종료되면 Wait는 자식 프로세스를 정리(reap)하여, 시스템에 리소스를 낭비하는 좀비 프로세스(zombie process)가 남지 않도록 합니다.

(Aside) 문제 상황: "Broken Pipe"

  1. 클라이언트가 먼저 연결을 종료합니다.
  2. 서버는 이 사실을 아직 모른 채, 해당 클라이언트 소켓에 write() (데이터 전송)를 시도합니다.
  3. 텍스트의 설명처럼, 첫 번째 write() 호출은 성공할 수 있습니다. 데이터가 커널의 TCP 전송 버퍼로 복사되기만 해도 함수는 성공을 반환하기 때문입니다.
  4. 커널이 이 데이터를 실제로 네트워크로 전송하려 할 때, 클라이언트로부터 "연결이 이미 닫혔다"는 응답(RST 패킷)을 받습니다.
  5. 이후 서버가 두 번째 write()를 시도하면, 커널은 이 프로세스에게 "더 이상 데이터를 보낼 수 없다"는 신호로 SIGPIPE 시그널을 보냅니다.
  6. 치명적인 결과: SIGPIPE 시그널의 기본 동작(Default behavior)은 해당 프로세스를 즉시 종료시키는 것입니다. 이로 인해 서버 전체가 다운(crash)될 수 있습니다.

해결책: SIGPIPE 무시 및 EPIPE 오류 처리

이 텍스트는 "강건한(Robust) 서버"가 되기 위한 두 가지 해결책을 제시합니다.

  1. SIGPIPE 시그널 무시 (Catch or Ignore):
    • 서버 초기화 시에 signal(SIGPIPE, SIG_IGN);과 같은 코드를 사용해 SIGPIPE 시그널을 명시적으로 무시하도록 설정해야 합니다.
    • 이렇게 하면 시그널로 인해 프로세스가 종료되는 최악의 상황을 막을 수 있습니다.
  2. write() 반환 값 확인:
    • SIGPIPE가 무시되면, 두 번째 write() 함수는 프로세스를 종료시키는 대신 1을 반환합니다.
    • 이때 전역 변수 errno*는 EPIPE** ("Broken pipe")로 설정됩니다.
    • 서버 코드는 write()의 반환 값이 1이고 errnoEPIPE인지 확인해야 합니다.
    • 이 오류를 감지하면, 서버는 "아, 클라이언트가 연결을 끊었구나"라고 인지하고 해당 연결을 안전하게 종료(close)한 뒤 다른 요청을 계속 처리해야 합니다.

요약

이 글은 write()가 실패하는 것 자체는 정상이지만, 그 실패로 인해 서버 프로세스 전체가 종료되는 것은 버그임을 강조합니다. 따라서 안정적인 서버는 반드시 SIGPIPE 시그널을 처리하고, write()EPIPE 오류를 확인하여 예외를 처리해야 한다는 것입니다.

11.7 요약

  • 클라이언트-서버 모델: 모든 네트워크 애플리케이션의 기본 구조입니다. 서버는 리소스(자원)를 관리하고, 클라이언트는 해당 리소스에 대한 서비스를 요청합니다.
  • 인터넷과 소켓: 프로그래머의 관점에서 인터넷은 IP 주소와 도메인 이름으로 식별되는 호스트들의 집합입니다. 이 호스트들은 소켓 인터페이스(Sockets Interface)를 통해 통신하며, 소켓은 파일 디스크립터(File Descriptor) 형태로 애플리케이션에 제공됩니다.
  • HTTP와 웹 서버: 웹 서버와 브라우저는 HTTP 프로토콜을 사용합니다.
  • 정적 vs. 동적 컨텐츠 (핵심):
    • 정적 컨텐츠 (Static): 서버의 디스크에서 파일을 읽어 클라이언트에게 반환합니다. (우리가 살펴본 serve_static 함수)
    • 동적 컨텐츠 (Dynamic): 서버가 자식 프로세스(child process)를 생성하여 외부 프로그램을 실행시키고, 그 프로그램의 표준 출력(stdout)을 클라이언트에게 반환합니다. (우리가 살펴본 serve_dynamic 함수)
  • CGI (Common Gateway Interface): 동적 컨텐츠를 처리할 때, 서버가 자식 프로세스에게 어떻게 인자(arguments)를 전달하고, 자식 프로세스가 어떻게 응답을 반환해야 하는지에 대한 표준 규칙입니다.
profile
멈추지 않기

0개의 댓글