프로젝트 중간에 다른 팀이 회원 DB의 이름과 생년월일 컬럼을 암호화하겠다고 알려 왔다. 함께 받은 것은 복호화 API가 아니라 Java(Spring) 라이브러리였다. 그 두 컬럼을 집계 SQL에서 쓰고 있는 우리 배치는 AWS Glue 위에서 도는 파이썬 Spark 잡이라, 라이브러리를 그대로 가져다 쓸 방법이 없었다.
하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트였고, 나는 서비스 DB를 매일 S3로 복사해 L0(원본 복사), L1(정제), L2(집계)로 가공하는 배치를 맡고 있었다. 구조는 테이블이 늘어도 코드는 그대로인 Glue + Iceberg 배치에 정리했다. 여기서 필요한 사실은 셋이다. L1 SQL은 이름을 탈퇴 회원 필터(name != '-')로, 생년월일을 출생 연도 계산(SUBSTRING(BIRTH_DT, 1, 4))으로 쓴다. 암호화 뒤에는 값이 암호문 컬럼(NAME_ENC, BIRTH_DT_ENC)에 들어간다. Glue 잡은 NAT 없는 프라이빗 서브넷에서 돌아 인터넷으로 나갈 수 없다.
라이브러리 소스를 먼저 읽었다
암호화 팀의 라이브러리는 Spring과 MyBatis에 의존했다. 키 정보를 DB 테이블에서 읽고 Redis 캐시를 거쳐 복호화하는 구조였다. jar를 Glue에 싣거나 복호화 전용 Lambda를 세우는 방법이 떠올랐지만 둘 다 무거웠다. 앞의 것은 Spring 컨텍스트와 DB 매퍼를 Spark 잡 안에 띄워야 하고, 뒤의 것은 행마다 호출할지 묶음으로 호출할지부터 설계해야 한다. 고르기 전에 소스를 따라가 봤다.
실제 암호화는 AWS Encryption SDK(ESDK) for Java가 하고 있었다. DB에 들어가는 값은 ESDK 메시지를 Base64로 인코딩한 것이고, 알고리즘은 ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY(메시지 포맷 v2)였다. ESDK는 봉투 암호화를 쓴다. 데이터는 데이터 키로 암호화하고, 그 데이터 키는 KMS의 마스터 키(CMK)로 한 번 더 감싸서 감싼 데이터 키(EDK)를 암호문 헤더에 함께 넣는다.
ESDK 메시지 v2 헤더 (실제 샘플에서 확인한 값)
Version 0x02
Algorithm Suite 0x0478 (AES_256_GCM_HKDF_SHA512_COMMIT_KEY)
Message ID ...
Encryption Context {} (빈 값)
EDK 개수 1
Provider ID aws-kms
Provider Info arn:aws:kms:ap-northeast-2:111122223333:key/xxxxxxxx-...
Ciphertext <KMS가 감싼 데이터 키>
...
본문(암호화된 평문)
그렇다면 키 테이블도 Redis도 필요 없다. 헤더의 EDK를 KMS에 보내 풀고(kms:Decrypt), 나온 데이터 키로 본문을 풀면 된다. 이 일은 파이썬 라이브러리 aws-encryption-sdk가 그대로 해 준다. Glue 역할에 대상 CMK로 범위를 좁힌 kms:Decrypt를 주고, 프라이빗 서브넷에서 KMS에 닿도록 KMS VPC 엔드포인트를 추가했다. 같은 EDK를 KMS에 반복해서 묻지 않도록 ESDK의 Caching Cryptographic Materials Manager도 붙였다.
평문은 L0에 남기지 않았다
복호화가 된다고 평문을 S3에 저장하면, 암호화 팀이 DB에서 없앤 개인정보가 데이터 레이크에 다시 생긴다. L1 SQL을 하나씩 보니 이름 자체를 읽는 곳은 없었다.
| 컬럼 | SQL에서의 실제 쓰임 | L0에 저장하는 값 |
|---|---|---|
이름 (NAME) | != '-' 탈퇴 회원 필터로만 사용 | 있음(*) / 없음(-) |
생년월일 (BIRTH_DT) | 출생 연도(앞 4자리)만 사용 | 앞 4자리 |
L0에서 복호화한 직후 이 파생값만 남기고 원래 컬럼 이름으로 저장했다. name != '-'는 *와 -에도 똑같이 동작하고, SUBSTRING(BIRTH_DT, 1, 4)는 네 자리 문자열에서도 같은 값을 돌려주니 L1 SQL은 고칠 필요가 없다. 어떤 컬럼을 어떤 모드로 풀지는 Terraform 변수로 정한다.
pii_kms_key_arns = ["arn:aws:kms:ap-northeast-2:111122223333:key/xxxxxxxx-..."]
# 해당 테이블의 L0 작업에
pii_columns = [
{ source = "NAME_ENC", target = "NAME", mode = "presence" },
{ source = "BIRTH_DT_ENC", target = "BIRTH_DT", mode = "birth_year" },
]
두 번 넘어졌다
암호화 팀에게 받은 샘플 암호문과 CMK ARN으로 PoC를 돌리자 KMS가 InvalidCiphertextException을 돌려줬다. 같은 CMK로 GenerateDataKey는 잘 됐고 우리 역할의 권한도 정상이었다. 헤더를 직접 뜯어 보니 버전, 알고리즘, provider info의 CMK ARN까지 전부 표준대로였다. 다른 건 EDK의 길이였다. 감싼 데이터 키가 32바이트였다. AES-256 키 원본 크기 그대로다. 같은 CMK로 GenerateDataKey를 불러 받은 실제 KMS 암호문은 184바이트였다.
샘플은 암호화 팀이 개발용 모의 KMS(use-mock=true)로 만든 값이었다. 모의 KMS는 데이터 키를 감싸지 않고 원본 32바이트를 EDK 자리에 넣는다. 실제 KMS(use-mock=false)로 다시 만든 샘플은 EDK가 184바이트였고, 이름과 생년월일 모두 그대로 풀렸다.
dev에 배포하자 이번에는 L0 잡이 바로 실패했다.
PythonException: PII decrypt failed (mode=presence): No module named 'cryptography'
aws-encryption-sdk는 실제 암복호를 cryptography 패키지에 맡기는데, 나는 Glue 런타임에 그게 기본으로 들어 있다고 가정했다. 프라이빗 서브넷이라 PyPI에서 받아 올 수도 없다. 의존성 트리 전체를 whl로 받아 레포의 glue/libs/에 커밋하고, terraform apply 때 S3에 올려 --additional-python-modules로 설치하게 했다. --no-deps를 줘서 pip가 의존성을 찾으러 PyPI로 나가지 않게 했고, cryptography와 cffi, pycparser를 더해 whl은 여섯 개가 됐다. Glue 5.0이 Python 3.11에 glibc 2.34 기반이라 manylinux_2_17 태그의 네이티브 whl도 그대로 로드됐다.
재배포 뒤 L0 로그에 PII column decrypted: NAME_ENC -> NAME (mode=presence)가 찍혔고, dev에서 크롤러부터 L2까지 전체 워크플로가 파생값 기준으로 집계를 냈다. 탈퇴 회원의 암호문이 NULL인지 '-'를 암호화한 값인지는 확인하지 못했다. = '-'로 세는 SQL 한 곳은 NULL이면 결과가 달라져서, 과거 날짜 수치와 비교해 보고 어긋나면 UDF에 NULL을 '-'로 바꾸는 한 줄을 넣기로 했다. stg와 prd는 배포 대기 상태로 작업을 마쳤다.