개발자로서 API를 설계하거나 라이브러리를 만들 때, 우리는 사용자들이 우리가 문서화한 방식대로만 코드를 사용할 것이라고 기대합니다.
하지만 현실은 그렇게 단순하지 않습니다.
바로 이 지점에서 '하이럼의 법칙(Hyrum's Law)'이 등장합니다.
충분히 많은 수의 API 사용자가 있다면, 당신이 계약서에서 무엇을 약속했는지는 중요하지 않다.
당신의 시스템에서 관찰 가능한 모든 동작은 누군가에 의해 의존 될 것이다.
- 하이럼 라이트(Hyrum Wright)
이 글에서는 하이럼의 법칙이 무엇인지, 왜 발생하는지, 그리고 개발자로서 이를 어떻게 다뤄야 하는지 알아보겠습니다.
하이럼의 법칙이란?
하이럼의 법칙은 간단히 말해 "사용자가 많아지면, 문서화된 동작뿐만 아니라 관찰 가능한 모든 동작에 대해 누군가는 의존하게 된다"는 소프트웨어 개발의 현실을 표현한 법칙입니다.
이는 구글의 엔지니어 하이럼 라이트가 제안했으며, 특히 대규모 소프트웨어 시스템에서 자주 목격됩니다.
이 법칙은 '암시적 의존성의 법칙'이라고도 불리는데, 문서화되지 않은 동작이나 구현 세부사항에 사용자들이 의존하게 되는 현상을 설명합니다.
(하이럼은 겸손하게도 좀 더 '보편적인 이름'을 지었지만, 구글 엔지니어들 사이에선 '하이럼의 법칙'으로 통용된다고 합니다. - 구글 엔지니어는 이렇게 일한다. 46p 발췌 -)
아 글쎄 제 의도는 그게 아니었다니깐요...?!
개발자 분들에게 유명한 meme인 비둘기 목 헬리콥터를 아실테지요.
한 개발자가 비둘기를 추상화 해서 API를 제공했다고 가정합시다.
여기엔 목 돌리기, 날개 퍼덕이기, 구구 울기 등 다양한 API가 포함 되겠죠.
헌데, 클라이언트 측 개발자는 이 목 돌리기의 횟수에 제한이 없다는 걸 알게 되고, 목 돌리기를 통해 비행하는 기능을 만들 수 있습니다.
이처럼 라이브러리나, API가 다수에게 사용되게 되면, 개발자가 의도하지 않은 모든 '관측 가능한 동작'들에 의존하게 될 수 있습니다.
차후 비둘기 API의 개발자가 목 돌리기의 초당 회수에 제한을 둔다던가 하면, 예기치 못한 방식으로 클라이언트 코드에 영향을 끼칠 수 있는 것이죠!
하이럼의 법칙이 작동하는 방식: 코드 예시
예시 1: 반환 값의 순서
아래와 같은 간단한 사용자 데이터 API 함수가 있다고 가정해 봅시다.
fun getUserData(): Map<String, Any> {
return mapOf(
"name" to "김개발",
"age" to 25,
"joinDate" to "2023-01-01"
)
}
공식 문서에는 "이 함수는 사용자 정보가 담긴 객체를 반환합니다"라고만 명시되어 있습니다.
그러나 Kotlin에서 맵의 속성 순서는 명세의 일부가 아닙니다.
그럼에도 불구하고 누군가는 아래와 같이 클라이언트 코드를 작성할 수 있습니다.
// 어떤 개발자는 이런 코드를 작성할 수 있습니다
val userData = getUserData()
val values = userData.values.toList()
val (name, age, joinDate) = values // 속성 순서에 의존!
이 개발자는 객체 속성의 순서가 항상 name → age → joinDate
라고 가정하고 코드를 작성했습니다.
만약 API 개발자가 내부 구현을 변경하여 속성 순서가 바뀐다면? 이 코드는 예상치 못한 버그를 발생시킬 것입니다.
예시 2: 성능 특성
fun searchUsers(query: String): List<User> {
// 내부 구현: 최대 100명의 결과만 반환
val results = database.findUsers(query).take(100)
return results
}
공식 문서에는 결과 수 제한에 대한 언급이 없지만, 어떤 개발자들은 이 함수가 항상 최대 100개의 결과만 반환한다는 것을 발견하고 이 동작에 의존하는 UI를 설계했을 수 있습니다.
만약 API 개발자가 내부 구현을 변경하여 최대 500개의 결과를 반환하도록 "개선"한다면?
(소프트웨어 엔지니어링 관점에서는 이렇듯 '개선'이 꼭 좋은 결과만을 초래하지 않을 수 있다는 걸 인지해야 합니다. '규모'가 커져 다양한 클라이언트 코드가 해당 라이브러리에 의존한다면 얼핏 '개선'사항으로 보이는 업데이트 일지라도, 더 신중히 생각해야겠죠..!)
이 UI는 예상치 못한 방식으로 깨질 수 있습니다.
예시 3: 비둘기 API와 헬리콥터 목 돌리기
앞서 언급한 개발 업계의 유명한 밈을 활용한 예시를 들어보겠습니다.
// 비둘기 API 명세
class Pigeon {
var position = Position(0, 0)
// 공식 문서화된 메서드들
fun walk(distance: Int) {
position.x += distance
println("비둘기가 ${distance}만큼 걸었습니다")
}
fun rotateNeck(degrees: Int) {
println("비둘기가 목을 ${degrees}도 돌렸습니다")
// 내부 구현: 목 돌리기는 단순히 시각적 효과만을 위한 것
}
data class Position(var x: Int, var y: Int)
}
이 API를 만든 개발자는 rotateNeck
메서드가 단순히 비둘기의 목을 돌리는 시각적 효과만을 위한 것이라고 생각했습니다.
하지만 어느 날, 한 개발자가 다음과 같은 코드를 작성했습니다.
fun makeMyPigeonFly(): Pigeon {
val pigeon = Pigeon()
// 비둘기 목을 헬리콥터처럼 빠르게 돌려서 날게 하기
fun helicopterNeckFlight(): Pigeon {
for (i in 0 until 1000) {
pigeon.rotateNeck(360) // 목을 초고속으로 회전!
}
pigeon.position.y += 100 // 날아오르기!
return pigeon
}
return helicopterNeckFlight()
}
이 개발자는 비둘기 목 돌리기의 내부 구현 특성(빠른 반복 실행 시 부작용 없음)에 의존해서 비행 기능을 만들었습니다.
만약 API 개발자가 나중에 rotateNeck
메서드의 내부 구현을 변경한다면,
(예: 목 돌리기 횟수에 제한을 둔다거나, 연속 회전 시 피로도를 추가한다면)
이 창의적인(?) 비행 솔루션(?)은 더 이상 작동하지 않을 것입니다.
이렇듯 API 개발자가 의도한 바와 상관없이, 사용자들은 관찰 가능한 모든 동작(심지어 비둘기의 목을 헬리콥터처럼 돌리는 것까지도!)에 의존하게 됩니다.
예시 4: 실제 오픈소스 사례
실제 사례를 들자면, 리액트(React)의 초기 버전에서는 컴포넌트의 렌더링 순서가 명세의 일부가 아니었습니다.
그러나 많은 개발자들이 이 순서에 의존하는 코드를 작성했고, 이로 인해 리액트 팀은 내부 구현을 변경할 때 매우 조심스럽게 접근해야 했습니다.
하이럼의 법칙이 중요한 이유
하이럼의 법칙은 소프트웨어 개발의 여러 측면에 깊은 영향을 미칩니다.
1. 버전 관리의 복잡성
모든 관측 가능한 동작이 "계약의 일부"로 취급되기 때문에, 단순한 내부 구현 변경도 사용자에게는 중대한 변화로 인식될 수 있습니다.
이로 인해 하위 호환성을 유지하는 것이 극도로 어려워집니다.
2. 리팩토링의 어려움
코드베이스가 성장하고 사용자가 많아질수록, 내부 구현을 변경하는 것은 점점 더 위험해집니다.
관찰 가능한 모든 동작에 누군가는 의존하고 있을 가능성이 높기 때문입니다.
3. 기술 부채 증가
시간이 지날수록 이러한 암시적 의존성은 계속 증가하여, 결국 코드베이스의 유연성을 크게 저하시킵니다.
하이럼의 법칙을 다루는 전략
하이럼의 법칙은 불가피한 현실이지만, 현명한 전략을 통해 그 영향을 최소화할 수 있습니다.
개발자를 위한 실용적 가이드
1. 명확한 계약 설계
API 설계 시 의도한 동작을 명확하게 문서화하고, 보장하지 않는 동작도 명시적으로 기록하세요.
/**
* 사용자 데이터를 반환합니다.
* @return 사용자 정보 객체
* @note 객체 속성의 순서는 보장되지 않습니다.
* @note 향후 추가 필드가 포함될 수 있습니다.
*/
fun getUserData(): Map<String, Any> {
// 구현...
return mapOf()
}
2. 강력한 추상화
캡슐화의 유용함을 다시 한 번 상기합니다.
구현 세부사항을 철저히 숨기고, 관측 가능한 동작을 최소화하세요. (원천 봉쇄)
// 나쁜 예: 내부 구현이 노출됨
fun processData(data: List<Int>): List<Int> {
return internalProcessingAlgorithm(data)
}
// 좋은 예: 명확한 계약만 노출
fun processData(data: List<Int>): ProcessResult {
// 내부적으로 어떻게 처리하든 결과 형식만 보장
val result = /* 내부 처리 */null // 예시 구현
return ProcessResult(
status = if (result?.isValid == true) "success" else "error",
value = if (result?.isValid == true) result.output else null
)
}
data class ProcessResult(val status: String, val value: Any?)
3. 테스트 전략 개선
의도된 동작만 테스트하고, 구현 세부사항에 의존하는 테스트는 피하세요.
// 나쁜 예: 구현 세부사항에 의존하는 테스트
@Test
fun `processData는 내부적으로 sortData를 호출한다`() {
val internalMock = mockk<Internal>()
every { internalMock.sortData(any()) } returns listOf(1, 2, 3)
processData(listOf(3, 1, 2))
verify { internalMock.sortData(any()) }
}
// 좋은 예: 결과에만 집중하는 테스트
@Test
fun `processData는 정렬된 데이터를 반환한다`() {
val result = processData(listOf(3, 1, 2))
assertEquals(listOf(1, 2, 3), result)
}
4. 점진적인 변경 관리
큰 변경은 작은 단계로 나누어 적용하고, 각 단계에서 사용자에게 충분한 마이그레이션 기간을 제공하세요.
사용자로서의 현명한 접근
API 사용자로서도 하이럼의 법칙을 염두에 두어야 합니다.
- 문서화된 동작에만 의존하세요
- 암시적 가정을 피하고, 불확실한 경우 명시적인 확인 로직을 추가하세요
- 코드가 특정 구현 세부사항에 의존하는지 정기적으로 검토하세요
결론: 현실적인 균형 찾기
하이럼의 법칙은 소프트웨어 개발의 불편한 진실을 보여줍니다.
완벽하게 이 법칙을 피할 수는 없지만, 그 영향을 최소화하기 위한 전략을 적용할 수 있습니다.
개발자로서 우리는 명확한 계약 설계, 강력한 추상화, 점진적인 변경 관리를 통해 하이럼의 법칙과 공존하는 법을 배워야 합니다.
이는 특히 성장하는 프로젝트와 대규모 사용자 기반을 가진 라이브러리를 개발할 때 더욱 중요합니다.
좋은 API 설계자는 미래를 예측하려 하지 않고, 변화에 적응할 수 있는 유연성을 만든다.
다음에 API를 설계하거나 라이브러리를 개발할 때, 하이럼의 법칙을 기억합시다.
우리에게 있어서 코드가 어떻게 사용될지 예상하는 것 만큼이나, 어떻게 오용될 수 있는지 고려하는 것이 더 중요할 수 있습니다.
또, 하이럼의 법칙이 야기하는 문제를 완벽하게 제거할 수 없다고 하더라도, 더 좋은 소프트웨어 설계를 위한 노력을 멈추지는 말아야 겠다는 다짐을 하게 됩니다.
하이럼의 법칙이 적용되는 사례를 직접 경험하고 핸들링한 경험은 아래 블로그 포스팅에서 확인하실 수 있습니다.
2025.04.06 - [개발 경험 기록/기타] - 직접 만나 본 하이럼의 법칙: API 설계의 의도치 않은 사용례와 그 해결 까지
직접 만나 본 하이럼의 법칙: API 설계의 의도치 않은 사용례와 그 해결 까지
소프트웨어 개발자라면 누구나 API를 설계하거나 라이브러리를 만들 때 사용자들이 우리의 의도대로 코드를 사용해주길 바랍니다.하지만 현실은 그렇게 단순하지 않죠.사용자가 많아질수록 우
shin-e-dog.tistory.com
참고 자료
'개발 방법론' 카테고리의 다른 글
헥사고날 아키텍쳐(feat. 코틀린 멀티 모듈) (1) | 2023.11.20 |
---|---|
바운디드 컨텍스트(Bounded Context)와 애그리게이트(Aggregate)의 차이 (0) | 2023.11.13 |
데이터베이스 트랜잭션이 둘 이상의 애그리거트 인스턴스를 수정하지 못하게 하라! (1) | 2023.11.12 |
비즈니스 요구사항 증가에 따른 애그리게이트 분리 (1) | 2023.11.12 |
바운디드 컨텍스트(Bounded Context)의 이해와 가상 적용 사례 (1) | 2023.11.12 |