DeepintoPharo:Chapter 04

From 흡혈양파의 번역工房
Jump to navigation Jump to search
제 4 장 Socket(소켓)

Socket(소켓)

작성:

Noury Bouraqadi (Noury.Bouraqadi@mines-douai.fr)
Luc Fabresse (Luc.Fabresse@mines-douai.fr)


현대 과학은 네트워크를 통해 협력하는 여러 장치를 수반하곤 한다. 그러한 협력을 마련하는 기본 접근법은 소켓을 이용하는 방법이다. 일반적인 사용의 예로 World Wide Web을 들 수 있다. 브라우저와 서버는 HTTP 요청과 응답을 전달하는 소켓을 통해 상호작용한다.


소켓의 개념은 1960년대에 버클리 대학교 연구원들에 의해 처음으로 소개되었다. 그들은 Unix 운영체제를 배경으로 C 프로그래밍 언어를 대상으로 첫 번째 소켓 API를 정의하였다. 이후 소켓의 개념은 다른 운영체제로 확산되었다. 그리고 소켓의 API는 거의 모든 프로그래밍 언어로 이식되었다.


이번 장에서는 Pharo를 배경으로 소켓의 API를 제시하고자 한다. 가장 먼저 클라이언트와 서버를 빌드하는 데 소켓을 사용하는 방법에 대한 예제를 제시하겠다. 클라이언트와 소켓은 소켓에 고유한 개념이다: 서버는 클라이언트가 발생한 요청을 기다린다. 그 다음으로 SocketStream와 그 사용 방법을 소개하겠다. 실제로 보통 소켓보다는 SocketStream을 사용하기가 쉽다. 실험에 유용한 Unix 네트워킹 유틸리티를 몇 가지 설명하면서 마무리 짓고자 한다.


기본 개념

Socket(소켓)

원격 통신에는 네트워크를 통해 어느 정도의 데이터 바이트를 교환하는 시스템 프로세스를 최소한 두 개 수반한다. 각 프로세스는 최소 하나의 소켓을 통해 네트워크로 접근한다 (그림 4.1 참조). 소켓은 통신 네트워크 상의 플러그로 정의할 수 있다:

그림 4.1: 소켓을 통한 프로세스간(Inter-Process) 원격 통신


소켓은 양방향 통신을 구축하는 데 사용된다: 소켓은 데이터의 전송과 수신을 모두 허용한다. 그러한 상호작용은 소켓에 의해 캡슐화되는 통신 프로토콜에 따라 이루어질 수 있다. 인터넷을 비롯해 이더넷 LAN[1]과 같은 다른 네트워크 상에서 널리 사용되는 두 가지 기본 프로토콜로 TCP/IP와 UDP/IP가 있다.


TCP/IP vs. UDP/IP

TCP/IP는 전송 제어 프로토콜/인터넷 프로토콜을 나타낸다 (줄여서 TCP). TCP는 (데이터 손실 없이) 신뢰성 있는 통신을 보장한다. 이는 통신에 관여한 애플리케이션이 실제로 대화 전에 연결되어 있을 것을 요구한다. 연결이 구축되고 나면 상호작용하는 당사자들은 임의의 바이트 양을 전송하고 수신할 수 있다. 이를 보통 스트림 통신이라고 부른다. 데이터는 전송 시와 같은 순서로 목적지에 도달한다.


UDP/IP는 사용자 데이터그램 프로토콜/인터넷 프로토콜(줄여서 UDP)을 의미한다. 데이터그램은 64KB를 초과할 수 없는 데이터 덩어리를 나타낸다. UDP는 두 가지 이유에서 신뢰성이 없는 프로토콜이다. 첫째, UDP는 데이터그램이 그 목적지에 실제로 도달할 것인지 보장하지 못한다. 두 번째 이유는, 수신자가 하나의 전송자로부터 다수의 데이터그램을 임의의 순서로 수신할 수도 있기 때문이다. 그럼에도 불구하고 UDP는 TCP보다 속도가 빠른데, 데이터를 전송하기 전에 연결을 요구하지 때문이다. 일반적인 UDP의 사용으로는, 클라이언트가 서버에게 그들의 상태를 알려주어야 하는 서버 기반의 소셜 애플리케이션에서 사용되는 "heart-beating"을 들 수 있겠다 (예: Requesting interactions, 또는 Invisible).


본 장의 나머지 부분에서는 TCP 소켓에 전적으로 집중하여 살펴볼 것이다. 가장 먼저 클라이언트 소켓의 생성, 서버로 연결, 데이터 교환, 연결 중단에 관한 방법을 보여줄 것이다. 이러한 라이프사이클은 웹 서버와 상호작용하기 위해 클라이언트 소켓을 이용하는 예제를 통해 설명할 것이다. 다음으로 4.3절은 서버 소켓을 소개한다. 서버 소켓의 라이프사이클, 그리고 이를 이용해 동시연결을 처리 가능한 서버를 구현하는 방법을 설명하겠다. 마지막으로 4.4절에서는 소켓 스트림을 소개하겠다. 그리고 클라이언트와 서버 측에서 소켓 스트림의 사용을 설명함으로써 그들의 이점을 개략적으로 설명하겠다.


TCP 클라이언트

우리는 TCP 클라이언트를 다른 애플리케이션, 즉 서버와 데이터를 교환하기 위해 TCP 연결을 시작하는 애플리케이션이라고 부른다. 클라이언트와 서버는 다른 언어로 개발될 수 있음을 언급하는 것이 중요하겠다. Pharo에서 그러한 클라이언트의 라이프사이클은 4가지 단계로 이루어진다:

  1. TCP 소켓을 생성한다.
  2. 소켓을 서버로 연결한다.
  3. 소켓을 통해 서버와 데이터를 교환한다.
  4. 소켓을 닫는다.


TCP 소켓 생성하기

Pharo는 단일 소켓 클래스를 제공한다. 여기에는 소켓 타입(TCP 또는 UDP)별로 하나의 생성 메서드가 있다. TCP 소켓을 생성하기 위해서는 아래와 같은 표현식을 평가할 필요가 있겠다:

Socket newTCP


TCP 소켓을 어떤 서버로 연결하기

TCP 소켓을 서버로 연결하기 위해서는 해당 서버의 IP 주소를 나타내는 객체를 가질 필요가 있다. 해당 주소는 SocketAddress의 인스턴스이다. 이를 손쉽게 생성하는 방법은 IP 스타일 네트워크명 검색 및 해석 기능을 제공하는 NetNameResolver를 사용하는 것이다.


스크립트 4.1는 소켓 주소 생성의 두 가지 예제를 제공한다. 첫 번째는 서버명을 설명하는 문자열로부터('www.esug.org') 주소를 생성하는 반면, 나머지 하나는 서버의 IP 주소를 나타내는 문자열로부터 ('127.0.0.1') 생성한다. NetNameResolver를 사용하기 위해서는 DNS[2]를 이용해 네트워크로 연결된 머신이 존재해야 함을 명심한다. 유일한 예외는 로컬 호스트 주소를 검색하는 경우인데, 자신의 소프트웨어(본문에서는 Pharo)를 실행시키는 머신을 참조하는 일반 주소인 127.0.0.1를 예로 들 수 있겠다.


스크립트 4.1: 소켓 주소 생성하기

| esugAddress localAddress |
esugAddress := NetNameResolver addressForName: 'www.esug.org'.
localAddress := NetNameResolver addressForName: '127.0.0.1'.


이제 스크립트 4.2와 같이 우리 TCP 소켓을 서버로 연결할 수 있다. connectTo:port: 메시지는 매개변수로서 제공된 포트와 서버 주소를 이용해 서버로의 소켓 연결을 시도한다. 서버 주소는 서버가 사용하는 네트워크 인터페이스(예: 이더넷, wifi)의 주소를 나타낸다. 포트는 네트워크 인터페이스 상의 통신 끝점을 의미한다. 각 네트워크 인터페이스는 각 IP 전송 프로토콜마다 (예: TCP, UDP) 0부터 65535까지 숫자로 매겨진 포트의 집합체를 갖고 있다. 주어진 프로토콜에서 인터페이스 상의 포트 번호는 단일 프로세스만이 사용할 수 있다.


스크립트 4.2: TCP 소켓을 ESUG 서버로 연결하기

| clientSocket serverAddress |
clientSocket := Socket newTCP.
serverAddress := NetNameResolver addressForName: 'www.esug.org'.
clientSocket
    connectTo: serverAddress port: 80;
    waitForConnectionFor: 10.
clientSocket isConnected
     true


connectTo:port: 메시지는 소켓을 연결하기 위한 요청을 (프리미티브 콜을 통해) 시스템으로 발행한 직후 리턴한다. waitForConnectionFor: 10 메시지는 소켓이 서버에 연결될 때까지 현재 프로세스를 연기한다. 이는 매개변수가 요청한 바와 같이 거의 10 초를 기다린다. 소켓이 10초 이후에도 연결되지 않은 경우 ConnectionTimedOut 예외가 시그널링된다. 그 외의 경우는 true를 응답하는 표현식 clientSociet isConnected를 평가함으로써 실행을 진행할 수 있다.


서버와 데이터 교환하기

연결이 구축되고 나면 클라이언트는 서버와 ByteString의 인스턴스를 교환(전송/수신)할 수 있다. 보통 클라이언트는 서버로 일부 요청을 전송한 후에 응답을 예상한다. 웹 브라우저가 이러한 스키마에 따라 행동하는데, 웹 브라우저는 URL에 의해 식별된 어떤 웹 서버로 요청을 보내는 클라이언트다. 그러한 요청은 서버 상에서 보통 html 파일이나 사진(picture)과 같은 리소스로의 경로에 해당한다. 이후 브라우저는 서버 응답을 기다린다 (예: html 코드, 사진 바이트 수).


스크립트 4.3: TCP 소켓을 통해 서버와 데이터 교환하기

| clientSocket data |
... "create and connect the TCP clientSocket"
clientSocket sendData: 'Hello server'.
data := clientSocket receiveData.
... "Process data"


스크립트 4.3은 클라이언트 소켓을 통해 데이터를 전송하고 수신하는 프로토콜을 보여준다. 여기서는 sendData: 메시지를 이용해 서버로 'Hello server!' 문자열을 전송한다. 다음으로는, receiveData 메시지를 클라이언트 소켓으로 전송하여 답을 읽는다. 응답을 읽는 것을 블로킹(blocking)이라 부르는데, 응답이 읽힐 때 receiveData가 리턴한다는 의미다. 다음으로 data 변수의 내용이 처리된다.


스크립트 4.4: 최대 데이터 수신 시간을 바운딩하기

|clientSocket data|
... "create and connect the TCP clientSocket"
[data := clientSocket receiveDataTimeout: 5.
... "Process data"
    ] on: ConnectionTimedOut
    do: [ :timeOutException |
        self
            crLog: 'No data received!';
            crLog: 'Network connection is too slow or server is down.']


클라이언트는 receiveData를 이용함으로써 서버가 더 이상 어떤 데이터도 전송하지 않거나 연결을 종료할 때까지 기다린다는 사실을 명심하라. 다시 말해 클라이언트는 무기한으로 기다릴지도 모른다는 의미다. 이 방법이 아니라면, 스크립트 4.4에서 보이는 바와 같이 클라이언트가 너무 오랜 시간을 기다린 경우 ConnectionTimedOut 예외를 시그널링하는 대안책이 있다. 우리는 클라이언트 소켓에게 5초 간만 기다리도록 요청하기 위해 receiveDataTimeout: 메시지를 이용했다. 데이터가 그 기간 동안 수신되면 조용히 처리된다. 하지만 5초 이내에 데이터가 수신되지 않는 경우, ConnectionTimedOut이 시그널링된다. 예제에서는 어떤 일이 일어났는지 설명을 기록했다.


소켓 닫기

TCP 소켓은 양 끝의 장치들이 연결되어 있는 동안 생존한 채로 남는다. 소켓은 그것으로 close 메시지를 전송하여 닫을 수 있다. 그리고 다른 측에서 그것을 닫을 때까지는 연결된 채로 남는다. 이는 네트워크 실패 또는 다른 측이 다운(down)된 경우 무기한으로 지속될 수도 있다. 이것이 바로 소켓이 destroy 메시지를 수락하는 이유인데, 해당 메시지는 소켓이 요구하는 시스템 리소스를 해제시킨다.


실제로는 closeAndDestroy를 사용한다. 먼저 이는 close 메시지를 전송하여 소켓 끄기를 시도한다. 20초 가 지나서도 소켓이 연결되어 있으면 소켓은 파괴(destroy)된다. closeAndDestroy: seconds 라는 변형체(variant)도 있는데 이는 소켓이 파괴되기 전까지 기간을 매개변수로서 취함을 기억하라.


스크립트 4.5: Web Site 및 Cleanup과 상호작용

| clientSocket serverAddress httpQuery htmlText |
httpQuery := 'GET / HTTP/1.1', String crlf,
    'Host: www.esug.org:80', String crlf,
    'Accept: text/html', String crlfcrlf.
serverAddress := NetNameResolver addressForName: 'www.esug.org'.
clientSocket := Socket newTCP.
[ clientSocket
    connectTo: serverAddress port: 80;
    waitForConnectionFor: 10.
clientSocket sendData: httpQuery.
htmlText := clientSocket receiveDataTimeout: 5.
htmlText crLog ] ensure: [clientSocket closeAndDestroy].


위에서 설명한 단계를 요약하기 위해 스크립트 4.5의 서버로부터 웹 페이지를 가져오는 예제를 이용했다. 첫째, HTTP[3]쿼리를 가상으로 만들었다. 쿼리에 해당하는 문자열은 GET 키워드로 시작해 서버의 루트 파일을 요청하고 있음을 알리는 슬래시가 추가되었다. 프로토콜 버전 HTTP/1.1을 따른다. 두 번째 행은 웹 서버와 그 포트의 이름을 포함한다. HTTP 쿼리의 3, 4행은 우리 클라이언트가 수락한 포맷을 나타낸다. 우리는 쿼리의 결과를 Transcript 상에 표시할 것이기 때문에 HTTP 쿼리에 (Accept: 로 시작되는 행 참조) 클라이언트가 html 포맷으로 된 텍스트를 수락함을 명시하였다.


다음으로, www.esug.org 서버의 IP 주소를 검색하였다. 이후 TCP 소켓을 생성하여 서버로 연결하였다. 이전 단계에서 얻은 IP 주소와 웹 브라우저에 대한 기본 포트인 80을 이용했다. 연결은 10초 이내에 구축될 것이며 (waitForConnectionFor: 10), 그렇지 않은 경우 ConnectionTimedOut 예외를 받을 것이다.


http 쿼리를 전송하고 나면 (clientSocket sendData: httpQuery) 우리가 표시하는 수신된 html 텍스트를 소켓으로부터 읽는다. 소켓으로 하여금 서버의 응답을 5초 간 기다리도록 요청함을 명심하라 (clientSocketreceiveDataTimeout: 5). 시간이 만료되면 소켓은 빈 소켓을 응답한다.


마지막으로, 소켓을 닫고 연관된 리소스를 해제시킨다 (clientSocketcloseAndDestroy). 소켓의 연결과 웹 서버와의 데이터 교환을 실행하는 블록으로 ensure: 메시지를 전송시켜 clean up을 확보한다.


TCP 서버

이제 간단한 TCP 서버를 구축해보자. TCP 서버는 TCP 클라이언트들로부터 TCP 연결을 대기하는 애플리케이션이다. 연결이 구축되고 나면 서버와 클라이언트 모두 어떤 순서로든 수신 데이터를 전송할 수 있다. 서버와 클라이언트에 큰 차이점이 있다면 서버는 최소 두 개의 소켓을 사용한다는 점이다. 클라이언트 연결을 처리하는 데에 하나의 소켓이 사용되고, 나머지 하나는 특정 클라이언트와 데이터를 교환하는 데에 사용된다.


TCP 소켓 서버 라이프사이클

TCP 서버의 라이프사이클은 다섯 단계로 나뉜다:

  1. connectionSocket 으로 표시된 첫 번째 TCP 소켓을 생성한다.
  2. connectionSocket 으로 하여금 포트를 듣도록 만들어 연결을 기다린다.
  3. 연결을 위한 클라이언트 요청을 수락한다. 그 결과 connectionSocket 은 interactionSocket 이라는 두 번째 소켓을 빌드할 것이다.
  4. interactionSocket를 통해 클라이언트와 데이터를 교환한다. 그 동안 connectionSocket 은 새 연결을 계속해서 기다리는 것이 가능하며, 다른 클라이언트들과 데이터를 교환하기 위해 새 소켓을 생성할지도 모른다.
  5. interactionSocket을 닫는다.
  6. 서버를 끝내고(kill) 클라이언트 연결의 수락을 중단하기로 결정했다면 connectionSocket 을 닫는다.


그림 4.2: 다중 클라이언트를 동시에 지원(server)하는 소켓 서버


이러한 라이프사이클의 동시 실행은 그림 4.2에 잘 표시되어 있다. 서버는 connectionSocket 를 통해 들어오는 클라이언트 연결 요청을 듣는데, 어쩌면 다수의 interactionSockets (클라이언트별로 하나)를 통해 다중 클라이언트와 데이터를 교환할지도 모른다. 아래에서는 소켓 서빙 기계(socket serving machinery)를 설명하겠다. 그 다음으로 완전한 서버 클래스를 묘사하고, 서버 라이프사이클 및 관련된 동시성 문제를 설명하고자 한다.


Serving Basic 예제

우리는 단일 클라이언트 요청을 수락하는 에코(echo) TCP 서버의 단순한 예제를 통해 serving basics를 설명하고자 한다. 이는 그것이 수신한 데이터가 무엇이든 클라이언트에게 다시 전송하고 종료한다. 스크립트 4.6에서 코드를 제공한다.


스크립트 4.6: 기본 에코(Echo) 서버.

| connectionSocket interactionSocket receivedData |
"Prepare socket for handling client connection requests"
connectionSocket := Socket newTCP.
connectionSocket listenOn: 9999 backlogSize: 10.

"Build a new socket for interaction with a client which connection request is accepted"
interactionSocket := connectionSocket waitForAcceptFor: 60.

"Get rid of the connection socket since it is useless for the rest of this example"
connectionSocket closeAndDestroy.

"Get and display data from the client"
receivedData := interactionSocket receiveData.
receivedData crLog.

"Send echo back to client and finish interaction"
interactionSocket sendData: 'ECHO: ', receivedData.
interactionSocket closeAndDestroy.


첫째, 들어오는 연결을 처리하는 데에 사용할 소켓을 생성한다. 소켓이 9999 포트를 듣도록 구성한다. backlogSize는 10으로 설정되는데, 운영체제에게 버퍼를 10회의 연결 요청만큼 할당하도록 요청함을 의미한다. 이러한 백로그는 사실상 우리 예제에선 사용되지 않을 것이다. 하지만 좀 더 현실적인 서버가 다중 연결을 처리한 후 대기 중인 연결 요청은 백로그로 보관해야 할 것이다.


연결 소켓(connectionSocket 변수에 의해 참조되는)이 준비되면 클라이언트 연결을 듣기 시작한다. WaitForAcceptFor: 60 메시지는 소켓이 60초간 연결을 기다리도록 만든다. 60초 동안 어떤 클라이언트도 연결을 시도하지 않을 경우, 메시지는 nil을 응답한다. 그 외의 경우 클라이언트의 소켓으로 연결된 새 소켓 interactionSocket을 얻는다. 이 시점에서 우리는 더 이상 연결 소켓이 필요하지 않으므로 닫을 수 있다 (connectionSocket closeAndDestroy 메시지).


상호작용 소켓은 이미 클라이언트로 연결되어 있으므로 이를 이용해 데이터를 교환할 수 있다. 위에 소개된 receiveData와 sendData: 메시지를 (4.2절 참고) 이용하면 되겠다. 우리 예제에서는 클라이언트로부터 데이터를 기다린 다음 Transcript 에 표시하고, 마지막으로 다시 클라이언트에 보내는데 'ECHO' 문자열을 앞에 붙인다. 끝으로 상호작용 소켓을 닫음으로써 클라이언트와 상호작용을 끝낸다.


스크립트 4.6의 서버를 테스트하는 옵션으로 여러 가지가 있다. 첫 번째 간단한 옵션은 4.5절에 논한 nc (netcat) 유틸리티를 사용하는 것이다. 먼저 워크스페이스에서 서버 스크립트를 실행한다. 다음으로 단말기에서 아래 명령행을 평가한다:

echo "Hello Pharo" | nc localhost 9999


그 결과 Pharo 이미지의 Transcript 상에 아래와 같은 행이 표시될 것이다:

Hello Pharo


클라이언트 측, 다시 말해 단말기에는 다음이 표시될 것이다:

ECHO: Hello Pharo


순수한 Pharo 대안책은 두 가지 다른 이미지의 사용에 의존한다: 하나는 서버 코드를 실행하는 이미지, 나머지 하나는 클라이언트 코드를 실행하는 이미지다. 사실 우리 예제는 사용자 상호작용 프로세스 내에서 실행되기 때문에 Pharo UI는 waitForAcceptFor: 도중이라든가 어떤 특정 시점에 freeze될 것이다. 스크립트 4.7은 클라이언트 이미지에서 실행될 코드를 제공한다. 서버 코드를 먼저 실행해야 함을 주목하라. 이를 어길 경우 클라이언트는 실패할 것이다. 상호작용 이후에는 클라이언트와 서버 모두 종료된다는 사실도 명심하라. 따라서 예제를 두 번째로 실행하길 원한다면 클라이언트와 서버 측 모두에서 실행해야 할 것이다.


스크립트 4.7: 에코 클라이언트.

| clientSocket serverAddress echoString |
serverAddress := NetNameResolver addressForName:'127.0.0.1'.
clientSocket := Socket newTCP.
[ clientSocket
        connectTo: serverAddress port: 9999;
        waitForConnectionFor: 10.
    clientSocket sendData: 'Hello Pharo!'.
    echoString := clientSocket receiveDataTimeout: 5.
    echoString crLog.
] ensure: [ clientSocket closeAndDestroy ].


에코 서버 클래스

여기서는 동시성 문제를 다루는 EchoServer 클래스를 정의한다. 이 클래스는 동시적 클라이언트 쿼리를 처리하고, UI를 freeze시키지 않는다. 그림 4.3은 EchoServer가 두 개의 클라이언트를 처리하는 방법의 예를 보여준다.

그림 4.3: 두 개의 클라이언트를 동시에 서빙(serving)하는 에코 서버


클래스 4.8에 라벨로 표시된 정의에서 볼 수 있듯이 EchoServer는 세 개의 인스턴스 변수를 선언한다. 첫 번째(connectionSocket)는 클라이언트 연결을 듣는 데에 사용되는 소켓을 참조하고, 나머지 두 개의 인스턴스 변수(isRunning은 부울을, isRunningLock은 Mutex를 보유) 동기화 문제를 처리하는 동시 서버 프로세스 라이프사이클을 관리하는 데에 사용된다.


클래스 4.8: EchoServer 클래스 정의

Object subclass: #EchoServer
    instanceVariableNames: 'connectionSocket isRunning isRunningLock'
    classVariableNames: ''
    poolDictionaries: ''
    category: 'SimpleSocketServer'


isRunning 인스턴스 변수는 서빙이 실행되는 동안에 true로 설정되는 플래그다. 아래에서 알 수 있듯이 여러 프로세스에 의해 접근이 가능하다. 따라서 동시적 쓰기 접근이 존재하더라도 플래그에 대한 읽기 접근이 일관된 값을 얻도록 보장할 필요가 있겠다. 이는 isRunning이 한 번에 하나의 프로세스에 의해서만 접근되도록 보장하는 lock(isRunningLock 인스턴스 변수)을 이용하면 되겠다.


메서드 4.9: EchoServer>>is Running 읽기 접근자

EchoServer>>isRunning
    ^ isRunningLock critical: [ isRunning ]


메서드 4.10: EchoServer>>isRunning: 쓰기 접근자

EchoServer>>isRunning: aBoolean
    isRunningLock critical: [ isRunning := aBoolean ]


플래그로의 접근은 접근자 메서드를 통해서만 가능하다 (메서드 4.9와 메서드 4.10). isRunning은 isRunningLock 으로 전송된 critical: 메시지의 인자인 블록 내부에서 읽히고 쓰인다. 이러한 lock은 Mutex의 인스턴스에 해당한다 (메서드 4.11 참조). critical: 메시지를 수신하면 mutex는 인자(블록)을 평가한다. 이러한 평가 도중에 critical: 메시지를 동일한 mutex로 전송하는 다른 프로세스들은 보류된다. 그리고 보류 메시지가 더 이상 없을 때까지 이 과정이 반복된다. 그러므로 mutex는 isRunning 플래그가 순차적으로 읽히고 쓰이도록 보장한다.


메서드 4.11: EchoServer>>initialize 메서드

EchoServer>>initialize
    super initialize.
    isRunningLock := Mutex new.
    self isRunning: false


우리 서버의 라이프사이클을 관리하기 위해 두 개의 메서드, EchoServer>>start와 EchoServer>>stop를 소개했다. 가장 간단한 형태인 EchoServer>>stop 로 시작하며, 그 정의는 메서드 4.12에서 제공한다. 이는 단순히 isRunning 플래그를 false로 설정한다. EchoServer>>serve 메서드에서 서빙 루프를 중단하는 결과를 야기할 것이다 (메서드 4.13 참조).


메서드 4.12: EchoServer>>stop 메서드

EchoServer>>stop
    self isRunning: false


메서드 4.13: EchoServer>>serve 메서드

EchoServer>>serve
    [ [ self isRunning ]
        whileTrue: [ self interactOnConnection ] ]
        ensure: [ connectionSocket closeAndDestroy ]


서빙 프로세스의 활동은 serve 메서드에서 구현된다 (메서드 4.13 참조). 이것은 isRunning 플래그가 true인 동안 연결 상의 클라이언트와 상호작용한다. stop 이후에 서빙 프로세스는 연결 소켓을 파괴함으로써 종료된다. ensure: 메시지는 서빙 프로세스가 이례적으로 종료되더라도 파괴가 실행되도록 보장하는 역할을 한다. 그러한 종료는 예외(예: 네트워크 연결 끊김) 또는 사용자 액션(예: 프로세스 브라우저를 통해) 때문에 발생할 수도 있다.


메서드 4.14: EchoServer>>start 메서드

EchoServer>>start
    isRunningLock critical: [
        self isRunning ifTrue: [ ^ self ].
        self isRunning: true].
    connectionSocket := Socket newTCP.
    connectionSocket listenOn: 9999 backlogSize: 10.
    [ self serve ] fork


서빙 프로세스의 생성은 EchoServer>>start 가 책임진다 (메서드 4.14 마지막 행 참조). EchoServer>>start 메서드는 먼저 서버가 이미 실행되고 있는지를 확인한다. isRunning 플래그가 true로 설정된 경우 리턴하고, 그 외의 경우 연결 처리에 전념하는 TCP 소켓이 생성되어 포트 9999를 듣도록 만들어진다. 백로그 크기는 위에서 언급한 바와 같이 10으로 설정되므로, 시스템은 보류 중인 클라이언트 연결 요청을 10개까지 보관하도록 버퍼를 할당한다. 해당 값은 서버의 속도와 (VM과 하드웨어에 따라 좌우) 클라이언트 연결 요청의 최대 속도에 따라 좌우되는 상반관계(trade-off)에 해당한다. 백로그 크기는 어떤 연결 요청의 손실도 피하기에 충분히 커야 하지만 메모리의 낭비를 피하려면 너무 커서는 안 된다. 마지막으로 EchoServer>>start 메서드는 [self serve] 블록으로 fork 메시지를 전송함으로써 프로세스를 생성한다. 생성된 프로세스는 생성자(creator) 프로세스(예: 워크스페이스로부터 실행한 경우 UI 프로세스, EchoServer>>start 메서드를 실행하는 프로세스)와 우선순위가 동일하다.


메서드 4.15: EchoServer>>interactOnConnection 메서드

EchoServer>>interactOnConnection
    | interactionSocket |
    interactionSocket := connectionSocket waitForAcceptFor: 1 ifTimedOut: [^self].
    [self interactUsing: interactionSocket] fork


EchoServer>>serve 메서드(메서드 4.13 참조)는 연결된 클라이언트와 상호작용을 트리거한다. 이러한 상호작용은 EchoServer>>interactOnConnection 메서드(메서드 4.15 참조)에서 처리된다. 첫째로, 연결 소켓은 1초간 클라이언트 연결을 기다린다. 해당 시간 동안 어떤 클라이언트도 연결을 시도하지 않는 경우 단순히 리턴한다. 그 외의 경우 상호작용에 참여하는 다른 소켓을 결과로 받는다. 다른 클라이언트 연결 요청을 처리하기 위해 상호작용이 다른 프로세스에서 실행되므로 마지막 행에 fork가 포함된다.


메서드 4.16: EchoServer>>interactUsing: 메서드

EchoServer>>interactUsing: interactionSocket
    | receivedData |
    [ receivedData := interactionSocket receiveDataTimeout: 5.
        receivedData crLog.
        interactionSocket sendData: 'ECHO: ', receivedData
    ] ensure: [
        interactionSocket closeAndDestroy ]


EchoServer>>interactUsing: 메서드(메서드 4.16 참조)에서 구현된 바와 같이 클라이언트와의 상호작용은 클라이언트에 의해 제공된 데이터를 읽고 앞에 'ECHO:' 문자열을 붙여 다시 전송하는 것을 핵심으로 한다. 데이터를 교환했든 하지 않았든 (시간 만료) 상호작용 소켓이 파괴되도록 보장한다는 사실을 인지하는 것이 중요하다.


SocketStream

SocketStream은 TCP 소켓을 캡슐화하는 읽기-쓰기 스트림으로서, 기능 메서드 집합과 함께 버퍼링을 제공함으로써 데이터 교환을 수월하게 해준다. 이는 Socket 상에서 사용하기 쉬운 API 를 제공한다.

그림 4.4: SocketStream은 Socket을 수월한 방식으로 사용하도록 해준다.


SocketStream은 SocketStream class>>openConnectionToHost:port: 메서드를 이용해 생성이 가능하다. 호스트 주소나 이름, 그리고 포트를 제공함으로써 시스템의 기본 매개변수로 새 Socket을 초기화한다. 하지만 SocketStream class>>on: 메서드로 기존의 Socket 위에 SocketStream을 빌드할 수도 있는데, 이 방법을 선택 시 당신이 이미 설정한 데이터를 소켓으로 전송할 수 있다.


새 SocketStream을 이용해 유용한 메서드들이 있는 데이터를 수신할 수 있는데, 시간만료까지 데이터를 기다리는 receiveData, 데이터를 수신하지만 도달할 때까지 더 이상 기다리지는 않는 receiveAvailableData를 들 수 있겠다. IsDataAvailable 메서드는 데이터를 수신하기 전에 스트림 상에서 이용 가능한지 확인하도록 해준다.


데이터를 스트림에 두는 nextPut:, nextPutAll:, 또는 nextPutAllFlush: 메서드를 이용해 데이터를 전송하는 수도 있다. nextPutAllFlush: 메서드는 스트림에 데이터를 넣기 전에 다른 보류 중인 데이터를 비운다.


마지막으로 SocketStream이 사용을 완료하면 관련된 소켓을 완료하고 닫도록 close 메시지를 전송한다. SocketStream의 다른 유용한 메서드들은 아래에서 설명하겠다.


클래스 측에서의 SocketStream

아래 코드 조각을 이용해 클라이언트 측에서 소켓 스트림의 사용을 설명하겠다 (스크립트 4.17). 소켓 스트림을 이용해 웹페이지 첫 행을 얻는 방법을 보여준다.


스크립트 4.17: SocketStream을 이용해 웹 페이지의 첫 행을 얻기

| stream httpQuery result |
stream := SocketStream
    openConnectionToHostNamed: 'www.pharo-project.org'
    port: 80.
httpQuery := 'GET / HTTP/1.1', String crlf,
    'Host: www.pharo-project.org:80', String crlf,
    'Accept: text/html', String crlf.
[ stream sendCommand: httpQuery.
stream nextLine crLog ] ensure: [ stream close ]


첫 행은 새로 생성되어 제공된 서버로 연결된 소켓을 캡슐화하는 스트림을 생성한다. 이는 openConnectionToHostNamed:port: 메시지의 책임이다. 해당 메시지는 서버와 연결이 구축될 때까지 실행을 연기시킨다. 서버가 응답하지 않으면 소켓 스트림이 ConnectionTimedOut 예외를 시그널링한다. 이 예제는 사실 기본이 되는 소켓에 의해 시그널링된다. 기본 timeout delay는 45초다 (Socket class>>standardTimeout 메서드에서 정의됨). SocketStream>>timeout: 메서드를 이용해 다른 값을 선택할 수도 있다.


소켓 스트림이 서버로 연결되고 나면 HTTP GET 쿼리를 위조하여 전송한다. 스크립트 4.5(36 페이지)에 비교할 때 이번에는 (스크립트 4.17) 마지막 String crlf 를 건너뛰는데, SocketStream>>sendCommand: 메서드가 행의 끝을 표시하기 위해 전송된 데이터 다음에 CR과 LF 문자를 자동으로 삽입하기 때문이다.


요청한 웹 페이지의 수신은 우리의 소켓 스트림으로 nextLine 메시지를 전송하면 트리거된다. 데이터가 수신될 때까지 몇 초간 기다릴 것이다. 이후 데이터가 transcript에 표시된다. 연결이 안전하게 닫히도록 확보하였다.


이번 예제에서는 서버가 전송하는 응답의 첫 행만 표시했다. 하지만 소켓 스트림으로 upToEnd 메시지를 전송함으로써 html 코드를 포함해 전체 응답을 쉽게 표시할 수도 있다. 하지만 하나의 행을 표시할 때와 비교하면 좀 더 기다려야 함을 명심하라.


서버 측에서의 SocketStream

SocketStreams는 스크립트 4.18에 표시된 바와 같이 상호작용 소켓을 감싸기(wrap) 위해 서버 측에서 사용되기도 한다.


스크립트 4.18: SocketStream을 이용한 간단한 서버.

| connectionSocket interactionSocket interactionStream |
connectionSocket := Socket newTCP.
[
    connectionSocket listenOn: 12345 backlogSize: 10.
    interactionSocket := connectionSocket waitForAcceptFor: 30.
    interactionStream := SocketStream on: interactionSocket.
    interactionStream sendCommand: 'Greetings from Pharo Server'.
    interactionStream nextLine crLog.
] ensure: [
    connectionSocket closeAndDestroy.
    interactionStream ifNotNil: [interactionStream close]
]


소켓 스트림에 의존하는 서버는 여전히 소켓을 이용해 들어오는 연결 요청을 처리한다. 소켓 스트림은 클라이언트와의 상호작용을 위해 소켓이 생성되어야 실행된다. 소켓은 소켓 스트림에 래핑(wrap)되어 sendCommand: 또는 nextLine과 같은 메시지를 이용한 데이터 교환을 용이하게 만든다. 여기까지 완료되면 연결을 처리하는 소켓을 닫고 파괴하며, 상호작용 소켓 스트림을 닫는다. 상호작용 소켓 스트림은 기본이 되는 상호작용 소켓을 닫고 파괴하는 작업을 책임질 것이다.


Binary 모드 vs. Ascii 모드

교환된 데이터는 바이트 또는 문자로서 취급될 수 있다. 소켓 스트림이 binary를 이용해 바이트를 교환하도록 구성되면 바이트 배열과 같은 데이터를 전송 및 수신한다. 반대로 소켓 스트림이 acsii 메시지를 이용해 문자를 교환하도록 (기본 설정) 구성되면 데이터를 String으로 전송 및 수신한다.


아래 표현식을 이용해 시작되는 EchoServer(4.3 절 참고)의 인스턴스를 갖고 있다고 가정해보자.

server := EchoServer new.
server start.


소켓 스트림의 기본 행위는 전송과 수신 시 ascii 문자열을 처리하는 것이다. Binary 모드에서 행위는 스크립트 4.19에서 소개하였다. nextPutAllFlush: 메시지는 바이트 배열을 인자로서 수신한다. 이는 모든 바이트를 버퍼로 넣은 직후 전송을 트리거한다 (따라서 메시지명에 Flush가 포함된다). upToEnd 메시지는 서버가 다시 전송한 모든 바이트와 함께 배열을 응답한다. 이러한 메시지는 서버와의 연결이 닫힐 때까지 차단(block)함을 주목하라.


스크립트 4.19: 바이너리 모드에서 상호작용하는 SocketStream

interactionStream := SocketStream
46 Sockets
                        openConnectionToHostNamed: 'localhost'
                        port: 9999.
interactionStream binary.
interactionStream nextPutAllFlush: #[65 66 67].
interactionStream upToEnd.


클라이언트가 문자열(ascii 모드)을 관리하는지 혹은 바이트 배열(binary 모드)이 서버에 어떤 영향도 미치지 않는지 주목하라. 아스키 모드에서는 사실상 소켓 스트림이 ByteString의 인스턴스를 처리한다. 따라서 각 문자는 단일 바이트로 매핑한다.


데이터 구분하기(Delimiting Data)

SocketStream 은 그저 일부 네트워크에 대한 게이트웨이의 역할을 한다. 이는 어떤 구문도 제공하지 않고, 바이트를 전송하거나 읽는다. 교환된 데이터의 의미와 구성에 해당하는 구문(semantics)은 다른 객체에 의해 처리되어야 한다. 개발자들은 올바른 상호작용을 갖기 위해 사용할 프로토콜을 결정하고 상호작용의 양측에 강요해야 한다.


이를 위한 좋은 연습은 프로토콜을 구체화하는 것으로, 소켓 스트림을 감싸는 객체로서 구체화함을 의미한다. 프로토콜 객체는 교환된 데이터를 분석한 후 그에 따라 소켓 스트림에 보낼 메시지를 결정한다. 여느 대화든 그에 관여된 엔티티는 데이터를 바이트나 문자의 시퀀스로 정의하는 프로토콜이 필요하다. 전송자는 이러한 구성(organization)을 준수하여 수신자가 수신된 바이트 시퀀스로부터 유효한 데이터를 추출하도록 허용해야 한다.


한 가지 가능한 해결책은 각 데이터에 해당하는 바이트 또는 문자 사이에 삽입된 구분자(delimiter) 집합을 갖는 방법이다. 구분자의 예제로 ASCII 문자 CR과 LF의 시퀀스를 들 수 있다. 이 시퀀스는 SocketStream 클래스의 개발자들이 sendCommand: 메시지를 도입하던 때 유용하다고 간주된다. 해당 메서드(스크립트 4.5에 설명된)는 전송된 데이터 다음에 CR와 LF를 덧붙인다. CR 다음에 LF를 읽으면 수신자는 수신된 문자의 시퀀스가 완전하며 유효한 데이터로 안전하게 변환할 수 있음을 안다. 기능 메서드 nextLine(스크립트 4.17에 설명)은 CR+LF 시퀀스가 수신될 때까지 읽기를 실행하도록 SocketStream에 의해 구현된다. 하지만 어떤 문자나 바이트든 구분자로 사용 가능하다. 실제로 우리는 upTo: 메시지를 이용해 특정 문자/바이트까지 포함하는 문자/바이트를 모두 읽도록 소켓 스트림에게 요청할 수도 있다.


구분자를 사용하는 이점은 임의의 크기로 된 데이터를 처리한다는 데에 있다. 반면 한계를 찾기 위해 수신된 바이트나 문자를 분석할 필요가 있으므로 자원을 소모한다는 단점이 있다. 대안적 접근법으로, 고정된 크기의 chunk로 조직된 바이트 또는 문자를 교환하는 방법이 있다. 이러한 접근법은 보통 오디오나 비디오 컨텐트(content)의 스트리밍에 사용된다.


스크립트 4.20: 데이터를 chunk로 전송하는 컨텐트 스트리밍 소스

interactionStream := "create an instance of SocketStream".
contentFile := FileStream fileNamed: '/Users/noury/Music/mySong.mp3'.
contentFile binary.
content := contentFile upToEnd.
chunkSize := 3000.
chunkStartIndex := 1.
[chunkStartIndex < content size] whileTrue: [
    interactionStream next: chunkSize putAll: content startingAt: chunkStartIndex.
    chunkStartIndex := chunkStartIndex + chunkSize.
]
interactionStream flush.


스크립트 4.20은 mp3 파일을 스트리밍하는 스크립트의 예제이다. 먼저 바이너리(mp3) 파일을 열고 upToEnd: 메시지를 이용해 그 내용을 모두 검색한다. 이후 루프를 실행하여 데이터를 3000 바이트의 chunk로 전송한다. 우리는 세 개의 인자를 취하는 next:putAll:startingAt: 메시지에 의존하는데, 세 가지 인자는 데이터 chunk의 크기(문자 또는 바이트 수), 데이터 소스(시퀀스 가능 컬렉션), chunk의 첫 번째 요소에 대한 색인이 해당한다. 해당 예제에서 우리는 컨텐트 컬렉션의 크기가 chunk 크기의 배수라고 가정한다. 물론 실제 배경에서 이러한 가정은 유지되지 않으며, chunk보다 작은 데이터의 마지막 부분을 처리해야 할 것이다. 이에 가능한 해결책으로는 누락된 바이트를 zero로 대체하는 방법이 있다. 게다가 메모리 내 모든 것을 먼저 로딩하는 방법은 실용적인 해결책이 아니며, 스트리밍 접근법이 대신 주로 사용된다.


스크립트 4.21: SocketStream을 이용해 데이터를 chunk로 읽기

| interactionStream chunkSize chunk |
interactionStream := SocketStream
                        openConnectionToHostNamed: 'localhost'
                        port: 9999.
interactionStream isDataAvailable ifFalse: [(Delay forMilliseconds: 100) wait].
chunkSize := 5.
[interactionStream isDataAvailable] whileTrue: [
    chunk := interactionStream next: chunkSize.
    chunk crLog.
].
interactionStream close.
'DONE' crLog.


데이터를 chunk로 읽기 위해 SocketStream은 스크립트 4.21에 설명된 바와 같이 next: 메시지에 응답한다. 우리는 우리 머신의 포트 9999에서 크기가 5의 배수인 문자열을 전송하는 서버가 실행되고 있다고 간주한다. 연결 직후부터 데이터가 수신될 때까지 100 밀리초를 기다린다. 이후 5개 문자의 chunk로 데이터를 읽어와 Transcript 상에 표시한다. 따라서, 서버가 10개 문자로 된 'HelloWorld' 문자열을 전송할 경우, Transcript 상에서 첫 행에 Hello가, 두 번째 행에 World가 표시될 것이다.


네트워킹 실험을 위한 조언

클라이언트 측의 소켓과 소켓 스트림에 관련된 여러 절에서 우리는 웹 서버와의 상호작용을 예로 들었다. 따라서 HTTP Get 쿼리를 가상으로 만들어 서버로 전송한다. 간단하고 플랫폼에 구애 받지 않는(flatform agnostic; 크로스 플랫폼을 의미) 실험을 위해 이러한 예제를 선택하였다. 실제 규모의 애플리케이션에서 HTTP를 수반하는 상호작용은, 기본 Pharo 배포판에 속한 Zinc HTTP Client/Server 라이브러리와 같이 더 높은 수준의 라이브러리를 이용해 코딩되어야 한다[4].


네트워크 프로그래밍에서는 복잡성이 쉽게 증가한다. Pharo 외부의 툴박스를 이용해 이상 행위의 근원을 확인해야 하는 경우가 종종 있다. 이번 절에서는 저수준 네트워크 운용을 다루기 위한 수많은 Unix 유틸리티를 열거하겠다. Unix 머신(Linux, Mac OS X) 또는 Cygwin(Windows)를 이용하는 독자들은 nc(또는 netcat), netstat, lsof를 테스트에 이용할 수 있다.


nc (netcat)

nc는 TCP (기본 프로토콜)과 UDP 모두를 위한 클라이언트 또는 서버를 설정하도록 해준다. 이는 stdin의 내용을 상대측으로 재전송한다. 아래는 포트 9090을 듣는 로컬 머신 상에서 서버로 'Hello from a client'를 전송하는 방법을 보여준다.

echo Hello from a client | nc 127.0.0.1 9090


아래 명령행은 포트 9090을 듣고, 연결할 첫 번째 클라이언트에게 'Hi from server'를 전송하는 서버를 시작시킨다. 이는 상호작용 후 종료된다.

echo Hi from server | nc -l 9090


옵션 -k를 이용해 서버의 실행을 유지하는 수도 있다. 하지만 앞에 붙은 echo 문자가 생성한 문자열은 연결해야 할 첫 번째 클라이언트에게만 전송된다. 아니면, nc 서버가 당신이 텍스트를 입력하는 동안 텍스트를 전송하도록 만드는 대안적 방법이 있다. 아래 명령행을 평가하기만 하면 된다:

echo nc -lk 9090


서버를 시작한 단말기에 텍스트를 입력해보라. 이후 다른 단말기에서 클라이언트를 실행시켜라. 당신이 입력한 텍스트가 클라이언트 측에 표시될 것이다. 이 두 개의 행위는 (서버 측에 텍스트를 입력하고 클라이언트를 시작) 원하는 횟수만큼 반복할 수 있다.


클라이언트와 서버 간 연결을 좀 더 지속적으로 만들어 좀 더 상호작용을 증진시킬 수도 있다. 아래 명령행을 평가함으로써 클라이언트는 모든 행("Enter"로 끝나는)을 전송한다. EOF 신호(ctl-D)를 전송하면 종료될 것이다.

echo cat | nc -l 9090


netstat

위의 명령은 자신의 컴퓨터의 소켓과 네트워크 인터페이스에 관한 다양한 정보를 제공한다. 이것은 유용한 정보를 걸러내는 데에 적절한 옵션을 사용하도록 다수의 통계를 제공한다. 아래 명령행은 tcp 소켓의 상태와 그 주소를 표시하도록 해준다. 포트 번호와 주소는 점으로 구분됨을 주목한다.

netstat -p tcp -a -n


lsof

lsof 명령은 시스템에 열린 파일을 모두 열거한다. Unix에서는 모든 것이 파일이기 때문에 소켓도 물론 포함된다. 이미 netstat가 있다면 lsof 가 왜 유용할까? 그 이유는 lsof가 프로세스와 소켓 간 링크(link)를 표시하기 때문이다. 따라서 자신의 프로그램과 연관된 소켓을 찾을 수 있는 것이다.


아래 명령행이 제공하는 예제는 TCP 소켓을 열거한다. n과 P 옵션은 강제로 lsof로 하여금 호스트 주소와 포트를 숫자로 표시하도록 만든다.

lsof -nP -i tcp


요약

본 장에서는 TCP 소켓과 소켓 스트림을 이용해 네트워크 클라이언트와 서버를 모두 개발하는 방법을 소개하였다. 그리고 네트워크 프로그래밍의 필수 도구를 검토해보았다:

  • 소켓은 Socket 클래스의 저수준 양방향 통신 게이트웨이 인스턴스다.
  • 소켓을 기반으로 한 프로그래밍은 항상 하나의 서버와 하나 또는 그 이상의 클라이언트를 수반한다.
  • 서버는 클라이언트가 보내는 요청을 기다린다.
  • sendData: 와 receiveData 메시지는 데이터를 전송하고 수신하기 위한 소켓 프리미티브다.
  • 최대 대기 시간은 receiveDataTimeout: 을 이용해 설정 가능하다.
  • SocketStream는 TCP 소켓을 캡슐화하는 buffered read-write 스트림이다.
  • 네트워크 DNS는 장치명을 숫자형 인터넷 주소로 변환하는 NetNameResolver 클래스를 통해 접근이 가능하다.
  • Unix 도 디버깅과 테스트를 목적으로 유용한 네트워킹 유틸리티를 몇 가지 제공한다.


서론에서 언급하였듯 우리는 수준이 높고 기능 메서드를 제공하는 소켓 스트림의 사용을 권한다. 그들은 각기 AidaWeb과 Seaside 웹 프레임워크에서 사용하는 Swazoo 와 Kom 웹 서버처럼 프로젝트에서 성공적으로 사용된다.


그럼에도 불구하고 만일 통신하는 이미지들에 다른 객체들이 분산되어야 하는 애플리케이션을 갖고 있다면 스켓 스트림은 여전히 저수준으로 유지된다. 그러한 소프트웨어에서는 개발자들이 인자와 결과를 직렬화함으로써 원격 객체들 간 전달되는 메시지를 처리할 필요가 있다. 뿐만 아니라 분산된 쓰레기 수집도 처리해야 한다. 객체가 원격 객체에 의해 참조되는 경우 해당 객체는 파괴되어선 안 된다. 이렇게 반복되지만 사소하지 않은 문제들은 rST[5]와 같은 객체 요구 매개자(ORB)가 해결한다. ORB는 개발자를 네트워크 문제로부터 해방시켜주므로, 원격 객체들 간에 교환되는 메시지를 이용하여 원격 통신을 표현하도록 허용한다.


Notes

  1. 근거리 통신망
  2. 도메인 네임 시스템: 기본적으로 장치명을 그 IP 주소로 매핑하는 디렉터리
  3. 웹 통신에 사용되는 하이퍼텍스트 전송 규약.
  4. http://zn.stfx.eu/zn/index.html
  5. http://smalltalkhub.com/#!/~CAR/rST/