행위

소켓 강좌

Network의 기본

네트워크의 기본적인 사항에 대해 먼저 알아 보도록 하겠습니다.
거의 인터넷 표준으로 자리잡은 TCP/IP에 대해서만 알아보도록 하겠습니다. 그러나 TCP/IP 주제만 가지고도 몇 개의 강좌를 해야 되므로, 자세한 내용은 다른 서적이나 강좌를 참고하세요.
제가 추천하는 책은

  • TCP/IP Illustrated, Volume 1 (W. Richard Stevens 저)
  • TCP/IP Protocol Suite (Behrouz A, Forouzan 저)

를 추천합니다. 둘 다 원서입니다. 영어가 부담스러우신 분은 각 서적에 대한 번역서도 있으니 번역의 질 등을 잘 알아보고 선택하시기 바랍니다.
다른 책을 참고하셔도 크게 상관은 없으나 간략한 소개만 되어 있는 것이 아닌 자세한 내용이 있는 것을 보세요. 왜냐하면 정확한 TCP/IP의 동작을 알아야지 네트워크에 오류가 있을 때 보다 쉽게 오류를 수정할 수 있기 때문입니다.

클라이언트와 서버구조

대부분 네트워크 프로그램은 서버와 클라이언트로 구분할 수 있습니다. 두 개를 구분하는 기준은 그 역할이 무엇이냐 입니다. 그 기준을 간단히 설명하면

  • 서버 : 클라이언트가 어떤 작업의 요청이 들어오면 요청을 처리하고 그 결과를 클라이언트에게 알려 주는 역할
  • 클라이언트 : 서버에게 작업을 요청하고 그 작업의 결과를 받아서 보여주는 역할

이렇게 설명할 수 있습니다.
클라이언트/서버 구조는(이하 C/S) 우리 일상에도 많은 예가 있습니다. 가장 흔한 예가 웹 브라우저와 웹 서버입니다. 인터넷 익스플로러나 네스케이프가 클라이언트 역할을 하고 접속한 그리고 우리에게 서비스를 제공하는 컴퓨터가 서버라고 생각하시면 됩니다. 정확히는 아파치나 IIS 같은 http 서버를 말합니다.

여기서 클라이언트와 서버의 차이는 클라이언트는 서버 하나와 통신을 하지만, 서버는 여러 개의 클라이언트를 상대합니다. 그래야만 더 많은 사용자들에게 서비스를 제공할 수 있습니다.

그러나 모든 것이 C/S 구조로 된 것은 아닙니다. 클라이언트와 서버 역할을 모두 하는 것도 있습니다. P2P(Peer To Peer)가 그것입니다. 대표적인 소프트웨어로 소리바다나 구루구루 같은 것들이 여기에 속합니다. P2P는 클라이언트와 서버의 역할을 동시에 하고 있습니다.(여기에 대한 내용은 http://extremendl.net 에서 논의되고 있으니 참고하세요.)

TCP/IP

TCP/IP 를 알아보기 전에 간단히 프로토콜(Protocol)의 정의에 대해서 알아보도록 하겠습니다.

"둘 이상의 통신 개체 사이에 교환되는 메시지의 형태, 의미, 전송 순서, 그리고 메시지 송수신 및 기타 사전에 수행할 동작을 정의한 규약"

두 개 이상의 호스트(컴퓨터) 사이에서 데이터를 약속에 의한 방법으로 주고 받자는 것입니다. TCP/IP 도 이런 프로토콜의 한 종류입니다. 보통 TCP/IP 라 부르지만 정확히는 TCP/IP 프로토콜 그룹(패밀리)라고 부릅니다. 그룹이므로 TCP와 IP 프로토콜만 있는 것은 아닙니다. TCP, UDP, ARP, RARP, ICMP 등 여러 가지가 같이 있는 프로토콜입니다. 여기에서 대표적인 프로토콜?TCP와 IP 이기 때문에 TCP/IP 라고 부릅니다.

네트워크 프로토콜들은 대부분 계층(Layer)이라는 개념을 가지고 있습니다. 여기서 OSI 7 Layer 를 낯?드리고 싶지만, 이번 글은 소켓?련?글이므로 다른 서적이나 강좌를 참고하세요. 이것은 꼭 알아야 할 기본적인 네트워크 개념이니 꼭 익히셔야 합니다.
계층의 개념을 간단히 말씀 드리면, 어떤 계층의 통신 상대의 같은 계층과 의미 있는 통신을 합니다. 그리고 각 계층들은 그 밑 계층이 제공하는 서비스를 이용하여 그 상위 계층에 서비스를 제공합니다. 만약 한 계층의 인터페이스가 변한다면, 그 바로 상위 계층에만 영향을 줍니다. 그리고 어느 한 계층에서 생성된 메시지들은 상대방의 같은 계층에서 분석되고 작동합니다. 무슨 말인지 조금 어렵게 느껴질 수 있습니다. 저도 계층 개념을 이해하는데 어려움을 느꼈습니다. 일단 OSI 7 Layer 를 보시면서 네트워크의 개념을 잡으시기 바랍니다. 간단히 그림을 보도록 하겠습니다.

album_pic.php?pic_id=45&.jpg
<그림> 네트워크 계층과 흐름

그럼 TCP/IP 의 계층은 어떻게 되어 있는지 알아보도록 하겠습니다.

album_pic.php?pic_id=46&.jpg
<그림> TCP/IP의 계층

TCP/IP는 위 그림과 같이 4계층으로 이루어져 있습니다. 혹은 링크 계층을 물리 계층과 데이터링크 계층으로 나누기도 하는데 크게 다르지 않습니다. OSI 7 Layer 를 보시면 큰 무리 없이 이해됩니다. 그럼 각 계층을 하나씩 살펴보도록 하겠습니다.

1) 링크 계층

물리적인 인터페이스와 관련된 하드웨어적인 부분을 제어합니다. 운영체제와 디바이스 드라이버나 그와 관련된 랜카드, 그와 연결된 케이블 같은 것을 제어하는 계층을 말합니다.

2) 네트워크 계층

네트워크상의 패킷의 이동을 제어하는 계층입니다. 패킷이라는 말이 처음 나왔는데, 패킷은 네트워크를 통해 데이터를 전달할 때 헤더와 데이터의 묶음을 말합니다. 정확히 이 계층에서는 IP 데이터그램이라고 합니다.(IP를 이용하여 신뢰성 없이 전달됩니다.) 이 패킷에는 송/수신지의 정보가 포함되어 있습니다. 이 계층의 역할은 한마디로 우편물의 주소와 같습니다. 어느 곳에 편지를 보낸다고 할 때, 여러 우체국을 거쳐 목적지의 우편함까지 옵니다. 우편물을 패킷이라면 우체국들은 라우터나, 게이트웨이라고 할 수 있습니다. 패킷이 가려고 하는 호스트(컴퓨터)까지의 이동을 담당하는 곳이 네트워크 계층입니다. 주로 IP가 이 역할을 하는데 IP는 신뢰성이 보장되지 않습니다. 확실히 갔는지 아닌지 알 수 없습니다. 또 다른 기능들이 많이 숨어 있는 계층이지만 여기까지 하도록 하겠습니다.

3) 트랜스포트 계층

상위 응용 층에 대해 두 호스트간의 데이터 흐름을 제공합니다. TCP/IP 에는 TCP 와 UDP라는 트랜스포트 프로토콜이 있습니다.

TCP는 위의 상위계층이 준 데이터를 목적지로 전달과 흐름제어의 기능을 제공합니다. 흐름의 제어란 데이터를 언제 보내야 하는지 얼마큼의 크기로 보내야 하는지 어떤 것을 보내야 하는지를 제어한다고 간단히 생각해도 될 것 같습니다. 위의 네트워크 계층에서 우편물과 같아 잘 보내어 졌는지 잘 받았는지 확인할 길이 없다고 했습니다. 그러나 TCP는 이러한 것까지 알아서 해줍니다. 즉 전화라고 보시면 됩니다. 우리가 타인에게 전화를 걸면, 신호음이 가고 상대편이 받을 때까지 기다립니다. 만약 상대방이 받지 않는다면, 우리는 다시 전화를 걸 수 있습니다. TCP도 마찬가지 입니다. 보내고 잘 받지 못했다면 다시 보내는 것이죠. TCP 는 네트워크 계층의 상위계층입니다. 이전 Layer 를 설명할 때, 하위계층의 인터페이스를 이용한다고 했습니다. 즉. TCP도 IP 데이터그램을 이용하여 정보가 전달 되는 것입니다. 그래서 패킷만 두고 본다면, TCP도 신뢰성이 없습니다. 그러나. IP의 상위 계층인 TCP는 이러한 점을 보안하여 서비스를 해줍니다. 즉, “시간이 얼마나 지났는데 와야 할 패킷이 안 온다 무언가 문제가 있다.” 이런 식으로 보안합니다. TCP는 연결지향 서비스이고 두 호스트간의 신뢰성 높은 데이터 흐름을 제공합니다. 연결지향이라는 말은 TCP는 데이터를 주고 받기 전에 클라이언트와 서버가 이제 연결해서 데이터를 주고 받겠다는 약속을 하는 것입니다. 그리고 데이터를 보내면 그 데이터가 반드시 목적지에 도착하고, (일정 시간 내에 받지 못하면 패킷이 손실 되었다고 보고, 다시 보내달라고 요청을 합니다.) 보낸 순서 또한 똑같다는 것입니다. TCP로 보내는 패킷을 TCP 세그먼트(segment)라고 보통 부릅니다.

UDP는 비연결형 서비스입니다. 즉, 클라이언트와 서버가 연결 약속은 하지 않고 바로 데이터를 주고 받는다는 것을 말합니다. 그리고 신뢰성이 없습니다. 즉, 데이터가 목적지에 반드시 도착하리라는 보장이 없습니다. 물론 보낸 순서도 마찬가지입니다. UDP로 보내는 패킷을 UDP 데이터그램(datagram)이라고 부릅니다.
주로 TCP를 사용하기는 합니다만 UDP도 쓰이는 곳도 많습니다. UDP가 속도가 비교적 빠르기 때문에 패킷 하나 없어져도 크게 관계없는 실시간 방송이 라던지 그런 곳에 쓰입니다. 요즘 게임에도 TCP 와 UDP를 같이 섞어서 많이 사용한다고 합니다.

예를 들어 TCP와 UDP를 조금 더 알아 봅시다. 만약 서버에서 3개의 패킷을 보낸다고 가정을 한다면 여러 가지 라우터나 게이트웨이들은 패킷의 IP를 보고 이 패킷의 경로를 정해 목적지까지 보내 줍니다. 3개의 패킷은 가는 도중의 네트워크의 상태에 따라서 다른 경로를 통해 전달 될 수도 있습니다. 1번 패킷이 도중에 손실되고 3번이 2번 보다 먼저 목적지에 도착할 수도 있습니다. 만약 TCP 연결이라면 1번이 도착하지 않았으므로 다시 1번을 보내달라고 하고 서버는 다시 1번부터 3번까지 보내 줍니다. 여기서 1번만 보낼 수도 있지만 알고리즘이 복잡하고 네트워크가 충분히 빠르므로 1, 2, 3을 모두 보냅니다. 그런데 UDP는 3, 2번 패킷을 그대로 받습니다. 1번이 있는 지도 모릅니다. 그냥 받은 대로 쓰는 것입니다. 그런 특성들은 프로그래머들이 짜는 응용 계층에서 별도로 처리를 해주어야 합니다.

4) 응용 계층

간단히 우리가 쓰는 네트워크 응용 프로그램을 말합니다.

IP

IP는 인터넷상의 고유의 주소입니다. 전세계에서 유일합니다. 4바이트(32비트)의 숫자로 구성된 주소입니다. 랜카드와 1:1로 대응 됩니다. 예를 들면 104.245.123.24과 같은 식으로 되어있습니다. 이런 표시 방식을 dotted-decimal 방식이라고 합니다. 사람이 알기 쉽게 이런 식으로 쓰는 것입니다. 실제로는 11010100110... 이런 식으로 되어야 컴퓨터가 알아 볼 수 있습니다. IP는 클래스 A, 클래스 B, 클래스 C, 클래스 D, 클래스 E 가 있습니다.

클래스     | 범위
A 클래스  | 0.0.0.0 - 127.255.255.255
B 클래스  | 128.0.0.0 - 191.255.255.255
C 클래스  | 192.0.0.0 - 233.255.255.255
D 클래스  | 224.0.0.0 - 239.255.255.255
E 클래스  | 240.0.0.0 - 255.255.255.255

각 클래스들은 이런 범위를 가지고 있습니다. 클래스 E는 나중을 위해 예약되어 있는 클래스 입니다. 클래스 D는 멀티캐스트를 위한 IP입니다. 한마디로 우편물이 가기 위한 자신의 집의 주소라고 보시면 됩니다.

도메인 주소

컴퓨터는 IP를 인식하지만 사람이 외우기는 조금 불편합니다. 그래서 도메인 주소라는 것을 사용합니다. bgda.org 이런 식으로 쓰면 사람이 보다 알기겠지요. 컴퓨터는 도메인이 입력되어 들어오면, 그 도메인에 대항하는 IP로 변환해서 사용합니다. 이런 서비스를 DNS (Domain Name Service) 라고 합니다.

Port

포트(Port)는 하나의 컴퓨터에 실행 중인 여러 네트워크 프로그램을 구분하기 위해 부여된 번호입니다. 16비트로 구성된 번호입니다. 즉, 우편물이 집에 도착했는데 그 우편물이 누구의 것이냐는 것입니다. 여기서 집을 하나의 컴퓨터(호스트)라 하고, 주소를 컴퓨터의 IP, 우편물에 적힌 이름은 포트 번호라고 이해하시면 쉬울 것입니다. 즉, 컴퓨터까지는 왔는데 그 컴퓨터의 어느 프로그램이 패킷을 받을지를 알아야 하니 이런 번호가 부여됩니다.
우리가 자주 쓰는 웹 서버나 ftp 서버 같은 것들도 전부 포트번호가 있습니다. 그런데 이런 것들은 자주 많이 쓰이기 때문에 포트번호를 지정해 놓았습니다. 그래서 우리들은 인터넷 주소만 입력하면 바로 웹 페이지를 열 수 있는 것입니다. 포트 번호를 따로 적지는 않습니다. 왜냐하면, 미리 이 포트번호는 http의 번호이다라고 정해놓았기 때문입니다. 그렇게 많이 쓰는 서버들의 포트들을 well-known 포트라고 합니다. 1 - 1024까지는 well-known 포트로 되어있습니다. 그래서 보통 새로운 서버를 만든다면 이 포트(well-know port)는 되도록 피하는 것이 좋습니다. 포트 번호가 16비트니 포트번호는 충분할 것입니다.

루프백(loopback)

클라이언트와 서버가 같은 호스트에서 TCP/IP를 이용하여 서로 통신할 수 있도록 하는 것입니다. 127.0.0.1 - 127.255.255.255 까지가 루프백 주소로써 localhost라는 이름으로도 할당하고 있습니다. 루프백으로 보내어진 데이터는 밖으로는 보내어지지 않습니다. 그러나 브로드캐스트나 멀티캐스트주소로 보내어진 것은 루프백에 복사된 다음 밖으로 나가게 됩니다. 그리고 자신의 IP로 보내어진 것도 루프백으로 보내어집니다.

MTU

MTU(Maximum Transmission Unit) 최대 전송단위라는 것인데 대부분의 네트워크는 패킷의 상한선이 정해져 있습니다. 그것보다 많은 양은 그보다 작게 쪼개어서 보냅니다. 이런 것을 단편화(Fragmentation)라고 합니다. 단편화된 패킷은 받을 때 합쳐지게 됩니다.

Path MTU

두 호스트의 네트워크는 다를 수 있습니다. 즉, 누구는 LAN이고 누구는 전화선일 수도 있는 겁니다. 그때 두 네트워크의 MTU는 다릅니다. 그리고 두 호스트 사이에 어떠한 네트워크도 있을 수 있습니다. 이 두 호스트 사이에 패킷을 전송하는 링크상의 최소 MTU 크기가 Path MTU라 합니다. 만약에 A 와 D가 통신을 한다고 하면 A와 D사이에는 B, C 라는 네트워크가 있다고 해봅시다.

A - B - C - D

A의 MTU가 100 B 200 C 70 D 80이라는 MTU를 가지고 있다면 A와 D의 Path MTU는 70이 되는 것입니다.


TTL

Time-to-live 라는 것으로 패킷이 통과할 수 있는 라우터의 수를 제한하기 위해 사용됩니다. 하나의 라우터를 거칠 때마다 TTL 값이 1씩 줄어들어 0이 되면 패킷은 버려지게(삭제) 됩니다. 라우터를 많이 안거치는 로컬에서는 작아도 상관없지만, 외국이나 그런 먼 곳(거쳐야 할 라우터가 많은) 곳에 보내려면 TTL값은 충분히 커야 합니다. 그렇지 않으면, 가는 도중에 TTL값이 0이 되어 패킷이 삭제될 수 있습니다. 라우터는 두 개의 같은 네트워크를 연결하는 중간 다리 역할을 하는 것이라고 보시면 됩니다. 게이트웨이란 것도 있는데, 이것은 서로 다른 네트워크를 연결하는 역할을 한다고 보시면 됩니다.

TCP 연결 (Three-way Handshake)

위에서 TCP는 연결지향 서비스라고 했습니다. TCP 연결 설정은 다음의 시나리오로 이루어 집니다.

  1. 서버는 들어오는 연결을 받을 준비가 되어 있도록 준비되어야 합니다.
  2. 클라이언트가 접속을 요청합니다. (클라이언트가 서버에게 SYN 세그먼트를 보냄)
  3. 서버는 클라이언트의 SYN 도착을 클라이언트에게 알립니다. (서버가 클라이언트에게 SYN을 보냄)
  4. 클라이언트는 서버에게 SYN도착을 알림(클라이언트가 서버에게 ack를 보냄)

이때 교환하는 패킷이 3개인데 그래서 Three-way Handshake 라 합니다. 쉽게 말해서 클라이언트가 서버에게 “접속한다.” 그러면 서버는 “그래 접속해라.” 합니다. 그리고 클라이언트는 “알았다.” 라고 하고 접속이 완료 되는 겁니다.

TCP 연결 종료

다음과 같은 시나리오 입니다.

  1. 클라이언트에서 close를 호출하면 데이터를 그만 보내겠다는 FIN 세그먼트를 서버에 보냅니다.
  2. 서버가 FIN을 받으면 FIN을 받았다는 ack를 클라이언트에게 보내고 close가 호출됩니다.
  3. 그러면 서버도 FIN을 클라이언트에게 보내게 됩니다.
  4. 그러면 마지막으로 클라이언트는 FIN을 받고 ack를 서버에게 보냅니다.

여기서는 클라이언트가 close를 먼저 했는데. 서버가 먼저 할 수도 있습니다.

소켓 API

이 글에 있는 소스는 TCP/IP 소켓프로그래밍 version C <사이텍미디어>를 참고 하였음을 알려드립니다.

소켓 API란?

네트워크 상에서 호스트간에 통신을 가능하게 해주는 일반적인 인터페이스 입니다. 응용층과 트랜스포트 계층 사이의 중간에 위치해있습니다.

소켓 주소 구조

소켓 API는 소켓과 관련된 주소를 지정하기 위해 일반적인 소켓 구조체를 정의해 놓았습니다.(소켓 주소에는 TCP/IP를 위한 주소만 있는 것이 아니라 다양하게 많이 존재합니다.) 그 형태는

struct sockaddr
{
    unsigned short sa_family; /* Address family */
    char sa_data[14]; /* Family-specific address */
};

이렇게 생겼습니다. 실제 TCP/IP 소켓 주소를 지정할 때는 이 구조체를 사용하지 않습니다. TCP/IP에 맞추어 사용합니다. TCP/IP 소켓 주소를 위해 사용하는 구조체를 보면,

struct in_addr
{
    unsigned long s_addr;
};

struct sockaddr_in
{
    unsigned short sin_family;
    unsigned short sin_port;
    struct in_addr sin_addr;
    char sin_zero[8];
};

sockaddr_in의 sin_family 인자는 인터넷 주소 패밀리입니다. 이번 강좌에서는 AF_INET를 사용합니다. (일단 이렇게만 알아두세요.) 이건 IPv4 프로토콜이라는 것을 말합니다. sin_port 는 포트 번호를 지정하는 것입니다. short니 2바이트, 16비트 정수입니다. sin_addr은 IP주소가 들어 가는 부분입니다. 위에 struct in_addr의 정의가 나와있습니다. 정의에서 나오는 것과 같이 여기에는 IP의 32비트 주소가 들어 갑니다. 그러니 "128,23,23,14" 이런 문자열이 들어가진 않습니다. 이걸 110010101.. 이런 식으로 변환하여 넣어 주어야 합니다. 두 개를 변환하는 함수 물론 있습니다. 그리고 그 다음이 sin_zero(8) 이 부분인데 여기에는 0값이 들어 갑니다. 즉, 사용하지 않습니다. 사용하지 않는데 왜 여기에 있는가 하면, 앞에 일반적인 구조체를 보여 드렸습니다. 그것의 크기에 맞추어서 8바이트 더미 값이 들어가 있습니다.

바이트 순서(Byte Ordering)

컴퓨터 메모리에 데이터가 저장되는 순서를 말합니다. 이것은 수행되는 기계에(CPU) 의존됩니다. 여기에는 Little-Endian 과 Big-Endian이 있습니다. Little-Endian 은 가장 낮은 바이트부터 저장되고 Big-Endian은 가장 높은 바이트부터 저장됩니다. 그림을 보면 쉽게 이해가 가실 겁니다. 4바이트 정수를 저장한다고 하면,

|   1byte   |
+-----------+-----------+-----------+-----------+
|    1      |    2      |    3      |    4      |
+-----------+-----------+-----------+-----------+

|   1byte   |
+-----------+-----------+-----------+-----------+
|    4      |    3      |    2      |    1      |
+-----------+-----------+-----------+-----------+
   addr A     addr A+1     addr A+2    addr A+3

위에 것이 Little-Endian 밑에 그림이 Big-Endian 입니다. Little-Endian 방식에 대표적인 것이 Intel계열의 CPU이고 Big-Endian은 Sparc 계열의 CPU입니다. 같은 기종의 통신이면 바이트 순서는 중요하지 않지만, 서로 다른 기종의 통신이라면 중요해 집니다. 통신을 할 때는 Big-Endian을 따릅니다. 그래서 네트워크로 보내기 전에 이 바이트 순서를 조정해 주어서 보내어야만 합니다.
그럼 네트워크 순서와 호스트 순서를 바꾸는 함수에 대해서 알아 보도록 하겠습니다. 함수 이름에는 규칙이 있는데,

  • h - host
  • n - network
  • l - long
  • s - short

이렇습니다. 예를 하나 들면,

long int htonl (long int hostLong);

무슨 말일까요? 함수 이름이 htonl 입니다. 호스트에서 네트워크로 바꾸라는 얘기입니다. type은 long 형이고요. 이렇게 각각 long와 short에 대해 함수가 있습니다.

long int htonl (long int hostLong);
long int ntohl (long int netLong);

short int htons (short int hostShort);
short int ntohs (short int netShort);

사용의 예를 들어 보죠. 포트번호가 2바이트입니다. 이것을 네트워크로 보내기 위해서는 바이트 순서를 조정해야 합니다. 소켓 주소 구조체에 대입은

SocketAddress.sin_port = htons(appPort);

이렇게 합니다.

소켓기술자란?

소켓 기술자는 유닉스의 파일 기술자와 동일합니다. 다만 그 I/O가 네트워크일 뿐입니다. 쉽게 말해 컴퓨터에서 네트워크로 나가는 문의 고유번호라고 생각하시면 됩니다. 네트워크로 데이터를 보내거나 받으려면 커널에게 소켓을 만들어 달라고 요청을 합니다. 그럼 커널은 소켓 하나를 열고 우리에게 그 소켓의 고유번호를 줍니다. 그것이 소켓 기술자입니다. 유닉스의 파일기술자에 대해 잘 아신다면, 똑같다고 보면 이해가 빠르실 겁니다. 이제부터 소켓번호라고 하면 이 소켓 기술자를 얘기하는 것으로 하겠습니다.

TCP 소켓

이제 TCP 서버와 클라이언트의 기본적인 함수 호출 구조를 살펴 보도록 하겠습니다.

서버 : socket() -> bind() -> listen() -> accept() -> send()/recv()

서버가 여기까지 호출을 하게 되면 클라이언트의 접속이 들어 오는지 기다립니다.

클라이언트 : socket() -> connect() -> send()/recv()

여기서 클라이언트와 서버는 three-way handshake를 하여 연결을 합니다. 그 후에 서버와 클라이언트는 send()와 recv() 를 이용해 데이터를 주고 받습니다. 그리고 close() 함수를 호출하여 접속을 끊게 되는 것입니다.

그럼 서버와 클라이언트에서 공통으로 사용하는 소켓 생성과 소멸에 관련된 함수에 대해서 알아 보도록 하겠습니다. 그 전에 여기서 설명하는 함수는 다른 말이 없으면 리턴 값이 에러면 -1을 리턴하고 errno이라는 전역변수에 에러 값을 넣어줍니다. 이 errno의 값을 보고 무슨 에러가 났는지 알 수 있습니다.

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

socket 함수의 역할은 커널에 소켓을 열어 달라고 요청을 하여 그 소켓번호를 우리에게 넘겨 줍니다. protocolFamily는 AF_INET를 씁니다. IPv4 protocol 을 사용한다는 것을 알리는 것입니다. 물론 다른 것도 있지만 이 글에는 이것만 씁니다. type은 TCP를 사용할 땐 SOCK_STREAM, UDP를 사용할 땐 SOCK_DGRAM 을 넣어서 어떤 서비스를 사용하는지 커널에 알려 줍니다. protocol은 raw소켓을 쓸 때 말고는 0을 설정합니다. 그러니 여기서는 항상 0을 사용할 것입니다.(IPPROTO_TCP, IPPROTO_UDP를 이용하셔도 됩니다.) raw소켓은 IP계층의 서비스를 직접 이용할 때 쓰는 것입니다.

int close( int sockfd );

소켓을 닫고 통신을 종료합니다. sockfd는 닫을 소켓 번호입니다. 성공하면 0을 실패하면 -1을 반환합니다. 닫힌 소켓은 더 이상 사용할 수 없습니다. 여기선 TCP를 설명하니 TCP에 대해 조금 더 설명하겠습니다. 내부적으로 TCP는 send buffer 와 recv buffer가 있습니다. 만약에 close를 호출 하였는데 send buffer에 보낼 데이터가 남아 있으면 그것을 모두 보낸 후에 앞서 설명 드린 TCP 연결 종료 절차를 따릅니다.

위 두 함수에는 좀더 볼 것이 있는데. 그것은 참조 카운터입니다. socket로 소켓을 열면 참조 카운터가 1 증가 합니다. 그리고 다른 자식 프로세스로 복사될 때도 참조 카운터가 1증가 합니다. close는 참조 카운터를 1감소 시킵니다. 그러다가 참조카운터가 0이 되면 소켓을 닫습니다. 소켓 참조 카운터가 0이 아니라면 그것은 열린 상태가 되는 것입니다.

int shutdown( int sockfd, int howto );

이 함수도 네트워크 연결을 종료시키는 데 사용합니다. close()와 다른 점은 close는 참조 카운터를 1감소시키고 참조 카운터가 0이 되면 종료하는데 shutdown()은 참조 카운터와 상관없이 TCP의 연결 종료 절차를 시작합니다. 그런데 close()함수는 양방향(send recv) 둘 다 종료시키는데 반해 shutdown함수는 howto인자에 따라 동작이 달라집니다. 위에서 close함수를 설명할 때 약점이 하나 있었습니다. close()호출 후에 받을 데이터가 있다면 어떻게 할까요? 그건 받을 수 없습니다. 그러나 shutdown의 howto 인자를 설정하면 그것이 가능합니다. 그 값에는 다음과 같은 것이 있습니다.

  • SHUT_RD : 연결의 recv 한쪽만 닫습니다. 이제 이 소켓으로는 데이터를 받을 수 없습니다. 그리고 recv buffer도 폐기됩니다.
  • SHUT_WR : 연결의 send 한쪽만 닫습니다. 이제 이 소켓으로는 어떤 데이터도 보낼 수 없게 됩니다.send buffer에 남아 있는 데이터는 모두 보낸 뒤에 TCP 연결 종료 절차가 뒤따릅니다.
  • SHUT_RDWR : 연결의 양쪽 다 받습니다.

만약에 자신은 데이터를 다 보냈다 하면, SHUT_WR인자를 설정하여 shutdown()을 호출하면 다른 쪽이 보내는 데이터를 받을 수 있게 되는 것입니다.

이제는 TCP 서버의 기본적인 함수에 대해서 알아 보도록 하겠습니다.

TCP 서버는 통신의 종단에서 클라이언트의 연결요구를 수동적으로 기다리는 역할을 합니다. 그럼 그 과정을 요약하면,

  1. socket() 함수로 소켓을 생성
  2. bind() 함수로 생성된 소켓에 포트번호를 연결
  3. listen() 함수를 이용해 클라이언트의 연결요구를 받도록 함
  4. 클라이언트의 연결요청이 들어오면 accept() 함수로 새로운 소켓을 얻음
  5. send() recv()를 사용하여 클라이언트와 통신
  6. 서비스가 끝나면 close()함수를 이용하여 클라이언트의 연결을 닫음

이제 여기에 관련된 함수에 대해 알아 보겠습니다.

bind ()

int bind( int sockfd,
          struct sockaddr * localAddress,
          unsigned int addressLen );

bind() 함수의 원형입니다. bind함수는 localAddress 변수에 있는 IP 주소와 Port 번호를 연결시켜 주는 역할을 합니다. 만약, 클라이언트(ip 123.145.234.1 포트 6000)가 ip 203.229.234.13 port 5000 번으로 접속을 요청해 왔다고 합시다. 그럼 서버는 그 ip와 포트 변호를 보고 어느 프로그램의 패킷인지를 알 수 있어야 합니다. 바로 그것을 알려 주는 그리고 명시 하는 함수가 바로 bind입니다. 그런데 서버는 여러 개의 클라이언트를 처리 한다고 했습니다. 클라이언트는 똑같은 IP와 포트 번호를 이용해서 (사실 IP는 다를 수 있습니다.) 접속을 해올 것입니다. 그 예를 설명해 보죠.
A 라는 서버에 x y라는 두 개의 클라이언트가 접속을 해왔다고 합시다.

서버의 IP 는 111.222.333.44 Port 5000
x클라이언트 IP 222.222.222.22 Port 6000
y클라이언트 IP 222.222.222.33 Port 7000
이라고 가정하면,

x가 먼저 클라이언트에게 접속을 하고 y가 접속을 합니다. 그러면 TCP는 연결을 위해 소켓이 새로이 생성되는데 그곳에 socket pair이라는 구조체에 서버의 IP와 Port 클라이언트의 IP와 Port를 같이 저장합니다. 그러면 두 개의 x, y클라이언트가 구분이 되겠죠?
이렇게 클라이언트와 서버 IP, Port를 모두 사용하여 구분한다고 생각하시면 됩니다. 그리고 아까 서버의 IP가 다를 수 있다고 했는데 그것은 하나의 컴퓨터에 하나의 IP. 즉, 하나의 네트워크 인터페이스(랜카드) 만이 있는 것은 아닙니다. IP는 인터페이스당 유일하게 하나입니다. 그럼 만약에 서버가 2개의 인터페이스를 가지고 있다면 서버 프로그래밍의 설정에 따라서 동시에 두 IP에서 오는 패킷을 받을 수 있습니다.
나중에 서버 프로그래밍 예제를 보시면 나옵니다. INADDR_ANY 를 서버설정에서 IP부분에 설정을 하면 모든 인터페이스로부터 패킷을 받는다는 의미입니다. 물론 특정 인터페이스에서 오는 것만 받을 수도 있습니다. 그때 서버를 bind할 때 그 받을 IP만 지정하면 되는 것입니다.

listen()

int listen ( int socket, int backlog );

첫 번째 인자는 소켓 번호입니다. 두 번째 인자가 설명이 조금 필요한데, 간단히 설명하면 연결요구 개수의 최대값을 나타냅니다. 무슨 말이신지 이해가 잘 안되실 수도 있는데 조금 더 설명을 드리면, TCP가 접속을 할 때 three-way handshake를 한다고 했습니다. 이 도중에 또 다른 클라이언트가 접속을 요구 할 수도 있습니다. 그럼 어떻게 해야 할까요? 일단 어디에다 저장해두어서 지금 하고 있는 연결설정을 끝내고 차례대로 들어온 순서대로 연결 설정을 해주면 되겠지요? 그 저장해둘 클라이언트 연결 요구의 수를 말하는 것입니다. 내부적으로는 연결이 완료된 것 대기중인 것 이렇게 두 개의 큐가 있습니다. 이 큐는 FIFO(First In First Out) 로 동작합니다. 연결완료 된 것과 대기중인 것 모두 합친 것의 수입니다.
만약에 그 대기수도 다 차있는 상태에서 다시 연결 요구가 들어 오면 어떻게 할까요? TCP는 거기에 대해서 아무 것도 안 합니다. 그럼 클라이언트는 아무 응답이 없으므로 일정 시간 뒤에 다시 연결 요구를 합니다. 예전에는 이 수는 5 를 사용했던 것 같습니다. 많은 예제들이 아직도 5를 사용하고 있는데, 5 라고 해서 꼭 5개만 되는 것은 아닙니다. 운영체제나 네트워크 인터페이스 드라이브에 따라서 다를 수 있습니다. 어떤 것은 입력된 값 그대로 쓰고 어떤 건 여기에다가 1.5를 곱한 수를 사용하는 등 다양합니다. 그리고 이제 5 라는 제한도 없어 졌습니다. 더 큰 수도 지원합니다. 만약에 운영체제에서 지원하는 수보다 더 큰 수를 넣는다면 어떻게 될까요? 그렇게 해도 운영체제가 알아서 최대값을 안 넘게 해준다고 합니다.

accept()

int accept( int socket,
            struct socket * clientAddress, u
            nsigned int * addressLen );

이 함수의 기능은 listen 함수가 연결 요구의 개수를 지정하고 내부 큐에는 연결 설정이(three-way handshake) 완료된 큐와 대기중인 큐 두 개가 있다고 했습니다. 그 완료된 큐에서 순서대로(FIFO) 하나 가져와서 상대방과 연결된 하나의 소켓을 만드는 역할을 합니다. 만약에 완료된 큐에 아무것도 없다면 생길 때까지 블록 됩니다.
함수가 성공하면 새롭게 생성된 소켓 번호를 리턴 합니다. 이 소켓을 통해 클라이언트와 통신을 합니다.

send()/recv()

int send( int socket, const void * msg,
          unsigned int msgLength, int flag );
int recv( int socket, void * recvBuffer,
          unsigned int bufferLength, int flag );

이 함수들은 이름 그대로 데이터를 주고 받는 함수입니다. send 함수의 socket 인자는 보낼 곳의 소켓번호이고, msg는 보낼 메시지의 시작 포인터입니다. msgLength 는 보낼 메시지의 길이입니다. recv 함수의 socket 인자는 send 함수와 동일합니다. recvBuffer은 받을 버퍼의 시작 포인터이고, bufferLength는 해당 버퍼의 크기입니다. Send 함수의 리턴 값은 보낸 데이터의 byte수입니다. 그리고 recv 함수의 리턴 값은 recvBuffer에 넣은 데이터의 수를 리턴 합니다. 만약 상대방이 접속을 끊으면 recv 함수의 리턴 값은 0이 됩니다.
TCP에서 데이터를 주고 받을 때 잊지 말아야 할 중요한 것이 있습니다. 예를 들어 설명을 하면, 데이터 100바이트를 보내겠다고 가정합시다.

send( socket, buffer, 100, 0 );

이런 식으로 보내게 됩니다. 그런데 send함수는 100바이트 전부를 보낼 때까지 블록 됩니다.(blocking mode 일 때) 그리고 send로 보낸 데이터를 받을 때는 recv함수를 사용합니다. 받을 버퍼로 char buffer(512)를 선언했다고 하지요.

recv( socket, buffer, 512, 0 );

그런데 이 recv함수는 우리가 원하는 데이터의 양만큼 데이터를 받지 못합니다. 내부적으로 TCP는 보내는 버퍼와 받는 버퍼 두 개가 있다고 했습니다. send 함수는 보내는 버퍼에 보낼 데이터를 옮기고 리턴 됩니다. recv 함수는 받는 버퍼에 1바이트라도 있으면 그것을 가져옵니다. 우리가 512 바이트를 선언하고 512를 인자로 넘겨 512바이트 이상은 가져 오지 않습니다. blocking mode일 때 recv 함수는 받는 버퍼가 비어 있으면 데이터가 들어올 때까지 기다렸다가 들어오면 그것을 받아서 리턴 합니다. 즉, 우리가 받기를 원하는 만큼 받을 수 없다는 것입니다. recv 해서 받을 데이터의 양은 아무도 알 수 없습니다. 그래서 항상 리턴 값을 체크해서 얼마나 받았는지 확인을 해야 하는 것입니다.
그리고 TCP는 stream 방식입니다. 클라이언트에서 100바이트와 50바이트 250바이트를 이렇게 3번을 보냈다고 합시다. 그런데 이 데이터들을 구분할 수가 없다는 것입니다. 그것을 구분하는 것은 응용프로그램의 몫입니다. 데이터는 받는 버퍼에 구분 없이 연결되어 들어가 있는 것입니다. 이점을 항상 염두하고 프로그래밍을 해야 합니다. 즉, 서버에서 recv 를 했을 때 받은 크기는 100 이 아닐 수 있습니다. 100보다 클 수 있고 작을 수도 있습니다.

TCP 클라이언트 함수에 대해서 알아보도록 하겠습니다.

먼저 TCP 클라이언트 작성 순서를 알아보면,

  1. socket()함수로 소켓을 생성
  2. connect() 서버와의 연결
  3. send() recv() 사용하여 통신
  4. close()로 연결 닫음

이런 식으로 작성 합니다. connect()란 함수 말고는 나머지는 서버와 비슷합니다. 클라이언트는 bind() 함수를 사용하지 않는데 그것은 포트번호를 꼭 일정하게 묶을 필요가 없기 때문입니다. 커널이 알아서 적당하고 사용하지 않는 포트번호를 할당해 줍니다.

int connect( int socket, struct sockaddr * foreignAddress,
             unsigned int addressLength );

첫 번째 socket는 생성한 소켓의 번호입니다. 그리고 두 번째는 쉽게 말해 서버의 주소를 넣어서 보내는 것입니다. 그렇게 하면 그 쪽으로 연결을 요청해서 there-why handshake 를 하는 것입니다. addressLength 는 sockaddr 의 크기입니다.

소켓옵션

소켓옵션은 일반적으로 디폴트로 사용해도 문안하게 사용할 수가 있습니다. 그러나 보다 세밀한 설정을 하길 바란다면 이러한 옵션들을 설정하여 그 어플리케이션에 맞게 사용할 수 있습니다. 소켓 옵션에는 많은 것들이 있으니 Unix Network Programming vol 1 의 7장에 소켓 옵션에 대해서 잘 설명해 놓았습니다. 그것을 참고하시길 바랍니다. 여기서는 자주 쓰이는 옵션에 대해서 알아 보도록 하겠습니다. 그 전에 소켓을 옵션을 설정하고 설정된 것을 얻어 오는 함수를 알아보도록 하겠습니다.

int getsockopt( int socket, int level, int optname,
                void * optVal, unsigned int * optLen );
int setsockopt( int socket, int level, int optname,
                const void * optVal, unsigned int * optLen );

위의 두 개의 함수입니다. 하나는 얻어 오는 함수고 하나는 설정하는 함수입니다. 첫 번째 인자는 소켓 옵션을 얻어오거나 설정할 소켓의 번호입니다. 두 번째 인자는 소켓의 레벨인데, 어떤 것을 설정 혹은 얻을 것이냐 하는 겁니다. 일반적인 소켓의 옵션인가 IP에 관한 내용인가? TCP에 관한 내용인가? 아니면 IPv6의 내용인가? 하는 것을 나타냅니다. 여기에는 다음과 같은 것들이 있습니다.

  • SOL_SOCKET : 일반적인 소켓의 옵션들이 있습니다.
  • IPPROTO_IP : IP설정과 관계있는 곳: 주로 멀티캐스트와 관련된 것들이 있습니다.
  • IPPROTO_TCP : TCP와 관련있는 옵션들이 있습니다.

세 번째 인자는 그 레벨에서 어떤 것을 말하느냐입니다. 일반적인 소켓의 옵션에서도 그 중에 무엇을 변경 혹은 얻어 올 것인가 하는 것입니다. 버퍼의 크기를 변경할 건지 아니면 브로드캐스드인지 등 그런 세부적인 옵션을 말합니다.
네 번째 인자는 setsockopt 함수에서는 설정될 값이 무엇이냐 하는 것이고, getsocketopt 함수에서는 얻은 값을 저장할 변수의 포인터입니다.
다섯 번째는 네 번째 변수의 길이입니다. 변수라 말했지만 이것은 구조체로 된 것도 있습니다.
네 번째 인자를 보면 void * 형 입니다. 이것은 무엇을 말할까요. 즉, 변수의 형이 정해지지 않았다는 것입니다. 각 옵션에 따라 int도 있고, unsigned char도 있고, 구조체도 있습니다.

이제 자주 사용하는 옵션들에 대해서 알아보도록 하겠습니다.

1) SOL_SOCKET Level

  • SO_RCVBUF, SO_SNDBUF

버퍼의 크기를 바꾸는 옵션입니다. 커널의 recv Buffer, send Buffer의 크기를 조절하는 데 사용합니다. 이것을 어떻게 잘 설정하느냐에 따라 성능이 향상된다고 합니다. 버터의 크기는 테스트와 네트워크의 상태에 따라서 달라진다고 합니다. 그런데 보통은 (대역폭 * 지연율) * 2 의 공식에 따라 버퍼의 크기를 설정한다고 합니다. recv Buffer 의 크기를 변경하는 코드를 보도록 하겠습니다.

int rcvBufferSize;
int sockOptSize;
.
.
.
// 소켓 리시브 버퍼 크기 얻기
sockOptSize = sizeof( rcvBufferSize );
if( getsockopt( sock, SOL_SOCKET, SO_RCVBUF, &rcvBufferSize, &sockOptSize ) < 0 )
{
        printf( "getsockopt() failed
" );
        exit( 1 );
}
// 리시브 버퍼의 크기를 2배로 만든다.
rcvBufferSize *= 2;
if( setsockopt( sock, SOL_SOCKET, SO_RCVBUF, &rcvBufferSize, sizeof( rcvBufferSize ) < 0 ) )
{
        printf( "setsockopt() failed
" );
        exit( 1 );
}

여기에서 보면 SOL_SOCKET, SO_RCVBUF 가 나옵니다. 즉, 일반적인 소켓의 옵션 중에 recv Buffer 의 크기를 말합니다. recv Buffer 와 마찬가지로 send Buffer 변경 옵션은 SOL_SOCKET level에 있습니다. 그리고 SO_RCVBUF 대신에 SO_SNDBUF라는 것을 넣어 주면 변경이 가능합니다.

그런데 하나 주의 하실 점이 있습니다. 바로 호출 순서입니다. 소켓의 옵션들의 대부분이 호출 순서가 중요합니다. 클라이언트의 경우 connect() 하기 전에 recv Buffer 를 변경해야 하는데, 왜냐하면 three-way handshake 할 때 MSS를 알려 주기 때문입니다. 그리고 서버의 경우에 listensocket (listen() 함수 호출 시 전달되는 소켓번호) 의 설정은 listen() 호출 전에 먼저 설정을 해주어야 합니다. 쉽게 설명해서 연결이 성립될 때 그러니까 three-way handshake할 때 한번에 보낼 수 있는 TCP 세그먼트(패킷)의 크기의 최대값을 알려 줍니다. 연결이 성립되면 최대 세그먼트의 크기(MSS)를 변경할 수 없어 연결하기 전에 미리 바꾸어 두는 것입니다.

  • SO_LINGER

SO_LINGER 옵션이 있습니다. 이것은 TCP 에서 적용되는 것인데 close함수의 행동을 지정하는 옵션입니다. close() 하면 recv Buffer 나 send Buffer 에 보내거나 받을 데이터가 있다면 전부 처리 후 close() 를 합니다. 그 방법을 바꾸는 것입니다. 먼저 전달되는 구조체에 대해서 알아 보도록 합시다.

struct linger
{
    int l_onoff;
    int l_linger;
};

setsockopt( sock, SOL_SOCKET, SO_LINGER, &linger 구조체 주소, sizeof( linger ) );

이런 식으로 호출하면 되겠죠. 그리고 세부적인 동작 설정은 linger구조체의 변수 설정에 있습니다.

  1. l_onoff가 0이면 기본적인 TCP동작이 적용됩니다.
  2. l_onoff가 0이 아니고(주로 1을 넣습니다.) l_linger가 0이면 연결이 닫힐 때 버퍼의 내용을 버리고 연결을 끊어 버립니다.
  3. l_onoff가 0이 아니고 l_linger도 0이 아니면 소켓이 닫힐 때 블럭 당한다고 합니다.

이 소켓옵션을 쓸 땐 2번을 주로 씁니다. 쓰는 이유는 만약 서버가 종료되고 다시 시작 할 때 입니다. 연결이 끊어지고 남은 데이터를 전송합니다. 그때 남은 데이터를 보낸다면 클라이언트에게 ack 메시지(받았다는 확인 메시지)를 받아야 완전한 종료가 이루어집니다. 그 메시지를 기다리는 시간이 있습니다. 만약 그것을 다 받지 못했다면 다시 보내야 하지요. 그런 상황에서 다시 서버를 시작하려고 하면 이미 사용 중인 포트라는 에러를 내게 됩니다. 그래서 이런 옵션을 사용하는 것입니다. 그런데 그것은 바람직한 해결 방법이 아니라고 합니다. 그래서 이런 옵션은 추천되고 있지 않습니다. 이에 대한 해결책은 따로 있습니다. 그것이 다음에 설명할 포트 재사용 옵션입니다.

  • SO_REUSEADDR

이 옵션을 선택하여 주면 위의 예에서 말한 서버 재 시작 시 다시 시작할 수 있습니다. 간단히 사용법을 알아보도록 하지요.

int nResue = 1;
setsockopt( ListenSocket, SOL_SOCKET, SO_REUSEADDR, &nReuse, sizeof( nReuse ) );

이것도 호출 순서가 있는데 bind() 하기 전에 이 옵션을 설정해 놓아야 합니다. 이렇게 하면 소켓의 포트를 재 사용할 수 있습니다.

2) IPPROTO_IP Level

여기에는 주로 멀티캐스트와 관련된 옵션들이 있습니다. 다음에 멀티캐스트를 하실 때 그때 사용법을 참고하기면 됩니다.

3) IPPROTO_TCP Level

  • TCP_NODELAY

TCP에 보면 잔잔한 패킷들을 하나씩 다 보내는 것이 아니라 네트워크상에 작은 패킷들을 줄이기 위해 Nagle 알고리즘을 사용하여 어느 정도 묶어서 한꺼번에 보내는 것이 있습니다. 이것을 사용 할지 안 할지를 설정하는 옵션입니다. 이것은 주로 서버에서는 이 알고리즘을 사용 안 한다고 합니다. 왜냐하면 다른 일을 해야 하기 때문에 그냥 바로 보내버리는 것이 더욱 효과적이라는 것입니다. 패킷의 개수가 많아지기는 하지만 그런 알고리즘의 딜레이를 버림으로 보다 빠른 처리를 할 수 있다는 것입니다. 그러나 클라이언트의 경우는 Nagle알고리즘을 사용합니다. 작은 패킷을 묶어 보내 네트워크의 부하를 줄이자는 것입니다.

소켓 옵션은 다양하고 많은 것들이 있고 주의 사항들이 있습니다. 항상 자신의 프로그램에 맞게 올바른 설정을 하시고 사용하시기 전에 여러 가지로 알아보시고 하시기를 바랍니다. 그리고 옵션에서 인자 값을 넘길 때 인자의 형이 다릅니다. 위의 예제에서도 Linger옵션은 구조체를 사용하고 다른 것은 int 형이었습니다. 그리고 윈속(Winsock)도 다릅니다. 구조체로 된 부분은 거의 같지만 int로 된 부분은 BOOL로 사용하는 부분이 많이 있습니다. 잘 알아 보시고 사용하시기 바랍니다.

에코 프로그램

그럼 이제 소스를 보면서 이제까지의 내용들을 정리하도록 하겠습니다. 여기의 모든 소스는 유닉스나 리눅스 용입니다. 윈도우에서는 실행이 되지 않습니다.

구현할 것은 에코 서버와 클라이언트입니다. 에코 서버는 에코 클라이언트가 보낸 데이터를 받아서 그대로 다시 에코 클라이언트에게 보내는 것입니다. 그럼 에코 서버부터 보도록 하겠습니다.

에코 서버

서버의 실행은 <실행파일명 포트번호> 입니다.

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h> // socket() bind() connect()
#include <arpa/inet.h>  // socketaddr_in,  inet_ntoa()
#include <netinet/in.h> // 만약 FreeBSD 라면 이 해더를 추가해야 한다.
#include <string.h>
#include <unistd.h>
#include <stdlib.h>

#define RCVBUFSIZE 128
#define MAXPENDING 5

int main( int argc, char * argv[] )
{
        struct sockaddr_in echoServAddr, echoClntAddr;
        int servSock, clntSock;
        unsigned short echoServPort;
        unsigned int clntLen;
        char echoBuffer[BUF_LEN];
        int recvMsgSize;

        if( argc != 2 )
        {
                printf( "Usage : %s port
", argv[0] );
                exit( 1 );
        }

        echoServPort = atoi( argv[1] );

        if( ( servSock = socket( PF_INET, SOCK_STREAM, IPPROTO_TCP ) ) < 0 )
        {
                printf( "socket() failed
" );
                exit( 1 );
        }

        memset( &echoServAddr, 0, sizeof( echoServAddr ) );
        echoServAddr.sin_family = AF_INET;
        echoServAddr.sin_addr.s_addr = htonl( INADDR_ANY );
        echoServAddr.sin_port = htons( echoServPort );

        if(bind(servSock, (struct sockaddr *) &echoServAddr, sizeof(echoServAddr)) < 0)
        {
                printf("bind() failed
");
                exit(1);
        }

        if(listen(servSock, MAXPENDING) < 0)
        {
                printf("listen() failed
");
                exit(1);
        }

        for(;;)
        {
                clntLen = sizeof(echoClntAddr);

                if((clntSock = accept(servSock, (struct sockaddr *)
                    &echoClntAddr, &clntLen)) < 0)
                {
                        printf("accept() failed");
                        exit(1);
                }

                if((recvMsgSize = recv(clntSocket, echoBuffer, RCVBUFSIZE, 0))
                   < 0)
                {
                        printf("recv() failed");
                        exit(1);
                }
                while(recvMsgSize > 0)
                {
                        if(send(clntSocket, echoBuffer, recvMsgSize, 0)
                            != recvMsgSize)
                        {
                                printf("send() failed");
                                exit(1);
                        }
                        if((recvMsgSize = recv(clntSocket, echoBuffer,
                            RECVBUFSIZE, 0)) < 0)
                        {
                                printf("recv() failed");
                                exit(1);
                        }
                }
                close(clntSocket);
        }
}

자, 이게 에코서버의 모습입니다. 우선 소스부터 분석하도록 합시다. 우선 서버의 소켓을 열고 서버의 주소 정보를 채우고 bind 시키고 클라이언트의 요구를 듣는 상태로 들어 갔습니다. 그리고 클라이언트의 연결요청이 들어오면 accept함수를 호출하여 클라이언트와 연결을 하고 그리고 에코 서비스를 해주는 과정으로 되어있습니다. 지금 제가 설명한 과정이 분석이 되실 겁니다. 그래도 좀더 자세히 설명에 들어가도록 합시다. 이 서버는 TCP 서버입니다. 그것을 알 수 있는 부분은 어디입니까? 소켓을 처음 생성하는 부분입니다.

servSock = socket( PF_INET, SOCK_STREAM, IPPROTO_TCP );

여기서 SOCK_STREAM 으로 되어있습니다. 이것은 연결형 서버를 말하는 겁니다. 즉, TCP의 서비스를 이용하겠다는 것입니다. 여기서 마지막 인자를 IPPROTO_TCP 라고 되어있는데 꼭 이렇게 써주는 것은 아닙니다. 보통은 0 값을 전달합니다. 어느 것을 사용하나 상관은 없습니다. 명시적인 것이 좋으신 분은 IPPROTO_TCP라고 써주시면 되겠습니다. 여기서 PF_INET 를 사용했는데 AF_INET 와 무엇이 다를까요? PF 는 Protocol Family 의 약자이고 AF는 Address Family의 약자입니다. 글자는 달라고 내부적으로는 구분하지 않는다고 합니다. 그래서 여러 곳에서는 각각 다릅니다. PF_INET를 사용하는 것이 있고 AF_INET를 사용하는 곳도 있습니다. 여기에 접두어가 자신이 알기 쉽다고 생각하시는 것을 사용하시면 될 듯합니다. 중요한 것은 IPv4라는 겁니다. IPv6은 AF_INET6이라는 것을 사용합니다. 만약에 IP프로토콜 독립적으로 구성하고자 하시려면 이것을 고려 해주셔야 합니다. 다음으로 서버의 주소를 지정하고 bind 시켰습니다.

memset( &echoServAddr, 0, sizeof( echoServAddr ) );
echoServAddr.sin_family = AF_INET;
echoServAddr.sin_addr.s_addr = htonl( INADDR_ANY );
echoServAddr.sin_port = htons( echoServPort );

if( bind( servSock, ( struct sockaddr * ) &echoServAddr, sizeof( echoServAddr ) ) < 0 )
{
        printf( "bind() failed
" );
        exit( 1 );
}

이 부분입니다. 여기서 3번째 줄에 INADDR_ANY 라는 단어가 들어 갑니다. 서버의 IP 주소를 넣어 주는 부분입니다. 직접 IP 주소를 넣어 주어도 상관은 없습니다. 그런데 이렇게 하면 만약에 서버의 IP가 바뀌거나, 다른 곳에서 서버를 가동시켜야 한다면, 이 부분도 바꾸어야 합니다. 그러면 위 소스에서 쓰는 것처럼 쓰는 것이 더욱 좋을 것입니다. 그런데 중요한 것은 그것이 아닙니다. 서버는 (꼭 서버만 아니고) IP가 여러 개인 서버도 있습니다. 그럴 경우 예를 들면, 여기서 bind 한 포트를 5000번이라고 합시다. 그리고 서버의 IP가 203.241.228.57 과 203.241.228.66 두 개의 IP를 가지고 있다고 하면, 클라이언트가 서버의 아무 IP를 가지고 포트 5000으로 들어오면 우리의 서버 어플리케이션에서 받겠다는 것입니다. 즉, IP : 203.241.228.57, Port 5000 ... IP : 203.241.140.66, Port 5000 으로 접속하는 모든 클라이언트의 요청을 받겠다는 것입니다. 내부적으로는 INADDR_ANY는 0의 값이 들어가 있다고 합니다. 위 서버 코드에서 서버의 IP를 출력해 보세요. 그럼 0.0.0.0 이 출력 될 겁니다.

간단히 실험하나 해보도록 하겠습니다. 도스 명령프롬프트 창을 열어서

>nslookup daum.net

라고 쳐봅니다. 물론 네트워크가 되는 컴퓨터에서요. daum의 IP주소가 나오는데 여러 개가 나옵니다. 위 명령은 네임서버에 daum의 정보를 얻어 오는 것입니다.

서버에서 에코 서비스를 처리하는 부분을 보도록 하겠습니다.

for(;;)
{
        clntLen = sizeof(echoClntAddr);

        if((clntSock = accept(servSock,
             (struct sockaddr *) &echoClntAddr, &clntLen)) < 0)
        {
                printf("accept() failed");
                exit(1);
        }

        if((recvMsgSize = recv(clntSocket, echoBuffer, RCVBUFSIZE, 0)) < 0)
        {
                printf("recv() failed");
                exit(1);
        }

        while(recvMsgSize > 0)
        {
                if(send(clntSocket, echoBuffer, recvMsgSize, 0)
                      != recvMsgSize)
                {
                        printf("send() failed");
                        exit(1);
                }
                if((recvMsgSize = recv(clntSocket, echoBuffer,
                      RECVBUFSIZE, 0)) < 0)
                {
                        printf("recv() failed");
                        exit(1);
                }
        }
        close(clntSocket);
}

이 부분입니다. 클라이언트가 보낸 문자를 되돌려 보내는 부분입니다. 일단 무한 루프로 서버는 끝나지 않습니다. 여기에서 보면, 처음에 recv를 받고 send를 하고 다시 recv를 받습니다. 클라이언트는 처음 에코 요구를 하면 문자열을 한번만 보내는데 여기서는 한번 받고 루프를 돌면서 보내고 받고 그럽니다. 왜냐면 네트워크의 상태에 따라서 TCP는 얼마나 받을지 모른다는 것입니다. 그래서 루프를 돌면서 못 받은 데이터가 있으면 받아서 보내 줍니다.

여기서 에코 서버와 에코 클라이언트의 시나리오를 생각해 보도록 합시다. 서버가 일단 가동되고 있고, 그리고 클라이언트가 Hello라는 문자를 보내 서버에게 에코 요구를 보냅니다. 그런데 네트워크 상황이 안 좋아서 서버는 Hell 까지만 받고 말았습니다. 이것은 첫 번째의 recv 에서 받은 데이터입니다. 그러고 while로 들어가 send에서 서버는 Hell을 클라이언트에게 보냅니다. 그리고 다시 recv 로 들어갑니다. 그리고 o 라는 문자가 옵니다. 그리고 서버는 recv 로 받아서 다시 클라이언트에게 o를 보내어 줍니다. 그리고 다시 recv로 들어 갑니다. 그러면 여기서 서버는 recv 에서 블럭 됩니다. 네트워크에 어떤 데이터가 들어올 때까지 블록 되는 것입니다. 그럼 언제 recv가 리턴 되는가 하면 TCP는 연결을 끝내고 close를 할 때 recv에 0을 리턴 합니다. 설정될 때 three-way handshake하는 것처럼 연결을 끊을 때도 이와 비슷한 행동을 합니다. 그러니까 명시적으로 끊겠다고 하는 것이죠. 서버가 먼저 끊을 수 있고 클라이언트가 먼저 끊을 수도 있습니다. 어느 한쪽에서 close를 하면 “끊겠다”는 패킷을 다른 쪽에 보내면 다른 한쪽에서는 “알았다는 그리고 끊겠다”는 그런 종류의 단계를 취하는 것입니다. 그러면 여기서는 클라이언트가 먼저 끊었다고 합시다. 그럼 서버의 블록 된 recv는 0을 리턴 합니다. 그리고 while 루프를 끝냅니다. 그리고 마지막에 서버가 close를 해서 클라이언트와 연결을 닫습니다.

이제 서버의 구조에 대해 알아 봅시다. 위 서버는 클라이언트당 하나의 소켓이 열립니다. 처음 서버에서 소켓을 열어 listen() 함수에 전달하는 소켓을 보통 듣는 소켓(listen socket)이라고 합니다. 연결 요청은 이 소켓으로 들어 옵니다. 연결요청이 들어 오면 accept()로 다시 소켓 하나를 열어서 새로이 열린 소켓으로 클라이언트와 통신을 하는 것입니다. 그럼 이제 이 서버의 문제점을 알아 봅시다. recv에서 데이터를 받으면 블록 된다고 했습니다. 그런 클라이언트가 접속을 끝내지 않는다면, 다른 클라이언트의 서비스는 어려움이 많이 있습니다. 그리고 서버가 여러 가지 복잡한 일을 하는 것이라면, recv가 블록 되 프로세스가 놀고 있게 되어 효율적이지 못합니다. 그래서 이 문제를 해결하기 위해 넌 블로킹 모드, 비동기 모드 등이 나옵니다. 기본적인 서버의 구조와 네트워크는 데이터를 주고 받을 때 무엇이든 확신할 수 없다는 것. 지금 받은 것이 다 받은 것인지 보낸 것이 전부 보내어졌는지를 어떻게 확인하고 처리하는지 이해하시기를 바랍니다.

그럼 이번에는 클라이언트를 보기로 하지요. 클라이언트는 서버보다 간단하니 한번 보시면서 분석해 보시기 바랍니다. 보낸 데이터를 에코 해서 받을 때 어떻게 받았는지 어떻게 다 받았는지 클라이언트는 자신이 서버에 보낸 문자의 길이를 알고 있기 때문에 이것을 활용했습니다. 한번 보시면 이해하시리라 생각합니다.

에코 클라이언트

그럼 이제 클라이언트 소스입니다.

클라이언트의 실행은 <실행파일명 서버IP 에코문자 포트> 입니다.

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h> // socket() bind() connect()
#include <arpa/inet.h>  // socketaddr_in,  inet_ntoa()
#include <netinet/in.h> // 만약 FreeBSD 라면 이 해더를 추가해야한다.
#include <string.h>
#include <unistd.h>
#include <stdlib.h>

#define RCVBUFSIZE 128

int main(int argc, char * argv[])
{
        int sock;
        struct sockaddr_in echoServAddr;
        unsigned short echoServPort;
        char * servIP;
        char * echoString;
        char echoBuffer[RCVBUFSIZE];
        unsigned int echoStringLen;
        int bytesRcvd, totalBytesRcvd;

        if((argc < 3) || (argc  > 4))
        {
                printf("Usage: %s <Server IP> <Echo Word> [<Echo Port>]
", argv[0]);
                exit(1);
        }

        servIP = argv[1];
        echoString = argv[2];

        if(argc == 4)
                echoServPort = atoi(argv[3]);
        else
                echoServPort = 7; // 에코 서버의 well-know port입니다..

        if((sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0)
        {
                printf("socket() failed");
                exit(1);
        }

        memset(&echoServAddr, 0, sizeof(echoServAddr));
        echoServAddr.sin_family = AF_INET;
        echoServAddr.sin_addr.s_addr = inet_addr(servIP);
        echoServAddr.sin_port = htons(echoServPort);

        if(connect(sock, (struct sockaddr *) &echoServAddr, sizeof(echoServAddr)) < 0)
        {
                printf("connect() failed");
                exit(1);
        }

        echoStringLen = strlen(echoString);

        if(send(sock, echoString, echoStringLen, 0) != echoStringLen)
        {
                printf("send() failed");
                exit(1);
        }

        totalBytesRcvd = 0;
        printf("Received: ");

        while(totalBytesRcvd < echoStringLen)
        {
                // 문자끝 NULL을 넣기 위해 RCVBUFSIZE-1
                if((bytesRcvd = recv(sock, echoBuffer, RCVBUFSIZE-1,
								0)) <= 0)
                {
                        // 리턴값이 0이면 서버와 연결이 끊어짐..
                        printf("recv() 실패 혹은 서버와 연결이 끊어졌다.");
                        exit(1);
                }

                totalBytesRcvd += bytesRcvd;
                echoBuffer[bytesRcvd] = '�';
                printf(echoBuffer);
        }
        printf("
");

        close(sock);
        exit(0);
}

I/O 모델

Unix Network Programming 6장에 보면 I/O 모델에 대한 이야기가 나옵니다. 우선 그 모델을 살펴 보도록 하겠습니다.

Blocking I/O

위의 에코서버가 Blocking mode 입니다. 소켓을 열면 기본적으로 Blocking mode가 되는 것이지요. 말 그대로 Blocking 당한다고 생각하시면 되겠습니다. 위의 에코 서버를 생각해 봅시다. 에코 서버는 클라이언트로부터 데이터를 받기 위해 recv() 함수를 호출합니다. 그리고 프로세스는 클라이언트로 데이터가 올 때까지 멈춰있습니다. 그것이 Blocking 입니다. 그 함수가 일을 마칠 때까지 기다리고 있는 것입니다.
에코 서버처럼 간단하고 해야 할 일이 별로 없는 서버는 Blocking으로 만드는 것이 가능합니다. 그러나 게임같이 서버에서 많은 일을 하는 그리고 많은 사용자들을 처리하는 서버에는 알맞지 않습니다. 왜냐하면 한 클라이언트에게 데이터를 받기 위해 recv()에서 서버가 멈춰있다면 다른 클라이언트에게 피해가 있고, 다른 필요한 처리를 하는데 서버가 놀고 있게 되기 때문입니다. 그럼 그런 부분에서 함수를 바로 리턴하게 한다면 즉, 만약 리시브를 호출해서 받을 데이터가 있으면 받고 없다면 넘어가서 다른 일을 하면 될 것입니다.

Non-Blocking I/O

이렇게 나온 것이 Non-blocking 입니다. Non-blocking 은 요청한 I/O를 그 상황에서 할 수 있으면 하고 할 수 없다면 거기서 멈추지 말고 함수를 리턴하여 다른 작업을 할 수 있게 해주는 것입니다.
여기서 리턴될 때(I/O를 할 수 없어 리턴될 때) 다른 오류 코드를 리턴 한다면 I/O가 이루어졌는지 안 이루어 졌는지를 알 수 있을 것입니다. 그렇게 동작하는 모드가 Non-Blocking입니다.
그런데 여기서는 몇 가지 문제가 있는데 만약에 요청한 I/O를 할 수 없다면 클라이언트로부터 데이터는 받아야 하니 데이터를 받을 때까지 확인하는 작업이 필요해 지는 것입니다. 계속 반복문을 돌려 데이터를 다 받았는지를 확인 해야 하는 것입니다. 그것을 Polling 이라고 부릅니다. 이것은 CPU의 시간낭비인데 그것을 줄이는 방법은 어떤 것이 있을까요. 서버가 여러 가지 일을 하고 있는 상황에서 클라이언트가 어떤 데이터를 보내왔다고 하면, 그럼 여기서 누군가가 클라이언트가 데이터를 보내왔다는 것을 서버에게 알려 준다면, 그러면 폴링(polling)을 하는 것보다는 조금 더 좋은 성능을 보여 줄 수 있을 것입니다.

I/O Multiplexing

번역하자면 입출력 다중화라고 합니다. selcet()함수나 poll()함수를 이용하여 실제적으로 구현합니다. 이런 함수들을 이용하여 I/O를 호출하면 실제적으로는 시스템에서 blocking 됩니다. 어플리케이션에서는 blocking 당해 있지는 않습니다. 여기에 여러 개의 소켓들을 설정하여 그 소켓에 send, recv, error 등을 설정할 수 있습니다. 그러면 시스템에서 그런 설정된 사항에 맞는 상황이 일어나면 어플리케이션에게 그 사항을 알려줍니다. 그러면 어플리케이션에서 그것을 보고 알맞은 처리를 해주는 것이죠. 그러나 여기에도 단점이 있는데 한번에 select로 설정해 줄 수 있는 소켓의 개수가 제한이 다?것입니다.

Signal Driven I/O

이 방법은 인터럽트와 비슷하다고 생각하시면 됩니다. 이것은 만약에 어떤 I/O를 요청하고 그것이 준비가 되면 어플리케이션에게 신호를 보내어 준비되었다는 것을 알려 주는 것이지요. 만약에 이러한 방법을 쓴다고 한다면 클라이언트에게서 데이터가 들어 왔다면 어플리케이션에서 지정한 신호가 어플리케이션으로 온다는 겁니다. 어플리케이션에서는 그러한 신호를 받으면 그에 따른 적당한 처리를 해주면 됩니다. 그런데 여기에도 약간의 문제가 있습니다. 그 신호라는 것이 중복되어 들어 온다면 뒤에 온 신호는 무시됩니다. 그리고 이 방법은 TCP 에는 적당하지 않다고 하는데 왜냐하면 TCP 에서는 신호를 설정해두면 수많은 신호들이 어플리케이션에게 온다고 합니다. 그리고 신호가 발생되어도 어떤 일이 있었는지 알 수 없다고 합니다. 그래서 주로 UDP에서 사용한다고 합니다.

Asynchronaus I/O

Signal Driven I/O 에서는 I/O작업이 시작되는 순간에 신호를 보내어 알려 주는 것입니다. 비동기에서는 I/O작업이 완료되었을 때 이 사실을 알려주는 방식입니다.

대략적으로 개념은 이해하시리라 생각이 듭니다. 많은 방법들이 있습니다. Blocking 에서는 block 당하는 것을 해결하려고 non-blocking 이 나오고 non-blocking 의 폴링(polling)을 해결하려는 여러 가지 방법들이 나온 것 같습니다.

유닉스에서는 주로 I/O Multiplexing 을 많이 사용한다고 합니다. 그러나 실제 성능을 테스트해보면 non-blocking 이 가장 좋은 성능을 낸다고 합니다. 그런데 non-blocking 은 적성이 힘들고, 유지보수가 힘들다고 합니다. 그래서 I/O Multiplexing 을 사용하라고 권장하는 것 같습니다.

요즘에는 kqueue 가 BSD 계열 유닉스에서는 그것이 가장 좋은 성능을 낸다고 하고 윈도우에는 IOCP(I/O complete port)가 좋은 성능을 낸다고 합니다. 그러면 이런 좋은 성능을 내는 것만 사용하면 되지 않을까요? 그런 건 아닌 것 같습니다. 그 서버의 역할에 맞는 I/O 모델을 사용하는 것이 가장 효율적일 것입니다. Blocking 으로 충분히 감당할 수 있는 서버인데 무리해서 다른 모델을 도입하는 것은 개발 속도와 유지보수 면에서 불리한 면이 있을 수 있습니다. 그리고 서버의 여러 가지 설계, 그런 부분에서 해당 서버에 잘 맞는 I/O 모델을 선택해서 쓰면 되겠지요.

I/O Multiplexing 예제

이제 위의 에코 서버를 다시 작성해 봅시다.

위의 에코 서버는 한번에 한 사용자만을 처리할 수 있는 그런 서버였습니다. 그런데 서버에서 한 클라이언트가 아닌 여러 클라이언트를 처리 해주는 경우가 대부분입니다. 그럼 어떤 방법으로 여러 클라이언트를 처리 할까요? 일단 blocking으로 생각을 해보면 앞 강좌와 같은 코드가 나올 겁니다. (따로 스레드나 프로세스를 생성하지 않는다면 말이지요.) 이 방법은 한 클라이언트가 접속을 끝내지 않는다면 다른 사용자들은 끝날 때까지 가만히 기다리고 있는 그런 상황에 이르게 됩니다.

그럼 위에서 나온 다른 방법을 한번 살펴 보죠. 우리는 TCP이기 때문에 시그널방식은 접어두고, non-blocking 은 폴링(polling)을 사용해야 하고 성능은 좋지만 간단한 에코 서버이기 때문에 굳이 복잡하게 프로그래밍을 할 필요가 없을 것 같습니다. 비동기 모드는 조금 더 많은 공부를 해야 하니 I/O Multiplexing 으로 하겠습니다. I/O Multiplexing 방법 중 하나가 select()를 이용하는 방법입니다. 먼저 select() 함수에 대해 알아보도록 하겠습니다.

int select( int maxDescPlus1,
            fd_set * readDescs, fd_set * writeDescs,
            fd_set * exceptionDescs,
            struct timeval * timeout );

이것이 select함수의 원형입니다. 인자를 반대로 가며 설명을 하겠습니다. 먼저 timeval 입니다. 이 구조체는 몇 초인지, 그리고 몇 마이크로 초인지를 설정할 수 있습니다. 구조체를 살펴보면,

struct timeval
{
    long tv_sec;  // 초
    long tv_usec; // 마이크로초
};

이 구조체의 필드를 채워서 보내면 어떤 입출력이 준비가 되거나 시간이 지나면 select()함수가 리턴 됩니다. 그러니까 지정된 시간 이상은 입출력의 준비를 받지 않겠다는 것입니다. 만약 이 필드에 전부 0으로 설정을 하면 지정된 입출력들을 점검한 뒤 바로 리턴 됩니다. 그리고 여기에 null값을 주면 무한히 기다리게 되는 것입니다.
그 다음 인자를 보면 새로운 fd_set라는 것이 보입니다. 여기에 fd_set이라는 것으로 설정하여 시스템에게 어플리케이션에서 무엇을 해주길 바라는 지 알려 주는 것입니다. 변수 명을 보면 read, write, exception이라는 접두어가 있습니다. 말 그대로 recv에 필요한 것이면 설정하여 두 번째 인자에 넣어 주고 send가 필요하면 그 다음, exception 에 대한 처리가 필요하다면 그 다음의 인자에 설정하여 넣어 주면 되는 것입니다. 그러면 fd_set는 어떻게 생겨 먹었을까요. 여러 개의 나열된 비트 필드로 이루어져 있습니다.

소켓번호   0   1   2   3   4   5   6 ....
        -------------------------------------
        | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
        -------------------------------------

<그림> fd_set의 구조

만약 이렇게 하면 소켓번호 0번과 3번에 어떤 I/O가 일어 나면 알려 달라는 것입니다. 이것을 readDescs 인자에 넣으면 0번과 3번에 어떤 데이터가 들어오면 select문이 리턴 되는 것입니다. 똑같이 write와 exception인자도 동작이 똑같습니다. 그런데 0번은 표준 입력을 말합니다. 즉, 키보드를 말하지요. 1번은 표준출력을 2번은 표준에러를 말합니다. 표준에러와 출력은 주로 모니터를 가리킵니다. 소켓번호는 유닉스의 파일 디스크립터와 같다고 하였습니다. 유닉스는 전부 파일로 관리되니 0, 1, 2 는 표준 입력 출력 에러로 지정되어 있는 것입니다. 다시 위의 그림을 이야기 하면 키보드(표준입력)이나 소켓 번호 3번에 어떤 데이터가 들어오면 select가 리턴 되는 것입니다. 그런데 비트필드로 되어 있으니까 사용하기 불편합니다. 여기에는 매크로가 있습니다. 그 매크로를 이용해서 설정하고, 지우고, 확인합니다. 그 매크로를 알아보면,

매크로 명                                |설명
FD_ZERO(fd_set *fdset)         |*fdset의 모든 비트를 0으로 설정
FD_SET(int fd, fd_set *fdset)   |*fdset 중 소켓 fd에 해당하는 비트를 1로 설정
FD_CLR(int fd, fd_set *fdset)   |*fdset 중 소켓 fd에 해당하는 비트를 0으로 설정
FD_ISSET(int fd, fd_set *fdset) |*fdset 중 소켓 fd에 해당하는 비트가 1이고, 소켓에 I/O 변화가 생겼으면 true를 리턴

이렇게 있습니다. 처음에 FD_ZERO를 이용하여 초기화하고 확인할 소켓번호에 FD_SET로 설정한 뒤, select()를 호출합니다. 그리고 select가 리턴 되면, FD_ISSET로 어느 것이 입력 혹은 출력, 에러가 되었는지 확인하여 알아 내는 것입니다. 초기화는 중요합니다. 잘못된 값이 들어가는 것을 방지 하니깐요. 꼭 초기화를 해주시기 바랍니다.
첫 번째 인자는 설정될 소켓번호의 최대값에 +1을 한 것입니다. +1을 한 이유는 배열과 비슷합니다. 위의 그림을 보면 0부터 시작합니다. 즉, 지정한 소켓의 최대값이 아니라 지정한 소켓의 개수를 나타내는 것이기 때문입니다.
select()함수의 리턴 값은 -1이면 에러를 나타내고 0이면 타임아웃을 나타냅니다. 그리고 양수이면 준비된 소켓번호의 카운터를 말합니다.

그럼 소스를 보도록 하겠습니다.

#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/time.h>

#define MAXPENDING 5
#define RCVBUFSIZE 512

int CreateTCPServerSocket(unsigned short port);
int AcceptTCPConnection(int servSock);
void HandleTCPClient (int clntSocket);

int main(int argc, char * argv[])
{
        int * servSock;
        int maxDescriptor;
        fd_set sockSet;
        long timeout;
        struct timeval selTimeout;
        int running = 1;
        int nPorts;
        int port;
        unsigned short portNo;

        if(argc < 3)
        {
                printf("Usage : %s <Timeout (secs.)> <Port 1> ...
", argv[0]);
                exit(1);
        }

        timeout = atol(argv[1]);
        noPorts = argc - 2;

        servSock = (int *) malloc(noPorts * sizeof(int));
        maxDescriptor = -1;

        for(port = 0; port < noPorts; port++)
        {
                portNo = atoi(argv[port + 2]);
                servSock[port] = CreateTCPServerSocket(portNo);
                if(servSock[port] > maxDescriptor)
                        maxDescriptor = servSock[port];
        }

        printf("Starting server : Hit return to shutdown
");
        while(running)
        {
                FD_ZERO(&sockSet);
                FD_SET(STDIN_FILENO, &sockSet);
                for(port = 0; port < npPorts; port++)
                        FD_SET(servSock[port], &sockSet);

                selTimeout.tv_sec = timeout;
                selTimeout.tv_usec = 0;

                if(select(maxDescriptor+1, &sockSet, NULL, NULL,
                      &selTimeout) == 0)
                        printf(
                        "No echo requests for %ld secs... Server still alive
",
                         timeout);
                else
                {
                        if(FD_ISSET(STDIN_FILENO, &sockSet))
                        {
                                printf("Shutting down server
");
                                getchar();
                                running = 0;
                        }
                        for(port = 0; port < noPorts; port++)
                        {
                                if(FD_ISSET(servSock[port], &sockSet))
                                {
                                        printf("Request on port %d : ", port);
                                        HandleTCPClient(
                                         AcceptTCPConnection(servSock[port]));
                                }
                        }
                }
        }

        for(port = 0; port < noPorts; port++)
                close(servSock[port]);
        free(servSock);
        exit(0);
}

int CreateTCPServerSocket(unsigned short port)
{
        int sock;
        struct sockaddr_in echoServAddr;

        if((sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0)
        {
                printf("socket() failed
");
                exit(1);
        }

        memset(&echoServAddr, 0, sizeof(echoServAddr));
        echoServAddr.sin_family = AF_INET;
        echoServAddr.sin_addr.s_addr = htonl(INADDR_ANY);
        echoServAddr.sin_port = htons(port);

        if(bind(sock, (struct sockaddr *) &echoServAddr,
            sizeof(echoServAddr)) < 0)
        {
                printf("bind() failed
");
                exit(1);
        }
        if(listen(sock, MAXPENDING) < 0)
        {
                printf("listen() failed
");
                exit(1);
        }

        return sock;
}

void HandleTCPClient (int clntSocket)
{
        char echoBuffer[RCVBUFSIZE];
        int recvMsgSize;

        if((recvMsgSize = recv(clntSocket, echoBuffer, RCVBUFSIZE, 0)) < 0)
        {
                printf("recv() failed
");
                exit(1);
        }

        while(recvMsgSize > 0)
        {
                if(send(clntSocket, echoBuffer, recvMsgSize, 0) != recvMsgSize)
                {
                        printf("send() failed
");
                        exit(1);
                }
                if((recvMsgSize = recv(clntSocket, echoBuffer,
                   RCVBUFSIZE, 0)) < 0)
                {
                        printf("recv() failed
");
                        exit(1);
                }
        }
        close(clntSocket);
}

int AcceptTCPConnection(int servSock)
{
        int clntSock;
        struct sockaddr_in echoClntAddr;
        unsigned int clntLen;

        clntLen = sizeof(echoClntAddr);

        if((clntSock = accept(servSock,
             (struct sockaddr *) &echoClntAddr, &clntLen)) < 0)
        {
                printf("accept() failed
");
                exit(1);
        }
        printf("Handling client %s
", inet_ntoa(echoClntAddr.sin_addr));
        return clntSock;
}

전체적인 프로그램의 흐름을 보면, 사용자가 사용하겠다는 포트를 여러 개 열어서 각각 클라이언트의 요청이 들어오기를 기다리고 있습니다. 사용자가 정한 시간에 맞추어서 말이죠. 그리고 요청이 들어오면 에코 서비스를 하고 만약 사용자가 리턴 키를 누르면 서버가 종료되는 것입니다. (STDIN_FILENO가 리턴 키를 누르면 준비 됨)

그러나 이 예제도 부족합니다. 여기에서는 한 클라이언트를 받던걸 여러 사용자에게 받게 하였을 뿐입니다. 한 사용자를 전부 처리 할 때까지 다시 다른 클라이언트는 기다려야 하는 것은 아직 해결되지 못했습니다.
그럼 어떻게 하면 공평하게 여러 사용자를 처리 할 수 있을까요? 만약 각 클라이언트마다 그 클라이언트를 전담하는 무언가를 만든다면, 어느 정도 공평하게 클라이언트를 처리할 수 있을 겁니다.
그 방법에는 클라이언트당 하나씩 프로세스를 따로 만들 수도 있습니다. 그러나 프로세스를 하나 만든다는 것은 많은 비용이 들어 갑니다. 한마디로 메모리도 많이 먹고 새로운 프로세스를 만드는 데도 시간이 많이 걸린다는 것입니다. 또한 컨텍스트 스위칭(다른 프로세스로 CPU 타임을 넘기는 행동) 하는 그 비용도 많이 듭니다. 그리고 사용자가 아주 많다면 분명 서버에 무리가 갈 것입니다.
프로세스보다 비용이 조금 드는 스레드를 한번 생각해봅시다. 분명 프로세스보다 비용이 덜 드니 프로세스보다는 성능이 좋을 것입니다. 그러나 사용자가 많아진다면, 이것 또한 해결책은 아닌 것 같습니다. 그러나 서버의 어떤 작업을 스레드로 분리하면 무언가 좋은 방법이 나올 것입니다. 그런 방법에 대해서는 다른 책이나 강좌를 참고하세요.

이번엔 프로세스가 아닌 스레드를 이용하여 에코 서버를 한번 만들어 보도록 합시다. 모든 문제를 해결할 수 있지는 않지만 공부하는 차원에서는 유용할 것입니다.
유닉스에서는 POSIX라는 표준이 있습니다. 여기에는 스레드에 대한 내용이 있는데 그것이 pthread입니다.

POSIX Thread

Thread vs Process

각 클라이언트를 다루기 위해 새로운 프로세스를 하나 만드는 것은 비용이 많이 듭니다. 그 내용을 살펴보면,

  • 프로세스가 하나 생성될 때마다 운영체제는 메모리, 스택, 파일/소켓 식별자들 및 기타를 포함한 부모 프로세스의 전체 상태를 복사
  • Thread들은 같은 프로세스 내의 멀티태스킹을 허용함으로써 이러한 비용을 감소. 새로 생성된 Thread는 부모와 같은 주소공간(코드 및 데이터)을 공유하고, 부모의 상태를 복제할 필요성 배제
  • 프로세스 복제이후 부모와 자식간에 정보를 주고받기 위해 프로세스간 통신(IPC) 필요 (자식으로부터 부모로 정보를 되돌리는 것은 더욱 많은 작업을 요구)
  • 프로세스 중의 모든 Thread가 공유하는 것
    • 프로세스 지시 사항
    • 대부분의 데이터
    • 공개된 파일들(Ex 지정 번호들)
    • 신호 처리기와 신호 배치들
    • 사용자와 그룹 ID
  • 각 Thread 자신만이 갖는 것
    • Thread ID
    • 프로그램 계수기와 스택 지시자를 포함한 레지스터의 조합
    • (지역변수와 반환 주소를 위한) stack
    • errno
    • 신호 선별
    • 우선순위

이와 같이 비교 될 수 있습니다.

Basic Thread Functions

거의 모든 pthread 함수는 성공하면 0을 리턴하고 실패하면 0이 아닌 값을 리턴 합니다. 그러나 errno 변수는 설정하지 않는 것이 특징입니다. 밑의 pthread 함수들은 모두 여기에 따른다고 생각하시면 됩니다.

pthread를 사용하기 위해서는 밑의 해더를 추가해야 합니다.

#include <pthread.h>

int pthread_create( pthread_t * threadID,        // Thread ID, (unsigned int)
                    const pthread_attr_t * attr, // Thread 속성, NULL Default
                    void * ( * func )( void * ), // 입구함수
                    void * arg );                // 여러 인자를 전달할 때, structure 이용

스레드를 생성합니다. 첫 번째 인자는 스레드가 생성되면 그 스레드의 ID가 저장되는 변수이고, 두 번째 인자는 여러 가지 속성(우선순위나 스텍 사이즈 등을 말합니다.)을 나타냅니다. default로 하려면 NULL을 전달 하면 됩니다. 세 번째 인자는 스레드의 입구 함수(스레드가 시작되는 함수, 스레드가 할 역할을 기술해놓은 함수)입니다. 스레드가 실행되면 그 함수를 실행합니다. 입구함수의 형태는 반드시

void * ThreadMain( void * arg );

위와 같은 형태여야 합니다. 그리고 마지막은 스레드 입구함수의 인자로 전달될 변수입니다.

void pthread_exit ( void * status ); // 리턴할 값

Thread 중단합니다. 만일 Thread가 분리되지 않으면 Thread ID와 리턴 값은 종결 프로세스의 다른 Thread에 의하여 나중까지 pthread_join에 남겨집니다. Thread가 종결 될 때에는 객체가 사라지므로 status 는 호출 Thread에 지역적인 변수를 지정하면 안됩니다.

int pthread_join ( pthread_t tid, void ** status );

tid가 가리키는 Thread가 종료할 때까지 위의 함수를 호출한 Thread의 수행을 멈춥니다. 만약 status가 NULL이 아니면 tid의 리턴 값은 status가 가리키는 영역에 저장됩니다.

pthread_t pthread_self ( void );

Thread 자신의 Thread ID 리턴 합니다. 이 값은 pthread_create() 로 얻은 스레드 ID와 동일합니다.

int pthread_detach ( pthread_t tid );

Thread 상태가 부모의 개입 없이도 종료 시 즉시 해제합니다. 주로 pthread_detach ( pthread_self() ); 로 사용합니다.

보다 더 자세한 사항은 Joinc의 Pthread API Reference를 참고하세요.

TCP Echo Server를 위한 클라이언트당 Thread 멀티태스킹 Source

/****** TCPEchoServer_Thread.c *******/

#include "TCPEchoServer.h"
#include <pthread.h> // for POSIX threads

void * ThreadMain(void * arg); // Main program of a thread

// Structure of arguments to pass to client thread
struct ThreadArgs
{
        int clntSock;
};

int main(int argc, char * argv[])
{
        int servSock;
        int clntSock;
        unsigned short echoServPort;
        pthread_t threadID;
        struct ThreadArgs * threadArgs;

        if(argc != 2)
        {
                fprintf(stderr, "Usage: %s <Server Port>
", argv[0]);
                exit(1);
        }

        echoServPort = atoi(argv[1]);

        servSock = CreateTCPServerSocket(echoServPort);

        for(;;)
        {
                clntSock = AcceptTCPConnection(servSock);

                // Create memory for client argument
                if((threadArgs =
                   (struct ThreadArgs *) malloc(sizeof(struct ThreadArgs)))
                    == NULL)
                        DieWithError("malloc() failed");

                threadArgs->clntSock = clntSock;
                // Create thread
                if(pthread_create(&threadID, NULL, ThreadMain,
                    (void *) threadArgs) != 0)
                        DieWithError("pthread_create() failed");
                printf("with thread %ld
", (long int) threadID);
        }
}

void * ThreadMain(void * threadArgs)
{
        int clntSock;
        // Guarantees that thread resource are deallocated upon return
        pthread_detach(pthread_self());

        clntSock = ((struct ThreadArgs *) threadArgs)->clntSock;
        free(threadArgs); // Deallocate memory for argument

        HandleTCPClient(clntSock);

        return (NULL);
}

TCP 관련 소스는 앞의 select() 예제 소스와 같으니 위의 소스를 보시면 되겠습니다. 소스코드의 컴파일은

$ gcc -o TCPEchoServer-Thread TCPEchoServer_Thread.c -Wall -lpthread (FreeBSD 라면 -pthread)

로 하시면 됩니다.

소켓 부분은 에코 서버와 거의 같으니 스레드 부분만 보도록 하겠습니다.

pthread_create(&threadID, NULL, ThreadMain, (void *) threadArgs);

이 부분이 스레드를 생성하는 함수입니다. 입구함수 인자로 여기에서는 소켓번호 하나만 전달합니다. 그런데 전달되는 구조체를

struct ThreadArgs
{
    int clntSock;
};

이렇게 선언해 놓았습니다. 물론 소켓번호만 전달해도 상관없습니다. 그런데 만약 여러 가지 정보를 전달하려고 하면 인자가 하나뿐이니 하나만 전달할 수 있습니다. 아니면 전역 등의 방법을 사용해야 합니다. 그런데 스레드 입구함수의 인자가 void * 형이기 때문에 이런 구조체를 만들어서 구조체의 포인터를 전달하면 여러 인자를 전달 할 수 있게 됩니다.

그리고 소스를 보면 전달될 인자를 동적 메모리 할당을 하였는데 반드시 이렇게 해야 합니다. 만약 지역변수로 전달하게 되면 어떻게 될까요? 스레드는 서로 경쟁하며 실행됩니다. 그러니까 정확히 어느 것이 먼저 실행될지는 아무도 모르는 것입니다. 그래서 지역변수로 선언해 놓았다면 스레드가 실행되어 인자가 참조되기 전에 스레드 함수를 호출한 곳이 먼저 종료되었다면 인자의 변수는 잘못된 메모리를 가리키고 있는 것이 되어 버립니다. 그래서 잘못된 결과를 이르게 하는 것이죠. 그리고 스레드 입구함수에서 인자의 메모리를 해제했습니다. 이것도 위와 비슷합니다. 만약에 스레드 함수를 호출한 곳에서 메모리를 해제한다면 지역변수와 똑같은 결과를 낳게 됩니다.

위 소스는 부족한 소스입니다. 만약 서버가 종료된다면 생성했던 스레드가 확실히 종료되었는지 그런 것을 알 수 없기 때문이죠. 물론 소멸되겠지만요. 안전하게 스레드가 종료되었는지 알고 나서 서버를 닫는 것이 더욱 좋을 것입니다.

위의 스레드의 소스는 하나의 처리를 스레드에게 맡김으로써 여러 사용자를 받을 수 있게 하였습니다. 프로세스를 생성하는 것도 이와 비슷합니다. Pthread_create() 대신 fork()함수를 이용해서 프로세스를 만들면 됩니다. 앞에서 select()와 스레드에 대해서 알아 봤습니다. 그럼 이 두 개를 결합하여 만드는 것은 어떨까요? 그런 것은 채팅 서버를 한번 만들어 보시면서 하면 좋은 예가 될 것 같습니다.

마치며

초보적인 내용인 만큼 이 글이 네트워크를 공부를 시작하시는 분에게 조금이나마 도움이 되었으면 합니다. 그리고 함수 설명보다는 원리에 중점을 두어 설명을 하려고 했는데 잘 안된 것 같습니다. 아직 UDP, 멀티캐스트 같은 주제가 남아 있습니다. 꼭 보시고 가시길 바랍니다. 보통 멀티캐스트는 지원하는 라우터가 많이 없어서 하기 힘들다고 합니다. 소프트웨어로 처리하는 방법이 있기는 하지만 아직 부족한 점이 많이 있나 봅니다. 그러나 UPD는 반드시 공부해보셔야 할 듯합니다. TCP와 UDP의 각자의 장/단점이 서로 절충 될 수 있는 소지가 많이 있기 때문입니다. 많은 운영체제가 운영체제마다 다른 poller 를 제공하고 있는데 보통 성능상의 문제로 이런 것들을 이용해서 서버를 작성해나간다고 합니다. 각각마다 장단점이 있어 제가 무어라고 할 수 없지만, 그런 것들은 하나의 방법인 것 같습니다. 여러 가지 방법을 생각해보고 자신의 어플리케이션에 가장 효율적이고 가장 알맞은 그런 방법을 찾으면 되는 것 같습니다. 조금이라도 좋은 성능을 얻기 위해 서버를 어떻게 구성해야 하는지 패킷은 어떻게 구성하는지 등 여러 가지 방법들을 연구해보고 테스트 해보세요.

관련링크


분류: LectureNote