개요
이번 글의 주제는 Kafka, Pulsar와 같은 Event Streaming 플랫폼, 그리고 이를 소비하는 Flink에서 메세지를 주고받을 때 Serialization, Deserailization을 하는 이유를 알아보는 것입니다. Flink 예제 코드를 보면서 동작 과정을 분석할 때 정확히 알아야 겠다는 생각이 들어서 조사해 보았습니다.
PulsarSource source = PulsarSource.builder()
.setServiceUrl("pulsar://host.docker.internal:6650")
.setTopics("order-topic")
// Deserialization - 왜 할까?
.setDeserializationSchema(new JsonDeserializationSchema(OrderMessage.class))
.setSubscriptionName("order-aggregator")
.build();
Flink 예제 코드 - Pulsar message 받고 Deserialization
정의
컴퓨팅에서 Serialization(직렬화)는 자료 구조 또는 객체 상태를 저장하거나 전송 1하고 나중에 재구성할 수 있는 형식으로 변환하는 프로세스이다. 2
용도
- 유선 및 네트워크를 통한 전송을 위해 데이터를 직렬화(메시징).
- 데이터 저장 (데이터베이스, 하드 디스크 드라이브에)
- 원격 프로시저 호출, 예를 들어 SOAP에서와 같이
- 특히 COM, CORBA 등과 같은 컴포넌트 기반 소프트웨어 엔지니어링에서 객체 배포
- 시시각각 변하는 데이터의 변화 감지
Event Streaming 플랫폼은 컴퓨터 네트워크로 이벤트를 전송한다. (Kafka는 TCP 프로토콜, Pulsar는 커스텀 바이너리 프로토콜) 따라서 Event Streaming 플랫폼을 사용할 때 Serialization을 하는 것은 첫 번째 용도, 네트워크 전송을 위한 용도에 해당한다
Serialization을 하는 이유
얼핏 생각했을 때는 이미 컴퓨터가 쉽게 해석할 수 있는 바이트코드 형식의 객체를 사람이 읽을 수 있는 JSON 같은 텍스트 형식으로 직렬화하는 것은 비효율적이어 보인다. '바이트코드 -> (Serialization) -> 텍스트 형식 -> (Deserialization) -> 바이트코드'로 다시 변환하기 때문이다. 바이트코드 그대로 전송하는 게 더 나아 보일 수 있다. 그러나 Serialization을 해야 하는 이유는 다음과 같다.
- 아키텍처 독립성 유지: 서로 다른 하드웨어 아키텍처에서도 데이터를 안정적으로 교환하고 재구성할 수 있도록 하기 위해서는 직렬화가 필요합니다. 이를 통해 엔디언, 메모리 레이아웃 등의 차이로 인한 문제를 방지할 수 있습니다.
- 데이터 전송 및 저장 용이: 직렬화된 데이터는 일련의 바이트 스트림으로 변환되므로, 이를 파일이나 네트워크를 통해 쉽게 전송하고 저장할 수 있습니다. 이는 객체의 상태를 보존하고 공유하는 데 유용합니다.
- 언어 및 플랫폼 간 호환성: 직렬화를 통해 서로 다른 프로그래밍 언어나 플랫폼 간에도 데이터를 교환할 수 있습니다. 이는 이기종 시스템 간의 통신과 데이터 공유를 가능하게 합니다.
- 객체 상태 저장 및 복원: 직렬화를 사용하면 객체의 상태를 저장하고 나중에 복원할 수 있습니다. 이는 응용 프로그램의 중단 및 재시작, 실행 중 객체 상태 저장 등에 유용합니다.
- 네트워크 통신 최적화: 직렬화를 통해 데이터를 압축하고 전송량을 줄일 수 있으므로, 네트워크 대역폭을 효율적으로 사용할 수 있습니다. 이는 분산 시스템이나 클라우드 컴퓨팅 환경에서 특히 중요합니다.
객체가 RAM에 저장되는 방식이 머신마다 다르다
똑같은 바이트코드를 RAM에 써도 머신에 따라서 다르게 해석할 수 있다. 그래서 바이트코드를 그대로 전송하면 다른 머신에서는 다른 방식으로 읽을 수 있다. 대표적인 예시가 엔디언이다.
엔디언(Endianness)은 컴퓨터 메모리와 같은 1차원의 공간에 여러 개의 연속된 대상을 배열하는 순서를 의미합니다. 주로 바이트를 배열하는 순서를 나타내는 데 사용되며, 두 가지 방식이 있습니다:
빅 엔디언(Big-Endian): 최상위 바이트(MSB, Most Significant Byte)부터 최하위 바이트(LSB, Least Significant Byte)까지 순서대로 저장하는 방식입니다. 예를 들어, 0x1234라는 2바이트 데이터를 빅 엔디언으로 저장하면 메모리 상에서 0x12, 0x34 순으로 저장됩니다. 리틀 엔디언(Little-Endian): 최하위 바이트(LSB)부터 최상위 바이트(MSB)까지 순서대로 저장하는 방식입니다. 예를 들어, 0x1234라는 2바이트 데이터를 리틀 엔디언으로 저장하면 메모리 상에서 0x34, 0x12 순으로 저장됩니다.
엔디언은 CPU 아키텍처마다 다를 수 있습니다. 인텔 x86 계열 CPU는 리틀 엔디언을 사용하고, 모토롤라 68000 계열과 파워PC 계열은 빅 엔디언을 사용합니다. 따라서 서로 다른 CPU 아키텍처 간에 데이터를 교환할 때는 엔디언을 고려해야 합니다. 이를 위해 네트워크 바이트 순서(Network Byte Order)라는 표준이 정의되어 있으며, 일반적으로 빅 엔디언을 사용합니다.
프로그래머는 이러한 엔디언 차이를 인지하고, 필요에 따라 적절한 변환 함수를 사용하여 데이터를 처리해야 합니다. 이를 통해 서로 다른 시스템 간의 데이터 호환성을 보장할 수 있습니다.
바이트코드를 그대로 전송한다면 엔디언이 다른 머신에서는 객체를 재구성하지 못할 수도 있다.
Serialization을 적용하지 않고 바이트코드를 그대로 전송하면 엔디언이 다른 머신에서 객체를 재구성하지 못할 수 있습니다. 그 이유는 다음과 같습니다:
1. 데이터 표현 방식의 차이: 엔디언이 다른 경우, 동일한 데이터가 메모리에 저장되는 바이트 순서가 다릅니다. 예를 들어, 정수 0x1234를 빅 엔디언 머신에서는 0x12 0x34로 저장하지만, 리틀 엔디언 머신에서는 0x34 0x12로 저장합니다. 따라서 바이트코드를 그대로 전송하면 수신 측 머신에서 잘못된 값으로 해석될 수 있습니다.
2. 객체 레이아웃의 차이: 객체의 메모리 레이아웃은 컴파일러, 운영체제, CPU 아키텍처 등에 따라 달라질 수 있습니다. 예를 들어, 객체의 필드 순서나 패딩 등이 다를 수 있습니다. 바이트코드를 그대로 전송하면 수신 측 머신에서 객체의 필드 값을 잘못 해석하거나 액세스할 수 있습니다.
3. 포인터 문제: 객체가 포인터를 포함하고 있다면, 해당 포인터 값은 송신 측 머신에서만 유효합니다. 수신 측 머신에서는 해당 주소가 유효하지 않거나 다른 객체를 가리킬 수 있습니다. 따라서 포인터를 그대로 전송하면 수신 측에서 잘못된 메모리 참조가 발생할 수 있습니다.
이러한 문제를 해결하기 위해 Serialization을 사용합니다. Serialization은 객체의 상태를 바이트 스트림으로 변환할 때, 엔디언과 무관한 중립적인 포맷을 사용합니다. 또한, 포인터와 같은 machine-specific한 데이터를 적절히 변환하거나 제외합니다. 수신 측에서는 역직렬화(deserialization) 과정을 통해 바이트 스트림을 해석하고, 객체를 자신의 머신에 맞게 재구성합니다.
따라서 이기종 시스템 간에 객체를 안정적으로 교환하기 위해서는 반드시 Serialization을 사용해야 합니다. 이를 통해 엔디언, 메모리 레이아웃, 포인터 등의 차이로 인한 문제를 해결할 수 있습니다.
언어마다 객체를 메모리에 쓰는 방식이 다르다
사람이 보기에 같은 내용을 저장하는 객체라도 언어(Java, C++ 등)에 따라 데이터를 메모리에 쓰는 방식이 다르다.
C++의 객체 바이트코드를 Java에서 직접 복원하는 것은 일반적으로 불가능합니다. 그 이유는 다음과 같습니다:
1. 언어의 차이: C++과 Java는 근본적으로 다른 프로그래밍 언어입니다. 각 언어는 고유한 문법, 객체 모델, 메모리 관리 방식 등을 가지고 있습니다. 따라서 C++의 객체 레이아웃과 Java의 객체 레이아웃은 서로 호환되지 않습니다.
2. 컴파일러의 차이: C++과 Java는 서로 다른 컴파일러를 사용합니다. 각 컴파일러는 언어 표준을 구현하는 방식, 최적화 전략, 객체 메모리 배치 등에서 차이가 있을 수 있습니다. 이러한 차이로 인해 동일한 소스 코드라도 컴파일된 바이트코드는 다를 수 있습니다.
3. 런타임 환경의 차이: C++은 일반적으로 네이티브 코드로 컴파일되어 직접 하드웨어에서 실행되는 반면, Java는 가상 머신(JVM) 위에서 실행됩니다. 이는 메모리 관리, 가비지 컬렉션, 런타임 지원 등에서 큰 차이를 만듭니다. 따라서 C++의 바이트코드를 Java 런타임에서 직접 실행할 수 없습니다.
4. 직렬화 포맷의 차이: C++과 Java는 서로 다른 직렬화 메커니즘을 가지고 있습니다. C++에는 표준 직렬화 라이브러리가 없으며, 개발자가 직접 구현하거나 타사 라이브러리를 사용해야 합니다. 반면 Java는 내장된 직렬화 기능을 제공합니다. 따라서 C++의 직렬화 포맷과 Java의 직렬화 포맷은 호환되지 않습니다.
하지만 C++과 Java 간에 데이터를 교환해야 하는 경우, 다음과 같은 방법을 사용할 수 있습니다:
1. 중간 데이터 포맷 사용: JSON, XML, Protocol Buffers 등의 언어 중립적인 데이터 포맷을 사용하여 C++과 Java 간에 데이터를 교환할 수 있습니다. 각 언어에서는 해당 포맷을 지원하는 라이브러리를 사용하여 데이터를 직렬화하고 역직렬화합니다.
2. 인터페이스 정의 언어(IDL) 사용: CORBA, COM, SOAP 등의 기술은 인터페이스 정의 언어를 사용하여 언어 간 통신을 지원합니다. IDL을 사용하여 C++과 Java 간의 인터페이스를 정의하고, 각 언어에서 해당 인터페이스를 구현할 수 있습니다.
3. 네이티브 인터페이스 사용: Java Native Interface (JNI)를 사용하여 Java에서 C++로 작성된 네이티브 코드를 호출할 수 있습니다. 이를 통해 C++과 Java 간에 데이터를 교환할 수 있습니다. 다만 이 방법은 복잡하고 에러가 발생하기 쉬우므로 주의해서 사용해야 합니다.
이러한 방법을 사용하면 C++과 Java 간에 데이터를 교환할 수 있지만, 직접적인 바이트코드 호환성은 기대하기 어렵습니다. 언어와 런타임의 차이를 고려하여 적절한 중간 매개체를 사용하는 것이 좋습니다.
참고 자료
https://en.wikipedia.org/wiki/Serialization
https://pulsar.apache.org/docs/next/developing-binary-protocol/
'MSA' 카테고리의 다른 글
[MSA] Saga Pattern (0) | 2024.04.21 |
---|---|
[MSA] Service Mesh (0) | 2024.04.21 |
[MSA] Microservice Architecture, SOA (0) | 2024.04.21 |