하위 호환
하위 호환은 새 버전이 옛 버전을 쓰던 쪽을 계속 받아 주는 성질입니다. 서버를 고쳐 올려도 옛 앱은 손대지 않은 채 돌아갑니다. 쓰는 쪽이 같은 날 함께 고치지 않아도 된다는 약속입니다.
쉽고 빠른 이해
하위 호환은 새 버전을 내놓아도 옛 쪽이 안 깨지게 막아 주는 약속입니다. 서버 응답에 항목을 하나 더해도, 그 항목을 모르는 옛 앱은 건너뛰고 하던 일을 계속합니다.
이게 없으면 만드는 쪽과 쓰는 쪽이 같은 순간에 함께 올라가야 합니다. 남의 손에 깔린 앱은 그렇게 못 합니다. 이미 디스크에 쌓인 옛 데이터는 고쳐 달라고 할 상대도 없습니다.
지키는 방법은 대개 셋입니다:
- 더하기만 합니다. 있던 것은 이름도 뜻도 안 바꿉니다
- 더한 것은 없어도 되게 두고, 안 들어오면 정해 둔 값으로 채웁니다
- 없앨 것은 미리 알리고 한참 뒤에 없앱니다
대가는 옛 길을 계속 들고 다니는 것입니다. 코드에 옛 길과 새 길이 함께 남고, 한 번 잘못 지은 이름도 오래 삽니다. 부르는 쪽을 내가 전부 쥐고 한 번에 같이 올릴 수 있으면 덜 챙겨도 됩니다. 남의 손에 깔린 앱과 이미 저장된 데이터에는 그런 예외가 없습니다.
상세
단골이 늘 시키던 이름 그대로 주문하면 늘 먹던 것이 나오는 가게를 떠올리면 됩니다. 메뉴가 몇 가지 늘어도 그 이름이 그대로면 단골은 메뉴판을 다시 볼 일이 없습니다. 이름은 그대로 두고 안에 든 것만 바꾸면, 단골은 아무 의심 없이 시키고 다른 것을 받아 갑니다.
하위 호환은 새 버전이 옛 버전을 쓰던 호출과 데이터를 받아도 하던 대로 도는 성질입니다. 서버 응답에 항목을 하나 더하는 변경이 그 예입니다. 옛 앱은 그 항목을 모르지만, 알던 항목은 그 이름 그대로 있으니 화면을 그리는 데 문제가 없습니다. 같아야 하는 것은 이름만이 아닙니다. 이름과 인자가 그대로여도 돌려주는 값의 뜻이 달라지면 깨집니다.
먼저 「하위」가 어느 쪽인지부터 가릅니다. 방향을 잘못 잡으면 뒤가 전부 뒤집힙니다.
「하위」가 가리키는 방향
「하위」는 옛 버전을 가리킵니다. 내 프로그램은 새것이고, 그 새것이 옛것 쪽으로 손을 내미는 그림입니다. 방향이 헷갈리기 쉬우니 누가 새 버전인지부터 정하고 읽으면 편합니다.
반대 방향에도 이름이 있습니다. 옛 프로그램이 나중에 나온 새 데이터를 만나고도 안 깨지면 그것은 상위 호환입니다. 둘은 같은 변경을 양쪽에서 부르는 말이 아니라 서로 다른 약속입니다.
| 이름 | 누가 새 버전인가 | 받는 쪽이 견디는 것 |
|---|---|---|
| 하위 호환 | 받는 쪽 | 옛 요청과 옛 데이터가 들어와도 돈다 |
| 상위 호환 | 보내는 쪽 | 새 요청과 새 데이터가 들어와도 안 깨진다 |
응답에 항목 하나를 더하는 변경이 둘을 가릅니다. 한 번의 주고받기에 방향이 둘입니다. 방향마다 이름이 다릅니다.
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 · 하위호환 · 역방향 호환성