dev notes

Text-to-SQL에서 암호화된 컬럼에 대응하는 법과 성능 비교

2026-04-2422 min read
공유

암호화된 컬럼을 만났습니다#

인사평가 결과를 자연어로 조회하는 에이전트를 개발하고 있었습니다. "과장급 평가 결과 보여줘"라고 입력하면 SQL을 생성해서 결과를 반환하는 구조입니다. 2편에서 만들어둔 파이프라인 위에 올리면 될 거라고 생각했습니다.

그런데 DB를 열어보니 직위 컬럼이 이렇게 되어 있었습니다.

sql
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:

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:

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:

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을 보겠습니다.

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
sql
-- 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% 이상이 값 미스매치에서 발생한다고 보고하고 있습니다.

@태그 아키텍처#

@태그 방식의 구현 구조를 설명합니다.

코드 테이블 복호화 캐시#

암호화된 값을 평문으로 보여주기 위해, 앱 시작 시 코드 테이블을 복호화해서 캐시합니다.

python
# 코드 테이블은 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에 평문이 저장되는 일은 없습니다.

@태그 트리거#

사용자가 @직위를 입력하면 캐시에서 리스트를 반환합니다.

python
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 생성 프롬프트에 넣습니다.

python
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을 보면 흥미롭습니다.

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에서도 "매니저"를 "과장"으로 매핑하지 못했습니다. 별도의 동의어 레이어가 필요합니다.

동의어 사전을 @태그 위에 추가했습니다#

python
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에 공개했습니다.

Connected Notes