릴리스 앱에 박힌 API 키 회전 — 서버 키는 바꾸고 Gemini 구키는 놔뒀다, «바꿨다»와 «반영됐다»는 다른 명제였다 (dex 검사표·회전 순서·Vercel Redeploy)

미리 3줄로 보면
  • 증상: 지난 글에서 새어 나온 그 Gemini 키가 배포된 앱 파일(dex) 안에 평문으로 들어 있었고, 새 것으로 바꾸는 «회전»을 해야 했습니다
  • 원인: 앱이 Gemini와 사주 서버를 직접 부르느라 키를 품은 구조. 그리고 회전 중 함정 — 서버 환경변수를 «바꿨는데» 구키가 여전히 통했습니다. 저장과 반영은 다른 일이었습니다
  • 해결: 새 빌드는 게이트웨이만 부르게 하고 dex 3개를 전수 검사(금지 패턴 5종 0건), 서버 키는 값 교체 → Redeploy → 401/200 실측으로 닫았습니다. Gemini 구키는 «놔둬도 된다»는 답으로 보류, 대신 키 두 개가 10자까지 같다는 걸 알게 됐습니다
안경 쓴 딱따구리가 책상 위에 열쇠 두 개를 나란히 놓고 돋보기로 들여다보는 모습, 한쪽 열쇠에는 빨간 리본, 다른 쪽에는 초록 리본이 달려 있고 뒤편 노트북 화면에는 자물쇠 아이콘이 떠 있다
안경 쓴 딱따구리가 책상 위에 열쇠 두 개를 나란히 놓고 돋보기로 들여다보는 모습, 한쪽 열쇠에는 빨간 리본, 다른 쪽에는 초록 리본이 달려 있고 뒤편 노트북 화면에는 자물쇠 아이콘이 떠 있다 (AI 생성 삽화)

혹시 이런 상황인가요?

AI API 키를 앱에 넣어 배포한 적이 있고, 나중에 «그 키가 앱 파일에서 뽑힌다»는 걸 알게 됐습니다. 새 키를 만들고 옛 키를 지우려는데 손이 멈춥니다. 옛 키를 지우면 아직 옛 버전을 쓰는 사람의 앱은 어떻게 되지? 서버 쪽 키도 바꿔야 하나? 바꿨다면 정말 바뀐 건 어떻게 확인하지? 이 글은 그 세 질문을 하루 동안 푼 기록입니다.

저도 똑같이 당했습니다

아기 이름을 사주로 추천해 주는 안드로이드 앱을 AI와 함께 만들고 있습니다. 지난 글에서 릴리스 앱의 logcat에 API 키가 찍히는 걸 봤는데, 그 뒤 숙제로 미뤄 뒀던 쪽이 생각보다 넓다는 걸 알았습니다. 배포한 앱 파일을 풀어 보면 키 두 개가 평문으로 들어 있었습니다. Gemini 키 하나, 사주 계산 서버용 키 하나. 처음엔 마지막 버전(내부 번호 vc11)만의 문제인 줄 알았는데, 나중에 AI가 git 이력을 뒤져 보니 Gemini 키는 첫 커밋부터, 사주 키도 뒤이어 — 결국 지금까지 만든 모든 빌드에 들어 있었습니다.

고친 새 빌드(vc12)는 만들어 뒀지만 문제는 «옛 키를 어떻게 죽이느냐»였습니다. AI에게 «구키 회전 절차 알려줘»라고 물었고, 그날 하루의 작업입니다.

원인은 이거였습니다 — 키가 앱 안에 있었다

무대를 한 문장으로 — 앱이 AI를 부를 때 열쇠를 어디에 두느냐의 문제입니다. 옛 빌드는 앱이 Gemini와 사주 서버를 직접 불렀고, 그래서 열쇠를 앱 안에 품었습니다. 앱은 누구나 내려받아 열어 볼 수 있으니, 열쇠를 현관 매트 밑에 둔 셈입니다.

옛 빌드는 앱이 앱에 박힌 키로 Gemini와 사주 계산 서버를 직접 부르고, 새 빌드는 비밀 없이 게이트웨이만 부르며 게이트웨이가 서버의 키로 Gemini를 부르는 구조 비교 개념도
옛 빌드는 앱이 앱에 박힌 키로 Gemini와 사주 계산 서버를 직접 부르고, 새 빌드는 비밀 없이 게이트웨이만 부르며 게이트웨이가 서버의 키로 Gemini를 부르는 구조 비교 개념도
게이트웨이(gateway)

앱과 외부 API 사이에 세우는 내 서버 경로. 앱은 게이트웨이만 부르고 진짜 키는 서버만 갖습니다.

새 빌드는 이 구조입니다. «지웠다»는 소스가 아니라 산출물에서 확인해야 한다는 게 AI의 입장이라, 빌드 직후 AI가 앱 번들(9,000,946바이트) 안의 dex 3개를 전수 검사했습니다.

dex

앱 파일 안에 실제로 들어가는 실행 코드 덩어리. 소스에서 지웠어도 빌드 설정을 거쳐 여기 남을 수 있어서, «앱에 키가 없다»는 dex를 봐야 말할 수 있습니다.

검사기대결과
AQ. 접두 (AI Studio 발급 키 형식)0건0건
AIza 접두 (클래식 Google 키 형식)0건0건
64자 hex (사주 서버 키 형식)0건0건
generativelanguage.googleapis.com0건0건
x-api-key0건0건
api/app/gemini-generate (게이트웨이 경로)1건 이상1건
api/app/saju-pillars (게이트웨이 경로)1건 이상1건

옛 빌드에서 키 두 개가 나왔던 그 classes2.dex에 이제 게이트웨이 경로만 있었습니다. 소스에 없다고 산출물에 없는 게 아닙니다. 키 접두어 형식은 작성 시점 기준, 제 프로젝트에서 관측한 것입니다.

덤으로 잡힌 함정 — `tee`가 실패를 삼킨다

빌드 명령 끝에 | tee 파일을 붙였더니 빌드가 깨져도 종료코드가 0이었습니다. 파이프라인의 종료코드는 마지막 명령(tee)의 것이라서요. 해법은 set -o pipefail 한 줄 — AI가 «with pipefail exit=1 / without exit=0»을 실제로 돌려 보여 줬습니다.

이렇게 고쳤습니다 — 회전은 «무엇이 무엇을 깨는지»부터

1) 영향 표를 먼저 만든다

AI는 절차를 바로 알려 주지 않고, 먼저 «무엇이 무엇을 부르는지» 코드와 서버를 실측했습니다.

조치별 영향 매트릭스 — Gemini 구키 삭제는 새 빌드 무영향·옛 빌드 작명 실패, 서버 키 값 교체는 새 빌드 무영향·옛 빌드 401 후 조용한 폴백, 그리고 서버 키를 삭제하면 인증 조건문이 통째로 꺼져 레거시 경로가 개방된다는 경고
조치별 영향 매트릭스 — Gemini 구키 삭제는 새 빌드 무영향·옛 빌드 작명 실패, 서버 키 값 교체는 새 빌드 무영향·옛 빌드 401 후 조용한 폴백, 그리고 서버 키를 삭제하면 인증 조건문이 통째로 꺼져 레거시 경로가 개방된다는 경고
조치새 빌드(vc12)옛 빌드(vc11)
Gemini 구키 삭제영향 없음 (서버의 신규 키 사용)작명 생성 실패 — 앱에 박힌 구키로 직접 호출하니까
사주 서버 키 값 교체영향 없음 (게이트웨이 경로는 이 키를 안 씀)401 → 조용히 폴백. 이름은 나오지만 «그라운딩»(사주 계산을 서버 값으로 확인하는 단계)이 꺼짐

여기서 «폭발 반경»이 정해집니다. 처음엔 «vc11을 누가 받았나»로 셌는데, 키가 모든 빌드에 있었다는 정정이 나오면서 질문이 내부 테스트에 올렸던 빌드를 누가 설치했나로 바뀌었습니다. 답은 내부 테스트 설치 0명(본인 계정 제외), 비공개 테스트 옵트인 0명이었습니다.

2) 서버 키는 «삭제»가 아니라 «교체»

키 두 개 중 사주 서버 키부터. 서버가 보관하는 키라 폭발 반경과 무관하게 바꿔야 했습니다 — 옛 앱 파일을 가진 사람은 누구든 레거시 경로를 영구히 부를 수 있고, 값을 바꾸는 것 말고는 닫을 방법이 없으니까요. 그런데 AI가 서버 코드에서 찾은 함정 하나가 절차를 바꿨습니다.

const requiredKey = process.env.SAJU_API_KEY;
if (requiredKey && !apiKeyMatches(...)) { /* 401 */ }

환경변수를 지우면 requiredKey가 비어 조건문이 통째로 꺼지고 레거시 경로가 인증 없이 열립니다. 그래서 삭제가 아니라 새 값으로 교체입니다. 새 값은 아무도 쓸 일이 없으니(새 빌드는 이 키를 안 씀) 무작위로 뽑아 넣고 잊으면 됩니다. 저는 아무 긴 문자열이나 넣었습니다.

3) 🔴 «바꿨다»와 «반영됐다»는 다른 명제였다

값을 바꾸고 저장한 뒤 AI에게 확인을 시켰더니 이렇게 나왔습니다.

호출기대실제
키 없이401401
구키401200
신규 값200401
앱 경로(새 빌드)200200

구키가 여전히 통하고 신규 값은 거부됐습니다. 실행 중인 서버 함수는 아직 옛 값을 들고 있었습니다. 제 프로젝트 실측(작성 시점 기준)으로는 Vercel 환경변수는 저장만으로는 배포된 함수에 주입되지 않고, 새 배포가 있어야 반영됩니다. 저는 Deployments에서 최신 Production 배포를 Redeploy했고, AI에게 같은 네 가지를 다시 재보게 했습니다.

환경변수 저장 후 실측(구키 200·신규 401) → Redeploy → 재실측(구키 401·신규 200·키 없이 401·앱 경로 200)으로 이어지는 흐름과 «바꿨다는 반영됐다가 아니다» 결론 도식
환경변수 저장 후 실측(구키 200·신규 401) → Redeploy → 재실측(구키 401·신규 200·키 없이 401·앱 경로 200)으로 이어지는 흐름과 «바꿨다는 반영됐다가 아니다» 결론 도식
호출기대실제
키 없이401401게이트 살아 있음
구키401401오염된 값 사망
신규 값200200반영됨
앱 경로(새 빌드)200200영향 없음

이걸로 레거시 경로의 창이 닫혔습니다. AI는 며칠 사이 세 번째 같은 모양이라고 적었습니다 — 며칠 전 «호출 성공 ≠ 기능 작동», 그날 아침 «tee가 실패를 exit 0으로 보고», 그리고 «저장 ≠ 반영». 그날 규칙으로 적은 건 Vercel env를 바꾼 뒤에는 반드시 실측한다였고, 모양은 그보다 넓습니다.

반영이 안 될 때 볼 것 두 가지 (콘솔 라벨은 작성 시점 기준)

① 스코프 — 환경변수를 Production에 넣었는지(Preview·Development만 바꾸면 운영엔 안 들어갑니다). ② 배포 — 저장 뒤 Redeploy가 실제로 돌았는지. 저는 ②였습니다.

4) Gemini 구키는 «놔둬도 된다» — 그리고 키 두 개가 10자까지 같았다

다음은 앱에 박혔던 Gemini 구키. 이 키는 제 다른 프로젝트도 같이 써서, 그냥 지우면 그쪽이 멈춥니다. 그래서 저는 이렇게 물었습니다.

VC11은 아무에게도 배포 안했어. 나 혼자 내 폰에 설치하고 테스트했어. 그럼 그냥 놔두면 되지 않을까?

답은 «네, 놔둬도 됩니다»였습니다. 다만 조건이 붙었고, 확인 뒤 «회전하지 않는다»로 기록해 두라고 시켰습니다. AI가 적은 근거는 셋 — ① 노출 범위 0(설치 0명·옵트인 0명) ② 비용이 다른 프로젝트로 번진다(지우려면 그 프로젝트를 먼저 옮겨야 하고, 같은 API를 쓰니 API 제한으로 우회할 수도 없음) ③ 회전보다 강한 통제가 따로 있다(프로젝트 할당량 상한 — 단, 키가 있는 프로젝트에 걸어야 하고, 이날은 이것도 함께 보류했습니다). 미룬 게 아니라 순서를 정한 겁니다.

그런데 그 «소비자 목록»을 그리다가 진짜 함정이 나왔습니다. 처음 AI는 «같은 프로젝트에 키 두 개»로 추정했는데, 클라우드 셸에서 프로젝트별 키 목록을 뽑아 보니 서로 다른 두 프로젝트였습니다. 오염된 구키는 다른 프로젝트 것(6월 5일 생성), 새 키는 이 앱의 프로젝트 것(8월 23일). 앱 첫 커밋이 6월 7일이니 다른 프로젝트 키를 이틀 뒤 가져다 쓴 게 노출의 시작이었습니다.

그리고 두 키가 10자까지 똑같았습니다. AQ.Ab8RN6J 뒤에 x냐 g냐, 11번째 한 글자로 갈렸습니다. AI는 «접두 11자면 충분하다»던 자기 판별법이 운이 좋았을 뿐이라고 적었습니다. 할당량을 걸려던 프로젝트도 틀려 있었고요 — 제가 받은 설명으로는 할당량은 프로젝트 단위라, 틀리면 아무것도 안 막습니다.

키는 접두어가 아니라 «프로젝트 + 키 이름»으로 식별한다

눈으로 보고 지우면 한 글자 차이로 엉뚱한 키를 지웁니다. «어느 프로젝트의 어느 키를 누가 쓰는가» 표를 먼저 적어 두세요.

5) 로컬 로그의 평문 사본

마지막으로, 구키 값이 제 PC의 빌드·실행 로그에 평문으로 남아 있었습니다. [0-9a-f]{64} 같은 패턴으로 치환하면 로그 안의 정상 체크섬까지 망가지기 때문에, AI는 실제 키 값 두 개만 리터럴로 찾아 바꿨습니다(텍스트 로그 44건 치환, 바이너리 캐시 1개 삭제). 마스킹도 «넓게»가 아니라 «정확히»입니다. 수학 문제에서 조건을 넓게 잡으면 답이 아닌 것까지 들어오는 것과 같아요.

같은 실수를 막는 체크리스트

  • 앱이 외부 API를 직접 부르고 있나? 그렇다면 키는 앱 안에 있다. 게이트웨이로 옮긴다.
  • 새 빌드의 키 검사는 소스가 아니라 산출물인 dex에서 — 금지 패턴(키 접두어·API 호스트·헤더 이름)과 기대 패턴(게이트웨이 경로)을 둘 다 센다.
  • 폭발 반경은 «마지막 버전을 누가 받았나»가 아니라 키가 든 모든 빌드를 누가 설치했나로 센다.
  • 회전 전에 조치×소비자 표를 만든다. 서버 보관 키와 앱에 박힌 공유 키는 답이 다르다.
  • 서버 키는 삭제 말고 교체 — if (key && …) 꼴의 조건문은 키가 비면 꺼진다.
  • 환경변수를 바꿨으면 Redeploy, 그리고 구키/신규값/키없이/앱경로 네 가지를 실측한다.
  • 키는 접두어가 아니라 프로젝트 + 키 이름으로 식별한다. 할당량도 프로젝트 단위다.
  • 로컬 로그·빌드 로그의 평문 사본은 리터럴로만 마스킹한다.
  • 빌드 로그를 tee로 남긴다면 set -o pipefail.

FAQ

Q. 옛 빌드를 아무도 안 쓰면 구키를 그냥 둬도 되지 않나요?
키가 어디 사느냐에 따라 답이 갈립니다. 서버가 보관하는 키는 노출 여부와 무관하게 값을 교체합니다. 앱에 박혔던 공유 키는 설치·옵트인 0명이고 다른 소비자가 있어서 «놔둬도 된다 — 대신 회전보다 강한 통제(키가 있는 프로젝트의 할당량 상한)가 있다»가 제가 받은 답이었고, 그날은 그것도 보류했습니다.

Q. 새 서버 키 값은 어디에 보관하나요?
새 빌드가 그 키를 안 쓴다면 보관할 필요가 없습니다. 무작위로 뽑아 넣고 잊고, 나중에 소비자가 생기면 그때 다시 뽑습니다.

Q. Redeploy를 했는데도 구키가 통해요.
스코프(Production인지)와 Production 배포를 다시 돌렸는지 봅니다. 제 경우는 Redeploy 한 번으로 끝났습니다.

Q. 키 두 개가 비슷하게 생겼는데 어느 게 어느 건지 어떻게 알죠?
접두어로 눈짐작하지 마세요(제 경우 10자까지 같았습니다). 프로젝트별 키 목록을 표로 뽑아 그 표로 고르세요.

한 줄 교훈

키 회전의 마지막 단계는 «바꾸기»가 아니라 «구키로 다시 불러 보기»입니다. 401이 나올 때까지는 아직 회전한 게 아닙니다.

댓글 남기기