검색 가능한 개인정보 암호화: AES-GCM과 HMAC
전화번호 같은 개인정보를 AES-256-GCM 암호문과 HMAC 검색 토큰으로 나누고 키 버전·정규화·배치 이관을 안전하게 설계하는 법을 설명한다.
이 글의 목차암호화와 검색은 서로 다른 문제였다11
회사에서 전화번호와 계좌번호 같은 개인정보를 평문 중심으로 다루는 경로를 줄이는 작업이 필요했다.
값을 암호화하는 것만으로 끝나는 일은 아니었다.
고객 문의를 처리하려면 권한이 있는 담당자가 원래 값을 확인할 수 있어야 했고, 사용자가 같은 정보를 이미 등록했는지도 찾아야 했다.
암호문을 매번 전부 복호화해 비교하면 검색 비용과 노출 범위가 함께 커진다.
복호화는 암호문을 원래 값으로 되돌리는 과정이다.
그래서 저장 역할을 세 가지로 나눴다.
- 원래 값이 필요할 때만 복원하도록 암호화와 위변조 확인을 함께 제공하는 AES-256-GCM 암호문을 저장했다.
- 정확히 같은 값인지 비교할 검색용 토큰을 별도로 저장했다.
- 나중에 암호화 키를 바꿀 수 있도록 키 버전을 함께 저장했다.
기존 데이터는 한 번에 모두 바꾸지 않고 배치로 나누어 이관했다.
배치는 많은 데이터를 정해진 묶음 단위로 처리하는 방식이다.
이 글에서는 실제 업무 기록으로 확인되는 구현 범위와, 공개용 예제에서 권장하는 보안 설계를 구분해 설명한다.
암호화와 검색은 서로 다른 문제였다
암호화는 값을 허가받지 않은 사람이 읽지 못하게 만드는 일이다.
검색은 같은 입력이 들어왔을 때 저장된 행의 위치를 빠르게 찾는 일이다.
두 요구를 암호문 컬럼 하나로 해결하려 하면 곧 막힌다.
안전한 암호화에서는 같은 평문도 매번 다른 초기화 벡터를 사용하면 다른 암호문이 나오는 것이 자연스럽다.
초기화 벡터는 같은 키와 같은 원문을 암호화할 때 결과가 반복되지 않도록 함께 사용하는 값이며 보통 IV라고 줄여 쓴다.
Node.js 공식 문서는 IV가 비밀일 필요는 없지만 예측하기 어렵고 고유해야 하며, 가능하면 암호학적으로 안전한 난수로 만들라고 설명한다.
같은 전화번호의 암호문이 매번 달라지면 WHERE encrypted_phone = ?처럼 암호문 자체를 비교해 검색할 수 없다.
반대로 검색을 위해 항상 같은 암호문을 만들면 입력의 반복 패턴이 드러나고, 안전한 IV 사용 원칙도 훼손하기 쉽다.
보관과 검색을 분리한 이유가 여기에 있다.
같은 “보호된 값”이라도 목적에 따라 저장 방법이 달라진다.
| 필요한 기능 | 후보 방식 | 예시 |
|---|---|---|
| 권한이 있는 사람이 원문을 다시 봐야 한다 | 인증된 암호화인 AES-GCM | 고객센터가 확인하는 전화번호 |
| 원문을 열지 않고 정확히 같은지 비교한다 | 비밀키를 사용하는 HMAC 검색 토큰 | 중복 가입 전화번호 확인 |
| 원문을 다시 알 필요가 없고 입력 검증만 한다 | 전용 비밀번호 해시 | 로그인 비밀번호 |
| 일부 문자열·범위·정렬이 필요하다 | 요구와 노출 위험을 별도 설계 | 전화번호 끝 네 자리, 금액 범위 |
비밀번호는 이 글의 AES-GCM 방식으로 저장하면 안 된다.
서비스가 원래 비밀번호를 복원할 이유가 없으므로, Argon2id나 scrypt처럼 비밀번호 검증을 위해 설계된 단방향 해시를 사용한다.
전화번호도 검색이 전혀 필요 없고 나중에 복원만 하면 된다면 HMAC 토큰을 추가하지 않는 편이 노출 면적과 운영 복잡도를 줄인다.
저장 필드는 “나중에 쓸 수도 있다”가 아니라 실제 필요한 기능에서 거꾸로 결정해야 한다.
AES-256-GCM 암호문이 맡은 역할
업무 기록에는 민감 필드를 AES-256-GCM으로 암호화했다고 남아 있다.
AES는 정해진 키로 데이터를 암호화하고 같은 키로 복호화하는 대칭키 암호 방식이다.
이름의 256은 키 길이가 256비트라는 뜻이고, GCM은 암호화와 데이터 위변조 확인을 함께 제공하는 동작 모드다.
GCM으로 암호화하면 암호문과 함께 인증 태그가 만들어진다.
인증 태그는 암호문이 중간에 바뀌지 않았는지 확인하는 짧은 검증값이다.
Node.js의 cipher.getAuthTag()는 암호화가 끝난 뒤 이 태그를 반환한다.
복호화할 때 태그가 없거나 암호문이 변조됐다면 decipher.final()이 오류를 던지므로, 그 데이터를 버리고 실패로 처리해야 한다.
실제 저장에는 암호문만 덩그러니 남겨서는 안 된다.
복호화에 필요한 IV와 인증 태그, 어떤 키를 사용했는지 나타내는 버전을 함께 관리해야 한다.
키 자체는 데이터베이스의 같은 행에 저장하지 않는다.
키와 암호문을 한 장소에 두면 데이터베이스 접근 권한 하나가 뚫렸을 때 둘이 함께 노출될 수 있기 때문이다.
키는 별도의 비밀 관리 시스템에서 버전으로 찾아 쓰는 편이 안전하다.
검색용 토큰은 복호화 대신 비교한다
검색용 토큰은 같은 입력에서 같은 결과를 만들어야 한다.
전화번호처럼 가능한 값의 범위가 비교적 좁은 데이터에 일반 해시만 사용하면 공격자가 흔한 값을 미리 계산해 대조하기 쉽다.
해시는 입력을 고정 길이 결과로 바꾸는 단방향 함수이며, 결과에서 원문을 직접 되돌릴 수 없다는 특성이 있다.
하지만 “되돌리기 어렵다”와 “추측하기 어렵다”는 같은 뜻이 아니다.
공개용 구현 예시에서는 비밀키를 함께 사용하는 HMAC을 검색 토큰으로 사용한다.
HMAC은 해시 기반 메시지 인증 코드의 약자로, 입력뿐 아니라 서버가 가진 비밀키가 있어야 같은 결과를 만들 수 있다.
업무 기록에는 “검색용 해시”라고만 남아 있어 운영 구현의 세부 알고리즘과 키 관리 방식은 공개하지 않는다.
아래 코드는 실제 회사 코드를 옮긴 것이 아니라 Node.js 공식 createHmac() API를 바탕으로 구조를 단순화한 예시다.
import { createHmac } from 'node:crypto';
function normalizePhone(value: string) {
return value.replace(/\D/g, '');
}
function createPhoneSearchToken(value: string, searchKey: Buffer) {
const normalized = normalizePhone(value);
return createHmac('sha256', searchKey)
.update(normalized, 'utf8')
.digest('hex');
}replace(/\D/g, '')는 숫자가 아닌 문자를 제거해 010-1234-5678과 01012345678을 같은 입력으로 맞춘다.
정규화 규칙이 저장할 때와 검색할 때 다르면 같은 사람의 값도 서로 다른 토큰이 되어 찾을 수 없다.
sha256은 HMAC 결과를 만들 해시 알고리즘으로 SHA-256을 선택하며, 여기서 원문을 복원하는 암호화 역할을 하지는 않는다.
digest('hex')는 바이트 결과를 데이터베이스에 저장하기 쉬운 16진수 문자열로 바꾼다.
HMAC 키는 AES 암호화 키와 분리하는 편이 좋다.
키를 분리하면 검색 기능의 권한과 복호화 기능의 권한을 따로 제한하고, 한 키를 교체할 때 다른 기능에 미치는 영향을 줄일 수 있다.
검색 토큰도 개인정보에서 파생된 값이므로 로그, URL, 분석 도구에 함부로 남기지 않는다.
HMAC을 썼다고 토큰이 익명 데이터가 되는 것은 아니다.
전화번호처럼 가능한 입력을 많이 추측할 수 있는 값은 검색 토큰과 HMAC 키가 함께 유출되면 후보를 대입해 원문을 맞춰 볼 수 있다.
검색 키도 비밀 관리 시스템에 두고 접근 주체를 제한하며 버전을 관리해야 한다.
키를 교체할 때는 새 키로 만든 토큰을 별도 컬럼에 채우고, 읽기 경로가 이전·새 버전을 함께 찾는 기간을 둔 뒤 전환하는 방식이 필요할 수 있다.
암호화 키 회전과 달리 HMAC 키 회전은 같은 입력의 검색 토큰 자체를 바꾸므로, 저장된 토큰을 다시 계산하는 이관과 조회 전환을 함께 계획해야 한다.
저장 직전에 평문을 제거했다
민감정보가 DTO나 입력 객체에 오래 남아 있으면 이후 로깅이나 예외 처리 과정에서 노출될 가능성이 커진다.
DTO는 네트워크나 함수 사이에서 데이터를 전달하기 위해 만든 객체를 뜻한다.
당시 구현은 생성 경로를 공통 서비스로 모으고, 암호문과 검색용 토큰을 만든 뒤 평문 필드를 저장 입력에서 제거하는 방향이었다.
async function protectBankAccount(input: UserInput) {
const plain = input.bankAccount?.trim();
if (!plain) {
return input;
}
input.bankAccountEncrypted =
await secrets.encryptBankAccount(plain);
input.bankAccountSearchToken =
await secrets.createBankAccountSearchToken(plain);
input.encryptionKeyVersion = 1;
delete input.bankAccount;
return input;
}예시의 encryptBankAccount()는 내부에서 암호문, IV, 인증 태그를 안전한 형식으로 묶어 반환한다고 가정한다.
encryptionKeyVersion = 1은 암호화 강도를 나타내는 숫자가 아니라 어떤 키로 암호화했는지 찾기 위한 버전이다.
키를 새 버전으로 교체해도 기존 행은 각자 저장된 버전에 맞는 키로 읽을 수 있다.
이 과정을 키 회전이라고 부른다.
공통 서비스로 모았다고 모든 경로가 자동으로 안전해지는 것은 아니다.
업무 기록에도 초기 구현 뒤 누락된 생성 경로를 추가로 보완한 Git 이력이 남아 있다.
정상적인 회원 가입만 테스트하지 말고 관리자 수정, 일괄 업로드, 복구 작업처럼 같은 데이터를 쓰는 모든 경로를 찾아야 한다.
실제 적용 범위와 당시 배치 값
내부 업무 기록으로 확인되는 범위는 다음과 같다.
- 4개 스키마의 7개 민감 필드에 암호화 구조를 적용했다.
- 변경은 15개 파일과 20개 메서드에 걸쳐 있었다.
- 기존 데이터 배치 크기는 2,000건으로 기록됐다.
- 병렬 처리 값은 500으로 기록됐다.
- 재시도할 수 있는 배치 처리 경로를 함께 구현했다.
2,000건과 병렬 처리 값 500은 당시 시스템에서 사용한 값이지, 다른 환경에 그대로 적용할 권장값이 아니다.
병렬 처리는 여러 작업을 동시에 실행해 전체 시간을 줄이는 방법이지만, 데이터베이스 연결과 암호화 CPU 사용량도 함께 늘린다.
새 환경에서는 작은 동시 처리 수로 시작하고 데이터베이스 지연과 오류율을 보며 값을 올리는 편이 안전하다.
기존 데이터는 멈출 수 있는 배치로 옮긴다
이미 저장된 평문 데이터를 한 트랜잭션에서 전부 암호화하면 작업 시간이 길어지고, 실패했을 때 처음부터 다시 시작하기 쉽다.
트랜잭션은 여러 데이터 변경을 하나의 작업처럼 성공하거나 실패하게 묶는 단위다.
큰 이관은 작은 배치로 나누고, 각 배치가 어디까지 끝났는지 확인할 수 있어야 한다.
아래 항목은 당시 숫자와 별개로 다음 이관에 적용할 운영 원칙이다.
- 아직 암호화되지 않은 행만 안정적인 순서로 읽는다.
- 한 배치 안에서 암호문, 검색 토큰과 키 버전을 함께 저장한다.
- 성공한 행을 다시 처리해도 결과가 망가지지 않도록 조건을 둔다.
- 실패한 행의 식별자와 오류 유형만 남기고 민감한 원문은 로그에 쓰지 않는다.
- DB 지연과 오류율이 기준을 넘으면 동시 처리 수를 줄이거나 작업을 멈춘다.
- 표본을 복호화해 원문과 일치하는지 권한이 제한된 검증 경로에서 확인한다.
같은 작업을 여러 번 실행해도 최종 상태가 달라지지 않는 성질을 멱등성이라고 한다.
배치에 멱등성이 있으면 중간 실패 뒤 안전하게 재시도하기 쉽다.
검색 기능이 바뀌는 범위도 알려야 한다
검색용 토큰은 정확 일치에는 잘 맞지만 부분 검색에는 바로 사용할 수 없다.
예를 들어 전화번호 전체가 같은 사용자를 찾을 수는 있지만 마지막 네 자리만 입력해 찾는 기능은 별도 설계가 필요하다.
접두어나 일부 문자열을 토큰으로 여러 개 저장하면 검색은 가능해지지만 노출되는 패턴과 저장량이 늘어난다.
요구사항이 생겼다는 이유만으로 부분 토큰을 추가하기보다, 꼭 필요한 검색인지와 접근 권한을 먼저 검토한다.
정렬과 범위 검색도 같은 이유로 어렵다.
암호화된 계좌번호를 숫자 순서로 정렬하거나 특정 범위를 찾는 기능은 이 구조의 목표가 아니다.
보안을 강화하면서 기존의 모든 검색 경험을 그대로 유지할 수 있다고 약속하면 나중에 더 위험한 우회 구현이 생기기 쉽다.
운영에서 확인할 실패 지점
| 실패 지점 | 사용자에게 보이는 현상 | 확인할 내용 |
|---|---|---|
| 정규화 규칙 불일치 | 저장된 값이 있는데 검색되지 않는다 | 저장과 검색이 같은 함수를 호출하는지 확인한다 |
| IV 재사용 | 화면에는 티가 나지 않지만 암호 안전성이 약해진다 | 암호화마다 새 IV를 생성하는지 확인한다 |
| 인증 태그 누락 | 복호화가 실패하거나 변조 확인을 못 한다 | 암호문과 IV, 태그를 한 단위로 저장한다 |
| 키 버전 누락 | 키 교체 뒤 기존 데이터를 읽지 못한다 | 모든 암호문에 버전이 있는지 검사한다 |
| 검색 토큰 인덱스 누락 | 정확 일치 검색이 DB 부하를 만든다 | 인덱스 정의와 실행 계획을 확인한다 |
| 로그에 평문 기록 | 장애 조사 도구에 개인정보가 남는다 | 요청 본문과 오류 객체의 로깅 경로를 점검한다 |
| 과도한 배치 병렬성 | DB 지연과 오류가 증가한다 | 동시 처리 수를 지표에 맞춰 조절한다 |
검색 토큰 인덱스를 빠뜨렸을 때 실제로 어떤 장애가 났는지는 암호화 검색 컬럼의 인덱스를 빠뜨려 DB가 멈췄다에 따로 정리했다.
구현 전 체크리스트
- 어떤 필드는 복호화가 필요하고 어떤 필드는 비교만 필요한지 구분했는가?
- AES 키, IV, 인증 태그와 키 버전의 저장 형식을 정했는가?
- 암호화마다 예측하기 어려운 새 IV를 생성하는가?
- 검색 입력의 정규화 규칙을 한 함수로 공유하는가?
- 검색 토큰에 사용할 비밀키를 암호화 키와 분리했는가?
- 평문과 검색 토큰이 로그와 URL에 남지 않는가?
- 생성과 수정, 관리자, 일괄 처리 등 모든 쓰기 경로를 확인했는가?
- 검색 토큰 컬럼의 인덱스와 실행 계획을 확인했는가?
- 배치를 중단하고 재시도할 수 있는가?
- 암호화 키 교체와 이전 버전 복호화 절차를 시험했는가?
결론
이 작업에서 가장 중요했던 판단은 암호화 함수를 고르는 일만이 아니었다.
보관, 검색, 키 교체와 기존 데이터 이관을 서로 다른 문제로 나누는 일이었다.
암호문은 제한된 경로에서 원래 값을 되찾기 위해 사용하고, 검색용 토큰은 복호화 없이 정확히 같은 값인지 비교하는 데 사용했다.
키 버전은 시간이 지난 뒤 키를 교체할 통로를 남겼고, 배치는 이미 쌓인 데이터를 한꺼번에 건드리지 않게 해 줬다.
이 구조로 4개 스키마의 7개 민감 필드를 암호문, 검색용 토큰과 키 버전으로 전환했고, 기존 데이터는 재시도 가능한 배치로 옮길 수 있게 됐다.
개인정보 암호화는 컬럼 하나를 바꾸는 작업이 아니라, 데이터가 생성되고 검색되고 복구되고 폐기되는 전체 경로를 다시 설계하는 작업이다.
민감한 업로드 경로의 권한과 파일 검증을 다룬 글은 S3 Presigned URL을 안전하게 쓰기 위해 확인한 것에서 이어서 볼 수 있다.