AI Sparkup

최신 AI 쉽게 깊게 따라잡기⚡

PII Proxy 튜토리얼 – Node.js로 LLM 호출 전 개인정보 자동 마스킹 구현하기

클라우드 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/genai

package.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_here

GLiNER 모델 다운로드

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 추론 시간이 추가된다
  • 모델이 플레이스홀더 자체를 추론에 활용해야 하는 경우(예: 이름을 직접 생성)에는 부적합

참고 자료



AI Sparkup 구독하기

최신 게시물 요약과 더 심층적인 정보를 이메일로 받아 보세요! (무료)