Laranon을 이용한 Laravel용 가역적 PII 익명화
요약
Laranon은 Laravel 환경에서 PII(개인 식별 정보)를 가역적으로 익명화하는 패키지입니다. 단순 정규 표현식의 한계를 넘어 체크섬 검증과 단어 단위 토큰화를 통해 데이터의 일관성을 유지하며, LLM 전송 시 보안과 추론 성능을 동시에 확보합니다.
핵심 포인트
- 체크섬(Luhn, mod-97 등)을 활용해 정규 표현식의 오탐 문제를 해결
- 단어 단위 이름 토큰화를 통해 문맥 유지 및 일관된 추론 지원
- 데이터를 익명화한 후 다시 원래 값으로 복원 가능한 가역적 구조
- 인메모리 맵을 사용하여 보안성을 높인 설계
저는 AI 어시스턴트가 실제 사례(고객 이름, 주민등록번호, IBAN, 전화번호 등)에 대한 질문에 답변하는 리걸테크(legal-tech) 제품을 만들고 있습니다. 해당 대화 내용은 제가 제어할 수 없는 LLM(Large Language Model)으로 전송됩니다. 데이터를 가공 없이 그대로 보내는 것은 선택지에 없습니다.
당연한 해결책으로 ID처럼 보이는 모든 것을 지워버리는 수많은 정규 표현식(regex) 뭉치를 사용하는 방법이 있지만, 이는 두 가지 방향에서 모두 문제가 발생합니다. 잘못된 양성(false positives, ID가 아닌 것을 삭제함)과 잘못된 음성(false negatives, 형식이 특이한 ID를 놓침)이 발생하며, 무엇보다 가장 최악인 점은 단방향이라는 것입니다. 데이터를 한 번 지워버리면 모델이 이제 [REDACTED]가 [REDACTED]와 대화하는 것에 대해 추론하게 되므로, 일관된 답변을 다시 돌려받을 수 없습니다.
저는 PII(개인 식별 정보)를 적절히 감지하고, 모델이 추론할 수 있는 안정적인 플레이스홀더(placeholder)로 교체한 뒤, 실제 값을 답변에 다시 되돌려 놓는 무언가를 원했습니다. 그것이 바로 Laranon입니다.
설치 방법
단일 Composer 패키지로 제공됩니다:
composer require edulazaro/laranon
이것이 필요한 전부입니다. Laranon은 기본적으로 데이터베이스에 아무것도 저장하지 않습니다. 채팅 턴(chat turn)은 요청과 함께 소멸되는 인메모리 맵(in-memory map)을 사용합니다. 테이블을 건드리는 유일한 경우는 큐에 쌓인 작업(queued jobs)을 위한 선택적인 데이터베이스 볼트(database vault)뿐이며, 이에 대해서는 아래에서 더 자세히 다룹니다.
사용 방법
나갈 때는 익명화하고, 들어올 때는 복원합니다. 토큰 맵은 절대 서버를 떠나지 않습니다.
use EduLazaro
Laranon
Laranon;
$result = Laranon::anonymize(
...
이것이 핵심 아이디어의 전부입니다. 아래의 모든 내용은 이를 신뢰할 수 있게 만드는 메커니즘과 실제 앱에 연결하는 방법들입니다.
정규 표현식 그 이상인 이유
몇 가지 설계 선택이 데모 수준과 고객 데이터를 믿고 맡길 수 있는 수준 사이의 차이를 만듭니다.
패턴이 아닌 체크섬(Checksums)
스페인의 DNI는 단순히 "8자리 숫자와 한 글자"가 아닙니다. 그것은 8자리 숫자와 올바른 mod-23 제어 문자의 조합입니다. Laranon은 해당 문자를 검증하며, IBAN은 mod-97, 신용카드는 Luhn 알고리즘과 IIN을 통해 검증하고, NIE, CIF, NSS, CCC도 동일한 방식으로 처리합니다. 잘못된 문자가 포함된 12345678A는 플래그(flag) 처리되지 않으며, 이를 통해 단순한 스크러버(scrubber)를 무용지물로 만드는 대부분의 오탐(false positives)을 제거합니다.
단어 단위 이름 토큰 (Per-word name tokens)
이름은 사람이 아닌 단어 단위로 토큰화(tokenized)되며, 어떠한 경우에도 신원을 추측하지 않습니다. "John Smith"는 이름(given name)과 성(surname) 각각에 고유한 안정적인 토큰이 부여되어 «PER_1» «AP_1»이 됩니다. 나중에 나타나는 단독 이름 "John"은 토큰이 사람이 아닌 단어에 속하기 때문에 다시 «PER_1»이 됩니다. "Mr. Baker"는 "John Baker"의 «AP_2»를 공유합니다. 경칭(Honorifics)과 조사(particles)는 평문(cleartext)으로 유지됩니다 ("John de la Cruz"는 «PER_1» de la «AP_3»로 읽힙니다). 이는 인간 독자가 가진 정보와 정확히 일치하며, 그 이상도 이하도 아닙니다.
교체값은 절대 중복되지 않음 (Replacements never repeat)
토큰 맵(token map)은 두 개의 서로 다른 값이 하나의 플레이스홀더(placeholder)를 공유하지 않음을 보장합니다. 만약 공유하게 된다면, 두 명의 인물이 병합되어 복원(restore) 과정에서 데이터가 뒤섞이게 될 것입니다.
정확하고 가역적인 복원 (Exact, reversible restoration)
모든 토큰은 바이트 단위로 원래의 텍스트와 정확히 일치하도록 매핑됩니다. 또한 스트리밍(streaming) 환경에서도 안전합니다. 멀티바이트 문자인 « 내부에서 토큰이 두 개의 SSE 청크(chunk)로 나뉘더라도, 버퍼링을 통해 올바르게 복원됩니다.
세션(Sessions): LLM 턴에 최적화된 구조
채팅 턴(chat turn)의 경우, 전체 프롬프트(사용자 메시지, 검색된 컨텍스트, 도구 결과)에 걸쳐 공유되는 하나의 인메모리(in-memory) 맵이 필요하며, 요청이 끝나면 해당 맵이 사라지기를 원합니다. 이것이 바로 세션(session)입니다. 세션은 자신의 맵을 소유하지만 아무것도 영구적으로 저장하지 않으며, 요청과 함께 소멸하는 일회성 객체입니다.
use EduLazaro\Laranon\Anonymizer;
$anon = Anonymizer::create();
...
anonymize()와 restore()는 문자열, 리스트, 또는 중첩된 점 경로(dot paths)와 * 와일드카드를 포함한 메시지 리스트 내의 키 경로(key path)를 인자로 받습니다:
$anon->anonymize($messages, 'content');
$anon->anonymize($messages, 'tool_calls.*.function.arguments');
여기서 $messages는 일반적인 OpenAI 채팅 형태(role, content, tool_calls...)를 가진 일반 PHP 배열입니다. Laranon은 해당 형태를 정의하거나 요구하지 않습니다. Laravel의 data_get()과 마찬가지로, 사용자가 제공한 점 표기법(dot path)을 따라 중첩된 배열을 탐색하며 도달한 문자열을 익명화할 뿐입니다. 역할(Roles), ID, 도구 이름(tool names) 및 그 외 모든 요소는 그대로 유지됩니다.
채팅 기록을 평문(실제 값)으로 유지하기 때문에, 익명화된 데이터를 영구적으로 저장하지 않습니다. 각 턴은 단순히 새로운 세션을 구축하고 프롬프트 전체를 처음부터 다시 익명화합니다. 토큰은 동일하게 생성됩니다(읽기 순서가 결정론적(deterministic)이기 때문). 따라서 멀티 턴(multi-turn) 대화는 턴 사이에 어떠한 상태(state)도 전달하지 않고도 일관성을 유지합니다.
큐 작업(Queued jobs)에는 세션이 아닌 스코프(scope)가 필요합니다
세션은 메모리에 존재하며 요청(request)과 함께 소멸하며, 이는 동기식 채팅 턴에 정확히 적합합니다. 큐 작업(queued job)은 다릅니다. 이는 세션을 생성한 요청이 종료된 후 한참 뒤에, 다른 프로세스에서 실행됩니다. 공유할 수 있는 인메모리 맵(in-memory map)이 존재하지 않습니다.
이를 위해 영구적인 볼트(vault)를 기반으로 하는 스코프(scope)를 사용하십시오. 맵은 사용자가 선택한 키 아래에 (앱 키로) 암호화되어 저장되므로, 작업이 이를 다시 열고 복원할 수 있습니다.
// 요청 내에서
$safe = Laranon::scope("job-\{id\}")->anonymize($text);
ProcessWithLlm::dispatch($safe->text, $id);
...
데이터베이스 볼트(database vault)는 테이블이 필요한 유일한 부분입니다. 설정을 한 번만 게시(publish)하고 마이그레이션(migration)을 실행하십시오:
php artisan vendor:publish --tag=laranon-config
php artisan vendor:publish --tag=laranon-migrations
그 다음 config/laranon.php에서 스코프 볼트를 database로 지정하여 작업 경계(job boundary)를 넘어 생존할 수 있도록 하십시오. cache는 수명이 짧은 작업에 적합하며, array는 단 하나의 요청 동안만 유지됩니다. 그리고 forget()은 가역적 가명화(reversible pseudonymization)를 실제 익명화(anonymization)로 전환하는 스위치입니다. 맵이 사라지면 토큰은 다시 원래대로 되돌릴 수 없습니다.
채팅 루프에 연결하기
세 가지 훅(hooks)이 전체 패턴입니다. 저는 이를 법률 AI 어시스턴트에서 실행하고 있지만, 이 중 어느 것도 특정 앱에 종속적이지 않습니다.
$anon = Anonymizer::create();
// 1. Anonymize the prompt before it leaves. Cover the message content AND the
...
Hook 2가 핵심입니다. 모델은 «AP_1»에 대해 추론하지만,
나가는 길에는 익명화(Anonymize)하고, 돌아오는 길에는 복원(restore)하며, 체크섬(checksums)을 통해 오탐(false positives)을 방지하고, 서버를 절대 떠나지 않는 토큰 맵(token map)을 사용합니다. 제가 직접 만든 정규표현식(regex) 레이어 대신 이 방식을 선택하게 된 결정적인 이유는 화려하지는 않지만 실용적인 부분 때문이었습니다. 복원이 정확하며, 동일한 값은 항상 동일한 토큰에 매핑되고, 세션(session) 및 스코프(scope) 모델이 요청(request)이나 큐 작업(queued job)에 즉시 적용됩니다. 기존 채팅 루프에 결합하는 데는 단 한 번의 스프린트(sprint)가 아닌, 오후 시간 정도면 충분했습니다.
📌 Inis 법률 챗봇을 통해 Laranon이 실제로 작동하는 모습을 확인할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기