'분류 전체보기'에 해당되는 글 104건

카테고리 없음

출처 : 최호진님의 블로그

1. 서문

2. Local Scheduler / System Scheduler

3. Symmetric Job Unit / Asymmetric Job Unit

4. Process / Thread (per client)

5. Pre-spawned / Post-spawn (per connection-request)

6. Reuse / One-time use (job unit life cycle)

7. Configurable / Fixed job

8. Single port listening / Multiple ports listening

9. Level detected triggering / Edge detected triggering

10. Asynchronous / Synchronous Handling




1. 서문



서버를 설계할 때 다음 같은 요소를 가지고 선택하게 된다.



  1. local-scheduler / system-scheduler (non-block socket handler)
  2. symmetric / asymmetric job unit
  3. process / thread (per client)
  4. pre-spawn / post-spawn (per connection request)
  5. configurable / fixed job (job unit modifiablity)
  6. reuse / one-time use (job unit life cycle)
  7. single port listening / multiple ports listening
  8. edge detected triggering / level detected triggering


이들은 대개 서버 설계 초기에 주로 선택하나, 때로는 중간에 그 모델을 바꾸어 설계할 수도 있다. 그러나 어떤 것들은 임기응변식으로 서버 설계 변경이 가능하나, 어떤 변경은 처음부터 완전히 다시 작성해야하는 경우가 생기기도한다. 서버를 설계할 때 내외적인 상황에 대하여 고려해야할 사실관계들을 살펴보고 적절한 선택을 위해 정리해보고자 한다. 위에서 나열한 요소들은 하나의 서버를 설계할 때 부분적으로 선택되어지므로, 어떤 상황에 대한 서버를 설계할 것인지를 충분히 고려하지 않으면, 설계 변경시 상당한 충격이 있을 것이다.


2. Local Scheduler / System Scheduler



Non-block I/O, Thread Pool 개념을 사용할 것인가?

Process, Thread 전담형으로 만들 것인가?



  • 선택의 동기

  1. 흔히 non-block socket을 쓸 것이냐, block socket을 쓸 것이냐로 구분하기 쉬운 것을 job scheduler(작업 스케쥴러) 혹은 task switching 입장에서 구분해보았다. 그 만큼 non-block I/O를 통해 처리하는 서버에서는 작업 스케쥴하는 비용이 크게 고려되어야한다는 것이며, 프로세스나 쓰레드의 스케쥴링에 들어가는 시간보다 클라이언트 요구를 처리하기 위해 들어가는 시간에 집중하도록 설계하는 것을 의미한다.
  2. 시스템 스케쥴러를 사용한다는 것은 여러접속의 요구 처리를 프로세스 혹은 쓰레드 스케쥴링에 넘겨 처리하겠다는 것이다.
  3. 스케쥴러에 들어가는 비용을 아까워하는 경우에 Local Scheduler(로컬 스케쥴러)를 선택하게 된다. 예를 들어 데이터의 양도 많고 동시 접속도 많은 경우를 생각해보자, 동시에 1000개 이상의 접속이 생기고 이것들을 프로세스 1000개로 운영하는 것보다 2 개정도의 프로세스가 500개씩 나누어 처리하면, OS의 스케쥴에 해당하는 비용을 크게 줄일 수 있을 것이다. 여기에서 500개의 접속을 하나의 프로세스에서 효과적으로 처리하기 위하여 로컬 스케쥴러라는 말을 도입하였다.
  4. 로컬 스케쥴러라는 것은 User level thread 수준의 복잡도를 요구하는 것이 아니며, non-block I/O를 처리하거나 Thread-pool을 도입하여 소켓당 State machine을 잘 운용하는 수준의 스케쥴러를 말한다.



  • 이점 및 주의점

  1. Local scheduler는 하나의 프로세스에서 작업 분배를 논리적으로 구분한 것일 뿐, OS가 보기에는 하나의 프로세스에 불과하다. 즉, 간단한 User-level thread라고 생각해도 좋을 정도이다. 따라서, system call을 수반하는 무거운 context switching을 막을 수 있으며, 작업들간의 분배가 상당히 가벼운 것에 그 이점이 있다.
  2. 주의할 것은 non-block 소켓을 다룰때는 특히 대량의 접속에 대한 처리가 있을 때, 프로세스당 열 수 있는 최대 디스크립터 수에 도달할 가능성이 많다. 이 경우 똑같은 일을 하는 프로세스가 listen port를 공유하여 경쟁적으로 클라이언트를 접수하는 pre-forked 방식 서버를 사용하여야한다.
  3. 모든 작업은 하나의 프로세스내에 남기 때문에 쓰레드 프로그래밍과 같은 (함수 재진입 문제 등) 수준의 주의를 요한다.



  • 구현에 따른 고려사항

크게 영향을 받는 요인은 프로세스당 최대 열 수 있는 디스크립터 수와 CPU의 개수이다. 후자에 대해서는 CPU 개수의 두 배정도에서 작업전환 비용을 절감하는 이점을 최대화 시키는 것이 경험적으로 알려져 있다. 즉, non-block I/O를 처리하는 쓰레드 풀안의 쓰레드 개수는 1 CPU machine에서 두 개정도가 적당하며, 그 이상 늘여도 성능향상이 월등히 좋아지지는 않는다는 것이다.


3. Symmetric Job Unit / Asymmetric Job Unit



생성되는 프로세스/쓰레드가 모두 같은 일을 하는가?

생성되는 프로세스/쓰레드 마다 역할이 분배되어 있는가?



  • 선택의 동기

  1. 프로세스 혹은 쓰레드가 다수 만들어 질 때, 이들은 모두 같은 일을 하거나(대칭적 작업단위) 상호 협조(비대칭 작업단위)를 하는 모델로 만들어진다.
  2. 예를 들면, HTTP Proxy 설계에 하나의 접속건에 대하여 하나의 쓰레드가 만들어진다면, 이 쓰레드 하나가 클라이언트를 요구를 파싱하고 접속해야할 서버에 접속하며, 서버의 응답을 다시 릴레이하는 일련의 과정을 전담하도록 설계되거나, 두 개정도의 쓰레드로 나누어 하나는 클라이언트와 접속을 담당하고 다른 하나는 서버쪽 접속을 담당하는 형태로 설계될 수 있다. 그 외에 주기적으로 가비지 콜렉팅을 하는 쓰레드도 만들수 있고, 로그를 분리하기 위한 쓰레드도 만들어 질 수 있다.
  3. 대칭적인 서버는 모든 프로세스 혹은 쓰레드가 동일한 일을 하는 작업 단위로 만들어져 접속된 클라이언트의 요구사항을 전담하여 처리한다.
  4. 하나의 처리가 짧은 응답시간을 갖지 않는 경우 처리를 여러 단계로 나누어 각 단계마다 복잡할 수록 여러 프로세스 혹은 쓰레드로 나누어 처리시킬 수 있다.이는 경험적인 프로세스 수 조정 과정을 통해 병목이 생길 수 있는 단계에 여러 프로세스를 둘 수 있는 우아함을 지원하게 된다.



  • 이점 및 주의점

  1. 작업단위 쪼개어 만들어지므로 설계와 구현에서 고립화가 쉽다. 재사용이 가능하므로 쓰레드나 프로세스 수가 경제적으로 생성되며, 그 라이프 사이클도 상당히 경제적이다.
  2. 작업단위가 추상화되면, 업그레이드나 작업단계 추가등이 상당히 명료하다.
  3. 요청이 여러 작업단위를 뛰어 다니므로 중간에 분실될 우려가 있다.
  4. 단순한 요청사항인 경우 굳이 작업단위를 나누게 되면 오히려 복잡한 설계가 될 수 있다.


4. Process / Thread (per client)



하나의 접속에 대하여 한 프로세스를 만들어 처리할 것인가?

하나의 쓰레드를 만들어 처리할 것인가?


작업개체를 프로세스로 할 것인가?

작업개체를 쓰레드로 할 것인가?



  • 선택의 동기

이 문제는 프로세스를 생성하는데 드는 비용이 많다는 것에 기초한다. 전통적으로 fork를 하는데 들어가는 비용이 크기 때문에, vfork라는 개념도 생기며, 뒤에서 보게될 prefork라는 개념도 생기게 된다. 일이 발생했을 때, 무거운 시스템콜을 될 수 있으면 줄이려고 대신 thread를 택할 수 있다.

  • 쓰레드의 이점과 주의점

  1. 이점은 모든 쓰레드가 메모리를 공유하면서 생기는 클라이언트간 자료공유가 쉬운 것에 있다. 또한 많은 OS에서 쓰레드 생성비용이 프로세스보다 작다.
  2. 문제는 하나의 쓰레드는 완전무결 해야하는데 있다. 즉, 리소스 (메모리, 디스크립터 등)가 절대 새지 않아야하며, 쓰레드 자체가 치명적인 오류를 일으켜 프로세스 전체에 영향을 줘서는 안되는데 있다. 반면, 접속이 해제됨과 동시에 프로세스가 종료되는 경우 리소스는 자동으로 해제되므로 쓰레드 기반보다는 견고하다.

  • 구현에 따른 고려사항

  1. 리눅스의 경우 쓰레드의 생성이나 프로세스의 생성이 그다지 비용차이가 많지 않다. 즉, 리눅스의 쓰레드는 좀 무거운 편이다. 따라서 fork에 들어가는 비용을 고려하는 것만으로 쓰레드를 선택하지는 않는다.
  2. 쓰레드의 경우 User level 쓰레드와 Kernel level 쓰레드가 있다. 이 경우 User level일 때의 고려사항은 자칫 하나의 쓰레드에서 block 상황에 빠지게 되어 프로세스가 멈추게 되는 상황이다.
  3. 쓰레드의 경우 하나의 쓰레드가 차지하는 stack의 크기가 있으므로, 쓰레드 생성시 적절한 스택의 크기를 정하는 문제가 발생한다. 스택의 크기에 따라서 생성되는 쓰레드 최대 수가 달라지기 때문이다.
  4. 쓰레드의 경우 하나의 프로세스가 열 수 있는 최대 프로세스 개수에 제한이 있고 그것에 도달하는 경우는 어쩔 수 없는 한계에 도달한 것이라 생각하고 변경해야하는 경우가 있다.


5. Pre-spawned / Post-spawn (per connection-request)



connection 요구가 있을 때, 새로운 작업개체를 만들고 수신하여 처리결과를 전송할 것인가?

미리 만들어진 작업개체가 connection 요구를 accept하고 수신하여 처리결과를 전송할 것인가?



  • 선택의 동기

Process 생성에 들어가는 비용이 많이 들기 때문에, 사전에 Process를 준비하여 빠른 응답을 줄 수 있도록한다.



  • Pre-spawned의 이점과 주의점

  1. 접속을 전담하는 형태로 작성되며, 대개 전담하는 모델이 이미 접속된 소켓에 대하여 하는 것과는 달리, 접속요구를 accept하는 것부터 전담하게 된다.
  2. 동시에 두개의 port를 listen하는 경우 accept하기전 select를 사용하고, 이 select에 참여하는 작업개체(Process, thread)를 하나로 한정하기 위해 semaphore나 mutex 등을 둔다. 이는 accept 경쟁에 실패한 작업개체들이 한 포트의 accept에 멈춰있어 다른 포트에 accept할 기회가 상실되는 것을 막기 위함이다.


6. Reuse / One-time use (job unit life cycle)



접속을 전담하는 작업개체가 접속이 종료되면 같이 종료되나?

접속을 전담하는 작업개체가 접속이 종료되면 다른 접속을 받기 위해 대기하나?

몇번의 접속을 전담하고나면 종료되나?



  • 선택의 동기

  1. 작업개체의 생성에 들어가는 비용을 줄이기 위해 재사용하도록 한다. 주로 Pre-spawned(Pre-forked) 방식에서 사용된다.



  • reuse 모델에서의 이점과 주의점

  1. 이전 접속의 데이터가 다음 접속에 영향을 주어서는 안된다.
  2. 한 클라이언트의 접속/응답 시간이 작은 경우에는 효과적이나, 그렇지 않은 경우에는 고려하기 어렵다.



7. Configurable / Fixed job



접속을 받아 실제 응답을 주는 것은 고정되어 있나?

접속을 받아 실제 응답을 주는 작업이 바이너리 릴리즈 후에 추가되거나 수정될 수 있는가?



  • 선택의 동기

서비스 모델이 확장성은 고려되지 않는 경우 모든 작업은 컴파일되어 릴리즈 되지만, 버전업이 잦거나 접속이 종료되지 않은 상황으로 hotfix를 해야하는 경우를 고려하면, 선택해야한다.



  • 이점과 주의점

  1. 외부 모듈이 실행되는 형태로 작성되므로, 보안상 허점이 발견될 수 있다.
  2. 안정성이 해결된 바이너리라는 확인작업 없이 hotfix하면, 기존의 서비스도 중지될 수 있다.
  3. 외부 모듈을 통해 인증을 거칠 수 있다.



  • 구현시 주의점

  1. 구현을 inetd와 같은 방식으로 할 경우 accept후 fork/exec를 사용한다.
  2. 구현을 shared object 방식으로 할 경우 외부 object를 dlopen 한다.


8. Single port listening / Multiple ports listening



서비스 포트가 하나인가?

서비스 포트가 하나 이상인가?



  • 선택의 동기

외부에 두 개의 port를 보임으로서 다른 프로토콜 또는 다른 주소를 하나의 서비스안에서 구현한다.



  • 이점과 주의점

  1. 하나의 바이너리만을 배포함으로써 서비스를 간소화시킬 수 있다. 대표적으로 inetd, apache web server
  2. accept 경쟁이 일어날 경우 한쪽 port로 몰려 "port 왕따 현상"이 일어나지 않아야한다.



  • 구현 참고

  1. 서비스 구분은 getsockname을 통해서 local port를 구함으로써 알 수 있다.


9. Level detected triggering / Edge detected triggering



수신 요청 신호를 확인하여 응답할 것인가?

요청 버퍼를 확인하여 응답할 것인가?



  • 선택의 동기

시스템에서 제공하는 전통적인 select, poll 방식의 감지는 동시에 참여하는 descriptor 수가 많아질 경우 scan하는 비용이 많이 들어간다. 따라서, 수신요청이 있을 때 바로 처리하도록한다.



  • 이점과 주의점

  1. 대량의 접속을 처리해야하는 경우 서비스를 해야할 시점을 선택하는 문제를 접속 혹은 수신 통지 서비스를 이용하므로 응답시간을 단축할 수 있다.
  2. 한번의 수신 통지에서 버퍼에 남겨두는 것이 없도록 모두 비워주지 않으면, 버퍼 오버플로가 발생하여 서비스가 정지할 수 있다.
  3. OS에 의존하는 서비스를 이용하므로 이식성을 포기해야한다.



10. Asynchronous / Synchronous Handling



요청한 작업을 Callback함수를 통해 마무리 할 것인가?

요청한 작업이 종료될때까지 기다릴 것인가?
요청한 작업이 종료되었는지 확인하여 처리할 것인가?



  • 선택의 동기

  1. read/write 시스템콜을 사용하는 전송요청은 전송이 마무리 될때까지 대기하여 시간을 소비하게 된다. 이 대기 시간을 의미있게 사용하기 위해, 수신/전송 요청과 실제 수신 데이터 도착 / 송신 완료가 이루어지는 시점을 분리하여 그 사이에 다른 일을 하도록 한다.
  2. 사용자의 수신/전송 요청에 대한 결과를 확인하는 것과 요청에 대한 커널의 응답이 비동기적으로 한다.
  3. read/write 라는 하나의 명령을 사용하지 않고, asynchronous I/O 구현 Library의 request, callback 메커니즘을 사용한다.



  • 이점과 주의점

  1. 전통적인 signal-base async I/O는 signal 이라는 상대적으로 무거운 시스템콜을 통하여 일어나므로 매 처리가 signal handling 상황이라는 것에 주의해야한다.
  2. 대개 라이브러리에 의존하는 서비스이므로 이식성을 포기해야한다.
카테고리 없음

Network의 기본

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

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

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

클라이언트와 서버구조

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

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

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

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

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

TCP/IP

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

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

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

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

1) 링크 계층

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

2) 네트워크 계층

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

3) 트랜스포트 계층

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

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

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

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

4) 응용 계층

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

IP

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

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

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

도메인 주소

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

Port

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

루프백(loopback)

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

MTU

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

Path MTU

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

A - B - C - D

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

TTL

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

TCP 연결 (Three-way Handshake)

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

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

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

TCP 연결 종료

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

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

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

소켓 API

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

소켓 API란?

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

소켓 주소 구조

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

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

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

struct in_addr
{
    unsigned long s_addr;
};

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

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

바이트 순서(Byte Ordering)

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

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

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

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

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

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

long int htonl (long int hostLong);

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

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

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

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

SocketAddress.sin_port = htons(appPort);

이렇게 합니다.

소켓기술자란?

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

TCP 소켓

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

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

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

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

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

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

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

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

int close( int sockfd );

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

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

int shutdown( int sockfd, int howto );

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

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

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

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

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

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

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

bind ()

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

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

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

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

listen()

int listen ( int socket, int backlog );

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

accept()

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

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

send()/recv()

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

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

send( socket, buffer, 100, 0 );

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

recv( socket, buffer, 512, 0 );

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

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

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

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

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

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

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

소켓옵션

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

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

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

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

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

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

1) SOL_SOCKET Level

  • SO_RCVBUF, SO_SNDBUF

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

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

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

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

  • SO_LINGER

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

struct linger
{
    int l_onoff;
    int l_linger;
};

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

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

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

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

  • SO_REUSEADDR

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

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

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

2) IPPROTO_IP Level

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

3) IPPROTO_TCP Level

  • TCP_NODELAY

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

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

에코 프로그램

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

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

에코 서버

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

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

#define RCVBUFSIZE 128
#define MAXPENDING 5

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

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

        echoServPort = atoi( argv[1] );

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

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

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

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

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

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

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

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

servSock = socket( PF_INET, SOCK_STREAM, IPPROTO_TCP );

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

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

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

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

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

 >nslookup daum.net

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

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

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

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

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

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

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

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

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

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

에코 클라이언트

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

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

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

#define RCVBUFSIZE 128

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

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

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

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

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

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

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

        echoStringLen = strlen(echoString);

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

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

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

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

        close(sock);
        exit(0);
}

I/O 모델

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

Blocking I/O

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

Non-Blocking I/O

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

I/O Multiplexing

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

Signal Driven I/O

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

Asynchronaus I/O

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

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

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

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

I/O Multiplexing 예제

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

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

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

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

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

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

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

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

<그림> fd_set의 구조

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

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

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

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

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

#define MAXPENDING 5
#define RCVBUFSIZE 512

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        return sock;
}

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

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

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

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

        clntLen = sizeof(echoClntAddr);

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

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

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

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

POSIX Thread

Thread vs Process

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

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

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

Basic Thread Functions

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

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

#include <pthread.h>

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

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

void * ThreadMain( void * arg );

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

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

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

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

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

pthread_t pthread_self ( void );

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

int pthread_detach ( pthread_t tid );

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

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

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

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

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

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

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

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

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

        echoServPort = atoi(argv[1]);

        servSock = CreateTCPServerSocket(echoServPort);

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

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

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

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

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

        HandleTCPClient(clntSock);

        return (NULL);
}

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

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

로 하시면 됩니다.

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

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

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

struct ThreadArgs
{
    int clntSock;
};

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

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

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

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

관련링크

카테고리 없음

Library 배치 순서도 무지 중요하다...

컴파일은 되지만 제대로 동작안하는 문제가 발생하기도 한다..
그걸로 이틀 고생!


-lcommon -lclucene
과 
-lclucene -lcommon

위의 두가지는 엄청난 차이... 컴파일은 잘되고 실행도 잘 되지만 원하는대로 동작해주지 않는다.
assign이 제대로 되지 않는다던지 등등 많은 것들이 ㅠ

컴파일러가 빙시가 된듯한 느낌...

조심하자
카테고리 없음

1 . strace -ff -e write=1,2 -s 1024 -p PID 2>&1 | grep "^ |" | cut -c11-60 | sed -e 's/ //g' | xxd -r -p

2 . strace -ff -e trace=write -e write=1,2 -p SOME_PID
카테고리 없음

 lsb_release -a
카테고리 없음
((Total Memory - Free Memory - Buffer Memory - Cached Memory) / Total Memory) * 100

카테고리 없음

find ./ -name "a.txt" -exec perl -pi -e 's/\\\"/\%22/g' {} \;

a.txt안의 \" 이란 문자열을 %22로 모두 변경
자체 변경 해버린다.
해당 디렉토리 내의 모든 파일을 변경하고 싶다면 a.txt 대신 * 넣자
카테고리 없음

usage : cat [option] file

redirection
> (n)   //  n is file or hardware output
< (n)  // n is file or hardware input
>> (n) // n is add tail

output : > , >>
input : < , <<

(1) cat > fun.txt    // create
(2) cat >> fun.txt    // add
(3) cat fun.txt // read
(4) cat -n fun.txt | more  // add line number and line by line output in fun.txt
(5) cat /etc/lilo.conf | grep linux   // "linux" which is included lilo.conf output
(6) cat /etc/lilo.conf | grep -v linux // linux form inside file was not included output
카테고리 없음

1. yum install *priorities

2. check "enabled = 1" in "/etc/yum/pluginconf.d/priorities.conf"

3. [base], [addons], [updates], [extras] = priority=1

4. [centosplus],[contrib] = priority=2

5. add rpmforge.repo
# Name: RPMforge RPM Repository for Red Hat Enterprise 5 - dag
# URL: http://rpmforge.net/
[rpmforge]
name = Red Hat Enterprise $releasever - RPMforge.net - dag
#baseurl = http://apt.sw.be/redhat/el5/en/$basearch/dag
mirrorlist = http://apt.sw.be/redhat/el5/en/mirrors-rpmforge
#mirrorlist = file:///etc/yum.repos.d/mirrors-rpmforge
enabled = 1
protect = 0
gpgkey = file:///etc/pki/rpm-gpg/RPM-GPG-KEY-rpmforge-dag
gpgcheck = 1
priority=20

6. uname -i
i386 - http://packages.sw.be/rpmforge-release/rpmforge-release-0.5.1-1.el5.rf.i386.rpm
x86_64 - http://packages.sw.be/rpmforge-release/rpmforge-release-0.5.1-1.el5.rf.x86_64.rpm

7. install DAG's GPG key
rpm --import http://dag.wieers.com/rpm/packages/RPM-GPG-KEY.dag.txt

8. check rpmforge release file
rpm -K rpmforge-release-0.5.1-1.el5.rf.*.rpm

9. install rpmforge
rpm -i rpmforge-release-0.5.1-1.el5.rf.*.rpm

10. check yum
yum check-update

......
Loading "priorities" plugin

**packages excluded due to repository priority protections

11. success
카테고리 없음

AddDefaultCharset UTF-8 -> AddDefaultCharset off

add
AddCharset us-ascii .ascii .us-ascii
AddCharset ISO-8859-1 .iso8859-1 .latin1
AddCharset ISO-8859-2 .iso8859-2 .latin2 .cen
AddCharset ISO-8859-3 .iso8859-3 .latin3
AddCharset ISO-8859-4 .iso8859-4 .latin4
AddCharset ISO-8859-5 .iso8859-5 .cyr .iso-ru
AddCharset ISO-8859-6 .iso8859-6 .arb .arabic
AddCharset ISO-8859-7 .iso8859-7 .grk .greek
AddCharset ISO-8859-8 .iso8859-8 .heb .hebrew
AddCharset ISO-8859-9 .iso8859-9 .latin5 .trk
AddCharset ISO-8859-10 .iso8859-10 .latin6
AddCharset ISO-8859-13 .iso8859-13
AddCharset ISO-8859-14 .iso8859-14 .latin8
AddCharset ISO-8859-15 .iso8859-15 .latin9
AddCharset ISO-8859-16 .iso8859-16 .latin10
AddCharset ISO-2022-JP .iso2022-jp .jis
AddCharset ISO-2022-KR .iso2022-kr .kis
AddCharset ISO-2022-CN .iso2022-cn .cis
AddCharset Big5 .Big5 .big5 .b5
AddCharset cn-Big5 .cn-big5
AddCharset WINDOWS-1251 .cp-1251 .win-1251
AddCharset CP866 .cp866
AddCharset KOI8 .koi8
AddCharset KOI8-E .koi8-e
AddCharset KOI8-r .koi8-r .koi8-ru
AddCharset KOI8-U .koi8-u
AddCharset KOI8-ru .koi8-uk .ua
AddCharset ISO-10646-UCS-2 .ucs2
AddCharset ISO-10646-UCS-4 .ucs4
AddCharset UTF-7 .utf7
AddCharset UTF-8 .utf8
AddCharset UTF-16 .utf16
AddCharset UTF-16BE .utf16be
AddCharset UTF-16LE .utf16le
AddCharset UTF-32 .utf32
AddCharset UTF-32BE .utf32be
AddCharset UTF-32LE .utf32le
AddCharset euc-cn .euc-cn
AddCharset euc-gb .euc-gb
AddCharset euc-jp .euc-jp
AddCharset euc-kr .euc-kr
AddCharset EUC-TW .euc-tw
AddCharset gb2312 .gb2312 .gb
AddCharset iso-10646-ucs-2 .ucs-2 .iso-10646-ucs-2
AddCharset iso-10646-ucs-4 .ucs-4 .iso-10646-ucs-4
AddCharset shift_jis .shift_jis .sjis

Restart Apache2
1 ··· 4 5 6 7 8 9 10 11
블로그 이미지

개발자

우와신난다