파괴적 변경: 무엇이 해당하고 어떻게 출시하는가
7분 분량 업데이트
파괴적 변경이란 올바르게 작성된 호출자가 버텨낼 수 없었던 변경이다. 이 정의가 중요한 이유는, 무언가가 “해당되는가”에 대한 대부분의 논쟁이 실제로는 누가 잘못 쥐고 있었는가에 대한 논쟁이기 때문이다. 호출자가 여러분의 문서를 따랐는데 여러분의 변경이 그 코드를 멈추게 만들었다면, 그 변경은 파괴적이었다. 여러분이 무엇을 의도했는지는 여기에 아무 상관이 없다.
그것이 테스트의 전부다. 이 글의 나머지는 거기서 파생되는 것들이다. 무엇이 이 테스트에 실패하는가, 무엇이 통과하는가, 병합되기 전에 실패를 어떻게 잡는가, 그리고 파괴적 변경을 출시하고 있다는 것을 알았을 때 무엇을 해야 하는가.
무엇이 파괴적 변경에 해당하는가
diff가 아니라 호출자에게 테스트를 적용하라. 문서화된 동작에만 의존했던 호출자가 계속 작동하기 위해 코드, 설정, 또는 데이터를 바꿔야 한다면, 그 변경은 파괴적이다. 필드 제거, 엔드포인트 이름 변경, 검증 강화, 기본값 변경, 값의 타입 변경은 모두 여기에 해당한다. 선택적 필드를 추가하는 것은 해당하지 않는다. 버그 수정은 보통 해당하지 않지만, 아래에 중요한 예외가 하나 있다.
| 변경 | 파괴적인가? | 이유 |
|---|---|---|
| 필드, 엔드포인트, 플래그, 옵션을 제거하거나 이름을 바꾼다 | 그렇다 | 올바른 호출자는 그것을 참조하고 있다 |
| 선택적 필드나 새 엔드포인트를 추가한다 | 아니다 | 기존 호출은 바뀌지 않는다 |
| 선택적 입력을 필수로 바꾼다 | 그렇다 | 그것을 생략했던 호출이 이제 실패한다 |
| 이전에는 받아들이던 검증을 강화한다 | 그렇다 | 작동했던 입력이 이제 거부된다 |
| 기본값을 바꾼다 | 그렇다 | 그것을 설정하지 않았던 호출자가 새 동작을 받는다 |
| 타입을 바꾼다(문자열에서 숫자로, 단일 값에서 배열로) | 그렇다 | 문서화된 타입에 맞춰 쓰인 파서가 실패한다 |
| 객체의 키 순서를 바꾼다 | 아니다 | 순서를 문서화하지 않은 한 |
| 호출자가 의존하고 있던 버그를 수정한다 | 실질적으로 그렇다 | 우발적 계약에 관한 절 참고 |
| 요청 한도나 크기 상한을 올린다 | 아니다 | 작동하던 것 중 멈추는 것은 없다 |
| 요청 한도나 크기 상한을 낮춘다 | 그렇다 | 문제없던 트래픽이 이제 제한된다 |
| 오류 메시지의 문구를 바꾼다 | 상황에 따라 다르다 | 문서화했거나 호출자가 그것을 매칭한다면 파괴적이다 |
무엇이 파괴적 변경이 아닌가
이전에 작동하던 모든 호출이 그대로 작동하고 의미도 같다면, 그 변경은 파괴적이지 않다. 새 엔드포인트 추가, 선택적 요청 매개변수 추가, 응답에 필드 추가, 필수 입력을 선택적으로 바꾸기, 한도 올리기, 아무도 매칭하지 않는 오류 메시지 개선은 모두 테스트를 통과한다. 이런 추가적 변경은 일반적인 체인지로그 항목과 함께 마이너 릴리스로 출시할 수 있다.
그래도 추가적 변경이 호출자를 망가뜨리는 경우가 세 가지 있다. 알 수 없는 필드를 거부하는 역직렬화기를 가진 클라이언트는 응답에 새 필드가 처음 생기는 순간 실패하므로, 호출자가 인식하지 못하는 필드는 무시해야 한다고 일찍부터 문서화하라. 새 enum 값은 모든 경우를 다 처리하는 switch문을 가진 모든 호출자를 망가뜨린다(아래에서 더 다룬다). 그리고 커진 응답은 호출자가 생각해본 적 없는 크기 제한, 타임아웃, 열 너비를 넘게 만들 수 있다.
표의 네 행은 더 자세히 볼 가치가 있다. 그곳이 바로 의견이 갈리는 지점이기 때문이다.
팀들이 놓치는 네 가지 파괴적 변경
우발적 계약. 여러분의 API가 삼 년 동안 같은 문서화되지 않은 필드를 반환해왔다면, 어떤 호출자는 그 위에 무언가를 쌓아올렸을 것이다. Hyrum의 법칙이 짧은 버전이다. 사용자가 충분히 많다면, 여러분 시스템의 관찰 가능한 모든 동작에 누군가는 의존하게 된다. 이것이 “그것은 버그 수정이었다”가 변명이 되지 못하는 이유다. 그 수정은 옳을 수 있으면서도 여전히 파괴적일 수 있다. 그것을 파괴적 변경으로서 출시하라.
스키마 변경 없는 동작 변경. 필드는 여전히 그대로 있고, 타입도 같지만, 이제 값이 다른 것을 의미한다. 예전에는 active 또는 inactive였던 status가 이제 suspended도 반환하게 되면, 이는 모든 경우를 다 처리하는 switch문을 가진 모든 호출자를 망가뜨린다. 로컬 시간에서 UTC로 옮겨가는 타임스탬프는 문서를 두 번 읽지 않은 모든 사람을 망가뜨린다. OpenAPI 파일의 diff에는 이런 것들이 전혀 나타나지 않는다.
강화된 검증. TLD가 없는 이메일, 끝에 붙은 공백, 80자를 넘는 이름을 거부하기 시작한다. 정확히 그것을 보내고 있던 모든 호출자는 지난주까지 작동하던 요청에 대해 이제 400을 받게 된다. 검증 변경은 “견고화” 수정으로 출시되는 가장 흔한 유형이다.
바뀐 기본값. 값을 명시적으로 설정한 사람은 아무도 눈치채지 못한다. 설정하지 않은 사람, 즉 대부분의 호출자는 한 줄도 바꾸지 않은 채 새 동작을 받게 된다. 바뀐 기본값이 사용자의 대다수를 망가뜨리는 이유는 정확히 그들이 그 설정을 본 적이 없기 때문이다.
출시 전에 파괴적 변경을 어떻게 탐지하는가
풀 리퀘스트의 계약을 main 브랜치의 계약과 CI에서 비교하고, 파괴적인 차이가 있으면 빌드를 실패시켜라. 대부분의 인터페이스 형식에는 스키마 diff 도구가 있고, 각 도구는 자기 형식의 파괴 규칙을 알고 있다.
| 인터페이스 | 도구 | 비교 대상 |
|---|---|---|
| REST (OpenAPI) | oasdiff | 두 OpenAPI 명세, 파괴적 변경 보고서 포함 |
| gRPC (Protobuf) | buf breaking | .proto 파일, 와이어 또는 소스 수준 |
| GraphQL | GraphQL Inspector | 두 스키마, 파괴적이거나 위험한 변경을 표시 |
| Rust 크레이트 | cargo-semver-checks | 공개 API와 마지막으로 게시된 버전 |
| TypeScript 패키지 | API Extractor | 패키지 공개 API의 커밋된 보고서 |
이 도구들은 제거된 필드, 이름이 바뀐 연산, 바뀐 타입을 안정적으로 잡아낸다. 하지만 위의 네 가지 중 처음 두 가지, 즉 우발적 계약과 동작 변경은 스키마에 나타나지 않으므로 볼 수 없다. 뻔한 것들은 도구로 막고, 나머지는 “올바른 호출자가 이것을 알아차릴 수 있는가?”라는 리뷰 질문으로 막아라. 같은 CI 작업은 체인지로그 항목을 요구하기에도 자연스러운 자리이며, 이는 CI에서 체인지로그 항목 강제하기에서 설명한다. 와이어 수준의 사례는 gRPC와 Protobuf API 변경에서 다룬다.
커밋에서 파괴적 변경을 어떻게 표시하는가
Conventional Commits에서 파괴적 변경은 콜론 앞의 !(feat(api)!: remove the legacy export endpoint) 또는 BREAKING CHANGE:로 시작하고 설명이 이어지는 푸터로 표시한다. 어느 쪽이든 메이저 버전에 대응한다. 푸터는 체인지로그 항목의 초안으로 써라. 누가 영향을 받고 무엇을 해야 하는지를 밝히면 된다. 이 관례가 어디까지 도움이 되는지는 Conventional Commits와 체인지로그에서 다룬다.
같은 규칙이 라이브러리에도 적용된다. 공개 함수의 제거, 매개변수 타입의 축소, 반환값의 변경은 시맨틱 버저닝에서 메이저 버전이다. 라이브러리가 항상 이를 따르는 것은 아니다. Maven Central 업그레이드 119,879건에 대한 연구에서는 16.6%가 시맨틱 버저닝을 어겼지만, 영향을 받은 클라이언트 프로젝트는 7.9%에 그쳤다. 그 변경의 대부분이 어떤 클라이언트도 호출하지 않는 코드를 건드렸기 때문이다. 파괴는 호출자에서 측정된다.
파괴적 변경은 어떻게 출시하는가
공개적으로, 날짜를 정해, 경로와 함께 출시하라. 아래 단계는 순서대로이며, 마지막 것이 대부분의 팀이 건너뛰는 단계다. 영향을 받은 사람들에게, 그들이 기다리고 있던 일이 이제 일어났다고 알리는 것이다.
- 그것인지 아닌지를 판단하라. diff가 아니라 위의 테스트를 사용하라. 두 명의 엔지니어가 동의하지 않는다면, 그것은 파괴적이다. 그 불일치 자체가, 호출자가 예전 동작에 합리적으로 의존했을 수 있다는 증거다.
- 버전을 붙여라. 시맨틱 버저닝 아래에서 파괴적 변경은 메이저 버전이다. 날짜 기반이거나 버전이 붙은 API를 운영한다면, 그것은 새 버전에 들어가고 예전 것은 정해진 날짜까지 계속 작동한다. 버전을 붙일 수 없다면, 여러분은 파괴적 변경을 출시하는 것이 아니라 체인지로그 항목이 붙은 장애를 출시하는 것이다. 어떤 방식이 버전을 운반하는지는 API 버저닝 모범 사례의 주제다.
- 코드가 병합되기 전에 항목을 써라. 그 항목은 고정된 형태를 가진다. 무엇이 바뀌는지, 누가 영향을 받는지, 무엇을 해야 하는지, 언제까지인지. 이 네 가지를 모두 채울 수 없다면, 그 변경은 아직 준비되지 않은 것이다. 릴리스 노트 템플릿이 바로 이런 이유로 이런 항목들을 버전 번호가 아니라 날짜와 함께 맨 앞에 둔다.
- 릴리스 번호가 아니라 기한을 제시하라. “v5에서 제거됨”은 여러분의 릴리스를 추적하지 않는 사람에게는 아무 의미가 없다. “2026년 11월 1일부터 작동하지 않음”은 모든 사람에게 하나의 의미를 갖는다.
- 마이그레이션 방법을 제공하라. 새 호출 옆에 예전 호출의 코드 샘플을 둔다. 변경이 이름 변경이라면 같은 문장 안에서 두 이름을 모두 말하라. 필드가 제거된 것이라면 그 데이터가 어디로 갔는지 말하라.
- 예전 동작이 문서화되어 있던 모든 곳에서 알려라. 체인지로그, 그 엔드포인트를 설명하는 문서 페이지, SDK의 릴리스 노트, 그리고 응답에 비추천 헤더가 있다면 그것까지. 한 곳에서만 알리는 것은 우연히 그곳을 본 사람들에게만 알리는 것이다.
- 루프를 닫아라. 고객이 그 변경을 요청했거나, 그것으로 이어진 버그를 신고했다면, 그것이 출시될 때 알려라. 이것이 그것을 사용자에게 행해진 일에서 사용자와 함께 행해진 일로 바꾸는 단계다.
좋은 파괴적 변경 항목은 어떤 모습인가
좋은 항목은 첫 줄에서 영향을 받는 호출자를 이름으로 밝히고, 날짜를 명시하며, 수정 방법을 포함한다. 우리가 사용하는 형태로, 검증 강화 사례에 대한 예시는 다음과 같다.
도메인이 없는 이메일 주소는 2026년 11월 1일부터 거부됩니다.
POST /users와PATCH /users/:id는 현재alice@localhost와 같은400 invalid_email을 반환합니다. 내부 디렉터리로부터 사용자를 생성하는 모든 연동에 영향을 줍니다. 마이그레이션: 완전한 형식의 주소를 보내거나, 필드를 생략하고 나중에 설정하세요. 이미 도메인이 있는 주소를 사용 중이라면 변경할 필요가 없으며, 이는 올해 생성된 계정의 99.4%에 해당합니다.
이런 공지가 어디에 있어야 하는지, 그리고 그 옆에 또 무엇이 있어야 하는지는 API 체인지로그에서 다룬다.
마지막 백분율은 장식이 아니다. 그것은 독자에게 걱정해야 할지 말지를 말해주며, 그것이 바로 독자가 그 항목을 열었을 때 갖고 있던 질문이다.
왜 그냥 피하지 않는가
대안이 더 나쁘기 때문이다. 아무것도 망가뜨리지 않는 API는 지금까지 저지른 모든 실수를 쌓아나간다. 잘못 붙인 필드 이름, 잘못된 기본값, 로컬 시간의 타임스탬프. 각각은 오후 한나절이면 이전할 수 있었을 호출자를 지키기 위해, 모든 새로운 호출자에게 영원히 부과되는 세금이다. 안정성에 대해 가장 좋은 평판을 가진 팀들은 좀처럼 무언가를 망가뜨리지 않으며, 그럴 때는 일정에 따라, 이전 경로와 함께, 대상에게 실제로 도달한 경고와 함께 그렇게 한다.
그 경고의 메커니즘은 API를 비추천 처리하기라는 자매 글의 주제다. 그것을 알리는 항목은 체인지로그 피드의 다른 어떤 항목과도 같은 방식으로 작성된다. 병합된 pull request로부터, 사람을 위해 보류되고, 그 후 영향을 받는 호출자들이 이미 읽고 있는 곳에 발행된다.
FAQ
파괴적 변경과 비파괴적 변경의 차이는 무엇인가? 파괴적 변경은 올바른 호출자가 계속 작동하기 위해 코드, 설정, 또는 데이터를 바꾸게 만든다. 비파괴적 변경은 기존의 모든 호출을 같은 의미로 계속 작동하게 둔다. 그래서 추가는 보통 안전하고, 제거, 이름 변경, 강화된 규칙은 보통 그렇지 않다.
필수 필드를 추가하는 것도 해당하는가? 그렇다. 기존의 모든 호출이 그것을 생략하고 있으므로, 기존의 모든 호출이 이제 실패한다. 합리적인 기본값과 함께 선택적으로 추가하거나, 엔드포인트에 버전을 붙여라.
버그 수정도 해당할 수 있는가? 그럴 수 있다. 호출자가 버그가 있는 동작에 의존하고 있었다면, 그것을 고치는 것은 문서가 무엇이라고 말했든 그들을 망가뜨린다. 관찰 가능한 출력을 바꾸는 모든 수정은, 아무도 그것에 의존하지 않았다는 것을 보여줄 수 없는 한 파괴적으로 취급하라.
시맨틱 버저닝이 웹 API에도 적용되는가? 그 규칙은 적용된다. 파괴적 변경은 새 메이저 버전을 받고 예전 것은 정해진 기간 동안 계속 작동한다. 그 번호는 흔히 패키지 버전이 아니라 URL이나 날짜 헤더에 있다.
얼마나 미리 알려야 충분한가? 호출자가 그 공지를 발견하고 작업을 처리할 수 있을 만큼. 90일이 공개 API에 대한 흔한 최소치이며, 원격으로 업데이트할 수 없는 채로 최종 사용자에게 출시되는 코드에서 쓰이는 것이라면 더 길어야 한다.
이 글의 기술적 내용은 독립적으로 검토되지 않았습니다. 잘못된 부분이 있으면 알려 주세요. 바로잡겠습니다.