하이럼의 법칙과 문서에 없는 계약
하이럼의 법칙을 설명하는 글에 거의 빠짐없이 등장하는 만화가 하나 있다. xkcd 의 Workflow다. 어느 프로그램의 업데이트 내역에 스페이스바를 오래 누르면 CPU 가 과열되던 문제를 고쳤다는 한 줄이 올라온다. 곧바로 오래 써 온 사용자 한 명이 항의를 남긴다. 스페이스바를 눌러 CPU 를 데우는 방식을 Ctrl 키 대신 써 왔는데 이번 업데이트 때문에 자기 워크플로가 깨졌으니 되돌려 달라는 내용이다.
웃자고 그린 만화인데 개발자라면 웃음이 반쯤에서 멈춘다. 버그를 고쳤을 뿐인데 그 버그에 기대던 누군가가 나타나는 일은 생각보다 흔하다. 이 법칙을 처음 적은 사람의 사이트인 hyrumslaw.com도 이 만화를 Obligatory XKCD 라는 이름으로 걸어 두었다.
이번 포스팅에서는 이 법칙이 어디서 나온 말인지 짚어 본 다음 JavaScript 에서 실제로 있었던 세 장면을 따라가 보려고 한다.
하이럼의 법칙
원문은 한 문장이다.
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.
API 를 쓰는 사람이 충분히 많아지면 계약에 무엇을 약속했는지는 상관없다. 시스템에서 관찰할 수 있는 모든 동작에 누군가는 기대게 된다.
풀어 말하면 사용자가 충분히 많은 API 에서는 문서에 적은 약속과 상관없이 밖에서 보이는 동작 전부에 누군가 의존한다는 관찰이다. 구글에서 대규모 라이브러리 마이그레이션을 하던 하이럼 라이트가 처음 적었다. 이름은 동료 타이터스 윈터스가 붙여서 『Software Engineering at Google』(한국어판 『구글 엔지니어는 이렇게 일한다』)에 실었다. 정렬 순서나 에러 메시지 문구처럼 약속한 적 없는 동작이라도 바꾸면 누군가의 코드가 깨질 수 있다.
하이럼 라이트는 구글에서 C++ 핵심 라이브러리와 대규모 코드 변경 도구를 다뤘다. 사이트의 소개 글을 보면 이 관찰은 아주 단순한 라이브러리 변경 하나가 멀리 떨어진 시스템을 깨뜨리는 일을 겪으며 나왔다.
이름에 얽힌 사연도 조금 웃기다. 책 1장의 각주에 따르면 하이럼은 이 관찰을 겸손하게 The Law of Implicit Dependencies 라고 부르려 했다. 그런데 구글 안에서는 Hyrum's Law 라는 이름이 굳어 버렸다(겸손하게 지은 이름은 살아남지 못했다). 정작 hyrumslaw.com 에는 The Law of Implicit Interfaces 라고 적혀 있다. 문서에는 없지만 쓰임새로 굳어 버린 인터페이스를 암시적 인터페이스라고 부른다.
책은 이 법칙을 엔트로피에 빗댄다. 없앨 수는 없다. 다만 염두에 두고 계획을 세울 수는 있다. 책이 드는 대표 예는 해시 기반 set 을 돌 때 나오는 순서다. 예제 코드는 파이썬으로 되어 있다. 이 글에서는 같은 종류의 이야기를 JavaScript 에서 찾아보자.
작은 배열에서만 안정적이던 sort
사용자 목록을 이름순으로 정렬한 다음 팀 번호로 한 번 더 정렬한다고 해보자. 우리는 같은 팀 안에서는 이름순이 그대로 남아 있길 기대한다. 이 기대는 정렬이 안정 정렬(stable sort)일 때만 맞는다. 비교 결과가 같은 원소끼리는 원래 순서를 지켜 주는 정렬을 안정 정렬이라고 한다.
const users = [
{ name: "민수", team: 2 },
{ name: "지영", team: 1 },
{ name: "철수", team: 2 },
{ name: "영희", team: 1 },
];
users.sort((a, b) => a.team - b.team);
console.log(users.map((u) => u.name).join(", "));
// ES2019 이후 엔진: 지영, 영희, 민수, 철수 (같은 팀 안에서는 원래 순서 유지)ES2019 전까지 스펙은 Array.prototype.sort 에 안정성을 요구하지 않았다. 그리고 V8 은 짧은 배열을 삽입 정렬로 처리했다. 그보다 긴 배열에는 불안정한 QuickSort 를 썼다. 경계는 원소 10개 안팎이었다. 삽입 정렬은 안정적이라서 원소 몇 개짜리 테스트 데이터로 돌리면 늘 기대한 순서가 나왔다. 그러다 실데이터가 커지면 같은 팀 안의 순서가 섞였다. 작은 배열에서 본 동작을 약속으로 믿은 코드가 이렇게 깨진다(테스트는 끝까지 초록색이었을 테니 더 고약하다).
V8 은 2018년 7.0 버전(Chrome 70)에서 정렬을 TimSort 로 바꿨다. 이때부터 V8 의 sort 는 안정 정렬이 됐다. 스펙도 ES2019 부터 안정성을 요구한다. 스펙이 정하는 건 안정성까지다. 어떤 알고리즘을 쓸지는 엔진이 고른다.
이 이야기에서는 쓰는 쪽이 이겼다. 많은 코드가 기대던 동작이 나중에 스펙이 됐기 때문이다. 파이썬 dict 도 비슷한 길을 걸었다. 3.6 에서는 삽입 순서를 지키는 게 CPython 구현의 세부 사항이었는데 3.7 부터 언어 명세가 됐다.
Object.keys 가 돌려주는 순서
API 응답을 id 를 키로 하는 객체로 받아서 Object.keys 순서대로 목록을 그린다고 해보자. 서버는 최신 글부터 차례로 넣어 보냈다. 그런데 화면에는 id 가 작은 글부터 나온다.
const posts = { b: 1, 2: "x", a: 2, 1: "y", "-1": "z", "01": "w" };
console.log(Object.keys(posts));
// [ '1', '2', 'b', 'a', '-1', '01' ]
console.log(JSON.stringify(posts));
// {"1":"y","2":"x","b":1,"a":2,"-1":"z","01":"w"}일반 객체의 자기 속성 키는 정해진 순서로 나온다. 배열 인덱스로 쓸 수 있는 정수 키가 오름차순으로 먼저 나온다. 그다음은 나머지 문자열 키가 만들어진 순서다. 심볼 키는 맨 뒤에 만들어진 순서대로 붙는다. 위 예제에서 -1 과 01 이 뒤에 남은 건 두 문자열이 배열 인덱스의 정규형이 아니어서다.
이 순서는 ES2015 에서 처음 정해졌다. 다만 그때 이 순서를 따라야 했던 건 Reflect.ownKeys 와 Object.getOwnPropertyNames 쪽이었다. Object.keys 와 JSON.stringify 가 같은 순서로 확정된 건 ES2020이다. for-in 은 ES2020 에서도 일부만 명세됐다.
엔진들은 스펙이 확정되기 전부터 사실상 같은 순서로 키를 돌려줬다. 사람들이 그 순서에 기대 코드를 짰으니 스펙이 현실을 따라간 면이 있다. 그렇다고 키 순서에 의미를 싣는 게 안전해지지는 않았다. 문자열 id 를 쓰던 서비스가 어느 날 숫자 id 로 바꾸기만 해도 화면 순서가 통째로 달라진다. 코드에 적히지 않은 입력이 결과를 바꾼다는 점에서 내 컴퓨터에서만 되는 코드에서 다룬 시간대 문제와도 닮았다. 순서 자체가 의미라면 배열이나 Map 에 담는 편이 낫다.
const ordered = new Map([["b", 1], ["a", 2]]);
console.log([...ordered.keys()]);
// [ 'b', 'a' ]에러 메시지를 읽는 코드
catch 블록에서 e.message 에 특정 문구가 들어 있는지 보고 분기하는 코드는 꽤 흔하다.
try {
config.user.name;
} catch (e) {
// 문구에 기대는 분기. 2021년 V8 문구 변경 뒤로는 이 조건이 참이 되지 않는다.
if (e.message.includes("Cannot read property")) {
useDefaultUser();
}
}에러 메시지는 사람이 읽으라고 쓴 문장이다. 그런데 이런 코드가 하나라도 생기면 그 문장은 프로그램의 입력이 된다. 2021년 무렵 V8 은 null 이나 undefined 의 속성을 읽을 때 나는 에러 문구를 바꿨다. "Cannot read property 'foo' of undefined" 였던 문구가 "Cannot read properties of undefined (reading 'foo')" 로 바뀌었다. 위 조건문은 그 뒤로 한 번도 참이 되지 않는다.
이 변경에 걸린 곳 중에는 React 저장소도 있었다. React 의 테스트 일부가 옛 문구에 맞춰져 있어서 Node 16 에서 깨졌다. React 팀은 PR #22477에서 두 형식을 모두 받아들이도록 테스트 매처를 고쳤다.
쓰는 쪽에서는 메시지 대신 기계가 읽으라고 만든 값으로 분기하면 된다. Node 는 에러에 ENOENT 나 ERR_INVALID_ARG_TYPE 같은 code 를 붙여 준다. error.name 이나 instanceof 로 나눌 수도 있다.
import { readFile } from "node:fs/promises";
try {
await readFile("settings.json", "utf8");
} catch (e) {
if (e.code === "ENOENT") {
// 파일이 없을 때만 기본값. code 는 문서에 적힌 값이다.
} else {
throw e;
}
}만드는 쪽이라면 기계가 읽을 값과 사람이 읽을 문장을 처음부터 나눠서 내보내면 된다. code 는 문서에 적어 두고 지킨다. message 는 사람이 읽기 좋게 계속 다듬는다.
순서를 일부러 섞는 언어
이 법칙을 진지하게 받아들인 쪽은 약속하지 않을 동작을 아예 관찰할 수 없게 만든다. Go 가 좋은 예다. Go 1(2012)부터 Go 의 map 은 프로그램을 실행할 때마다 순회 순서가 달라진다. 순서에 기대는 코드가 처음부터 자랄 수 없게 만든 것이다.
구글의 C++ 라이브러리인 Abseil 팀도 비슷하게 움직인다. 테스트에서 해시 컨테이너의 순서를 고정해 달라는 요청에 안 된다고 답했다. 대신 순서와 상관없이 원소를 비교하는 UnorderedElementsAre 를 쓰라고 안내하면서 이 법칙을 이유로 들었다.
우리 코드로 옮기면 순서를 보장하지 않는 함수는 문서에 그렇다고 적어 두는 데서 시작한다. 테스트도 순서에 기대지 않게 짠다. 물론 문서에 적었다고 문제가 사라지지는 않는다. 원문부터 계약에 무엇을 약속했든 상관없다고 말한다. 적어 두면 바꿀 때 명분은 생기지만 깨지는 코드까지 막아 주지는 못한다.
숨길 수 없는 동작도 있다. 응답이 얼마나 빨리 오는지 같은 성능과 타이밍은 밖에서 그대로 보인다. 책의 표현대로 이 법칙은 완화할 수는 있어도 없앨 수는 없다.
스페이스바를 누르던 사용자
앞서 이유를 모르는 코드를 지우기 전에에서 체스터턴의 울타리를 다뤘다. 그 글은 쓸모를 모르는 코드를 치우기 전에 이유부터 찾자는 이야기였다. 하이럼의 법칙은 방향이 반대다. 내가 약속하지 않은 동작을 바꾸기 전에 누가 거기에 기대고 있는지를 묻는다. 두 질문 모두 코드만 봐서는 답이 안 나온다.
만화 속 업데이트를 낸 개발자는 스페이스바 사용자의 요청을 들어줄지 말지 고를 수 있다. 하지만 CPU 온도를 Ctrl 키로 쓰는 사람이 있다는 사실은 업데이트를 내보낸 뒤에야 알 수 있었다. 필자라면 요청은 거절할 것 같다. 대신 다음 업데이트 내역 맨 위에 스페이스바를 오래 눌렀을 때의 동작이 바뀐다는 줄을 적어 둘 생각이다.