$ cd /blog
오류 해결

GitHub이 "레포가 없다"고 할 때 — private 저장소가 403이 아니라 404인 이유

레포 16개를 조회했더니 15개가 404였습니다. 이름이 틀린 게 아니라 토큰이 못 보는 private 저장소를 GitHub이 404로 숨긴 것이었습니다. fine-grained PAT의 소유자 제약과 토큰 선택 기준까지 정리했습니다.


안녕하세요. Jay입니다!

개인 저장소에 코드리뷰 자동화를 붙이려고 AI 에이전트에게 대상 레포 16개를 넘겼습니다. 잠시 뒤 이런 답이 돌아왔습니다.

"요청한 목록 중 확인된 레포는 하나뿐입니다. 나머지 15개는 모두 404 Not Found였습니다. 실제 owner가 다르거나 레포명이 틀린 것 같습니다."

이름을 다시 확인했습니다. 전부 맞았습니다. 그런데도 15개가 없다고 나왔죠. 범인은 오타가 아니라 GitHub의 응답 설계였습니다.

🔍 증상 — 성공한 하나가 하필 유일한 public이었다

조회 결과를 표로 놓고 보니 경계가 너무 깨끗했습니다.

구분 개수 결과
public 저장소 1 ✅ 조회 성공
private 저장소 15 404 Not Found

우연이라기엔 정확히 갈렸습니다. 공개 여부가 성공·실패를 완벽하게 예측하고 있었죠. 이름 문제였다면 이렇게 규칙적으로 갈릴 수 없습니다.

🎯 원인 1 — GitHub은 "권한 없음"을 404로 숨긴다

보통 API에서 권한이 없으면 403 Forbidden을 기대합니다. 그런데 GitHub REST API는 토큰이 접근할 수 없는 private 저장소에 404 Not Found를 돌려줍니다.

의도된 설계입니다. 403을 주면 "권한은 없지만 그 저장소는 존재한다"는 사실이 새어 나갑니다. 비공개 프로젝트의 이름을 무작위로 던져보며 존재 여부를 알아낼 수 있게 되는 거죠. 그래서 아예 없는 것처럼 응답합니다.

문제는 이 친절한 보안 설계가 디버깅할 때는 정반대로 작용한다는 점입니다. 404를 보면 누구나 이름부터 의심하지, 토큰 권한을 먼저 떠올리지 않으니까요.

🪤 원인 2 — fine-grained PAT는 소유자를 하나만 고른다

권한을 넓히면 되겠다 싶었는데, 여기서 두 번째 벽을 만났습니다. 당시 붙어 있던 토큰은 github_pat_로 시작하는 fine-grained PAT였습니다.

fine-grained PAT는 발급할 때 Resource owner(리소스 소유자)를 개인 계정 아니면 특정 조직 중 하나만 고를 수 있습니다. 그리고 그 토큰은 선택한 소유자의 저장소만 볼 수 있습니다.

제 토큰은 조직 소유로 발급돼 있었습니다. 즉 개인 계정 저장소는 Repository access 목록에 추가하는 선택지 자체가 나타나지 않습니다. 권한을 아무리 넓혀도 구조적으로 불가능한 거죠.

그럼 public 저장소 하나는 어떻게 통과했을까요. 공개 저장소는 소유자와 무관하게 누구나 읽을 수 있기 때문입니다. 그 하나가 통과한 게 오히려 진단의 결정적 단서였습니다.

🔧 해결 — 개인과 조직을 한 토큰으로 덮기

선택지는 셋이었습니다.

방식 개인 + 조직 동시 비고
fine-grained PAT 1개 소유자가 하나뿐이라 구조적으로 불가
Classic PAT 1개 스코프 기반이라 소유자 제약 없음
GitHub App 정석이지만 초기 구축 비용이 큼

지금 필요한 걸 가장 빨리 끝내려고 Classic PAT를 택했습니다. 필요한 스코프는 이렇습니다.

  • repo — private 저장소 읽기·쓰기 (저장소 웹훅 권한도 포함됩니다)
  • read:org — 조직 리소스 조회
  • admin:repo_hook — 웹훅을 직접 다룰 때 (도구가 요구하면)

여기서 한 번 더 걸렸습니다. gh CLI로 로그인하니 이런 에러가 났거든요.

error validating token: missing required scope 'read:org'

gh CLI는 조직 조회 때문에 read:org를 필수로 요구합니다. 개인 저장소만 쓸 계획이어도 없으면 로그인 자체가 거부됩니다. 다행히 Classic PAT는 토큰 값을 바꾸지 않고 스코프만 추가할 수 있어서, 발급 화면에서 체크만 더하고 Update를 누르면 기존 등록분이 그대로 새 권한을 갖습니다.

대신 대가도 분명합니다. Classic PAT의 repo저장소 단위로 좁힐 수 없어서 계정의 모든 저장소에 읽기·쓰기가 열립니다. 그래서 저는 만료를 90일로 걸어두고 캘린더에 갱신 알림을 잡아뒀습니다.

💡 Jay의 한 줄 진단법

같은 상황을 다시 만나면 이 순서로 봅니다.

  1. 성공한 저장소가 public인지 확인합니다. public만 통과했다면 이름 문제가 아니라 권한 문제입니다.
  2. 토큰 형식을 봅니다. github_pat_이면 fine-grained, ghp_면 Classic입니다.
  3. fine-grained라면 Resource owner가 무엇인지 확인합니다. 조직으로 발급됐는데 개인 저장소를 찾고 있다면, 권한을 넓히는 걸로는 절대 해결되지 않습니다.

한 줄로 확인하려면 조직 저장소를 하나 찔러보면 됩니다.

gh api repos/<org>/<repo> --jq .permissions.admin

이게 true인데 개인 저장소가 404라면, 토큰이 조직 소유라는 뜻입니다.

결론

  • GitHub은 권한 없는 private 저장소에 403이 아니라 404를 줍니다. 존재 자체를 숨기려는 의도된 설계입니다
  • 그래서 404를 보면 이름부터 의심하게 되지만, 공개 여부로 성공·실패가 갈린다면 권한 문제입니다
  • fine-grained PAT는 Resource owner가 하나뿐이라, 조직 소유 토큰으로는 개인 저장소를 볼 수 없습니다. 권한을 넓혀도 안 됩니다
  • 개인과 조직을 한 토큰으로 덮어야 한다면 Classic PAT가 현실적이고, 격리가 중요해지면 GitHub App으로 넘어가는 게 맞습니다
  • gh CLI는 read:org를 필수로 요구합니다

에러 메시지가 친절하지 않을 때는 실패한 것들의 공통점보다 성공한 하나의 특징을 보는 게 빠를 때가 있습니다. 이번엔 그 하나가 전부를 설명해줬습니다.

다음에도 유익한 포스팅으로 찾아오겠습니다. 감사합니다!

📚 참고 / 출처

#GitHub#API#PAT#인증#gh CLI

$ ls related/

더 많은 IT·AI 활용법이 궁금하다면?

매주 새로운 실전 가이드가 업데이트됩니다.

전체 글 보기