들어가며: 공개 키를 금고에 넣은 사고
AI 에이전트에게 서버 작업을 맡기면, 언젠가는 API 키를 다루게 됩니다. 이 글은 OpenClaw라는 AI 에이전트 런타임에서 실제로 겪은 사고 하나를 정리한 것입니다.
개념마다 어느 제품의 것인지 구분해 표기하겠습니다. 센티넬과 egress 프록시는 OpenClaw의 방식이고, 공개용 키는 Supabase의 개념이며, 빌드 시점 인라인은 Next.js의 규약입니다. 이 구분이 이번 사고를 이해하는 데 결정적이었습니다.
증상은 단순했습니다. 배포한 웹앱에서 게스트 로그인이 실패했고, 안내 문구는 인증 제공자(Supabase)에서 익명 로그인이 꺼져 있을 수 있다고 알려 주었습니다. 그래서 설정을 확인했지만 익명 로그인은 정상적으로 켜져 있었고, 직접 호출한 가입 요청도 200으로 성공했습니다. 원인은 전혀 다른 곳에 있었습니다.
핵심 모순은 이랬습니다. 문제가 된 키는 이름부터 공개용(publishable)이었습니다. 브라우저에 노출되어도 되는 키이고, 실제 권한은 데이터베이스의 행 수준 보안(RLS)이 통제합니다. 그런데 그 키를 두 가지 방식으로 잘못 다루고 있었습니다.
첫째, 그 키를 보호된 시크릿 저장소에 넣어 두었습니다. 공개되어도 되는 값을 브라우저에 내보내지 못하도록 막는 저장소에 넣은 셈입니다.
둘째, 브라우저 번들에 넣어야 하는 환경변수 접두사(NEXT_PUBLIC_)를 붙여 두었습니다. 이 접두사가 붙은 값은 빌드 시점에 문자열 그대로 번들에 박힙니다.
두 잘못이 만나면서 사고가 났습니다. 아래에서 관련 개념을 하나씩 정리하겠습니다.
1. 센티넬: 프로세스에는 진짜 키 대신 표식만 넣는다
가장 특이한 용어는 센티넬(sentinel)입니다. 표식, 혹은 빈칸 표식이라고 옮길 수 있습니다.
OpenClaw는 자격증명을 보호 저장소에 넣어 두고, 실행 프로세스에는 진짜 값 대신 oc-sent-v2로 시작하는 가짜 표식 문자열을 넣습니다. 실제 형태는 oc-sent-v2.암호문.end 같은 모양입니다.
왜 이런 방식을 쓰는지가 핵심입니다. 에이전트가 환경 변수를 출력하거나 명령 인자에 키를 섞으면 그대로 노출됩니다. 그런데 진짜 값을 애초에 주지 않으면, 새어 나갈 것도 없습니다. 저장소에는 실제 값이 그대로 보관되어 있고, 프로세스에는 여기 무언가 있다는 표식만 보입니다.
표식 뒤의 암호문은 위조가 어렵게 인증된 값이고, 끝의 버전 표기(v2)는 표식 형식의 버전입니다. 형식이 바뀌어도 구버전 표식을 인식할 수 있도록 남겨 둡니다.
한 가지 분명히 해 둘 점이 있습니다. 표식만 유출되어도 원래 자격증명이 드러나지는 않지만, 이것이 프로세스 격리는 아닙니다. 실제 값은 같은 프로세스 메모리에 여전히 존재합니다.
2. 치환과 egress 프록시: 나갈 때만 진짜 값으로 바꾼다
센티넬만 있으면 아무 데도 인증할 수 없습니다. 그래서 값을 내보내는 시점에 진짜 값으로 바꿔 주는 중개자가 필요합니다. 이것이 egress 프록시이고, 바꾸는 행위를 치환(substitution)이라고 부릅니다.
이 프록시는 게이트웨이가 소유하는 루프백 프록시로 동작합니다. 요청이 프로세스를 떠나기 직전에 URL, 헤더, 스트리밍 본문에서 표식을 찾아 실제 값으로 바꿉니다.
중요한 점은 조건부라는 것입니다. 아무 요청이나 바꿔 주면 프록시 자체가 유출 경로가 됩니다. 그래서 정해진 호스트로 나갈 때만 치환합니다. 결과적으로 센티넬은 어디서든 통하는 키가 아니라, 특정 호스트에 묶인 일회용 표식이 됩니다.
3. allowedHosts: 치환이 허용되는 목적지
센티넬은 목적지 목록에 묶여 있습니다. 예를 들어 인증 제공자 주소와 배포 대상 API 주소 두 곳이 허용 목록에 들어가는 식입니다. 이 목록은 정확한 호스트명으로만 일치하고, 와일드카드나 포트는 지원하지 않습니다.
여기에 함정이 있었습니다. 배포 업로드가 특정 호스트에 대해 허용되지 않았다는 오류로 막혔고, 그래서 허용 호스트를 추가했습니다. 그런데 허용 호스트를 추가하자 서버 측 점검이 200으로 통과해 버렸습니다. 브라우저 번들에는 여전히 센티넬이 박혀 있는데도 말입니다.
이것이 오진을 만든 지점입니다. 통과한 검사가 어느 계층에서 통과했는지 확인하지 않으면, 성공으로 오독하게 됩니다.
4. kind secret과 kind env: 두 가지 저장 방식
OpenClaw의 공유 시크릿 저장소는 두 가지 접근 모드를 둡니다.
- 보호된 시크릿(kind secret): 실행 환경에는 센티넬이 보입니다. 허용 호스트로 나갈 때만 실제 값으로 치환됩니다. 저장 후에는 쓰기 전용이라 값을 되읽는 기능 자체가 없고, 길이조차 조회할 수 없습니다.
- 읽기 가능한 환경 변수(kind env): 실행 환경에 실제 값이 그대로 보입니다. 짧은 진단에 적합하고, 사용 후 회전과 삭제를 고려합니다.
즉 센티넬은 보호 방식의 부작용이지 기능이 아닙니다. 대신 그 대가로 값을 확인할 수 없습니다. 값이 잘렸는지 확인해야 한다면 일시적으로 읽기 가능한 방식으로 바꿔 확인하고, 즉시 회전하고 삭제해야 합니다.
여기에도 한계가 있습니다. 저장소 값은 암호화되지 않고 게이트웨이 상태 데이터베이스에 평문으로 들어갑니다. 파일 권한으로만 보호되므로, 더 강한 격리가 필요하면 외부 볼트 연동을 써야 합니다.
5. publishable key와 NEXT_PUBLIC: 치환이 닿지 못하는 지점
사고의 뿌리는 여기입니다. 서로 다른 두 제품의 개념이 겹칩니다.
공개용 키(publishable key)는 Supabase의 신형 키 체계에서 기존 익명 키를 대체하는 이름입니다. 설계상 공개되어도 되는 키이고, 실제 권한은 데이터베이스의 행 수준 보안(RLS)이 통제합니다.
NEXT_PUBLIC_ 접두사는 Next.js 규약입니다. 이 접두사가 붙은 환경변수는 빌드 시점에 자바스크립트 문자열로 박혀 브라우저로 나갑니다. 런타임에 바꿀 수 없습니다.
둘을 합치면 모순이 드러납니다. 브라우저에 문자열 그대로 있어야 하는 값인데, 프록시 치환은 네트워크 요청이 나갈 때만 동작합니다. 빌드 산출물 안에 박히는 값은 네트워크를 타지 않으니 치환될 기회가 없습니다.
해결은 주입값을 배제한 채 빌드하는 것이었습니다. 실행 환경의 센티넬 값을 지우고, 설정 파일에 넣어 둔 실제 값만 쓰게 하는 것입니다.
env -u NEXT_PUBLIC_SUPABASE_URL -u NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY npm run build
이때 env -u가 필요한 이유가 있습니다. 실제 환경변수는 설정 파일보다 우선합니다. 센티넬이 든 환경변수를 지우지 않으면, 멀쩡한 파일 값을 무시하고 센티넬이 번들에 박힙니다.
정리하면 이렇습니다. 네트워크를 타고 나가는 값은 치환으로 보호할 수 있지만, 빌드 결과물에 문자열로 박히는 값은 그 보호 밖에 있습니다. 공개용 키는 후자에 속하므로 처음부터 공개를 전제로 다뤄야 합니다.
6. 센티넬 오염과 배포 게이트
번들이나 산출물에 센티넬이 박히는 사고를 오염(taint)이라고 부르겠습니다. 무서운 점은 눈에 띄지 않는다는 것입니다. 앞에서 봤듯 서버 측 점검은 치환 덕분에 통과하고, 브라우저에서만 조용히 실패합니다.
그래서 검사를 자동화된 게이트로 만들었습니다.
- 사전 검사: 설정 파일의 공개 환경변수 값이 센티넬인지 확인합니다.
- 산출물 검사: 빌드 결과 파일 전체에 센티넬 문자열이 인라인되었는지 확인합니다.
검사 도구만 만들고 배포 경로를 고정하지 않으면 우회로로 다시 샙니다. 실제로 이 계열 사고가 세 번 났고, 세 번째에 배포 스크립트 자체를 고정했습니다. 사람이 매번 올바른 명령을 기억하는 방식은 오래가지 않습니다.
7. 대조군: 치환이 개입했는지 가르는 실험
추측을 배제하는 기법도 함께 쓰게 되었습니다. 같은 요청을 두 번 보냅니다.
- 센티넬로 호출하면 치환되어 200이 돌아옵니다.
- 같은 호출을 프록시 우회로 보내면 치환되지 않고 거부됩니다.
두 결과가 갈리면 치환이 개입한 것이고, 두 결과가 같으면 프록시는 무관하며 원인은 상류나 자격증명 자체입니다. 이 대조를 하지 않으면 통과를 성공으로 오독합니다.
8. 오류 코드가 말해 주는 것
시크릿 계층에서 만난 오류는 원인을 꽤 정확히 알려 줍니다.
- 특정 호스트에 허용되지 않았다는 오류는 값이 나쁜 것이 아닙니다. 센티넬이 등록되었지만 그 호스트에 묶이지 않았다는 뜻이고, 친절하게도 필요한 명령까지 함께 알려 줍니다. 이 경우 설정 파일을 고쳐도 풀리지 않습니다. 값이 파일이 아니라 번들 내용에 있기 때문입니다.
- 프록시 인증 거부(407) 오류는 프록시 자체의 인증이 만료되었다는 뜻입니다. 주로 장시간 실행되는 백그라운드 프로세스에서 나타납니다.
- 상류 호스트에 도달하지 못했다는 오류는 상대 서비스 장애의 증거가 아닙니다. 대상 서비스 자체의 상태 확인으로 판정해야 합니다.
9. SecretRef: 설정에서 시크릿을 가리키는 참조
마지막으로 설정 계층의 개념입니다. 시크릿을 설정 파일에 직접 쓰지 않고 참조(Ref)로만 가리키는 방식입니다. OpenClaw는 환경 변수, 파일, 외부 명령 실행, 공유 저장소 네 가지 참조 방식을 지원합니다.
센티넬과 헷갈리기 쉬운데 층이 다릅니다. 센티넬은 실행 프로세스에 주입되는 표식이고, SecretRef는 설정 필드가 값을 가리키는 포인터입니다.
10. 이 개념은 OpenClaw만의 것이 아니다
여기까지 읽으면 OpenClaw의 독자 기능처럼 보일 수 있어, 분명히 해 두겠습니다. 자격증명을 모델 컨텍스트 밖에 두는 아이디어 자체는 업계 공통입니다.
예를 들어 MCP는 민감 데이터를 대역 외 URL로 요청하는 방식을 규격에 넣어 두었습니다. 다른 에이전트 런타임인 Hermes도 볼트 연동과 프록시 기반 자격증명 주입을 지원합니다. 구현 방식과 보관 책임의 범위가 다를 뿐, 문제의식은 같습니다.
그리고 어떤 방식이든 공통으로 남는 한계가 있습니다.
- 호스트 바인딩은 그 호스트를 신뢰할 수 있게 만들어 주지 않습니다. 허용한 서비스가 요청 자격증명을 되돌려 주면 그대로 유출됩니다.
- 정책은 호스트명 기준이라 IP 수준 고정이 아닙니다.
- 결국 프로세스 격리와 네트워크 정책이 함께 있어야 완성됩니다.
마무리: 두 가지 교훈
이번 사고에서 남은 교훈은 두 가지입니다.
첫째, 보호된 저장소에 넣을 값과 브라우저로 보낼 값을 구분해야 합니다. 공개되어도 되는 키를 금고에 넣으면, 금고가 오히려 배포를 방해합니다.
둘째, 통과한 검사가 어느 계층에서 통과했는지 확인해야 합니다. 서버 측 점검이 200이어도 브라우저에서는 실패할 수 있습니다. 중개자가 값을 바꿔 주는 구조에서는, 테스트가 성공했다는 사실만으로 설정이 옳다고 결론 내릴 수 없습니다.
0 댓글