개인정보 컬럼을 암호화하는 일은 암호 알고리즘보다 키를 어디에 두고 언제 바꾸느냐에서 더 자주 실패한다. 키가 설정 파일에 평문으로 있거나, 키를 한 번 바꾸려면 테이블 전체를 다시 써야 하는 구조라면 암호화는 형식만 남는다. 이 글은 KEK와 DEK를 나누는 Envelope Encryption으로 개인정보 컬럼 암호화를 설계하는 방법, 저장 형식, 검색·키 순환·성능 문제를 차례로 정리한 것이다.

한눈에 보기
암호화 위치는 세 가지다. 어느 쪽이 무엇을 막는지부터 정해야 설계가 흔들리지 않는다.
| 방식 | 막는 것 | 못 막는 것 |
|---|---|---|
| 디스크·TDE 암호화 | 디스크·백업 파일 도난 | DB 계정으로 하는 SELECT, SQL Injection |
| DB 함수로 컬럼 암호화 | 테이블 덤프 유출 | 키가 DB 안에 있으면 DBA·DB 계정 탈취 |
| 애플리케이션 Envelope Encryption | DB 전체 유출, DBA 열람 | 애플리케이션 서버 탈취, 권한 있는 API 악용 |
이 글은 세 번째를 다룬다. 애플리케이션이 DB에 쓰기 전에 암호화하고, 키는 KMS가 관리한다.

KEK와 DEK, 두 단계로 나누는 이유
- DEK(Data Encryption Key): 실제 개인정보를 암호화하는 대칭키다. 애플리케이션 메모리에서만 평문으로 존재한다.
- KEK(Key Encryption Key): DEK를 암호화하는 키다. KMS나 HSM 밖으로 나오지 않는다.
DB에는 암호문과 함께 KEK로 암호화된 DEK가 저장된다. DB가 통째로 유출돼도 DEK를 풀려면 KMS 호출 권한이 필요하다. 그리고 KEK를 바꿀 때 데이터 전체가 아니라 DEK만 다시 감싸면 된다. 키 하나로 모든 데이터를 직접 암호화하면 키 교체가 곧 전체 재암호화가 되므로, 결국 아무도 키를 바꾸지 않게 된다.
AWS KMS 기준으로 GenerateDataKey를 부르면 같은 DEK가 두 형태로 온다. Plaintext는 바로 암호화에 쓰고, CiphertextBlob은 저장한다. 평문 DEK는 디스크·로그·캐시 서버에 쓰지 않는다.
// AWS SDK for Java v2
GenerateDataKeyResponse res = kms.generateDataKey(GenerateDataKeyRequest.builder()
.keyId(kekArn) // KEK 식별자
.keySpec(DataKeySpec.AES_256)
.build());
byte[] dek = res.plaintext().asByteArray(); // 메모리에서만 사용
byte[] encryptedDek = res.ciphertextBlob().asByteArray(); // data_key 테이블에 저장
AES-256-GCM 암호화와 저장 형식
알고리즘은 인증 암호(AEAD)인 AES-256-GCM을 쓴다. 암호문이 바뀌면 복호화가 AEADBadTagException으로 실패하므로 변조를 알아챌 수 있다. 지켜야 할 규칙은 둘이다.
- nonce(IV)는 12바이트, 암호화할 때마다 새로 만든다. 같은 키로 nonce를 한 번이라도 재사용하면 두 평문의 관계가 드러나고 인증도 깨진다.
- 무작위 nonce를 쓸 때 NIST SP 800-38D는 키 하나당 암호화 횟수를 2^32회 이하로 권고한다. DEK를 영원히 쓰지 않고 주기적으로 새로 받는 이유 중 하나다.
private static final SecureRandom RANDOM = new SecureRandom();
static byte[] encrypt(byte[] dek, byte[] plaintext, byte[] aad) throws GeneralSecurityException {
byte[] nonce = new byte[12];
RANDOM.nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(dek, "AES"), new GCMParameterSpec(128, nonce));
cipher.updateAAD(aad);
byte[] ct = cipher.doFinal(plaintext); // 암호문 뒤에 16바이트 태그가 붙는다
return ByteBuffer.allocate(12 + ct.length).put(nonce).put(ct).array();
}
static byte[] decrypt(byte[] dek, byte[] stored, byte[] aad) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(dek, "AES"),
new GCMParameterSpec(128, stored, 0, 12));
cipher.updateAAD(aad);
return cipher.doFinal(stored, 12, stored.length - 12); // 태그가 틀리면 AEADBadTagException
}
DEK는 행마다 따로 두지 않고 data_key 테이블로 뺀다. 암호화된 DEK를 행마다 반복 저장하면 용량이 늘고 키 교체 대상도 흩어진다.
CREATE TABLE data_key (
dek_id BIGINT PRIMARY KEY,
kek_id VARCHAR(200) NOT NULL, -- 어떤 KEK로 감쌌는지
encrypted_dek VARBINARY(512) NOT NULL,
status VARCHAR(16) NOT NULL, -- ACTIVE / DECRYPT_ONLY / RETIRED
created_at TIMESTAMP NOT NULL
);
ALTER TABLE member ADD COLUMN phone_enc VARBINARY(256); -- nonce + 암호문 + 태그
ALTER TABLE member ADD COLUMN phone_dek_id BIGINT; -- data_key 참조
status는 키 교체 중에 필요하다. 새 DEK가 ACTIVE가 되면 이전 DEK는 DECRYPT_ONLY로 바뀌어 읽기에만 쓰인다.
AAD로 암호문을 행에 묶기
AAD(Additional Authenticated Data)는 암호화되지는 않지만 태그 계산에 들어가는 값이다. 여기에 테이블명|컬럼명|행 ID를 넣으면 A 회원의 전화번호 암호문을 B 회원 행에 복사했을 때 복호화가 실패한다. AAD가 없으면 DB 쓰기 권한만 있는 공격자가 암호문을 바꿔치기해 다른 사람 정보를 자기 화면에 띄울 수 있다.
주의할 점이 두 가지 있다.
- 행 ID는 INSERT 전에 알아야 한다. DB가 만드는 자동 증가 ID는 암호화 시점에 없다. UUID나 애플리케이션에서 발급하는 ID를 쓰거나, 바뀌지 않는 업무 키(회원번호)를 AAD에 넣는다.
- JPA
AttributeConverter로는 행 ID를 AAD에 넣을 수 없다.convertToDatabaseColumn은 컬럼 값 하나만 받고 엔티티를 모른다. 컨버터 방식은 붙이기 쉽지만 AAD가테이블명|컬럼명수준에 머문다. 행 단위 묶음이 필요하면 서비스 계층이나 엔티티 리스너에서 암호화한다.
AWS KMS에도 비슷한 장치인 encryption context가 있다. GenerateDataKey와 Decrypt에 같은 키-값을 넘겨야 DEK가 풀리고, 그 값이 CloudTrail 감사 로그에 남는다.
검색이 필요한 컬럼은 블라인드 인덱스로
GCM은 같은 값도 nonce에 따라 매번 다른 암호문이 나오므로 WHERE phone_enc = ?가 동작하지 않는다. 전화번호로 회원을 찾아야 하면 별도 키로 만든 HMAC 값을 같이 저장한다.
// 인덱스 키는 DEK와 다른 키를 쓴다. 정규화(하이픈 제거)를 먼저 한다.
byte[] idx = hmacSha256(indexKey, normalize(phone));
member.setPhoneIdx(Arrays.copyOf(idx, 16)); // 일부만 저장해 충돌을 허용
- 같은 값은 같은 인덱스가 되므로 "이 두 행의 값이 같다"는 사실은 드러난다. 성별·생년처럼 값 종류가 적은 컬럼에는 만들지 않는다.
- 일방향 해시(bcrypt, Argon2)는 비밀번호 검증용이다. 복호화가 필요한 개인정보에 쓰는 도구가 아니다.
- 부분 일치(
LIKE '%1234')는 블라인드 인덱스로 풀리지 않는다. 뒷자리 검색이 꼭 필요하면 뒷자리만 따로 인덱스를 만들고 그만큼 노출이 늘어나는 것을 문서로 남긴다.
KEK 회전과 DEK 교체는 다른 작업이다
| 작업 | 바뀌는 것 | 데이터 재암호화 | 언제 |
|---|---|---|---|
| KMS 자동 키 회전 | KEK의 키 material | 없음. 이전 material이 남아 과거 DEK도 풀린다 | 정기(주기는 KMS 설정) |
| 다른 KEK로 이전 | data_key.encrypted_dek, kek_id |
없음. DEK만 다시 감싼다(AWS ReEncrypt) |
계정·리전 이전, KEK 권한 정리 |
| DEK 교체 | 새 DEK 발급, 행의 암호문 | 있음. 배치로 다시 쓴다 | DEK 유출 의심, 암호화 횟수 한도 접근 |
DEK 교체 배치는 DECRYPT_ONLY DEK를 쓰는 행을 페이지 단위로 읽어 새 DEK로 다시 암호화한다. 한 번에 몰아서 하지 않고 처리량을 정해 나눠 돌리며, 남은 행이 0이 되면 이전 DEK를 RETIRED로 바꾼다. 마지막 행이 남아 있는데 키를 지우면 그 데이터는 영영 못 읽는다.
성능과 공공·망분리 환경
DEK 캐시. 요청마다 KMS를 부르면 지연이 늘고 KMS 요청 한도와 요청당 비용에 걸린다. 평문 DEK를 애플리케이션 메모리에 짧게 캐시한다. 기준은 시간(예: 5분)과 사용 횟수 두 가지로 두고, 둘 중 먼저 닿는 쪽에서 버린다. AWS Encryption SDK의 데이터 키 캐싱도 같은 방식이다. Redis 같은 공유 캐시에는 평문 DEK를 두지 않는다.
읽기 경로. 목록 화면 100건을 복호화할 때 DEK가 같으면 KMS 호출은 한 번이다. 마스킹된 값만 보여주는 화면이면 복호화 자체를 하지 않는 편이 안전하고 빠르다.
망분리 공공 환경. 클라우드 KMS를 쓸 수 없으면 온프레미스 HSM이나 키 관리 서버가 KEK를 맡는다. 공공 사업은 국가정보원 검증필 암호모듈(KCMVP) 사용과 알고리즘(ARIA 등)을 요구하는 경우가 많으므로 사업 요구사항과 「개인정보의 안전성 확보조치 기준」 대응표를 먼저 확인한다. 구조(KEK·DEK 분리, 저장 형식, 교체 절차)는 그대로 쓰고 암호화 호출부만 바뀐다.
KMS 키 사용 권한은 관리자 접근 통제와 같은 원칙으로 다룬다. 운영자 계정이 Decrypt를 직접 부를 수 없게 하는 설계는 Zero Trust 관점의 관리자 시스템 접근 통제 설계를, 키 사용 로그를 감리 증적으로 모으는 방법은 감리 대응을 위한 아키텍처 증적 자동 수집 시스템 설계를 참고한다.
자주 하는 실수
KEK를 application.yml이나 환경 변수에 둔다. 서버 한 대가 털리면 키와 암호문이 같이 나가므로 Envelope 구조의 의미가 없다.
nonce를 고정값이나 순번으로 만든다. 서버 여러 대가 같은 DEK로 같은 순번을 쓰는 순간 nonce가 재사용된다.
복호화한 값을 로그·예외 메시지에 남긴다. 엔티티 toString(), 요청 본문 로깅, heap dump에 평문이 그대로 남는다. 덤프 파일 접근 권한도 개인정보 파일 수준으로 관리한다.
평문 DEK 배열만 지우면 안전하다고 믿는다. Arrays.fill(dek, (byte) 0)은 해야 하지만 SecretKeySpec이 내부 복사본을 가지므로 메모리 소거가 완전히 보장되지는 않는다. 캐시 시간을 짧게 두는 것이 실제 방어다.
이전 DEK를 교체 배치가 끝나기 전에 삭제한다. 남은 행을 세지 않고 키를 지우면 복구할 수 없다.
점검표
- KEK가 KMS·HSM 밖으로 나오지 않고, 설정 파일·저장소에 키 값이 없다.
- 평문 DEK가 디스크, 공유 캐시, 로그 어디에도 기록되지 않는다.
- 암호화할 때마다
SecureRandom으로 12바이트 nonce를 새로 만든다. - 저장 값에 nonce, 암호문, 태그, DEK 식별자가 모두 남아 복호화에 필요한 정보가 행 안에서 해결된다.
- AAD에 테이블·컬럼·행 ID(또는 업무 키)를 넣었고, 다른 행으로 복사한 암호문이 복호화에 실패하는 테스트가 있다.
- 검색 컬럼은 DEK와 다른 키로 만든 블라인드 인덱스를 쓰고, 값 종류가 적은 컬럼에는 만들지 않았다.
-
data_key에 상태(ACTIVE/DECRYPT_ONLY/RETIRED)가 있고 DEK 교체 배치 절차가 문서로 있다. - DEK 캐시에 시간·사용 횟수 상한이 있고 공유 캐시를 쓰지 않는다.
- KMS
Decrypt권한이 애플리케이션 역할에만 있고, 호출 기록이 감사 로그로 남는다. - 공공 사업이면 검증필 암호모듈·알고리즘 요구사항을 확인하고 결과를 산출물에 적었다.
컬럼 암호화의 핵심은 데이터를 암호화하는 키(DEK)와 그 키를 지키는 키(KEK)를 나누어, 키는 KMS가 지키고 애플리케이션은 짧게 빌려 쓰며, 저장 형식과 교체 절차를 처음부터 정해 두는 것이다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.