소켓 강좌

_

_

Network의 기본

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

를 추천합니다. 둘 다 원서입니다. 영어가 부담스러우신 분은 각 서적에 대한 번역서도 있으니 번역의 질 등을 잘 알아보고 선택하시기 바랍니다.
다른 책을 참고하셔도 크게 상관은 없으나 간략한 소개만 되어 있는 것이 아닌 자세한 내용이 있는 것을 보세요. 왜냐하면 정확한 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 를 보시면서 네트워크의 개념을 잡으시기 바랍니다. 간단히 그림을 보도록 하겠습니다.

http://www.gpgstudy.com/forum/album_pic.php?pic_id=45&;.jpg
<그림> 네트워크 계층과 흐름

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

http://www.gpgstudy.com/forum/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을 따릅니다. 그래서 네트워크로 보내기 전에 이 바이트 순서를 조정해 주어서 보내어야만 합니다.
그럼 네트워크 순서와 호스트 순서를 바꾸는 함수에 대해서 알아 보도록 하겠습니다. 함수 이름에는 규칙이 있는데,

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

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_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의 내용인가? 하는 것을 나타냅니다. 여기에는 다음과 같은 것들이 있습니다.

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

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

1) SOL_SOCKET Level

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

int rcvBufferSize;
int sockOptSize;
.
.
.
// 소켓 리시브 버퍼 크기 얻기
sockOptSize = sizeof( rcvBufferSize );
if( getsockopt( sock, SOL_SOCKET, SO_RCVBUF, &amp;rcvBufferSize, &amp;sockOptSize ) < 0 )
{
        printf( 'getsockopt() failed
' );
        exit( 1 );
}
// 리시브 버퍼의 크기를 2배로 만든다.
rcvBufferSize *= 2;
if( setsockopt( sock, SOL_SOCKET, SO_RCVBUF, &amp;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 옵션이 있습니다. 이것은 TCP 에서 적용되는 것인데 close함수의 행동을 지정하는 옵션입니다. close() 하면 recv Buffer 나 send Buffer 에 보내거나 받을 데이터가 있다면 전부 처리 후 close() 를 합니다. 그 방법을 바꾸는 것입니다. 먼저 전달되는 구조체에 대해서 알아 보도록 합시다.

struct linger
{
    int l_onoff;
    int l_linger;
};

setsockopt( sock, SOL_SOCKET, SO_LINGER, &amp;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 메시지(받았다는 확인 메시지)를 받아야 완전한 종료가 이루어집니다. 그 메시지를 기다리는 시간이 있습니다. 만약 그것을 다 받지 못했다면 다시 보내야 하지요. 그런 상황에서 다시 서버를 시작하려고 하면 이미 사용 중인 포트라는 에러를 내게 됩니다. 그래서 이런 옵션을 사용하는 것입니다. 그런데 그것은 바람직한 해결 방법이 아니라고 합니다. 그래서 이런 옵션은 추천되고 있지 않습니다. 이에 대한 해결책은 따로 있습니다. 그것이 다음에 설명할 포트 재사용 옵션입니다.

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

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

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

2) IPPROTO_IP Level

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

3) IPPROTO_TCP Level

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( &amp;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 *) &amp;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 *)
                    &amp;echoClntAddr, &amp;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( &amp;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 * ) &amp;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 *) &amp;echoClntAddr, &amp;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(&amp;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 *) &amp;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 {#sign