Reporting Date: April. 11, 2025
분산 시스템 간의 통신에 대해 다루고자 한다.
목차
01 파이프
Pipe
공유 메모리와 메시지 패싱은 IPC을 위한 대표적인 방법이다.
이 두 방식은 일반적으로 가장 널리 사용된다.
이 외에도 파이프라는 특별한 방식도 존재한다.
한 프로세스의 출력 데이터를
다른 프로세스의 입력으로 직접 전달하는 방식으로,
특히 부모-자식 프로세스 간 통신이나
명령어 파이프라인 처리에 자주 사용된다.
파이프는 초창기 유닉스 개발자들이
통신의 단순화를 위해 도입했지만,
OS 내부에서 프로세스 간 연결, 동기화, 버퍼링 등의
처리를 구현하는 것은 결코 단순하지 않았다고 전해진다.
하나의 프로세스가 데이터를 쓰고,
다른 프로세스가 읽는 통신 수단으로,
즉, "전달자" 역할을 정확히 한다.
초기 UNIX 시스템에서 도입된 가장
기초적이고 오래된 IPC 방식 중 하나이다.
아래 항목들은 모두 실제로 파이프 구현 시 고려하는 중요한 요소이다.
고려 요소 | 설명 |
단방향, 양방향 통신 | 기본 파이프는 단방향, 양방향이 필요하면 두 개 사용 또는 socketpair, named pipe(FIFO) 사용 |
Half Duplex vs Full Duplex | 일반 파이프는 Half Duplex (한 번에 한 방향), Full Duplex는 소켓 등을 사용함 |
부모-자식 관계 여부 | 익명 파이프는 주로 부모-자식 프로세스 간에 사용 |
동일 머신, 네트워크 통신 여부 | 일반 파이프는 동일 시스템 내 통신만 허용, 네트워크 통신은 소켓이 필요 |
02 파이프의 종류
파이프는 프로세스 간 통신(IPC)의 한 방식으로,
데이터를 한 프로세스에서 다른 프로세스로 전달할 수 있도록 해준다.
파이프는 크게 두 가지로 나뉜다.
1 . 일반 파이프
Ordinary Pipe, 익명 파이프
생산자-소비자 형태의 통신 모델을 따른다.
생산자 | 파이프의 한쪽 끝(쓰기 종단, write-end)에 데이터를 쓴다. |
소비자 | 반대쪽 끝(읽기 종단, read-end)에서 데이터를 읽는다. |
단방향 통신만 가능하며, 데이터를 한 방향으로만 전송할 수 있다.
일반적으로 부모 프로세스가 파이프를 생성하고, 자식 프로세스와 통신하는 데 사용된다.
다른 프로세스가 생성한 일반 파이프에는 접근할 수 없다.
UNIX/Linux와 Windows 모두 이를 지원하며,
Windows에서는 익명 파이프(Anonymous Pipe)라고 부른다.
파이프는 임시적인 통신 수단으로, 한 번 사용하면 다시 사용할 수 없다.
다시 사용하려면 새로 명령어나 파이프를 생성해야 한다.
Linux 명령어
$ ls -l | more
- ls -l 명령은 파일 목록을 길게 출력한다.
- | (파이프) 기호는 ls의 출력을 more 명령어로 넘긴다.
- more는 한 화면(보통 24줄)씩 데이터를 표시하며,
사용자가 스페이스바를 눌러 다음 내용을 볼 수 있게 한다. - 이 파이프 연결은 단방향이며, ls -l의 출력만 more로 전달된다.
2 . 지명 파이프
Named Pipe, FIFO
일반 파이프보다 더 강력한 기능을 갖춘 IPC 방식이다.
이름을 가진 파이프로, 파일 시스템 내에 실제 파일처럼 존재하며,
관련 없는 프로세스 간에도 통신할 수 있다.
양방향 통신이 가능하고, 부모-자식 관계 없이도 사용할 수 있다.
한 번 생성되면 통신 프로세스가 종료되더라도 지속적으로 존재한다.
UNIX/Linux에서는 이를 FIFO라고 부르며,
mkfifo 명령으로 생성할 수 있다.
Windows에서도 Named Pipe는 시스템 수준 IPC로 널리 사용된다.
다만, FIFO의 양방향 통신은
반이중(Half Duplex) 방식으로, 동시에 송수신은 불가능하다.
동시에 데이터를 주고받기 위해선 두 개의 FIFO를 사용하는 것이 일반적이다.
FIFO 생성 및 사용
$ mkfifo my-pipe
$ gzip -q -c < my-pipe > out.gz &
- mkfifo 명령어는 my-pipe라는 이름의 지명 파이프를 생성한다.
- 이후 gzip은 my-pipe를 통해 전달되는 데이터를 압축하여 out.gz로 출력한다.
파이프의 사용 범위
일반 파이프는 같은 시스템 내의 프로세스,
특히 부모-자식 관계에서 주로 사용된다.
지명 파이프는 같은 시스템 내에서
관련 없는 프로세스 간 통신도 가능하지만,
다른 컴퓨터 간 통신은 불가능하며,
이 경우, 네트워크 소켓 등을 사용해야 한다.
03 분산 시스템에서의 통신 방식
Client-Server Communication
단일 시스템 내에서의 프로세스 간 통신은
동일한 메모리 공간 또는 OS 자원을 공유하므로 구현이 비교적 간단하다.
그러나 분산 환경에서는 시스템이 지리적으로
분리되어 있고, 서로 다른 OS를 사용할 수 있으며,
네트워크를 통해 통신해야 하므로 고려해야 할 요소가 많아진다.
이러한 분산 환경에서 클라이언트-서버 구조로
프로세스가 통신할 때 주로 사용되는 세 가지 방식은 다음과 같다:
- 소켓 (Socket)
- 원격 프로시저 호출 (RPC)
- 원격 메소드 호출 (RMI)
04 소켓
네트워크를 통해 데이터를 교환하기 위한
통신의 종단점(endpoint) 역할을 한다.
이를 전기 플러그에 비유하자면,
장치를 전원에 연결해주는 접점이자 통로와 같은 개념이다.
두 프로세스가 네트워크 상에서 통신하기 위해서는
각 프로세스마다 하나씩, 총 2개의 소켓이 필요하다.
소켓을 사용한 통신 구조 예시
소켓 통신에서는 통신을 위해 각 호스트(컴퓨터)마다
고유한 IP 주소와 해당 프로세스를 식별하는 포트 번호가 필요하다.
이 두 가지를 결합하면 소켓 주소(Socket Address)가 되며,
이를 통해 두 시스템 간에 데이터를 주고받을 수 있다.
- 클라이언트 호스트 (Host X)
IP 주소: 146.86.5.20
포트 번호: 1625
→ 소켓 주소: 146.86.5.20:1625
- 웹 서버 (Web Server)
IP 주소: 161.25.19.8
포트 번호: 80 (HTTP 기본 포트)
→ 소켓 주소: 161.25.19.8:80
이 두 소켓 사이에 통신이 설정되면,
클라이언트는 웹 서버에 접속하여
데이터를 요청 및 응답을 받을 수 있게 된다.
이처럼, IP 주소 + 포트 번호의 조합이 통신을 위한 통로(채널)를 구성한다.
인터넷 통신의 경우,
IPv4 주소 체계(32비트, 약 43억 개의 주소)가 빠르게 고갈되어,
더 많은 주소 공간을 제공하는 IPv6(128비트)가 도입되었다.
각 연결은 반드시 고유한 소켓 쌍을 통해 이루어지므로,
서로 다른 여러 통신이 동시에 발생할 수 있다.
소켓 통신은 효율적이고 널리 사용되지만,
상대방의 IP 주소와 포트를 정확히 알아야 한다는 점,
그리고 데이터가 단순한 바이트 스트림(Byte Stream) 형식이라는 점에서
낮은 수준(low-level)의 통신 방식으로 간주된다.
이러한 이유로, 중간에 데이터가 유실 혹은 상대가
갑자기 사라지는 경우, 이를 복구하거나 식별하는 기능이 부족하다.
사용자가 상대방의 IP 주소를 직접 기억하기 어려우므로,
도메인 이름(Domain Name) 시스템이 도입되었다.
예: www.example.com → IP 주소로 변환 (DNS)
2 . 고수준 통신 방식의 필요성
소켓은 강력하지만
프로그래밍 난이도가 높고, 복잡한 처리가 필요하다.
또한 데이터의 구조를 직접 관리해야 하며,
신뢰성 확보도 개발자가 처리해야 한다.
이를 해결하기 위해 더 고수준(High-level)의
통신 방식인 다음과 같은 기술이 등장하게 된다.
- RPC (Remote Procedure Call)
마치 로컬 함수처럼 다른 시스템에 있는 함수를
호출할 수 있도록 해주는 방식.
- RMI (Remote Method Invocation)
RPC의 객체지향 버전으로, 자바 환경에서
객체의 메소드를 원격으로 호출할 수 있도록 지원한다.
이러한 방식들은 개발자가 네트워크 세부 구현을
신경 쓰지 않고도 통신 기능을 구현할 수 있게 도와주며,
분산 시스템 프로그래밍의 생산성과 안정성을 크게 높여준다.
단계 | 클라이언트 | 서버 | 설명 |
1 | socket() – 소켓 생성 | socket() – 소켓 생성 | 통신을 위한 엔드포인트 생성 |
2 | bind() – IP, 포트 주소 할당 | 서버의 주소 정보 설정 | |
3 | listen() – 연결 요청 대기 | 클라이언트 접속을 기다림 | |
4 | connect() – 서버에 연결 요청 | 클라이언트가 서버에 접속 시도 | |
5 | accept() – 연결 요청 수락 | 연결 수락 후, 데이터 송수신을 위한 연결 소켓 생성 | |
6 | write() – 데이터 전송 | read() – 데이터 수신 | 클라이언트 → 서버 |
read() – 데이터 수신 | write() – 데이터 전송 | 서버 → 클라이언트 | |
7 | close() – 소켓 종료 | close() – 소켓 종료 | 통신 종료 후 자원 해제 |
Java에서 소켓 통신을 이용해
현재 시간을 제공하는 DateServer와 DateClient 코드
1 . DateServer.java
서버 역할을 하며, 클라이언트가 접속하면 현재 시간을 전달한다.
import java.io.*;
import java.net.*;
public class DateServer {
public static void main(String[] args) {
try {
// 6013번 포트로 서버 소켓 생성
ServerSocket sock = new ServerSocket(6013);
// 클라이언트 접속을 무한히 기다림
while (true) {
// 클라이언트 접속 수락
Socket client = sock.accept();
// 출력 스트림 생성 (자동 flush true)
PrintWriter pout = new PrintWriter(client.getOutputStream(), true);
// 현재 날짜 및 시간 전송
pout.println(new java.util.Date().toString());
// 클라이언트와의 연결 종료
client.close();
}
} catch (IOException ioe) {
System.err.println(ioe);
}
}
}
2 . DateClient.java
클라이언트 역할을 하며, 서버에 접속해서 시간 정보를 받아 출력한다.
import java.io.*;
import java.net.*;
public class DateClient {
public static void main(String[] args) {
try {
// 서버에 접속 (로컬 주소, 포트 6013)
Socket sock = new Socket("127.0.0.1", 6013);
// 입력 스트림 생성
InputStream in = sock.getInputStream();
BufferedReader bin = new BufferedReader(new InputStreamReader(in));
// 서버로부터 받은 데이터 출력
String line;
while ((line = bin.readLine()) != null) {
System.out.println(line);
}
// 소켓 종료
sock.close();
} catch (IOException ioe) {
System.err.println(ioe);
}
}
}
05 RPC
원격지 시스템 간 통신을 위한 가장 보편적인 방식 중 하나로,
네트워크에 연결된 두 시스템이 구조화된 메시지를 이용해
서로의 프로시저를 호출할 수 있도록 지원한다.
이는 단순히 바이트 스트림을 주고받는 소켓 통신과 달리,
데이터를 의미 있는 구조로 구성하므로,
오류 발생 시 원인을 더 쉽게 파악할 수 있다.
핵심은, 클라이언트가 원격지의 프로시저를 마치
자신의 지역(local) 함수처럼 호출할 수 있도록 해주는 것이다.
이때 클라이언트와 서버 사이에서 중개 역할을 하는 것이
바로 스터브(Stub) 라는 프로그램이다.
1 . 스터브
나중에 로드되거나 원격지에 위치한
큰 프로그램을 대리하기 위한 작은 프로그램 루틴이다.
클라이언트 측에서는 호출할 원격 프로시저의 포트를 알아내고,
서버로 보낼 메시지를 구조화된 형태로 생성해야 한다.
이 과정을 파라미터 마샬링(Parameter Marshalling) 이라고 하며,
이는 원격지에 전송될 파라미터를 네트워크 전송에 적합하도록
직렬화(serialization) 하는 작업이다.
예를 들어, 클라이언트가 32비트 인텔 기반 시스템(P1),
서버가 리눅스 64비트 기반 시스템(P2)일 경우,
메모리 구조나 바이트 저장 순서가 다르므로,
파라미터를 서로에게 맞는 형식으로 변환해야 통신이 가능하다.
이를 위해 플랫폼 독립적인 데이터 표현 방식이 필요하다.
2 . Windows 환경의 RPC 구현
Windows에서는 스텁 코드를 작성할 때
Microsoft Interface Definition Language (MIDL) 이라는
전용 언어를 사용하며, 해당 언어를 통해 RPC 통신용 코드가 컴파일된다.
이처럼 특정 플랫폼에서는 전용 언어와 컴파일러가 존재하며,
RPC를 위한 구조화된 코드를 자동으로 생성해준다.
또한, 서로 다른 시스템 간의 호환성을 보장하기 위해
외부 데이터 표현(External Data Representation, XDR) 형식을 사용한다.
이 형식은 빅 엔디언과 리틀 엔디언과 같이
서로 다른 메모리 저장 방식을 가진 시스템 간에도
데이터를 정확히 해석할 수 있도록 도와준다.
예를 들어 어떤 시스템은 데이터를
12 | 34 | 56 순서대로 저장하지만,
다른 시스템은 이를 반대로 저장할 수도 있다.
이러한 차이를 해결하기 위해 XDR은 공통의 표현 방식을 제공한다.
3 . RPC의 신뢰성과 커널 역할
원격 통신은 로컬 통신보다 장애 발생 가능성이 훨씬 높으며,
메시지가 정확히 한 번만 전달되는 것이 매우 중요하다.
즉, 중복 없이, 누락 없이 한 번만 성공적으로 도달해야 한다.
예를 들어, 마이크로 커널 구조를 채택한
Mach OS에서는 클라이언트가 서버에 연결 요청을 보내면,
커널은 이를 허용하고 특정 포트(P)를 통해 통신을 중개한다.
서버는 요청을 처리한 후, 그 결과를 다시 포트 P를 통해
커널에 전달하고, 커널은 이 결과를 클라이언트에 전달한다.
일반적으로 RPC는 스텁 코드, 라이브러리 함수,
커널, 네트워크 계층 등이 계층적으로 통신에 관여하며,
이 구조는 보다 안전하고 추상화된 통신 방식을 제공한다.
또한, 대부분의 OS는 클라이언트와 서버를 연결해주는
랑데부(rendezvous) 또는 매치메이커(matchmaker) 서비스와 같은
이름의 중개 기능을 제공하여, 동적으로 클라이언트와 서버를 연결한다.
1 . RPC 실행 과정 (Mach 기준)
단계 | 주체 | 동작 설명 |
1 | 사용자 (클라이언트) | RPC X를 호출하기 위해 커널에게 메시지 전송 요청 |
2 | 커널 | Matchmaker에 메시지를 보냄 (RPC X의 포트 번호 조회) |
3 | Matchmaker | 요청받은 RPC X의 포트 번호를 검색 |
4 | 커널 | Matchmaker 응답을 받아 RPC 메시지에 포트 P를 포함 |
5 | 커널 | 포트 P를 통해 서버에게 RPC 메시지 전송 |
6 | 데몬 (서버) | 포트 P에서 메시지를 수신하고 처리 시작 |
7 | 커널 | 서버의 응답 메시지를 수신 후 클라이언트에게 전달 |
8 | 사용자 (클라이언트) | 결과를 받아 출력 또는 처리 |
통신 메시지 예시
- 클라이언트 → 매치메이커
From: Client To: Matchmaker Port: Matchmaker Re: address for RPC X - 매치메이커 → 클라이언트
From: Server To: Client Port: Kernel Re: RPC X Port: P - 클라이언트 → 서버
From: Client To: Server Port: P <RPC X contents> - 서버 → 클라이언트
From: RPC Daemon To: Client Port: Kernel <output>
2 . 계층 구성도
Client Side Server Side
------------ --------------
Application Application
| |
Client Stub Server Stub
| |
Runtime Library Runtime Library
| |
Transport (with Kernel) Transport (with Kernel)
| |
Matchmaker Daemon (중앙 포트 관리)
이 구조는 초기 분산 시스템 설계에서 각 서버 프로시저가 고유 포트를 갖고,
클라이언트가 이를 찾아서 호출한다는 개념을 잘 보여준다.
Matchmaker는 DNS처럼 RPC 포트 네임 서비스를 제공하는 중간 역할을 한다.
06 RMI
Java에서 제공하는 RPC 기능 중 하나로,
자바 프로그램이 원격 객체(Remote Object)에 있는
메서드를 호출할 수 있도록 해주는 기능이다.
이 기능은 내부적으로 JRMP를 사용하여 통신이 이루어진다.
(Java Remote Method Protocol)
기존의 RPC가 함수 호출에 기반을 두고 있는 반면,
RMI는 객체 지향 기반이므로 차이점이 몇 가지 있다.
가장 큰 특징은 객체 자체를 원격 메서드의 인자로 전달할 수 있다.
즉, 단순히 데이터를 주고받는 것이 아니라, 객체 자체를
네트워크를 통해 넘기므로 더욱 풍부하고 유연한 통신이 가능하다.
또한, RMI는 JVM 환경에서 실행되므로
C 기반의 RPC에서 흔히 사용되는 XDR과
같은 별도의 데이터 직렬화 표준이 필요 없다.
Java Virtual Machine(JVM),
eXternal Data Representation(XDR)
이 덕분에, 자바 객체를 별도 처리 없이 바로
직렬화(Serialize)하여 전송할 수 있어 이식성과 개발 효율성이 높다.
즉, JVM 환경 안에서는 복잡한 코드화 작업 없이
객체를 그대로 원격지로 전달하고, 메서드를 호출하고,
그 결과를 받아올 수 있는 고수준의 통신 방식인 셈이다.
파라미터 마샬링
Marshalling Parameters
RMI에서 클라이언트가 서버의 원격 객체 메서드를 호출 시,
단순히 메서드 이름과 값만 전달되는 것이 아닌,
그 메서드에 전달될 파라미터들(A, B) 또한 함께
네트워크를 통해 전송되어야 한다.
이 과정에서 필요한 것이 바로 마샬링(Marshalling)이다.
원격 호출 시 필요한 데이터를 구조화된 메시지 형태로
패킹(포장)하여 전송할 수 있게 만드는 작업으로,
클라이언트 측의 스터브(Stub)가 자동으로 수행한다.
클라이언트는 마치 로컬 메서드를 호출하듯이 메서드를 호출하면,
스터브는 그 호출을 감지해 파라미터와 메서드 정보를 네트워크 전송에
적합한 형태로 직렬화(Serialize)하여 서버 측으로 전송한다.
서버 측에서는 이 요청을 받는 역할을 하는 스켈레톤(Skeleton)은
클라이언트로부터 도착한 패킹된 메시지를 언마샬링(Unmarshalling)하여
원래의 파라미터와 메서드 호출로 복원하고, 실제 서버의 메서드를 실행한다.
메서드가 실행된 후에는 그 결과 값(예: boolean)도
마찬가지로 반환값을 마샬링하여 클라이언트에게 전달하고,
클라이언트 스터브는 이를 받아 다시 클라이언트 앱에게 전달한다.
즉, 전체 과정은 다음과 같은 흐름이다:
- 클라이언트가 원격 메서드 호출
(예: val = server.remoteMethod(A, B)) - Stub이 A, B, 메서드 정보를 묶어 마샬링
- 네트워크를 통해 서버로 전송
- Skeleton이 메시지를 언마샬링
- 서버의 실제 메서드를 실행
- 반환값을 다시 마샬링하여 클라이언트로 전송
- 클라이언트에서 결과를 받아 활용
RMI 구성 요소와 과정 해석
클라이언트와 서버가 서로 데이터를 주고받으며
하나의 통합된 프로세스를 만드는 것
1. Client
- 원격 메서드를 호출하는 주체.
- 예: val = server.someMethod(A, B)
- 사용자는 로컬 메서드처럼 호출하지만, 실제로는 네트워크를 통해 호출됩니다.
2. Stub Object
- 클라이언트와 서버 사이의 중개자 역할을 한다.
- 사용자가 호출한 메서드를 네트워크를 통해
전송할 수 있는 형식으로 바꾸는(마샬링) 역할한다. - 마치 색상 필터처럼 데이터를 포장한다.
3. Marshalled Parameters
- 호출된 메서드 이름, 인자들(A, B)을
네트워크 전송에 적합한 구조화된 형태로 변환한 것이다. - 클라이언트에서 서버로 전달된다.
- 마치 각 색상이 데이터로 변환되어 하나로 전달되는 과정처럼 생각할 수 있다.
4. Skeleton Object (서버 측)
- 수신된 마샬링된 메시지를 언마샬링(Unmarshalling)하여
원래의 메서드 호출과 파라미터로 복원한다. - 실제 서버 객체에게 호출 요청을 전달한다.
5. Server Object
- 실제 메서드 로직을 가진 객체.
- 클라이언트가 요청한 someMethod를 실행하고, 그 결과값을 반환한다.
6. Marshalled Return Value
- 서버에서 처리된 결과를 클라이언트에게 다시 보내기 위해 마샬링된 형태이다.
- 예: boolean, int, String 등
7. Return Value
- 클라이언트는 이 결과를 받아 로컬 메서드처럼 사용한다.
- 사용자는 네트워크 통신이 일어난 것을 거의 인식하지 못한다.
Java RMI를 이용해 클라이언트가 원격 서버에서
현재 날짜(Date) 를 받아오는 예제이다.
① 구성 요소 설명
역할 | 클래스/파일 | 설명 |
인터페이스 | RemoteDate | 원격에서 호출 가능한 메서드를 정의함. Remote 인터페이스를 상속받고, 예외로 RemoteException을 던짐. |
서버 구현체 | RemoteDateImpl | 인터페이스를 구현하며 실제 동작을 정의함. UnicastRemoteObject를 상속하여 RMI 객체로 등록됨. |
서버 실행 | main() in RemoteDateImpl |
RMI 레지스트리에 객체를 등록함 (Naming.rebind()) "DateServer" 라는 이름으로 등록. |
클라이언트 | RMIClient | RMI 레지스트리에서 "DateServer"를 찾아 원격 메서드 호출. getDate()를 호출해 현재 날짜를 받아옴. |
② 전체 흐름 요약
인터페이스 정의 (RemoteDate.java)
→ 클라이언트와 서버가 공유해야 하는 인터페이스.
public interface RemoteDate extends Remote {
Date getDate() throws RemoteException;
}
서버 구현체 작성 (RemoteDateImpl.java)
→ UnicastRemoteObject를 상속받아 네트워크 통신 가능 객체로 만듦.
public class RemoteDateImpl extends UnicastRemoteObject implements RemoteDate {
public RemoteDateImpl() throws RemoteException {}
public Date getDate() throws RemoteException {
return new Date(); // 현재 시간 반환
}
public static void main(String[] args) {
try {
RemoteDate dateServer = new RemoteDateImpl();
Naming.rebind("DateServer", dateServer); // 등록
} catch (Exception e) {
System.err.println(e);
}
}
}
클라이언트 작성 (RMIClient.java)
→ 등록된 서버 객체를 찾아 getDate()를 호출.
public class RMIClient {
public static void main(String[] args) {
try {
String host = "rmi://127.0.0.1/DateServer";
RemoteDate dateServer = (RemoteDate) Naming.lookup(host);
System.out.println(dateServer.getDate()); // 날짜 출력
} catch (Exception e) {
System.err.println(e);
}
}
}
실행 순서
- rmiregistry 실행 (RMI 레지스트리)
- RemoteDateImpl 서버 실행 → 객체 등록됨
- RMIClient 실행 → DateServer 객체 호출 → 날짜 반환
'2025 - 1학기 > 플랫폼OS' 카테고리의 다른 글
다중 코어 프로그래밍 (0) | 2025.05.12 |
---|---|
다중 스레드 프로그래밍 (7) | 2025.04.19 |
OS 협력 (2) | 2025.04.18 |
OS 메세지 (0) | 2025.04.17 |
OS 구현 (0) | 2025.04.14 |