Info

해당 section은 client-server system 간에 사용되는 통신 전략 2가지에 대해서 다룬다.

  • Socket
  • remote procedure calls(RPCs)

3.8.1 Sockets

소켓(socket)이란 통신의 끝점으로 정의할 수 있다. 통신을 하기 위해서는 각각의 프로세스소켓을 가지고 있어야한다.

소켓의 식별 방식

소켓은 IP주소와 포트(port)번호를 묶어서 식별된다.

SOCKET CreateSocket(const char* ip, const char* port) {
    SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr(ip); // IP 주소
    addr.sin_port = htons(atoi(port)); // 포트 번호
 
    // 소켓에 주소를 할당
    bind(sock, (sockaddr*)&addr, sizeof(addr));
 
    return sock;
}

예시 사용 코드

client-server 연결(connection)

일반적으로 소켓은 client-server 아키텍쳐에서 주로 사용된다. 서버는 특정한 포트에서 클라이언트의 요청을 기다린다. 클라이언트에서 연결을 요청하고 서버는 요청을 수락함으로써 연결이 완료된다.

void ServerListen() { 
	SOCKET listenSocket = CreateSocket("127.0.0.1", "8080"); 
	listen(listenSocket, SOMAXCONN); // SOMAXCONN: 소켓이 동시에 받을 수 있는 최대 연결 수 SOCKET 
 
	clientSocket = accept(listenSocket, NULL, NULL); // 클라이언트와의 연결이 수립되었습니다. 
}

서버가 특정 포트에서 클라이언트의 연결 요청을 기다리는 예시 코드


void ConnectToServer() {
    SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 서버 IP
    serverAddr.sin_port = htons(8080); // 서버 포트
 
    connect(clientSocket, (sockaddr*)&serverAddr, sizeof(serverAddr));
    // 서버에 연결되었습니다.
}

클라이언트가 서버에 연결 요청을 하는 예시

서버 & 서비스

‘SSH’, ’FTP’, ’HTTP’와 같은 서비스를 제공하는 서버는 ‘well-known 포트’에서 요청을 기다린다(listen).

well-known 포트 : 1024 이하의 ‘포트’들을 말한다. 이는 ‘standard 서비스’를 위해 주로 사용된다.

참고로 well-known 포트는 IANA라는 기관에서 관리한다. (well-known 포트에 할당된 서비스 목록)

클라이언트의 포트 할당

‘클라이언트 프로세스’가 처음 연결을 요청하면, ‘호스트(host)’는 ‘포트’를 할당한다. 이때 1024 보다 큰 ‘포트’가 할당된다.

클라이언트가 연결요청을 할 때, 클라이언트의 시스템상의 OS가 포트를 1024보다 큰 임의의 포트로 할당한다는 설명으로 보인다.

소켓의 고유성

‘클라이언트’와의 모든 연결은 고유해야 하므로, 같은 ‘서비스’에 연결된 ‘클라이언트’는 모두 다른 ‘포트’를 가져야한다.

고유성은 ‘서버 IP + 서버 포트 + 클라이언트 IP + 클라이언트 포트’의 조합으로 달성된다. (4-tuple)

소켓 통신의 저수준 특성

‘socket’은 분산 프로세스(distributed processes) 간 통신에서 일반적으로 사용되며 효율적이지만, 구조화 되지 않은(unstructured) ‘byte stream’만을 교환할 수 있다. 그렇기 때문에 ‘클라이언트’ 또는 ‘서버’에서 ‘데이터’의 구조(structure)를 부여하여 사용해야한다.

여기서 구조를 부여한다는 것을 직렬화를 수행해야하는 것을 의미한다.

3.8.2 Remote Procedure Calls (RPCs)

‘RPC’는 네트워크를 통해 다른 시스템에 있는 프로세스(서버)가 제공하는 프로시저(procedure-call)를 로컬 시스템(클라이언트)에서 호출할 수 있도록 추상화하는 메커니즘이다. 이는 IPC 메커니즘 위에서 동작하기 때문에 유사한 점이 많다.

IPC와의 비교

IPC와 달리 RPC는 별도의 시스템에서 실행되는 프로세스를 다루기 때문에, ‘메시지 기반 통신’(message-based communication) 방식을 사용해야한다.

‘IPC 메시지’와 달리, RPC 통신에서 교환되는 메시지는 잘 구조화 되어있다.

여기서 말하는 ‘메시지 기반 통신’이란게 메시지를 ‘JSON’, ‘proto’ 등과 같은 형식의 파일을 구조화한 것과 패킷을 포함하는 것들로 통신하는 프로토콜을 의미한다. gRPC를 예시로 본다면 Protocol Buffers(proto) 방식을 사용한다.

syntax = "proto3";
 
package example;
 
// 간단한 메시지 정의
message HelloRequest {
 string name = 1;
}
 
message HelloResponse {
 string greeting = 1;
}
 
// 서비스 인터페이스 정의
service Greeter {
 rpc SayHello (HelloRequest) returns (HelloResponse);
}

gRPC의 proto예시 코드

RPC 메시지 구조

각 메시지에는 listen 중인 RPC 데몬(daemon)의 주소(서버의 주소)가 지정되어 있고, 실행되어야할 함수의 식별자, 그리고 그 함수에 전달되어야할 매개변수가 포함된다.

RPC daemon은 RPC 시스템에서 요청된 프로시저의 실행을 위해서 사용되는 백그라운드 프로세스를 의미한다.

RPC 통신 과정

클라이언트는 서버에서 사용되는 ‘포트’로 메시지를 보낸다. 서버의 RPC 데몬은 메시지 데이터를 받고, 해당하는 함수를 실행한 한다. 그런 다음 함수의 출력을 별도의 메시지를 통해 클라이언트가 처음 메시지를 전송한 ‘포트’로 반환한다.

매개변수 직렬화 & stub

여기서 직렬화(serialize)란, 메모리 상에 있는 객체나 데이터 구조를 네트워크를 통해 전송 가능한 형태로 변환하는 과정을 말한다.

‘semantics’란, 원격 프로시저 호출이 마치 로컬 프로시저 호출처럼 느껴지도록 하는 전체적인 프로세스와 원리를 의미한다.

RPC에서는 직렬화, 전송 과정을 추상화하는 ‘stub’이라는 인터페이스를 사용한다.

데이터 표현의 차이 해결

stub을 호출할 때, 직렬화하는 과정을 거치게 되는데 이를 프로토콜에 맞게 변환하는 과정을 ‘Marshals’라한다.

window system에서는 MIDL(Microsoft Interface Definition Language)를 이용하여 stub을 작성한다.

데이터를 전송하는 과정에서 서로의 데이터 표현 방식에 차이가 있을 수 있는데, (big-endian, little-endian) 이를 해소하고자, RPC 시스템은 XDR(external data representation)을 전송할 수 있다.

XDR은 중립적인 형태를 가지며 수신측은 해당 데이터를 받을 경우 매개변수를 풀어내면서 자기 기종의 형태로 데이터를 바꾸어 사용한다.

호출의 의미론(semantic) - 1

호출의 의미론은 ‘최대 한번(at most once)’과 ‘정확히 한번(exactly once)’ 두 가지로 나눌 수 있다.

로컬 프로시저는 제한된 경우가 아니라면 실패하지 않지만, RPCs는 네트워크 오류나 메시지 중복처리로 인해서 실패할 수 있다.

실패하는 경우를 처리하기 위해서 운영체제는 ‘최대 한번’ 실행하는 것이 아니라 ‘정확히 한번’실행할 수 있도록 보장한다.

로컬 프로시저는 반드시 반환값을 반환하므로 ‘정확히 한번’ 실행하는 것을 보장한다 할 수 있다.

호출의 의미론(semantic) - 2

RPCs에서 ‘최대 한번’은 메시지에 타임스탬프(timestamp)를 넣어서 구현할 수 있다.

서버는 이미 처리한 메시지의 타임스템프를 저장하고, 메시지 중복 처리 감지를 위한 충분한 크기의 기록 저장소를 유지함으로써 ‘최대 한번’을 구현한다.

서버는 이미 처리한 메시지의 타임스탬프를 포함한 처리 이력(history)을 관리함으로써 중복 메시지를 식별한다. 이를 위해, 필요한 정보를 포함할 수 있도록 충분한 용량과 시간 범위(복잡도)를 갖춘 이력 저장소를 유지한다.

이미 처리 이력이 있는 메시지는 무시된다.

호출의 의미론(semantic) - 3

RPCs에서 ‘정확히 한번’은 ‘최대 한번’에서 구현된 것과 더불어 RPC 요청이 수신되었고 실행되었다는 ACK(acknowledgement)를 보내야한다.

클라이언트는 해당 호출에 대한 ACK를 받을 때까지 주기적으로 각 RPC 호출을 재전송한다.

Question

‘최대 한번(at most once)’과 ‘정확히 한번(exactly once)’에 대한 개념이 모호하게 느껴질 수 있는데 내가 생각하기에 실제로 구현하기 나름인 모호한 개념처럼 보인다. 예를 들어 ‘최대 한번’은 재시도를 할 수도 안할 수도 있다. 데이터가 유실되거나 네트워크 오류로 인해서 데이터가 유실되는 등의 처리는 개발자나 상황에 따라 다른듯 보인다. 중요한 점은 ‘최대 한번’은 중복 처리를 방지하는 개념이고 ‘정확히 한번’은 ACK를 서버에서 받음으로써 한번의 처리(ACK 반환)가 이루어질 때 까지 시도한다는 점이다.

클라이언트와 서버 간의 바인딩(binding task) - 1

Standard procdure call은 link, load, excution 때에 프로시저가 메모리 주소로 치환되어 바인딩된다.

RPC 또한 비슷한 방식으로 바인딩되어야 하는데, 서버와 클라이언트는 메모리를 공유하지 않기 때문에 서로에 대한 포트를 모른다.

이를 해결하기 위해 일반적인 2가지 방식이 있다. ‘컴파일 할 때, 고정된 포트 번호(fixed port addresses & number)를 주는 것’과 ’랑데부(rendezvous) 방식에 의해 동적으로 바인딩하는 방법’이다.

클라이언트와 서버 간의 바인딩(binding task) - 2

‘컴파일 할 때, 고정된 포트 번호(fixed port addresses & number)를 주는 것’은 말 그대로 컴파일 이전에 고정된 포트번호를 작성하여 컴파일하는 것이다.

이는 단순하고 직관적이지만, 서비스 포트를 다시 설정하고 싶을 경우 다시 컴파일을 해야한다.

클라이언트와 서버 간의 바인딩(binding task) - 3

’랑데부 방식에 의해 동적으로 바인딩하는 방법’은 운영체제에서 제공하는 고정된 RPC 포트인 랑데부(또는 matchmaker) 데몬(daemon)을 사용하는 것이다.

클라이언트는 자신이 실행하기를 원하는 RPC 이름을 담고 있는 메시지를 랑데부 데몬에게 보내어 해당 포트 번호를 반환 받는다.

이는 유연한 방식을 제공하는 대신, 통신 초기에 오버헤드(overhead)가 생긴다.

RPC를 이용한 DFS 구현

RPC는 DFS(distributed file system)을 구현하기에 유용하다. 이는 몇 개의 ‘RPC 데몬’과 클라이언트로 구현할 수 있다.

DFS에서 메시지는 실행할 디스크 연산(disk operation)을 포함한다.

여기서 디스크 연산은 파일 관련 연산에 해당하는 작업을 의미한다. (read(), write(), rename(), delete(), status() 등등)

DFS는 연산 결과를 클라이언트에게 메시지로 반환한다.

Question

클라이언트는 stub을 통해 단순하고 직관적으로 서버에 접근하여 작업을 수행하지만, 서버에서는 이를 DFS로 구현하여 동작할 수 있기 때문에 유용하다는 것으로 보인다. 한 번의 RPC 호출로 DFS 데몬에 접근하여 쉽게 DFS 작업을 수행할 수 있다.

Remote Procedure Call Flow

출처