해당 주제들은 필자가 회사에서 서버 코드를 분석하던 중 알게된
이번 글에서는 봉투암호화와 이와 관련된 여러 블록암호화 종류와 예시코드를 볼 예정이다.
0. 들어가기 전에
암호화 기초로 이전에 대칭키, 비대칭키에 대해 공부했다. ([Security 기초] HTTPS와 SSL/TLS이해와 실습 (feat. 암호화 기초))
| 구분 | 대칭키 | 비대칭키 (공개키/비공개키) |
| 동작 방식 | 암호화와 복호화에 동일한 하나의 열쇠 사용 | 암호화(공개키)와 복호화(비공개키)에 서로 다른 열쇠 쌍 사용 |
| 속도 | 연산이 단순하여 처리 속도가 매우 빠름 | 수학적 연산이 복잡하여 처리 속도가 느림 |
| 실생활 비유 | 집 현관문 열쇠 (잠그고 열 때 같음) | 우체통 (누구나 잠글 수 있지만 우체부만 열 수 있음) |
| 치명적 한계 | 단일 키 유출 시 시스템 전체가 뚫림 (안전한 키 전달 및 관리 취약) | GB, TB 단위의 대용량 데이터 암호화 시 성능이 크게 떨어짐 |
1. 봉투암호화란?
봉투 암호화? 왠 봉투 암호화? 암호화면 암호화인거지 봉투는 왜붙지...?
봉투 암호화를 영어로 하면 Envelope Encryption이다.
봉투에 뭔가 또 담아서 암호화를 진행하는건가?
그러니까, 키로 데이터 암호화용 키를 암호화한다. 그런데 그 키를 다른 키로 암호화한다.
데이터를 암/복호화하는 키 (Key 1) + 그 Key 1을 다시 암/복호화하는 키 (Key 2)
이렇게 2개의 키를 한 번에 사용하는 암호화를 봉투 암호화라고 한다.
2. DEK와 KEK 그리고 프로세스
봉투암호화를 보게 되면 두 단어를 보게 된다. DEK랑 KEK? 이게 뭐지?
봉투 암호화의 특징은 위에서 보듯이 암호화된 데이터와 암호화된 키를 만들기 위해서는
(1)데이터를 암호화하고 복호화 하기 위한 키 그리고 (2) 이 1번에서 사용되는 키 자체를 암호화 하기 위한 키가 필요하다.
핵심 키의 역할을 2개로 분담한 것이다.
DEK(Data Encryption Key)
실제 평문 데이터를 엄호화하는 데 사용되는 키. 왜 이걸 데이터키라고 하는지 모르겟다.
Data Encryption Key인데 줄여서 데이터 키라고 하는 것 같다.
고객의 개인정보, 대용량 파일 등 실제 '원본 데이터'를 직접 암호화/복호화하는 가볍고 빠른 실무용 열쇠이다.
KEK(Key Encryption Key)
마스터 키라고도 한다.
원본 데이터를 암호화하는 데 사용된 'DEK 자체'를 한 번 더 암호화하여 안전하게 밀봉하는 상위 보안 열쇠이다.
그런데 필자는 이게 왜 좋은지 모르겠다.
이에 대해서 Gemini에게 설명을 부탁했다.
1. 네트워크 및 메모리 I/O 병목 해결 (가장 핵심적인 이유)
보안이 가장 강력한 마스터 키(KEK)는 AWS KMS 같은 하드웨어 보안 장비(HSM) 내부에 갇혀 있으며, 보안상 외부(WAS나 DB)로 절대 반출할 수 없습니다.
만약 키를 하나만 쓴다면, 사용자가 업로드한 5GB짜리 영상 파일을 KEK로 암호화하기 위해 5GB 데이터를 고스란히 네트워크를 태워 KMS 서버로 보내야 합니다.
그리고 거기서 암호화된 5GB 데이터를 다시 WAS로 받아와야 합니다. 대용량 트래픽이 몰리는 환경에서 매번 파일이나 데이터를 KMS 서버로 왕복시키면 네트워크 대역폭이 고갈되고 엄청난 지연(Latency)이 발생하여 시스템이 뻗어버립니다.
키를 두 개로 나누면 구조가 이렇게 바뀝니다:
애플리케이션(WAS) 메모리에서 매우 가벼운 일회용 DEK를 즉석에서 생성합니다. 이 DEK로 5GB 데이터를 즉시 암호화합니다(로컬 처리이므로 매우 빠름). 그런 다음 암호화는 끝났으니, 고작 '수십 바이트'에 불과한 DEK만 KMS로 휙 보내서 KEK로 암호화해 옵니다.
즉, 무거운 실제 데이터 암호화는 로컬 서버 리소스로 처리하고, 네트워크 대역폭은 가벼운 열쇠(DEK)만 왔다 갔다 하게 만드는 고도의 성능 최적화 기법입니다.
2. 키 교체(Key Rotation) 시 발생하는 디스크 I/O 방지
보안 규정상 암호화 키는 주기적(예: 1년)으로 새것으로 교체해야 합니다. 만약 단일 키만 사용했다면, 스토리지에 쌓여있는 수십 TB의 데이터를 모두 구형 키로 복호화한 다음, 신형 키로 다시 암호화해서 디스크에 덮어써야 합니다.
이 과정에서 막대한 디스크 I/O와 DB 부하가 발생하며, 사실상 서비스 장애 수준의 작업이 됩니다.
DEK와 KEK가 분리되어 있으면 이 과정이 극적으로 단순해집니다: 실제 데이터는 DEK로 잠겨 있으므로 1바이트도 건드릴 필요가 없습니다.
데이터베이스에 봉투처럼 묶여있는 '암호화된 DEK'만 쏙 뽑아와서, 구형 KEK로 복호화한 뒤 신형 KEK로 다시 잠가서 업데이트해 주면 끝납니다. 대용량 데이터의 마이그레이션 없이 메타데이터 수준의 변경만으로 키 로테이션 요구사항을 충족할 수 있습니다.
3. 유출 시 피해 범위(Blast Radius) 최소화
모든 데이터를 하나의 단일 키로 잠그면, 그 키가 해커에게 유출되는 순간 DB 전체가 뚫리게 됩니다.
봉투 암호화 방식에서는 DB의 Row 단위나 개별 파일마다 매번 '새로운 고유 DEK'를 발급하여 사용합니다.
수백만 개의 파일이 있다면 수백만 개의 각기 다른 DEK가 존재하는 것입니다. 만약 운이 나빠 암호화된 DEK 하나가 유출되어 풀리더라도, 해당 파일 단 하나만 뚫릴 뿐 나머지 시스템 데이터는 안전합니다.
그리고 이 수백만 개의 DEK를 잠그고 있는 최종 KEK는 KMS 인프라 밖으로 절대 나오지 않기 때문에 원천적인 방어가 가능해집니다.
결론적으로, 데이터를 잠그는 열쇠(DEK)는 '서버 성능'을 위해 일회용으로 쓰고, 그 열쇠들을 통제하는 열쇠(KEK)는 '절대적인 보안'을 위해 안전한 곳에 모셔두는 역할 분담이라고 보시면 됩니다.
그러하면 우리에게 클라우드 KMS 서비스가 이 봉투암호화를 사용하면 어떻게 동작하는지 알아보자.
[데이터를 저장할 때: 암호화]
- 애플리케이션이 KMS(키 관리 시스템)에 데이터 키(DEK) 생성을 요청합니다.
- KMS는 '사용 가능한 원본 DEK'와 '마스터 키(KEK)로 암호화된 DEK' 두 가지를 세트로 반환합니다.
- 애플리케이션은 로컬 환경에서 전달받은 원본 DEK를 이용해 대용량 원본 데이터를 순식간에 암호화합니다.
- 암호화가 완료되면 보안을 위해 원본 DEK는 메모리에서 즉시 삭제합니다.
- 최종적으로 데이터베이스에는 [암호화된 데이터 + 암호화된 DEK]를 한 세트로 묶어 안전하게 보관합니다.
[데이터를 조회할 때: 복호화]
- 데이터베이스에서 [암호화된 데이터 + 암호화된 DEK] 세트를 불러옵니다.
- 애플리케이션은 데이터를 직접 풀 수 없으므로, '암호화된 DEK'만 KMS로 보내 마스터 키(KEK)로 복호화해 달라고 요청합니다.
- KMS가 내부 안전 구역에서 복호화한 '원본 DEK'를 다시 돌려줍니다.
- 애플리케이션은 전달받은 원본 DEK를 사용해 암호화된 데이터를 복호화하여 원본 정보를 읽어냅니다.
3. 블록 암호(Block Cipher)에 대해
블록 암호? 위키피디아에서는 다음과 같이 설명한다.
암호학에서 블록 암호(영어: block cipher)는 기밀성 있는 정보를 정해진 블록 단위로 암호화하는 대칭키 암호 시스템이다.
만약 암호화하려는 정보가 블록 길이보다 길 경우에는 특정한 운용 모드가 사용된다. (예: ECB, CBC, OFB, CFB, CTR)
근데 굳이 왜 블록으로 만들까? 이는 더 강한 암호화를 위함이다.
암호는 일반적으로 스트림 암호(stream cipher)와 블록 암호(block cipher)로 나뉜다.
스트림 암호는 어떤 크기의 데이터든 암하화 할 수 있는 반면, 블록 암호화는 고정된 크기의 "블록" 단위로만 데이터를 암호화할 수 있다.
대칭키는 무엇인가? 암/복호화시에 사용되는 키로 유출이 되는 순간 다 털리기 때문에 잘 보관해야한다.
그런데 블록 암호는 기밀성 있는 정보를 정해진 블록 단위로 암호화하는 대칭키 암호 시스템! 이라고한다.
즉, 대칭키만 쓴다는 말이다! 왜? 대칭키 암호 시스템이니까!
기밀성 있는 정보? 기밀성이 무엇인가? (김일성 아님 하지만 발음은 같다 끼낄)
기밀성?
인가된 사람, 프로세스 또는 시스템만이 정보와 데이터에 접근하고 열람할 수 있도록 보장하는 정보 보안의 핵심 원칙
즉 비밀스럽게 가려진 정보를 블록 단위로 암호화하는 대칭키 암호 시스템을 블록 암호라고 할 수 있다.
4. 블록 암호의 종류와 간단 분석
블록 암호화에는 다음과 같은 6가지 운용 방식(operation mode)가 있다.
ECB, CBC, CFB, OFB, CTR, PCBC에 대해 각각 간단히 알아보고 회사에서 현재 사용중인 CBC는 어떻게 해서 봉투 암호화와 연동중인지 간단히 정리하려고 한다.
그림에서 열쇠는 대칭키를, 창문 똥골뺑이는 XOR연산을 의미한다.
필자는 위키피디아에 있는 암호화 종료를 검색해서 직접 그려보았다.

6가지의 모드를 하나 하나 알아보겠다.
여기서 초기화 백터(IV)가 출현한다. 이게 정확히 뭐냐...? (C++ 아님)
첫 블록을 초기화할 때 사용하는 값을 의미한다.
일반적으로 무작위 비트열로 정해진 길이를 생성한다. 즉, 매번 값이 다르게 설정되어야 한다.
그리고 중간 중간 직사각형 상자에 파란 글씨로 'block cipher'라고 적혀있다.
이것은 블록 암호화의 핵심 알고리즘 부분을 나타내는 것으로 유명한것은 AES, DES, SEED & ARIA가 있다.
중간에 이걸 써서 우리가 만든 대칭키로 암호화 하는 것이다.
다만 이것을 특정 길이로 잘라서 암호화해서 이어서 쓰는것이다.
예를 들어서 128바이트 길이의 원본 데이터를 16바이트의 블록으로 나눈다고 하자.
그러면 8개의 블록으로 나눈 후 각각 블록들을 암호화해서 이어붙인 후 전송한다.
너무 당연하게도 복호화할 때도 이것을 다시 잘라서 대칭키를 사용해서 복호화를 한다.
물론 뒤에서 자세히 설명할 6가지 블록 암호 유형 중 CTR, CFB, OFB는 길이가 원본과 같지만
ECB, CBC, PCBC는 최종 암호는의 길이가 원본보다 항상 길어진다.
무엇 때문어 그런지는... 다음의 각자 블록 암호화 유형에 대해 학습하면 알 수 있다!
ECB(Electronic Code Book) Mode

블록 암호중에서 가장 간단한 암호화 방식이다.
암호화하려는 메시지를 여러 블록으로 나누어 순차적으로 암호화한다.
모든 블록이 같은 암호화 키를 사용하기에 취약하다.
예를 들어, 이미지를 암호화 한 경우 동일한 곳에서 동일하게 암호화되므로 형체가 나오게 된다.(?)
왜 그렇냐고? 같은 데이터가 같은 암호문으로 1:1 치환되었기 때문에 전체적인 패턴이 고스란히 남아버리기 때문이다.
흰색이라면 항상 빨간색으로, 파란색이라면 항상 초록색으로 변환이 되버리는 것이다.
CBC(Cipher Block Chaining) Mode

초기에는 무작위로 생성된 초기화 벡터를 이용해서 평문을 암호화한다.
이 암호화된 암호문을 다시 다음 블록의 초기화 벡터로 이용해서 그 다음 블록의 평문을 암호화한다.
또다시 암호화된 암호문을 다시 다음 블록의 초기화 벡터로 이용해서 그 다음 블록의 평문을 암호화한다.
예를 들어, 이미지를 암호화할 경우 ECB에서는 형체를 알 수 있지만 CBC의 경우에는 암호문들이 동일하지 않아 형체가 나오지 않는다.
어째서? 흰색이라면 항상 빨간색으로, 파란색이라면 항상 초록색으로 변환이 되는게 아니라 이제 파란색에 흰색을 또 섞어버려서 예측이 불가능해지기 때문이다.
CBC 모드는 평문이 반드시 블록 크기(예: AES의 경우 128비트/16바이트)의 배수여야 한다.
그렇기에 데이터가 모자르다면 padding 작업을 통해서라도 데이터를 채워넣어야 한다.
CFB(Cipher Feedback) Mode

CBC와 마찬가지로 IV가 사용된다. 암호화는 순차적으로 처리하며 복호화는 병렬 처리가 가능하다.
CBC, CFB 두 모드 암호문 한 개의 블록에서 에러 발생시 현재 복호화되는 평문 블록과 다음 복고화되는 평문 블록에 영향을 준다.
CBC와 다르게 CFB는 블록 암호기(block cipher)가 평문을 암호화하는 것이 아니라, 평문과 섞을 난수 스티림(key stream)을 생성한다.
(1)이전 암호문 혹은 IV를 (2)블록 암호기에게 전달해서 (3)난수 수트림을 생성한다.
그 다음 (4)이 난수 스트림을 평문과 XOR처리한다. 그러면 암호문이 짜잔! 완성되고 이를 반복한다.
재미있는 점은 XOR연산만 하기 때문에 평문이 5바이트라면 난수 스트림 중 5바이트만 잘라서 xor하면 정확히 5바이트의 암호문이 나온다고 한다. 고로, 패딩이 필요없으므로 불필요한 오버헤드 최소화에 적절하다.
OFB(Output Feedback) Mode

CFB와 동일하게 패딩을 추가하지 않고 블록 단위 암호화를 스트림 암호화 방식으로 구성한다.
OFB 모드에서는 블록 암호화에 데이터를 직접 넣고 암호화하는 것이 아니라 데이터에 바를 "무작위 난수 양념"을 뽑아내는 용도로만 사용한다.
CTR(Counter) Mode

- 번호표 준비: 중복되지 않는 고유값인 Nonce(논스) 뒤에 1씩 증가하는 숫자인 Counter(카운터)를 붙여서 입력값을 만듭니다. (예: Nonce, Counter, Nonce, Counter+1, Nonce, Counter+2...)
- 난수 생성: 이 번호표들을 block cipher에 넣고 대칭키로 암호화하여 일회용 난수(양념)를 뽑아냅니다.
- 최종 합체: 뽑아낸 난수와 평문을 단순히 ⊕(XOR)로 겹쳐서 암호문을 만듭니다.
장점
(1) 초고속 병렬 처리 가능
(2) 패딩 없음 -> 원본과 암호문의 길이가 완전히 같음
PCBC(Propagating cipher block chaining) Mode

- 첫 번째 블록: 첫 평문과 초기화 벡터(IV)를 섞어서(⊕) block cipher에 넣고 암호문을 만듭니다.
- 사슬 엮기: 2번째 블록을 암호화하기 전에, [1번째 평문]과 [1번째 암호문]을 먼저 서로 섞어버립니다.
- 두 번째 블록 암호화: 위에서 섞어 만든 값을 [2번째 평문]에 한 번 더 섞은 뒤, block cipher 기계에 넣어 암호화합니다.
- 무한 반복: 이 과정을 끝까지 반복합니다.
특징
- 도미노 효과 (오류 전파): 이름에 'Propagating(전파되는)'이 붙은 이유가 있습니다. 이전 평문과 암호문이 모두 다음 블록에 영향을 주기 때문에, 전송 중에 데이터가 딱 1비트만 깨지거나 해커에 의해 변조되어도 그 뒤에 오는 모든 암호문 블록이 복호화될 때 깨진 글자로 도미노처럼 싹 다 무너집니다.
- 현재의 위상: 아주 미세한 오류나 변조도 용납하지 않는 강력한 무결성이 필요할 때(예: Kerberos v4 프로토콜 등) 쓰였으나, 암호 분석 기술이 발전하면서 현재는 다른 안전한 모드들에 밀려 실무에서는 거의 사용되지 않습니다.
어후... 지겨워...
5. AWS KMS의 봉투암호화와 CBC의 결합
자! 그러면 필자의 프로젝트에서는 어떤 방식을 사용했나?
CBC를 우선 사용했는데, AWS KMS의 봉투암호화와 CBC를 이용해서 보안을 지키고 있다.
어떤 흐름으로 활용하고 있는지 정리하면 다음과 같다.
(1) 마스터키 (CMK)는 AWS KMS안에만 둔다.
실제 데이터를 암호화하는 키를 코드나 DB에 직접 두지 않는다. 최상위 마스터키(CMK)는 AWS KMS 내부에만 존재하며, 애플리케이션은 그 키를 절대 내려받지 않는다.
(2) KMS에게 데이터키(DEK)를 발급받는다 — 봉투암호화의 시작
암호화가 필요한 시점에 KMS의 `GenerateDataKey`를 호출한다. 이 한 번의 호출로 두 가지가 돌아온다.
(3) DEK로 데이터를 AES-CBC 암호화한다
발급받은 DEK를 AES-256 키로 삼아 CBC 모드로 평문을 암호화한다. 이때 **매 암호화마다 랜덤 IV를 새로 생성**한다. 같은 평문이라도 매번 다른 암호문이 나오게 하기 위함이다.
(4) 복호화에 필요한 정보를 함께 묶어 저장한다
암호문만 저장하면 나중에 풀 수 없다. 그래서 다음을 하나로 이어 붙인 뒤 Base64로 인코딩해 저장한다.
(5) 복호화는 역순으로 진행한다
저장된 값을 Base64 디코딩해 keyId · IV · 암호문으로 분리한다. keyId로 알맞은 DEK를 찾고
(필요하면 저장해둔 봉투를 KMS `Decrypt`로 풀어 DEK를 복원), 그 DEK와 IV로 CBC 복호화하면 원래 평문이 나온다.
실제 코드로는 Gemini에게 어떻게 구성하면 되는지 물어보았다.
키 준비 - KMS로 DEK 발급 (봉투암호화)
public class DataKeyProvider {
// AWS 콘솔에서 생성한 마스터키(CMK)의 ARN
private static final String CMK_ARN =
"arn:aws:kms:<region>:<account-id>:key/<key-uuid>";
private final AWSKMS kms = AWSKMSClientBuilder.standard()
.withRegion(Regions.AP_NORTHEAST_1)
.build();
/** KMS에서 새 데이터키(DEK) 발급: 평문 DEK + 봉투(암호화된 DEK)를 함께 얻는다 */
public DataKey generate() {
GenerateDataKeyResult result = kms.generateDataKey(
new GenerateDataKeyRequest()
.withKeyId(CMK_ARN)
.withKeySpec("AES_256"));
byte[] plaintextDek = result.getPlaintext().array(); // 실제 암호화용
byte[] wrappedDek = result.getCiphertextBlob().array(); // 저장용 봉투
return new DataKey(plaintextDek, wrappedDek);
}
/** 저장해둔 봉투를 KMS로 풀어 평문 DEK를 복원 */
public byte[] unwrap(byte[] wrappedDek) {
DecryptResult result = kms.decrypt(
new DecryptRequest().withCiphertextBlob(ByteBuffer.wrap(wrappedDek)));
return result.getPlaintext().array();
}
}
암호화 - DEK로 AES-CBC
public class CryptoService {
private static final int IV_LENGTH = 16;
/**
* @return Base64( keyId(2B) + IV(16B) + 암호문 )
*/
public String encrypt(String plainText, String keyId, byte[] dek) throws Exception {
// ① 매 암호화마다 랜덤 IV 생성
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
// ② DEK로 AES-256-CBC 암호화
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(dek, "AES"), new IvParameterSpec(iv));
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
// ③ keyId + IV + 암호문을 하나로 묶는다
byte[] keyIdBytes = keyId.getBytes(StandardCharsets.UTF_8); // 2 bytes
ByteBuffer buffer = ByteBuffer.allocate(
keyIdBytes.length + iv.length + encrypted.length);
buffer.put(keyIdBytes);
buffer.put(iv);
buffer.put(encrypted);
return Base64.getEncoder().encodeToString(buffer.array());
}
}
복호화 - 역순 분리 후 CBC
public String decrypt(String encoded, KeyResolver keyResolver) throws Exception {
byte[] decoded = Base64.getDecoder().decode(encoded);
if (decoded.length < 2 + IV_LENGTH) {
throw new IllegalArgumentException("암호문 길이가 유효하지 않습니다.");
}
// ① keyId / IV / 암호문 분리
String keyId = new String(decoded, 0, 2, StandardCharsets.UTF_8);
byte[] iv = Arrays.copyOfRange(decoded, 2, 2 + IV_LENGTH);
byte[] cipherText = Arrays.copyOfRange(decoded, 2 + IV_LENGTH, decoded.length);
// ② keyId에 해당하는 DEK 확보 (봉투를 KMS로 풀어 복원)
byte[] dek = keyResolver.resolveDek(keyId);
// ③ CBC 복호화
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(dek, "AES"), new IvParameterSpec(iv));
byte[] decrypted = cipher.doFinal(cipherText);
return new String(decrypted, StandardCharsets.UTF_8);
}
① KMS가 키를 발급·보관하고(봉투암호화)
② 그 키로 CBC 암호화하며
③ 복호화에 필요한 keyId·IV를 암호문과 함께 묶어 저장
관리는 KMS에, 실제 암호화 연산은 로컬 CBC에 나누어 맡긴 구조라고 볼 수 있다.
어후... 생각보다 길어서 정리하는데 힘들었다.
코드야 그냥 아 그런가보다 하고 대~충 쳐서 이거이거 해줘잉~ 하게 되면 촥! 해주지만...
그래서 이게 우리가 원하는 대로 정말 알맞게 작성된건지 확인해야하면 결국에는 사람이 알아야 한다.
AI를 활용하면 수많은 부분에서 자동화가 가능하지만 부분 부분 명확한 이해와 설계가 절실히 필요하다.
요즘 필자는 그래서 쓰이는 개념에 대해서 모르는 것을 직접 그리면서 학습 중이다.
이번의 글도 회사에서 AWS KMS를 쓰고 padding을 쓰고 Java의 기본 암호화 기능을 사용하는데 이 뒤의 기본 개념이 부족했기에 자세히 정리했다.
재미있는건 필자가 아마 2021년도에 정보처리기사 자격증을 취득했는데 암호화 관련해서는 하나도 기억이 안나고 정말
A->B를 골라라! 하는 수준의 키워드 암기만을 했지 1도 도움이 안됐다.
결국 나만의 이야기로 학습을 진행해야 그나마 기억에 오래남는것 같다.
참고: