dev notes

Text-to-SQL에서 암호화된 컬럼을 다루는 방법

2026-04-2423 min read
공유

인사평가 결과를 자연어로 조회하는 Text-to-SQL 기능을 개발하고 있었습니다.

예를 들어 사용자가

과장급 평가 결과 보여줘

라고 입력하면 LLM이 SQL을 만들고, 실제 평가 데이터에서 결과를 조회하는 구조입니다.

기존에 만들어둔 Text-to-SQL 파이프라인을 레거시 시스템의 평가 DB에 연결하려고 했는데, 직위·직책·직무처럼 일부 조직 정보가 암호화되어 있어 LLM이 인식하지 못한다는 문제가 있었습니다. 기존 DB 스키마와 암호화 정책은 그대로 유지해야 했습니다.

sql
SELECT pos_val
FROM tb_eval_recipient
LIMIT 3;
 
-- {cipher} RFVNTVlTRUVEQ0lQSDAwMQ==
-- {cipher} RFVNTVlTRUVEQ0lQSDAwMg==
-- {cipher} RFVNTVlTRUVEQ0lQSDAwMw==

사용자는 "과장"이라고 입력하지만 DB에는 "과장"이라는 값이 없습니다.

그래서 LLM이 자연스럽게 이런 SQL을 만들면

sql
WHERE pos_val = '과장'

실행은 되지만 결과는 0건이기에 사용자가 입력하는 평문과 DB에 저장된 cipher를 어디선가 연결해줘야 했습니다.

프롬프트에 암호화 키밸류 딕셔너리를 넣었다#

가장 먼저 해본 건 단순했습니다.

평문과 cipher의 매핑을 프롬프트에 같이 넣었습니다.

text
pos_val 컬럼은 암호화되어 있습니다.
 
사원 → {cipher} RFVNTVlTRUVEQ0lQSDAwMQ==
대리 → {cipher} RFVNTVlTRUVEQ0lQSDAwMg==
과장 → {cipher} RFVNTVlTRUVEQ0lQSDAwMw==
부장 → {cipher} RFVNTVlTRUVEQ0lQSDAwNA==

애플리케이션에서 코드 테이블을 복호화해 평문 → cipher 매핑을 만들고, 그걸 SQL 생성 프롬프트에 넣는 방식입니다.

정확한 값을 입력했을 때는 잘 동작했습니다.

text
사용자: 과장 평가 결과 보여줘

LLM: 과장에 해당하는 cipher 선택

WHERE pos_val = '{cipher} ...'

다만 실제 자연어 입력에서는 정확한 코드값만 들어오지는 않습니다. "과장""과정"으로 잘못 입력할 수도 있고, 우리 DB에는 없는 "차장"을 요청할 수도 있습니다.

LLM 자체도 문맥을 기반으로 이런 표현을 어느 정도 보정할 수 있습니다. 오히려 궁금했던 것은 평문→cipher 딕셔너리라는 제한된 후보를 같이 제공했을 때 LLM의 판단이 어떻게 달라지는지였습니다.

"과정"처럼 명백한 오타는 "과장"으로 잘 보정할 수 있는지, 반대로 "차장"처럼 실제 후보에 없는 값까지 가장 비슷한 직위로 임의 매핑하지는 않는지 확인해 보기로 했습니다.

세 가지 방식을 비교해봤다#

딕셔너리를 넣는 방식이 실제로 더 나은지 확인하려면 비교 기준이 필요했습니다. 그래서 암호화가 없다고 가정한 평문 방식과, 프롬프트 딕셔너리 방식, 값 선택을 애플리케이션으로 넘긴 @태그 방식을 함께 비교했습니다.

A. 평문#

암호화가 없는 경우를 기준선으로 두었습니다.

sql
WHERE pos_val = '과장'

LLM이 사용자의 표현을 그대로 SQL 조건으로 만들거나, 문맥을 보고 오타나 유사 표현을 자체적으로 보정하는 방식입니다.

B. 딕셔너리#

프롬프트에 평문과 cipher 매핑을 함께 제공합니다.

text
과장 → {cipher} ...
부장 → {cipher} ...

LLM은 사용자 입력을 보고 제공된 후보 중 적절한 값을 골라 SQL 조건에 사용합니다.

여기서는 딕셔너리가 오타 보정에 실제로 도움이 되는지와, 반대로 후보에 없는 값까지 억지로 기존 값에 매핑하는 경우가 생기는지를 같이 확인하고 싶었습니다.

C. @태그#

직위 값의 선택 자체를 LLM에게 맡기지 않고, Agent 실행 전에 애플리케이션의 코드 조회 단계에서 확정하는 방식입니다.

사용자가 @직위를 입력하면 실제 DB에 존재하는 직위 목록을 보여주고, 그중 하나를 선택하면 애플리케이션에서 해당 값을 cipher로 변환합니다.

Loading diagram...

테스트 질문은 세 종류로 나눴습니다.

유형질문 예시확인한 내용
오타과정, 부잔, 사왼LLM이 입력 오류를 어떻게 보정하는지
없는 값차장, 전무, 상무실제 후보에 없는 값을 임의로 매핑하는지
동의어매니저, 시니어, 주니어다른 표현을 실제 직위 값과 연결할 수 있는지

각 질문은 세 방식에서 5회씩 반복했고, 총 225회 실행했습니다.

생성된 SQL만 비교하지 않고 실제 7,111명 규모의 평가 데이터에 실행해 결과 행 수까지 함께 확인했습니다. 같은 SQL이 실행 가능한지뿐 아니라, 사용자가 의도한 조건과 실제 조회 결과가 일치하는지를 기준으로 비교했습니다.

결과#

전체 결과는 다음과 같았습니다.

방식정확도의도와 다른 결과가 정상 반환된 횟수
평문27% (20/75)5건
딕셔너리47% (35/75)8건
@태그51% (38/75)0건

정확도만 보면 @태그가 가장 높았고, 딕셔너리도 평문보다 꽤 나은 결과를 보였습니다.

그런데 생성된 SQL과 실제 조회 결과를 하나씩 확인해 보니 정확도만으로는 잘 드러나지 않는 차이가 있었습니다.

특히 딕셔너리는 오타에는 도움이 됐지만, 실제로 존재하지 않는 값을 만났을 때는 오히려 잘못된 값을 만들어내는 경우가 있었습니다.

딕셔너리는 오타를 보정하는 데 도움이 됐다#

오타 질문만 따로 보면 차이가 꽤 컸습니다.

방식오타 정확도
평문1/25 (4%)
딕셔너리19/25 (76%)
@태그20/25 (80%)

예를 들어 "부잔 점수 보여줘"를 5회 실행했을 때 결과는 다음과 같았습니다.

text
평문       0건 / 0건 / 0건 / 0건 / 0건
딕셔너리  19건 / 19건 / 19건 / 19건 / 19건
@태그      19건 / 19건 / 19건 / 19건 / 19건

평문 방식에서는 "부잔"을 그대로 SQL 조건에 넣었습니다.

sql
WHERE pos_val = '부잔'

반면 딕셔너리를 제공한 경우에는 후보 중 "부장"을 선택했습니다.

sql
WHERE pos_val = '{cipher} ...' -- 부장

LLM 자체도 문맥을 보고 오타를 보정할 수 있지만, 실제 직위 후보를 함께 제공하니 선택 범위가 좁아지면서 보정이 훨씬 안정적으로 동작했습니다.

"과정" 역시 일부 실행에서는 "과장"으로 제대로 보정됐습니다.

여기까지만 보면 딕셔너리를 프롬프트에 넣는 것으로 충분해 보였습니다.

없는 값을 넣었을 때는 상황이 달랐다#

다음으로 DB에 실제로 존재하지 않는 값을 넣어봤습니다.

예를 들어 "차장"은 해당 평가 데이터에 존재하지 않는 직위였습니다.

"차장 점수 현황"을 5회 실행한 결과입니다.

text
평문       0건 / 0건 / 0건 / 0건 / 에러
딕셔너리  698건 / 19건 / 19건 / 0건 / 에러
@태그      0건 / 0건 / 0건 / 에러 / 에러

딕셔너리 방식에서는 같은 질문인데도 실행마다 판단이 달랐습니다.

한 번은 "차장""대리"로 해석했습니다.

sql
WHERE r.pos_val = '{cipher} ...' -- 대리

결과는 698명이었습니다.

다른 실행에서는 "부장"으로 해석했습니다.

sql
WHERE r.pos_val = '{cipher} ...' -- 부장

이번에는 19명이 반환됐습니다.

오타에서는 유용했던 동작이 그대로 문제의 원인이 된 셈입니다.

text
입력값이 후보에 없음

후보 중 비슷한 값을 찾음

"부잔"처럼 실제 값의 오타라면 "부장"을 고르는 것이 맞습니다.

하지만 "차장"처럼 실제 후보에 없는 값이라면 어느 후보를 골라도 틀린 답입니다.

LLM 입장에서는 두 경우를 완전히 구분하기 어렵고, 같은 “유사한 후보 선택”이 한쪽에서는 교정으로, 다른 쪽에서는 잘못된 값 선택으로 나타났습니다.

필터를 아예 빼버리는 경우도 있었다#

더 신경 쓰였던 것은 SQL 에러가 아니라 정상적인 결과처럼 보이는 오답이었습니다.

"과정 평가 결과"를 딕셔너리 방식으로 5번 실행했을 때 2번은 "과장"으로 보정해 정확한 30건을 반환했습니다.

그런데 나머지 3번은 직위 조건 자체를 생략했습니다.

sql
WHERE s.eval_sn = :evalId
-- pos_val 조건 없음

이 경우에는 과장 30명이 아니라 전체 평가 대상인 7,111명이 반환됐습니다.

Loading diagram...

API는 200으로 응답했고 SQL도 문제없이 실행됐습니다. 기존 검증 단계만 놓고 보면 모두 통과할 수 있는 쿼리였습니다.

검증결과
SQL 문법정상
테이블·컬럼 존재 여부정상
EXPLAIN 실행정상
사용자가 요청한 직위 조건 유지실패

0건이나 SQL 에러라면 사용자가 질문을 다시 확인할 수 있습니다. 반면 조건이 빠진 SQL은 더 넓은 데이터를 정상 결과처럼 반환하므로 잘못됐다는 사실을 알아채기 어렵습니다.

이 사례는 Self-Correction에서 다룬 문법·스키마·실행 검증만으로는 잡기 어려운 영역이었습니다. SQL의 실행 가능 여부와 별도로, 사용자가 확정한 조건이 최종 SQL에 남아 있는지도 확인할 필요가 있었습니다.

직위 값 선택은 LLM 밖으로 뺐다#

결과를 보고 나서는 직위처럼 실제 후보가 정해져 있는 값까지 LLM이 추론하도록 둘 필요가 없다고 판단했습니다.

사용자가 실제로 존재하는 값 중 하나를 선택하고, 애플리케이션이 그 값을 cipher로 변환한 뒤 SQL 생성 단계에 넘기도록 바꿨습니다.

Loading diagram...

기존 Agent 파이프라인을 바꾼 것은 아닙니다. Intent Agent와 Table Agent가 질문의 의도와 관련 테이블을 찾고, SQL Agent가 쿼리를 구성하는 흐름은 유지했습니다. 그 앞에 실제 코드값을 확인하는 단계를 추가했습니다.

이 구조에서는 LLM이 "과장"이 어떤 cipher인지 추론하거나, "차장""대리""부장" 중 하나로 고를 일이 없습니다.

애플리케이션에서 이미 확정한 조건만 SQL 생성 컨텍스트에 전달합니다.

text
사용자 질문:
평가 결과 보여줘
 
확정된 조건:
pos_val = '{cipher} RFVNTVlTRUVEQ0lQSDAwMw=='

기존에는 LLM이

text
자연어 값 해석
→ 실제 직위 추론
→ cipher 선택
→ SQL 생성

까지 맡았다면, 변경 후에는

text
확정된 조건 전달
→ SQL 생성

으로 역할을 줄였습니다.

직위 목록과 cipher 매핑은 애플리케이션에서 관리했다#

직위와 직책처럼 후보 수가 많지 않은 코드성 데이터는 애플리케이션에서 복호화해 캐시했습니다.

python
_pos_val_cache: dict[str, str] = {}
 
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

사용자가 @직위를 입력하면 캐시된 실제 직위 목록을 보여줍니다.

python
async def handle_tag(tag: str) -> list[str]:
    if tag == "직위":
        return list(_pos_val_cache.keys())

사용자가 하나를 선택하면 애플리케이션에서 cipher로 변환합니다.

python
def resolve_position(selected: str) -> str | None:
    return _pos_val_cache.get(selected)

전체 흐름은 다음과 같습니다.

text
UI            과장

Application   과장 → {cipher} ...

LLM           확정된 조건으로 SQL 생성

DB            cipher 기준 조회

DB에는 기존과 동일하게 cipher 값이 저장되고, 실제 SQL 조건에서도 cipher를 사용합니다.

평문은 사용자가 값을 확인하고 선택하는 구간과 애플리케이션 내부의 매핑 과정에서만 사용하도록 범위를 제한했습니다.

동의어는 암호화와 별개의 문제였다#

동의어 질문도 같이 테스트했는데, 이쪽은 세 방식 모두 결과가 좋지 않았습니다.

text
평문       0/25
딕셔너리  0/25
@태그      0/25

"매니저", "시니어", "주니어" 같은 표현을 실제 직위인 "과장", "대리", "사원"과 안정적으로 연결하지 못했습니다.

중요한 것은 평문 DB에서도 동일하게 실패했다는 점이었습니다.

따라서 이 문제는 cipher를 어떻게 매핑하느냐와는 분리해서 봤습니다. 사용자가 사용하는 자연어 표현과 실제 HR 도메인의 코드값을 어떻게 연결할 것인지에 대한 별도의 문제였습니다.

현재는 확실하게 정의할 수 있는 표현만 동의어 사전에서 먼저 정규화하고 있습니다.

python
SYNONYMS = {
    "매니저": "과장",
    "주니어": "사원",
    "시니어": ["대리", "과장"],
}

하나의 실제 값으로 확정할 수 있다면 바로 변환합니다.

text
매니저 평가 결과

매니저 → 과장

과장 → cipher

SQL 생성

반대로 "시니어"처럼 여러 직위가 후보가 될 수 있다면 LLM이 임의로 하나를 정하지 않고 사용자에게 후보를 보여줍니다.

text
시니어급 평가 결과

후보: [대리] [과장]

사용자 선택

cipher 변환

이렇게 하면 동의어 처리와 암호화 값 변환을 서로 다른 단계로 관리할 수 있습니다.

적용한 구조#

최종적으로는 다음 흐름으로 정리했습니다.

Loading diagram...

자연어 표현을 실제 도메인 값으로 좁히는 단계와, 선택된 값을 cipher로 바꾸는 단계는 애플리케이션에서 처리하고 SQL Agent에는 확정된 조건을 이용한 SQL 생성을 맡겼습니다.

직위나 직책처럼 정답 후보가 명확한 데이터까지 모두 LLM에 맡기기보다, 애플리케이션에서 확정할 수 있는 부분은 먼저 정리해서 넘기는 쪽이 결과를 예측하고 검증하기 쉬웠습니다.

정리#

암호화된 직위 컬럼을 Text-to-SQL에서 처리하기 위해 평문, 프롬프트 딕셔너리, @태그 방식을 비교해보았습니다.

딕셔너리를 제공하면 오타를 보정하는 데는 확실히 도움이 됐습니다. 하지만 후보에 존재하지 않는 값이 들어오면 비슷한 직위를 대신 선택하거나 필터를 생략하면서 정상 SQL 형태의 잘못된 결과를 반환하는 경우도 있었습니다.

의도와 다른 결과가 정상 반환된 횟수는 다음과 같았습니다.

text
평문       5건
딕셔너리  8건
@태그      0건

이번 기능은 인사평가 데이터를 조회하는 용도였기 때문에 정확도 몇 %를 더 높이는 것보다, 사용자가 잘못된 조회 결과를 정상적인 데이터로 받아들이는 경우를 줄이는 쪽을 우선했습니다.

그래서 실제 값의 범위가 정해져 있는 직위·직책 같은 조건은 LLM이 직접 선택하지 않도록 하고, 애플리케이션에서 후보를 확정한 뒤 cipher로 변환해 SQL 생성에 전달했습니다.

동의어처럼 자연어 표현 자체가 애매한 문제는 암호화 처리와 섞지 않고 별도의 정규화 단계로 분리했습니다.

레거시 시스템의 스키마와 암호화 정책을 바꾸지 않고, 코드값을 확인하고 변환하는 단계를 Text-to-SQL Agent 앞에 추가하는 방식으로 연결했습니다.

읽어주셔서 감사합니다.

Connected Notes