사전 하위 호환
개념

하위 호환

gabury1

하위 호환은 새 버전이 옛 버전을 쓰던 쪽을 계속 받아 주는 성질입니다. 서버를 고쳐 올려도 옛 앱은 손대지 않은 채 돌아갑니다. 쓰는 쪽이 같은 날 함께 고치지 않아도 된다는 약속입니다.

쉽고 빠른 이해

하위 호환은 새 버전을 내놓아도 옛 쪽이 안 깨지게 막아 주는 약속입니다. 서버 응답에 항목을 하나 더해도, 그 항목을 모르는 옛 앱은 건너뛰고 하던 일을 계속합니다.

이게 없으면 만드는 쪽과 쓰는 쪽이 같은 순간에 함께 올라가야 합니다. 남의 손에 깔린 앱은 그렇게 못 합니다. 이미 디스크에 쌓인 옛 데이터는 고쳐 달라고 할 상대도 없습니다.

지키는 방법은 대개 셋입니다:

  1. 더하기만 합니다. 있던 것은 이름도 뜻도 안 바꿉니다
  2. 더한 것은 없어도 되게 두고, 안 들어오면 정해 둔 값으로 채웁니다
  3. 없앨 것은 미리 알리고 한참 뒤에 없앱니다

대가는 옛 길을 계속 들고 다니는 것입니다. 코드에 옛 길과 새 길이 함께 남고, 한 번 잘못 지은 이름도 오래 삽니다. 부르는 쪽을 내가 전부 쥐고 한 번에 같이 올릴 수 있으면 덜 챙겨도 됩니다. 남의 손에 깔린 앱과 이미 저장된 데이터에는 그런 예외가 없습니다.

상세

단골이 늘 시키던 이름 그대로 주문하면 늘 먹던 것이 나오는 가게를 떠올리면 됩니다. 메뉴가 몇 가지 늘어도 그 이름이 그대로면 단골은 메뉴판을 다시 볼 일이 없습니다. 이름은 그대로 두고 안에 든 것만 바꾸면, 단골은 아무 의심 없이 시키고 다른 것을 받아 갑니다.

하위 호환은 새 버전이 옛 버전을 쓰던 호출과 데이터를 받아도 하던 대로 도는 성질입니다. 서버 응답에 항목을 하나 더하는 변경이 그 예입니다. 옛 앱은 그 항목을 모르지만, 알던 항목은 그 이름 그대로 있으니 화면을 그리는 데 문제가 없습니다. 같아야 하는 것은 이름만이 아닙니다. 이름과 인자가 그대로여도 돌려주는 값의 뜻이 달라지면 깨집니다.

먼저 「하위」가 어느 쪽인지부터 가릅니다. 방향을 잘못 잡으면 뒤가 전부 뒤집힙니다.

「하위」가 가리키는 방향

「하위」는 옛 버전을 가리킵니다. 내 프로그램은 새것이고, 그 새것이 옛것 쪽으로 손을 내미는 그림입니다. 방향이 헷갈리기 쉬우니 누가 새 버전인지부터 정하고 읽으면 편합니다.

반대 방향에도 이름이 있습니다. 옛 프로그램이 나중에 나온 새 데이터를 만나고도 안 깨지면 그것은 상위 호환입니다. 둘은 같은 변경을 양쪽에서 부르는 말이 아니라 서로 다른 약속입니다.

이름 누가 새 버전인가 받는 쪽이 견디는 것
하위 호환 받는 쪽 옛 요청과 옛 데이터가 들어와도 돈다
상위 호환 보내는 쪽 새 요청과 새 데이터가 들어와도 안 깨진다

응답에 항목 하나를 더하는 변경이 둘을 가릅니다. 한 번의 주고받기에 방향이 둘입니다. 방향마다 이름이 다릅니다.

flowchart TD
    subgraph S1["하위 호환 · 견디는 쪽은 새 서버"]
        O1["옛 앱"] --> R1["옛 요청"] --> N1["새 서버"]
    end
    subgraph S2["상위 호환 · 견디는 쪽은 옛 앱"]
        N2["새 서버"] --> R2["모르는 항목이 섞인 응답"] --> O2["옛 앱"]
    end
    S1 --> S2

새 서버가 옛 앱의 요청을 그대로 처리하는 것은 서버의 하위 호환입니다. 옛 앱이 모르는 항목을 만나고도 멈추지 않는 것은 그 앱이 상위 호환으로 만들어졌기 때문입니다. 앱이 모르는 항목에서 오류를 내도록 짜여 있었다면, 서버가 아무리 조심해도 이 변경은 깨집니다.

호환을 챙기는 세 층

챙길 곳은 세 층입니다. 층마다 옛 쪽이 들고 있는 것이 다르고, 깨졌을 때 드러나는 모습도 다릅니다.

층 옛 쪽이 들고 있는 것 깨지면 나는 일
호출 접점 고치지 않은 옛 호출 코드 요청이 거절되거나 엉뚱한 답이 온다
데이터 옛 형식으로 적혀 디스크에 남은 바이트 읽다가 실패한다
기계어 접점 다시 빌드하지 않은 옛 실행 파일 실행 자체가 안 된다

첫째 층은 프로그램끼리 서로를 부르는 접점입니다. 함수 이름과 인자, 요청 주소와 응답 모양이 이 층에 들어갑니다. 이 접점을 API(Application Programming Interface, 응용 프로그램 인터페이스)라고 부릅니다.

둘째 층은 저장하고 주고받는 데이터입니다. 옛 형식으로 저장해 둔 것을 새 코드가 읽어야 합니다. 어떤 이름의 값이 어떤 모양으로 들어 있는지를 적어 둔 것을 스키마라고 합니다.

셋째 층은 기계어 수준의 접점입니다. 라이브러리를 새로 배포했을 때 그것을 쓰는 프로그램을 다시 빌드해야 하는지를 이 층이 가릅니다. 이 접점의 이름이 ABI(Application Binary Interface, 응용 프로그램 이진 인터페이스)입니다.

두 버전이 겹치는 구간

배포는 한순간에 끝나지 않습니다. 서버를 여러 대 굴리면 새 버전이 뜬 대수가 늘어나는 동안 옛 버전도 같이 요청을 받습니다. 이렇게 두 버전이 함께 도는 구간을 버전 혼재라고 합니다.

구간의 길이는 곳마다 다릅니다. 서버끼리면 몇 분입니다. 모바일 앱은 쓰는 사람이 갱신을 미루면 몇 달이 갑니다. 저장된 데이터는 지우지 않는 한 끝이 없습니다.

sequenceDiagram
    participant O as 옛 앱
    participant N as 새 앱
    participant S as 새 서버
    O->>S: 옛 모양 요청
    S-->>O: 항목 하나가 더 붙은 응답
    Note over O: 모르는 항목은 건너뛴다
    N->>S: 새 모양 요청
    S-->>N: 항목 하나가 더 붙은 응답

화살표 네 개가 모두 정상입니다. 새 서버는 두 모양의 요청을 다 받고, 어느 쪽에도 오류를 돌려주지 않습니다. 이 구간을 견디게 만드는 것이 하위 호환입니다.

견디지 못하면 선택지가 줄어듭니다. 옛 요청이 거절되기 시작하면 배포를 멈추고 옛 버전으로 되돌리는 롤백 말고는 길이 없습니다.

한쪽씩 조금씩 내보내며 지켜보는 카나리 배포도 두 버전이 겹쳐도 괜찮다는 전제 위에 서 있습니다.

안전한 변경과 깨는 변경

가르는 잣대는 하나입니다. 옛 쪽이 알던 것이 그 이름과 그 뜻으로 남아 있나. 남아 있으면 안전하고, 없어지거나 뜻이 바뀌면 깨집니다.

대체로 안전한 변경 깨는 변경
없어도 되는 항목을 더한다 있던 항목을 없애거나 이름을 바꾼다
새 함수나 새 주소를 더한다 반드시 채워야 하는 입력을 더한다
받아 주는 값의 범위를 넓힌다 받아 주는 값의 범위를 좁힌다
모르는 값을 만나면 건너뛴다 같은 입력에 다른 결과를 돌려준다

더하는 쪽이 안전하고 빼거나 좁히는 쪽이 깬다고 외워 두면 대개 맞습니다. 다만 더하기도 깰 때가 있습니다. 반드시 채워야 하는 입력을 하나 더하면, 그것을 모르는 옛 호출은 전부 거절됩니다.

표의 마지막 줄은 겉모양이 그대로여서 놓치기 쉽습니다. 이름도 인자도 같습니다. 돌려주는 값의 뜻만 바뀐 경우입니다. 옛 코드는 아무 오류 없이 돕니다. 그러면서 틀린 값을 쓰므로 눈에 띄는 데까지 오래 걸립니다.

잣대 하나와 예외 둘을 한 줄기로 세우면 이렇게 갈립니다.

flowchart TD
    A["옛 쪽이 알던 이름이 남았나"] -->|아니다| X["깬다"]
    A -->|그렇다| B["뜻이 그대로인가"]
    B -->|아니다| Y["조용히 깬다 · 오래 안 드러난다"]
    B -->|그렇다| C["반드시 채워야 하는 입력을 더했나"]
    C -->|그렇다| Z["깬다"]
    C -->|아니다| W["안전하다"]

더하기로 버티는 법

가장 자주 쓰는 방법은 새로 더한 것을 없어도 되게 두는 것입니다. 안 들어왔을 때 대신 쓸 값을 미리 정해 두면 됩니다. 그 값을 기본값이라고 합니다. 아래는 함수에 인자를 하나 더한 코드입니다.

greet(name, greeting="Hi")  // 기본값 "Hi"

greet("Eunsu")           // "Hi, Eunsu"
greet("Eunsu", "Hello")  // "Hello, Eunsu"

한 함수가 두 모양의 호출을 다 받습니다. 인사말을 안 넘기던 옛 코드는 한 줄도 안 고쳤는데 계속 돕니다. 둘째 인자는 새 코드만 씁니다.

데이터에서도 방법은 같습니다. 새로 더한 항목은 없어도 되게 두고, 그 항목이 없는 옛 데이터를 읽을 때는 기본값으로 채웁니다. 거꾸로 데이터를 읽는 쪽은 모르는 항목을 만나도 멈추지 말고 건너뛰게 만듭니다. 그렇게 해 두면 그 코드는 나중에 나올 새 데이터까지 견딥니다.

그래도 깨야 할 때

깨는 변경을 영영 못 하는 것은 아닙니다. 다만 옛 쪽이 옮겨 갈 시간을 주고 깹니다.

대상은 응답에서 항목 하나를 없애는 변경입니다. 옛 앱이 아직 그 항목을 읽고 있으니, 바로 빼지 않고 아래 순서로 갑니다.

flowchart TD
    A["새 방식을 더한다 · 옛 방식은 남겨 둔다"] --> B["없앨 것을 미리 알린다"]
    B --> C["둘 다 도는 동안 쓰는 쪽이 옮겨 온다"]
    C --> D["옛 방식을 없앤다 · 하위 호환이 끊긴다"]

끊는 때는 맨 끝입니다. 앞의 세 단계는 옛 쪽이 옮겨 올 시간을 버는 일이고, 그 시간을 벌기 위해 한동안 두 방식을 다 들고 있습니다. 미리 알리는 둘째 단계를 폐기 예고라고 합니다.

읽는 쪽이 이 끊김을 미리 알아볼 수 있게 버전 번호로 알리기도 합니다. 버전을 1.4.2 처럼 셋으로 적고, 맨 앞 칸인 1 을 올리면 깨는 변경이 들었다는 뜻으로 읽는 규칙이 시맨틱 버저닝입니다.

옛 접점을 없애지 않고 새 것을 그 옆에 세우는 방법도 씁니다. 주소 앞에 /v1 · /v2 를 붙여 옛 접점과 새 접점을 나란히 두고, 옛 쪽이 다 옮겨 갈 때까지 둘을 함께 굴립니다.

하위 호환의 대가

하위 호환은 공짜가 아닙니다. 옛 길과 새 길이 코드에 함께 남으므로 읽을 것도 시험할 것도 늘어납니다. 옛 방식을 남긴 곳이 여럿이면 그 조합만큼 확인할 경우가 늘어납니다.

늘어나는 모양은 더하기가 아니라 곱하기입니다.

flowchart TD
    subgraph L1["첫째 곳 · 길이 둘"]
        A1["옛 길"]
        A2["새 길"]
    end
    subgraph L2["둘째 곳 · 형식이 셋"]
        B1["옛 형식"]
        B2["중간 형식"]
        B3["새 형식"]
    end
    L1 --> L2 --> C["확인할 경우 2 × 3 = 6 · 곳이 하나 더 늘면 다시 곱해진다"]

한 번 잘못 지은 이름과 잘못 고른 기본값도 오래 삽니다. 밖으로 내보낸 다음에는 고치는 것 자체가 깨는 변경이 되기 때문입니다. 그래서 공개 API는 처음 내보내기 전에 이름을 손보는 편이 쌉니다.

되돌릴 수 있게 남겨 둔 옛 길이 새 기능을 막기도 합니다. 옛 쪽이 기대하는 동작을 지켜야 하니 안쪽 구조를 바꾸기 어려워집니다. 지키는 기간을 미리 밝혀 두는 것은 이 대가를 줄이는 방법입니다.

덜 챙겨도 되는 경우

언제나 끝까지 챙겨야 하는 것은 아닙니다. 덜 챙겨도 되는 경우가 셋 있고, 셋 다 단서가 붙습니다.

경우 덜 챙겨도 되는 조건 그래도 걸리는 것
부르는 쪽을 내가 전부 쥐고 있다 한 번에 같이 배포할 수 있다 서버가 여러 대면 겹치는 구간이 남는다
아직 아무도 안 쓴다 실험용이라고 미리 밝혀 두었다 안 밝혔으면 쓰는 쪽은 그것을 약속으로 받아들인다
옛 형식으로 저장된 데이터가 있다 없다 한 번에 옮기지 않는 한 읽는 쪽은 계속 챙겨야 한다

첫째 줄의 조건은 둘입니다. 부르는 쪽을 내가 전부 쥐고 있어야 하고, 한 번에 같이 배포할 수 있어야 합니다. 둘 다 맞으면 덜 챙겨도 됩니다. 사내에서만 쓰는 서비스 둘 사이가 그런 경우입니다.

다만 같이 배포한다는 말이 같은 순간을 뜻하는지는 확인해야 합니다. 서버가 여러 대면 겹치는 구간은 여전히 생깁니다.

데이터는 예외가 적습니다. 옛 형식으로 저장된 것을 지울 수 없다면, 읽는 쪽의 하위 호환은 계속 필요합니다. 옛 데이터를 새 형식으로 한 번에 옮기는 데이터베이스 마이그레이션을 하지 않는 한 그렇습니다.

관련 항목

하위 호환과 방향이 맞세워지는 개념

상위 호환성 · 양방향 호환성 · 하위 비호환 변경 · 브레이킹 체인지 · 상호운용성

하위 호환이 지켜지는지 알리는 버전 규칙

시맨틱 버저닝 · 메이저 버전 · 마이너 릴리스 · 패치 릴리스 · 버전관리 · 안정성 수준

하위 호환을 챙겨야 하는 접점

API · API 설계 · 공개 API · 프로토콜 · ABI · 공유 라이브러리 · 동적 링크

데이터 쪽에서 호환을 다루는 장치

스키마 · 스키마 진화 · 직렬화 · 역직렬화 · 필드 · 기본값 · 데이터베이스 마이그레이션

두 버전이 겹치는 구간을 만드는 배포 방식

배포 · 카나리 배포 · 블루-그린 배포 · 단계적 출시 · 롤링 업데이트 · 버전 혼재 · 롤백

옛 방식을 걷어내는 단계

폐기 예고 · 유예 기간 · 기능 플래그 · 이중 쓰기 · 확장-수축 패턴

호환 약속을 적어 두는 문서

서비스 수준 계약 · 릴리스 노트 · 변경 로그 · 지원 기간 · 인터페이스 정의 언어

다른 이름: 하위 호환성 · backward compatibility · 하위호환 · 역방향 호환성