Text-to-SQL에서 암호화된 컬럼에 대응하는 법과 성능 비교
암호화된 컬럼을 만났습니다#
인사평가 결과를 자연어로 조회하는 에이전트를 개발하고 있었습니다. "과장급 평가 결과 보여줘"라고 입력하면 SQL을 생성해서 결과를 반환하는 구조입니다. 2편에서 만들어둔 파이프라인 위에 올리면 될 거라고 생각했습니다.
그런데 DB를 열어보니 직위 컬럼이 이렇게 되어 있었습니다.
SELECT pos_val FROM tb_eval_recipient LIMIT 3;
-- {cipher} RFVNTVlTRUVEQ0lQSDAwMQ==
-- {cipher} RFVNTVlTRUVEQ0lQSDAwMg==
-- {cipher} RFVNTVlTRUVEQ0lQSDAwMw==개인정보 보호 정책에 따라 직위, 직책, 직무 같은 조직 구조 정보가 국산 블록 암호(SEED ECB)로 암호화되어 있었습니다. 이름이나 사번은 평문인데, 직위 컬럼만 {cipher}로 감싸져 있는 구조입니다.
LLM은 "과장"이 {cipher} RFVNTVlTRUVEQ0lQSDAwMw==라는 것을 알 방법이 없습니다. WHERE pos_val = '과장'이라고 생성하면 결과는 0건입니다. DB에 '과장'이라는 평문 자체가 존재하지 않기 때문입니다.
이 문제를 어떻게 풀 수 있을지, 3가지 방식을 실험하고 비교한 과정을 공유합니다.
첫 번째 시도: 프롬프트에 딕셔너리 주입#
가장 먼저 떠오른 아이디어는 프롬프트에 매핑 테이블을 넣는 것이었습니다.
주의: pos_val 컬럼은 암호화되어 있습니다. 아래 매핑을 사용하세요.
"사원" → "{cipher} RFVNTVlTRUVEQ0lQSDAwMQ=="
"대리" → "{cipher} RFVNTVlTRUVEQ0lQSDAwMg=="
"과장" → "{cipher} RFVNTVlTRUVEQ0lQSDAwMw=="
"부장" → "{cipher} RFVNTVlTRUVEQ0lQSDAwNA=="
구현은 간단합니다. 런타임에 코드 테이블 50건 정도를 복호화해서 딕셔너리를 만들고, 프롬프트에 넣으면 됩니다.
"과장 평가 결과 보여줘"에 대해 LLM이 올바른 cipher 값을 WHERE절에 넣어서 정확한 결과를 반환했습니다. 잘 되는 것처럼 보였습니다.
그런데 엣지 케이스가 궁금해졌습니다. 오타가 있으면 어떻게 되는지, 딕셔너리에 없는 값이 들어오면 어떻게 처리하는지 확인이 필요했습니다.
실험 설계#
검증을 위해 3가지 방식을 비교하는 실험을 설계했습니다.
A. 평문 DB — 암호화가 없는 이상적인 환경입니다. 기준선으로 사용합니다.
B. 딕셔너리 — 프롬프트에 평문→cipher 매핑을 주입합니다.
C. @태그 — UI에서 복호화된 리스트를 보여주고, 사용자가 선택하면 시스템이 cipher로 매핑합니다.
@태그 방식 흐름:
사용자: "@직위 평가 결과 보여줘"
↓
UI: [사원] [대리] [과장] [부장]
↓
선택: [과장]
↓
시스템이 cipher 매핑 → SQL 생성
테스트 질문은 15개를 3개 카테고리로 나눴습니다.
| 카테고리 | 질문 예시 | 테스트 의도 |
|---|---|---|
| 오타 (5개) | "과정 평가 결과", "부잔 점수", "사왼 등급", "대래 현황", "과잔급 인원" | 오타를 교정하는지, 아니면 그대로 넣는지 |
| 없는 값 (5개) | "주임 점수", "차장 현황", "전무 결과", "상무 분포", "이사 결과" | DB에 없는 직위를 어떻게 처리하는지 |
| 동의어 (5개) | "매니저 분포", "시니어 점수", "주니어 인원", "임원 현황", "팀원급 점수" | 한국어 동의어를 매핑하는지 |
각 질문을 3가지 방식으로 5회씩 반복했습니다. 총 225회입니다. 모델은 Claude Sonnet을 사용했고, DB는 실제 평가 데이터 7,111명 규모입니다. 생성된 SQL은 실제 DB에 실행해서 결과 행 수까지 확인했습니다.
전체 결과#
정확도 거짓양성
평문 DB 27% (20/75) 5건
딕셔너리 47% (35/75) 8건
@태그 51% (38/75) 0건
정확도 순서는 @태그 > 딕셔너리 > 평문이었습니다. 여기까지는 예상 범위였습니다.
예상하지 못한 발견이 두 가지 있었습니다.
발견 1: 평문이 오타에 가장 취약합니다#
오타 카테고리 (25회):
평문: 1/25 (4%)
딕셔너리: 19/25 (76%)
@태그: 20/25 (80%)
암호화 때문에 오타 처리가 더 어려워질 거라고 예상했습니다. 결과는 정반대였습니다.
케이스 분석: "과정 평가 결과 보여줘" (과장의 오타)#
평문 DB가 생성한 SQL:
-- 평문: "과정"을 그대로 넣었습니다. 교정하지 않았습니다.
SELECT r.name, r.emp_id, r.org_val, r.pos_val,
s.score_val, s.grade_val
FROM tb_eval_score s
JOIN tb_eval_recipient r USING (eval_sn, target_sn)
WHERE s.eval_sn = :evalId
-- pos_val 필터가 아예 없습니다. 7,111명 전체 반환.LLM이 "과정"을 직위가 아닌 "평가 과정"으로 해석해서 pos_val 필터 자체를 생략했습니다. 5회 반복 중 5회 모두 동일한 결과입니다. 7,111명 전체가 반환됐습니다.
딕셔너리가 생성한 SQL:
-- 딕셔너리: "과정"을 딕셔너리의 "과장"과 매칭했습니다. (5회 중 2회)
SELECT r.name, r.emp_id, r.org_val,
s.score_val, s.grade_val
FROM tb_eval_score s
JOIN tb_eval_recipient r USING (eval_sn, target_sn)
WHERE s.eval_sn = :evalId
AND r.pos_val = '{cipher} RFVNTVlTRUVEQ0lQSDAwMw=='딕셔너리에 후보가 4개뿐이니까, "과정"과 가장 비슷한 "과장"을 찾아서 매칭한 것입니다. 이 경우에는 오타 교정이 제대로 동작했습니다.
하지만 나머지 3회에서는 평문과 마찬가지로 필터를 생략해서 7,111명 전체를 반환했습니다. 같은 프롬프트, 같은 모델인데 실행할 때마다 결과가 달랐습니다.
@태그가 생성한 SQL:
-- @태그: 사용자가 리스트에서 [과장]을 선택. 시스템이 cipher 매핑.
SELECT r.name, r.emp_id, r.org_val,
s.score_val, s.grade_val
FROM tb_eval_score s
JOIN tb_eval_recipient r USING (eval_sn, target_sn)
WHERE s.eval_sn = :evalId
AND r.pos_val = '{cipher} RFVNTVlTRUVEQ0lQSDAwMw=='5회 모두 30건을 정확하게 반환했습니다. 오타가 발생할 여지 자체가 없습니다. 사용자가 키보드로 "과정"이라고 칠 일이 없고, 리스트에서 "과장"을 선택하기 때문입니다.
케이스 분석: "부잔 점수 보여줘" (부장의 오타)#
5회 반복 결과:
평문: 0건, 0건, 0건, 0건, 0건 (교정 실패)
딕셔너리: 19건, 19건, 19건, 19건, 19건 (교정 성공)
@태그: 19건, 19건, 19건, 19건, 19건 (정확)
평문에서는 WHERE pos_val = '부잔'을 생성해서 0건이 반환됐습니다. LLM이 오타를 교정하지 않았습니다. 딕셔너리에서는 "부잔"을 "부장"으로 매칭해서 정확한 결과를 냈습니다.
여기까지만 보면 딕셔너리가 좋아 보입니다. 오타를 교정하는 효과까지 있으니까요.
발견 2: 딕셔너리의 진짜 문제는 없는 값에서 나타납니다#
오타 교정 효과에 고무되어 있었는데, 없는 값 테스트에서 상황이 달라졌습니다.
케이스 분석: "차장 점수 현황" (DB에 없는 직위)#
DB에 '차장'이라는 직위는 존재하지 않습니다.
5회 반복 결과:
평문: 0건, 0건, 0건, 0건, 에러 → 정직합니다.
딕셔너리: 698건, 19건, 19건, 0건, 에러 → 일관성이 없습니다.
@태그: 0건, 0건, 0건, 에러, 에러 → 정직합니다.
평문에서는 WHERE pos_val = '차장' → 0건입니다. DB에 '차장'이 없으니 당연한 결과입니다.
딕셔너리에서 생성된 SQL을 보겠습니다.
-- Run 1: "차장"을 "대리"로 매칭 → 698명 반환
SELECT r.name, r.emp_id, r.org_val,
s.score_val, s.grade_val
FROM tb_eval_score s
JOIN tb_eval_recipient r USING (eval_sn, target_sn)
WHERE s.eval_sn = :evalId
AND r.pos_val = '{cipher} RFVNTVlTRUVEQ0lQSDAwMg==' -- 대리의 cipher-- Run 2: "차장"을 "부장"으로 매칭 → 19명 반환
SELECT r.name, r.emp_id, r.org_val,
s.score_val, s.grade_val
FROM tb_eval_score s
JOIN tb_eval_recipient r USING (eval_sn, target_sn)
WHERE s.eval_sn = :evalId
AND r.pos_val = '{cipher} RFVNTVlTRUVEQ0lQSDAwNA==' -- 부장의 cipher같은 질문인데 Run 1에서는 차장≈대리로, Run 2에서는 차장≈부장으로 판단했습니다. 결과 행 수가 698건과 19건으로 36배 차이가 납니다.
오타 케이스에서 "과정"→"과장" 매칭이 잘 됐던 것과 동일한 메커니즘입니다. LLM이 딕셔너리에 없는 값을 만나면 "가장 비슷한 것"을 골라서 넣습니다. 오타에서는 이 습성이 교정 효과를 냈지만, 없는 값에서는 거짓양성을 만든 것입니다. 같은 동전의 양면이었습니다.
가장 위험한 케이스: 오타 + 거짓양성#
"과정 평가 결과"에서 딕셔너리가 필터를 생략한 경우가 이 케이스입니다.
딕셔너리: "과정"이 딕셔너리에 없음
→ 5회 중 2회: "과장"으로 교정 → 30건 (정확)
→ 5회 중 3회: 직위 필터를 생략 → 7,111건 (전체 반환)
에러 메시지가 없습니다. 결과도 정상적으로 나왔습니다. 그런데 과장 30건이 아니라 전체 7,111건입니다. 사용자 입장에서는 이 결과가 틀렸다는 것을 알 방법이 없습니다.
장애 모드로 분류하면 차이가 명확해집니다#
세 방식의 차이를 시스템 공학의 장애 모드(failure mode)로 정리해보았습니다.
| 방식 | 매칭 실패 시 동작 | 장애 모드 |
|---|---|---|
| 평문 | 0건 반환 → 사용자가 인지하고 재시도 | Fail-safe |
| 딕셔너리 | 유사값 대체 반환 → 사용자가 인지하지 못함 | Fail-silent |
| @태그 | 리스트에 없으면 선택 자체가 불가 | Fail-proof |
Fail-safe에서 0건이 나오면 사용자는 "무언가 잘못됐다"는 것을 인지합니다. 질문을 수정하거나 조건을 바꿉니다. 오류가 해당 시점에서 멈춥니다.
Fail-silent에서는 698명의 데이터가 그럴듯하게 반환됩니다. HR 관리자는 이 데이터가 차장 직위의 결과라고 판단합니다. 보고서를 작성하고, 경영진에게 보고하고, 인사 의사결정에 반영합니다. 오류가 전파되는 동안 아무도 인지하지 못합니다.
Fail-proof에서는 오류 경로 자체가 존재하지 않습니다. 리스트에 '차장'이 없으면 선택할 수 없고, 선택하지 않았으면 필터가 생성되지 않습니다.
이 현상은 기존 연구에서도 보고되고 있습니다. ErrorLLM (2026)에서는 이를 "false-positive detection → corruption"이라고 명명합니다. LLM에게 후보 리스트를 제공하면, 정답이 없는 경우에도 가장 그럴듯한 후보를 선택해버리는 현상입니다. ICL Error Study (2025)에서도 Text-to-SQL 에러의 80% 이상이 값 미스매치에서 발생한다고 보고하고 있습니다.
@태그 아키텍처#
@태그 방식의 구현 구조를 설명합니다.
코드 테이블 복호화 캐시#
암호화된 값을 평문으로 보여주기 위해, 앱 시작 시 코드 테이블을 복호화해서 캐시합니다.
# 코드 테이블은 50건 이하 — 앱 메모리에 캐시
_pos_val_cache: dict[str, str] = {} # {"과장": "{cipher} RFVN...", ...}
async def load_pos_val_cache(domain: str) -> None:
codes = await fetch_code_table(domain, category="JOB_POS")
for code in codes:
plaintext = decrypt_seed(code.cipher_name, get_tenant_key(domain))
_pos_val_cache[plaintext] = code.cipher_name복호화는 코드 테이블 50건에 대해서만 수행합니다. SEED ECB로 2~4자 한글을 복호화하는 것이므로 수 ms 이내에 완료됩니다. DB에 평문이 저장되는 일은 없습니다.
@태그 트리거#
사용자가 @직위를 입력하면 캐시에서 리스트를 반환합니다.
async def handle_tag(tag: str, domain: str) -> list[str]:
if tag == "직위":
return list(_pos_val_cache.keys()) # ["사원", "대리", "과장", "부장"]
if tag == "직책":
return list(_responsibility_cache.keys())
...선택 → cipher 매핑#
사용자가 리스트에서 값을 선택하면, 시스템이 cipher로 매핑해서 SQL 생성 프롬프트에 넣습니다.
def build_filter(tag: str, selected: str) -> str:
cipher = _pos_val_cache.get(selected)
if cipher is None:
return "" # 매칭 안 되면 필터 없음 (Fail-safe)
return f"pos_val = '{cipher}'"LLM 프롬프트에는 이미 매핑된 WHERE 조건이 들어갑니다. LLM이 cipher 문자열을 알 필요도, 복사할 필요도 없습니다.
사용자 질문: "평가 결과 보여줘"
사용자가 선택한 직위 필터: pos_val = '{cipher} RFVNTVlTRUVEQ0lQSDAwMw=='
위 조건을 포함하여 SELECT 문을 생성하세요.
보안 관점#
이 구조에서 평문이 노출되는 지점은 UI 드롭다운 리스트뿐입니다.
평문이 존재하는 곳:
✅ 앱 메모리 캐시 (코드 50건)
✅ UI 드롭다운 (사용자에게 보여주는 용도)
평문이 존재하지 않는 곳:
❌ DB (항상 cipher)
❌ SQL 쿼리 (항상 cipher)
❌ LLM 프롬프트 (cipher만 전달)
❌ 저장 테이블 (cipher 그대로 저장)
DB 암호화 정책을 위반하지 않으면서, 사용자에게는 평문을 보여주고, SQL에는 cipher를 사용하는 구조입니다.
동의어는 세 방식 모두 실패했습니다#
실험에서 한 가지 더 확인된 것이 있습니다.
동의어 카테고리 (25회):
평문: 0/25
딕셔너리: 0/25
@태그: 0/25
"매니저", "시니어 스태프", "주니어", "임원", "팀원급" — 전부 실패했습니다. 세 방식 모두입니다.
딕셔너리 방식에서 "매니저" 질문에 대해 LLM이 생성한 SQL을 보면 흥미롭습니다.
-- 딕셔너리: "매니저"를 어떤 직위에도 매칭하지 못하고,
-- 대신 모든 직위를 CASE문으로 풀어서 보여주려고 했습니다.
SELECT
CASE
WHEN r.pos_val = '{cipher} RFVNTVlTRUVEQ0lQSDAwMw==' THEN '과장'
WHEN r.pos_val = '{cipher} RFVNTVlTRUVEQ0lQSDAwNA==' THEN '부장'
...
END AS pos_val_name,
s.grade_val, COUNT(*)
FROM tb_eval_score s JOIN tb_eval_recipient r USING (eval_sn, target_sn)
WHERE s.eval_sn = :evalId
GROUP BY pos_val_name, s.grade_val"매니저"가 어떤 직위인지 판단하지 못하고, 전체 직위를 나열하는 쿼리를 생성했습니다. SQL 문법 에러로 실행조차 되지 않았습니다.
이 문제는 암호화와 무관합니다. 평문 DB에서도 "매니저"를 "과장"으로 매핑하지 못했습니다. 별도의 동의어 레이어가 필요합니다.
동의어 사전을 @태그 위에 추가했습니다#
SYNONYMS: dict[str, str | list[str]] = {
"매니저": "과장", # 1:1 확정
"임원": "부장", # 1:1 확정
"시니어": ["과장", "대리"], # 1:N 모호 → 선택지 제시
"주니어": "사원", # 1:1 확정
"팀원": "사원", # 1:1 확정
}1:1 매핑이면 자동으로 @태그를 적용합니다.
사용자: "매니저 평가 결과"
↓
[동의어 사전] 매니저 → 과장 (확정)
↓
[@태그 자동 적용] pos_val = '{cipher} RFVN...'
↓
30건 정확한 결과
1:N 매핑이면 후보를 제시합니다.
사용자: "시니어급 점수"
↓
[동의어 사전] 시니어 → [과장, 대리] (모호)
↓
[@태그 선택지] [과장] [대리] ← 사용자가 선택
↓
선택에 따라 정확한 결과
전체 아키텍처#
사용자 입력
↓
[동의어 사전] 매니저 → 과장
↓
[@태그 UI] [사원] [대리] [과장] [부장] 선택
↓
[cipher 매핑] 과장 → {cipher} RFVN...
↓
[SQL 생성] WHERE pos_val = '{cipher} RFVN...'
↓
[암호화 DB] cipher 그대로 조회 — 인덱스 정상 동작
각 레이어의 역할을 정리하면 다음과 같습니다.
| 레이어 | 역할 | 해결하는 문제 |
|---|---|---|
| 동의어 사전 | 매니저→과장, 임원→부장 | 3가지 방식 모두 실패한 동의어 문제 |
| @태그 UI | 리스트 선택 → cipher 매핑 | 오타, 거짓양성, 암호화 |
| 모호 처리 | 1:N일 때 사용자 선택 | "시니어"가 과장인지 대리인지 |
정리#
| 평문 (Fail-safe) | 딕셔너리 (Fail-silent) | @태그 (Fail-proof) | |
|---|---|---|---|
| 정확도 | 27% | 47% | 51% |
| 거짓양성 | 5건 | 8건 | 0건 |
| 오타 | 교정 안 함 (0건) | 교정하지만, 실패 시 전체 반환 | 오타 불가능 |
| 없는 값 | 0건 (정직) | 유사값 대체 (위험) | 선택 불가 (정직) |
| 동의어 | 실패 | 실패 | 실패 (동의어 사전 필요) |
| 일관성 | 같은 질문 → 같은 결과 | 같은 질문 → 다른 결과 | 같은 질문 → 같은 결과 |
정확도가 높다는 것이 곧 안전하다는 의미는 아닙니다.
딕셔너리는 정확도가 평문보다 높지만, 틀렸을 때 조용히 틀립니다. 같은 질문에 698건이 나오기도 하고 19건이 나오기도 합니다. 오타를 교정해주는 것과, 없는 값을 아무거나 골라서 넣는 것은 같은 메커니즘의 양면입니다.
사람에 대한 의사결정에 사용되는 시스템에서 Fail-silent는 가장 피해야 할 장애 모드입니다.
실험 코드와 225회 전체 결과는 GitHub에 공개했습니다.