본문 바로가기
개발 경험 기록/기타

직접 만나 본 하이럼의 법칙: 자체 제작 라이브러리의 의도치 않은 사용례와 그 해결 까지

by 시니성 2025. 4. 6.

소프트웨어 개발자라면 누구나 API를 설계하거나 라이브러리를 만들 때 사용자들이 우리의 의도대로 코드를 사용해주길 바랍니다.
하지만 현실은 그렇게 단순하지 않죠.
사용자가 많아질수록 우리가 예상하지 못한 방식으로 우리의 코드가 사용됩니다.
이것이 바로 '하이럼의 법칙(Hyrum's Law)'의 핵심입니다.

최근 제가 개발한 BridgeApi 라이브러리를 통해 하이럼의 법칙을 직접 경험하게 되었고, 이를 어떻게 인식하고 해결했는지 공유하고자 합니다.

하이럼의 법칙이란?

충분히 많은 수의 API 사용자가 있다면, 당신이 계약서에서 무엇을 약속했는지는 중요하지 않다. 당신의 시스템에서 관찰 가능한 모든 동작은 누군가에 의해 의존될 것이다.

— 하이럼 라이트(Hyrum Wright)

간단히 말해, 하이럼의 법칙은 사용자들이 API 문서에 명시된 내용뿐만 아니라 관찰 가능한 모든 동작에 의존하게 된다는 것을 의미합니다.
개발자로서 의도한 사용법과 무관하게, 사용자가 많아지면 누군가는 당신이 생각지도 못한 방식으로 당신의 코드를 사용할 것입니다. (자세한 이론적 내용은 아래 포스팅을 참조바랍니다.)

 

2025.03.31 - [개발 방법론] - 아 글쎄 제 의도는 그게 아니었다니깐요?! - 하이럼의 법칙 -

 

아 글쎄 제 의도는 그게 아니었다니깐요?! - 하이럼의 법칙 -

개발자로서 API를 설계하거나 라이브러리를 만들 때, 우리는 사용자들이 우리가 문서화한 방식대로만 코드를 사용할 것이라고 기대합니다.하지만 현실은 그렇게 단순하지 않습니다.바로 이 지

shin-e-dog.tistory.com

 

실제 사례: 자체 라이브러리인 BridgeApi와 Axios의 유사성에 의존하는 코드

배경: BridgeApi 라이브러리 개발

저는 안드로이드 웹뷰에서 네이티브 코드를 호출할 수 있는 BridgeApi라는 라이브러리를 개발했습니다.
이 라이브러리는 프론트엔드 개발자들이 네이티브 기능을 REST API 스타일로 쉽게 사용할 수 있도록 설계되었습니다.

클라이언트 측 라이브러리(BridgeApiClient)를 설계할 때, 저는 프론트엔드 개발자들에게 친숙한 인터페이스를 제공하기 위해 아래와 같이 인기 있는 HTTP 클라이언트 라이브러리인 Axios와 유사한 API를 채택했습니다.

// BridgeApi 클라이언트 사용 예시
const api = BridgeApi.create({
    headers: {"Authorization": "Bearer token"},
    timeout: 5000,
});

// GET 요청
api.get('/users/1')
   .then(user => console.log(user))
   .catch(error => console.error(error));

// POST 요청
api.post('/users', { name: "John", age: 30 })
   .then(response => console.log(response));

다만 이 설계는 학습 곡선을 낮추고 개발자들의 적응을 돕기 위함이었지, Axios와의 완벽한 호환성을 보장하기 위함은 아니었습니다. (라이브러리의 자세한 개발 과정은 아래 포스팅에서 보실 수 있습니다.)

2024.07.22 - [작디 작은 나만의 라이브러리/BridgeApi] - [신입 개발자의 '작디 작고 작디 작고 자그만' 첫 라이브러리 제작기] 0. Bridge-Api를 구상하게 된 계기.

 

[신입 개발자의 '작디 작고 작디 작고 자그만' 첫 라이브러리 제작기] 0. Bridge-Api를 구상하게 된

안녕하세요.아주아주아주 오랜만에 생성형 AI가 만든 글이 아닌 직접 쓰는 글을 올리게 됐습니다.이번 주제는 제가 처음으로 만들어본 작은 라이브러리, Bridge-Api의 제작기입니다.여태까지 시리

shin-e-dog.tistory.com

 

문제 발견: 하이럼의 법칙의 작동

몇 개월 후, 저는 이직 전 인수인계를 위해 프론트엔드 팀이 개발한 코드를 검토하던 중 다음과 같은 코드를 발견했습니다.

import { BridgeApi } from "bridge-api-client-ts";
import { customAxios } from "./axios.ts";

const customBridgeClient = BridgeApi.create();

// 플랫폼에 따라 다른 HTTP 클라이언트 사용
export const apiClient = import.meta.env.VITE_REACT_API_MODE_ENV === "ANDROID" 
    ? customBridgeClient 
    : customAxios;

이 코드는 환경에 따라 BridgeApiAxios를 직접 교체하여 사용하고 있었습니다.
문제는 두 라이브러리가 유사하게 설계되었다는 이유로, 완전히 호환된다고 가정하고 있다는 점이었습니다.

이 코드는 완전히 하이럼의 법칙에 들어맞는 예시였습니다.
프론트엔드 개발자들은 두 라이브러리의 API가 비슷하다는 '관찰 가능한 동작'에 의존하고 있었고, 저는 이런 사용 방식을 의도하거나 문서화한 적이 없었습니다.

잠재적 위험 인식

이러한 접근 방식의 위험성은 명확했습니다.

  1. BridgeApiAxios의 API가 변경되면 호환성이 깨질 수 있음
  2. 두 라이브러리가 미묘하게 다른 동작을 할 경우 예측할 수 없는 버그 발생 가능성
  3. 향후 의존성 업데이트시 마이그레이션이 어려워 질 수 있음

문제 해결 과정

1. 프론트엔드 팀에 협조 요청

문제를 발견한 후, 저는 프론트엔드 팀에 다음과 같은 내용의 협조 요청을 보냈습니다.

* 현재 '프론트 엔드' 개발에서 플랫폼에 따라 axios와 bridgeClient 바꿔 끼우는 것으로 사용하고 있습니다.

* 이는 사실 제가 bridgeClient를 만들때 예상한 사용례는 아닙니다. axios와의 호환을 위해 axios함수와 파라미터 스펙을 맞춘 것이 아니라, 프론트엔드 개발자분들이 사용하기에 친숙하도록 설계한 것이기 때문입니다.

* 현재는 일종의 하이럼의 법칙에 해당하는 케이스로, 제가 의도하지 않은 방향으로 bridgeClient에 의존하고 있습니다. (* 하이럼의 법칙: API명세 외의 '관측되는 동작'에 의존하는 것. https://shin-e-dog.tistory.com/153)

* 나중에 bridgeClient의 스펙이 수정되거나 axios의 스펙이 수정되는 경우 큰 문제를 야기할 수 있습니다. axios와 bridgeClient간의 결합도가 떨어질 수 있도록 코드를 수정하시길 권유드립니다.

 

이 메시지에서 저는 다음 내용들을 전달하고 싶었습니다.

  • 현재 사용 방식의 문제점 설명
  • 하이럼의 법칙 개념 소개
  • 잠재적 위험 제시
  • 코드 수정 요청

이렇게 명확한 커뮤니케이션을 통해 문제의 본질을 이해시키고 해결하고자 했습니다.

2. 어댑터 패턴을 활용한 해결책

프론트엔드 팀은 이 문제를 어댑터 패턴을 사용해 해결했습니다.
어댑터 패턴은 호환되지 않는 인터페이스들을 함께 작동하게 해주는 디자인 패턴입니다.

// 공통 인터페이스 정의
export interface ApiRequestInterface {
  get<T>(url: string, timeout?: number): Promise<ApiCommonResponse<T>>;
  post<T>(url: string, data?: any, timeout?: number): Promise<ApiCommonResponse<T>>;
  put<T>(url: string, data?: any, timeout?: number): Promise<ApiCommonResponse<T>>;
  patch<T>(url: string, data?: any, timeout?: number): Promise<ApiCommonResponse<T>>;
  delete<T>(url: string, data?: any, timeout?: number): Promise<ApiCommonResponse<T>>;
}

// 클라이언트 인터페이스 구현
class ApiClientInterface implements ApiRequestInterface {
  private client: ApiRequestInterface;

  private constructor(client: ApiRequestInterface) {
    this.client = client;
  }

  // 팩토리 메서드
  static create(): ApiClientInterface {
    const isAndroid = import.meta.env.VITE_REACT_API_MODE_ENV === "ANDROID";

    if (isAndroid) {
      return new ApiClientInterface(new BridgeApiAdapter(BridgeApi.create()));
    } else {
      return new ApiClientInterface(new AxiosAdapter(customAxiosInstance));
    }
  }

  // 인터페이스 메서드 구현 (위임)
  get<T>(url: string, timeout?: number): Promise<ApiCommonResponse<T>> {
    return this.client.get(url, timeout);
  }

  post<T>(url: string, data?: any, timeout?: number): Promise<ApiCommonResponse<T>> {
    return this.client.post(url, data, timeout);
  }

  // 나머지 메서드 구현...
}

// 사용 방법
export const ApiClient = ApiClientInterface.create(); 

이 방식을 통해 다음과 같은 이점과 안정성을 확보할 수 있었습니다.

  1. 명확한 인터페이스를 정의하여 기대하는 API 계약을 명시
  2. 각 라이브러리별 어댑터가 구현 세부사항을 캡슐화
  3. 팩토리 메서드를 통한 적절한 구현체 생성
  4. 향후 변경에 대한 유연성 확보

결과 분석

이 해결책은 다음과 같은 장점을 제공했습니다:

  1. 확실한 계약: 명시적인 인터페이스를 통해 기대되는 동작을 명확히 함
  2. 느슨한 결합: 두 라이브러리 간의 직접적인 의존성 제거
  3. 유지보수성 향상: 한 라이브러리의 변경이 다른 라이브러리에 영향을 미치지 않음
  4. 확장성: 필요시 다른 HTTP 클라이언트도 쉽게 추가 가능

무엇보다 중요한 점은, 이제 더 이상 하이럼의 법칙의 함정에 빠지지 않는다는 것입니다.
우리는 암시적인 동작에 의존하지 않고, 명시적인 계약을 통해 코드를 작성하게 되었습니다.

교훈과 결론

이 경험을 통해 배운 교훈은 다음과 같습니다.

  1. 의도를 명확히 하라: API 설계 시 의도한 사용 방법을 명확히 문서화하고, 보장하지 않는 동작도 명시적으로 언급해야 합니다.
  2. 암시적 동작에 주의하라: 특히 인기 있는 라이브러리와 유사한 API를 설계할 때, 사용자들이 완전한 호환성을 가정할 수 있다는 점을 인지해야 합니다.
  3. 선제적으로 대응하라: 문제가 발생하기 전에 잠재적 리스크를 식별하고 커뮤니케이션하는 것이 중요합니다.

하이럼의 법칙은 소프트웨어 개발의 불편한 진실을 보여줍니다.
우리가 의도한 바와 상관없이, 사용자들은 관찰 가능한 모든 동작에 의존하게 됩니다.
완벽하게 이 법칙을 피할 수는 없지만, 그 영향을 최소화하기 위한 전략을 적용할 수 있습니다.

이 케이스는 제게 PI 설계와 라이브러리 개발에 있어 하이럼의 법칙을 고려하는 중요성을 일깨워준 소중한 경험이었습니다.

728x90