API 키는 비밀번호나 마찬가지예요. 그 키로 남이 내 API를 마음대로 쓰면 요금이 저한테 청구되니까요. 그런데 배포용으로 빌드한 제 앱을 실기기에서 테스트하다가, logcat에 제 API 키가 평문 그대로 찍혀 있는 걸 봤습니다. ?key=AIza... 이렇게요. 등골이 서늘했죠.
원인은 제가 개발 편의로 켜둔 로그 하나를 배포 빌드에서 끄지 않은 것이었어요. 초보가 정말 자주 하는 실수라 정리합니다. 코드 한 줄이면 막는데, 모르면 키가 통째로 새어 나갑니다. (사실 같은 앱을 만들며 결제 쪽에서도 아찔한 사고를 겪었는데, 그 이야기는 결제 한 번에 유료가 무한으로 풀린 일에 따로 정리해뒀어요.)
무엇을 보고 알았나 — logcat에 찍힌 키
우선 logcat부터요.
logcat: 안드로이드 앱이 돌면서 남기는 실행 로그. 개발 중 “지금 뭐가 실행됐는지”를 보는 창이에요.
저는 네트워크 요청이 잘 나가는지 보려고 HttpLoggingInterceptor를 켜뒀어요.
HttpLoggingInterceptor: 앱이 서버로 보내는 요청과 받는 응답을 logcat에 찍어주는 개발용 도구(OkHttp).
Level.BODY로 켜면 URL·헤더·본문을 전부 남깁니다.
문제는 배포(릴리스) 빌드로 만든 앱의 logcat에도 이 로그가 그대로 나왔고, 거기에 요청 URL이 통째로 찍히면서 키가 딸려 나왔다는 거예요.
왜 키가 URL에 실렸나
여기가 핵심이에요. 제가 쓰는 API는 인증 키를 URL 쿼리 파라미터로 받습니다.
쿼리 파라미터: 주소 뒤에
?key=값&다른키=값형태로 붙는 값. 구글 계열 API가 키를 이렇게 받는 경우가 많아요.
즉 요청 주소가 https://.../v1/...?key=AIza... 같은 꼴이에요. 그런데 Level.BODY는 이 URL을 통째로 로그에 남기니까, 그 안의 ?key=...도 같이 남습니다. 키를 헤더에 숨겨 보냈으면 그나마 눈에 덜 띄었을 텐데, URL에 실리니 로그·주소창 기록·중간 프록시 등 곳곳에 더 잘 남아요.
진짜 원인 — ‘개발 편의’를 배포 빌드에 남겼다
로그를 켠 코드가 이랬어요. 켜고 끄는 조건이 없었습니다.
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY // ← 항상 켜짐(배포 빌드에서도)
})
개발 중엔 이 로그가 유용해요. 그런데 조건 없이 항상 BODY로 두면 배포 빌드에서도 똑같이 다 찍힙니다. 게다가 이게 Gemini API·사주 API 두 군데 다 그랬어요. 한 곳만 고쳐선 안 됐죠.
여기서 알아야 할 게 BuildConfig.DEBUG예요.
BuildConfig.DEBUG: 지금 빌드가 개발용(디버그)이면
true, 배포용(릴리스)이면false가 되는 값. 이걸로 “개발 때만” 도는 코드를 만들 수 있어요.
왜 배포 빌드 logcat이 위험한가
“어차피 로그인데 뭐 어때?” 싶을 수 있어요. 개발 중엔 맞아요. 그런데 배포 빌드는 실제 사용자 기기에서 돕니다. logcat은 저만 보는 게 아니에요 — 크래시 리포터, PC에 연결했을 때(adb), 예전엔 심지어 다른 앱까지 읽을 수 있었어요. 키가 한 번 로그에 남으면 회수할 방법이 없고, 그 키로 누가 제 API를 쓰면 요금은 저한테 옵니다. 그래서 이건 사소한 로그가 아니라 진짜 자격증명(credential) 유출이에요.
‘배포 빌드는 로그를 자동으로 지운다’는 오해
저도 처음엔 “릴리스로 빌드하면 로그 정도는 알아서 빠지겠지” 하고 막연히 믿었어요. 아닙니다. 안드로이드는 배포 빌드라고 해서 로그나 네트워크 로깅을 자동으로 제거해 주지 않습니다. 코드 최적화·난독화 도구(R8/ProGuard)를 따로 설정하지 않는 한, 개발 때 켠 로그는 배포 앱에도 그대로 살아 있어요. 그래서 “배포 전에 내가 직접 끄는” 조건을 코드에 박아두는 게 꼭 필요합니다. 자동으로 알아서 되는 게 아니거든요.
어떻게 고쳤나
고친 건 딱 한 줄 조건이에요. 개발 빌드일 때만 로그를 켜고, 배포 빌드에선 끕니다.
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (com.example.BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY // 개발: 다 본다
} else {
HttpLoggingInterceptor.Level.NONE // 배포: 아무것도 안 남긴다
}
})
Level.NONE은 로그를 아예 안 남긴다는 뜻이에요. 이걸 Gemini·사주 API 양쪽 인터셉터에 똑같이 넣었습니다. 이제 배포 빌드에선 네트워크 로그가 안 나오니 키도 안 새요.
배포 전 필수 점검: 개발 편의로 켠 네트워크 로깅(HttpLoggingInterceptor 등)은 반드시 BuildConfig.DEBUG로 게이팅하세요. 특히 키가 URL 쿼리로 실리는 API는 로그에 그대로 남습니다. 배포 빌드 logcat을 한 번 직접 훑어 key=·token= 같은 게 없는지 눈으로 확인하는 게 안전해요.
하지만 이걸로 “안전”해진 건 아니다 — 더 큰 구멍
여기서 솔직해야겠어요. 방금 고친 건 로그에 키가 찍히던 것뿐이에요. 정작 더 근본적인 문제는 그대로 남아 있습니다. 바로 API 키가 앱 안에 심겨 있다는 사실이에요.
제 앱은 키를 BuildConfig(빌드할 때 앱 안에 값을 박아 넣는 통로)에 담아 씁니다. 그런데 이렇게 앱에 박힌 값은, 배포된 설치 파일을 디컴파일하면 꺼낼 수 있어요. 로그를 아무리 막아도, 키가 앱 안에 들어 있는 한 마음먹은 사람은 결국 뽑아낼 수 있다는 뜻이죠.
디컴파일: 배포된 앱(APK 등)을 거꾸로 뜯어 그 안의 코드·값을 들여다보는 것. 앱에 심어 넣은 문자열은 비밀이 아니라고 봐야 한다.
그럼 진짜 해법은 뭘까요? 키를 앱에서 아예 빼는 것입니다. 앱은 내 서버에만 요청하고, 서버가 대신 키를 들고 외부 API를 부르는 서버 프록시 구조로 옮기는 거예요. 그러면 키가 사용자 기기로 내려가지 않습니다. 저는 아직 이 이관을 못 했어요. 그래서 이번 수정은 “급한 출혈(로그 노출)을 막은 것”이고, 근본 치료(서버 프록시)는 다음 숙제로 남겨둔 상태입니다. 오답노트니까 정직하게 적어둬요 — 한 겹 막았다고 다 막은 게 아니라는 걸요.
그래서 뭘 하면 되나
앱을 배포하기 전에, 이 표대로 점검하세요.
| 신호 | 판단 | 액션 |
|---|---|---|
네트워크 로깅을 Level.BODY로 켜둠 | 🔴 배포 시 유출 위험 | if (BuildConfig.DEBUG)로 게이팅, 릴리스는 NONE |
API 키가 URL 쿼리(?key=)로 실림 | 🔴 로그·프록시에 잘 남음 | 로깅 게이팅은 필수, 가능하면 헤더 인증 검토 |
| 배포 직전 최종 점검 | 🟢 회수 불가 대비 | 릴리스 빌드 logcat을 직접 훑어 키/토큰 확인 |
키가 앱에 임베드됨(BuildConfig) | 🟡 디컴파일로 추출 가능 | 서버 프록시로 이관(근본 해법·숙제) |
한 문장 요약: 개발 편의로 켠 로깅은 배포 빌드에서 반드시 끄고(BuildConfig.DEBUG 게이팅) 배포 전 logcat을 눈으로 확인하라 — 다만 로그를 막아도 앱에 심은 키는 디컴파일로 새니, 근본 해법은 서버 프록시로 키를 앱 밖에 두는 것이다.
확인은 몇 초면 됩니다. 배포 빌드를 실기기에 올린 뒤 PC에 연결하고, 터미널에서 로그를 키워드로 걸러 보면 돼요. adb logcat(기기 로그 보기)에 이어 adb logcat | Select-String "key="(윈도우) 또는 adb logcat | grep -i "key="(맥·리눅스)처럼요. 여기서 ?key=...가 뜨면 유출이니 바로 막아야 합니다.
키 유출은 터진 뒤엔 늦어요. 로그에 한 번 남은 건 지울 수 없어서, 이미 샜다면 그 키는 죽은 걸로 보고 폐기 후 재발급하는 게 유일한 회수예요(로그는 못 지우니 키 자체를 바꾸는 것). 로그 노출을 막는 예방은 조건문 한 줄이라 오늘 바로 할 수 있습니다 — “개발 때만”이라는 울타리(BuildConfig.DEBUG)를 습관처럼 두르는 거죠. 다만 그건 첫 겹일 뿐이에요. 키를 앱 밖 서버로 옮기는 근본 이관은 저도 아직 숙제로 안고 있고, 그건 다음 글에서 이어가려 합니다. 보안은 한 번에 끝나는 게 아니라 겹겹이 쌓는 일이더라고요.