카테고리 없음

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 는 릴레이 자신의 상태일 뿐, 살아있다는 뜻이 아닙니다