Skip to content

Latest commit

 

History

History
84 lines (45 loc) · 12.6 KB

forward-and-backward-compatibility.md

File metadata and controls

84 lines (45 loc) · 12.6 KB

상위호환성과 하위호환성

구 버전의 소프트웨어와 새 버전의 소프트웨어가 공존하는 상황은 피할 수 없는 숙명이다. 네이티브 앱은 자동 업데이트를 꺼놓은 경우 수년간 구버전이 유지될 수 있다. 비교적 자유롭게 배포할 수 있는 서버 사이드에서도, 모든 서버를 동시에 배포하는 것은 서비스 중단 점검을 하지 않고서야 불가능하다. 따라서 호환성을 지키는 것은 시스템이 올바르게 동작하고 유저에게 비즈니스 가치를 최대한으로 전달하기 위해 반드시 필요한 작업이다.

호환성의 종류

호환성에는 크게 두 가지 종류가 있다.

  • 상위호환성 - 새로운 시스템이 기존 시스템의 출력을 이해하는 성질
  • 하위호환성 - 기존 시스템이 새로운 시스템의 출력을 이해하는 성질

헷갈리는 개념이라서, 예시를 통해 알아보도록 하자. 커스텀한 포맷으로 파일을 쓰고 읽는 어떤 소프트웨어가 있다. 이 때 구 버전의 소프트웨어가 적은 파일을 새 버전의 소프트웨어가 잘 읽고 처리할 수 있으면, 해당 소프트웨어는 하위호환성을 가진다고 이야기한다. 반대로, 새 버전의 소프트웨어가 적은 파일을 구 버전의 소프트웨어가 잘 읽고 처리할 수 있으면, 해당 소프트웨어는 상위호환성을 가지는 것이다.

일반적으로 상위호환성은 지키기 어렵다. 미래에 소프트웨어가 어떻게 변할지를 미리 예측해야 하기 때문이다. 위의 데이터 상위호환성을 설명할 때 든 예시를 보면, 미래에 데이터 포맷이 어떤 식으로 변할지 지금 시점에서 어떻게 알겠는가? 그래서 상위호환성은 보통 지키는 것이 아니라 지켜야 하는 상황을 피해가는 것이 중요하다.

한편, 하위호환성은 이미 배포된 소프트웨어가 어떤 식으로 동작하는지 잘 알고 있기 때문에 비교적 잘 지킬 수 있다. 개발자는 분기 로직을 적절히 추가하기만 하면 된다. 예를 들어 위의 파일을 쓰고 적는 소프트웨어의 경우, 하위환성을 지키기 위해서는 읽어들이는 파일의 포맷 버전을 알아낸 뒤 각 버전에 맞는 서로 다른 처리 로직을 적용할 수 있다.

서버 개발을 하면서 호환성을 고려해야 하는 부분은 크게 두 가지가 있다.

  • API
  • 데이터

API의 호환성

어떤 서버가 노출한 API를 다른 클라이언트가 사용하는 경우 호환성이 고려되어야 한다.

API의 경우, 상위호환성은 보통 고려할 필요가 없다. 왜냐하면 API를 노출하는 소프트웨어는 API를 사용하는 소프트웨어보다 항상 먼저 배포되어야 하기 때문이다. 따라서 API 작성에서는 하위호환성만 고려하면 된다.

API의 하위호환성은 세 가지 포인트만 기억하면 어렵지 않게 지킬 수 있다.

  • API에는 새로운 필드 추가만 한다 - 기존에 존재하는 API의 기능을 확장하고 싶다면 반드시 새로운 필드를 추가하기만 한다. 이는 클라이언트가 보내는 요청과 서버가 내려주는 응답 모두에 해당한다. API에 존재하는 기존 필드를 변경하거나 삭제하지 않으면 구 클라이언트는 요청과 응답에 새로운 필드가 추가된지 모른 채 정상 동작할 것이다.

  • 추가한 필드에 대해 구 클라가 default로 올리는 값을 고려한다 - request body에 새 필드를 추가하는 경우, 구 클라이언트는 해당 필드를 비운 채로 요청을 보낼 것이다. 이 때 서버가 빈 필드를 deserialize하는 로직에 대해 잘 알고, 구 클라이언트가 비워서 올려준 케이스를 잘 처리할 수 있도록 서버 로직을 짜야 한다.

    예를 들어, API 요청에 boolean 필드를 하나 추가했는데, 클라이언트가 해당 필드를 비워서 요청을 날리면 deserialization 과정에서 false로 변환된다고 하자. 이러면 서버 입장에서 요청을 받았을 때 해당 값에 false가 들어 있으면 구 클라이언트가 비워서 보낸 건지, 새 클라이언트가 false로 채워서 보낸 건지 알 수 없게 된다. 이게 문제인지 아닌지는 API가 수행하는 로직에 따라 다를 것이다.

    이 default 값이 문제가 되지 않도록 하기 위해서는 여러가지 조치가 필요할 수도 있다. 예를 들어 필드 타입을 nullable로 바꾸거나, 서버 로직을 보강하거나, 새 클라이언트는 default 값을 올리지 않도록 서버 개발자와 클라이언트 개발자가 합의를 볼 수 있다.

  • 클라이언트는 API 응답에 존재하는 모르는 필드를 무시한다 - serialization / deserialization 도구 중에는 deserialization 과정에서 모르는 값이 있으면 에러를 내는 경우가 종종 있다. 이는 하위호환성에 치명적인데, 위에서 본 필드 추가를 통한 API의 확장과 개선을 불가능하게 만들기 때문이다. 따라서 클라이언트는 서버가 내려준 응답에 모르는 필드가 포함된 경우 deserialization 과정에서 이를 무시하고 에러를 던지지 않도록 구현되어야 한다.

API의 하위호환성에서 고려해야 할 한 가지 포인트는 클라이언트의 종류이다. 웹이나 다른 서버 등 즉시 배포되어 구 클라이언트가 오래 남지 않을 수 있는 경우면 하위호환성을 고려하지 않아도 괜찮을 수 있다. 잠시 클라이언트가 동작하지 않을 수는 있지만, 금방 복구된다. 하지만 네이티브 앱이나 레거시 서버와 같이 구 버전의 클라이언트가 오래 남아 있을 수밖에 없는 경우에는 하위호환성을 반드시 고려해야 한다.

데이터의 호환성

어떤 소프트웨어가 publish한 데이터를 다른 소프트웨어가 사용하는 경우 호환성이 고려되어야 한다.

내용을 더 진행하기에 앞서, 두 가지 용어를 정의하고 가려고 한다. 앞으로 이 글에서는 데이터를 생산하고 저장하는 소프트웨어를 publisher, 데이터를 읽고 소비하는 소프트웨어를 consumer라고 부르겠다. 서버 - 클라이언트라고 부르지 않는 이유는, 보통 데이터를 생산하고 소비하는 양쪽이 모두 서버일 가능성이 높기 때문이다.

일반적인 엔터프라이즈 어플리케이션은 보통 데이터를 저장하고 전파시키기 위한 다양한 도구를 포함한다. 서버는 수시로 데이터베이스, 태스크 큐, 데이터 스트림, 캐시 등의 도구에 데이터를 새롭게 쌓고, 읽고, 수정한다. 이를 다른 관점에서 보면, 데이터 관련 도구는 서버간의 통합을 도와주는 하나의 인터페이스로 작용한다. 따라서 데이터의 스키마와 데이터에 적히는 값에 대해 호환성을 유지하는 것이 필요하다.

데이터의 호환성을 고려해야 하는 상황은 크게 데이터의 스키마가 변경되는 경우와 적재되는 값이 변경되는 경우로 나눌 수 있다.

데이터의 스키마가 변경되는 경우

가장 자주 일어나는 케이스는 RDBMS에서 새로운 테이블이나 컬럼을 추가하는 경우이다. 이 경우, 스키마를 변경하기 전에 서버를 배포하면 서버가 데이터를 읽는 데에 실패할 수 있다. 따라서 해당 테이블을 참조하는 서버를 배포하기에 앞서 반드시 데이터베이스 스키마부터 배포되어야 한다.

여기에서의 가정은, API의 호환성에서 이야기했던 것처럼 서버가 모르는 column을 무시하고 데이터를 읽을 수 있도록 작성되어 있다는 것이다. 스키마를 먼저 배포하면 데이터베이스 테이블에는 새로운 column이 추가되었는데 서버가 아직 배포되지 않아 새 column을 알지 못하는 상태가 반드시 존재하게 되는데, 이 경우 서버가 정상 동작해야 한다.

이를 일반화하면 다음과 같은 원칙을 세울 수 있다 : 스키마가 변경되는 경우, 스키마부터 배포하라.

적재되는 값이 변경되는 경우

두 개의 서버 1, 서버 2가 하나의 데이터베이스 테이블을 공유하는 시스템을 예시로 들어보자. 해당 테이블에는 enum 값이 저장되어 있는 column이 있다. 현재 가능한 enum 값은 A, B, C 3가지이다. 이 때 이 enum에 새로운 값 D를 추가하려고 한다. 만약 서버 1이 먼저 배포되어서 테이블에 값 D가 적히기 시작했다면, 서버 2에서 장애가 발생할 수 있다. 왜냐하면 서버 2는 아직 D라는 enum 값을 모르는 상태이므로, 해당 테이블의 값을 읽어서 객체로 변환할 때 D라는 값을 해당 enum type으로 형변환하는 데에 실패할 것이기 때문이다.

이 외에도 다양한 상황이 있을 수 있다. 태스크 큐에 새로운 종류의 태스크가 추가됐는데 태스크를 처리하는 워커가 배포되지 않아 새 태스크를 인식할 수 없다거나, 데이터 스트림에 적재하는 데이터의 값이 달라졌는데 데이터 스트림 처리기가 배포되지 않아 데이터를 해석하지 못할 수도 있다.

위에서 든 예시는 모두 상위호환성이 지켜지지 않는 상황들인데, consumer가 해석할 수 없는 데이터를 producer가 생산하기 때문에 발생한다. 따라서 producer보다 consumer를 먼저 배포하면 이런 상위호환성 문제를 피할 수 있다. enum 예시에서는 서버 2를 서버 1보다 먼저 배포했다면 서버 2에서 장애가 발생하는 일은 없었을 것이다. 태스크 큐와 데이터 스트림의 경우도 마찬가지로 워커와 데이터 스트림 처리기를 먼저 배포하면 문제가 없다.

즉, 우리는 다음과 같은 원칙을 얻는다 : producer 보다 consumer 먼저 배포하라.

적재되는 값이 달라지는 경우 - producer와 consumer가 명확히 나뉘지 않는다면?

대부분의 엔터프라이즈 어플리케이션은 scaling과 SPOF 회피를 위해 한 종류의 서버를 여러 대 띄운다. 이런 상황에서 취하는 일반적인 배포 전략인 rolling update와 canary 배포에서는 배포 도중 한 서버의 구 버전과 신 버전이 공존하는 시기가 존재한다. 이 때 하나의 서버가 동시에 producer이자 consumer인 경우, consumer를 먼저 배포한다는 원칙을 지킬 수 없다.

이로 인해 발생하는 문제는 상위호환성을 지켜야 할 필요성이 생긴다는 점이다. rolling update나 canary 배포가 진행되는 동안 구 버전의 서버는 새 버전의 서버가 적는 데이터를 정상적으로 읽고 처리할 수 있어야만 한다. 특히 canary는 배포 기간이 길기 때문에 더욱 오래 상위호환성을 유지해야 한다. 하지만 상위호환성은 지키는 것이 아예 불가능할 수도 있다. enum이 추가되는 상황에서 도대체 어떻게 상위호환성을 지킬 수 있는가?

그래서 우리는 상위호환성을 지켜야 하는 상황을 피해가야 한다. 피해가는 기본적인 전략은 간단하다. 데이터를 produce 하는 코드 전에 consume 하는 코드를 먼저 배포하는 것이다. 이를 기반으로, 위 문제를 피할 수 있는 두 가지 방법에 대해 알아보자.

  • multi-step 배포 -  새 데이터를 읽을 수 있는 상태로 서버를 먼저 배포하고, 그 이후에 새 데이터를 적재하는 로직이나 읽고 처리하는 로직을 배포하면 상위호환성 문제를 회피할 수 있다. 아까 위에서 살펴본 enum 문제를 예시로 들면, enum type에 값 D를 추가한 코드만 먼저 배포하고, 그 다음 값 D를 적재하고 사용하는 코드를 다시 배포하는 것이다.
  • feature flag - feature flag를 활용해서 consumer 부터 배포되는 것과 동일한 효과를 발휘할 수 있다. consumer 부터 배포하라는 원칙의 핵심은, 데이터를 생산하기 전에 서버가 데이터를 소비할 수 있는 상태로 만들어두라는 뜻이다. 이 말은 producer가 먼저 배포되더라도 새 종류의 값을 적지만 않는다면 문제가 없다는 뜻이다. 따라서, 특정 시점 이후로 데이터를 produce 하게끔 feature flag를 통해 구현해놓고 서버를 미리 배포하면 상위호환성 문제가 발생하지 않는다.

그러므로, 아까 살펴본 두 가지 원칙을 다시 정리하면 다음과 같이 수정할 수 있을 것이다.

  • 스키마가 변경되는 경우, 스키마부터 배포하라.
  • produce 하기 전에 consumer 먼저 배포하라.