2P by GN⁺ | ★ favorite | 댓글 1개
  • Go 코드베이스의 net/http에는 MaxBytesError.Error()가 반환하는 오류 문자열 "http: request body too large"를 Hyrum's Law 때문에 바꿀 수 없다는 주석이 있음
  • Hyrum's Law는 API 사용자가 충분히 많아지면, 공식 계약에 없는 관찰 가능한 동작까지 누군가 의존하게 된다는 원칙임
  • 오류 메시지처럼 사소해 보이는 문자열도 외부 코드가 정확한 문구에 맞춰 동작하면, 변경 순간 기존 코드가 깨질 수 있음
  • Go 내부에서는 crypto/rsainternal/weak에도 비슷한 주석이 있으며, 무작위 스트림 동작이나 미확정 의미론이 고정되는 위험을 다룸
  • Go에만 국한된 문제가 아니므로, 공개 API나 라이브러리는 의도치 않은 동작이 사실상 표준처럼 굳어지지 않도록 설계해야 함

Go 코드에서 확인된 Hyrum's Law

  • net/http/request.goMaxBytesError.Error()는 다음 문자열을 반환함
    • "http: request body too large"
    • 해당 주석에는 “Due to Hyrum's law, this text cannot be changed.”라고 적혀 있음
  • Hyrum's LawHyrum Wright의 이름을 딴 원칙이며, hyrumslaw.com의 정의는 다음과 같음
    • API 사용자가 충분히 많으면 계약에서 무엇을 약속했는지와 무관하게 시스템의 모든 관찰 가능한 동작을 누군가 의존하게 됨
  • MaxBytesError 사례의 핵심은 오류 메시지의 정확한 문구가 외부 코드에서 사용될 수 있다는 점임
    • 작은 문구 변경도 기존 코드를 깨뜨릴 수 있음
    • http: request body too large 검색 결과에서 해당 문자열을 쓰는 Go 오픈소스 코드가 확인됨

다른 Go 패키지와 외부 코드베이스 사례

  • crypto/rsa의 무작위 스트림 의존성

    • crypto/rsa/rsa.goEncryptOAEP에는 Hyrum's Law 관련 주석이 있음
    • 이 함수는 무작위 스트림에 대해 결정적 실행을 약속하지 않지만, MaybeReadByte를 적용하지 않기 때문에 누군가 현재 동작에 의존할 가능성이 있음
    • crypto/rsa/pss.goSignPSS도 같은 맥락의 주석을 포함함
    • 두 경우 모두 잘 정의된 수의 무작위 바이트가 암호문이나 서명에 잘 정의된 방식으로 포함되므로 감내 가능한 약속으로 다뤄짐
  • internal/weak의 의미론 고정 위험

    • internal/weakgo:linkname으로 해당 패키지와 참조 함수에 접근하는 것을 툴체인이 명시적으로 금지한다고 적고 있음
    • 이 패키지의 의미론은 제안 절차를 거치지 않았으며, 기능을 노출하면 Hyrum's Law 때문에 기존 의미론이 고정될 수 있음
  • Go 밖에서도 반복되는 패턴

    • Hyrum's Law 언급은 Go에만 한정되지 않음
    • grep.app의 다언어 검색 결과에서 여러 언어의 사례를 볼 수 있음
    • Python의 urllib.parse와 Pixar OpenUSD의 array.h도 관련 코드베이스 예시임
    • JavaScript의 진화도 여러 기묘하고 의도치 않은 동작에 대한 광범위한 의존이 사실상 표준이 된 사례로 연결됨

변경하기 전에 확인해야 할 것

  • 코드 변경 시에는 문서화된 API뿐 아니라 외부 코드가 의존할 수 있는 관찰 가능한 동작까지 고려해야 함
  • 처음부터 의도치 않은 동작에 대한 의존 가능성을 줄이는 시스템 설계가 필요함

댓글과 토론

Hacker News 의견들
  • Hyrum의 법칙은 유용한 관찰이지만, 거기에 집착해 잘못된 결론을 내리면 안 됨
    함수의 전체 실행 시간도 관찰 가능한 속성이므로, 함수를 더 빠르게 최적화하는 것조차 깨지는 변경이라고 볼 수 있음. 갑자기 큐가 너무 빨리 비워져 교착 상태가 생길 수도 있으니까. 그래도 사용자의 99.99999999%는 아무 노력 없이 코드가 빨라지는 걸 좋아할 것임
    결국 무엇이 깨지는 변경인지는 기술적 계약이 아니라 사회적 계약일 수밖에 없음. 아니면 문자 그대로 아무것도 바꿀 수 없게 됨. 라이브러리 작성자는 API에서 바뀌지 않는 부분을 문서화하고 합리적으로 행동하며 사용자에게 공감해야 하고, 라이브러리 사용자는 문서화되지 않은 인터페이스를 핵심 의존성으로 삼는 건 자기 책임임을 이해하고 작성자에게도 공감해야 함

    • 오픈소스 라이브러리 작성자에게는 위 말이 전부 맞다고 봄
      다만 다른 관점에서 보면 Hyrum의 법칙은 기술적 계약도 사회적 계약도 아니라, 충분히 많이 쓰이는 시스템에서 나타나는 창발적 기술 속성
      그 속성에 어떻게 대응할지는 사회적 맥락에 달려 있음. FOSS 관리자라면 최적화로 99.99%가 빨라지고 0.01%만 코드를 고치거나 새 API로 옮기면 되는 경우 배포함. 대형 기술 회사라면 최적화도 해야 하고 회사 안에서 0%도 깨지면 안 되므로 여러 팀과 협업해 절충점을 찾음. 기업용 소프트웨어 회사라면 0.1%만 깨져도 그 사용자가 상위 5개 계약 중 하나라면 배포하지 않음
    • 예전에 매우 비효율적인 루틴을 약 100초에서 0.1초 정도로 줄인 적이 있는데, 그 때문에 보고 시스템이 깨졌음
      원래 작성자가 여러 비동기 함수를 호출해 놓고, 예전의 느린 루틴이 끝날 때쯤이면 그 함수들이 모두 끝났을 거라고 가정했기 때문임. 정확히 무슨 일이 벌어진 건지 알아내는 데 엄청 오래 걸림
    • 1980년대에는 실제로 그런 문제가 있었음
      그래서 PC에는 속도를 낮추는 터보 버튼이 달렸고, 8비트 컴퓨터는 더 빠른 CPU가 있었는데도 10년 내내 속도를 올리지 않았음. 요즘은 거의 모든 것이 둘 이상의 CPU에서 돌아가므로, 충분히 빠른지만 제외하면 함수 실행 시간에 의존하는 경우는 거의 없음. 임베디드에서도 단일 CPU가 단종되는 일을 겪고 나서 그런 의존성을 피하려고 함
    • 언젠가 load bearing teapot에 대해 라이트닝 토크를 해보고 싶음
      내부 API에서 HTTP Status 418을 핵심 의존성으로 만들었던 이유와, 주어진 제약 아래에서 왜 그것이 가장 덜 나쁜 선택이었는지에 대한 이야기임
    • 함수의 전체 실행 시간 같은 것은 함수 작성자의 통제 아래 있지 않으므로, 이 논리는 거의 부조리에 가까워 보임
      운영 환경이나 당시 시스템 부하, GC 실행 등이 모두 영향을 줄 수 있음
      요약하면, 기계에서 생겨나는 창발적 동작을 의도된 인터페이스나 어떤 종류의 계약으로 보지는 않음. 따라서 누군가 의도치 않은 동작에 의존했더라도, 미묘한 버그를 고치는 것이 깨지는 변경으로 간주되지 않는 것처럼 이것도 깨지는 변경으로 보지 않음
      이 경우는 무엇보다 Go가 하위 호환성에 매우 강하게 헌신한다는 증거에 더 가까워 보임
  • 하하, crypto/rsa 주석을 내가 썼음. Go에서는 Hyrum의 법칙과 하위 호환성 https://go.dev/doc/go1compat을 정말 진지하게 다룸
    예를 들면 여러 GenerateKey 함수에서 알고리즘이 고정되지 않도록 MaybeReadByte https://pkg.go.dev/crypto/internal/randutil#MaybeReadByte로 무작위 스트림에서 바이트 하나를 추가로 읽음. 바로 어제도 nil 공개키가 들어간 private ECDSA key가 예전에는 동작했는데 지금은 안 된다는 보고가 들어와서, 아마 다시 동작하게 해야 할 듯함 https://go.dev/issue/70468
    맵 순회는 내부 구현이 노출되지 않도록 무작위 순서를 사용함. rand.Rand의 출력은 호환성 약속의 일부로 간주되므로 개선하려고 꽤 큰 노력을 해야 했음 https://go.dev/blog/randv2 https://go.dev/blog/chacha8rand
    문서에 어떤 약속을 적고 어떤 동작은 “바뀔 수 있음”이라고 명시할지 항상 논의함. 문서화된 것은 절대 바꿀 수 없고, “바뀔 수 있음”이라고 명시하지 않은 것도 아마 바꾸기 어렵다는 걸 알기 때문임 https://go-review.googlesource.com/c/go/+/598336/comment/5d6...

    • 맵 순회 순서 변경은 특정 순서에 의존하지 못하게 해서 미래의 깨지는 변경을 줄이는 데 도움이 되지만, 변경 당시에는 이전 순서 동작에 의존하던 코드에는 깨지는 변경이었음
      그래도 가치 있는 절충이라고 봄. Go를 많이 쓰고 강한 하위 호환성을 좋아하지만, Go 개발자들이 성능을 개선하고 기능을 추가할 자유가 커진다면 깨지는 변경 비율이 약간 높아지는 것도 기꺼이 받아들일 수 있음
      다른 생태계 사용자들이 감수하는 지옥을 보면, 예컨대 Python 같은 경우, 이런 생각이 나만의 것은 아니라고 봄
    • MaybeReadByte를 여러 GenerateKey 함수에서 쓴다고 했지만 ed25519에서는 그렇게 하지 않는 것 같음
      ed25519.NewKeyFromSeed()가 생기기 전에는 private key에서 public Ed25519 key를 파생하는 유일한 방법이었고, 거기에 의존한 코드를 쓴 적이 거의 확실함. 별로 마음에 들지 않았지만 할 수 있는 게 그뿐이라 기억하기 쉬움
      다만 ed25519.GenerateKey 문서가 출력이 결정적이라고 명시한 건 좋음. Go 암호화 API에서 굳어진 동작을 조사하고 유지하며, 새로운 굳어짐을 막는 일을 정말 잘해온 것 같음
    • nil key 사례는 이런 경우까지 지원하는 게 얼마나 제정신인지 의문이 들게 함
      악명 높은 A20 line (https://en.wikipedia.org/wiki/A20_line)처럼, 이 깨진 동작을 영원히 끌고 가야 하게 됨
    • 아이러니하게도 예전에 Go로 부하 분산기를 짰는데, 무작위 맵 순회 순서에 의존했음
    • Go에서 가장 과소평가되는 부분 중 하나임. 12년 전에 쓴 코드가 아직도 그냥 동작함
  • 구체적으로 언급된 문제의 해법은 문자열 기반 오류를 쓰지 말고 센티널 오류를 쓰는 것임 https://thomas-guettler.de/go/wrapping-and-sentinel-errors
    더 일반적으로는 API 소비자가 비기술적 문자열에 조금이라도 의존하고 싶어지는 코드를 만들지 말아야 함. 미리 정의된 오류 값, 타입, 혹은 비기술적 문자열을 담은 상수처럼 언어의 1급 구성요소를 사용하면, API 소비자는 문자열을 직접 하드코딩하는 대신 반환값을 상수와 비교할 수 있음
    Hyrum의 법칙은 분명 존재하지만, 그 영향을 줄일 수는 있음

    • 짜증나는 점은 문제의 오류가 이미 센티널 오류라는 것임
      연결된 검색에서 최상위 원인으로 보이는 Grafana는 문자열 비교 대신 errors.As(&http.MaxBytesError{})를 써야 했음
      Hyrum의 법칙의 핵심은 API를 아무리 잘 설계해도 상관없다는 것임. 사람들은 계약이 아니라 동작에 의존하게 됨
    • 이 예시에서는 책임이 제공자가 아니라 소비자에게 있음
      여전히 err.String() == "no more tea available."를 확인하는 코드를 쓸 수 있음. 그렇게 하면 안 된다는 데는 동의하지만, 그렇게 하지 못하게 막는 것은 없음
      게다가 errors.Is는 Go에 비교적 최근에 추가되었기 때문에, 사람들이 이런 식으로 오류를 확인하던 시점에는 리터럴 문자열을 확인하는 게 더 쉬웠음. Go에서 API 제공자는 소비자가 .String() 반환값을 확인하는 것을 막을 수 없음
    • 몇 년 전에는 문자열 오류 비교가 이걸 하는 유일한 방법이었고, Go에는 하위 호환성 약속이 있음
    • 원시 오류 문자열을 확인하는 코드는 그냥 나쁜 코드이고, Go의 하위 호환성 보장에서 제외해야 함
      특히 표준 라이브러리에서는 거의 변명의 여지가 없음
    • Go의 초기 설계가 문제임. 오랫동안 문자열 기반 오류가 유일한 방법이었고, 기억이 맞다면 일부 표준 라이브러리 패키지에도 아직 남아 있으며 생태계 전체는 말할 것도 없음
      프로그래밍 언어의 역사를 의도적으로 무시한 뒤 “만들면서 설계하자”는 접근을 택하면 이렇게 됨
  • Hyrum의 법칙에 맞서는 방법도 흥미로운 주제임
    한 가지 가능성은 사람들이 의존하지 않기를 바라는 부분에 무작위성을 넣는 것임
    기억이 맞다면 QUIC 프로토콜이 이렇게 함. 현재 버전에서는 사용하지 않는 필드가 있지만, 라우터가 그 필드로 패킷을 식별하기 시작하지 못하도록 명세에서 null 바이트가 아니라 무작위 값으로 설정하라고 요구함
    출처는 아마 여기임: https://www.rfc-editor.org/rfc/rfc9000#section-17.2.1
    “Unused 필드의 값은 서버가 임의의 값으로 설정한다. 클라이언트는 이 필드 값을 반드시 무시해야 한다. [...] QUIC의 다른 버전이 비슷한 권고를 하지 않을 수도 있음에 유의하라”
    이런 걸 greasing이라고 부르고, ossification을 막기 위한 것이라고 알고 있음

    • GREASE는 RFC 8701에서 만든 약어로, “Generate Random Extensions And Sustain Extensibility”를 뜻하며 처음에는 TLS 맥락에서 쓰였음
      https://www.rfc-editor.org/rfc/rfc8701.html
      이 RFC의 가장 이른 초안은 2016년 중반으로 거슬러 올라가며, 아마 이 용어가 공개적으로 처음 등장한 시점일 가능성이 큼: https://datatracker.ietf.org/doc/html/draft-davidben-tls-gre...
    • 훌륭함. QUIC에는 꽤 익숙한데 이 내용은 몰랐음
      10년 뒤 깨어나서 그 비트들이 정말 필요해졌는데, 10개 브랜드의 라우터 20종이 그 비트들은 반드시 특정 방식이어야 한다고 결정해버린 상황만큼 끔찍한 것도 없음
      반대쪽에서 체크섬이나 암호화가 있어서 비트가 건드려지면 깨진다면 가산점임. 미들박스들의 “영리한 해킹”은 정말 골칫거리임
  • 이건 stringly typed 소프트웨어의 좋은 예임
    Go 설계자들은 예외를 원하지 않았지만, panic/recover로 여전히 비슷한 것이 있고, 타입 없는 오류는 해로움. 반대로 패턴 매칭 없이 타입 있는 오류를 어떻게 처리할 수 있을까? 대부분 언어의 catch는 초보적인 패턴 매칭이기 때문임
    https://learn.microsoft.com/en-us/dotnet/csharp/language-ref...

    • Go에는 타입 있는 오류가 있음. 이 경우에 쓰지 않았을 뿐임
  • 예전에 한 직장에서 오류 메시지의 오타를 발견해 고쳤다가, 그 오타 있는 텍스트에 의존하는 연결망이 너무 깊어서 실질적으로 고칠 수 없다는 걸 알게 됐고 결국 오타 있는 텍스트로 되돌려야 했음
    아직도 신경 쓰임

  • 이건 일종의 Hyrum의 법칙이지만, 사실상 그냥 Go다운 Go
    오류가 enum 타입이었다면 소비자는 문자열 치환만으로 바꿀 수 있었을 것임. 대신 문자열을 타입처럼 쓰고 있으니, 소비자가 어떻게 의존하고 있을지 알 수 없게 됨. 오류 문자열 가운데 6글자만 확인하다가 바꾸면 깨질 수도 있음
    수십 년 전부터 다른 언어에서 더 나은 대안이 쓰였는데도, 또 하나의 끔찍하고 시대착오적인 설계 결정임. 초기 실수와 바꿀 수 없음이 결합하면 영원히 묶이게 됨

    • 안타깝게도 그 주석은 본질적으로 틀렸음. 많은 경우 문자열 자체가 공식 API였음
  • 이 법칙은 견고성 원칙, 즉 Postel의 법칙과 정확히 반대라는 점이 흥미로움
    “보낼 때는 보수적으로, 받을 때는 관대하게”
    입력을 관대하게 받는다면 어떤 방식으로 관대했는지 이해하고 적어도 내부적으로 문서화해야 함. Hyrum의 법칙 때문에, 대규모 코드베이스 변경 이후에도 그 모든 방식을 영원히 지원해야 하게 됨
    바로 그 이유로 “받는 것에 관대한” API를 만들지 않으려 함

    • 나도 그쪽을 선호함
      API로 받는 데이터의 기준을 느슨하게 두면, 결국 그 데이터를 어떤 정규 형식으로 마사지할지 결정해야 하게 됨. 그리고 그 결정은 거의 항상 어떤 식으로든 사용자에게 놀라운 동작으로 이어지는 것 같음
  • 패키지 작성자마다 이 문제를 받아들이는 정도가 다른 듯함. 며칠 전 json 패키지에서 이런 주석을 봤음
    isValidNumbers가 유효한 JSON 숫자 리터럴인지 보고함
    isValidNumber는 내부 구현 세부사항이어야 하지만, 널리 쓰이는 패키지들이 linkname으로 접근하고 있음
    hall of shame의 대표 구성원으로 github.com/bytedance/sonic이 포함됨

  • API를 배포하면서 배운 것들임
    클라이언트는 게시자가 의도한 방식이 아니더라도 자기 일을 끝내기 위해 필요한 건 뭐든 함. 클라이언트는 문서를 읽지 않음. 충분히 많은 클라이언트가 어떤 동작에 의존하면 버그도 API의 일부가 됨. API 호출 수가 중요도와 반드시 일치하지는 않음
    그래서 API를 개발할 때는 가능한 한 베타 API를 일찍 내고 어떻게 쓰는지 보면서 놀라움을 줄이려 함. 대부분의 경우 이전 버전을 지원하면서 주 버전을 올림. 그러려면 API의 SLA를 정의해야 함