Tech_News

당신의 LLM API 키는 지금 어디에 살고 있나 — 의존성 하나 털리면 벌어지는 일

TeEm0 2026. 7. 8. 09:00
728x90

1. 도입: 왜 지금 이 얘기를 꺼내는가

질문 하나 던지고 시작하자. 오늘 당신 프로젝트의 의존성 하나가 털렸다고 치자. 공격자가 OpenAI, Anthropic, Gemini API 키를 빼갈 수 있을까?

이 질문의 답은 어떤 LLM 프로바이더를 쓰느냐, 코드가 얼마나 견고하냐에 달려 있지 않다. 대부분의 팀이 한 번도 진지하게 고민 안 한 딱 하나의 아키텍처 결정에 달려 있다. 바로 "앱이 돌아가는 동안 프로바이더 API 키가 실제로 어디에 존재하느냐"다.

요즘 실무에서 LLM API 도입이 폭발적으로 늘고 있다. 사내 챗봇, RAG 파이프라인, 코드 리뷰 봇까지. 그런데 대부분은 OPENAI_API_KEY를 환경변수에 박고 SDK 초기화해서 끝낸다. 나도 처음엔 그랬다. 문제는 이 방식이 공급망 공격(supply chain attack)에 통째로 노출된다는 점이다.

원문(Dev.to, Hadil Ben Abdallah)에서는 2026년 3월 LiteLLM 공급망 사고를 예로 든다. 이 사고가 주목받은 건 LiteLLM이 유독 취약해서가 아니라, AI 인프라가 이제 얼마나 값진 공격 타깃이 됐는지, 그리고 털린 의존성 하나가 얼마나 빠르게 고가치 크레덴셜에 도달하는지를 보여줬기 때문이다. 이 글에서는 두 가지 게이트웨이 아키텍처를 비교하고, 실제로 돌려볼 수 있는 데모, 그리고 실무에서 시크릿을 어떻게 관리할지까지 정리한다.

2. 핵심: 키가 어디 사느냐가 전부다

모든 LLM 애플리케이션은 결국 같은 일을 한다. 모델 프로바이더에 요청 보내고, API 키로 인증한다. 중요한 건 "API 키를 쓰느냐"가 아니라 "요청하는 순간 그 키가 어디에 존재하느냐"다. 크게 두 가지 패턴이 있다.

패턴 A: 앱이 프로바이더 키를 직접 들고 있다

우리가 제일 익숙한 방식이다. 환경변수에서 키 읽고, SDK 초기화하고, 프로바이더로 직접 쏜다.

import os
from openai import OpenAI

api_key = os.environ["PROVIDER_API_KEY"]
client = OpenAI(api_key=api_key)
response = client.responses.create(...)

구현 빠르고 이해하기 쉽다. 많은 프로젝트에서 충분히 합리적이다. 하지만 핵심은 이거다. 프로바이더 키가 이제 애플리케이션 프로세스 안에 산다. 그 프로세스에서 돌아가는 모든 패키지, 프레임워크, 플러그인, 의존성이 같은 권한으로 실행된다. 그중 하나만 털려도 악성 코드는 당신 앱과 똑같은 환경에서 키를 읽을 수 있다.

패턴 B: 앱이 네트워크 프록시와 대화한다

인증을 앱에서 분리하는 방식이다. 앱은 프로바이더로 직접 쏘지 않고 게이트웨이/프록시로 보낸다. 프록시가 프로바이더 키를 들고 있다가, 자기 프로세스 안에서만 키를 주입해 업스트림 요청을 대신 처리한다.

Application
   │
   ▼
Gateway / Proxy   ← 여기에만 프로바이더 키가 산다
   │
   ▼
LLM Provider

앱 관점에서 흐름은 거의 똑같다. 차이는 앱이 무엇을 안 갖고 있느냐다. 앱은 프로바이더 키 대신 스코프가 제한된 게이트웨이 토큰만 들고 있다. 프록시가 이 토큰을 검증하고, 라우팅/정책을 적용한 뒤, 자기 프로세스 안에서 진짜 프로바이더 키를 붙여 보낸다.

이게 사고의 결과를 바꾼다. 악성 코드가 앱 프로세스에서 실행돼도, 프로바이더 API 키는 애초에 거기 없다. 물론 게이트웨이 토큰이 털리는 것도 보안 사고다. 하지만 게이트웨이 토큰은 좁게 스코프를 걸 수 있고, 중앙에서 즉시 폐기할 수 있고, 앱 재배포 없이 로테이션할 수 있고, 특정 작업만 허용하도록 제한할 수 있다. 프로바이더 키를 갈아끼우는 것과는 회복 난이도가 완전히 다르다.

같은 악성 의존성을 두 아키텍처에 돌려보자

원문의 데모를 재현해보자. 아래는 임포트되는 순간 프로세스 환경변수를 스캔해서 크레덴셜처럼 생긴 걸 출력하는 "악성" 패키지다. 실제로는 네트워크 요청도, 파일 쓰기도 안 하지만 개념 증명엔 충분하다.

# malicious_dep.py
import os

_INTERESTING = ("API_KEY", "SECRET", "TOKEN", "PASSWORD", "PRIVATE_KEY")

def harvest():
    found = {
        k: v for k, v in os.environ.items()
        if any(marker in k.upper() for marker in _INTERESTING)
    }
    for name, value in found.items():
        shown = value[:8] + "..." if len(value) > 12 else value
        print(f"[malicious_dep@import] EXFILTRATED {name}={shown}")

harvest()

이제 시나리오 A. 앱이 프로바이더 키를 직접 환경변수에 들고 있는 경우다.

$ export PROVIDER_API_KEY="sk-provider-abc123xyz"
$ python -c "import malicious_dep"
[malicious_dep@import] EXFILTRATED PROVIDER_API_KEY=sk-provi...3xyz

별 대단한 짓을 한 게 아니다. 인증 우회도, 메모리 손상 익스플로잇도, 다른 서비스 침투도 없다. 그냥 이미 자기가 실행되는 프로세스 안에 있던 데이터를 읽었을 뿐이다. 공격자 입장에선 그걸로 충분하다.

이번엔 시나리오 B. 앱은 게이트웨이 토큰만 들고 있다.

$ unset PROVIDER_API_KEY
$ export GATEWAY_TOKEN="gw-scoped-read-only-789"
$ python -c "import malicious_dep"
[malicious_dep@import] EXFILTRATED GATEWAY_TOKEN=gw-scope...-789

주목할 건 무엇이 안 나왔는지다. 프로바이더 API 키가 없다. 애초에 앱 프로세스에 존재한 적이 없으니까. 앱은 여전히 모델 응답을 정상적으로 받지만, 프로바이더 인증은 프록시 안에서 일어난다.

여기서 "그럼 프록시가 문제를 해결했네"라고 결론 내리면 안 된다. 의존성은 여전히 크레덴셜을 하나 훔쳤다. 게이트웨이 토큰은 진짜고, 공격자가 이걸로 프록시를 통해 요청을 날릴 수도 있다. 핵심은 "뭐가 샜냐"가 아니라 "샌 게 뭘 할 수 있고, 얼마나 빨리 복구되냐"다. 프로바이더 키가 새면 프로바이더 콘솔 들어가서 키 재발급하고 전 서비스 재배포해야 한다. 게이트웨이 토큰은 프록시에서 한 줄로 폐기하고 새 토큰 발급하면 끝이다.

3. 실무 관점: 도입 시 고려사항과 흔한 함정

키 노출 경로별 위험도 비교

실무에서 키가 새는 경로는 생각보다 다양하다. 경로별로 정리하면 이렇다.

  • 앱 프로세스 환경변수: 위에서 봤듯 같은 프로세스의 모든 의존성이 읽는다. 가장 흔하고 가장 넓은 공격면.
  • CI/CD 파이프라인 변수: GitHub Actions Secrets, GitLab CI Variables 등. 로그에 실수로 echo $OPENAI_API_KEY 찍으면 그대로 노출된다. PR에서 돌아가는 워크플로에 시크릿 노출되면 포크에서 탈취 가능.
  • 컨테이너 런타임: docker inspect/proc/1/environ로 환경변수 다 보인다. 이미지 레이어에 ENV OPENAI_API_KEY=... 박아넣으면 이미지 pull 가능한 누구나 본다.
  • .env 파일 커밋: .gitignore에 안 넣고 커밋했다가 GitHub 스크래핑 봇한테 몇 분 만에 털리는 클래식.

공통점은 "키가 앱 프로세스가 도달 가능한 위치에 평문으로 존재"한다는 것이다. 패턴 B(프록시)는 이 문제를 프록시 프로세스 하나로 격리시켜 폭발 반경(blast radius)을 줄인다.

흔한 함정 1: 컨테이너 환경변수는 다 보인다

많은 사람이 컨테이너 안에 환경변수로 넣으면 "격리됐다"고 착각한다. 아니다. 같은 호스트에서 docker 접근 권한만 있으면 이렇게 보인다.

$ docker inspect my-llm-app --format '{{range .Config.Env}}{{println .}}{{end}}'
PATH=/usr/local/bin:/usr/local/sbin
PROVIDER_API_KEY=sk-provider-abc123xyz
LANG=C.UTF-8

또 이미지 빌드 단계에서 ENV로 박으면 레이어 히스토리에 영구히 남는다. docker history로 확인 가능하다. 빌드 시점 시크릿은 반드시 --secret 마운트(BuildKit)를 쓰거나 런타임 주입해야 한다.

흔한 함정 2: Vault 붙였는데 결국 환경변수로 다시 흘린다

Vault를 도입해놓고 앱 시작 스크립트에서 이렇게 하는 경우가 진짜 많다.

export PROVIDER_API_KEY=$(vault kv get -field=api_key secret/llm)
python app.py

이러면 Vault를 쓰든 말든 결국 키가 앱 프로세스 환경변수로 다시 들어온다. 의존성 공격 관점에선 아무것도 안 바뀐 것이다. Vault의 진짜 가치는 중앙 관리, 감사 로그, 자동 로테이션, 동적 시크릿이지, "환경변수에 넣기 전 잠깐 안전"이 아니다.

흔한 함정 3: AWS Secrets Manager 권한 에러

ECS/EKS에서 Secrets Manager 붙이다 보면 십중팔구 이 에러를 만난다.

ResourceInitializationError: unable to pull secrets or registry auth:
execution resource retrieval failed: unable to retrieve secret from asm:
service call has been retried 1 time(s):
AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/ecsTaskExecutionRole/...
is not authorized to perform: secretsmanager:GetSecretValue
on resource: arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:llm/openai-key-AbCdEf
because no identity-based policy allows the secretsmanager:GetSecretValue action

범인은 대부분 Task Execution Rolesecretsmanager:GetSecretValue 권한이 없어서다. 그리고 KMS 커스텀 키로 암호화한 시크릿이면 kms:Decrypt도 추가로 필요하다. 이걸 놓쳐서 한참 헤매는 사람 많다. 최소 권한 정책 예시는 이렇다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:ap-northeast-2:123456789012:secret:llm/openai-key-*"
    },
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:ap-northeast-2:123456789012:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }
  ]
}

안전한 시크릿 관리 옵션 비교

  • HashiCorp Vault: 동적 시크릿, 자동 로테이션, 세밀한 정책. 자체 운영 부담이 크다. Vault Agent나 CSI Provider로 파일 마운트하면 환경변수보다 낫다(단, 파일도 프로세스가 읽으면 의존성이 읽음).
  • AWS Secrets Manager: AWS 생태계면 통합이 편하다. 자동 로테이션 지원. IAM 권한 설정이 함정.
  • SOPS: 암호화된 시크릿을 Git에 커밋해서 GitOps로 관리. KMS/age로 암호화. 배포 시점 복호화. 인프라 부담이 적어 소규모 팀에 좋다.
  • LLM 프록시 게이트웨이(패턴 B): LiteLLM Proxy, 자체 프록시 등. 근본적으로 폭발 반경을 줄이는 유일한 방법. 다만 프록시 자체가 새 SPOF가 되고 운영 포인트가 하나 늘어난다.

정직하게 말하면, 어느 것도 만능이 아니다. 프록시를 써도 게이트웨이 토큰은 여전히 프로세스 안에 있다. 관건은 "완전 제거"가 아니라 "털렸을 때 피해를 얼마나 좁히고, 얼마나 빨리 복구하느냐"다.

SOPS로 키를 Git에 안전하게 넣는 실전 예시

소규모 팀이라면 프록시 세우기 전에 SOPS만 붙여도 평문 커밋 사고는 막는다.

$ cat secrets.yaml
openai_api_key: sk-provider-abc123xyz

$ sops --encrypt --age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8j \
    secrets.yaml > secrets.enc.yaml

$ cat secrets.enc.yaml
openai_api_key: ENC[AES256_GCM,data:xJk2...,iv:...,tag:...,type:str]
sops:
    age:
        - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8j
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            ...
            -----END AGE ENCRYPTED FILE-----

이제 secrets.enc.yaml은 안심하고 커밋해도 된다. 배포 시점에 복호화한다.

$ export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt
$ sops --decrypt secrets.enc.yaml
openai_api_key: sk-provider-abc123xyz

4. 정리: 한 줄 요약과 선택 기준

한 줄 요약: LLM API 키 보안의 90%는 "키가 앱 프로세스가 읽을 수 있는 곳에 있느냐"로 결정된다. 프록시로 프로바이더 키를 앱 밖으로 빼내면 의존성이 털려도 폭발 반경이 줄어든다.

  • 혼자/소규모, 빠른 프로토타입: 최소한 SOPS나 Secrets Manager로 평문 커밋·환경변수 하드코딩부터 막아라.
  • 여러 앱이 같은 프로바이더 키 공유, 감사·로테이션 필요: 프록시 게이트웨이(패턴 B)를 진지하게 검토하라. 앱은 스코프 토큰만 들고, 폐기·로테이션을 중앙화한다.
  • 규제/보안 요구가 높은 조직: Vault 동적 시크릿 + 프록시 조합. 운영 부담은 크지만 회복 속도와 감사 추적이 최고다.

핵심은 마인드셋 전환이다. "우리 코드는 안전하다"가 아니라 "우리 의존성 중 하나가 이미 털렸다고 가정하면, 그때 무슨 크레덴셜이 손에 잡히나?"를 물어야 한다. 이 질문에 답이 안 나온다면, 오늘 docker inspect 한 번 돌려보는 것부터 시작하자.

참고 자료

728x90