클라우드 LLM에 프롬프트를 보내기 전, 로컬 NER 모델로 개인식별정보(PII)를 탐지·마스킹하고 응답 후 원본으로 복원하는 프록시를 Node.js + Express로 구현하는 방법을 다룬다.
아키텍처 개요
애플리케이션 → [로컬 PII 프록시] → Gemini API
↑
GLiNER NER + 정규식프록시는 요청마다 탐지 → 마스킹 → 추론 → 복원 4단계를 처리한다. 클라우드 모델은 <PERSON_0>, <EMAIL_1> 같은 플레이스홀더만 보고 실제 개인정보는 프록시 메모리 안에서만 유지된다.
프로젝트 스택
- Node.js ≥ v22
- Express — HTTP 프록시 서버
gliner— 로컬 GLiNER NER 모델 (ONNX)@google/genai— Gemini API 클라이언트- Docker — 배포용 패키징
프로젝트 셋업
mkdir pii-proxy && cd pii-proxy
npm init -y
npm install express cors gliner @google/genaipackage.json에 "type": "module" 추가 및 스크립트 설정:
{
"type": "module",
"scripts": {
"dev": "node --env-file=.env --watch server.js",
"start": "node --env-file=.env server.js",
"test": "node --test test/leak-check.js"
}
}.env 파일에 Gemini API 키 설정:
GEMINI_API_KEY=your_actual_api_key_hereGLiNER 모델 다운로드
Hugging Face의 onnx-community/gliner_medium-v2.1 페이지에서 onnx/model_int8.onnx를 다운로드해 model/gliner_medium-v2.1.onnx로 저장한다.
로컬 NER 파이프라인 구현 (ner.js)
import { Gliner } from "gliner/node";
let glinerInstance = null;
async function getGliner() {
if (!glinerInstance) {
glinerInstance = new Gliner({
tokenizerPath: "onnx-community/gliner_medium-v2.1",
onnxSettings: { modelPath: "model/gliner_medium-v2.1.onnx" },
});
await glinerInstance.initialize();
}
return glinerInstance;
}
const ENTITY_TYPES = [
"person", "email", "phone", "address", "city", "state",
"country", "zipcode", "ip_address", "national_id",
"user_id", "credit_card", "account", "token",
];
const REGEX_PATTERNS = [
{ type: "EMAIL", pattern: /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g },
{ type: "PHONE", pattern: /\b(?:\+?\d{1,3}[\s.\-]?)?(?:\(?\d{2,4}\)?[\s.\-]?)?\d{3,4}[\s.\-]?\d{3,4}\b/g },
];
export async function detectEntities(text) {
const gliner = await getGliner();
const results = await gliner.inference({
texts: [text],
entities: ENTITY_TYPES,
flatNer: true,
threshold: 0.1, // 낮게 설정 — 미탐지가 오탐지보다 위험
multiLabel: false,
});
// ... (스팬 추출 + 정규식 결합 + 중복 제거)
return mergeSpans([...glinerEntities, ...regexEntities]);
}
threshold: 0.1은 의도적으로 낮게 설정한다. 보안 경계에서는 오탐지보다 미탐지가 더 위험하다. 실제 서비스에서는 고유 픽스처셋으로 임계값을 조정한다.
PII 마스킹 레이어
요청 컨텍스트에 엔티티 맵을 저장해 동일 값이 같은 플레이스홀더를 받도록 처리한다:
// 마스킹: "<PERSON_0>" 형태의 플레이스홀더로 치환
// 복원: 응답 내 플레이스홀더를 원본값으로 교체프록시 서버 (server.js)
Express 서버가 /v1/chat/completions 요청을 받아 NER 탐지 → 마스킹 → Gemini 호출 → 복원 후 반환한다.
node --env-file=.env server.js
# 서버 기동 후 curl 또는 OpenAI 호환 클라이언트로 http://localhost:3000 에 요청Docker 배포
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "--env-file=.env", "server.js"]회귀 테스트 (leak-check)
CI에서 알려진 PII가 탐지 안 되면 실패하는 테스트 스위트를 Node.js 내장 node:test로 작성한다. PII 우회 회귀를 조기에 차단한다.
한계 및 트레이드오프
- GLiNER가 모든 문맥 의존 PII를 100% 탐지하지는 못한다
- 지연 증가: 로컬 NER 추론 시간이 추가된다
- 모델이 플레이스홀더 자체를 추론에 활용해야 하는 경우(예: 이름을 직접 생성)에는 부적합
참고 자료
- How to build a local AI proxy to redact PII before LLMs — LogRocket (2026-04-29)