하위 비호환 변경
고친 사람 github-actions[bot]
하위 비호환 변경은 옛 버전에 맞춰 짠 코드를 더는 돌지 않게 만드는 변경입니다. 함수 이름을 바꾸거나 응답에서 항목 하나를 빼는 일이 그렇습니다. 쓰는 쪽이 자기 코드를 고쳐야 하므로 내는 쪽은 이런 변경을 따로 표시하고 미리 알립니다.
쉽고 빠른 이해
무슨 일을 하는 물건인가 — 쓰는 쪽 코드를 깨뜨리는 변경입니다. 라이브러리가 getUser 를 fetchUser 로 이름만 바꿔도, 옛 이름을 부르던 앱은 새 버전에서 멈춥니다.
왜 따로 가르나 — 깨는 변경이 아무 표시 없이 섞여 오면 쓰는 쪽은 버전을 올린 뒤에야 알아챕니다. 깨는 변경이라고 미리 갈라 두면 버전을 올리기 전에 고칠 곳을 압니다.
어떻게 도나
- 새 방식을 먼저 더합니다. 옛 방식은 곧 없앤다고 알립니다
- 쓰는 쪽이 새 방식으로 옮겨 갈 시간을 줍니다
- 옛 방식을 없애는 버전에서 버전 번호의 맨 앞 칸을 올립니다
대가 — 쓰는 쪽마다 코드를 고치고 다시 시험하는 일이 생깁니다. 그 일을 덜어 주려면 내는 쪽이 옛 방식과 새 방식을 한동안 함께 돌봐야 합니다.
언제 깨나 — 옛 동작이 보안 구멍일 때, 잘못 지은 구조가 새 기능을 막을 때 깹니다. 쓰는 쪽이 전부 한 팀 안에 있으면 함께 고치면 되니 부담이 작습니다.
상세
어느 날 건물 관리실이 벽의 콘센트를 전부 새 모양으로 바꿨다고 해 봅시다. 냉장고도 노트북도 멀쩡합니다. 그런데 플러그가 안 맞아 아무것도 못 꽂습니다. 집집마다 어댑터를 사거나 플러그를 갈아야 다시 씁니다.
API(Application Programming Interface)는 한 프로그램이 다른 프로그램에게 내놓은 사용법입니다. 함수 이름과 인자, 웹 주소와 응답 모양이 모두 여기에 듭니다. 쓰는 쪽은 이 사용법에 맞춰 자기 코드를 짭니다.
하위 호환은 새 버전이 옛 버전에 맞춰 짠 코드를 계속 받아 주는 성질입니다. 하위 비호환 변경은 이 성질을 깨는 변경입니다. 실무에서는 영어 그대로 브레이킹 체인지(breaking change)라고 많이 부릅니다.
앞의 콘센트에 대 보면 이렇습니다. 콘센트 모양이 API, 플러그가 쓰는 쪽 코드입니다. 콘센트를 새 모양으로 바꾼 일이 하위 비호환 변경입니다.
메일 라이브러리의 send 함수는 1.x 버전에서 받는 사람과 본문만 받았습니다. 2.0 버전에서는 보내는 사람도 반드시 받도록 바뀌었습니다.
# 1.x: def send(to, body)
# 2.0: def send(to, body, sender)
send("[email protected]", "hi") # 1.x: 보낸다
send("[email protected]", "hi") # 2.0: TypeError
앱 쪽 코드는 한 글자도 안 바뀌었습니다. 라이브러리 버전만 올렸습니다. 그런데 같은 줄이 오류를 냅니다. TypeError 는 인자의 개수나 종류가 안 맞을 때 파이썬이 내는 오류입니다.
무엇이 깨지는지 정하는 약속
어떤 변경을 「깬다」고 치는지는 두 기준으로 갈라 봐야 합니다. 내는 쪽이 한 약속과, 쓰는 쪽이 실제로 기댄 동작입니다.
공개 API는 밖에서 쓰라고 내놓은 부분입니다. 내는 쪽이 「이렇게 쓰면 이렇게 돈다」고 약속한 범위이기도 합니다. 공개 API 를 바꾸면 공식적인 하위 비호환 변경입니다. 라이브러리 안에서만 쓰는 함수는 밖에서 부르는 코드가 없어서, 이름을 바꿔도 깨는 변경이 아닙니다.
쓰는 쪽이 많아지면 약속 밖의 동작에도 누군가 기댑니다. 오류 메시지의 문구나 목록이 돌아오는 순서 같은 것입니다. 이런 것을 바꾸면 약속을 어기지 않고도 누군가 깨집니다.
버그 고침도 같은 경우입니다. 쓰는 쪽 코드가 버그 동작에 맞춰 짜여 있으면, 버그를 고치는 순간 그 코드가 틀립니다.
정리하면 약속을 깨면 누구에게나 하위 비호환 변경입니다. 약속 밖 동작을 바꾸면 거기 기댄 쓰는 쪽만 깨집니다. 그래서 내는 쪽은 약속하는 것과 약속하지 않는 것을 문서에 갈라 적어 둡니다.
깨는 변경의 흔한 모양
깨는 변경은 몇 가지 꼴로 되풀이됩니다. 아래 표가 흔한 꼴과 깨지는 까닭입니다.
| 변경 | 깨지는 까닭 |
|---|---|
| 함수나 필드를 없애거나 이름을 바꾼다 | 옛 이름을 부르던 코드가 대상을 못 찾는다 |
| 반드시 넣어야 하는 인자를 더한다 | 그 인자를 모르는 옛 호출이 거절된다 |
| 값의 타입을 바꾼다 | 숫자로 읽던 코드가 문자열을 받는다 |
| 받아 주는 입력의 범위를 좁힌다 | 전에 받아 주던 입력이 거절된다 |
| 돌려주는 값의 범위를 넓힌다 | 쓰는 쪽이 처음 보는 값을 받을 수 있다 |
| 같은 입력에 다른 결과를 낸다 | 옛 결과에 맞춘 판단이 틀려진다 |
대체로 더하는 변경은 안전합니다. 빼거나 바꾸는 변경은 깹니다. 다만 표의 둘째 줄처럼 더하기가 깰 때도 있습니다.
입력과 출력은 방향이 반대입니다. 입력은 받아 주는 범위를 넓히면 안전합니다. 좁히면 깹니다. 출력은 거꾸로, 돌려주는 범위를 넓히면 깰 수 있습니다.
출력 쪽 예입니다. 주문 상태가 paid 와 shipped 둘뿐이던 응답에 refunded 가 새로 더해집니다. 두 값만 다루도록 분기를 짠 쓰는 쪽은 처음 보는 값에서 막힙니다.
소리 없이 깨지는 변경
모양은 그대로 두고 뜻만 바꾸는 변경도 있습니다. 이런 변경은 오류 없이 깨집니다.
정적 타입 언어는 변수와 인자의 타입을 실행 전에 검사하는 언어입니다. 이름이나 인자가 바뀐 변경은 이런 언어에서 빌드가 실패해 빨리 드러납니다. 정적 타입이 아닌 언어라도 첫 호출에서 오류가 납니다.
뜻만 바뀌면 사정이 다릅니다. 연결 함수의 timeout 인자는 1.x 에서 초 단위였습니다. 이것이 2.0 에서 밀리초 단위로 바뀌었다고 해 봅시다.
connect(timeout=30) # 1.x: 30초 기다림
connect(timeout=30) # 2.0: 0.03초 기다림
두 줄은 글자가 같습니다. 둘 다 오류 없이 돕니다. 2.0 에서는 기다리는 시간이 천분의 일로 줄어 요청이 줄줄이 시간 초과로 끊깁니다. 원인이 코드에 안 보여서 찾는 데 오래 걸립니다.
회귀는 새 버전으로 바꾼 뒤 전에 되던 것이 안 되는 일입니다. 알리지 않은 하위 비호환 변경은 쓰는 쪽에서 회귀로 드러납니다.
회귀 테스트는 전에 되던 동작이 여전히 되는지 다시 확인하는 시험입니다. 버전을 올릴 때 이 시험을 돌리면 소리 없는 변경을 잡아내기도 합니다.
깨지는 대상마다 다른 모습
하위 비호환 변경은 함수에만 생기지 않습니다. 무엇을 바꾸느냐에 따라 깨지는 모습이 다릅니다.
| 바꾸는 것 | 쓰는 쪽 | 깨지는 모습 |
|---|---|---|
| 라이브러리 | 그 라이브러리를 가져다 쓰는 코드 | 빌드가 실패하거나 호출이 오류를 낸다 |
| 웹 API | 요청을 보내는 앱과 다른 서비스 | 요청이 거절되거나 응답을 못 읽는다 |
| 데이터 포맷과 스키마 | 저장해 둔 데이터를 읽는 프로그램 | 옛 데이터를 못 읽는다 |
| 명령줄 도구 | 그 도구를 부르는 스크립트 | 옵션을 못 알아듣고 멈춘다 |
대상마다 깨지는 시점도 다릅니다. 라이브러리는 쓰는 쪽이 버전을 올릴 때까지 안 깨집니다. 웹 API 는 서버를 올리는 순간 모든 쓰는 쪽이 한꺼번에 새 버전을 받습니다.
컴파일하는 언어의 라이브러리는 깨지는 단계가 둘로 갈립니다. 첫째는 쓰는 쪽 코드를 다시 빌드할 때입니다. 쓰는 쪽 소스 코드를 고치지 않고 새 버전에 맞춰 다시 빌드할 수 있으면 소스 호환입니다.
둘째는 이미 빌드해 둔 프로그램을 그대로 돌릴 때입니다. 다시 빌드하지 않고도 새 버전과 함께 돌면 바이너리 호환입니다.
두 호환은 따로 놉니다. 함수 인자의 타입을 int 에서 long 으로 넓히면 쓰는 쪽 소스는 그대로 다시 빌드됩니다. 하지만 옛 버전으로 빌드해 둔 프로그램은 실행할 때 옛 모양의 함수를 찾다가 멈출 수 있습니다. 이런 깨짐은 쓰는 쪽이 다시 빌드하기만 하면 낫습니다.
버전 번호로 알리기
내는 쪽은 깨는 변경이 들었다는 사실을 버전 번호로 알립니다. 쓰는 쪽은 그 번호만 보고 새 버전을 받을지 정합니다.
시맨틱 버저닝(Semantic Versioning)은 버전 번호를 2.3.1 처럼 세 칸으로 적는 규칙입니다. 칸마다 그 칸을 올리는 때가 정해져 있습니다.
| 칸 | 이름 | 올리는 때 | 예 |
|---|---|---|---|
| 맨 앞 | 메이저 버전 | 하위 비호환 변경이 하나라도 들 때 | 2.3.1 → 3.0.0 |
| 가운데 | 마이너 버전 | 깨지 않고 기능을 더할 때 | 2.3.1 → 2.4.0 |
| 끝 | 패치 버전 | 깨지 않고 버그를 고칠 때 | 2.3.1 → 2.3.2 |
앞 칸을 올리면 뒤 칸은 0 으로 돌아갑니다. 그래서 쓰는 쪽은 맨 앞 칸만 보고도 깨는 변경이 들었는지 압니다.
의존성은 프로젝트가 가져다 쓰는 남의 코드입니다. 웹 프레임워크나 데이터베이스 드라이버가 흔한 의존성입니다.
패키지 매니저는 의존성을 내려받고 버전을 맞춰 주는 도구입니다. 의존성이 수십 개로 늘면 사람이 일일이 받아 둘 수 없어서 씁니다.
많은 패키지 매니저는 의존성의 버전을 하나로 박는 대신 범위로 적게 해 줍니다. 이렇게 적은 범위가 버전 범위입니다. 「2 로 시작하는 버전」으로 적어 두면 마이너 버전과 패치 버전은 들어오고 3.0.0 은 막힙니다.
버전 번호의 약속을 믿는 셈입니다. 깨지 않는 버전은 저절로 받습니다. 깨는 버전만 사람이 골라 받습니다.
변경 로그는 버전마다 무엇이 바뀌었는지 적은 기록입니다. 깨는 변경은 변경 로그에서 따로 묶어 눈에 띄게 적습니다.
마이그레이션 가이드는 옛 버전에서 새 버전으로 옮겨 가는 방법을 적은 문서입니다. 깨는 변경이 든 버전을 낼 때 함께 냅니다.
깨기 전에 거치는 단계
깨는 변경을 한 버전에 몰아 내면 쓰는 쪽은 버전을 올리는 날 전부 고쳐야 합니다. 그래서 대개 몇 버전에 걸쳐 나눕니다. 함수 이름을 getUser 에서 fetchUser 로 바꾸는 일을 가지고 따라가 봅니다.
순서는 셋입니다. 먼저 새 방식을 더합니다. 옛 방식은 곧 없앤다고 알립니다. 한참 지난 뒤에 옛 방식을 없앱니다.
사용 중단(deprecation)은 곧 없앤다고 알리는 일입니다. 사용 중단된 함수는 아직 돕니다. 대신 부를 때마다 경고를 내거나 문서에 표시가 붙습니다. 쓰는 쪽은 그 경고를 보고, 옛 방식이 없어지기 전에 새 방식으로 옮깁니다.
sequenceDiagram
participant 라이브러리
participant 앱
라이브러리->>앱: 1.4 버전 · fetchUser 를 더한다
Note over 라이브러리,앱: getUser 도 돈다 · 부르면 경고를 낸다
앱->>앱: fetchUser 로 옮긴다
라이브러리->>앱: 2.0 버전 · getUser 를 없앤다
Note over 앱: 이미 옮겨서 안 깨진다
1.4 는 깨지는 쪽이 없어서 마이너 버전으로 냅니다. 2.0 에서 옛 함수를 없애는 순간이 하위 비호환 변경입니다. 그때 메이저 버전을 올립니다.
웹 API 와 데이터에서 나눠 바꾸기
웹 API 와 데이터베이스는 쓰는 쪽이 버전을 고를 수 없습니다. 내는 쪽이 바꾸는 순간 모두가 새 버전을 받습니다.
그래서 웹 API 는 옛 버전과 새 버전을 한동안 함께 띄웁니다. /v1/users 와 /v2/users 처럼 주소에 버전 번호를 넣는 API 버전 관리가 흔한 방법입니다.
사용자의 폰에 깔린 앱처럼 내가 못 고치는 쓰는 쪽도 있습니다. 그런 앱은 사용자가 업데이트할 때까지 옛 요청을 계속 보냅니다. 이런 쓰는 쪽이 많을수록 옛 버전을 오래 남겨야 합니다.
데이터베이스의 스키마도 같은 문제를 겪습니다. 스키마는 테이블과 열의 이름과 타입을 정한 틀입니다. 서버 코드는 이 틀에 맞춰 데이터를 읽으므로, 열 이름 하나만 바꿔도 읽는 쪽이 영향을 받습니다.
롤링 업데이트는 서버를 몇 대씩 차례로 새 버전으로 바꾸는 배포 방식입니다. 서비스를 멈추지 않고 배포하려고 씁니다. 그 도중에는 옛 서버와 새 서버가 함께 돕니다. 이때 열 이름을 한 번에 바꾸면 옛 이름으로 읽던 옛 서버가 멈춥니다.
그래서 새 열을 먼저 더합니다. 한동안은 두 열에 함께 씁니다. 모든 서버가 새 열을 읽게 된 뒤에 옛 열을 지웁니다. 늘렸다가 줄이는 이 순서가 확장-축소 패턴(expand-contract)입니다.
깨는 비용을 누가 치르나
하위 비호환 변경의 비용은 주로 쓰는 쪽에 쌓입니다. 그래도 그 비용을 알면서 깨는 때가 있습니다.
비용은 내는 쪽보다 쓰는 쪽이 더 많이 냅니다. 쓰는 쪽마다 코드를 고치고 다시 시험하고 다시 배포합니다. 쓰는 쪽이 천 곳이면 같은 일이 천 번 일어납니다.
그래도 깨는 변경을 고르는 때가 있습니다. 옛 동작 자체가 보안 구멍일 때가 그렇습니다. 잘못 지은 이름과 구조가 쌓여 새 기능을 막을 때도 그렇습니다. 깨지 않고 버티는 비용이 깨는 비용보다 커진 때입니다.
쓰는 쪽이 전부 한 팀 안에 있으면 부담이 작습니다. 내는 쪽과 쓰는 쪽을 한 번에 같이 고쳐 함께 배포하면 됩니다. 부담의 크기는 내가 못 고치는 쓰는 쪽이 얼마나 많으냐로 정해집니다.
관련 항목
하위 비호환 변경이 깨뜨리는 호환성
하위 호환 · 상위 호환 · 소스 호환 · 바이너리 호환 · 의미 호환 · 와이어 호환
하위 비호환 변경이 생기는 접점
API · 공개 API · 웹 API · 라이브러리 · ABI · 스키마 · 데이터 포맷 · 명령줄 인터페이스
하위 비호환 변경을 알리는 버전 규칙
시맨틱 버저닝 · 버저닝 · 메이저 버전 · 마이너 릴리스 · 패치 릴리스 · API 버전 관리 · 안정성 수준
하위 비호환 변경을 나눠 내는 절차
사용 중단 · 사용 중단 경고 · 확장-축소 패턴 · 병렬 변경 · 롤링 업데이트 · 지원 종료
하위 비호환 변경을 알리는 문서
변경 로그 · 릴리스 노트 · 마이그레이션 가이드 · 컨벤셔널 커밋
하위 비호환 변경을 잡아내는 쓰는 쪽의 장치
버전 범위 · 락 파일 · 계약 테스트 · 회귀 테스트 · 정적 타입
하위 비호환 변경을 받아들이는 의존성 관리
의존성 · 패키지 매니저 · 의존성 관리 · 의존성 지옥
뜻하지 않은 하위 비호환 변경을 부르는 현상
하위 비호환 변경이 속하는 상위 분야
다른 이름: 브레이킹 체인지 · breaking change · 호환성을 깨는 변경 · 깨는 변경 · 하위 호환성을 깨는 변경