Big Data/Designing Data-Intensive Applicatiosn

04. 인코딩(Encoding)과 발전(Evolution)

Data Engineer 2019. 9. 15. 22:04

만물은 변하고 그대로 있는 것은 아무것도 없다.
- 에베소의 헤라클레이토스, 플라톤이 크라틸로스에서 인용(기원전 360년)

 

4장은 다음과 같은 인용문으로 시작합니다. 익스트림 프로그래밍에도 나오는 "모든 것은 변한다"라는 말과 일맥상통합니다. 애플리케이션은 시간이 지남에 따라 필연적으로 변합니다. 즉, 요구사항이 변하거나 환경이 변함에 따라 기능이 추가되거나 변경되는 것입니다. 이러한 개념은 1장에서 나온 특성 중 발전성(evolvability)에 의미와 같이 변경 사항은 쉽게 적용할 수 있어야 한다는 의미입니다.

 

이와 같이 애플리케이션의 기능이 변경하려면 저장하는 데이터도 변경되어야 합니다. 새로운 필드나 레코드 유형을 저장하거나 기존 데이터를 새로운 방법으로 나타내야 할 것입니다.

 

여러 데이터 모델에 따라 이러한 변경 사항을 대처하는 다양한 방법이 존재합니다. 관계형 데이터베이스는 스키마를 변경하여 변경한 시점부터 하나의 스키마가 적용됩니다. 이와 달리 스키마리스(schemaless) 데이터베이스는 스키마가 변경된 시점 이후에도 이전 데이터 포맷과 새로운 데이터 포맷이 함께 포함되어 있을 수 있습니다. 

 

이와 같이 이전 데이터 타입과 새로운 데이터 타입이 시스템  상에 공존할 수 있으므로 시스템이 계속 원할하게 실행되게 하려면 호환성을 유지해야 합니다. 호환성은 2가지 형태가 있습니다.

 

  • 하위 호환성
    • 새로운 코드는 예전 코드가 기록한 데이터를 읽을 수 있어야 합니다.
  • 상위 호환성
    • 예전 코드는 새로운 코드가 기록한 데이터를 읽을 수 있어야 합니다.

데이터 인코딩을 위한 포맷

프로그램은 최소 두가지 다른 형태로 표현된 데이터를 사용해 동작합니다. 첫 번째는 메모리에 저장되는 형태입니다. 메모리에 저장되는 데이터 구조의 경우 객체나 구조체, 리스트, 배열, 해시 테이블 등으로 데이터가 유지됩니다. 이에 반해 데이터를 파일에 쓰거나 네트워크를 통해 전송하는 경우 일련의 바이트 열로 인코딩해야 합니다.

 

이와 같이 두 가지 다른 데이터를 표현하는 차이로 인해 데이터 구조의 전환이 필요합니다. 메모리 표현에서 바이트열로 전환하는 것을 보통 인코딩(직렬화나 마샬링이라고도 함)이라고 합니다. 그와 반대는 디코딩(역직렬화, 언마샬링이라고도 함)이라고 합니다.

 

이러한 데이터 전환은 일반적인 문제이기 때문에 많은 라이브러리와 인코딩 포맷이 존재합니다. 그럼 간단히 이러한 인코딩 포맷에 관해 살펴보도록 하겠습니다.

언어별 포맷

다양한 프로그래밍 언어는 인메모리 객체를 바이트열로 인코딩하는 기능을 내장하고 있습니다. 예를 들어 자바의 java.io.Serializable, 루비의 Marshal, 파이썬은 pickle 등이 있습니다. 또한 자바 전용인 Kryo와 같은 다양한 서드파티 라이브러리도 있습니다.

 

  • 장점
    • 최소한의 추가 코드로 인메모리 객체를 저장하고 복원 가능
  • 단점
    • 다른 언어와 같이 사용하는 경우 데이터 읽기가 어렵다.
    • 애플리케이션의 임의의 바이트열을 디코딩할 수 있게 되면 원격으로 임의의 코드를 실행하는 것과 같은 보안 이슈가 존재
    • 데이터 버전 관리 문제 - 상하위 호환성 문제 발생
    • 효율성 문제 - 예를 들어 자바의 내장 직렬화는 성능이 좋지 않기로 유명

일반적으로 언어에 내장된 부호화를 사용하는 방식은 좋지 않습니다.

JSON, XML과 CSV

많은 프로그래밍 언어에서 읽고 쓸 수 있는 표준화된 인코딩 방식으로 JSON과 XML이 있습니다.

 

  • 장점
    • 텍스트 형식이어서 어느 정도 사람이 읽을 수 있음
    • 다양한 언어에서 지원
    • 조직간 데이터 교환 형식으로 사용하기 좋음
  • 단점
    • 수를 인코딩하는데 문제가 있음
      • 큰 수를 다루거나 부동소수점을 다루는 경우 부정확해질 수 있음
    • 데이터 타입이 모호한 점이 있기 때문에 숫자나 이진 문자열 같은 항목은 주의가 필요
    • 스키마 사용을 강제하지 않기 때문에 애플리케이션에서 인코딩 / 디코딩 과정을 직접 처리해야 함

이와 같은 단점에도 불구하고 JSON, XML, CSV는 다양한 용도로 사용하기에 충분합니다. 조직 간에 데이터를 교환하는 경우 보통 사람들 사이에 동의만 한다면 얼마나 읽기 쉽고 효율적인 형식인지는 중요하지 않습니다. 다른 조직에 동의를 얻는 어려움은 대부분의 다른 문제보다 크기 때문입니다. 그러한 이유로 JSON과 XML, CSV는 앞으로도 인기를 유지할 것입니다.

바이너리 인코딩

아파치 스리프트(Apache Thrift)와 프로토콜 버퍼(Protocol Buffers)는 대표적인 바이너리 인코딩 라이브러리입니다. 두개의 바이너리 인코딩은 같은 원리로 데이터를 표현합니다. 둘 다 인코딩할 데이터를 위한 스키마가 필요합니다. 먼저 스리프트 인터페이스 정의 언어를 통해서 정의한 스키마는 다음과 같습니다.

struct Person {
  1: required string       userName,
  2: optional i64          favoriteNumber,
  3: optional list<string> interests
}

프로토콜 버퍼로 정의한 동등한 스키마는 다음과 같습니다.

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

두 라이브러리에서 스키마는 매우 비슷하다는 것을 알 수 있습니다. 두 라이브러리는 스키마 정의를 사용해 코드를 생성하는 도구가 있습니다. 이 도구를 사용하여 다양한 프로그래밍 언어로 스키마를 구현한 클래스를 생성합니다. 애플리케이션 코드는 생성된 코드를 호출해 스키마의 레코드를 인코딩하고 디코딩할 수 있습니다. 프로토콜 버퍼를 사용한 인코딩 예제는 다음 그림과 같습니다.

 

이미지 출처 : https://notes.shichao.io/dda/ch4/

 

이러한 스키마는 시간에 지남에 따라 변하게 된다고 말씀드렸습니다. 이러한 변화를 스키마 발전(schema evolution)이라고 부릅니다. 스리프트나 프로토콜 버퍼와 같은 바이너리 인코딩 라이브러리는 어떻게 하위 호환성과 상위 호환성을 유지하면서 스키마를 변경할 수 있을까요? 

 

이러한 바이너리 인코딩 라이브러리들이 상위 호환성을 유지하기 위해서는 필드에 새로운 태그 번호를 부여하는 방식으로 스키마에 새로운 필드를 추가합니다. 예전 코드에서 새로운 코드로 기록한 데이터를 읽으려는 경우 모르는 필드 번호는 무시하는 방식으로 호환성을 유지할 수 있는 것입니다. 

 

반대로 하위 호환성은 어떻게 유지할까요? 각 필드의 고유한 태그 번호가 있는 동안에는 태그 번호가 고유하기 때문에 새로운 코드는 예전 코드에서 기록한 데이터를 읽을 수 있습니다. 단지 하위 호환성 유지를 위해 필드 조건을 required로 할 수 없습니다. 하위 호환성 유지를 위해서는 스키마 초기 배포 후에 추가되는 모든 필드는 optional이거나 기본 값을 가져야 합니다. 필드 삭제는 optional 필드만 삭제할 수 있고 같은 태그 번호는 다시 사용할 수 없습니다.

 

Dataflow

데이터플로는 추상적인 개념으로 하나의 프로세스에서 다른 프로세스로 데이터를 전달하는 방법을 이야기하며 많은 방법이 있습니다. 프로세스 간 데이터를 전달하는 가장 보편적인 방법 3가지에 대해 살펴보겠습니다.

데이터베이스를 통한 Dataflow

데이터베이스에 기록하는 프로세스는 데이터를 인코딩하고 데이터베이스에서 읽는 프로세스는 데이터를 디코딩합니다. 데이터베이스도 하위 호환성과 상위 호환성이 필요합니다. 데이터베이스 스키마에 새로운 필드를 추가하고 새로운 코드에서 새로운 필드를 위한 값을 기록하는 경우에 unknown 필드로 처리하고 애플리케이션 레벨에서 필드가 유실할 수 있다는 사실을 인지하고 있으면 됩니다. 

 

스키마 발전을 통해 기본 저장소가 여러가지 버전의 스키마로 인코딩 된 레코드를 포함해도 전체 데이터베이스가 단일 스키마로 인코딩 된 것처럼 보이게 합니다.

서비스를 통한 Dataflow

네트워크를 통해 통신해야 하는 프로세스가 있을 때 가장 일반적인 방법은 클라이언트와 서버의 두 역할로 배치하는 것입니다. 서버는 네트워크를 통해 API를 공개하고 클라이언트는 이 API로 요청을 만들어 서버에 연결할 수 있습니다. 서버가 공개한 API를 보통 서비스라고 합니다.

 

서버 자체가 다른 서비스의 클라이언트일 수 있습니다. 이런 접근 방식은 보통 대용량 애플리케이션의 기능 영역을 소규모 서비스로 나누는데 사용합니다. 이런 애플리케이션 개발 방식을 전통적으로는 서비스 지향 설계(service-oriented architecture, SOA)라고 불렀으며 최근에는 이를 더욱 개선한 마이크로서비스 설계(microservices architecture)라는 이름으로 사용합니다.

 

이러한 마이크로서비스 아키텍처의 핵심 설계 목표는 서비스를 배포와 변경에 독립적으로 만들고 애플리케이션 변경과 유지보수를 더 쉽게 할 수 있는 것입니다. 간단한 예를 들어 예전 버전과 새로운 버전의 서버와 클라이언트가 동시에 실행되기를 기대하는 것입니다. 서버와 클라이언트가 사용하는 데이터 인코딩은 서비스 API 버전 간 호환이 가능해야 합니다.

 

웹 서비스란 서비스와 통신하기 위한 기본 프로토콜로 HTTP를 사용하는 서비스를 말합니다. 웹 서비스에는 가장 대중적으로 사용하는 2가지 방법이 있습니다. REST와 SOAP입니다. REST는 프로토콜이 아니라 HTTP의 원칙을 토대로 한 설계 철학입니다. REST 원칙에 따라 설계된 API를 RESTful이라고 합니다. 반면에 SOAP은 네트워크 API 요청을 위한 XML 기반 프로토콜입니다. 

 

웹 서비스는 네트워크 상에서 API를 요청하기 위한 여러 기술 중 하나입니다. 이러한 웹 서비스는 원격 프로시저 호출(remote procedure call, RPC)의 아이디어를 기반으로 합니다. RPC의 경우 로컬 함수 호출과는 다르게 예측 가능하지 않습니다. 그 이유는 네트워크 문제 때문입니다. 언제 어디서 네트워크 문제가 발생할지 모르기 때문입니다. 이러한 RPC가 가지고 있는 단점에도 불구하고 RPC는 많은 곳에서 이용되고 있습니다. 다양한 인코딩 라이브러리 위에 다양한 RPC 프레임워크가 개발되었습니다. 예를 들어 스리프트와 아브로는 RPC 기능을 내장하고 있으며, gRPC는 프로토콜 버퍼를 이용한 RPC의 구현입니다. 피네글(Finagle)은 스리프트를 사용하고 Rest.li는 HTTP 위에 JSON을 사용합니다.

 

발전성이 있으려면 RPC 클라이언트와 서버를 독립적으로 변경하고 배포할 수 있어야 합니다. 서비스를 통한 Dataflow의 발전성의 경우 단순하게 다음과 같이 가정합니다. 모든 서버를 먼저 갱신하고 나서 모든 클라이언트를 갱신하는 것입니다. 그러면 요청은 하위 호환성만 필요하고 응답은 상위 호환성만 필요한 것이죠.

 

그러나 RPC는 서비스 호환성 유지를 더 어렵게 합니다. 그 이유는 서비스 제공자는 클라잉너트를 강제로 업그레이드할 수 없기 때문입니다. 호환성을 깨는 변경이 필요하면 서비스 제공자는 보통 여러 버전의 API를 함께 유지합니다. API 버전 관리는 정해진 방식은 보통 없지만 RESTful API의 경우 HTTP header에 버전 번호를 사용하는 것이 일반적입니다.

메시지 패싱 Dataflow

이번 절에서는 비동기 메시지 전달 시스템(asynchronous message-passing system)에 대해서 살펴볼 예정입니다. 이 시스템은 클라이언트 요청(보통 메시지라고 함)을 임시로 메시지를 저장하는 메시지 브로커(message broker)라는 중간 단계를 거쳐 전송하는 것이 데이터베이스와 유사합니다. 메시지 브로커를 사용하는 방식은 직접 RPC를 사용하는 방식과 비교했을 때 다음과 같은 장점이 있습니다.

 

  • 메시지 브로커가 버퍼처럼 동작할 수 있기 때문에 안정적
  • 메시지 유실 방지
  • 송신자는 수신자의 IP 주소나 포트 번호를 알 필요 없음
  • 하나의 메시지는 여러 수신자에게 전송 가능
  • 논리적으로 송신자와 수신자가 분리

이러한 메시지 패싱 통신의 경우 단방향으로 동작하는 것이 RPC와의 주된 차이점입니다. 즉 비동기적으로 동작하는 통신 패턴을 사용합니다.

 

이번 장에서는 데이터 구조를 네트워크나 디스크에 저장하기 위해 바이트 열로 변환하는 다양한 방법에 관해 살펴보았습니다. 인코딩 방법에 따라 효율성뿐만 아니라 애플리케이션의 아키텍처와 배포의 선택사항에도 많은 영향을 미칩니다. 애플리케이션의 변경을 쉽게 할 수 있는 발전성이 중요합니다. 이러한 발전성을 통해 애플리케이션의 변경사항을 반영하고 애플리케이션의 호환성을 제공할 수 있습니다. 이번장에서 데이터 인코딩 형식과 호환성 특징에 관해 살펴보았고 또한 여러 시나리오를 보여주는 다양한 데이터플로 모드에 대해서 살펴보았습니다. 애플리케이션 개발에 조금만 주의를 기울이면 상, 하위 호환성과 롤링 업그레이드 가능하다는 사실을 알 수 있었습니다.

 

애플리케이션의 발전은 더욱더 빨라지고 있고 배포 빈도도 높아지고 있기 때문에 인코딩과 발전성에 대해 더욱 신경을 써야할 것입니다.