
대칭키 암호화: 집에 열쇠 놓고 옴
가장 빠르고 단순한 암호화 방식. 하지만 열쇠를 배달하다가 털리면 끝장이다. 키 배송 문제(Key Distribution Problem)의 딜레마.

가장 빠르고 단순한 암호화 방식. 하지만 열쇠를 배달하다가 털리면 끝장이다. 키 배송 문제(Key Distribution Problem)의 딜레마.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

스타트업 초기에 고객 데이터베이스를 백업하면서 처음으로 암호화를 직접 구현해야 했다. "암호화? 쉽지. 라이브러리 갖다 쓰면 되는 거 아냐?"라고 생각했는데, 코드를 짜다 보니 선택의 늪에 빠졌다.
// Node.js crypto 문서를 보는데...
crypto.createCipheriv('aes-128-cbc', key, iv);
crypto.createCipheriv('aes-256-gcm', key, iv);
crypto.createCipheriv('chacha20-poly1305', key, iv);
AES-128? AES-256? CBC? GCM? ChaCha20? 이게 다 뭐야. 숫자가 크면 더 안전한 건 알겠는데, 왜 이렇게 옵션이 많은 거지? 더 큰 문제는 "이 암호화 키를 서버에 어떻게 안전하게 전달하지?"였다.
그날 밤 암호화 관련 자료를 파다가 깨달았다. 대칭키 암호화는 겉보기엔 단순하지만, 실제로 제대로 쓰려면 블록 암호화 모드, 키 유도, 키 교환 같은 주변 생태계를 전부 이해해야 한다는 걸.
처음엔 단순하게 생각했다. "암호화 = 비밀번호로 잠그기"잖아. 그런데 실제로 다뤄보니 두 가지 세계가 있더라.
로컬 파일 암호화 (BitLocker, FileVault)는 설정 한 번 하면 끝이다. 비밀번호 입력하고 드라이브 전체가 암호화된다. 엄청 빠르다. 몇백 기가 데이터도 실시간으로 읽고 쓸 수 있다.
HTTPS 통신은 훨씬 복잡하다. 브라우저와 서버가 처음 만났는데 어떻게 암호화 키를 안전하게 공유하지? 도청자가 모든 패킷을 보고 있는데?
이 차이가 뭘까 고민하다가 깨달았다. 로컬 암호화는 키를 내가 직접 입력하니까 문제없다. 하지만 네트워크 암호화는 "키를 어떻게 안전하게 교환하느냐"가 핵심 문제다.
대칭키 암호화의 아이러니는 여기 있다. 암호화 자체는 엄청 빠르고 강력한데, 키 배송(Key Distribution)이 너무 어렵다.
대칭키 암호화(Symmetric Encryption)는 정말 직관적이다.
하나의 비밀 키로 암호화하고, 똑같은 키로 복호화한다. 끝.
현관문 자물쇠를 생각하면 된다. 문 잠글 때 쓰는 열쇠와 열 때 쓰는 열쇠가 똑같다. 그래서 "대칭(Symmetric)"이라는 이름이 붙었다. 수학적으로 표현하면 이렇다.
암호화: Ciphertext = Encrypt(Plaintext, Key)
복호화: Plaintext = Decrypt(Ciphertext, Key)
이 단순함이 엄청난 속도를 가능하게 만든다. 비대칭키 암호화(RSA 같은)가 소수의 곱셈 같은 복잡한 수학 연산을 쓰는 반면, 대칭키는 비트 치환(substitution)과 순열(permutation)만 반복한다. CPU 입장에서는 거의 직선 도로를 달리는 것과 같다.
실제 속도 차이는 어마어마하다. RSA-2048로 1MB 데이터를 암호화하면 몇 초 걸린다. AES-256으로 같은 데이터를 암호화하면 밀리초 단위다. 수백 배에서 수천 배 빠르다.
그래서 실제로는 대용량 데이터는 무조건 대칭키로 암호화한다. 하드 디스크, 데이터베이스, 동영상 스트리밍 전부 대칭키다. HTTPS도 실제 데이터 전송은 대칭키(AES)를 쓴다. 비대칭키는 처음 "키 교환"할 때만 쓴다.
AES(Advanced Encryption Standard)는 2001년 미국 국립표준기술연구소(NIST)가 공식 표준으로 채택한 블록 암호 알고리즘이다. 벨기에 암호학자 두 명(Joan Daemen, Vincent Rijmen)이 만든 Rijndael 알고리즘이 선택됐다.
AES는 고정된 블록 크기 128비트를 쓴다. 키 크기는 세 가지를 선택할 수 있다.
숫자가 클수록 더 안전하지만 약간 느려진다. 실제로는 AES-128도 충분히 안전하다고 본다. 현존하는 컴퓨터로는 2^128 경우의 수를 brute-force 공격할 수 없다. 우주의 나이보다 오래 걸린다.
AES-256은 정부 기관이나 군대에서 최고 기밀(Top Secret) 문서를 암호화할 때 쓴다. 일반 서비스는 AES-128이면 충분하다.
AES 이전에는 DES(Data Encryption Standard)가 표준이었다. 1977년에 채택되어 20년 넘게 쓰였다. 하지만 DES는 치명적인 약점이 있었다.
키 길이가 56비트밖에 안 된다. 2^56 = 약 72조. 숫자로는 커 보이지만, 1998년에 EFF(Electronic Frontier Foundation)가 만든 전용 하드웨어 "Deep Crack"은 56시간 만에 DES 키를 깼다. 1999년에는 distributed.net 프로젝트가 22시간 만에 깼다.
오늘날 GPU 클러스터를 쓰면 몇 시간이면 DES를 뚫을 수 있다. 그래서 DES는 2005년에 공식적으로 폐기(deprecated)됐다.
DES의 수명을 연장하려고 3DES(Triple DES)가 나왔다. DES를 세 번 연속으로 돌리는 방식이다(Encrypt-Decrypt-Encrypt). 키 길이가 168비트로 늘어나서 안전해졌지만, 속도가 느려졌다. 결국 AES가 대체했다.
교훈: 암호화 알고리즘은 키 길이가 생명이다. 아무리 복잡한 수식을 써도 키가 짧으면 brute-force로 뚫린다.
AES는 블록 암호(Block Cipher)다. 데이터를 128비트 블록 단위로 쪼개서 암호화한다. 마치 벽돌을 하나씩 쌓듯이.
스트림 암호(Stream Cipher)는 데이터를 바이트 단위(또는 비트 단위)로 연속적으로 암호화한다. 물이 흐르듯이. 대표적인 예가 ChaCha20이다.
블록 암호 (AES):
[128비트 블록1] -> 암호화 -> [암호문1]
[128비트 블록2] -> 암호화 -> [암호문2]
...
스트림 암호 (ChaCha20):
평문 스트림 XOR 키 스트림 = 암호문 스트림
블록 암호는 블록 크기보다 작은 데이터를 암호화할 때 패딩(padding)이 필요하다. 스트림 암호는 패딩이 필요 없어서 데이터 길이가 변하지 않는다.
ChaCha20은 모바일에서 AES보다 빠르다. AES는 CPU의 하드웨어 가속(AES-NI instruction set)이 있어야 빠른데, 오래된 ARM 프로세서는 이게 없다. ChaCha20은 소프트웨어 구현도 빠르다. 그래서 Google은 Android와 Chrome에서 ChaCha20-Poly1305를 쓴다.
블록 암호를 쓸 때 같은 평문 블록이 항상 같은 암호문 블록을 만들면 패턴이 드러난다. 이게 ECB 모드의 치명적 약점이다.
ECB (Electronic Codebook) 모드는 각 블록을 독립적으로 암호화한다.
블록1 -> AES -> 암호블록1
블록2 -> AES -> 암호블록2
문제는 같은 평문 블록이 반복되면 같은 암호 블록이 반복된다는 거다. 유명한 예시가 "ECB 펭귄"이다. 펭귄 이미지를 ECB로 암호화하면 펭귄 윤곽이 그대로 보인다. 패턴이 살아있다.
CBC (Cipher Block Chaining) 모드는 이전 블록의 암호문을 다음 블록 암호화에 섞는다.
블록1 XOR IV -> AES -> 암호블록1
블록2 XOR 암호블록1 -> AES -> 암호블록2
IV(Initialization Vector)는 무작위 값이다. 같은 평문도 IV가 다르면 완전히 다른 암호문이 나온다. CBC는 오랫동안 표준이었지만, 패딩 오라클 공격(Padding Oracle Attack) 같은 취약점이 발견됐다.
GCM (Galois/Counter Mode)은 최신 표준이다. 암호화와 동시에 인증 태그(Authentication Tag)를 생성해서 데이터 무결성을 보장한다.
평문 -> AES-CTR -> 암호문
암호문 -> GHASH -> 인증 태그
누군가 암호문을 조작하면 인증 태그가 맞지 않아서 복호화가 실패한다. TLS 1.3은 AES-GCM을 기본으로 쓴다. 암호화 + 무결성 검증이 동시에 되니까 효율적이다.
대칭키 암호화의 가장 큰 문제는 키를 어떻게 안전하게 공유하느냐다.
Alice와 Bob이 통신하려면:
키를 그냥 전송하면 Eve도 키를 얻는다. Eve가 키를 알면 모든 암호문을 복호화할 수 있다. 게임 끝.
이게 키 배송 문제(Key Distribution Problem)다. 냉전 시대에는 외교관이 가방에 암호 키를 넣고 직접 날라다 줬다. 물리적으로 전달하면 안전하니까. 하지만 인터넷 시대에는 이게 불가능하다.
1976년 Whitfield Diffie와 Martin Hellman이 놀라운 아이디어를 냈다. 공개 채널에서 비밀 키를 만드는 방법.
비유하자면 이렇다. Alice와 Bob이 각자 물감을 가지고 있다. 도청자가 보는 앞에서:
도청자는 노란색, 주황색, 초록색만 봤다. 갈색을 만들 수 없다. 물감을 섞는 건 쉽지만 역으로 분리하는 건 어렵기 때문이다.
실제 Diffie-Hellman은 이산 로그 문제(Discrete Logarithm Problem)를 쓴다.
1. 공개 값: 소수 p, 생성자 g
2. Alice 비밀 키: a, 공개 키: A = g^a mod p
3. Bob 비밀 키: b, 공개 키: B = g^b mod p
4. 공유 비밀: s = B^a mod p = A^b mod p = g^(ab) mod p
도청자는 g, A, B를 알지만 a나 b를 계산할 수 없다. 이산 로그 문제는 수학적으로 어렵다(큰 소수에 대해).
Diffie-Hellman 덕분에 Alice와 Bob은 암호화된 채널 없이도 공유 비밀을 만들 수 있다. 이 공유 비밀을 AES 키로 쓰면 된다.
실제로는 비대칭키(공개키)와 대칭키를 조합한다.
왜 이렇게 하냐? 비대칭키는 느리고, 대칭키는 키 교환이 어렵다. 둘의 장점만 합치는 거다.
HTTPS/TLS가 정확히 이 방식이다.TLS 핸드셰이크:
1. 서버가 공개키 인증서를 보낸다
2. 클라이언트가 랜덤 대칭키를 생성한다
3. 서버 공개키로 대칭키를 암호화해서 보낸다
4. 서버가 개인키로 복호화해서 대칭키를 얻는다
5. 이제 둘 다 같은 대칭키를 가졌다
6. 이후 모든 통신은 AES로 암호화한다
또는 Diffie-Hellman으로 대칭키를 만든다. TLS 1.3은 ECDHE(Elliptic Curve Diffie-Hellman Ephemeral)를 쓴다. 매번 새로운 키를 만들어서 Forward Secrecy를 보장한다.
하이브리드 암호화 = 보안(비대칭키) + 속도(대칭키)const crypto = require('crypto');
// AES-256-GCM 암호화 함수
function encrypt(plaintext, password) {
// 비밀번호에서 키 유도 (PBKDF2)
const salt = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
// IV (Initialization Vector) 생성
const iv = crypto.randomBytes(12); // GCM은 12바이트 IV 권장
// 암호화
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
// 인증 태그 가져오기 (GCM의 무결성 보장)
const authTag = cipher.getAuthTag();
// salt, iv, authTag, encrypted를 함께 저장해야 복호화 가능
return {
salt: salt.toString('hex'),
iv: iv.toString('hex'),
authTag: authTag.toString('hex'),
encrypted: encrypted
};
}
// AES-256-GCM 복호화 함수
function decrypt(encryptedData, password) {
// 저장된 값들 복원
const salt = Buffer.from(encryptedData.salt, 'hex');
const iv = Buffer.from(encryptedData.iv, 'hex');
const authTag = Buffer.from(encryptedData.authTag, 'hex');
// 같은 방식으로 키 유도 (salt가 같아야 같은 키 나옴)
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
// 복호화
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// 사용 예시
const secret = "고객 신용카드 정보: 1234-5678-9012-3456";
const password = "super-secret-password-2024";
const encrypted = encrypt(secret, password);
console.log("암호화 결과:", encrypted);
// {
// salt: '3a7f2b...',
// iv: '9c4e1a...',
// authTag: '8b2d3f...',
// encrypted: 'a3f8c2...'
// }
const decrypted = decrypt(encrypted, password);
console.log("복호화 결과:", decrypted);
// "고객 신용카드 정보: 1234-5678-9012-3456"
// 잘못된 비밀번호로 시도하면?
try {
decrypt(encrypted, "wrong-password");
} catch (err) {
console.log("복호화 실패:", err.message);
// "Unsupported state or unable to authenticate data"
}
중요 포인트:
비밀번호를 직접 키로 쓰면 안 된다. 비밀번호는 보통 짧고 패턴이 있다. 반드시 키 유도 함수(KDF)를 써야 한다.
PBKDF2(Password-Based Key Derivation Function 2)는 비밀번호를 안전한 암호화 키로 변환한다. 100000 반복(iteration)을 돌려서 brute-force를 어렵게 만든다.
Salt는 무작위 값이다. 같은 비밀번호도 salt가 다르면 다른 키가 나온다. 레인보우 테이블(Rainbow Table) 공격을 막는다.
IV(Initialization Vector)도 무작위다. 같은 평문을 여러 번 암호화해도 매번 다른 암호문이 나온다.
GCM 모드는 authTag를 생성한다. 복호화할 때 authTag가 맞지 않으면 실패한다. 누군가 암호문을 조작했거나 키가 틀린 거다.
const fs = require('fs');
const crypto = require('crypto');
// 큰 파일을 스트림으로 암호화
function encryptFile(inputPath, outputPath, password) {
const salt = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
const iv = crypto.randomBytes(16);
// 파일 시작 부분에 salt와 iv 저장
const outputStream = fs.createWriteStream(outputPath);
outputStream.write(salt);
outputStream.write(iv);
// 스트림 암호화 (CBC 모드, 파일은 보통 CBC 씀)
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
const inputStream = fs.createReadStream(inputPath);
inputStream.pipe(cipher).pipe(outputStream);
outputStream.on('finish', () => {
console.log(`파일 암호화 완료: ${outputPath}`);
});
}
// 파일 복호화
function decryptFile(inputPath, outputPath, password) {
const inputStream = fs.createReadStream(inputPath);
// 먼저 salt와 iv 읽기 (각 16바이트)
let salt, iv;
inputStream.once('readable', () => {
salt = inputStream.read(16);
iv = inputStream.read(16);
const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256');
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
const outputStream = fs.createWriteStream(outputPath);
inputStream.pipe(decipher).pipe(outputStream);
outputStream.on('finish', () => {
console.log(`파일 복호화 완료: ${outputPath}`);
});
});
}
// 10GB 영화 파일도 메모리 부족 없이 암호화 가능
encryptFile('movie.mp4', 'movie.mp4.enc', 'my-password');
decryptFile('movie.mp4.enc', 'movie_decrypted.mp4', 'my-password');
스트림 암호화의 장점:
.env 파일을 Git에 올릴 때도 암호화한다 (git-crypt 같은 도구 씀)비밀번호는 그대로 키로 쓰면 위험하다. "password123" 같은 흔한 비밀번호는 사전 공격(Dictionary Attack)에 취약하다.
PBKDF2는 오래됐지만 안전하다. 단점은 GPU 공격에 약하다. 해시 함수(SHA-256)만 쓰니까 GPU로 병렬 처리가 가능하다.
Argon2는 2015년 Password Hashing Competition에서 우승한 최신 알고리즘이다. 메모리를 많이 쓴다. GPU나 ASIC으로 공격하려면 메모리 비용이 엄청나게 들어서 brute-force가 비효율적이다.
// Argon2 사용 (bcrypt, scrypt도 비슷)
const argon2 = require('argon2');
async function deriveKey(password) {
// Argon2id (Argon2i + Argon2d 조합, 추천)
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64MB 메모리 사용
timeCost: 3, // 3번 반복
parallelism: 4 // 4개 스레드
});
// hash는 salt 포함된 문자열
return hash;
}
async function verifyPassword(password, hash) {
return await argon2.verify(hash, password);
}
// 실제 사용
const userPassword = "user-secure-password-2024";
const storedHash = await deriveKey(userPassword);
console.log(storedHash);
// $argon2id$v=19$m=65536,t=3,p=4$...
const isValid = await verifyPassword(userPassword, storedHash);
console.log(isValid); // true
권장 사항:
사용자가 비밀번호 입력 → PBKDF2로 키 유도 → 마스터 키 복호화 → 디스크 전체 AES 암호화
2. HTTPS/TLSClient Hello → Server Hello + Certificate
→ Key Exchange (ECDHE)
→ 공유 비밀 생성
→ AES-128-GCM으로 데이터 암호화
브라우저와 서버가 매번 새로운 세션 키를 만든다. 하나의 세션 키가 노출돼도 과거/미래 세션은 안전하다 (Forward Secrecy).
3. 데이터베이스 컬럼 암호화-- PostgreSQL에서 pgcrypto 확장 사용
INSERT INTO users (name, ssn_encrypted)
VALUES ('Alice', pgp_sym_encrypt('123-45-6789', 'encryption-key'));
SELECT name, pgp_sym_decrypt(ssn_encrypted, 'encryption-key')
FROM users;
민감한 개인정보(주민등록번호, 신용카드)는 컬럼 레벨 암호화한다.
4. JWT 암호화JWT는 보통 서명(Signature)만 있다. 내용은 Base64라서 누구나 읽을 수 있다. 정말 민감한 정보는 JWE(JSON Web Encryption)를 쓴다.
// JWE: RSA로 AES 키 암호화, AES로 페이로드 암호화
const jose = require('node-jose');
const keystore = jose.JWK.createKeyStore();
const key = await keystore.generate('RSA', 2048);
const payload = JSON.stringify({ userId: 123, role: 'admin' });
const encrypted = await jose.JWE.createEncrypt({ format: 'compact' }, key)
.update(payload)
.final();
console.log(encrypted); // eyJhbGciOiJSU0EtT0FFUC0yNTYi...
처음 암호화를 배울 때는 "AES-256 쓰면 안전하겠지"라고 생각했다. 하지만 실제로 부딪혀보니 암호화 알고리즘 선택은 전체 퍼즐의 10%밖에 안 된다.
진짜 어려운 건:
대칭키 암호화는 빠르고 강력하지만 키 관리가 전부다. 아무리 강한 자물쇠를 사도 열쇠를 현관 매트 밑에 두면 소용없다.
실제로 배운 교훈:
스타트업 초기에 데이터베이스 백업을 암호화하면서 이 모든 걸 배웠다. 처음엔 단순히 openssl enc -aes-256-cbc만 쓰면 되는 줄 알았는데, 키 관리, 스크립트 자동화, 장애 복구 시나리오까지 고려하니 암호화 생태계 전체를 이해해야 했다.
암호화는 은총알(Silver Bullet)이 아니다. 전송 구간 암호화(TLS), 저장 암호화(AES), 키 관리(KMS), 접근 제어(IAM), 감사 로그(Audit Log)가 모두 맞물려야 진짜 보안이 된다.
하지만 그 시작은 대칭키 암호화를 제대로 이해하는 것이다. AES가 왜 빠른지, 블록 모드가 왜 중요한지, 키 배송 문제가 왜 어려운지 알면, HTTPS 핸드셰이크를 보는 눈이 달라진다. "아, 저기서 ECDHE로 공유 비밀 만들고, AES-GCM으로 전환하는구나." 퍼즐 조각이 맞춰진다.
앞으로 비대칭키(RSA, ECC), 해시(SHA-256), 디지털 서명(ECDSA)을 공부하면서 이 퍼즐을 완성할 생각이다. 하지만 지금은 대칭키만으로도 실제로 쓸 수 있는 암호화 시스템을 만들 수 있다는 걸 알게 됐다. 그것만으로도 큰 진전이다.