LLM이 만든 API 키 하드코딩, 왜 반복될까? — 근본 원인과 실전 방어 코드
이 글을 끝까지 읽으면, LLM이 왜 자꾸 API 키를 코드에 박아 넣는지 원인을 정확히 이해하고, 오늘 당장 우리 저장소에 적용할 수 있는 탐지·차단 코드까지 손에 넣게 됩니다.
안녕하세요, 오랜 기간 개발과 보안 현장을 오간 ICT리더 리치입니다. 최근 개발 현장에서는 LLM에게 코드 작성을 맡기는 일이 아주 자연스러운 일상이 되었습니다. 그런데 그렇게 생성된 코드를 리뷰하다 보면 OPENAI_API_KEY = "sk-proj-..." 처럼 실제 키 형식 그대로 하드코딩된 값이 버젓이 들어있는 경우를 심심찮게 마주하게 됩니다.
문제는 이런 코드를 받아든 개발자 상당수가 "AI가 생성한 코드니까 이미 검증된 것 아닐까"라고 무의식적으로 신뢰해 버린다는 점입니다. 실제로 여러 조직의 코드베이스를 점검해 보면, 겉으로는 잘 작동하는 기능 코드 안에 이런 패턴이 반복적으로 숨어 있는 사례를 어렵지 않게 발견할 수 있습니다.
오늘은 이런 현상이 특정 개인이나 팀만의 문제가 아니라 왜 LLM 전반에서 구조적으로 반복되는지 근본 원인을 짚어보고, 어느 조직에서든 바로 적용할 수 있는 탐지·차단 코드까지 함께 정리해 드리겠습니다.
📌 바로가기 목차
| LLM이 만든 API 키 하드코딩의 반복 원인과 보안 위험, 그리고 안전한 환경변수 사용과 실전 방어 코드를 다루는 블로그 포스팅 대표 썸네일 |
1. LLM이 API 키를 하드코딩하는 근본 원인
혹시 이런 프롬프트 써보신 적 있나요? "Stripe 결제 연동 예제 코드 짜줘." 이 한 줄만 던지면 LLM은 십중팔구 예제를 '완결된 실행 가능한 코드'로 보여주려고 실제 키처럼 보이는 문자열을 변수에 직접 넣습니다.
2024년 GitGuardian 조사에서는 공개 저장소에서만 1,270만 건이 넘는 시크릿이 유출된 것으로 집계됐는데, 그중 상당수가 튜토리얼·예제 코드에서 비롯됐습니다. LLM은 바로 이 튜토리얼 스타일 코드를 대량으로 학습했습니다.
결국 문제의 뿌리는 모델이 '악의'가 있어서가 아니라, 사람이 만든 예제 코드의 관행을 그대로 흡수했다는 데 있습니다. 다음 섹션에서는 이 패턴이 왜 프롬프트를 바꿔도 계속 튀어나오는지 더 깊게 들여다봅니다.
2. 왜 같은 실수가 반복될까 — 학습 데이터와 프롬프트의 함정
"시스템 프롬프트에 '환경변수 써라'라고 넣었는데도 또 하드코딩하던데요?" 실제로 자주 듣는 하소연입니다. 원인은 세 갈래로 나뉩니다.
첫째, '즉시 실행 가능한 완결성'을 우선시하는 학습 목표 때문에 모델이 os.environ 같은 외부 의존성보다 리터럴 값을 선호합니다. 둘째, 짧은 프롬프트일수록 모델이 보안 맥락보다 '빠른 예시 제공'에 최적화된 응답을 냅니다. 셋째, 대화가 길어질수록 앞서 설정한 보안 규칙이 컨텍스트 뒷부분에서 희석되는 현상도 실무에서 자주 관찰됩니다.
| 원인 유형 | 구체적 양상 | 대응 방향 |
|---|---|---|
| 학습 데이터 편향 | 튜토리얼·예제 코드의 관행 흡수 | 코드 리뷰 단계에서 필수 검증 |
| 모호한 프롬프트 | 보안 요구사항 미명시 | 시스템 프롬프트에 명시적 금지 조항 |
| 컨텍스트 희석 | 긴 대화에서 초기 규칙 약화 | 자동화 스캐너로 최종 방어선 구축 |
| 빠른 실행 우선 응답 | 환경설정 단계 생략 | 템플릿·보일러플레이트 사전 제공 |
결국 프롬프트 한 줄로 완전히 막을 수 있는 문제가 아닙니다. 다음 섹션에서는 이 허점이 실제로 어떤 사고로 이어졌는지 구체적인 사례를 살펴봅니다.
3. 실제 유출 사고 사례로 보는 위험 신호
"우리 팀은 프라이빗 저장소니까 괜찮겠지" — 이 생각이 가장 위험합니다. 실제로 하드코딩된 키는 CI 로그, 스크린샷, 캐시된 아티팩트, 서드파티 SaaS 연동 과정에서 의도치 않게 노출되는 경우가 훨씬 많습니다.
GitGuardian의 연례 리포트에 따르면 공개 저장소에서 발견되는 시크릿의 상당수가 커밋 이후 몇 초 안에 자동화된 스캐너 봇에 의해 수집됩니다. 사람이 알아차리기 전에 이미 악용 시도가 시작되는 것이죠. 저 역시 예전에 AWS 액세스 키를 실수로 커밋했다가, 30분도 안 돼 마이닝 봇에 계정이 뚫린 경험이 있습니다.
- CI/CD 로그 노출: 빌드 로그에 환경변수 값이 그대로 출력되어 팀 외부 공유 링크로 새어나가는 경우.
- 포크(Fork) 저장소 유출: 프라이빗 저장소를 포크하거나 클론한 팀원의 로컬 백업이 별도 클라우드에 동기화되는 경우.
- AI 채팅 히스토리 저장: LLM에게 실제 운영 키를 붙여넣고 디버깅을 맡긴 뒤, 그 대화 기록이 그대로 남는 경우.
- Docker 이미지 레이어 잔존: 빌드 중 임시로 넣은 키가 이미지 레이어 히스토리에 영구히 남아 레지스트리에 배포되는 경우.
⚠️ 주의: 이미 한 번이라도 커밋 히스토리에 올라간 키는 삭제해도 git 로그에 남습니다. 유출이 확인되면 삭제가 아니라 즉시 키 폐기(revoke) 및 재발급이 우선입니다.
![]() |
| LLM이 생성한 코드에서 반복되는 API 키 하드코딩 문제를 실제 개발·보안 관점에서 설명하고, 코드 리뷰, 저장소 보호, 시크릿 스캔, CI/CD 게이트, 시크릿 매니저를 통한 다층 방어 파이프라인을 시각적으로 정리한 남성 중심 인포그래픽 |
4. 개발자도 놓치는 의외의 하드코딩 지점
의외로 많은 팀이 ".env는 절대 커밋 안 한다"까지만 챙기고 안심합니다. 보안 교육 자료에도 대부분 이 규칙까지만 강조되다 보니, 정작 실제 유출은 그 밖의 훨씬 더 사각지대에 가까운 지점에서 벌어집니다.
이런 지점들의 공통점은 '코드'가 아니라 '코드 주변의 산출물'이라는 점입니다. 그래서 정적 분석 도구가 소스 파일만 검사하도록 설정돼 있으면 아예 스캔 대상에서 빠져버리는 경우가 많습니다.
아래는 실제 컨설팅 현장과 팀 내 사고 리뷰에서 반복적으로 확인된 대표적인 하드코딩 사각지대입니다.
- 테스트 코드·픽스처 파일: "테스트니까 괜찮다"는 안이한 판단으로 실제 키를 그대로 넣고, 이 파일은 코드 리뷰에서도 상대적으로 가볍게 취급되어 오래 방치되는 경우가 많습니다.
- Jupyter 노트북 셀 출력: 코드 셀 자체는 변수 처리를 잘 해도, 실행 결과로 출력된 셀(output) 안에 키 값이 그대로 저장되어 노트북 파일과 함께 커밋되는 경우입니다.
- README·위키 예시 코드: "실제로 동작하는 예제를 보여주자"는 의도로 문서 작성자가 실제 값을 그대로 붙여넣고, 문서는 스캐너의 검사 대상에서 흔히 제외됩니다.
- Postman·Insomnia 컬렉션 파일: API 요청을 저장한 컬렉션 JSON 안에 인증 헤더 값이 그대로 박제되고, 팀원끼리 공유하는 과정에서 외부 저장소나 클라우드로 함께 흘러나갑니다.
- Slack·이슈 트래커 에러 로그: "빨리 디버깅해야 하니까"라는 급한 마음에 에러 로그를 통째로 복사해 붙여넣는데, 로그 안에 인증 토큰이나 키가 그대로 포함돼 있는 경우가 흔합니다.
- CI 캐시·아티팩트: 빌드 속도를 위해 캐싱한 중간 산출물 안에 환경변수 값이 그대로 직렬화되어 남고, 이 캐시가 별도 저장소나 외부 스토리지에 보관되는 경우도 있습니다.
LLM이 "빠른 디버깅"을 도와주는 과정에서 바로 이런 경로들을 자연스럽게 통과하게 됩니다. 사용자가 에러 로그, 노트북 출력, 컬렉션 파일을 그대로 붙여넣고 도움을 요청하면, 모델은 그 안에 담긴 값을 그대로 반영해 답변을 만들어내기 때문입니다. 저 역시 실제 컨설팅 현장에서 이런 경로로 흘러 들어간 키가 몇 달 뒤 전혀 다른 시스템 사고의 원인으로 밝혀진 사례를 여러 번 목격했습니다.
5. 방어 전략 비교 — 어떤 방법이 우리 팀에 맞을까
방어 방법은 한 가지가 아닙니다. 팀 규모, 파이프라인 성숙도, 그리고 예산에 따라 어떤 계층을 먼저 도입할지가 달라져야 합니다. 흔히 "제일 좋은 도구 하나만 도입하면 되지 않나?"라고 생각하기 쉽지만, 실제로는 각 방어 계층이 서로 다른 시점과 다른 실패 지점을 커버하기 때문에 하나만으로는 구멍이 남을 수밖에 없습니다.
아래 표는 대표적인 네 가지 방어 계층을 동작 시점, 강점, 한계, 도입 난이도까지 함께 비교한 것입니다.
| 방어 계층 | 동작 시점 | 강점 | 한계 | 도입 난이도 |
|---|---|---|---|---|
| Pre-commit 훅 | 로컬 커밋 직전 | 가장 이른 시점에서 즉시 차단, 비용 거의 없음 | --no-verify로 우회 가능, 팀원 전원 설치 필요 |
낮음 |
| CI 파이프라인 스캔 | 푸시·PR 시점 | 우회 불가능한 강제 게이트, 병합 조건으로 지정 가능 | 이미 원격 저장소에 한 번은 올라간 뒤 탐지 | 중간 |
| 시크릿 매니저(Vault 등) | 런타임 조회 | 키 자체가 코드·저장소에 존재하지 않아 유출 경로 원천 차단 | 초기 구축 비용·학습 곡선, 운영 복잡도 증가 | 높음 |
| 저장소 전체 히스토리 스캔 | 주기적·수동 실행 | 과거에 이미 커밋된 오래된 유출까지 소급 탐지 가능 | 발견 시점이 늦어 그 사이 이미 폐기 전까지는 계속 유효한 상태 | 낮음~중간 |
| 런타임 이상탐지·감사 로그 | 키 사용 시점 | 유출 이후라도 비정상 사용 패턴을 빠르게 포착 | 사전 예방이 아닌 사후 대응 성격, 별도 모니터링 체계 필요 | 중간~높음 |
표를 보면 알 수 있듯, 각 계층은 서로 다른 시점의 구멍을 메웁니다. Pre-commit 훅은 실수를 가장 빨리 잡아내지만 사람이 마음만 먹으면 우회할 수 있고, CI 스캔은 우회는 막지만 이미 한 번 원격에 노출된 뒤라는 태생적 지연이 있습니다. 시크릿 매니저는 가장 근본적인 해결책이지만 그만큼 도입 비용이 크고, 히스토리 스캔과 런타임 모니터링은 이미 벌어진 사고를 뒤늦게라도 확인하는 안전망 역할을 합니다.
결론적으로 어느 한 계층도 완벽하지 않기 때문에, Pre-commit 훅 + CI 스캔 + 시크릿 매니저, 그리고 여력이 된다면 런타임 이상탐지까지 함께 쓰는 다층 방어가 정답입니다. 팀 규모가 작다면 앞의 두 계층부터 먼저 갖추고, 이후 조직이 성장함에 따라 나머지 계층을 순차적으로 얹어가는 방식이 현실적입니다.
6. 지금 바로 적용할 실전 체크리스트
이론은 충분합니다. 아래 체크리스트를 팀 위키에 그대로 붙여넣고 오늘 중으로 하나씩 점검해 보세요.
- ☑ LLM 프롬프트 표준화: "환경변수 또는 플레이스홀더만 사용, 실제 값 절대 생성 금지"를 시스템 프롬프트에 고정 문구로 삽입.
- ☑ Pre-commit 훅 전 팀원 적용: 로컬 단계에서부터 커밋을 원천 차단하는 스크립트 배포.
- ☑ CI 파이프라인 시크릿 스캔 의무화: PR 병합 조건에 스캔 통과를 필수 게이트로 등록.
- ☑ 저장소 히스토리 전수 조사: 과거 커밋까지 포함해 한 번은 전체 스캔 실행.
- ☑ 키 회전(Rotation) 주기 설정: 유출 여부와 무관하게 정기적으로 키를 갱신하는 정책 수립.
💡 실전 팁: LLM에게 코드를 요청할 때 "이 코드에 시크릿이 하드코딩되어 있는지 스스로 검토하고 있다면 플레이스홀더로 교체해줘"라는 자기 검증 지시를 추가하면 재발률이 눈에 띄게 줄어듭니다.
다음 FAQ에서 자주 헷갈리는 부분을 정리했어요.
| API 키 하드코딩 위험, 코드 보안, 시크릿 관리, 저장소 보호, 클라우드 및 개발 보안 이슈를 상징적으로 담은 텍스트 없는 여성 대표 썸네일 |
💻 실전 코드 — 바로 쓰는 하드코딩 API 키 탐지·차단 자동화
아래 코드는 LLM이 생성했거나 사람이 실수로 넣은 API 키가 커밋되기 전에 걸러내는 3단계 방어선입니다. Python 스캐너, Bash pre-commit 훅, GitHub Actions YAML 설정 순서로 구성했으며, 로컬 → 커밋 → CI 세 지점에서 각각 다시 한번 검증하는 구조입니다.
▶ 실전 코드 ① — Python 시크릿 패턴 스캐너
스테이징된 파일을 대상으로 대표적인 API 키 패턴(OpenAI, AWS, Stripe, GitHub 토큰 등)을 정규식으로 검사하고, 하나라도 발견되면 종료 코드 1을 반환합니다.
# secret_scanner.py — 커밋 전 하드코딩 시크릿 탐지 스캐너
import re
import subprocess
import sys
# 주요 서비스별 API 키 패턴 (실제 값이 아닌 형식 매칭용)
PATTERNS = {
"OpenAI": r"sk-[A-Za-z0-9]{20,}",
"AWS Access Key": r"AKIA[0-9A-Z]{16}",
"GitHub Token": r"gh[pousr]_[A-Za-z0-9]{30,}",
"Stripe Key": r"sk_live_[A-Za-z0-9]{20,}",
"Generic Secret": r"(?i)(api[_-]?key|secret|token)\s*=\s*['\"][A-Za-z0-9\-_]{16,}['\"]",
}
def get_staged_files():
# git에 스테이징된 파일 목록만 검사 대상으로 제한
result = subprocess.run(
["git", "diff", "--cached", "--name-only"],
capture_output=True, text=True
)
return [f for f in result.stdout.splitlines() if f.strip()]
def scan_file(filepath):
findings = []
try:
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
except FileNotFoundError:
return findings # 삭제된 파일은 건너뜀
for label, pattern in PATTERNS.items():
for match in re.finditer(pattern, content):
line_no = content[:match.start()].count("\n") + 1
findings.append((filepath, line_no, label))
return findings
def main():
all_findings = []
for filepath in get_staged_files():
all_findings.extend(scan_file(filepath))
if all_findings:
print("🚨 하드코딩 시크릿 의심 항목이 발견되었습니다:\n")
for filepath, line_no, label in all_findings:
print(f" - {filepath}:{line_no} [{label}]")
print("\n환경변수 또는 시크릿 매니저 사용으로 교체 후 다시 커밋하세요.")
sys.exit(1)
print("✅ 시크릿 패턴 미발견. 커밋을 계속 진행합니다.")
sys.exit(0)
if __name__ == "__main__":
main()
이 스크립트는 실제 시크릿 문자열을 저장하지 않고 '형식'만 검사하기 때문에 안전하게 팀 전체에 공유할 수 있습니다. PATTERNS 딕셔너리에 회사에서 쓰는 서비스의 키 형식을 추가하면 탐지 범위를 넓힐 수 있습니다.
▶ 실전 코드 ② — Bash Pre-commit 훅 등록 스크립트
위 Python 스캐너를 git pre-commit 훅으로 자동 연결하는 설치 스크립트입니다. 한 번만 실행하면 이후 모든 커밋 시도마다 자동으로 검사가 실행됩니다.
#!/usr/bin/env bash
# install-hook.sh — pre-commit 훅 설치 스크립트
set -euo pipefail
HOOK_PATH=".git/hooks/pre-commit"
SCANNER_PATH="secret_scanner.py"
if [ ! -f "$SCANNER_PATH" ]; then
echo "❌ $SCANNER_PATH 파일을 찾을 수 없습니다. 저장소 루트에서 실행하세요."
exit 1
fi
# pre-commit 훅 파일 생성
cat > "$HOOK_PATH" << 'EOF'
#!/usr/bin/env bash
# 커밋 직전 자동 실행되는 시크릿 스캐너
python3 secret_scanner.py
if [ $? -ne 0 ]; then
echo "⛔ 커밋이 차단되었습니다. 위 경고를 확인하세요."
exit 1
fi
EOF
chmod +x "$HOOK_PATH"
echo "✅ pre-commit 훅이 설치되었습니다. 이제 커밋 시 자동으로 시크릿을 검사합니다."
신규 팀원 온보딩 스크립트에 bash install-hook.sh 한 줄을 포함시키면, 로컬 환경마다 수동으로 설정할 필요 없이 전 팀원에게 동일한 방어선을 자동 배포할 수 있습니다.
▶ 실전 코드 ③ — GitHub Actions CI 시크릿 스캔 설정
로컬 훅은 --no-verify 옵션으로 우회될 수 있기 때문에, PR 단계에서 한 번 더 검증하는 CI 게이트가 반드시 필요합니다. 아래는 오픈소스 스캐너인 gitleaks를 워크플로에 연결하는 예시입니다.
name: secret-scan
on:
pull_request:
branches: [main, develop]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- name: 저장소 체크아웃 (전체 히스토리 포함)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: gitleaks 시크릿 스캔 실행
uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_ENABLE_COMMENTS: true
- name: 스캔 실패 시 PR 병합 차단 안내
if: failure()
run: |
echo "시크릿이 탐지되어 워크플로가 실패했습니다."
echo "해당 키를 즉시 폐기(revoke)하고 히스토리에서 제거한 뒤 다시 푸시하세요."
이 워크플로는 PR이 열릴 때마다 전체 커밋 히스토리까지 훑기 때문에, 로컬 훅을 우회해 들어온 시크릿도 병합 전 단계에서 다시 한번 걸러냅니다. required status check로 지정하면 스캔 실패 시 병합 버튼 자체가 비활성화됩니다.
💡 실전 팁: 세 코드는 각각 로컬·커밋·CI라는 서로 다른 방어 계층을 담당하므로 하나만 도입하지 말고 반드시 함께 적용하세요. 이미 유출된 키가 있다면 스캐너 도입보다 키 폐기·재발급이 항상 우선입니다.
![]() |
| LLM이 API 키를 코드에 하드코딩하는 이유를 학습 데이터 편향, 프롬프트 한계, 컨텍스트 희석 관점에서 분석하고, pre-commit 훅·CI 시크릿 스캔·시크릿 매니저 기반의 실전 방어 전략까지 한눈에 정리한 여성 중심 인포그래픽 |
7. 자주 묻는 질문 (FAQ)
프롬프트는 확률을 낮추는 데 도움이 되지만 100% 차단을 보장하지는 않습니다. 2번 섹션에서 설명한 학습 데이터 편향과 컨텍스트 희석 때문에, 결국 코드가 저장소에 들어가기 전 단계의 자동화 검증이 반드시 함께 있어야 합니다.
충분하지 않습니다. 커밋 히스토리에는 여전히 남아 있어서 git log -p로 얼마든지 복원 가능합니다. 3번 섹션에서 강조했듯 파일을 지우는 것보다 해당 키를 즉시 폐기하고 새 키를 발급하는 것이 훨씬 중요합니다.
꼭 그렇지는 않습니다. 5번 비교표를 보면 초기 단계에서는 Pre-commit 훅과 CI 스캔만으로도 실질적 위험 대부분을 줄일 수 있습니다. 팀 규모와 예산에 맞춰 단계적으로 도입하는 것을 권장합니다.
권장하지 않습니다. 4번 섹션에서 언급했듯 대화 기록 자체가 유출 경로가 될 수 있습니다. 항상 마스킹된 값이나 테스트 전용 키로 재현한 뒤 공유하는 습관을 들이세요.
TruffleHog, detect-secrets 등도 널리 쓰입니다. 도구마다 탐지 패턴과 오탐률이 다르므로 실전 코드 섹션의 커스텀 스캐너와 병행해서 두 개 이상을 조합하면 탐지율을 더 높일 수 있습니다. 더 궁금한 점은 댓글로 남겨주세요!
8. 마무리 요약
✅ 지금 이 순간, 우리 저장소부터 점검하세요
LLM이 API 키를 하드코딩하는 건 모델의 악의가 아니라 학습 데이터의 관행과 프롬프트의 한계가 만든 구조적 문제입니다. 프롬프트만으로는 막을 수 없고, 로컬 훅·CI 스캔·시크릿 매니저를 겹겹이 쌓는 다층 방어만이 실질적인 해답입니다.
이미 유출된 키가 있다면 무엇보다 즉시 폐기와 재발급이 우선이라는 사실, 꼭 기억해 주세요. 오늘부터라도 pre-commit 훅 하나만 설치해두면 다음 사고를 막을 수 있습니다.
오늘 글을 읽으셨다면, 지금 당장 팀 저장소에 secret_scanner.py 하나만이라도 설치해 보세요. 10분 투자가 훗날 몇백만 원짜리 클라우드 청구서를 막아줄 수 있습니다.
여러분 팀은 지금 어떤 방어 계층까지 갖추고 계신가요? 댓글로 현황을 공유해 주시면 함께 점검해 드리겠습니다.


댓글
댓글 쓰기