Skip to content
피스타치오는 맛있어
Instagram

4장. 부호화와 발전

, 아키택처, 프로그래밍, 분산시스템, 시스템디자인4 min read

데이터 부호화 형식

메모리에서 데이터는 객체, 구조체, 리스트, 배열, 해시 테이블, 트리 등으로 다루어지는데, 이 데이터를 파일에 쓰거나 네트워크를 통해 전송하려면 일련의 바이트열로 부호화(직렬화)해야 합니다. JSON 같은 형식이 이런 부호화의 한 예시입니다.

언어별 형식

많은 프로그래밍 언어는 인메모리 객체를 바이트열로 부호화하는 기능을 내장합니다. java의 java.io.Serializable, 루비의 Marchal, 파이썬의 pickle 이 대표적입니다. 그러나 일반적으로 이 언어 차원에서 내장된 기능을 사용하는 것은 다음과 같은 이유에서 좋지 못합니다.

  • 특정 프로그래밍 언어에 묶여 있어 다른 언어에서 읽기 어렵다.
  • 복호화 과정에서 임의의 클래스를 인스턴스화하게 되는데, 공격자가 바이트열을 조작해 원격 코드실행과 같은 끔찍한 보안 문제를 일으킬 수 있다.
  • 상위 하위 호환성이 잘 지켜지지 않는다.
  • 효율이 나쁘다. 특히 자바의 내장 직렬화가 성능이 안좋기로 유명하다.

Json 과 XML, 이진 변형

JSON, XML, CSV 같은 건 우리가 자주 사용하긴 하지만, 미묘한 문제점들이 있습니다.

  • 수의 부호화가 애매하다. 일단 XML, CSV는 문자열과 숫자를 구별할 수도 없을 뿐더러, JSON 은 숫자를 구별하긴 하지만 수의 (부동소수점에 따른)정밀도를 지정하지 않습니다.
  • JSON 과 XML 은 이진 문자열을 지원하지 않는다. 그래서 보통 이진 문자열을 담기 위해 Base64인코딩을 하는데, 이 때 데이터 크기가 33%나 증가합니다.
  • XML 이나 JSON 은 스키마를 지원하는 데, 스키마의 해석을 구현하지 않은 애플리케이션은 이를 해석하기 위해 하드코딩을 해야 할 수도 있습니다.

스리프트(Thrift)와 프로토콜 버퍼

JSON 과 XML 은 둘 다 장황하고 많은 공간을 차지합니다. 이진 부호화를 하면 더 적은 공간을 차지한다는 점에 착안에 Thrift 와 Protocol Buffers 가 등장했고 각각 페이스북과 구글이 개발 & 오픈소스화 했습니다.

1message Person {
2 required string user_name = 1;
3 optional int64 favorite_number = 2;
4 repeated string interests = 3;
5}

스리프트나 프로토콜 버퍼는 위와같이 스키마를 정의하면 다양한 프로그래밍 언어로 구현한 클래스를 생성합니다. 이런 식으로 스키마를 이용하게 되면 몇가지 장점을 얻습니다.

  • 부호화된 데이터의 사이즈가 작다.
  • 복호화를 할 때 스키마가 필요하기 때문에 스키마가 최신 상태인지 확신할 수 있다.
  • 스키마 변경이 적용되기 전에 하위/상위 호환성을 확인할 수 있다.
  • 정적 타입 언어 사용자에게 스키마로부터 코드 생성되는 기능이 굉장히 유용하다. 컴파일 타임에 타입 체크를 할 수 있기 때문이다.

장점은 명확해 보입니다. 그러나 스키마는 분명 시간이 지남에 따라 필연적으로 변할텐데, 3번째 장점인 상하위 호환성은 어떻게 유지될까요?

상위 호환성은 새로운 태그 번호 4가 추가된 상황에서 그 사실을 알지 못하는 예전코드는 파서가 그냥 간단히 새로운 필드를 무시해버리면서 지켜집니다. 기존 태그 2가 삭제된 경우라면 새로운 코드에서는 그냥 이를 무시하기만 하면 된다. 다만 required 인 필드는 삭제하지 못합니다.

하위 호환성을 살펴봅시다. 고유한 태그 번호가 있기 때문에 새로운 코드는 예전 데이터를 항상 읽을 수 있습니다. 그러나 새롭게 필드를 추가할 때 required 로는 추가하지 못합니다. 새로운 코드가 예전 데이터를 읽는 데 실패할 것이기 때문입니다.

필드의 데이터 타입이 바뀌는 경우는 어떨까요? int64 를 int32 같은 걸로 바꾸게 되면 데이터가 잘립니다. 그러니 데이터 타입 변경은 주의해야 합니다. 또, 프로토콜 버퍼에서는 특이하게 optional 은 언제든지 repeated 로 변경이 가능합니다. 예전 데이터를 읽는 새로운 코드는 0이나 1개의 사이즈인 리스트를 보게되고, 새로운 데이터를 읽는 예전 코드는 리스트의 마지막 요소를 보게 됩니다.

아브로

아브로는 하둡에 스리프트를 적용하려다 잘 안되서 빡쳐가지고 만든 바이너리 부호화 형식입니다.

1record Person {
2 string userName;
3 union { null, long } favoriateNumber = null;
4 array<String> interests;
5}

일단 태그번호가 없다는 점에서 스리프트랑 프로토콜 버퍼와는 차이점을 보입니다. 그럼 스키마를 변경하는 것이 불가능하지 않을까요?

아브로에서는 이를 위해 쓰기 스키마와 읽기 스키마를 나누었습니다. 양 스키마가 서로 동일하지 않아도 호환만 가능하면 되는 것입니다. 아브로에서의 상위 호환성은 새로운 버전의 쓰기 스키마와 예전 버전의 읽기 스키마가 같이 동작한다는 것을 의미하고, 하위 호환성은 새로운 버전의 읽기 스키마와 예전 버전의 쓰기 스키마가 같이 동작한다는 것을 의미합니다.

데이터플로 모드

지금까지 메모리를 공유하지 않는 다른 프로세스로 데이터를 보낼 때 바이트열로 써야 하고 JSON 보다는 프로토콜 버퍼같은 게 더 많은 장점이 있으며 상위/하위 호환성을 잘 지켜야 된다는 걸 살펴보았습니다.

이제부터는 데이터플로라는 단어가 나올 텐데, 데이터플로는 추상적인 개념으로 하나의 프로세스에서 다른 프로세스로 데이터를 전달하는 방법에 관한 이야기입니다.

데이터베이스를 통한 데이터플로

데이터베이스에 저장하는 행위는 사실 미래의 자신에게 메시지를 보내는 일이라고 볼 수 있습니다. 저장해놓으면 언젠가는 꺼내 쓸 것이기 때문입니다.

이 때 하위 호환성이 제공되지 않으면 미래의 자신은 예전의 데이터를 복호화할 수 없습니다.

데이터가 새로운 버전이라 A 라는 신규 필드를 들고 있다고 했을 때, 예전 코드는 A 라는 필드에 대해서 모르므로 모델 객체로 복호화를 시킬 때 해당 값을 누락하게 됩니다. 객체가 업데이트 되고 다시 저장될 때 A 는 끝까지 누락된 상태이므로 A 데이터가 유실될 수 있습니다.

데이터를 새로운 스키마로 마이그레이션하는 것은 가능하나, 대용량 데이터셋 대상으로는 매우 값비싼 작업이기 떄문에 대부분의 관계형 데이터베이스에서는 이 비용을 치르는 대신 null 을 기본 값으로 갖는 새로운 컬럼을 추가하는 간단한 스키마 변경을 허용합니다. 이 때 예전 로우를 읽으려는 경우 null 로 채웁니다.

서비스를 통한 데이터플로: REST와 RPC

클라이언트와 서버가 있고 보통 서버가 API 를 제공하면 클라이언트가 요청하는 방식을 많이들 구현합니다. 이 떄 서버가 구현한 API 를 서비스라고 합니다. 최근에는 한 서비스가 다른 서비스에 필요한 기능을 요청하는 개발 방식이 만들어져 SOA(서비스 지향 설계, Service oriented architecture)라고 불리었고, 이를 더욱 개선한 것이 MSA(마이크로서비스 설계, Microservices architecture)입니다.

MSA 의 핵심 설계 목표는 서비스를 배포와 변경에 독립적으로 만들어 애플리케이션 변경과 유지보수를 더 쉽게 할 수 있게 만드는 것입니다. 이를 위해서는 데이터베이스와 유사하게 API 의 버전 간 호환성이 보장되어야 합니다.

REST는 HTTP의 원칙을 토대로 한 설게 철학입니다. 간단한 데이터 타입을 강조하며 URL을 사용해 리소스를 식별하고 캐시 제어, 인증, 콘텐츠 유형 협상에 HTTP를 사용합니다. 이 원칙에 따라 설계된 API를 RESTful 이라고 합니다. 이 경우 호환성을 깨는 변경이 필요하면 보통 여러 버전의 API를 함께 유지하고, URL이나 HTTP Accept 헤더에 버전 번호를 사용하는 것이 일반적입니다.

RPC(Remote Procedure Call)는 원격 네트워크 요청을 마치 로컬 메소드를 실행하는 것처럼 사용할 수 있게 추상화를 제공합니다. 그러나 로컬 함수와는 다르게 네트워크 요청은 장애가 쉽게 발생하기 때문에, 이와 관련한 대책(장애시 재전송과 같은)을 같이 세워야 합니다. RPC의 경우, 스키마의 상하위 호환은 해당 RPC가 사용하는 모든 부보화 방식으로부터 상속됩니다(e.g. gRPC는 프로토콜 버퍼를 사용).

메시지 전달 데이터플로

비동기 메시지 전달 시스템은 클라이언트 요청(메시지)을 메시지 브로커(혹은 메시지 큐)나 메시지 지향 미들웨어라는 중간 단게를 거쳐 전송합니다. 이 방식은 RPC에 비해 여러 장점이 있습니다.

  • 죽었던 프로세스에 메시지를 다시 전달할 수 있기 때문에 메시지 유실을 방지할 수 있다.
  • 수신자가 뻗은 상태라면 메시지 브로커가 버퍼처럼 동작해 시스템 안정성이 높아진다.
  • 하나의 메시지를 여러 수신자로 전송할 수 있다.
  • 송신자는 누가 수신하고 소비하는지 관심이 없어서 논리적으로 분리된다.

송신자는 메시지가 전달될 때까지 기다리지 않고 단순히 메시지를 보내고 잊어버리기 때문에 비동기 방식입니다.

일반적으로 메시지 브로커는 다음과 같이 동작합니다.

  • 프로세스 하나가 메시지를 메시지를 이름이 지정된 큐나 토픽으로 전송하고 브로커는 해당 메시지를 하나 이상의 컨슈머에게 전달한다.
  • 동일한 토픽에 여러 프로듀서와 컨슈머가 있을 수 있다.
  • 토픽은 단방향 데이터플로만 제공한다(근데 응답 큐를 쓰거나 다른 토픽으로 pub 해서 마치 양방향처럼 보이게 할 수 있음).

메시지 브로커는 메시지가 부호화된 채로 전달하므로, 부호화가 상하위 호환성을 제공하기만 한다면 프로듀서와 컨슈머를 독립적으로 배포할 수 있는 유연성을 얻게 됩니다.

분산 엑터 프레임워크

서로 다른 프로세스로 메시지를 전달하는 것 외에도, 같은 프로세스 안에서 메시지를 전달하며 동시성을 처리하기 위한 프로그래밍 모델이 있습니다. 스레드(race condition, lock, deadlock 과 같은 문제들)를 직접 처리하는 대신 로직이 액터에 캡슐화됩니다.

이건 뭐 그냥 이런 게 있구나 하고 넘어가면 될 것 같습니다.

© 2023 by 피스타치오는 맛있어. All rights reserved.