편리한 화면 캡쳐 프로그램 "오픈 캡쳐"

사용자 삽입 이미지


opencapture_v1.3.4.exe 다운로드

소스코드 다운로드

이 프로그램은 프리웨어입니다.


오픈 캡쳐 간단한 도움말


2007.6.23

 

#추가 된 기능

오페라 웹 브라우저에서 스크롤 캡쳐 가능하도록 추가 ( 오페라 V9.10 에서 테스트 함 )

#변경 된 부분

pcx 포맷을 더 이상 읽거나 로드 할 수 없으며, 대신 mng 파일을 읽거나 로드 할 수 있도록 추가하였습니다.

#수정 된 버그

윈도우, 컨트롤, 오브젝트 캡쳐 기능이 IE 에서 제대로 동작하지 않던 점 수정
네이버 블로그 카폐, 다음 블로그 카폐 스크롤 캡쳐 안되던 점 수정
기타 버그 수정



------------------------------------------------------------------------
내가 다니는 회사에 같이 일하고 있는 직원이 직접 만들어
본인도 편리하게 쓰고 있고 많은 사람들이 고마워하는 화면 캡쳐 툴이다.
------------------------------------------------------------------------


http://openproject.nazzim.net/

Posted by 젤라피

네트워크 프로그래밍의 기초 Ⅰ


내용

   * 소켓이란 무엇인가.
   * 두가지 종류의 소켓
   * 네트워크 이론과 저수준의 알수없는 것들
   * struct s--이걸 모르면 외계인이 지구를 파괴할걸~~
   * 순서 바꾸기
   * IP주소 와 활용법
   * socket()--파일 기술자를 잡아라
   * bind()--나는 어떤 포트에 연결되었나?
   * connect()--어이 거기!
   * listen()--누가 전화좀 걸어주지
   * accept()--포트3490에 전화걸어주셔서 감사합니다.
   * send() and recv()--말좀 해봐!
   * sendto() and recvfrom()--말좀해봐! 데이터그램방식
   * close() and shutdown()--꺼지쇼!
   * getpeername()--누구십니까?
   * gethostname()--난 누구인가?
   * DNS--"whitehouse.gov", - "198.137.240.100"
---------------------------------------------------------

소켓이란 무엇인가.

소켓이란 단어는 많이 들었을 것이다. 그리고 아마도 그 소켓이 정확히 무엇인가에 대하여 궁금해 하기도 했을 것이다. 소켓은 정규 유닉스 파일 기술자를 이용하여 다른 프로그램과 정보를 교환하는 방법을 의미한다.

아마도 유닉스를 잘하는 사람들이 이렇게 얘기하는 것을 들어본 적이 있을 것이다. "유닉스에서는 모든게 파일로 되어있군!" 실제로 그들이 얘기하는 것은 모든 유닉스 프로그램들이 어떤 종류의 입출력을 하더라도 파일 기술자를 통해서 하게 된다는 것이다. 파일 기술자는 사실 열려진 파일을 의미하는 정수일 뿐이다.

그러나 그 파일은 네트워크가 될수도 있고 FIFO, 파이프, 터미널, 실제 디스크상의 파일이 될수도 있으며 그 밖의 무엇도 다 된다는 것이다. 유닉스의 모든것은 파일이다! 따라서 당신이 인터넷을 통하여 멀리 떨어진 다른 프로그램과 정보를 교환하기 위해서는 파일 기술자를 이용하면 된다는 것이다.

"똑똑이 양반, 그 파일 기술자는 도대체 어떻게 만드는거요?" 라는게 당신의 맘속에 지금 막 떠오른 질문일 것이다. 여기에 대답이 있다. socket()을 호출하면 소켓 기술자를 얻게 되고 send(), recv()등의 소켓에 관련된 함수를 호출하여 정보를 교환할 수 있다. (man send, man recv를 해봐도 됨)

"잠깐!" 이렇게 이의를 제기하겠지. "그 소켓 기술자가 파일 기술자라면 도대체 왜 read(),write()를 쓰면 안되는거요?" 짧게 말하면 맞다. 그러나 send(),recv()를 쓰는 것이 여러모로 네트워크를 통한 정보전달을 제어하기에 도움이 된다는 것이다.

다음은 뭔가? 소켓의 종류는? DARPA 인터넷 주소(인터넷 소켓), 경로명과 지역노드(유닉스 소켓), CCITT X.25 주소(X.25 소켓, 그냥 무시해도 됨)등이 있고 아마도 당신이 쓰는 유닉스에 따라서 더 많은 종류의 소켓들이 있을 것이다. 이 문서는 첫번째 (인터넷 소켓) 하나만 설명할 것이다.

---------------------------------------------------------

두가지 종류의 소켓

인터넷 소켓에 두가지 종류가 있나? 그렇다. 음..사실은 거짓말이다. 좀 더있긴 하지만 겁을 주고 싶지 않기 때문에 이것 두가지만 이야기 하는 것이다. RAW 소켓이라는 매우 강력한 것도 있으며 한번 봐두는 것도 좋다.(Raw 소켓은 root만이 쓸 수 있고, 새로운 프로토콜을 만들때 사용하는 소켓이다.)

두가지 종류는 무엇인가? 하나는 스트림소켓 이고 다른 하나는 데이터그램 소켓이다. 이후에는 SOCK_STREAM, SOCK_DGRAM으로 지칭될 것이다. 데이터그램 소켓은 비연결 소켓이라고도 한다. (비록 그 소켓에서도 원한다면 connect()를 사용할 수도 있다. connect()절을 참조할것)

스트림 소켓은 양측을 신뢰성있게 연결해 주는 소켓이다. 만약 두가지 아이템을 이 소켓을 통하여 보낸다면 그 순서는 정확히 유지될 것이다. 에러까지 교정된다. 만일 에러가 생긴다면 당신 실수이고 당신실수를 막는 방법은 여기서 설명하지 않을 것이다.

스트림 소켓은 어디에 쓰이는가? 아마도 텔넷이라고 들어봤을 것이다. 들어봤느뇨? 그게 이 소켓을 쓴다. 입력한 모든 글자는 그 순서대로 전달이 되야 하는 경우이다. 사실 WWW사이트의 포트 80에 텔넷으로 접속하여 "GET pagename" 을 입력하면 HTML 화일의 내용이 우르르 나올 것이다.

어떻게 스트림 소켓이 이정도의 정확한 전송 품질을 갖추게 되는가? 이 소켓은 TCP를 이용하기 때문이다. (Transmission Control Protocol, RFC-793에 무척 자세하게 나와있다.) 아마도 TCP 보다는 TCP/IP를 더 많이 들어봤을 것이다. 앞부분은 바로 이 TCP이고 뒷부분의 IP는 인터넷 라우팅을 담당하는 프로토콜이다.

괜찮군~ 데이터그램 소켓은 어떤가? 왜 비연결이라고 하는지? 내용에 무슨 관련이 있는지? 왜 신뢰도가 떨어지지? 사실 이 소켓의 경우 당신이 데이터그램을 보낸다면 정확히 도착할 수도 있다. 또는 패킷들의 순서가 바뀌어서 도착할 수도 있다. 그러나 만약 도착한다면 그 내용은 사실 정확한 것이다. 데이터그램 소켓 또한 라우팅에는 IP를 이용하지만 TCP는 이용하지 않는다. 사실은 UDP(RFC-768)을 이용한다. 연결을 안하는가? 스트림 소켓에서처럼 열려있는 연결을 관리할 필요가 없는것이다. 그냥 데이터 패킷을 만들어서 목적지에 관련된 IP헤더를 붙여서 발송하기만 하면 되는 것이다. 연결이 필요없다. 보통 tftp나 bootp 에 사용되는 것이다.

좋아! 그러면 데이터 패킷이 도착하지 않을지도 모르는 이런 걸 어떻게 실제 프로그램에서 사용하지? 사실 프로그램들은 UDP위에 그 나름대로의 대책을 갖추고 있는 것이다. 예를 들면 tftp같은 경우에는 하나의 패킷을 보낸 후에 상대편이 잘 받았다는 응답 패킷이 올때까지 기다리는 것이다. 만약 일정시간(예를 들면 5초)동안 응답이 없으면 못받은 것으로 간주하고 다시 보내고, 다시 보내고 응답이 있으면 다음 패킷을 보내고 하게 되는것이다. 이 잘받았다는 응답(ACK reply) 방식은 사실 SOCK_DGRAM을 사용할 경우 매우 중요하다.

---------------------------------------------------------

네트워크 이론과 저아래의 알수없는 것들

간단히 프로토콜의 레이어에 대해서 언급을 했지만(UDP위에 나름대로의 대책 어쩌구) 이제는 실제로 네트워크가 어떻게 작동하는 지를 알아볼 때가 되었고 실제로 SOCK_DGRAM이 어떻게 구성되는 지를 알아볼 필요가 있을 것같다. 사실 이 절은 그냥 넘어가도 된다.

여러분~ 이제는 데이타 캡슐화에 대하여  배우겠어요~ 사실 이것은 매우 중요하다.

얼마나 중요하냐면 우리 학교에서 네트워크 코스를 통과하려면 반드시 알아야 하는 사항이기 때문이다. (흠..) 내용은 이렇다. 데이터 패킷이 만들어지면 먼저 첫번째 프로토콜(tftp 프로토콜)에 필요한 머리말과 꼬리말이 붙는다. 이렇게 한번 캡슐화된 내용은 다시 두번째 프로토콜(UDP)에 관련된 머리말과 꼬리말이 다시 붙게 된다. 그 다음에는 IP, 그 다음에는 마지막으로 하드웨어 적인 계층으로서 이더넷 프로토콜로 캡슐화가 되는 것이다.

다른 컴퓨터에서 이 패킷을 받게 되면 하드웨어가 이더넷 헤더를 풀고 커널에서 IP와 UDP 헤더를 풀고 tftp 프로그램에서 tftp헤더를 풀고 하여 끝으로 원래의 데이터를 얻게 되는 것이다.

이제 드디어 악명높은 계층적 네트워크 모델(Layered Network Model)을 얘기할 때가 된것 같다. 이 모델은 다른 모델들에 비해서 네트워크의 시스템을 기술하는 측면에서 많은 이점이 있다. 예를 들면 소켓 프로그래밍을 하는 경우 더 낮은 계층에서 어떤 물리적인 방식(시리얼인지 thin ethernet인지 또는 AUI방식인지)으로 전달되는 지에 대하여 전혀 신경을 쓰지 않고도 작업이 가능해 질 수 있다는 것이다. 실제 네트워크 장비나 토폴로지는 소켓 프로그래머에게는 전혀 관계없는 분야이다.

더이상 떠들지 않고 다음 계층들을 일러 주는데 만일 네트워크 코스에서 시험을 보게 될 경우라면 외우는 것이 좋을 것이다.(이거 질리도록 봤습니다. ^^ )

   * Application
   * Presentation
   * Session
   * Transport
   * Network
   * Data Link
   * Physical

물리적 계층(Physical layer)는 하드웨어(시리얼, 이더넷등) 이다. 어플리케이션 계층은 상상할 수 있듯이 물리적 계층의 반대편 끝이다. 이 계층을 통하여 사용자는 네트워크와 접촉하게 되는 것이다.

사실 이 모델은 자동차 수리 설명서 처럼 실질적인 뭔가를 할 수 있기에는 너무나 일반적인 얘기이다. 유닉스의 경우를 들어 보다 실질적인 얘기를 해 본다면,

   * Application Layer (telnet, ftp, etc.)
   * Host-to-Host Transport Layer (TCP, UDP)
   * Internet Layer (IP and routing)
   * Network Access Layer (was Network, Data Link, and Physical)

이러한 계층으로 살펴 본다면 아까의 데이터 캡슐화가 각각 어떤 계층에 속하는 가를 알 수 있을 것이다. 이렇게 많은 작업이 하나의 데이터 패킷을 만드는데 동원되는 것이다. 이 내용을 당신이 데이터의 패킷 머리부분에 몽땅 타이핑 해 넣어야 한다는 얘기다. (물론 농담이다.) 스트림 소켓의 경우 데이터를 내보내기 위해 해야 할 일은 오직 send()를 호출하는 것 뿐이다. 데이터 그램의 경우에는 원하는 방식으로 데이터를 한번 캡슐화하고 (tftp방식등) sendto()로 보내버리면 되는 것이다.커널이전송계층과 인터넷 계층에 관련된 캡슐화를 하고 나머지는 하드웨어가 한다. 아~첨단 기술!!

이것으로 간단한 네트워크 이론은 끝이다. 참, 라우팅에 관해서 하고 싶던 얘기들을 하나도 안했다. 흠, 하나도 없다. 정말이지 라우팅에 관해서 하나도 얘기하지 않을 것이다. 라우터가 IP헤더를 벗겨내서 라우팅 테이블을 참조하여 어쩌구 저쩌구...만일 정말로 여기에 관심이 있다면 IP RFC를 참조할 것이며 만약 거기에 대해서 하나도 알지 못한다면! 생명에 지장은 없다.

---------------------------------------------------------

struct S

결국은 여기까지 왔군. 드디어 프로그래밍에 관한 얘기를 할 때이다. 이 절에서는 실제로 꽤나 이해하기 어려운 소켓 인터페이스에서 쓰이는 여러가지 데이터 타입에 대한 얘기를 할 예정이다.
먼저 쉬운것. 소켓 기술자이다.소켓 기술자의 데이터 형은
        int
이다. 그냥 보통 int이다. (정수형)

뭔가 좀 이상하더라도 그냥 참고 읽기 바란다. 이것은 알아야 한다. 정수에는 두 바이트가 있는데 상위 바이트가 앞에 있거나 또는 하위 바이트가 앞에 있게 된다. 앞의 경우가 네트워크 바이트 순서이다. 어떤 호스트는 내부적으로 네트워크 바이트 순서로 정수를 저장하는 경우도 있으나 안그런 경우가 많다. 만일 NBO(Network Byte Order)라고 언급된 정수가 있다면 함수를 이용하여 (htons()함수) 호스트 바이트 순서로  바꾸어야 한다. 만약 그런 언급이 없다면 그냥 내버려 둬도 된다.첫번째 구조체, struct sockaddr. 이 구조체는 여러가지 형태의 소켓 주소를 담게된다.

struct sockaddr {
       unsigned short    sa_family;    /* address family, AF_xxx       */
       char              sa_data[14];  /* 14 bytes of protocol address */
};

sa_family 는 여러가지가 될 수 있는데, 이 문서에서는 그중에서 "AF_INET"인 경우만 다루게 된다. sa_data 는 목적지의 주소와 포트번호를 가지게 된다. 약간 비실용적이군. sockaddr 구조체를 다루기 위해서는 다음과 같은 parallel structure를 만들어야 한다. ("in"은 인터넷을 의미한다.)

struct sockaddr_in {
       short int          sin_family;  /* Address family               */
       unsigned short int sin_port;    /* Port number                  */
       struct in_addr     sin_addr;    /* Internet address             */
       unsigned char sin_zero[8]; /* Same size as struct sockaddr */
};

이 구조체는 각각의 항을 참조하기가 좀더 쉬운 것 같다. 주의할 점은 sin_zero배열은 sockaddr 과 구조체의 크기를 맞추기 위해서 넣어진 것이므로 bzero()나 memset()함수를 이용하여 모두 0으로 채워져야 한다. 또한 꽤 중요한 점인데, 이 구조체는 sockaddr 의 포인터를 이용하여 참조될 수 있고 그 반대도 가능하다는 것이다. 따라서 socket()함수가 struct sockaddr * 를 원하더라도 struct sockaddr_in을 사용할 수 있고 바로 참조할 수도 있는 것이다. 또한 sin_family는 sa_family에 대응되는 것이며 물론 "AF_INET"로 지정되어야 하며 sin_port, sin_addr은 네트워크 바이트 순서로 되어야 하는 점이 중요한 것이다.

그러나! 어떻게 struct in_addr sin_addr 전체가 NBO가 될 수 있는가? 이 질문은 살아남은 가장 뭣같은 유니온인 struct in_addr 에 대한 보다 신중한 검토가 필요할 것같다.

/* Internet address (a structure for historical reasons) */
struct in_addr {
       unsigned long s_addr;
};

음.. 이것은 유니온 "이었었"다. 그러나 그런 시절은 지나갔다. 시원하게 없어졌군! 따라서 만약 "ina"를 struct sockaddr_

in형으로 정의해 놓았다면 ina.sin_addr.s_addr 로 NBO 상태의 4바이트 인터넷 어드레스를 정확하게 참조할 수 있을 것이다. 만약 사용하는 시스템이 struct in_addr에 그 끔찍한 유니온을 아직도 사용하고 있더라도 #defines S 덕분에 위에 한것과 마찬가지로 정확하게 참조할 수는 있을 것이다.

---------------------------------------------------------

순서 바꾸기

이제 다음 절로 왔다. 네트워크와 호스트 바이트 순서에 대해서 말이 너무 많았고 이제는 실제 움직일 때라고 본다.

좋다. 두가지 형태의 변환이 있는데 하나는 short(2 바이트)와 long(4바이트)의 경우이다. 이 함수들은 unsigned변수에서도 잘 작동된다. 이제 short변수를 호스트 바이트 순서에서 네트워크 바이트 순서로 변환하는 경우를 보자. 호스트의 h 로 시작해서 to 를 넣고 네트워크의 n 을 넣은 후 short의 s 를 넣는다. 그래서 htons()이다. (읽기는 호스트 투 네트워크 쇼트이다.)

너무 쉬운가?
사실 h,n,s,l 의 어떤 조합도 사용가능하다. (물론 너무 바보스러운 조합을 하지는 않겠지..예를 들어 stolh, 쇼트 투 롱 호스트?? 이런건 없다. 적어도 이 동네에서는없다.) 있는 것들은 다음과 같다.

   * htons()--"Host to Network Short"
   * htonl()--"Host to Network Long"
   * ntohs()--"Network to Host Short"
   * ntohl()--"Network to Host Long"

아마도 이제 상당히 많이 알게된 것같이 생각들을 할 것이다. "char의 바이트 순서를 어떻게 바꾸지?(역자주: 이 질문은 아마 의미없는 질문으로 한 것 같은데 답도 없고 더이상의 언급이 없는 것으로 보아 빼고 싶은 부분이다.)" 또는 "염려마, 내가 쓰는 68000 기계는 이미 네트워크 바이트 순서로 정수를 저장하니까 변환할 필요는 없어 " 라고 생각할 수도 있을 것이다.

그러나 꼭 그렇지만은 않다. 그렇게 작성된 프로그램을 다른 기계에서 작동시킨다면 당연히 문제가 발생할 것이다. 여기는 유닉스 세계고 이기종간의 호환성은 매우 중요한 것이다. 반드시 네트워크에 데이터를 보내기 전에 네트워크 바이트 순서로 바꿔서 보낸다는 것을 기억할 지어다.

끝으로 sin_addr, sin_port는 네트워크 바이트 순서로 기록하는데 왜 sin_family는 안 그러는가? 답은 간단하다. sin_addr과 sin_port는 캡슐화되어 네트워크로 전송되어야 하는 변수인 것이다. 따라서 당연히 NBO여야 한다. 그러나 sin_family는 시스템 내부에서 커널에 의해서만 사용되는 변수이며 네트워크로 전송되지 않는 것이므로 호스트 바이트 순서로 기록되어야 하는 것이다.

---------------------------------------------------------

IP주소는 무엇이며 어떻게 다루는가?

다행스럽게도 IP주소를 산정해 주는 수많은 함수들이 있으며 따라서 4바이트의 long변수에 직접 계산해서 << 연산자를 이용해서 집어넣어야 하는 수고는 할 필요가 없다.

먼저 struct sockaddr_in ina가 정의되어 있고 132.241.5.10 이 IP 주소이며 이 값을 변수에 넣어야 한다고 가정해 보자. inet_addr()함수가 바로 이럴 때 사용하는 것이다. 그 함수는 숫자와 점으로 구성된 IP주소를 unsigned long 변수에 집어 넣어 준다. 다음과 같이 하면 된다.

ina.sin_addr.s_addr = inet_addr("132.241.5.10")

inet_addr()는 결과값으로 이미 NBO인 값을 돌려주며 굳이 htonl()을 또 사용할 필요는 없다는 점에 주의해야 한다. 멋지군! 그러나 위의 짤막한 코드는 그렇게 견실해 보이진 않는다. 왜냐하면 inet_addr()은 에러의 경우 -1을 돌려주게 되며 unsigned long에서 -1은 255.255.255.255를 의미한다. 이는 인터넷 브로드캐스트 어드레스가 된다. 나쁜 녀석. 항상 에러 처리를 확실히 하는것이 좋다.

좋다. 이제 IP주소를 long에 넣는것은 알았는데 그 반대는 어떻게 할 것인가? 만약에 값이 들어있는 struct in_addr은 가지고 있는데 이를 숫자와 점으로 표시하려면? 이 경우는 inet_ntoa()를 쓰면 된다.(ntoa 는 네트워크 투 아스키이다.)

printf("%s",inet_ntoa(ina.sin_addr));

위의 코드는 IP주소를 프린트 해 줄것이다. 이 함수는 long 변수가 아니라 struct in_addr 를 변수로 받아 들인다는 점을 주의해야 한다. 또한 이 함수는 char 에 대한 포인터를 결과로 돌려 주는데 이는 함수내에 static 한 공간에 저장되며 따라서 매번 함수가 호출될 때마다 이 포인터가 가리키는 곳의 값은 변화한다는것이다. 즉 예를 들면,

    char *a1, *a2;
    .
    .
    a1 = inet_ntoa(ina1.sin_addr);  /* this is 198.92.129.1 */
    a2 = inet_ntoa(ina2.sin_addr);  /* this is 132.241.5.10 */
    printf("address 1: %s\n",a1);
    printf("address 2: %s\n",a2);

의 출력은 이렇게 나올 것이다.

    address 1: 132.241.5.10
    address 2: 132.241.5.10

만약에 이 값을 저장해야 할 필요가 있다면 strcpy()를 이용하여 고유의 char 배열에 저장해야 할 것이다. 이절에서 얘기할 것은 다 했다. 나중에 "whitehouse.gov" 문자열을 해당하는 IP주소로 바꾸는 법을 알려 줄것이다. (DNS절 참조)

---------------------------------------------------------

socket() ; 파일 기술자를 잡아라

안하면 맞을것 같아서 socket() 시스템 호출에 대해서 얘기해야만 할것같다. 이걸
잠깐 보자.

    #include <sys/types.h>
    #include <sys/socket.h>
    int socket(int domain, int type, int protocol);

그런데 이 변수들은 또 뭔가? 첫째 domain 은 struct sockaddr_in 에서처럼 AF_INET 로 지정하면 된다. 다음 type 은 SOCK_STREAM이나 SOCK_DGRAM으로 지정하면 된다. 끝으로 protocol은 0으로 지정하면 된다. (언급하지 않았지만 더 많은 domain과 더 많은 type 이 있다는 것을 기억하라. socket() 맨페이지를 참고하고 또한 protocol 에 대해서 좀더 알려면 getprotobyname()을 참조하면 된다.)

socket()은 바로 나중에 사용할 소켓 기술자인 정수값을 돌려주며 에러시에는 -1을 돌려주게 된다. 전역변수인 errno에 에러값이 기록된다. (perror()의 맨페이지를 참조할 것.)

---------------------------------------------------------

bind() ; 나는 어떤 포트에 연결되었나?

일단 소켓을 열게 되면 이 소켓을 현재 시스템의 포트에 연결시켜 주어야 한다.(이 작업은 보통 listen()함수를 이용해서 외부의 접속을 대기할 때 시행되며 일반적으로 머드게임 사이트들이 telnet *.*.*.* 6969 로 접속하라고 할때도 이 작업을 시행했다는 의미이다. ) 만약에 그저 다른 호스트에 연결하기만 할 예정이라면 그냥 connect()를 사용하여 연결만 하면 되고 이 작업은 필요가 없다.

아래는 bind() 시스템 호출의 선언이다.

    #include <sys/types.h>
    #include <sys/socket.h>
    int bind(int sockfd, struct sockaddr *my_addr, int addrlen);

sockfd는 socket()함수에서 얻은 소켓 기술자이며 my_addr은 IP 주소에 관한 정보(즉, IP 주소와 포트번호)를 담고 있는 struct sockaddr 에 대한 포인터 이고 addrlen은 그 구조체의 사이즈(sizeof(struct sockaddr))이다. 휴~~ 한방에 받아들이기에는 좀 그렇군. 예를 보자.

#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
   
#define MYPORT 3490

main()
{
 int sockfd;
 struct sockaddr_in my_addr;
 sockfd = socket(AF_INET, SOCK_STREAM, 0); /* do error checking! */    
 my_addr.sin_family = AF_INET;     /* host byte order */
 my_addr.sin_port = htons(MYPORT); /* short, network byte order */
 my_addr.sin_addr.s_addr = inet_addr("132.241.5.10");
 bzero(&(my_addr.sin_zero), 8);    /* zero the rest of the struct */

 /* don't forget your error checking for bind(): */
 bind(sockfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr));
    .
    .
    .

몇가지 주의할 점은 my_addr.sin_port 는 my_addr.sin_addr.s_addr과 같이 NBO이다. 또한 헤더화일은 각각의 시스템마다 다를 수 있으므로 각자의 시스템의 맨 페이지를 참고해야 할 것이다.

마지막으로 bind()와 관련해서 주소나 포트의 지정이 때에 따라서 자동화 될 수도 있다는 것을 언급해야 할 것같다.

        my_addr.sin_port = 0; /* choose an unused port at random */
        my_addr.sin_addr.s_addr = INADDR_ANY;  /* use my IP address */

my_addr.sin_port를 0으로 지정하면 자동으로 사용되지 않고 있는 포트 번호를 지정해 줄것이며 my_addr.sin_addr.s_addr를 INADDR_ANY로 지정할 경우 현재 작동되고 있는 자신의 IP주소를 자동으로 지정해 주게 된다.

만약 여기서 약간만 주의를 기울였다면 INADDR_ANY를 지정할 때 NBO로 바꾸는 것을 빼먹은 것을 눈치챌 것이다. 나아쁜~~. 그러나 난 내부정보를 알고 있지롱. 사실은 INADDR_ANY는 0이다. 0은 순서를 바꾸어도 0인것이다. 그러나 순수이론적인 측면에서 INADDR_ANY가 그러니까 12정도인 세계가 존재한다면 이코드는 작동 안할것이다. 그래서? 난 상관없다. 정 그렇다면,

   my_addr.sin_port = htons(0); /* choose an unused port at random */
   my_addr.sin_addr.s_addr = htonl(INADDR_ANY);  /* use my IP address */

이제는 믿기 어려울 정도로 이식가능한 코드가 되었다. 다만 지적하고 싶은 것은 작동하는 데에는 아무 문제가 없다는 점이다. bind()또한 에러가 났을때 -1을 돌려주며 errno에 에러의 코드가 남게 된다.

bind()를 호출할 때 주의할점 : 절대 제한선 아래로 포트번호를 내리지 말라는 것이다. 1024 아래의 번호는 모두 예약되어 있다. 그 위로는 65535까지 원하는 대로 쓸 수가 있다. (다른 프로그램이 쓰고 있지 않은 경우에 한해서..)

또 하나의 작은 꼬리말 : bind() 를 호출하지 않아도 되는 경우가 있다. 만일 다른 호스트에 연결 (connect())하고자 하는 경우에는 자신의 포트에는 (텔넷의 경우처럼)전혀 신경 쓸 필요가 없다. 단지 connect()를 호출하기만 하면 알아서 bind가 되어 있는지를 체크해서 비어있는 포트에 bind를 해준다.

---------------------------------------------------------

connect() ; 어이~ 거기~

이제 잠깐만 마치 자신이 텔넷 프로그램인 것처럼 생각해 보기로 하자. 당신의 사용자는 명령하기를 (TRON영화에서처럼.. (역자: 난 그 영화 안 봤는데..)) 소켓 기술자를 얻어오라 했고 당신은 즉시 socket()를 호출했다. 다음에 사용자는 132.241.5.10 에 포트 23(정규 텔넷 포트번호)에 연결하라고 한다. 윽, 이젠 어떻게 하지?

다행스럽게도 당신(프로그램)은 connect()절(어떻게 연결하는가)를 심각하게 읽고있으며 당신의 주인을 실망시키지 않으려고 미친듯이 읽어나가는 중이로다~~
connet()는 다음과 같이 선언한다.

    #include <sys/types.h>
    #include <sys/socket.h>
    int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);

sockfd는 이제는 친숙해진 소켓 기술자이며 serv_addr은 연결하고자 하는 목적지인 서버의 주소와 포트에 관한 정보를 담고 있는 struct sockaddr 이며 addrlen은 앞에서 이야기 한것과 같이 그 구조체의 크기이다.
뭔가 좀 이해가 갈듯 하지 않은가? 예를 들어 보자.

#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
     
#define DEST_IP   "132.241.5.10"
#define DEST_PORT 23
main()
{
  int sockfd;
  struct sockaddr_in dest_addr;   /* will hold the destination addr */
  sockfd = socket(AF_INET, SOCK_STREAM, 0); /* do some error checking! */
  dest_addr.sin_family = AF_INET;        /* host byte order */
  dest_addr.sin_port = htons(DEST_PORT); /* short, network byte order */
  dest_addr.sin_addr.s_addr = inet_addr(DEST_IP);
  bzero(&(dest_addr.sin_zero), 8);      /* zero the rest of the struct */
  /* don't forget to error check the connect()! */
  connect(sockfd, (struct sockaddr *)&dest_addr, sizeof(struct   sockaddr));
     .
     .
     .

다시 말하건데 connect()의 결과값을 한번 체크해 봐야 한다. 에러일 경우 -1을돌려주고 errno를 세팅하기 때문이다.

또한 bind()를 호출하지 않은 것에 주의해야 한다. 기본적으로 여기서는 자신의 포트 번호에는 결코 관심이 없기 때문이다. 단지 어디로 가는가만이 중요하다. 커널이 알아서 로컬 포트를 선정해 줄 것이며 우리가 연결하고자 하는 곳에서는 자동으로 이 정보를 알게 될 것이다.

---------------------------------------------------------

listen() ; 누가 전화좀 걸어주지~

이제 보조를 바꾸어서, 만약에 어디론가 연결하고자 하는 것이 아니라 외부로부터의 접속을 대기해서 접속이 올 경우 어떤 방식으로든지 간에 처리를 해 주어야 하는 경우라면 어찌 할 것인가. 이 작업은 두 단계로 이루어진다. 먼저 listen()을 해야 되고 그 다음에 accept()를 해야 된다는 것이다.

listen()은 상당히 간단하지만 약간의 설명은 필요하다.

    int listen(int sockfd, int backlog);

sockfd는 보통의 소켓 기술자이며 backlog는 접속대기 큐의 최대 연결 가능 숫자이다. 그건 또 뭔 얘기인가? 외부로부터의 연결은 이 대기 큐에서 accept()가 호출될 때까지 기다려야 한다는 것이며 숫자는 바로 얼마나 많은 접속이 이 큐에 쌓여질 수 있는가 하는 것이다. 대부분의 시스템은 이 숫자를 조용하게 20정도에서 제한하고 있으며 보통은 5에서 10 사이로 지정하게 된다.

또 다시 listen()도 에러의 경우 -1을 돌려주며 errno를 세팅한다. 아마 상상할 수 있듯이 listen()보다 앞서서 bind()를 호출해야 하며 만약에 bind()가 되지 않으면 우리는 랜덤하게 지정된 포트에서 외부의 접속을 기다려야 한다. (포트를 모르고서 누가 접속할 수 있겠는가? 우엑~~) 따라서 외부의 접속을 기다리는 경우라면 다음 순서대로 작업이 진행되어야 하는 것이다.

    socket();
    bind();
    listen();
    /* accept() goes here */

위의 것만으로도 이해가 갈만하다고 보고 예제에 대신하겠다. (accept()절에 보다 괜찮은 코드가 준비되어 있다.) 이 모든 sha-bang(역자: 이 뭐꼬?)중에서 가장 헷갈리는 부분은 accept()를 부르는 부분이다.

---------------------------------------------------------

accept() ; 포트 3490에 전화걸어주셔서 감사합니다.

준비! accept()를 호출하는 것은 뭔가 좀 수상하긴 하다. 과연 뭐가 벌어지는가? 저 멀리 떨어진 곳에서 누군가가 connect()를 호출하여 당신이 listen()을 호출하고 기다리는 포트에 접속을 시도한다. 그들의 연결은 바로 accept()가 호출되기 까지 큐에서 바로 당신이 accept()를 호출하여 그 연결을 지속하라고 명령할 때까지 대기하게 된다.

그러면 이 함수는 오로지 이 연결을 위한 완전히 신제품 소켓 파일 기술자를 돌려주게 된다. 갑자기 당신은 하나값으로 두 개의 소켓 기술자를 갖게 되는 것이다. 원래의 것은 아직도 그 포트에서 연결을 listen()하고 있다. 또 하나는 새롭게 창조되어 드디어 send()와 recv()를 할 준비가 되도록 하는 것이다.드디어 여기까지 왔다! 감격~~ 선언은 아래와 같다.

     #include <sys/socket.h>
     int accept(int sockfd, void *addr, int *addrlen);

sockfd는 listen()하고 있는 소켓의 기술자이다. 뻔하지 뭐.. addr은 로컬 struct sockaddr_in의 포인터이다. 여기에 들어온 접속에 관한 정보가 담겨지게 되고 이를 이용해서 어느 호스트에서 어느 포트를 이용해서 접속이 들어왔는지를 알 수 있게 된다. addrlen은 로컬 정수 변수이며 이 정수에는 struct sockaddr_in의 크기가 미리 지정되어 있어야 한다. 이 숫자보다 더 많은 바이트의 정보가 들어오면 accept()는 받아 들이지 않을 것이며 적데 들어온다면 addrlen의 값을 줄여 줄 것이다.
accept() 는 에러가 났을 경우에 어떻게 한다고? -1을 돌려주고 errno 를
세팅한다.아까 맨치로 한방에 받아들이기에는 좀 그러니까 예제를 열심히 읽어 보자.

#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MYPORT 3490    /* the port users will be connecting to */
#define BACKLOG 10     /* how many pending connections queue will hold */
main()
{
  int sockfd, new_fd;  /* listen on sock_fd, new connection on new_fd */
  struct sockaddr_in my_addr;    /* my address information */
  struct sockaddr_in their_addr; /* connector's address information */
  int sin_size;
  sockfd = socket(AF_INET, SOCK_STREAM, 0); /* do some error checking! */
  my_addr.sin_family = AF_INET;         /* host byte order */
  my_addr.sin_port = htons(MYPORT);     /* short, network byte order */
  my_addr.sin_addr.s_addr = INADDR_ANY; /* auto-fill with my IP */
  bzero(&(my_addr.sin_zero), 8);        /* zero the rest of the struct */

  /* don't forget your error checking for these calls: */
  bind(sockfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr));
  listen(sockfd, BACKLOG);
  sin_size = sizeof(struct sockaddr_in);
  new_fd = accept(sockfd, &their_addr, &sin_size);
       .
       .
       .

이제 new_fd를 이용해서 send()와 recv()를 이용할 수 있다는 것이다. 만약 원한다면 더이상의 연결을 받아들이지 않고 하나의 연결만 이용하기 위해서 close()를 이용하여 원래의 sockfd를 막아 버릴 수도 있다.

---------------------------------------------------------

send(), recv() ; 말좀해봐~

이 두 함수는 스트림 소켓이나 연결된 데이터그램 소켓위에서 정보를 주고 받을 때 사용하는 것들이다. 만약 보통의 비연결 데이터그램 소켓을 사용한다면 sendto()와 recvfrom()절을 참조하도록 한다.send() 호출의 선언은 아래와 같다.

    int send(int sockfd, const void *msg, int len, int flags);

sockfd는 socket()를 통해서 얻었거나 accept()를 통해서 새로 구한, 데이터를 보낼 소켓의 기술자이며, msg는 보낼 데이터를 가리키는 포인터, len은 보낼 데이터의 바이트 수 이며 flags 는 그냥 0으로 해야 한다. (플래그에 관한 보다 자세한 내용은 send()의 맨 페이지를 참조할 것.) 약간의 예제가 다음과 같다.

    char *msg = "Beej was here!";
    int len, bytes_sent;
    .
    .
    len = strlen(msg);
    bytes_sent = send(sockfd, msg, len, 0);
    .
    .
    .

send()는 결과값으로 보내진 모든 바이트 수를 돌려주는데 이것은 보내라고 한 숫자보다 작을 수도 있다. 가끔은 보내고자 하는 데이터의 크기가 미처 감당하지 못할 만한 숫자인 경우도 있으며 이 경우 send()는 자기가 감당할 수 있는 숫자만큼만 보내고 나머지는 잘라 버린후 당신이 그 나머지를 다시 보내 줄 것으로 기대하는 것이다.

만약에 보내라고 한 데이터의 크기보다 작은 숫자가 결과값으로 돌아 왔다면 그 나머지 데이터를 보내는 것은 전적으로 당신의 책임인 것이다. 그나마 희소식은 데이터의 사이즈가 작다면 (1k 이내라면) 아마도 한번에 모두 보낼 수 있을 것이다. 또한 에러의 경우 -1을 돌려주며 errno를 세팅한다. recv()의 경우도 상당히 유사하다.

    int recv(int sockfd, void *buf, int len, unsigned int flags);

sockfd는 읽어올 소켓의 기술자이며 buf는 정보를 담을 버퍼이다. len은 버퍼의 최대 크기이고 flags는 0으로 세팅해야 한다. (자세한 flags의 정보는 recv() 맨 페이지를 참조할 것.) recv()는 실제 읽어들인 바이트 숫자를 돌려주며 에러의 경우는 -1, errno를 세팅한다.

쉬웠을까? 쉬웠지.. 이제 당신은 스트림 소켓을 이용해서 데이터를 보내고 받을 수 있게 되었다. 우와~ 유닉스 네트워크 프로그래머네~~

---------------------------------------------------------

sendto(), recvfrom() ; 말좀해봐~ 데이터 그램 방식

괜찮은걸, 이라고 말하고 있는줄로 생각하겠다. 그런데 데이터그램에 관한 나머지는 어딨지? 노프라블레모~ 아미고~(역자: 터미네이터2가 생각나는군~~) 이제 할 것이다.

데이터그램 소켓은 연결을 할 필요가 없다면 데이터를 보내기 전에 주어야 할 나머지 정보는 어떻게 주어야 하는가? 맞다. 목적지의 주소를 알려주어야 한다. 여기에 예제가 있다.

int sendto(int sockfd, const void *msg, int len, unsigned int flags, const struct sockaddr *to, int tolen);

보다시피 이 함수는 두가지 부가정보가 더 들어간 것 이외에는 기본적으로 send()와 동일하다. to 는 struct sockaddr의 포인터이며(아마도 struct sockaddr_in) 여기에는 목적지의 주소와 포트번호가 담겨 있어야 할 것이다. tolen은 그 구조체의 크기인 것이다.

send()와 마찬가지로 sendto()도 보내어진 데이터의 바이트수를 결과로 돌려주며(실제 보내라고 준 데이터의 크기보다 작을지도 모르는), 에러의 경우 -1을 돌려준다. 비슷하게 recvfrom()도 아래와 같다.

    int recvfrom(int sockfd, void *buf, int len, unsigned int flags
                 struct sockaddr *from, int *fromlen);

역시 이것도 두가지 변수가 더 주어지게 된다. from은 데이터를 보내는 장비의 주소와 포트를 담고 있는 struct sockaddr 이며 fromlen은 로컬 정수변수로서 구조체의 크기가 세팅되어 있어야 한다. 함수가 호출된 뒤에는 fromlen에는 실제 from의 크기가 수록되게 된다. recvfrom()은 실제 받은 데이터의 바이트수를 돌려주며 에러의 경우는 -1,errno를 세팅하게 된다.

만약 connect()를 이용하여 데이터그램 소켓을 연결한 후의 상황이라면 간단히send(), recv() 를 사용해도 상관 없으며 소켓 인터페이스는 자동으로 목적지와 소스에 관한 정보를 함수에 추가해서 작동되게 될 것이다.

---------------------------------------------------------

close(), shutdown() ; 꺼지쇼.

휴~~ 하루종일 데이터를 보내고 받았더니..이제는 소켓을 닫을 때가 된 것이다.이건 쉽다. 정규 파일 기술자에 관한 close()를 사용하면 되는 것이다.

    close(sockfd);

이것으로 더이상의 입출력은 불가능 해지며 누구든지 원격지에서 이 소켓에 읽고 쓰려고 하는 자는 에러를 받게 될 것이다.

약간 더 세밀한 제어를 위해서는 shutdown()을 사용하면 된다. 이것을 이용하면 특정방향으로의 통신만을 끊을 수도 있게 된다.

    int shutdown(int sockfd, int how);

sockfd는 소켓 기술자이며 how는 다음과 같다.
   * 0 - 더이상의 수신 금지
   * 1 - 더이상의 송신 금지
   * 2 - 더이상의 송수신 금지(close()와 같은 경우)
shutdown() 은 에러의 경우 -1을 돌려주며 errno를 세팅한다. 황송하옵게도 연결도 되지않은 데이터그램 소켓에 shutdown()을 사용한다면 단지 send(), recv()를 사용하지 못하게만 만들 것이다. connect()를 사용한 경우에만 이렇게 사용할 수 있다는 것을 기억해야 한다. (역자: 그렇다면 sendto,recvfrom은 사용이 된다는 얘기인가??테스트가 필요할 듯.) 암것도 아니군.

---------------------------------------------------------

getpeername() ; 누구십니까?

이 함수는 되게 쉽다. 너무 쉬워서 절을 따로 만들 필요가 없지않나 고민했지만 여기 있는걸 보니까.. getpeername()은 상대편 쪽 스트림 소켓에 누가 연결되어 있는가를 알려준다.

    #include <sys/socket.h>
    int getpeername(int sockfd, struct sockaddr *addr, int *addrlen);

sockfd는 연결된 스트림 소켓의 기술자이며 addr은 상대편의 정보를 담게 될 struct sockaddr(또는 struct sockaddr_in)의 포인터 이며 addrlen은 정수를 가리키는 포인터로서 구조체의 크기가 지정되어 있어야 한다. 에러의 경우는 -1을 돌려주고 errno를 세팅한다. (외우겠군.) 일단 주소를 알게되면 inet_ntoa()나 gethostbyaddr()을 이용하여 좀더 많은 정보를 알아낼 수 있게 되지만 상대편의 login name을 알게되는 것은 아니다. (만일 상대편에 ident 데몬이 돌고 있다면 알아낼 방법이 없는 것은 아니지만 이 내용은 이 글의 취지를 벗어나는 내용이므로 RFC-1413을 참조하라고 말하고 싶다.)

---------------------------------------------------------

gethostname() ; 난 누구인가?

getpeername()보다 더 쉬운 것이 이 함수이다. 결과로 프로그램이 돌고 있는 컴퓨터의 이름을 알려준다. 이름은 gethostbyname()을 이용하여 로컬 장비의 IP주소를 알아내는데 사용될 수도 있다.

뭐가 더 재미있는가? 몇가지 생각해 볼 수 있는데 이 문서에는 적절하지 않은 내용이다(역자: 과연 뭘까..되게 궁금하네..). 어쨌거나,

    #include <unistd.h>
    int gethostname(char *hostname, size_t size);

hostname은 문자열의 포인터이며 함수가 돌려주는 값을 담게 될 변수이다. size는
그 문자열의 크기이다. 성공적이면 0을, 에러의 경우 -1을 리턴하고 errno를 세팅한다.

---------------------------------------------------------

DNS ; whitehouse.gov - 198.137.240.100

모르는 사람을 위하여 DNS는 Domain Name Service 라는 것을 먼저 얘기 하겠다.간결하게 얘기한다면 DNS에다가 사람이 읽을수 있는 주소를 말해주면 DNS는 bind,connect,sendto,어쨌거나 IP주소가 필요한 것들에서 사용할 수 있는 IP주소를 돌려준다. 즉 누군가가 이렇게 입력했다면

    $ telnet whitehouse.gov

telnet 은 connect()에 사용하기 위해서 198.137.240.100이라는 IP주소를 찾아내게 된다. 그런데 어떻게 그렇게 하는 것인가? gethostbyname()을 사용하면된다.

    #include <netdb.h>
    struct hostent *gethostbyname(const char *name);

보다시피 결과로 struct hostent의 포인터가 돌아온다. 그 구조는 아래와 같다.

    struct hostent {
        char    *h_name;
        char    **h_aliases;
        int     h_addrtype;
        int     h_length;
        char    **h_addr_list;
    };
    #define h_addr h_addr_list[0]

각 필드에 대한 설명은 다음과 같다.
   * h_name - 호스트의 공식적인 이름
   * h_aliases - 호스트의 별명으로서 NULL 로 끝맺음된다.
   * h_addrtype - 주소의 종류, 보통 AF_INET
   * h_length - 주소의 바이트 수
   * h_addr_list - 0으로 끝나는 네트워크 주소들, NBO로 되어 있다.
   * h_addr - h_addr_list속의 첫번째 주소

gethostbyname()은 위의 구조체의 포인터를 돌려주게 되며 에러의 경우 NULL을 돌려준다. errno는 세팅되지 않고 h_errno가 세팅이 된다. (아래의 herror()참조) 그런데 이걸 어떻게 사용하는가? 보통 컴퓨터 매뉴얼들 처럼 독자앞에 정보를 마구 쌓아놓은 것만으로는 부족한 법이다. 이 함수는 사실 보기보다는 쓰기가 쉬운 편이다.
예제를 보자.

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <netdb.h>
#include <sys/types.h>
#include <netinet/in.h>
int main(int argc, char *argv[])
{
  struct hostent *h;
  if (argc != 2) {  /* error check the command line */
  fprintf(stderr,"usage: getip address\n");
  exit(1);
  }
  if ((h=gethostbyname(argv[1])) == NULL) {  /* get the host info */
       herror("gethostbyname");
       exit(1);
  }
  printf("Host name  : %s\n", h->h_name);
  printf("IP Address : %s\n",inet_ntoa(*((struct in_addr *)h->h_addr)));
  return 0;
  }

gethostbyname()에서는 errno가 세팅되지 않는 까닭으로 perror()를 사용할 수 없고 herror()을 사용해야 한다. 간단히 호스트의 이름을 담고 있는 스트링을 gethostbyname()함수에 넣어 줌으로써 바로 struct hostent 를 얻게 되는 것이다.남아있는 한가지 수상한 점은 위의 방법으로 어떻게 주소를 숫자와 점으로 출력할 것인가 하는 문제이다. h->h_addr 은 문자 포인터( char *) 인데 inet_ntoa()는 변수로서 struct in_addr 을 원하기 때문이다. 따라서 h->h_addr 을 struct in_addr * 으로 형변환을 하고 결과값을 얻기 위해 다시 역참조 하면 된다는 것이다.

Posted by 젤라피
네트워크 프로그래밍의 기초 Ⅱ

내용

   * 클라이언트 서버의 배경
   * 간단한 스트림서버
   * 간단한 스트림클라이언트
   * 데이터그램 소켓
   * 블로킹
   * select()--동기화된 중복입출력, 대단하군!
   * 참고사항

-----------------------------------------------------------

요즘은 클라이언트-서버가 판치는 세상이죠~~ 네트워크에 관한 모든 것은 서버 프로세스를 요청하는 클라이언트 프로세스로서 다루어진다. 텔넷을 이용하여 23번포트에 접속하는 (클라이언트)것은 서버프로그램(telnetd)을 작동시키게 되는 것이며 이 서버 프로그램은 들어오는 각종 신호를 받아들여서 당신의 텔넷 접속을 위하여 로그인 프롬프트를 주게 되는 것이다. 등등.

주목할점은 클라이언트와 서버간에는 SOCK_STREAM이든, SOCK_DGRAM이든지간에 같은 것으로만 된다면 의사소통이 된다는 것이다. 좋은 예들은 telnet-telnetd,ftp-ftpd, 또는 bootp-bootpd 등이다. ftp를 쓴다면 반드시 상대편에 ftpd가 돌고 있다는 것이다.

보통 호스트에는 하나의 서버 프로그램이 돌고 있게 된다. 그리고 그 서버는 fork()를 이용하여 다중의 클라이언트를 받게 되는 것이다. 기본적인 루틴의 구조는 다음과 같다. 서버는 접속을 대기하다가 accept()를 호출하게 되며 그 때 fork()를 이용하여 자식 프로세스를 만들어내어 그 접속을 처리하게 된다. 이것이 바로 다음에 소개될 예제 서버 프로그램의 구조이다.

-----------------------------------------------------------

간단한 스트림 서버

이 서버가 하는 일은 오직 스트림 접속을 하게 되는 모든 클라이언트에게 "Hello, World!\n"을 출력해 주는 것이다. 이 서버를 테스트하기 위해서는 하나의 윈도우에서 이 서버를 실행시켜 놓고 다른 윈도우에서 텔넷 접속을 시도해 보는 것이다.

    $ telnet remotehostname 3490

hostname 은 서버 프로그램이 작동된 호스트의 이름이다.
서버 프로그램 코드

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/wait.h>
#define MYPORT 3490    /* the port users will be connecting to */
#define BACKLOG 10     /* how many pending connections queue will hold */
main()
{
   int sockfd, new_fd;  /* listen on sock_fd, new connection on new_fd */
   struct sockaddr_in my_addr;    /* my address information */
   struct sockaddr_in their_addr; /* connector's address information */
   int sin_size;
   if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
            perror("socket");
            exit(1);
   }
   my_addr.sin_family = AF_INET;         /* host byte order */
   my_addr.sin_port = htons(MYPORT);     /* short, network byte order */
   my_addr.sin_addr.s_addr = INADDR_ANY; /* auto-fill with my IP */
   bzero(&(my_addr.sin_zero), 8);       /* zero the rest of the struct */
   if (bind(sockfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr))        == -1) {
            perror("bind");
            exit(1);
   }
   if (listen(sockfd, BACKLOG) == -1) {
            perror("listen");
            exit(1);
   }

   while(1) {  /* main accept() loop */
        sin_size = sizeof(struct sockaddr_in);
        if ((new_fd = accept(sockfd, (struct sockaddr *)&their_addr, \
             &sin_size)) == -1) {
                perror("accept");
                continue;
        }
        printf("server: got connection from %s\n", \
        inet_ntoa(their_addr.sin_addr));

        if (!fork()) { /* this is the child process */
        if (send(new_fd, "Hello, world!\n", 14, 0) == -1)
                perror("send");
                close(new_fd);
                exit(0);
        }
        close(new_fd);  /* parent doesn't need this */
        while(waitpid(-1,NULL,WNOHANG) > 0); /* cleanup childprocesses */
        }
    }

이 코드는 문법상의 단순함을 위하여 하나의 커다란(내 생각에) main()에 모든 것이 들어가 있다. 만약에 이것을 잘게 잘라서 작은 여러개의 함수로 구성을 하는것이 좋다고 생각된다면 그래도 된다. 다음의 클라이언트 코드를 이용한다면 이 서버로부터 문자열을 받아 낼수도 있다.

-----------------------------------------------------------

간단한 스트림 클라이언트

이녀석은 서버보다 더 쉬운 코드이다. 이 프로그램이 하는 일은 명령행에서
지정된 주소에 3490번 포트에 접속하여 서버가 보내는 문자열을 받는 것 뿐이다.

    #include <stdio.h>
    #include <stdlib.h>
    #include <errno.h>
    #include <string.h>
    #include <netdb.h>
    #include <sys/types.h>
    #include <netinet/in.h>
    #include <sys/socket.h>
    #define PORT 3490    /* the port client will be connecting to */
    #define MAXDATASIZE 100 /* max number of bytes we can get at once */
    int main(int argc, char *argv[])
    {
     int sockfd, numbytes;
     char buf[MAXDATASIZE];
     struct hostent *he;
     struct sockaddr_in their_addr; /* connector's address information */
       if (argc != 2) {
           fprintf(stderr,"usage: client hostname\n");
           exit(1);
       }
     if ((he=gethostby name(argv[1])) == NULL) {  /* get the host info */
            herror("gethostbyname");
            exit(1);
     }
     if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
            perror("socket");
            exit(1);
     }
     their_addr.sin_family = AF_INET;      /* host byte order */
     their_addr.sin_port = htons(PORT);    /* short,network byte order */
     their_addr.sin_addr = *((struct in_addr *)he->h_addr);
     bzero(&(their_addr.sin_zero), 8);  /* zero the rest of the struct */
     if (connect(sockfd, (struct sockaddr *)&their_addr, \
         sizeof(struct sockaddr)) == -1) {
         perror("connect");
         exit(1);
     }
     if ((numbytes=recv(sockfd, buf, MAXDATASIZE, 0)) == -1) {
            perror("recv");
            exit(1);
        }
        buf[numbytes] = '\0';
        printf("Received: %s",buf);
        close(sockfd);
        return 0;
    }

이 클라이언트를 작동하기에 앞서서 서버를 작동시켜놓지 않았다면 connect()함수는 "Connection refused"를 돌려주게 될것이다. 쓸 만하군!

-----------------------------------------------------------

데이터그램 소켓

이에 관해서는 그다지 얘기할 것이 많지 않다. 따라서 그냥 두개의 프로그램을 보여 주겠다. listener는 호스트에 앉아서 4950포트에 들어오는 데이터 패킷을 기다린다. talker는 지정된 호스트의 그 포트로 뭐든지 간에 사용자가 입력한 데이터를 보낸다.

listener.c
    #include <stdio.h>
    #include <stdlib.h>
    #include <errno.h>
    #include <string.h>
    #include <sys/types.h>
    #include <netinet/in.h>
    #include <sys/socket.h>
    #include <sys/wait.h>
    #define MYPORT 4950    /* the port users will be connecting to */
    #define MAXBUFLEN 100
    main()
    {
     int sockfd;
     struct sockaddr_in my_addr;    /* my address information */
     struct sockaddr_in their_addr; /* connector's address information */
     int addr_len, numbytes;
     char buf[MAXBUFLEN];
     if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
           perror("socket");
            exit(1);
        }
        my_addr.sin_family = AF_INET;         /* host byte order */
        my_addr.sin_port = htons(MYPORT); /* short, network byte order */
        my_addr.sin_addr.s_addr = INADDR_ANY; /* auto-fill with my IP */
        bzero(&(my_addr.sin_zero), 8);    
        /* zero the rest of the struct */
        if (bind(sockfd, (struct sockaddr *)&my_addr,             sizeof(structsockaddr)) == -1) {
            perror("bind");
            exit(1);
        }
        addr_len = sizeof(struct sockaddr);
        if ((numbytes=recvfrom(sockfd, buf, MAXBUFLEN, 0, \
            (struct sockaddr *)&their_addr, &addr_len)) == -1) {
            perror("recvfrom");
            exit(1);
        }
        printf("got packet from %s\n",inet_ntoa(their_addr.sin_addr));
        printf("packet is %d bytes long\n",numbytes);
        buf[numbytes] = '\0';
        printf("packet contains \"%s\"\n",buf);
        close(sockfd);
    }

결국 socket()를 호출할 때 SOCK_DGRAM을 사용하게  된것을 주의하고, listen()이나 accept()를 사용하지 않은것도 주의해 봐야 한다. 이 코드가 바로 비연결 데이터그램 소켓의 자랑스러운 사용예인 것이다.

talker.c
    #include <stdio.h>
    #include <stdlib.h>
    #include <errno.h>
    #include <string.h>
    #include <sys/types.h>
    #include <netinet/in.h>
    #include <netdb.h>
    #include <sys/socket.h>
    #include <sys/wait.h>
    #define MYPORT 4950    /* the port users will be connecting to */
    int main(int argc, char *argv[])
    {
     int sockfd;
     struct sockaddr_in their_addr; /* connector's address information */
     struct hostent *he;
     int numbytes;
     if (argc != 3) {
            fprintf(stderr,"usage: talker hostname message\n");
            exit(1);}

     if ((he=gethostbyname(argv[1])) == NULL) {  /* get the host info */
            herror("gethostbyname");
            exit(1);
        }
        if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
            perror("socket");
            exit(1);
        }
      their_addr.sin_family = AF_INET;    /* host byte order */
      their_addr.sin_port = htons(MYPORT);/* short, network byte order */
      their_addr.sin_addr = *((struct in_addr *)he->h_addr);
      bzero(&(their_addr.sin_zero), 8); /* zero the rest of the struct */
      if ((numbytes=sendto(sockfd, argv[2], strlen(argv[2]), 0, \
          (struct sockaddr *)&their_addr, sizeof(struct sockaddr))) ==            -1) {
            perror("sendto");
            exit(1);
        }
      printf("sent %d bytes to %s\n",numbytes, inet_ntoa(               their_addr.sin_addr ) );
        close(sockfd);
        return 0;
    }

이것이 다다. listener를 한 호스트에서 실행 시키고 다른 곳에서 talker를 실행시킨다. 핵가족시대에 어울리는 가족용 오락이 될수도... 앞에서도 얘기했었지만 한가지 작은 내용을 더 말해야 할것 같다. 만약 talker에서 connect()를 호출해서 연결을 했다면 그 다음부터는 sendto(), recvfrom()이 아니라 그냥 send().recv()를 사용해도 된다는 것이다. 전달되어야 하는 호스트의 주소는 connect()에 지정된 주소가 사용되게 된다.

-----------------------------------------------------------

블로킹

블로킹. 아마 들어봤겠지. 그런데 도대체 그게 뭘까? 사실 "잠들다"의 기술용어에 불과한 것이다. 아마도 listener를 실행시키면서 눈치를 챘겠지만 그 프로그램은 그저 앉아서 데이터 패킷이 올때까지 기다리는 것이다. 잠자면서.. recvfrom()을 호출했는데 데이터가 들어온 것이 없다면? 바로 뭔가 데이터가 들어올 때까지 블로킹이 되는 것이다(그냥 거기서 자고 있는 것이다.). 많은 함수들이 블로킹이 된다. accept()는 블록이 된다. recv*()종류들이 모두 블록이 된다.

그들이 이렇게 할 수 있는 이유는 그렇게 할  수 있도록 허락을 받았기 때문이다. 처음에 socket()으로 소켓이 만들어질때 커널이 블록 가능하도록 세팅을 했기 때문이다. 만일 블록할수 없도록 세팅하려면 fcntl()을 사용한다.

    #include <unistd.h>
    #include <fcntl.h>
    .
    .
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    fcntl(sockfd, F_SETFL, O_NONBLOCK);
    .
    .

소켓을 블록할수 없도록 세팅함으로써 정보를 추출하는 데에 효과적으로 socket을 이용할 수 있다. 만일 데이터가 접수되지 않은 소켓에서 데이터를 읽으려고 시도한다면 -1을 결과로 돌려주고 errno를 EWOULDBLOCK 으로 세팅하게 된다. 일반적으로는 이런 식으로 정보를 뽑아 내는 것은 별로 좋은 방식은 아니다. 만일 들어오는 데이터를 감시하기 위하여 이런 방식으로 바쁘게 데이터를 찾는 루틴을 만든다면 이는 CPU 시간을 소모하게 되는 것이다. 구식이다. 보다 멋진 방법은 다음절에 나오는 select()를 사용하여 데이터를 기다리는 식이다.

-----------------------------------------------------------

select() ; 동기화된 중복 입출력. 대단하군!

이건 뭔가 좀 이상한 함수이다. 그러나 상당히 유용하므로 잘 읽어보기 바란다.
다음 상황을 가정해 보자. 지금 서버를 돌리고 있으며 이미 연결된 소켓에서 데이터가 들어오는 것을 기다리고 있다고 하자.

문제없지, 그냥 accept()하고 recv()몇개면 될텐데.. 서둘지 말지어다, 친구. 만일 accept()에서 블로킹이 된다면? 동시에 어떻게 recv()를 쓸 것인가? 블로킹 못하게 세팅한다고? CPU시간을 낭비하지 말라니까. 그러면 어떻게? 더이상 떠들지 말고 다음을 보여주겠다.

       #include <sys/time.h>
       #include <sys/types.h>
       #include <unistd.h>
       int select(int numfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

이 함수는 화일 기술자의 "집합", 특별히 readfds,writefds,exceptfds등을 관리한다. 만일 일반적인 입력이나 소켓 기술자로부터 읽어 들일수 있는가를 확인하려면 단지 화일 기술자 0과 sockfd를 readfds에 더해주기만 하면 된다.

numfds는 가장 높은 파일 기술자에다가 1을 더해서 지정해야 하며 이번 예제에서는 정규 입력의 0보다 확실히 크게 하기 위해서 sockfd+1 을 지정해야 한다. select()의 결과값이 나올때 readfs는 선택한 파일 기술자 중에 어떤 것이 읽기 가능한가를 반영할 수 있도록 수정되며 FD_ISSET() 매크로를 이용하여 체크할 수 있다.

너무 멀리 나가기 전에 이 "집합"들을 어떻게 관리하는 가에 대해서 얘기를 해야 할것 같다. 각각의 "집합"은 fd_set형이며 다음의 매크로들로 이를 제어할 수 있다.

   * FD_ZERO(fd_set *set) - 파일기술자 집합을 소거한다.
   * FD_SET(int fd, fd_set *set) - fd 를 set에 더해준다.
   * FD_CLR(int fd, fd_set *set) - fd 를 set에서 빼준다.
   * FD_ISSET(int fd, fd_set *set) - fd가 set안에 있는지 확인한다.

끝으로 이 수상한 struct timeval은 또 무엇인가? 아마도 누군가가 어떤 데이터를 보내는 것을 무한정 기다리기를 원치는 않을 것이다. 특정시간마다 아무일도 안 벌어지더라도 "현재 진행중..."이라는 메시지를 터미널에 출력시키기라도 원할 것이다. 이 구조체는 그 시간간격을 정의하기 위해서 사용되는 것이다. 이 시간이 초과되고 그 때까지 select()가 아무런 변화를 감지하지 못한 경우라면 결과를 돌려주고 다음 작업을 진행 할수 있도록 해준다.
struct timeval의 구조는 다음과 같다.

    struct timeval {
        int tv_sec;     /* seconds */
        int tv_usec;    /* microseconds */
    };

기다릴 시간의 초를 지정하려면 그냥 tv_sec에 지정하면 된다. tv_use c에는 마이크로 초를 지정한다. 밀리초가 아니고 마이크로초이다. 마이크로초는 백만분의 일초이다. 그런데 왜 usec인가? u는 그리스 문자의 Mu를 닮았고 이는 마이크로를 의미하는데 사용된다. 함수가 끝날때 timeout에 남은 시간이 기록될수도 있으며 이 내용은 유닉스마다 다르기는 하다.

와우~ 마이크로 초 단위의 타이머를 가지게 되었군! 만일 timeval에 필드들을 0으로 채우면 select()는 즉시 결과를 돌려주며 현재 set들의 내용을 즉시 알려주게 된다. timeout을 NULL로 세팅하면 결코 끝나지 않고 계속 파일 기술자가 준비되는 것을 기다리게 되며 끝으로 특정한 set에 변화에 관심이 없다면 그 항목을 NULL로 지정하면 된다. 다음은 정규 입력에 무언가 나타날때까지 2.5초를 기다리는 코드이다.

       #include <sys/time.h>
       #include <sys/types.h>
       #include <unistd.h>
       #define STDIN 0  /* file descriptor for standard input */
       main()
       {
           struct timeval tv;
           fd_set readfds;
           tv.tv_sec = 2;
           tv.tv_usec = 500000;
           FD_ZERO(&readfds);
           FD_SET(STDIN, &readfds);
           /* don't care about writefds and exceptfds: */
           select(STDIN+1, &readfds, NULL, NULL, &tv);
           if (FD_ISSET(STDIN, &readfds))
               printf("A key was pressed!\n");
           else
               printf("Timed out.\n");
       }

만일 한줄씩 버퍼링하는 터미널이라면 엔터키를 치지 않는 이상은 그냥 타임아웃에 걸릴 것이다. 이제 아마도 이 훌륭한 방법을 데이터그램 소켓에서 데이터를 기다리는 데에 사용할수 있으리라고 생각할 것이다. 맞다. 그럴 수도 있다. 어떤 유닉스에서는 이 방법이 되지만 안되는 것도 있다. 하고자 하는 내용에 대해서는 아마도 맨페이지를 참조해야 할 것이다.

select()에 관한 마지막 얘기는 listen()이 된 소켓이 있다면 이 방법을 이용하여 소켓 기술자를 readfds에 첨가하는 방식으로 새로운 연결이 있었는가를 확인할 수도 있다는 것이다. 이것이 select()에 대한 짧은 검토였다.

-----------------------------------------------------------

참고사항

여기까지 와서는 아마 좀더 새로운 다른 것은 없는가 할것이다. 또 어디서 다른 무언가를 더 찾을수 있는가를 알고자 할 것이다. 초보자라면 다음의 맨페이지를 참고하는 것도  좋다.

   * socket()
   * bind()
   * connect()
   * listen()
   * accept()
   * send()
   * recv()
   * sendto()
   * recvfrom()
   * close()
   * shutdown()
   * getpeername()
   * getsockname()
   * gethostbyname()
   * gethostbyaddr()
   * getprotobyname()
   * fcntl()
   * select()
   * perror()

다음 책들도 도움이 될 것이다.

     Internetworking with TCP/IP, volumes I-III
     by Douglas E. Comer and David L. Stevens.
     Published by Prentice Hall.
     Second edition ISBNs: 0-13-468505-9, 0-13-472242-6,0-13-474222-2.

     There is a third edition of this set which covers IPv6 and IP over ATM.
     Using C on the UNIX System
     by David A. Curry.
     Published by O'Reilly & Associates, Inc.
     ISBN 0-937175-23-4.

     TCP/IP Network Administration
     by Craig Hunt.
     Published by O'Reilly & Associates, Inc.
     ISBN 0-937175-82-X.

     TCP/IP Illustrated, volumes 1-3
     by W. Richard Stevens and Gary R. Wright.
     Published by Addison Wesley.
     ISBNs: 0-201-63346-9, 0-201-63354-X, 0-201-63495-3.

     Unix Network Programming
     by W. Richard Stevens.
     Published by Prentice Hall.
     ISBN 0-13-949876-1.

웹상에는 다음과 같은 것들이 있을 것이다.

     BSD Sockets: A Quick And Dirty Primer
     (http://www.cs.umn.edu/~bentlema/unix/--has other great Unix       system     programming info, too!)

     Client-Server Computing
     (
http://pandonia.canberra.edu.au/ClientServer/socket.html)

     Intro to TCP/IP (gopher)    
     (
gopher://gopher-chem.ucdavis.edu/11/Index/Internet_aw
      /Intro_the_Internet/intro.to.ip/)

     Internet Protocol Frequently Asked Questions (France)
     (http://web.cnam.fr/Network/TCP-IP/)
     The Unix Socket FAQ (
http://www.ibrado.com/sock-faq/)

끔찍하지만..RFC도 봐야 하겠다.

     RFC-768 -- The User Datagram Protocol (UDP)
     (ftp://nic.ddn.mil/rfc/rfc768.txt)
     RFC-791 -- The Internet Protocol (IP)
     (ftp://nic.ddn.mil/rfc/rfc791.txt)
     RFC-793 -- The Transmission Control Protocol (TCP)
     (ftp://nic.ddn.mil/rfc/rfc793.txt)
     RFC-854 -- The Telnet Protocol (ftp://nic.ddn.mil/rfc/rfc854.txt)
     RFC-951 -- The Bootstrap Protocol (BOOTP)
     (ftp://nic.ddn.mil/rfc/rfc951.txt)
     RFC-1350 -- The Trivial File Transfer Protocol (TFTP)
     (
ftp://nic.ddn.mil/rfc/rfc1350.txt)

Posted by 젤라피

Socket에 대한 이해

NetWork 2008/03/29 11:44

1. Socket 에 대한 기본지식

1.1. Socket Layer

Socket 은 유닉스의 파일 기술자를 통해서 다른 프로그램간의 정보교환을 가능하도록 해주는 방법으로, 같은 시스템에 있는 프로그램들간의 정보교환을 위한목적, 혹은 다른 시스템(네트웍 상으로 멀리떨어져있는) 들간의 정보교환을 위한 목적으로 사용된다.

그런데 왜 Layer 라고 부르는가 그 이유는 TCP/IP 4계층의 응용계층(applicaton layer)과 전송계층(transmission layer) 중간에 존재하기 때문이다. 아래의 그림을 보라

그림 1. 소켓 계층

위의 그림은 TCP/IP 개요에서 이미 본적이 있는 그림일 것이다. 그때의 그림과 달라진 점이 있다면, 응용계층과 전송계층에 Socket Layer 가 존재한다는 것이다. 이 Socket Layer 가 응용계층과 전송계층 사이에 존재하게 됨으로 우리 프로그래머들은 복잡하게 TCP 를 직접 제어할 필요없이, Socket Layer 에서 제공하는 다양한 함수(Socket API)를 이용해서 간단하게 인터넷 네트웍 프로그래밍 작업을 하게 되는것이다.

Socket Layer 은 응용계층에서 받은 메시지를 하부 Socket API 를 이용해서 전송계층으로 보낸다. 전송계층에는 2가지 대표적인 프로토콜 이 있는데 바로 TCP 와 UDP 이다. 그럼으로 우리 프로그래머들은 TCP 프로토콜을 사용할것인지 UDP 프로토콜을 사용할것인지만 결정해주면된다.


1.2. 왜 Layer 구조를 가지는가

일상 생활에서 소켓레이어와 비슷한게, 전화기라고 볼수 있을것이다. 우리는 상대편에서 전화를 걸기 위해서 상대편전화의 지리적 위치가 어디인지, 어떤 전화국에서 관리하는지, 언어를 신호로 변환 시키기 위해서 어떠한 작업을 해야하는지, 어떻게 보내야 하는지 전혀 알필요가 없다. 그냥 수화기 들고 전화 번호만 누르면 그걸로 끝이다. 즉 전화기 라는게 있음으로 그 내부에서 일어나는 여러가지 복잡한 통신 프로세스를 모르고도 상대편과 전화통화를 할수 있게 된다.

Socket Layer 이 존재함으로써, 우리는 TCP/UDP 헤더를 어떻게 만들어야 하는지, 구조가 어떻게 되는지, 어떻게 커널에 전달해야 하는지 신경쓸필요 없이 네트웍 프로그램을 만들수 있게 된다.


1.3. Socket

"Socket 이라뇨 우리는 위에서 Socket Layer를 이미 다루었는데요 ?" 라고 의문을 가질수도 있을것이다. Socket Layer 과 Socket 는 엄연히 다르다. Socket Layer 는 계층을 나타내는 것이다. 즉 Socket 를 다루기 위한 계층이다. 이는 TCP가 전송계층이 아닌것과 마찬가지이다. 우리는 Socket Layer 에서 제공하는 다양한 API를 통해서 Socket 를 제어하게 된다.

그럼 Socket 이란 무엇인가. 소켓이란 유닉스 파일 지시자 를 이용하여 다른 프로그램과 정보교환을 하는 방법(혹은 도구) 이다. 일반적으로 유닉스 상에서 정보교환은 파일지시자를 통한다는걸 알고 있을것이다. 마찬가지로 Socket 를 이용한 지역 혹은 네트웍으로 연결된 프로그램 간의 정보교환 역시 파일지시자를 통해서 이루어진다.

다중연결서버 만들기(1) 의 zipcode_multi.c 를 이용해서 소켓이 어떻게 작동하는지 알아보도록 하겠다. 먼저의 위의 프로그램을 컴파일 시키고 작동을 시켜보자. 작동을 시켰다면 ps 로 zipcode_multi 프로그램의 pid 를 확인해보고 /proc/pid/fd 디렉토리로 이동해서 어떠한 파일 지시자를 가지고 있는지 확인해보도록 하자.

[yundream@localhost test]# ./zipcode_multi 4444
...
[yundream@localhost test]# ps -ax | grep zipcode 
 2473 ttyp1    S      0:00 ./zipcode_multi 4444
			
pid가 2473 이므로 이 프로그램의 /proc/2473/fd 로 이동해서 ls해보면 프로그램에서 사용하고있는 파일지시자들에 대해서 알수 있다.
[yundream@localhost test]# ls -al /proc/2473/fd
합계 0
dr-x------    2 root     root            0  5월 28 16:07 .
dr-xr-xr-x    3 root     root            0  5월 28 16:07 ..
lrwx------    1 root     root           64  5월 28 16:14 0 -> /dev/ttyp1
lrwx------    1 root     root           64  5월 28 16:14 1 -> /dev/ttyp1
lrwx------    1 root     root           64  5월 28 16:14 2 -> /dev/ttyp1
lr-x------    1 root     root           64  5월 28 16:14 3 -> /home/mycvs/test/zipcode.txt
lrwx------    1 root     root           64  5월 28 16:14 4 -> socket:[171434]
			
0, 1, 2 는 각각 표준입력, 표준출력, 표준에러를 가리키는 파일지시자 라는것은 이미 알고 있을것이다. 3 은 프로그램이 연 파일을 가리킨다. 마지막 4가 바로 socket 통신을 위해 만들어진 파일 지시자이다. 다른 것들이 터미널이나 파일을 가리키는것과는 달리 socket 를 가리 키고 있음을 알수 있다.

여기에 새로운 클라이언트가 접근을하면 (telnet 이나 전용클라이언트 를 이용해서) 다음과 같은 파일 지시자가 하나 추가 될것이다.

lrwx------    1 root     root           64  5월 28 16:14 5 -> socket:[171435]
			


1.4. socket API

이번에는 socket 레이어에서 제공하는 소켓 관련 함수들을 설명하도록 하겠다.


1.4.1. 소켓 생성 및 연결

1.4.1.1. socket(2) 함수

이러한 소켓 은 socket(2) 함수를 이용해서 만들어진다. 최초 socket 함수를 이용해서 소켓을 생성하면 커널은 통신을 위한 종점(end point,즉 통신연결상황을 체크하는)을 생성하고, 여기에 대한 파일 지시자를 되돌려준다. 프로그램은 socket 함수를 이용해서 생성한 파일 지시자에 새로운 연결이 들어오는 지를 확인하게 된다.

위에 있는 TCP/IP 4계층을 보면 Socket Layer 아래에는 최소한 2개 이상의 사용가능한 데이타 그램의 타입이 있음을 알수 있다. 이러한 데이타 그램의 타입에는 TCP, UDP, RAW 등이 있다. TCP 소켓, UDP 소켓, RAW 소켓이라고 부르기도 한다. 또한 다양한 소켓 주소패밀리(군)를 제공한다.

표 1. 소켓주소 패밀리

UNIX 유닉스 도메인 소켓, IPC 용으로 많이 사용한다.
INET TCP/IP 프로토콜을 이용한 인터넷주소 패밀리, 보통의 네트웍프로그래밍시 주로 사용
IPX 노벨의 IPX 프로토콜, 게임을 좋아한다면 많이 들어봤음직한
AX25 아마추어 라디오 X.25
X25 X.25 프로토콜

그러므로 socket 함수는 위의 소켓 주소 패밀리와 소켓 타입 지정이 가능해야 한다.

int socket(int domain, int type, int protocol);
					
첫번째 아규먼트가 소켓주소 패밀리 지정을 위해서 사용되며, 두번째 아규먼트가 소켓 타입지정을 위해서 사용된다. 소켓주소 패밀리는 주로 INET(AF_INET), UNIX(AF_UNIX) 가 사용되며, 소켓타입은 TCP(SOCK_STREAM), UDP(SOCK_DGRAM), RAW(SOCK_RAW) 가 사용된다.

즉 인터넷 프로토콜을 이용하는 TCP 소켓을 만들기 원한다면 socket(AF_INET, SOCK_STREAM, 0) 과 같이 사용하면 된다.

socket 함수가 성공적으로 수행되면, 사용가능한 소켓을 가르키는 파일 지시자를 되돌려주며, 이 파일지시자는 endpoint(연결 확인 통로) 로써 사용된다.


1.4.1.2. bind(2) 함수

socket 함수를 이용해서 만들어진 소켓에 이름을 부여한다.

라고 번역된 man 페이지혹은 관련된 번역서에서 설명을 하고 있지만, 소켓에 특성을 부여(소켓과 특성을 묶는다(bind))한다 라는게 좀더 적당한 설명이 아닐까 싶다.

int bind(int  sockfd, struct sockaddr *my_addr, socklen_t addrlen);
					
인자로 주어진 sockfd 에대해서 sockaddr 을 이용해서 특성을 묶어준다. bind 함수를 통해서 우리는 sockfd 가 사용할 포트번호(port), 그리고 연결을 받아들일 IP 주소 특성등을 묶어줄수 있다. IP 주소는 IPv4, IPv6 등이 사용될수 있을것이다.

bind 함수는 보통 서버에서 사용된다. 그 이유는 대부분의 서비스(HTTP, FTP..)들이 지정된 포트번호를 통해서 서비스 되기 때문이다. 반면 클라이언트의 경우 커널에서 할당한 임의의 포트번호를 이용해서 서버와 연결하기 때문에 bind 를 사용할 필요가 없다.


1.4.1.3. connect(2) 함수

int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
					
이것은 클라이언트측 에서 사용되며, struct sockaddr 구조체에 세팅된 내용대로 서버측에 연결한다. sockaddr 구조체에는 연결될 서버에 대한 정보들, 즉 주소 패밀리 IP 번호와 PORT 번호 등이 들어가 있으며, connect 함수는 sockaddr 정보를 이용해서 서버측에 연결을 하며 서버와의 통신을 위한 endpoint 와 sockfd 를 연결시킨다. sockfd 는 socket 함수를 이용해서 만들어진 소켓 지정 번호이다.


1.4.1.4. listen(2) 함수

int listen(int sockfd, int backlog);
					
서버측에서 사용되며 socket 함수를 이용해서 만들어진 sockfd 에 대해서, 들어 오는 연결을 기다린다. backlog 는 아직 완전히 연결되지 않은 연결들이 대기할 queue 의 길이를 명시하기 위해서 사용된다.


1.4.1.5. accept(2) 함수

int accept(int s,  struct  sockaddr  *addr,  socklen_t *addrlen); 
					
listen 을 통해서 만들어진 미연결의 대기열에서 가장 앞에 있는 연결의 내용을 가져와서 새로운 연결 소켓을 만들어준다. 새로만들어진 연결소켓은 파일 지시자를 할당하여서 리턴해주게 되며, 우리는 리턴된 파일 지시자를 이용해서 새로만들어진 소켓과 통신을 할수 있게 된다.


1.4.2. 입출력 함수

유닉스에서 소켓은 파일과 동일하게 취급 되기 때문에 read(), write()와같은 시스템 함수를 이용해도 대부분의 입출력을 다룰 수 있다. 그러나 이들 시스템 함수들은 네트워크의 특성을 고려하지 않고 만들었기 때문에 네트워크 정보를 필요로 하는 작업을 하기에는 적당하지 않은 점이 있다.

예를들어 UDP를 이용해서 통신을 할경우 읽기는 문제없지만 쓰기에는 문제가 생길 수 있다. UDP는 연결 소켓을 만들지 않기 때문에 쓸때 연결된 호스트의 정보를 알 수가 없기 때문에 write()함수로는 데이터를 전송할 수 없게 된다. 이럴경우에는 소켓 API를 사용해서 통신을 해주어야 한다.


1.4.2.1. 입력함수 - recvfrom/recvmsg

소켓으로 부터 데이터를 받기 위해서 사용한다.

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

ssize_t  recvfrom(int s, void *buf, size_t len, int flags, struct sock-
addr *from, socklen_t *fromlen);

ssize_t recvmsg(int s, struct msghdr *msg, int flags);
					
소켓 지정자 s로 부터 데이터를 읽는 일을 한다. 둘다 연결지향 소켓과 비연결지향 소켓 모두에 사용할 수 있다. 보통 recvfrom()함수가 사용하기에 직관적인 관계로 쉽게 사용할 수 잇다. 소켓으로 부터 len 만큼 데이터를 읽어와서 buf에 저장한다. 또한 5번째 인자인 from를 통해서 데이터를 보낸 호스트의 인터넷 정보를 얻어 올 수 있다. 그러므로 비연결 지향 소켓을 사용하더라도 이 인터넷 정보를 통해서 데이터를 수신할 목적지 호스트를 결정할 수 있게 된다. fromlen는 sockaddr 구조체의 길이다. 나머지 자세한 내용은 recvform(2)의 맨페이지를 참고하기 바란다.


1.4.2.2. 출력함수 - sendto/sendmsg

소켓으로 데이터를 보내기 위해서 사용한다.

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

ssize_t  sendto(int  s,  const  void *buf, size_t len, int flags, const
struct sockaddr *to, socklen_t tolen);
ssize_t sendmsg(int s, const struct msghdr *msg, int flags);
					
역시 직관적인 sendto를 널리 사용한다. 소켓 지정자 slen크기만큼 buf의 내용을 보낸다. to를 이용해서 데이터를 받을 호스트를 명시할 수 있다. sendto()와 recvfrom()함수의 사용예는 UDP 소켓 프로그래밍 을 참고하기 바란다.


1.4.3. 인터넷 주소변환

인터넷 주소 자체가 인간이 인지하기 어려운 수로 되어 있다 보니 이것을 관리하기 쉽도록 점박이 3형제 스타일의 인터넷 주소체계를 만들어서 관리하고 여기에 또 도메인 이름을 줘서 쉽게 기억할 수 있도록 하고 있다. 프로그래머나 사용자는 보통 도메인 이름이나 점박이 3형제 스타일의 인터넷 주소를 사용하게 되는데, 실제 네트워크 프로그램에서는 32bit 주소 형태로 변환 시켜줘야할 필요가 있다.

여기에서는 이들 주소간 변환과 관련된 함수를 소개한다.


1.4.4. 인터넷 주소 <-> 32bit 주소

inet_addr(3), inet_aton(3), inet_network(3), inet_ntoa(3) 의 함수를 이용해서 인터넷 주소와 32bit 주소간 변환을 할 수 있다. inet_addr(3)과 inet_network(3)함수는 점박이 3형재 스타일 인터넷 주소로 부터 32bit 주소를 얻기 위해서, inet_aton(3)과 inet_ntoa(3)그 반대의 변환 값을 얻기 위해서 사용한다. 자세한 내용은 man 페이지를 참고하기 바란다(그냥 함수 링크를 클릭하면 된다).


1.4.5. 도메인 이름 -> 32bit 주소

점박이 3형제 스타일의 인터넷 주소는 확실히 관리하기 좋고 외우기에 좀더 편하긴 하지만 숫자로 되어 있다는 것 때문에 인터넷 서비스를 위한 호스트 주소로 사용하기엔 적당하지 않다. 그래서 인터넷 주소에 이름을 주는 서비스가 만들어지게 되었는데 도메인 서비스이다. 도메인 서비스는 도메인 이름에 대한 인터넷 주소를 되돌려 주는 일을 한다. 자세한 내용은 인터넷 주소 변환문서를 참고하기 바란다.


1.4.5.1. gethostbyname/gethostbyaddr

도메인 이름에서 인터넷 주소를 얻어오는 일을 한다. 자세한 내용은 gethostbyname(3)과 getbyaddr(3)의 맨페이지를 참고 하기바란다.


1.4.6. 네트워크 바이트 오더

네트워크 통신을 하다보면 CPU의 바이트 오더가 다른 이유로 이를 표준 바이트 오더인 네트워크 바이트 오더로 변환해서 보내고, 받아들인 데이터는 호스트의 바이트 오더에 맞게 다시 변경시켜주는 작업이 필요하다. 이러한 작업을 위해서 소켓은 몇 개의 함수들을 제공한다. 바이트 오더에 대한 자세한 내용은 endian에 대해서 를 참고하기 바란다.


1.4.6.1. 호스트 바이트 오더 -> 네트워크 바이트 오더

htonl(3), htons(3) 함수를 사용한다. 전자는 4byte 데이터, 후자는 2byte 데이터를 네트워크 바이트 오더를 따르도록 변환한다.


1.4.6.2. 네트워크 바이트 오더 -> 호스트 바이트 오더

ntohl(3), ntohs(3) 함수를 사용한다. 전자는 4byte데이터, 후자는 2byte데이터를 호스트 바이트 오더를 따르도록 변환한다.


1.4.6.3. 엔디안 검사 함수

이건 보너스다. 현재 CPU의 바이트 오더 방식을 알려 주는 간단한 함수다.

int endian(void)
{
	int i = 0x00000001;
	if ( ((char *)&i)[0] )
		return LITTLE_ENDIAN;
	else
		return BIG_ENDIAN;
}
					


2. 소켓 프로그래밍 일반

2.1. 서버측 socket 생성 순서

다음은 서버측의 소켓 생성 순서를 나열한 것이다.

  1. 서버측의 소켓 생성순서는 최초 socket 함수를 이용해서 endpoint 소켓, 즉 클라이언트의 연결을 듣기 위한 소켓을 생성하게 된다. 이 소켓은 서버가 종료될때까지 남아있게 된다.

  2. bind 함수를 호출하여 소켓특성을 묶어준다. 이 함수를 이용하여 port 번호를 지정해주며, 받아들일 IP주소에 대한 설정을 한다.

  3. listen 함수를 이용하여 듣기 소켓(socket 함수를 통해서 만들어진) 에 연결이 있는지 기다린다. 만약 연결이 있다면, 연결 대기열(queue)에 쌓아놓는다.

  4. accept 함수를 이용하여 연결 대기열에 대기중인 연결이 있다면 해당 연결에 대하여 새로운 소켓을 만들고 만들어진 소켓에 대한 파일 지시자를 되돌려준다. 이 소켓은 읽기/쓰기로 만들어진다. 만약 연결 대기열에 대기중인 연결이 없다면 (기본적으로) 해당 영역에서 봉쇄(block)된다.

  5. read, write 등의 함수를 이용해서 통신을 한다.


2.2. 클라이언트 측 socket 생성순서

다음은 클라이언트측의 소켓 생성 순서를 나열한 것이다. 서버측에 비하여서 훨신 간단하게 이루어짐을 알수 있다.

  1. 최초 socket 를 이용하여 endpoint 소켓을 생성한다. 클라이언트 이므로 이것은 듣기 소켓이 아니고, 연결 소켓이 될것이다. (이름만 다를뿐 사실 듣기 소켓과 연결 소켓의 구분은 없다)

  2. connect 를 이용하여 서버에 연결한다.

  3. read, write 등의 함수를 이용해서 서버와 통신한다.


3. 결론

이상 Socket Layer 의 개념과 Socket Layer 에서 제공하는 Socket API 에 대한 간단히 알아 보았다. 여기에 있는 API 들은 가장 기본적인(통신을 위해서 필요한) 함수들이다. 나머지 좀더 세밀한 함수들에 대해서는 Unix NetWork Programming 등의 서적을 참고하기 바란다.

Posted by 젤라피

왜 ? IOCP 인가??

NetWork 2008/03/29 11:41


1.
문서의 목적                                                   Ver 1.1

 


M$에서는 서버/클라이언트간에 통신을 함에 있어서 5가지 형태의 함수를 제공하고 있다. ( 소캣의 IO상태를 검사하는 방법을 기준으로할 때 )

 

1)      select 를 사용하는 방식

2)      윈도우를 필요한 WSAAsyncSelect 를 사용하는 비동기 셀렉트 방식

3)      WSAEventSelect 를 사용하는 이벤트 셀렉트 방식

4)      Overlapped IO를 사용하는 방식

5)      IOCP를 사용하는 방식

 

지금의 우리는 서버라고 하면 모두들 IOCP를 사용하려고 한다. 물론 검증된바 IOCP가 최고의 효율을 내기때문이다. 하지만 왜 IOCP를 해야하는지에 대한 의문은 한번씩 가진적이 있을것이다.

 

이 문서는 각각 형태의 IO검출 방법에서, 그 코딩방법을 비교함으로써 IOCP를 사용해야되는 당위성에대해 본인의 극히 주관적인 관점으로 설명하고저 한다. 이론적인 이야기는 여러 필자들과 각종 서적에서 훓어주고 지나갔기 때문에 구구절절 적지는 않겠다. 실무위주로 적겠다는 이야기다.

문서의 난이도는 중급정도 될까 ?! 

소스와 코딩방법은 여러책들과 컬럼들에서 많이 다루었기 때문에 이번에 제외시켰다.

 

( 참고로 본인은 1),2),5)번 방식으로 구성된 서버를 운영했다. )

 

 

2. 최고의 효율을 내는 서버란 무슨뜻일까 ?

 


'최대 다수의 최대 행복' 이것은 벤덤과 밀의 공리주의에 대한 이야기다. 벤덤은 양적공리주의 즉 많은 인간이 많이 행복하면되는 것이고, 밀은 질적공리주의 즉 양도 중요하지만 그 질(정신적인것)도 중요하다는 이야기를 했다고 한다. 당연한 이야기잖아.

 

최고의 효율을 내는 서버는 최대한 유저를 많이 붙여서 최대한 빠르게 요청을 처리해서 알려주면되는것이다. 지극히 경제학적인 관점으로 봐야한다. 최대한 유저들이 붙어서 최대한 양적/질적 만족을 하면 된다는것이다.

 

& 서버 프로그램을 하다보면 서버당 몇 명 유저까지 붙일수있느냐고 묻는 사람이 있다.

지극히 어려운 질문이다.

SOCKET 변수는 winsock.h 파일에 다음과 같이 저장되어 있다.

 

typedef    u_int       SOCKET;

 

데이터형이 unsigned int 이므로 대략 42억개정도의 소캣을 붙일수있다.

그럼 질문한 사람에게 42억개라고 말해주기에는 나의 공력이 허락하지 않는다.

 

소캣 하나가 생성되면 유저당 생성되는 수신/송신버퍼와 각종 변수들을 위한 메모리가 할당되야하기 때문에 , 서버 박스의 메모리에 따라 접속자가 결정되는 측면도 무시할수없다.

 

가령 서버박스의 메모리가 1기가이고 유저당 송신버퍼 64K, 수신버퍼 64K, 각종변수가 2K 해서 , 대략 130KB의 메모리를 사용한다면 붙을 수 있는 유저의 수는 7692가 되겠다. (1GB / 130KB = 7692 , 물론 다른 프로그램이 사용하는 메모리는 0으로 두고선.)

 

하지만 이것은 서버에 붙은 유저가 아무짓도 하지않는다는 가정을 둘때고 , 실제로는 다음의 내용들까지 고려해야 서버당 접속 가능한 유저수를 산출할수있다. (다음의 이야기는 얼마나 빠르게처리해서 모든 유저들을 만족시켜 줄수있을까라는 문제와 연관이 있다.)

 

l       유저들의 각종 요청에 따른 서버의 커맨드처리 작업시 소요되는 CPU 연산에의한 부하량을 고려 ( 어플리케이션에서 사용하는 CPU 부하량 )

l       팩킷의 송수신시에 발생되는 CPU의 부하 고려 ( 소캣 라이브러리에서 사용하는 CPU 부하량 )

l       서버가 다시 송신해 주는 팩킷의 양에 따라서, 서버를 운영하는 서비스 제공자가 할당받은 네트웍 Bandwidth의 허용하는 범위 고려.

l       내부 동적 메모리 버퍼의 증감량에 따른 메모리 문제가 생길수있는지 문제 고려

l       DB 처리에 따른 병목 현상으로 서버 효율 감소 고려

 

위의 사항들은 철저하게 어떤 제품을 만들려는 그 회사의 운영 및 설계에 따라서 달라지기 때문에 처음에 이야기했던 서버당 접속 유저수에 대한 질문은 , 반대로 질문자가 서버에서 어떤 일을 하고싶은지에대한 답을 줘야 해결해 줄수있게 된다.

 

&  CPU의 활용문제

서버를 돌리는데 적정한 CPU의 부하는 몇 % 일까 ?

정답은 유저들이 요청한 것을 처리하고 결과를 수신 받을 때 오래 걸리지 않았다고 느껴질만큼 서버의 CPU가 소요되고 있는 상태가 적정한 수준이다.

나의 경우는 지속적으로 서버의 CPU 80~90% 차지하고 있지 않는다면, 수초에서 수분까지는 그 CPU 80~90% 점유하고 있어도 문제는 없었다.

그런데 CPU의 사용이 너무 적은것도 문제가 된다. 메모리도 충분하고 팩킷을 보내는양도 적고 CPU도 계속 10~20%정도밖에 사용하고 있지 않다면 , 자원의 낭비가 있으므로 더 많은 유저수를 받을수있도록 시스템을 구성하는것도 좋을것이다.

. 지극히 뻔한 이야기다.

 

그래도 서버는 돌아야한다.

 

3. Input / Output 발생의 검출 및 애플리케이션에 통지

 


역시 당연하지만 소켓에서 Input receive가 되고 Output send하는것을 말한다.

 

다음에서 설명하는 5가지 방식의 주요한 관점은 이런 I/O가 발생된 것을 어떻게 검출해 내는지에 대한 방법론적인것과 , 어떻게 하면 애플리케이션단에 통지를 해서 팩킷을 파싱(parsing)하고 , 그 파싱에 따라서 구분해낸 유저들의 요청을 효과적으로 처리해 주는가에 대한 문제이다.

또한 고속으로 데이터를 수신/송신할때 문제점들과 유저들의 요청을 처리해줄 때 쓰래드 관리를 어떠한 방식으로 할지에 대한 문제들도 점검해 보겠다.

(미리 이야기하지만 WSAEventSelect 방식과 Overlapped IO방식으로는 서버를 구성해보지 않았기 때문에 개념적으로(머리속에 들어있는 공상으로만 T.T) 논리를 풀어가야만 하는 한계가 있다. )

 

 

 

 

 

통신 라이브러리

 

팩킷 수신

검출

팩킷 파싱 & 디코딩

 

 

 

 

 

서버 어플리케이션 단

 

커맨드 처리

유저 요청 작업 처리

 

 

 

 

 

통신 라이브러리

 

팩킷 송신

팩킷 인코딩

 

 

 

 

 

 

 

 

 

 

 


                                          그림1) 팩킷 수신 및 처리 그리고 송신

아래의 그림2),3)는 위 그림1)의 내용중 통신 라이브러리에서 사용되는 쓰래드와 함수의 관계를 그려본 것이다. 나의 경우는 검출하는 쓰래드가 멀티냐 싱글이냐에 따라서 대략 두가지 방법을 사용하고 있다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

그림 2) 검출 쓰래드가 싱글. (나의 경우 select , WSAAsyncSelect에 적용)

IO 검출 싱글 쓰래드

( Receive, Send, Close )

Recv Thread

(싱글/멀티쓰래드) Parsing

Send Thread

(싱글/멀티쓰래드)

 

( send list

내용 전송 )

Accept Thread

(싱글쓰래드)

커맨드 처리

 

 

 

 

 

 

 

 

 

 

 

 

 

 


IO검출에서 send 상황 검출은 select , WSAAsyncSelect 두방식이 서로 다름, 뒤에설명

 

 

 

 

 

 

 

 

 

 

 

 

그림 3) 검출 쓰래드가 멀티. (나의 경우 IOCP 에 적용)

IO 검출 멀티 쓰래드

( Receive, Send, Close )

Recv 함수

 

Parsing

Send Thread

(싱글쓰래드)

 

( send list

내용 전송 )

Accept Thread

(싱글쓰래드)

커맨드 처리

 

 

 

 

 

 

 

 

 

 

 

 

 

 


위의 그림들은 라이브러리 구성하는 사람들마다 다르기 때문에 절대적인 것은 아니다.

 

일단 IO의 검출과 그 통지에 대해서 알아보자. 크게 보면 두가지 경우라고 볼수있다.

 

1)     유저 라이브러리에서 IO의 검출 상황을 항상 체크해서 상태를 알아 내는 방법.

2)     시스템이 IO의 검출이 있을 때 이벤트를 통해 알려주는 방법.

 

사실 1), 2)의 방식 모두 어떤 IO에 대한 상태가 바뀌면 그것에 대한 상태변화는 시스템이 알려주게 되어 있다. 다른점은 이번에 IO 상태의 변화가 생겼을 때 Receive, Send, Close 들중에 어떤 IO의 상태 변화인지를 직접 알려주는지 , 아니면 라이브러리에서 확인하는 루틴을 넣어야 하는지에 대한 차이이다. 이것은 개발 효율 및 라이브러리 속도에 아주 큰 영향을주는 부분이다.

 

1)의 방식은 select, WSAEventSelect 방식에서 사용하고 2)의 방식은 WSAAsyncSelect, IOCP에서 사용하고 있는 방법이다. 특이하게 Overlapped IO에서는 두가지 모두 지원을 한다.

 

전체 다이어그램을 보면서 이야기해보자.

 

위의 그림 2)와 그림3) 사이에 크게 별다른 것은 없다.

다만 검출하는 쓰래드가 멀티냐 싱글이냐에 따라서 그것을 수신해서 처리하는 함수를 쓰래드로 해야할지 아니면 바로 호출되는 함수로 해야할지가 달라진다는 것이다.

 

그림2)의 경우를 보자.

Receive가 된 것을 검출하면 해당 팩킷을 Receive 팩킷 리스트에 저장을 한다. Receive 쓰래드에서는 Receive 팩킷 리스트에서 내용을 가져와 유저별로 리시브버퍼에 넣어주고 , 역시 쓰래드에서 순차적으로 유저별 리시브 버퍼의 내용을 파싱 한뒤에 커맨드 처리 함수 부분으로 보낸다.

이런 방법도 있다. Receive가 된 것을 검출하면 Receive팩킷 리스트에 넣지 말고 바로 유저별 리시브 버퍼에 넣어주는 방법도 있다. 그리고 리시브 쓰래드(이때는 파싱 쓰래드라고 불러주는 것이 좋을거같다.)에서는 순차적으로 파싱을 해서 커맨드 처리 함수 부분으로 보내주면 된다. 속도는 위의 방법보다 이것이 팩킷을 복사하는 횟수가 줄일수 있기 때문에 더 빠른 처리를 보장한다. 하지만 리시브 버퍼가 비워지지 않았다면 복사를 할수없어서 버퍼가 비워질때까지 , 즉 파싱되어서 커맨드가 처리될때까지 기다려야되는 문제가 생긴다.

리시브 쓰래드는 싱글또는 멀티로 둘수있는데, 서버 생성시에 미리 멀티 쓰래드를 여러 개 만들어둔뒤에 유저에 따라서 쓰래드를 구분하고 동시에 파싱을 한다면 더 높은 처리율을 보일것이다.

첫번째 방법은 대용량서버에서 유용하고 두번째 방법은 서버가 수신해야될 팩킷의 양이 적은 서버에서 유용한 방법이다.

 

그림 3)처럼 멀티쓰래드로 검출이 된다면 바로 수신 처리 함수로 연결을 해서 따로 쓰래드를 만들지 않아도 되는 편리한점이있다. 물론 멀티로 수신 검출을 해서 리시브 리스트를 구성한뒤에 , 리시브-파싱 쓰래드에서 읽어서 쓰도록 만들어도 되겠지만 , 처리 속도를 조금이라도 더 올릴 생각이라면 그러한 작업은 불필요하다고 생각한다.

 

Send Thread는 다음의 작업을한다. 유저의 요청에 따라  서버가 응답 팩킷을 만든뒤에 send packet list에 넣은 것을 send(), WSASend()함수 호출에 의해서 송신하는 역할을 한다.

 

Receive의 검출뿐아니라 Send의 검출 또한 IO 검출 쓰래드에서 이루어진다.

 

Send의 검출은 해당 소켓으로 팩킷을 보낼수있는가에 초점이 맞추어진다. 하지만 이 Send에 대한 검출은 다음에서 소개하는 여러가지 IO방식에 따라서 달라지기 때문에 각각의 항목에서 설명하도록 하겠다.

 

그럼 정리를해서 이상적인 IO 통지 구조를 그려보자.

 

 

 

 

 

 

       수신 이벤트 통지      송신 결과 통지           send packet list 전달

 

 

 

 

 

 

  send packet list 추가

 

그림 4) 이상적인(?) IO 통지. (나의 경우 실제 IOCP에 적용 시킨 방식)

IO 검출 멀티 쓰래드  ( Receive, Send, Close )

 

Recv 함수

 

Parsing

Send Thread

(싱글쓰래드)

Accept Thread

(싱글쓰래드)

커맨드 처리

Send 함수

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


l       일단 IO의 검출은 무조건 멀티쓰래드여야 한다.

l       Receive가 검출되었다면 얼마만큼의 데이터를 읽어야하고, 어떤 소켓에서 검출되었는지 이벤트로 통지해 줘야한다.

l       그러면 Recv 함수에서 해당 소켓에게 날라온 데이터만큼 읽으면 된다. (실제 IOCP에서는 다른 방법을 사용한다. 뒤의 IOCP설명에 추가하겠음) 읽혀진 데이터는 Recv Buffer에 들어간다.

l       Send의 검출은 송신 결과 통지를 통해서 현재 내가 유저별 Send Buffer에서 팩킷 송신 함수를 통해서 보낸것중에서 얼마만큼이 보내졌는지 알려줘야한다. 즉 이것은 Send 완료의 통지이다. Send 완료의 통지를 해줘야 더 팩킷을 보낼수있는 없는지 알게 되기때문이다.

l       만약 보낼려고했던 팩킷보다 더 적은 팩킷을 보냈다고 통지가 오면, Send함수에서는 보내진 부분외에 부분만 더 송신함수를 이용해서 보내면 된다.

l       Send Thread는 커맨드 처리부에서 만들어진 send packet list의 내용을 읽어서 유저들의 Send Buffer에 넣어주고, 팩킷 송신 함수를 호출하는 일을 한다. 이때 해당 유저의 Send Buffer가 가득차 있다면 더 이상 팩킷을 send packet list에서 읽어서 넣어주는 읽을 하지않는다.

l       Receive Send 완료 결과의 통지는 리스트로 구성되어 큐에 들어가 있어야 한다. 윈도우 동기화개체중에 이벤트 방식으로 하는것도 좋지만 이벤트만 사용하면 데이터를 같이 넣어서 보낼수없기 때문에 데이터 리스트 큐도 이용해야한다. Recv함수/Send함수에서는 리스트 큐에 있는 것을 꺼내와서 어떤 작업이 이루어졌는지 확인을 해야하기 때문이다. IO 검출 멀티쓰래드에서는 데이터 리스트큐에서 꺼내오는 작업을 한뒤에 Recv함수와 Send함수 호출을 하게 된다.

l       어떤 소켓이 검출되었는지는 SOCKET 타입의 핸들을 알려주는것보다 직접 해당 소켓의 메모리 포인터 오브젝트(클래스 또는 구조체 포인터)를 알려주는 것이 편하다. 프로그래머가 해당 SOCKET를 가지고 링크드 리스트를 돌려서 객체를 찾는것보다 속도 면에서 훨씬 이익이기때문이다.

l       Accept Thread는 개별적으로 항상 돌면서 유저들이 접속해 오는지 검사를 한다.

 

사실 이러한 조건을 만족하는 소켓 프로그램방식이 바로 IOCP 이다. 물론 다음에서 제공하는 5가지 방법을 이용해서 위의 조건들을 만족하는 통신 라이브 러리를 구성할수도 있다. 하지만 이미 IOCP에서 제공되는 기능을 다시 만드는 무모한짓을 하는 것이 의미있는지는 모르겠다. (학술적 차원에서 접근은 배제하고 ^.^ )

 

 

 


4. select 방식

 


이 함수는 울트라 무식 함수의 결정체인 동시에, 그 구조의 단순함으로 소켓 세상을 한동안 풍미했던 구시대의 유물이다. ( 하지만 아직도 난 이놈으로 구성한 서버를 쓰고 있다. ) 왜 함수의 이름이 선택 인지 각자 생각해보자.

 

Select 방식으로 서버를 구성하려면 아래의 것들을 생각해야한다.

l       검출 쓰래드에서 select 로 한꺼번에 걸수있는 최대 소켓은 1024개이다. 문제점1)

l       문제점1)을 해결하려면 이에 따라서 select하는 부분을 쓰래드로 나눈다고 할 때 한 쓰래드에서 관리해 줘야할 유저의 수를 정해야 한다.

 

한가지! select 방식에서는 Send Thread가 별로 필요가 없다. 커맨드 처리가 된 것을 Send packet list에 넣은뒤에 검출 쓰래드에서 select를 이용해 팩킷을 보낼수있는지 검사를 한뒤에 송신을 하면 되기때문이다. (한 쓰래드당 처리할 유저수를 정한뒤에 해당쓰래드에서 읽을것이 있는지, 보낼수있는지, 접속이 되었는지, 종료가 되었는지 검사하는 루틴을 넣는 방식을 이용하기 때문이다.)

 

문제점1)에 대해서 생각해보자.

Winsock2.h 파일을 인클루드하기 전에 FD_SETSIZE값을 새로 #define해서 최대값인 1024로 바꾸지않는다면 초기값은 64로 인식이 된다. 즉 최대 64명뿐이 받을수없는것이다.  그럼 1000으로 바꾸어서 사용한다고 생각해보자.

 

그림2)에서 처럼 싱글 쓰래드로 간다고 하면, 한번에 select에서 걸수있는 유저의 수가 1000명이기 때문에 유저가 1200명이라고 하면 , 먼저 1000 select해서 receive할것이 있는지, send가능한지 파악을 한뒤에 다시 200명을 또 select 해야한다. 만약 수신과 송신할것의 개수가 매우 많다면 서버 다른 유저들것까지 처리가 느려지므로 문제가 발생된다.  이것은 서버의 효율면에서 매우 불리하다.

 

그럼 그림3)의 구조로 가보자. 유저를 200명 단위로 잘라서 쓰래드를 만든다고 해보자. 쓰래드 생성삭제시에 속도감소를 피하기위해서 미리 유저 제한을 2000명으로 두고 , 쓰래드도 10개를 만들어 두었다고 하자. 위의 경우보다 분명히 효율은 올릴수 있을것이다. 하지만 이때도 200명중 누군가의 팩킷 처리를 위해서 다른 사람들은 대기하고 있어야한다. 하나의 IO에 하나의 프로세스밖에 진행을 하지 못하기 때문이다.

 

 

전반적 이해를 돕기위해서 IO를 관리하는 구조를 간단하게 그려보았다.

 

Receive, Close 검출 싱글 쓰래드

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

그림 5) select 방식을 이용 IO검출 구조도

Recv Thread

(싱글/멀티쓰래드) Parsing

Send Thread

(싱글/멀티쓰래드)

- send 가능 검출

- send list

내용 전송

Accept Thread

(싱글쓰래드)

커맨드 처리

 

 

 

 

 


일단 Select 방식에서 사용하는 다음의 구조체와 매크로를 이해 해야한다.

 

typedef struct fd_set {

u_int   fd_count;               /* how many are SET? */

SOCKET  fd_array[FD_SETSIZE];   /* an array of SOCKETs */

} fd_set;

 

select 방식에서는 fd_set 구조체로 read,write,except 세가지 선언을 해서 사용한다.

보면 알겠지만 fd_count는 해당 fd_set 안에 들어있는 소켓의 갯수 , 그리고 그 소켓들의 배열로 이루어져 있다. (WINDOWS.H 안에 들어있다.)

 

FD_CLR       : fd_array 배열에서 지정된 SOCKET을 찾아서 삭제한다.

배열에서 빠진곳은 뒤의 SOCKET들을 끌어서 다시 채워준다. <- 속도 낭비

FD_SET       : fd_array 배열의 마지막에 지정된 SOCKET을 추가한다.

FD_ZERO    : fd_count의 값을 0으로 바꾼다.

FD_ISSET    : fd_array 배열에 지정한 SOCKET이 들어있는지 검사를 한다.

 

Select방식의 기본 작업 순서는 다음과 같다.

 

1)      FD_ZERO 으로 새로 소켓을 지정한다고 셋팅을 한다.

2)      FD_SET으로 fd_set 변수에 현재 접속된 모든 소켓을 배열에 입력해준다. 이때 순차적으로 배열에 FD_SETSIZE로 지정된 한계까지 넣을수있다.

3)      select를 이용해서 read/write/except 세가지 fd_set 을 걸어준다.

4)      FD_ISSET를 이용해서 read/write/except 세가지 상황을 검사한다.

 

그림 5)를 쓰래드 기능별로 설명해 보겠다.

Receive, Close 검출 싱글 쓰래드

Receive할것이 있는지(Read확인) 소켓이 끊어졌는지 검사를 한다. 만약 Receive할것이 있다면 내용을 읽어서 Receive Packet List에 넣어준다. 이 쓰래드는 싱글 쓰래드로 하는 대신에 읽는 속도를 최대한 올리는데 관점을 둔다. 서버는 읽어야할것들을 최대한 빨리 읽어들여야하기때문이다.

 

Recv Thread

Receive Packet List에 있는 내용을 가져와서 유저별 수신 버퍼에 복사를 한뒤에 해당 팩킷을 파싱하고 커맨드처리부분까지 전달하는 역할을 한다. 이 쓰래드는 유저의 수가 몇 백명이상된다면 멀티로 하는것도 괜찮은 방법이다.

 

Send Thread

send 가능한지 select함수를 이용해서 검사를 하고 , 가능하다면 send packet list에 있는 내용을 전송을 한다. 이 쓰래드 역시 유저의 수가 많다면 멀티로 하는 것이 좋다.

 

그런데 Receive, Close 검출 싱글 쓰래드Recv Thread가 꼭 두개로 분리되어야할 필요는 없다. Receive, Close 검출 싱글 쓰래드를 멀티로 바꾼뒤 , Recv Thread를 사용하지 않고 파싱과 커맨드 처리부의 연결을 Receive, Close 검출 쓰래드에서 해도 문제는 없다. 다만 이 경우 서버가 receive하는 양이 너무 많지 않아야 효율을 올릴수가 있다.

 

select 방식은 IO의 검출을 소켓 배열에 있는것들을 하나하나씩 비교함으로써 알아내고 있다. 이것은 치명적으로 속도의 저하를 가져온다. 멀티쓰래드로 select를 구현한다고 해도 유저를 쓰래드별로 관리해야하는 불편함이있다.

 

 

만약 IO 검출이 멀티쓰래드에서 되고, 쓰래드별 유저의 분리가 자동으로되며 그 IO 검출에의한 통지가 자동으로 되고, IO에대한 수신/파싱/전송등의 처리가 유저별로 쓰래드에 분산되어 각각 할수있다면 서버의 속도가 매우 올라갈 것이다.

 

 


5. WSAAsyncSelect 방식

 


이것은 위의 select 방식보다 매우 편리하다.

WSAAsyncSelect 의 경우는 윈도우를 하나 만들어야 한다. 그 이유는 만든 윈도우에서 IO의 검출이 자동으로 되고 , 그 윈도우의 메시지 큐에 저장이 되기때문이다.

개발자는 단지 WSAAsyncSelect를 이용해서 생성한 윈도우 핸들을 소캣과 매핑시켜주고 , 메시지큐에 있는 내용을 받아서 각각의 이벤트에 맞게 처리만 해주면 되기때문이다.

 

이 방식에서 이전의 그림2)에서있는 싱글 쓰래드검출부분을 별도로 만들어주지않아도 된다. 시스템의 메인쓰래드에서 그 검출부분을 해주기때문이다. 

 

기본적으로 WSAAsyncSelect는 앞에서 이야기했던 IO의 멀티 쓰래드 검출이라는 원칙에는 위배가된다. IO검출 자체를 시스템으로 넘겨줬기 때문에 싱글 쓰래드에서 검출해서 메시지 큐로 들어오기때문이다.( 솔직히 말하면 그럴것이라는 추측! )

 

메시지큐에서는 FD_ACCEPT, FD_READ, FD_WRITE, FD_CLOSE, FD_CONNECT, FD_OOB 6가지 신호를 받을수있다. Select방식과 달리 어떤 IO가 이루어지고 있는지 구별해 내는 코드가 필요없고 , 개발자는 해당 신호가 발생된것에대한 작업만 해주면 되는것이다.

이것들중에 중요한 FD_ACCEPT, FD_READ, FD_WRITE를 이야기해 보자.

 

FD_ACCEPT 이벤트

Select방식에서는 accept()를 호출하고 대기상태로 기다렸지만 , WSAAsyncSelect 에서는 이벤트로 이것이 뜨기 때문에 꼭 Accept Thread를 만들어서 처리할 필요는 없다. 하지만 동기화 개체를 이용할 때 일관성을 유지하고, Accept 처리를 빨리하기위해서는 Accept List Queue를 만든뒤에 유저가 들어와서 이벤트가 발생하면 list에 넣었다가 Accept Thread에서 꺼내어 쓰는 것이 여러모로 편리하다.

 

FD_WRITE 이벤트

WSAAsyncSelect 방식에서 FD_WRITE가 뜨는경우는 다음과 같다. (** 매우 중요)

l       WSAAsyncSelect 에서 FD_WRITE가 지정 될 때 즉 소캣과 매핑이 될 때

ex) WSAAsyncSelect(m_Sock,m_hWnd,m_RecvMsg

,FD_WRITE|FD_CONNECT|FD_READ|FD_CLOSE)

l       WSAEWOULDBLOCK 발생된 뒤에 일정한 시간이 지나서 이것이 해지되고, send 할 수 있는 상태가 되었을 때

 

WSAAsyncSelect 방식은 자동으로 넌블러킹 소켓으로 바꾸어 준다. 넌 블러킹 모드에서 send()함수를 호출하고 그 결과를 기다리지 않기 때문에 , 송신한 팩킷이 모두 갔는지 안갔는지 확인을 하지 않고 바로 또 send()를 호출하게 된다. ( 이방식에서는 Send Thread에서 send packet list에 있는 팩킷들을 열나게 send()를 하다가 그만보내라는 신호가 발생되면 그 상태가 풀릴때까지 기다렸다가 또 열나게 보내는 방식이다. 무식하다 )

이때 그만보내라고 발생되는 send() 오류가 WSAEWOULDBLOCK 이것인데 , 오류라고 보기보다는 경고라고 봐야 할것이다. 이것이 발생되면 해당 유저의 소켓에 대해 송신을 정지시키고 FD_WRITE가 발생될때까지 더 이상 팩킷을 보내지말고 기다린다. FD_WRITE가 발생되면 해당 유저 소켓의 남아있는 데이터를 전송하면 되는것이다.

 

FD_READ 이벤트

FD_READ의 처리는 WSAAsyncSelect를 이용해서 고성능 서버를 만드는데 있어서 제일 문제가 된다.

 

이 방식에서 기본적 로직은 FD_READ신호를 받으면 ioctlsocket()함수를 호출해서 읽어야할 데이터 크기를 확인한후에,  recv()함수를 호출해서 모든 데이터를 읽어들일때까지 반복적으로 recv()함수를 호출하면 된다는것이다.

 

그런데 문제는 읽어할 내용이 생기면 윈도우 메시지 큐로 FD_READ 신호가 들어가게 된다는 것이다. 즉 유저들의 접속이 많아서 반복적으로 유저들로부터 어떤 데이터가 들어오면 윈도우 메시지큐가 가득차게되어 더 이상 FD_READ신호가 발생되지않는 매우 골치아픈 상황이 생긴다는 점이다. 물론 빨리 빨리 recv()를 해서 메시지큐가 가득차지않게 하면 되지만 서버가 유저들의 요청을 처리하다보면 그럴수없는 상황이 생기기도 한다. 서버에 1000명 가량 붙어서 많은 요청이 들어오면 1~2주에 한번씩 그런 일이 발생하는 것을 보았다. 이것은 반대로 서버에서 클라이언트로 신호를 보낼때도 발생될수있다. 한꺼번에 서버에서 많은량의 팩킷을(작은사이즈로 많은횟수) 발송하면 클라이언트가 더 이상 팩킷을 받지못하는 불상사가 생길수있다. 기회가 되는 사람은 실험을 통해서도 쉽게 만들어 낼수가 있다.

 

해결 방법은 있다. 하지만 이 방법은 다른 문제를 발생시킨다. 다음을 보자.

 

처음 FD_READ신호를 받으면 ioctlsocket()를 호출하고 바로 다음과 같은 호출을한다.

 

WSAAsyncSelect(m_Sock,m_hWnd,m_RecvMsg,FD_WRITE| FD_CLOSE)

 

즉 다시 FD_READ신호를 받지않는다는것이다.

대신 루프를 돌면서 계속 ioctlsocket()를 체크해서 더 이상 읽어들일것이 없을 때 까지 모든 내용을 다 읽고 , 더 이상 내용이 없으면 반복루프를 빠져나가고 다음의 함수를 호출한다.

 

WSAAsyncSelect(m_Sock,m_hWnd,m_RecvMsg,FD_WRITE| FD_READ|FD_CLOSE)

 

다시 FD_READ 신호를 받는다는것이다.

그리고 함수를 빠져나간다.

 

이렇게 하면 더 이상 recv()할것이 없을때까지 계속 팩킷을 읽어들이게 되어서 , 따로 FD_READ신호를 받을필요도 없고, 메시지큐에 FD_READ 가 넘칠때까지 쌓이는 문제도 피할수있다.

 

하지만 여기서도 문제는 있다. 위의 FD_WRITE 가 발생되는 조건에 대한 이야기중에서 WSAAsyncSelect 에서 FD_WRITE가 지정 될 때 즉 소캣과 매핑이 될 때 라는 이야기가 있다.  다음과 같은 상황을 생각해 보자.

 

a)      서버에서 어떤 클라이언트에게 팩킷을 발송한다.

b)      클라이언트까지의 네트웍이 느리거나 , 클라이언트가 recv()하는것이 느려서 서버가 제대로 보내지못하고 결국 WSAEWOULDBLOCK 이 발생되었다.

c)      정상적이라면 WSAEWOULDBLOCK이 풀리면 FD_WRITE 가 발생되어야 한다. 하지만 그 유저에 대한 FD_READ 할것이 있어서 WSAAsyncSelect()를 호출하다가 보면 정상적으로 WSAEWOULDBLOCK이 풀려서 FD_WRITE가 발생되어야하는데 그전에 위의 WSAAsyncSelect() 함수 호출에 의해서 FD_WRITE가 신호가 발생되는 불상사가 생기게 된다.

d)      서버는 해당 클라이언트에대한 FD_WRITE가 발생된 것을 보고 우드 블록이 풀렸는지 알고 더 팩킷을 보내게 되고, 결국 또 우드블럭이 발생되는 문제가 생길수있다.

 

결국 WSAAsyncSelect 방식에서는 고성능 서버를 만들기위해서는 우드블럭 문제가 생겼을 때 문제처리를 어떻게 해줘야하는지가 관건이 될수있다.

Select방식에서는 보낼수있는지 검사를한뒤에 보내기 때문에 속도 자체는 느리지만 우드블럭이생기는 것은 줄일수있지만, 이 방식에서는 보낼수있는 상태를 검사하지않기 때문에 문제가 될수있는것이다. (WSAAsyncSelect를 사용하면서 send()를 하기전에 select를 써서 보낼수있는지 상태를 검사한뒤에 보내는방법은 적용해 보지않았다. 관심있는사람은 한번 해보시길)

- select 방식을 사용한다고 해도 우드블럭이 전혀 생기지않는 것은 아니다 , 다만 보내기전에 항상 루틴이 있기 때문에 그 발생빈도를 줄일수있는 잇점이 있다.

 

나의 경우는 서버의경우. FD_WRITE를 이용해서 우드블럭이 풀리는 시점을 검사하는 것을 포기하고 , 최초 우드블럭이 발생되면 더 이상 해당 소켓으로 팩킷을 전송하지않고 일정한 시간동안 대기한뒤에 다시 팩킷을 발송한다 . 그래도 우드블럭이 풀리지않으면 그 팩킷은 지워버린다. (!)

 

클라이언트의 경우는 서버로 보낼 때 클라이언트에서 우드블럭이 발생되었다는 것은 서버가 바쁘다는것이므로 FD_WRITE를 이용해 우드블럭 풀림을 검사해서 작업을 하는것이좋다.

 

끝으로 생각해볼것이 있다.

 

만약 TCP단의 send 버퍼가 다 차서 더 이상 보낼수없는 상태 , 우드블럭 상태를 어플리케이션단에 감지할수있다면 어떨까 ?

그러면 어플리케이션단에 send 버퍼를 두고 그 내용이 확실히 전송되었는지 확인되면 보낸만큼 버퍼에서 지우고 , 다시 send 버퍼에 보낼 내용을 복사해주면 되기때문이다.

IOCP가 해결책을 제시해준다.

 

 

참고) WSAEWOULDBLOCK

우드블럭 에러라고 하지만 실제로 에러로 보기는 문제가있다. 그냥 시스템의 상태를 알려준다고 생각하자.

Send()에서 우드블럭메시지는 논블럭킹 소켓에서 자주 발생이 된다. Send()를 하고 그 결과를 기다리지않기 때문에 계속해서 TCP로 팩킷을 보내게 되고 TCP단에 버퍼를 초과하게되면 결국 WSAEWOULDBLOCK메시지가 발생하게 되는것이다.


6. WSAEventSelect 방식

 


많은 부분 WSAAsyncSelect 방식과 유사하다. 다른점은 IO 검출의 통지를 윈도우 메시지를 통해서 하지않고 유저가 만들어준 이벤트 동기화 개체를 이용한다는 점이다.

(사실 이방법으로 대용량 서버를 운영해 보지않았기 때문에 내용을 적는다는 것이 지나친 자만이겠지만 그냥 주관적인 나의 생각을 적어보겠다. )

 

WSAEventSelect방식은 하나의 소켓이 생성될때마다 이벤트를 하나씩 만들어서 해당 소켓과 이벤트를 맵핑시키는 개념이고 , 그 매핑을 시키는 함수가 WSAEventSelect이다. 위의 WSAAsyncSelect와 달리 윈도우를 만들지 않아도 되기 때문에 더 깔끔(?)한 프로그래밍을 할수있다는 장점도 있지만 다음의 불편한 점이 있다.

 

l       WSAAsyncSelect 방식에서는 IO의 검출을 윈도우 시스템이 해서 알려주지만 이 방식에서는 WSAWaitForMultipleEvents()함수와 같은 이벤트 검출 함수를 이용해서 항상 감시상태로 있어야한다.

l       IO의 상태가 변했을 때 이벤트가 발생을 하지만 , 이벤트를 받은후에 소켓에 대한 어떤 IO 가 발생한것인지 구별해 내는 루틴이 필요하다. ( WSAEnumNetworkEvents 함수 이용 )

l       WSAWaitForMultipleEvents 이용할때 동시에 검사할 수 있는 이벤트의 최대값은 64이다. 즉 한번에 64유저의 IO 상황을 체크할수있다는 것이다. 결국 한쓰래드에서 64개만 체크할수있기 때문에 더 많은 유저를 받아들이려면 쓰래드의 수를 늘려야한다는 문제가 있다.

l       WSAAsyncSelect 방식에서 발생된 FD_WRITE의 문제는 여전히 남아있다.

 

1000명의 유저를 받는 서버를 만든다고 할 때 16개의 쓰래드가 필요하게 된다. 멀티 쓰래드에서 검출을 할 수는 있지만 , 미리 쓰래드 풀을 만들어서 관리해야하고 많은 유저를 받을 때 쓰래드수가 지나치게 많아지는 문제가 있다.

 

물론 WSAWaitForMultipleEvents()를 사용하지않고 , WaitForMultipleObjects()를 사용할수도 있다. 하지만 이것을 사용하게 되면 발생된 이벤트가 어떤 소켓에 의해서 발생된것인지 한번더 검사하는 루틴이 들어가게되므로 유저가 많이 접속하는 고성능서버에서 Receive신호가 많다고하면 매우 많은 효율의 낭비를 보게된다.

 

몇백명 안쪽에서 유저를 받는다면 WSAAsyncSelect 방식보다 좋은 효율을 볼것으로 기대가 된다.

 


7. Overlapped IO 방식

 


Overlapped IO , 한글로 억지로 번역한다면 중첩입출력, 이것에대한 개념은 IOCP로 연결이 되기 때문에 파일 IO를 기준으로 해서 설명을 간단히 해보도록하겠다.

 

l       파일 블록을 읽을 때 IO가 하나의 핸들을 열어서 여러구간을 읽기가 가능하다.

 

 


      A                            C

                        B

위의 그림처럼 하나의 핸들을 열어서 동시에 3군데에서 Read/Write가 가능하다.

물론 위와 같은 방식은 구버젼 IO함수에서도 구현할 수는 있다. 그러나 ! 구현해 보면 알겠지만 여간 귀찮은 일이 아니다.

 

l       파일 블록을 중첩해서 IO가 가능하다.

 

 


            A                                               C

                               B

이것은 매우 막강한 기능이다. 서버에서 중첩된 읽기가 가능한 파일 IO를 구현한다고했을 때 결국은 시스템의 효율을 더욱 더 높이는 일이 될수있기때문이다. 주의할점은 Read만 하는 시스템에서는 A,B,C 구간 작업을할 때 Lock을 할 필요가 없지만 Write 작업시에는 하나의 기록이 다른 하나에 영향을 줄수있기 때문에 Lock을 걸어주는 것이 필요하다.

 

그럼 어떤 사람이 묻는다.

A,B작업 같이 write 할 때 Lock을 건다는 것은 두개의 쓰래드가 같이 갈수없으니까 결국 속도면에서보면 이전 방식과 똑같은거 아닌가요 ?

니말이 맞다.  그렇지만 A,C 작업을할때는 이익을 볼수있잖아. 그리고 Read 작업시에는 Lock에 신경을 쓰지않아도 되서 많은 이익을 볼수있으니깐 결국 사용해야 되지 않겠어 ? ( 역시 지극히 주관적인 생각이다. 이견이 있다면 할말 없음 )

 

l       서버가 IO 작업을 걸어두고 다른 작업을 진행시킬수있다.

이것은 Overlapped IO라기보다는 Overlapped IO를 사용해서 생기는 부수적 효과라고 봐야한다. 중첩 입출력을 해야하기 때문에 당연히 IO의 완료가 되었음을 바로 알수있는 블록킹모드가 아니고 논 블록킹모드로 가야하고 , 그렇기 때문에 IO가 끝나기 전에 다른작업을 할수있게 되는것이다. 역시 서버에서 이 기능은 막강하다.

 

앞에 5페이에서 이야기를 했지만 Overlapped IO IO의 통지를 두가지 형태 모두 지원한다고 했다. 어떻게 보면 IOCP라는 방식이있는 상황에서 어정쩡한 느낌도 있지만 어쨌든 Overlapped IO방식을 설명해야 하는 필요성이있는 관계로 간단하게 이야기를 해보겠다.

 

l       이벤트를 이용해서 어떤 IO 상태인지 검사를 해내는 방식

l       완료 함수를 이용해서 알아내는 방식

 

1)     이벤트를 이용해서 어떤 IO 상태인지 검사를 해내는 방식

이 방식은 WSAEventSelect 방식과 매우 유사하다.

유저의 접속이 이루어지면 이벤트를 하나 만들어두고 , 추후에 IO 상태 변경상황이 발생되면 이벤트가 발생된 것을 보고 어떤 IO가 변동되었는지 확인을 해서 각각에 맞는 처리를 해준다는것이다. WSAEventSelect 방식에서 WSAEnumNetworkEvents() 역할을 했던 것이 여기서는 WSAGetOverLappedResult()함수가 된다.

다만 다른 것은 WSAEnumNetworkEvents()에서 FD_READ 가 발생되었다면 recv()함수를 호출해야된다는것이고, WSAGetOverLappedResult()에서 Read를 알았다면 내가 Read를 해 왔으니 가져가서 사용하라고 알려준다는 차이다. 이것은 Overlapped IO IOCP 동시에 해당되는 이야기이기 때문에 IOCP를 설명하는 부분에서 자세히 설명하겠다.

 

2)     완료 함수를 이용해서 알아내는 방식

쉽게 생각해서 IO의 상태 변화를 시스템에서 알려주는데 , 알려주는 방법을 유저가 만든 함수를 연결시켜주면 그 함수를 통해서 알려준다는 것이다. 함수포인터를 이용하는 방법으로 SetTimer함수의 마지막 파라메터를 셋팅하는것과 같은 개념이라 할수있겠다.

( 대부분 NULL값을 넣겠지만 그곳에 함수를 연결해주면 지정된 시간에 그 함수가 호출되는 편리한 상황이 연출된다. -.- 뭐 사실 그렇게 편하지는 않지만 )

 

내 경우는 이런 두가지 기능을 M$에서 제공받는다고는 하지만, 그래도 IOCP가 있는데 Overlapped IO를 사용하는 것은 무모한 짓이라는 판단이 들어 과감하게 접어버렸다.

 

기본적으로 IO의 검출을 멀티쓰래드로 만든다는 큰 그림 안에서 다음과 같은 불편한 문제가 생기기 때문이다. ( 물론 직접 만들어보지않았기 때문에 머리속에서만 계산이 돌아갔다. 역시 극히 주관적인 생각임을 밝힌다. )

 

1)      번의 방식은 WSAWaitForMultipleEvents(),  WaitForMultipleObjects() 둘중에 어떤 것을 사용할지에대한 고민이다. 전자는 64라는 것에 대한 문제가 있고 후자는 해당 소켓과 IO에대한 검출을 할 때 관리의 불편함에 대한 걱정이 있다. WSAEventSelect를 사용할 때 발생되었던 문제점과 유사하다.

 

2)      완료 함수를 사용하게 되면 , IO 검출에 대해서 해당함수가 불려지는데 유저가 많이 붙고 ,  많은 팩킷이 서버에 유입된다고 할 때 함수의 호출이 지나치게 많아지면 큐의 증가에의한 메모리 관리와 CPU 관리에 대한 제어가 되지 않아 통제 불능의 상황에 빠질지도 모른다는 걱정이 있다.

(주의할것은 내가 이런 방식으로 대용량 서버를 구현해 보지않았기 때문에 지나친 걱정일수도 있고, 이 방식으로 서버를 운영하려고 했던 사람에게 잘못된 정보를 줄수도 있다는것이다. 이 문서는 절대적인 자료가 아니다. )

 

이 방식에서는 APC Queue (Asynchronous Procedure Call Queue, 비동기적 함수 콜 큐)를 이용하는데 , 이것은 운영체제의 커널에서 운영되는 것으로 여기서는 중첩입출력에 대한 완료 함수를 저장하는 큐로 사용이 된다. ( 우리가 만드는 쓰래드는 사용자가 만드는 APC라고 해서 user mode APC라고 한다. 즉 쓰래드를 만들고 함수를 등록해 주면 우리가 그 함수를 호출하지않아도 자동적으로 그 함수가 호출되는데 이 역시 APC라고 할 수 있는것이다.)

 

여하튼 커널에서 APC Queue를 운영하면서 내부적으로 멀티쓰래드를 구성해서 완료 함수의 호출이 이루어지기 때문에 통제 불가능의 함수호출은 이루어지지 않을거라고 생각이 든다. ( 역시 공부가 더 필요한 부분이다. )

 

하지만 .

IOCP를 이용하게 되면 이러한 걱정들이 많은 부분 사라지게 된다. 자신의 멀티쓰래드 될것의 개수를 지정하고 , 커널은 그 개수의 범위안에서만 쓰래드를 관리하기 때문에 컨텍스트 스위칭이나 , 시스템의 지나친 사용 , IO가 발생했을 때 어떤 소켓에 대해 발생했는지 등등에 대한 관리가 매우 편하게 이루어지기 때문이다.

 

이제 IOCP로 넘어가자.

 

 


8. IOCP 방식

 


IOCP I/O completion port의 준말이다.

I/O의 통지를 특정한 Port를 통해서 알려준다는 말이다. Port는 왜 포트라는 말을 M$에서 붙였는지 모르겠다. 뭔가 특정한 소켓에서의 포트가 있는거처럼 보여서 혼동을 유발시킨다. 포트는 없다.

GetQueuedCompletionStatus()를 이용해서 현재 IO 상태를 판독해 내는 것이 끝이다.

 

윈도우에는 메시지 큐라는것이있고, GetMessage()라는 함수가 있다. 메시지큐에서 이벤트를 하나 읽어오는 함수다. 이벤트는 운영체제가 넣어준다. 개발자는 큐에서 메시지를 꺼내와 어떤 것인지 판독을 해서 처리해주면 되는거다. 윈도우에서 사용되는 모든 Event driven 방식은 큐와 큐에서 꺼내오는 함수만 이해를 하면 상황끝이다.

시스템의 커널에서는 IO의 상태 변화 통지를 IO Completion Queue에 계속해서 넣어주고 GetQueuedCompletionStatus()함수는 순서대로 꺼내오는 역할을 하는것이다.

 

GetQueuedCompletionStatus() 함수는 이전의 WSAWaitForMultipleEvents() ,   WaitForMultipleObjects()과 같은 역할을 하지만 단순히 어떤 소켓에서 발생되었는지 , 또는 어떤 이벤트에서 발생되었는지만 알아내는 것이 아니고 , 어떤 소켓 개체에서 발생되었는지 , 어떤 IO의 변동이 있었는지 , 얼마큼의 데이터 변동이 있었는지 상황을 알려주기 때문에 WSAWaitForMultipleEvents(),WaitForMultipleObjects() 때보다 개발자가 프로그래밍을 해야되는 내용들이 많이 줄어든다.

 

IOCP의 장점은 지금까지 오면서 말했던 여러가지 방식들의 단점이 없다는 것이 장점이라고 할수있고 , 단점은 NT , 2000, XP 계열에서만 사용할수있다는것이다. 98이하 버전에서는 이 방식을 사용할수없다. 즉 클라이언트용 소켓 라이브러리에서는 적합하지 않은 방식이다.

 

그러면 IOCP를 사용하는 방식을 간단하게 짚어보자.

 

일단 처음에 유저의 접속이 이루어지면 Accept를 하고 유저와 관련된 변수 / 버퍼의 메모리등 개체를 할당하고,  WSARecv()함수를 이용해서 읽어들일 버퍼를 한번 걸어줘야한다.

 

select, WSAAsyncSelect, WSAEventSelect 방식에서는 어떤 IO가 이루어졌는지 확인을하고 , 해당 IO 검출에 따라서 recv() / send()함수를 사용했다. 하지만 Overlapped IO, IOCP 방식에서는 먼저 읽어야할 공간을 운영체제 커널에 알려주는 작업을 해야한다.

 

그러면 커널은 해당 유저의 소켓에 Receive한 내용을 채워서 다시 통지를 해주는것이다.

 

우물에서 물동이를 넣어서 힘들게 끌어올린뒤에 그 물을 나누어 가져가는 것이 아니고 , 물동이만 우물에 넣어주면 신령이 나타나서 물동이를 채워서, 꺼내서, 던져주는 것이다.

 

계속 Receive를 받고 싶으면 WSARecv()함수를 이용해서 물동이를 계속 우물에 넣어주면 된다. 주의할것은 WSARecv()함수로 읽어들일 버퍼를 걸어줄 때 이전에 걸었던 부분과 중복되는 부분이 있으면 안된다는 것이다. 당연한 이야기겠지만 시스템은 날라온 버퍼가 이전 버퍼 부분과 중복이 되는지 확인을 하지 않는다는것이다.

 

중첩 입출력이라고 중첩된 버퍼를 보내도 알아서 채워준다는 것은 아니다. 파일을 읽어들일 때 중첩되는 구간을 읽는것과 , 버퍼를 보내주고 그곳에 채워달라고 요청하는것에 대한 혼동이 없기를 바란다.

 

Send할때는 더 재미가 있다.

다른 IO방식에서는 현재 송신이 가능한지 그 상태를 알아내는 것이 매우 불편했다.

 

IOCP에서는 송신을 할 때 WSASend()함수를 통해서 보내야할 데이터를 걸어주면 된다.

그러면 GetQueuedCompletionStatus()함수에서 데이터를 얼마나 보냈는지 알려주게 된다. 개발자는 전체 보내야될 데이터 중에 안보낸만큼만 더 걸어주고 , 또 다른 팩킷을 걸어주면 되는것이다.

 

즉 커맨드 처리함수에서 유저의 요청에대한 보내야될 팩킷 리스트를 만들고, 작동중인 Send Thread에서는 순차적으로 유저의 Send Buffer에 넣어주고 , WSASend()함수를 호출하면 된다. 이때 Send Buffer의 남아 있는 공간보다 보내야할것이 더 많다면 조금후에 보내는 루틴을 만들면되는것이다. 이렇게 하면 송신중에서 생길수있는 최대 문제인 WSAEWOULDBLOCK 문제를 해결할수있게된다.

 

추가로 IOCP로 라이브러리를 구성하면 다음의 것들을 생각해야 한다.

 

l       메모리 풀을 만들어 주는 것이 좋다. 특히 유저 소켓에 대한 메모리 풀 (또는 커넥션 풀이라고 하자)을 만들어주는 것이 좋다.

커넥션 풀에는 유저 소켓별 변수들과 수신버퍼, 송신버퍼들이 들어 있다고 하자. (커넥션 풀을 1000으로 잡는다면, 유저 접속을 1000명으로 제한한다는 것이다. )

 

일단 커넥션풀을 사용하지 않는 상황을 생각해 보자.

멀티쓰래드에서 GetQueuedCompletionStatus()를 통해 IO 신호가 검출되기 때문에 만약 4개의 쓰래드가 있고 , 동시에 두개의 Receive와 한 개의 Close 신호가 들어왔다면 상황에 따라서 문제가 생길수가 있다.

 

만약 Receive 두개가 먼저 처리되고 Close가 처리된다면 문제가없다.

하지만 Close신호가 먼저 처리되고 Receive가 처리되면 문제가 된다. Close에서 해당 소켓의 메모리를 삭제했기 때문에, 리시브 버퍼 포인터가 이미 지워 졌기 때문이다. 물론 해결할 수 있는 방법도 있다. 각각의 쓰래드 진입시에 Lock을해서 소켓 오브젝트의 생성 삭제를 감시하면 되겠지만, 속도에서 많은 부분 손실을 보게된다.

 

그래서 커넥션 풀을 만들고 , 지금 Close가 되었는지 아닌지 플래그를 만들고, 그 플래그를 통해서 위의 상황을 제어하면 아주 좋다.

 

l       Receive buffer, Send buffer의 링버퍼 운영에 주의해야 한다.

(미안하지만 링버퍼를 만들고 운영하는 방법들은 여러 책들을 참고하기 바란다.)

각각의 버퍼 구간들이 서로 꼬이지 않게 Lock을 잘걸어주면서 작업을 해야한다. 특히 Send buffer의 경우 내가 보내는 중에 링버퍼의 끝까지와서 버퍼의 위치 바꾸는 작업을할 때 , 아직 보내지않은 것을 보내는 루틴과 섞이는 문제가 생길수있다.

 

 


                                                  A

                                    B                             C

A : 지금 보내려고 버퍼에는 들어있지만 WSASend에 걸지 않은 구간.

B : WSASend에 걸어서 , 이미 보내졌지만 보냈다는 통지가 오지 않은 구간.

C : 새로 보내야 될 팩킷 , 링버퍼에 넣어야 하는데 버퍼공간이 부족하다.

 

이 경우 C를 버퍼에 걸기 위해서는 A 부분을 맨앞으로 옮기고 , 그 다음부터 C를 버퍼에 복사하면 된다. 하지만 B가 있다.

B의 데이터중에 얼마만큼의 데이터가 실제로 보내졌는지 알수가 없기 때문에 단순히 A 부분부터 복사를 하면 안되는것이다. B의 구간중 일부도 복사를 할 필요가 있기때문이다.

C를 버퍼에 넣기전에 B구간에대한 처리가 완료될때까지 기다리는 부분을 잘 구성해야 할것이다.

 

 

정작 IOCP에 대한 이야기를 하려고 했더니 할말이 별로 없다. 생각했던거 보다는 많이 부족한 문서가 되어 버렸다.

 

IO의 검출은 통신 라이브러리에서 제일 기본적인 부분이고 코어에 해당된다. 하지만 통신 라이브러리는 이것이 끝이 아니다.

 

파싱 쓰래드를 둘것인가 말것인가, 그렇다면 싱글로 아니면 멀티로 , 인코딩 / 디코딩은 어떻게 할것인가 , 팩킷 시리얼링은 어떻게 할것인가 , 클라이언트의 접속 관리는 어떻게 할것인가 , 메로리풀과 버퍼 관리는 어떻게 하는 것이 좋을까, DB풀과 쓰래드 풀은 어떻게 할까  …….

 

생각할것이 끝이없다.

 

앞에 이상적인 IO 통지 구조에서 이야기했던 여러가지 조건들을 다시 한번 생각해 보기 바란다. 개인의 프로그래밍 스타일과 쓰래드 , 이벤트 운영에 대한 취향 그리고 이상향이 다르기 때문에 정도는 없다.

 

다만 최대다수의 최대행복에대한 좋은 가이드라인을 제시했다면 거기서 만족하련다.

 

출처 : 변해룡 님

[출처] 왜IOCP인가

Posted by 젤라피