카테고리 없음
API 키를 코드에서 분리하는 법 - 로컬 릴레이 패턴
바뿌
2026. 10. 30. 07:11
외부 API를 여러 곳에서 호출하다 보면 키가 여기저기 흩어집니다.
스크립트에 하나, 환경변수에 하나, 테스트 코드에 또 하나.
키를 한 곳에만 두고 나머지는 키 없이 호출하게 만드는 방법을 씁니다.
결론부터
[스크립트들] [로컬 릴레이] [외부 API]
키 없음 ──────► 키를 주입해서 중계 ──────► 인증 필요
localhost X-API-Key- 릴레이만 키를 안다. 호출하는 쪽은 키를 모른다
- 키를 바꿀 때 한 곳만 고치면 된다
- 스크립트에 키가 없으니 실수로 커밋할 일이 없다
왜 이렇게 하나
키를 여러 곳에 두면 이런 일이 생깁니다.
| 문제 | 설명 |
|---|---|
| 누락 | 새 스크립트를 짤 때마다 키 설정을 다시 해야 한다 |
| 유출 | 환경변수 설정 과정에서 셸 히스토리나 로그에 남는다 |
| 커밋 사고 | .env 를 .gitignore 에 안 넣어서 올라간다 |
| 교체 비용 | 키를 바꾸면 모든 곳을 찾아 고쳐야 한다 |
키가 있는 곳이 하나면 이 문제들이 전부 사라집니다.
구현 — 의존성 없이 60줄
Node 내장 모듈만 씁니다. express 도 필요 없습니다.
import http from 'node:http'
const PORT = process.env.PORT || 3000
const TARGET = (process.env.TARGET_BASE || 'https://api.example.com').replace(/\/+$/, '')
const KEY = (process.env.API_KEY || '').trim()
if (!KEY) {
console.warn('[relay] API_KEY 가 비어 있음 - 서버가 401을 돌려준다')
}
const server = http.createServer(async (req, res) => {
// 상태 확인용
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
return res.end(JSON.stringify({ ok: true, target: TARGET, keyLoaded: Boolean(KEY) }))
}
// 지정한 경로만 중계한다 — 아무거나 통과시키지 않는다
if (!req.url.startsWith('/api/')) {
res.writeHead(404, { 'Content-Type': 'application/json' })
return res.end(JSON.stringify({ error: 'not_found', hint: 'this relay only forwards /api/*' }))
}
const chunks = []
for await (const c of req) chunks.push(c)
const body = Buffer.concat(chunks)
try {
const headers = {}
if (req.headers['content-type']) headers['Content-Type'] = req.headers['content-type']
if (KEY) headers['X-API-Key'] = KEY // 들어온 키가 있어도 릴레이 키로 덮어쓴다
const upstream = await fetch(TARGET + req.url, {
method: req.method,
headers,
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
})
const buf = Buffer.from(await upstream.arrayBuffer())
res.writeHead(upstream.status, {
'Content-Type': upstream.headers.get('content-type') || 'application/json',
})
res.end(buf)
console.log(`[relay] ${req.method} ${req.url} -> ${upstream.status}`)
} catch (e) {
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'relay_upstream_error', message: e.message }))
}
})
server.listen(PORT, () => console.log(`[relay] :${PORT} -> ${TARGET} (key ${KEY ? 'loaded' : 'MISSING'})`))
설계에서 중요한 부분
① 경로를 제한한다
if (!req.url.startsWith('/api/')) → 404
아무 경로나 통과시키면 열린 프록시가 됩니다. 남이 내 릴레이로 아무 데나 요청을
보낼 수 있게 되고, 그 요청에 내 키가 붙습니다.
② 들어온 키를 덮어쓴다
if (KEY) headers['X-API-Key'] = KEY
호출하는 쪽이 키를 보내도 무시하고 릴레이 키를 씁니다. 키를 아는 곳을 하나로 강제합니다.
③ 경로를 가공하지 않는다
fetch(TARGET + req.url, ...)
경로를 그대로 넘기면, 서버에 새 엔드포인트가 생겨도 릴레이를 고칠 필요가 없습니다.
④ 헤더를 전부 넘기지 않는다Content-Type 만 선별해서 전달합니다. 클라이언트의 Host, Cookie, Authorization 등이
그대로 가면 의도치 않은 동작이 생길 수 있습니다.
컨테이너로 띄우기
services:
relay:
image: node:20-alpine # 부동 태그 대신 구체 버전 핀을 권장
restart: unless-stopped
mem_limit: 128m
working_dir: /app
command: node relay.mjs
env_file: [.env] # ← 키는 여기에만
environment: [PORT=3000]
ports: ["9410:3000"]
volumes:
- ./relay.mjs:/app/relay.mjs:ro # 읽기 전용
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
.env는.gitignore에. 대신.env.example을 커밋해 어떤 값이 필요한지 알린다:ro로 마운트하면 컨테이너가 소스를 못 고친다/health를 두면 키가 로드됐는지 밖에서 확인할 수 있다
curl -s http://localhost:9410/health
# {"ok":true,"target":"https://api.example.com","keyLoaded":true}
keyLoaded는 불리언으로만 돌려줍니다. 키 값이나 앞 몇 자를 노출하면 안 됩니다.
쓰는 쪽은 이렇게 단순해집니다
# 전
curl -H "X-API-Key: $API_KEY" https://api.example.com/api/items
# 후
curl http://localhost:9410/api/items
스크립트에서도 키 관련 코드가 사라집니다.
const BASE = process.env.BASE || 'http://localhost:9410'
await fetch(`${BASE}/api/items`, { method: 'POST', body: JSON.stringify(payload) })
한계 — 알고 써야 합니다
| 한계 | 설명 |
|---|---|
| 릴레이가 꺼지면 전부 멈춘다 | 단일 장애점이 된다. restart: unless-stopped 필수 |
| 릴레이에 접근 가능한 사람은 키를 쓸 수 있다 | 키 값은 몰라도 API는 호출된다 → LAN 밖에 노출하지 말 것 |
| 서버에 없는 기능은 못 만든다 | 단순 중계다. 경로가 없으면 404가 그대로 온다 |
| 서버가 죽으면 502 | 릴레이 /health 는 릴레이 자신만 본다. 서버 상태와 혼동 금지 |
마지막이 실제로 헷갈렸던 부분입니다. /health 가 ok:true 여도 서버가 죽어 있을 수 있습니다.
릴레이는 자기가 살아 있고 키를 들고 있다는 것만 보고합니다.
정리
- 키를 아는 곳을 하나로 만들면 누락·유출·교체 비용이 사라집니다
- 구현은 내장 모듈 60줄이면 됩니다
- 경로를 제한하세요. 안 하면 열린 프록시가 됩니다
- 들어온 키를 덮어써서 키를 아는 곳을 강제합니다
- 경로를 가공하지 마세요. 서버가 바뀌어도 릴레이를 안 고쳐도 됩니다
/health는 릴레이 자신의 상태일 뿐, 살아있다는 뜻이 아닙니다