API Key 노출 사건
하루는 OpenAI에서 메일이 왔다.

OpenAI API Key가 노출되어 해당 키를 비활성화했다는 내용이다.
처음에는 '어디서 노출되었지?'하며 새로운 키를 발급해서 다시 설정을 했다. 지금까지 설정해둔 제한 금액인 $5을 초과한 적이 없기 때문에 노출이 되어도 괜찮지라는 안일한 생각에 계속해서 미뤄왔다. 그런데 바꾼 API Key로 배포한지 하루도 채 지나지 않아 API Key가 또 비활성화가 되는 거다. 이렇게 빨리 API Key가 노출되었음을 OpenAI가 알아챘음에 놀라움과 동시에 '이제는 해결해야겠구나' 싶었다.
노출된 경로를 생각해보자. 현재 노출된 API Key는 웹 메모 확장 프로그램의 서비스 워커에서 사용 중이다. 이 API Key를 사용하는 경로는 다음과 같다.
- 사용자가 사이드 패널에서 요약 버튼을 누른다.
- 사이드 패널에서 Content UI에 본문 내용(사용자가 보고 있는 웹)을 요청한다.
- Content UI → 사이드 패널로 사용자가 보고 있는 본문 내용을 전달한다.
- 사이드 패널 → 서비스 워커로 본문 내용을 전달한다.
- 서비스 워커에서 API Key를 사용해 본문 내용을 ChatGPT에 전달해 요약 내용을 전달받는다.
- 서비스 워커 → 사이드 패널에 ChatGPT로부터 전달받은 내용을 그대로 전달한다.
- 사이드 패널은 이를 사용자에게 보여준다.
여기서 사이드 패널에서 Open AI Api Key를 직접 사용하지 않는 이유는, 네트워크 창이 사용자들에게 보여지기에 API Key가 유출될 것을 우려했기 떄문이다. 그래서 서비스 워커에 컨텐츠를 전달해, 서비스 워커에서 API Key를 사용하도록 설계했다. 그런데 내가 놓친 것이 있었다. 확장 프로그램 설정에서 해당 확장 프로그램의 서비스 워커를 들여다볼 수 있다는 것이었다.
위의 서비스 워커를 누르면 개발자 도구가 열린다.
여기서, 서비스 워커에서 주고받는 네트워크를 모두 확인할 수가 있다.
그렇다면 사이드 패널은 안전할까? 절대 아니다. 사이드 패널 또한 우클릭을 해서 검사를 누르면 개발자 도구를 열 수 있다.
확장 프로그램에서는 API Key를 숨길 곳이 없다
이 지점에서 깨달은 게 있다. 서비스 워커든 사이드 패널이든, 결국 모든 것이 사용자의 브라우저에서 실행된다는 점이다. 브라우저에서 실행되는 코드는 기본적으로 사용자가 접근할 수 있다. 코드 내부에서 API Key를 아무리 숨겨도, 네트워크 요청이 발생하는 순간 개발자 도구에 그대로 드러난다.
나도 코드가 번들링되고 난독화되어 있으니 괜찮을 거라 생각했다. 그런데 난독화는 코드를 읽기 어렵게 만들 뿐이었다. API Key가 요청 헤더나 바디에 포함되어 전송되는 순간, 개발자 도구의 Network 탭에 그대로 찍힌다.
Node.js 서버를 만들자.
현재 상황을 정리하면 서비스 워커에서 직접 API Key를 사용해 데이터 요청을 하기 때문에 발생한 문제이다. 서비스 워커 또한 클라이언트(브라우저)이기 때문에 결국 사용자가 개발자 도구를 통해 네트워크 창을 확인해 API Key가 노출당할 우려가 있다.
이제 서버를 만들어보자. Node.js 서버를 만드는 데에는 다양한 방법들이 있다. 기본 HTTP 서버, Express.js, Fastify, Koa.js 등을 활용해 서버를 처음부터 구축할 수도 있다. 새로운 서버를 배포하게 되면 배포하고 관리하는 과정이 상당히 번거로울 것이라 생각했다. 그래서 새로운 서버를 띄우지 않고 기존 우리 서비스에서 배포 중인 Next.js를 활용해, Next.js의 Route Handlers기능을 활용해야겠다고 결정했다.
Node.js의 Route Handlers는 아래와 같이 HTTP 요청 핸들러를 정의하는 방식으로 구현할 수 있다. POST 요청을 처리하는 핸들러 함수를 만들어 API 엔드포인트를 구성한다.
// app/api/openai/route.ts
export async function POST(request: NextRequest) {
// ..
}
확장 프로그램에서 요청한 것이 맞는지 검증한다.
// app/api/openai/route.ts
export async function POST(request: NextRequest) {
const origin = request.headers.get("origin");
const validOrigin = `chrome-extension://${EXTENSION.id}`;
if (origin !== validOrigin) {
return createErrorResponse(
ERROR_MESSAGES.UNAUTHORIZED,
HTTP_STATUS.FORBIDDEN,
);
}
// ..
}
확장 프로그램으로부터의 요청의 headers의 origin에는 확장 프로그램의 url이 설정되어있다. 이를 통해 악의적인 사용자의 무분별한 API 요청을 제한한다.
다만 header의 origin은 마음만 먹으면 위조할 수 있기에 이걸로 보안이 끝나는 건 아니다. 무분별한 요청을 걸러내는 최소한의 문턱 정도로 생각하고 있다. 사용량이 늘면 JWT 인증이나 Rate Limiting을 붙일 생각이다.
스트리밍 방식으로 메시지를 전달받고, 이를 다시 스트리밍 방식으로 전달한다.
스트리밍이란 데이터를 작은 단위로 나누어 지속적으로 전송하는 방식을 말한다. 일반적인 HTTP 요청은 전체 응답이 완성될 때까지 기다렸다가 한 번에 전송하지만, 스트리밍은 데이터가 준비되는 대로 부분적으로 전송한다. ChatGPT나 Claude에서 아래와 같이 답변이 단어 단위로 하나씩 나오는 게 바로 이 스트리밍 방식이다.
처음에는 일반적인 방식으로 구현하려고 했다. 요약이 완성될 때까지 기다렸다가 한 번에 응답을 보내는 것이다. 하지만 긴 글을 요약하는 경우 사용자가 10초 이상 아무런 피드백 없이 기다려야 하는 상황이 발생했다. 사용자 입장에서는 지금 동작 중인지 멈춘 건지 알 길이 없다.
그래서 스트리밍 방식을 도입하기로 결정했다. OpenAI API 자체가 스트리밍을 지원하고 있으니, 이를 그대로 클라이언트에 전달하면 된다. 구현 방법을 단계별로 살펴보자.
- ReadableStream 객체를 만든다.
ReadableStream은 Streams API의 ReadableStream 인터페이스로, 바이트 데이터를 읽을 수 있는 스트림을 제공한다.
const customReadable = new ReadableStream({
async start(controller) {
// ..
}
})
// ..
return new Response(customReadable, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
이 ReadableStream 객체는 클라이언트에 반환될 것이다. 그럼 클라이언트는 이 ReadableStream 객체를 전달받아서, 우리가 이후에 전달하는 스트리밍 데이터를 계속해서 받을 수 있다. 추가적으로, 헤더의 Connection을 'keep-alive'로 설정해 한 번의 연결로 여러 요청/응답을 처리할 수 있도록 한다.
- OpenAI의 스트림을 연다.
const customReadable = new ReadableStream({
async start(controller) {
const stream = await openai.chat.completions.create({
messages,
model: "gpt-4.0-mini",
stream: true,
});
// ..
}
})
이 때 OpenAI에 내가 전달하고 싶은 내용(본문 내용, 여기선 messages)을 전달한다. 그리고 OpenAI에 stream을 true로 전달하면 스트리밍 형식으로 메시지를 반환한다.
- 스트림에서 데이터 청크를 받아서 클라이언트에 전송한다.
const customReadable = new ReadableStream({
async start(controller) {
const stream = await openai.chat.completions.create({
messages,
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ content })}\n\n`,
),
);
}
}
// ..
}
})
OpenAI에서 반환하는 객체 또한 Stream 객체이다. 이 스트림 객체를 순회하면서 각 청크(chunk)에서 AI가 생성한 컨텐츠를 추출한다. 추출된 컨텐츠는 인코딩 후 클라이언트에 전송한다. 여기서 data:는 Server-Sent Events(SSE) 프로토콜의 표준 형식으로, 클라이언트가 스트림 데이터를 식별할 수 있도록 하는 접두사이다.
- 스트림 종료 마커 전송 후, 스트림을 닫는다.
const customReadable = new ReadableStream({
async start(controller) {
// ... 이전 코드
controller.enqueue(
encoder.encode(`data: [DONE]\n\n`),
);
controller.close();
}
})
여기서 [DONE]은 클라이언트에서 스트림이 종료되었음을 식별할 수 있는 마커이다. 이를 통해 클라이언트는 더 이상 데이터를 기다리지 않고 스트림을 종료할 수 있다.
이제 확장 프로그램의 네트워크 창에서 OpenAI API Key를 확인할 수 없다. 엔드포인트인 Next.js의 서버 URL만 확인할 수 있다.
전체 코드는 아래에서 살펴볼 수 있습니다.
맺으며
환경 변수가 네트워크 창에서 노출이 되고 있음에도 ‘아직 문제가 없으니까’라는 이유로 무시를 하고 있었다. 그런데 OpenAI에서 해당 API Key를 지속적으로 비활성화해준 덕분에 미뤄왔던 숙제를 드디어 해치웠다. 앞으로도 중요한 정보가 노출이 될 때는 Next.js Route Handlers, AWS Lambda, Google Cloud Functions 등의 서버리스 서비스를 사용해야겠다는 생각을 했다. 혹은 백엔드 서버와 협업을 한다면 백엔드에게 도움을 요청할 수도 있을 것이다.
Next.js 프레임워크덕분에 Route Handlers 기능을 활용해 간편하게 서버를 구현할 수 있었지만, 다음 번에는 직접 서버를 띄워서 구현을 해봐도 재미날 거 같다는 생각을 했다. 이번에도 선택지를 고민하는 과정에서 AI의 도움을 많이 받았다.
AI덕분에 혼자서는 구현하기에 허들이 높은 것들을 보다 편하게 구현할 수 있음에 감사하다. 앞으로도 AI를 잘 활용하는 개발자가 되어야지.