기술

외부 API 직접 호출이 세 번 다른 이유로 막혔던 것이 공식 CLI에 위임하니 뚫렸습니다

2026.09.119분 읽기

내부 프로젝트 관리 도구에 LLM 호출을 붙이는 중이었습니다. 요약·분류·질의응답까지 모델을 부르는 자리가 늘어나는데, 호출량이 늘 때마다 사용량 요금이 따라 붙는 구조는 1인 규모 도구에서 부담이 큽니다. 이미 매달 구독료를 내고 있는 계정이 있으니 그 자격을 쓸 수 있는지부터 확인하기로 했습니다.

비슷한 발상으로 만들어진 사례를 먼저 찾았습니다. 개인 비서 에이전트와 몇몇 오픈소스 프로젝트가 공식 CLI의 인증 흐름을 빌려 쓰고 있었습니다. 저쪽이 이미 그렇게 돌리고 있으니 우리도 같은 원리를 적용할 수 있겠다고 보고 검증에 들어갔습니다. 결과부터 적으면 직접 호출은 전부 막혔고, 남은 길은 하나였습니다.


직접 호출은 세 번 다 다른 이유로 막혔습니다

첫 번째 벽은 봇 차단이었습니다. 그 서비스의 내부 백엔드 엔드포인트를 HTTP 클라이언트로 직접 부르니 403이 돌아왔습니다. 응답 본문은 JSON이 아니라 자바스크립트 챌린지 HTML 페이지였습니다. 브라우저가 아닌 클라이언트는 이 지점에서 통과하지 못합니다.

두 번째 벽은 토큰의 권한 범위였습니다. CLI가 이미 발급받아 둔 OAuth 토큰을 그대로 붙여 공개 API 엔드포인트를 불렀더니 401이 왔고, 메시지가 Missing scopes: model.request 였습니다. 그 토큰의 scope에는 커넥터 조회와 실행 권한만 들어 있었습니다.

토큰이 발급됐다는 사실과 그 토큰으로 원하는 API를 부를 수 있다는 사실은 별개입니다. 저는 인증만 통과하면 그 뒤는 열려 있다고 가정하고 있었습니다. scope 목록을 먼저 펴봤으면 여기서 반나절을 쓰지 않았을 겁니다.

세 번째 벽은 배포 채널이었습니다. 패키지 매니저의 최신 태그로 설치한 CLI는 0.36.0이었고, 최신 모델을 지정하자 더 새로운 버전이 필요하다며 거부했습니다. 별도 설치 채널로 받으니 0.130.0이 나왔습니다. 같은 이름의 도구가 채널에 따라 100 버전 가까이 벌어져 있었습니다.

134-1.jpg


네 가지 길을 늘어놓고 골랐습니다

세 번의 실패를 정리하고 나니 선택지가 네 개로 좁혀졌습니다. 각각을 붙여놓고 무엇이 남는지부터 봤습니다.

시도

결과

남는 문제

내부 백엔드로 직접 HTTP 호출

403

봇 차단이 그대로 남음

OAuth 토큰으로 공개 API 호출

401

토큰 scope에 모델 호출 권한 없음

인증 흐름을 우리가 직접 구현

가능하지만 미채택

차단은 그대로 + 약관 회색지대

공식 CLI에 subprocess 위임

동작 확인

CLI 출력 형식과 정책에 종속

세 번째를 접은 이유는 기술적 난이도가 아니었습니다. 인증 흐름을 직접 구현해도 봇 차단은 그대로 남고, 구독 자격을 애플리케이션이 대신 쓰는 형태는 약관상 회색지대에 걸립니다. 시간을 더 써도 두 문제 중 어느 쪽도 해결되지 않는 길이었습니다.


CLI에 떠넘기면 세 문제가 한꺼번에 사라집니다

네 번째를 골랐습니다. 이유는 단순합니다. 우리가 못 푼 문제 세 개를 그 CLI가 이미 풀어놓았기 때문입니다. 봇 차단 통과, 토큰 만료 시 갱신, 필요한 scope 확보까지 전부 CLI 안에서 끝납니다.

우리 코드가 아는 것은 프롬프트를 넣고 텍스트를 받는 인터페이스 하나로 줄어듭니다. 인증 상태를 우리 DB에 저장할 필요도, 토큰 만료를 감시할 스케줄러를 붙일 필요도 없어집니다.

출입 절차가 까다로운 건물에 서류를 넣는 일과 비슷합니다. 정문으로 직접 가면 신분 확인에서 막히지만, 이미 출입증을 가진 사람에게 봉투를 건네면 안까지 들어갑니다. 대신 그 사람이 그만두면 우리 서류도 같이 멈춥니다. 의존이 없어진 것이 아니라 자리를 옮긴 것이고, 그게 이 선택의 대가입니다.

134-2.jpg


구현에서 실제로 걸린 것

위임 자체는 subprocess 호출 한 줄입니다. 그런데 붙여놓고 보니 세 가지를 손봐야 했습니다.

표준 입력을 DEVNULL로 잠갔습니다. 프롬프트는 인자로 넘깁니다. CLI가 대화형 입력을 기다리는 상태로 들어가면 그 요청을 처리하던 스레드가 응답 없이 멈춥니다

출력을 두 스트림으로 나눠 읽었습니다. 0.130 기준으로 모델 응답만 표준 출력으로 나오고, 실행 메타 정보와 토큰 사용량 라벨은 표준 에러로 나옵니다. 한 덩어리로 받으면 응답 텍스트에 로그가 섞여 들어옵니다

버전을 고정하고 파싱 실패 시 원문 전체를 로그에 남기게 했습니다. 이 파싱은 CLI 출력 형식에 종속되고, 형식이 바뀌면 에러 없이 조용히 이상한 텍스트를 반환합니다

호출 위치도 같이 정했습니다. subprocess 호출은 트랜잭션 바깥에 둡니다. 외부 호출을 트랜잭션 안에서 부르면 상대 시스템이 느려질 때 커넥션 풀이 먼저 마릅니다. 이건 예전에 한 번 크게 겪은 적이 있습니다.


채팅은 단발 실행 대신 서버 모드로 붙였습니다

요약·분류처럼 한 번 부르고 끝나는 자리는 단발 실행 모드로 충분했습니다. 채팅은 달랐습니다. 이전 대화 맥락이 유지돼야 하는데, 매 요청마다 프로세스를 새로 띄우면 맥락이 사라지고 실행 비용도 매번 붙습니다.

그래서 CLI를 서버 모드로 띄웠습니다. 이 모드는 CLI를 JSON-RPC 서버로 올리고 대화 도구를 노출합니다. 스레드 식별자를 들고 다니면 여러 턴이 이어지고, 프로세스는 한 번만 띄워 재사용하므로 호출마다 붙던 실행 비용도 사라집니다.

남은 문제는 도구 호출이었습니다. 이 경로에는 네이티브 function calling이 없습니다. 그래서 개발자 지시문에 약속을 하나 심었습니다. 도구를 부르고 싶으면 약속된 태그 안에 JSON을 쓰라고 지시하고, 응답에서 그 블록을 정규식으로 뽑아냅니다.

<tool_call>{"name": "search_plan", "args": {"q": "지난주 완료"}}</tool_call>

오래된 ReAct 패턴 그대로입니다. 모델이 도구 스펙을 이해하는 것과 전용 API 필드로 그걸 표현하는 것은 다른 층의 문제이고, 앞쪽만 되면 뒤쪽은 약속으로 대체할 수 있습니다. 응답까지 20초, 품질은 로컬에서 돌리던 26B급 오픈 모델과 차이가 컸습니다.

다만 응답에서 식별자를 줄여 쓰는 문제가 남아, 지시문에 전체 이름을 그대로 쓰라는 규칙을 후속으로 붙였습니다.

134-3.jpg


같은 벽 앞에 섰을 때 먼저 확인할 것

이번 검증에서 순서를 잘못 잡아 쓴 시간이 적지 않았습니다. 다음에 비슷한 연동을 검토한다면 이 순서로 봅니다.

토큰이 발급됐는지가 아니라, 그 토큰의 scope에 부르려는 API 권한이 들어 있는지를 먼저 확인합니다

막힌 응답의 본문이 JSON인지 HTML인지 봅니다. HTML이면 인증 문제가 아니라 봇 차단입니다

공식 CLI가 있는지, 그 CLI가 인증과 갱신을 대신 떠맡는지 확인합니다. 있으면 자체 구현보다 먼저 후보에 올립니다

설치 채널이 여러 개면 어느 쪽이 최신인지 확인합니다. 채널마다 버전 격차가 큽니다

단발 호출인지 여러 턴 대화인지 정합니다. 대화가 필요하면 단발 실행이 아니라 서버 모드가 맞습니다

외부에 파는 서비스라면 구독 자격 위임 대신 발급 키 라우팅이 정공법인지 검토합니다


이 우회의 유효 기간은 우리가 정하지 않습니다

이 방식은 제공자가 CLI 정책을 바꾸면 그날로 끊깁니다. 우리 코드가 잘못한 게 없어도 끊깁니다. 그래서 호출부를 인터페이스 하나로 감싸고 구현체를 갈아끼울 수 있게 뒀습니다. 위임 방식이 막히면 그 자리에 발급 키 호출을 넣으면 됩니다.

한계도 분명합니다. 인증이 로컬 파일 하나에 얹혀 있어 여러 사용자가 각자 자격으로 호출하는 구조에는 맞지 않습니다. 이번 건은 사용자가 사실상 한 명인 내부 도구라 성립했습니다. 외부에 파는 서비스였다면 이 선택지는 검토 대상에서 빠집니다.

공식 CLI에 위임하는 방식은 인증이 까다로운 외부 시스템을 붙일 때 꽤 자주 쓸 수 있는 카드입니다. 붙이기 전에 그 CLI가 무엇까지 떠맡는지, 그리고 그게 끊겼을 때 우리 쪽에 무엇이 남는지를 같이 적어두면 됩니다.

#외부API연동#OAuth스코프#봇차단우회#CLI위임#subprocess#MCP서버#ReAct패턴