2P by GN⁺ 10일전 | ★ favorite | 댓글 1개
  • GitHub API를 사용하던 중 PR 코멘트 링크 생성 기능에서 ID 불일치로 링크가 작동하지 않는 문제가 발생
  • 조사 결과 GitHub은 GraphQL의 node IDREST API의 database ID라는 두 가지 ID 체계를 병행 사용
  • node ID를 base64 디코딩한 결과, 하위 32비트에 database ID가 포함되어 있음이 확인되어 간단한 비트마스크 연산으로 변환 가능
  • 추가 분석을 통해 GitHub이 MessagePack 기반의 새로운 ID 포맷문자열 기반의 레거시 포맷을 혼용하고 있음이 드러남
  • 이러한 구조는 GitHub 내부 객체 식별 체계의 이중성을 보여주며, 개발자에게 API 통합 시 주의가 필요함

GitHub의 이중 ID 체계 발견

  • Greptile의 AI 코드 리뷰 도구 기능 개발 중, GitHub PR 코멘트 링크가 작동하지 않는 문제 발생
    • 저장된 코멘트 ID를 URL에 연결했으나, 클릭 시 GitHub 페이지로 이동되지 않음
  • GitHub 문서 확인 결과, GraphQL API의 node IDREST API의 database ID가 서로 다른 체계로 존재
    • node ID 예시: PRRC_kwDOL4aMSs6Tkzl8
    • database ID 예시: 2475899260
  • node ID는 GitHub 전체에서 객체를 전역적으로 식별하기 위한 base64 인코딩 문자열, database ID는 정수형 URL 식별자로 사용

node ID와 database ID의 관계 분석

  • 여러 PR 코멘트의 node ID와 database ID를 비교한 결과, 두 값이 일정한 간격으로 증가함을 확인
  • node ID의 base64 부분을 디코딩하자 96비트 정수가 생성되었으며, 이 값의 하위 32비트가 database ID와 일치
    • 예시: PRRC_kwDOL4aMSs6Tkzl8 → 하위 32비트 = 2475899260
  • 간단한 비트마스크 연산으로 database ID를 추출 가능
    • decoded & ((1 << 32) - 1) 형태의 연산으로 변환 수행

GitHub의 레거시 ID 포맷

  • 오래된 저장소(torvalds/linux)의 node ID를 디코딩하자 다른 형식의 문자열이 나타남
    • 예시: MDEwOlJlcG9zaXRvcnkyMzI1Mjk4010:Repository2325298
  • 이 포맷은 [객체 타입 번호]:[객체 이름][Database ID] 구조로, 명시적 문자열 기반 식별자
  • 트리 객체의 경우 04:Tree2325298:7201bfb9... 형태로, 리포지토리 ID와 SHA 값을 함께 포함
  • GitHub은 레거시 포맷과 새로운 포맷을 병행 사용하며, 객체 유형과 생성 시점에 따라 포맷이 달라짐

새로운 node ID 포맷의 구조

  • GitHub의 GraphQL 마이그레이션 가이드는 node ID를 불투명 문자열로 취급하라고 명시하지만, 내부 구조는 존재
  • base64 디코딩 후 MessagePack으로 언팩하면 배열 형태의 데이터가 나타남
    • 예시: [0, 47954445, 2475899260]
  • 배열의 구성
    • 첫 번째 요소(0): 버전 식별자로 추정
    • 두 번째 요소(47954445): 리포지토리의 database ID
    • 세 번째 요소(2475899260): 객체의 database ID
  • 객체 유형에 따라 배열 길이가 다르며, 커밋은 SHA를 포함, 리포지토리는 두 요소만 포함

실용적 활용과 결론

  • 새로운 node ID에서 database ID를 추출하는 Python 코드 예시
    import base64, msgpack
    def node_id_to_database_id(node_id):
        prefix, encoded = node_id.split('_')
        packed = base64.b64decode(encoded)
        array = msgpack.unpackb(packed)
        return array[-1]
    
  • 이 방식으로 PR 코멘트의 database ID를 직접 추출하여 URL 링크 문제 해결 가능
  • GitHub은 현재 MessagePack 기반의 새로운 ID 체계와 문자열 기반의 레거시 체계를 동시에 유지
  • 이러한 구조는 GitHub 내부의 전환 과정과 호환성 유지 노력을 보여주며, API를 사용하는 개발자는 ID 포맷 차이에 주의해야 함
Hacker News 의견들
  • 최신 GitHub 글로벌 노드 ID'X-Github-Next-Global-ID' 헤더를 통해 강제로 사용할 수 있음
    ID는 객체의 타입 접두사와 base64로 인코딩된 msgpack 페이로드로 구성됨
    예를 들어 내 사용자 ID "U_kgDOAAhEkg"[0, 541842]로 디코딩되며, 이는 REST API의 databaseId와 일치함
    하지만 이런 내부 구현에 의존하지 말고, GraphQL API의 databaseId 필드를 직접 조회하는 것이 좋음
    관련 문서: GraphQL 글로벌 노드 ID 마이그레이션 가이드, 내 GitHub 사용자 정보, CyberChef 디코딩 예시, GitHub ETag 구현

  • 이런 식으로 디코딩하는 건 취약하다고 생각함
    GraphQL의 글로벌 노드 ID는 본래 불투명(opaque) 해야 함
    GitHub의 여러 타입(PullRequest 등)은 databaseId 필드를 제공하므로 그걸 쓰는 게 맞음
    대부분의 GraphQL API는 타입명과 DB ID를 base64로 인코딩하지만, 이 규칙이 항상 유지된다고 보장할 수 없음
    참고: PullRequest 객체 문서, GraphQL 글로벌 ID 스펙

    • GitHub의 GraphQL 타입에는 permalink, url 같은 필드와 UniformResourceLocatable 인터페이스가 있어서 직접 URL을 구성할 필요가 없음
    • 이런 내부 구조는 시간이 지나면 깨질 가능성이 높음
      그래서 API가 permalink를 제공하는 이유가 있음. ID나 링크 패턴은 언제든 바뀔 수 있음
    • 식별자에 메타데이터를 넣고 싶다면, 사용자가 내부 구조에 의존하지 않도록 암호화하는 게 좋음
      이런 방식은 pagination 토큰에서도 자주 사용됨
  • 010:Repository2325298 같은 ID는 명확한 구조를 가짐
    010은 타입 enum, Repository는 이름, 2325298은 DB ID임
    즉, 길이 접두사(length prefix) 형태임. Repository는 10자, Tree는 4자임

    • BitTorrent 프로토콜이 떠오름
    • 거의 URN처럼 보임
  • Opus 4.5는 이런 GitHub ID 디코딩 트릭을 알고 있으며, 자동으로 디코딩 코드를 작성함

  • 작성자가 발견한 내용은 기술적으로는 맞지만, 문서화되지 않았고 지원되지 않음
    GitHub는 과거에도 노드 ID 내부 구조를 조용히 바꾼 적이 있음
    MessagePack 배열에 필드를 추가하거나, 인코딩을 바꾸거나, 암호화하거나, UUID 기반으로 바꾸면
    이런 내부 구조에 의존한 시스템은 즉시 깨짐

  • 내가 명시적으로 저장하는 GitHub 식별자는 불변 URL 키(issue/pr 번호나 커밋 해시) 정도임
    댓글 ID는 JSON 블롭 안에 그냥 포함시킴
    모든 걸 정규화하려고 할 필요는 없음. JSON은 충분히 빠름
    코멘트 단위로 교차 쿼리를 하지 않는 이상, 성능 문제로 드러날 일은 거의 없음

    • 하지만 issue/pr URL은 불변이 아님
      저장소가 이름을 바꾸거나 다른 조직으로 옮기면 URL이 바뀔 수 있음
  • 예전 v3 API에는 ID가 없어서, 누군가 사용자명이나 저장소명을 바꾸면 누군지 추적하기 어려웠음
    그래서 나는 팀 단위 소유권 관리 시스템을 직접 구현했음
    Terraform provider가 별로라서, 오프보딩 시 “유일한 관리자였던 사람이 나갔다” 같은 문제가 자주 생겼기 때문임
    모든 저장소는 팀이 소유하고, 접근 권한도 팀 단위로만 부여함

    • “사용자에게 접근 권한을 준다”가 아니라 “팀에 권한을 주고, 사용자는 그 팀의 일원이다”라는 사고방식이 훨씬 효율적임
      이런 팀 기반 접근 제어는 GitHub뿐 아니라 다른 시스템에도 유용함
  • Hyrum’s Law의 전형적인 사례임 — 사람들이 문서화되지 않은 동작에 의존하기 시작하면 결국 깨짐

  • 데이터베이스 설계에서는 보통 외부에는 불투명한 자연 키를 제공하고, 내부에서는 증가형 정수 ID를 사용함

    • 그 이유는 두 가지임
      1. 외부에 객체 개수를 노출하지 않기 위해
      2. ID를 단순히 증가시켜 모든 객체를 순회하지 못하게 하기 위해
        하지만 복합 ID를 쓰면 이런 문제가 줄어듦.
        예를 들어 저장소 ID 안에 객체 ID가 포함되어 있으면, ID를 증가시켜도 같은 저장소 내 객체만 탐색됨
        여기에 엔트로피나 타임스탬프를 섞으면 악용이 거의 불가능함
    • 하지만 자연 키는 변경될 수 있음
      그래서 의미 없는 대체 키(surrogate key) 를 노출하는 게 더 안전함
      예를 들어 YouTube는 내부적으로 인덱스 번호를 쓰더라도, 외부에는 의미 없는 코드 형태의 ID를 제공함
  • GitHub 팀이 최근 몇 년간 Rails에 sharded/multi-database 지원을 대폭 확장한 이유가 이제 이해됨