
전자서명: 인터넷의 도장
종이 문서의 서명을 디지털화했습니다. 공개키로 검증, 개인키로 서명. 위조 불가능, 부인 방지. 블록체인과 HTTPS의 기술.

종이 문서의 서명을 디지털화했습니다. 공개키로 검증, 개인키로 서명. 위조 불가능, 부인 방지. 블록체인과 HTTPS의 기술.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

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

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

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

블록체인 공부하다가 "전자서명"이 계속 나왔습니다. "비트코인 거래를 개인키로 서명한다"는데, 서명을 어떻게 디지털로 하지? 종이에 도장 찍는 건 알겠는데, 파일에는 어떻게 도장을 찍을 수 있을까? 그리고 더 헷갈렸던 건, HTTPS 공부할 때 나온 "CA가 서버 인증서에 서명한다"는 개념이었습니다. 서명이면 손으로 쓰는 건데, 컴퓨터가 어떻게 서명을 하지?
실생활에서도 전자서명은 이미 많이 쓰입니다. 계약서 앱에서 손가락으로 서명하는 것도 보고, "공인인증서로 서명하세요" 같은 말도 들었는데, 이게 정확히 어떤 원리로 작동하는지는 몰랐습니다. 그냥 "디지털 세상의 도장" 정도로만 이해했죠.
그러다 npm 패키지를 배포할 때 "패키지 서명"이라는 걸 봤고, Docker 이미지에도 서명을 한다는 걸 알게 되었습니다. 소프트웨어 세계 곳곳에 전자서명이 있더라고요. 이걸 제대로 이해하지 않으면 보안을 이해할 수 없겠다 싶어서 파고들기 시작했습니다.
공개키 암호화는 이해했습니다. Bob이 Alice에게 비밀 메시지 보낼 때, Alice의 공개키로 암호화하면 Alice의 개인키로만 복호화할 수 있다는 것. 이건 직관적으로 이해가 됐습니다.
그런데 전자서명은 정반대였습니다. 개인키로 "암호화"하고 공개키로 "복호화"한다니, 이게 도대체 무슨 소리인가? 공개키는 누구나 가질 수 있는 건데, 그걸로 복호화할 수 있으면 비밀이 아니잖아요? 그럼 암호화의 의미가 뭐지?
그리고 "해시를 서명한다"는 개념도 헷갈렸습니다. 문서를 서명하는 게 아니라 해시를 서명한다고? 해시는 그냥 지문 같은 건데, 지문에 도장을 찍는다는 게 무슨 의미인지 와닿지 않았습니다.
가장 헷갈렸던 건 이거였습니다. "공개키로 검증만 하는데 어떻게 신뢰할 수 있지?" 누구나 공개키를 가질 수 있잖아요. 그럼 Eve가 가짜 공개키를 만들어서 "이게 Alice 공개키야" 하고 속이면 어떡하지? 이 문제를 해결하는 게 CA(Certificate Authority)라는 건 나중에 알게 되었습니다.
전자서명을 이해한 결정적 순간은 이거였습니다. 암호화의 목적이 '비밀 유지'만은 아니구나.
중세 시대 귀족들이 편지를 봉인할 때 밀랍(wax seal)을 사용했던 것처럼, 전자서명도 같은 개념이었습니다. 밀랍 봉인은 편지 내용을 숨기는 게 아닙니다. 누구나 봉인을 뜯고 읽을 수 있죠. 하지만 봉인이 깨지지 않았다면, 이 편지가 중간에 조작되지 않았고, 진짜 그 귀족이 보낸 거라는 걸 증명합니다.
공개키 암호화와 전자서명의 차이를 이렇게 받아들였습니다:
graph LR
A[공개키 암호화] --> B[목적: 비밀 유지]
A --> C[공개키로 잠금]
A --> D[개인키로만 열림]
E[전자서명] --> F[목적: 신원 증명]
E --> G[개인키로 잠금]
E --> H[공개키로 누구나 열림]
공개키 암호화: 남이 못 열게 잠그기 (비밀 유지) 전자서명: 내가 잠갔다는 걸 증명하기 (신원 증명)
이 차이를 이해하고 나니, "개인키로 암호화"가 말이 되더군요. 비밀을 만드는 게 아니라, "나만 이 잠금을 만들 수 있다"는 증명이었던 겁니다.
전자서명은 크게 두 단계로 나뉩니다. 서명 생성과 서명 검증. 나는 이걸 "밀랍 봉인 찍기"와 "밀랍 봉인 확인하기"로 이해했습니다.
Alice가 "Bob에게 100원 송금"이라는 문서에 서명한다고 치자.
sequenceDiagram
participant A as Alice
participant D as Document
participant H as Hash Function
participant S as Signature
A->>D: "Bob에게 100원 송금"
D->>H: 문서 전달
H->>H: SHA256 해싱
H->>S: 해시값: a3c7f9...
A->>S: 개인키로 암호화
S->>S: 서명 생성 완료
S->>D: 문서 + 서명
여기서 핵심은 문서 전체를 암호화하지 않는다는 겁니다. 문서가 10MB면 RSA 암호화가 엄청 느려지니까, 해시를 먼저 만듭니다. SHA256 해시는 문서가 아무리 커도 256비트(32바이트)로 고정됩니다.
그리고 이 해시를 개인키로 암호화합니다. 이게 바로 "서명"입니다. 이 서명은 Alice의 개인키로만 만들 수 있습니다. 누구도 흉내낼 수 없죠. 이게 밀랍 봉인에서 귀족의 문장(coat of arms)이 새겨진 도장으로 밀랍을 찍는 것과 같습니다.
Bob이 문서와 서명을 받았습니다. 이제 이게 정말 Alice가 보낸 건지 확인해야 합니다.
sequenceDiagram
participant B as Bob
participant D as Document
participant S as Signature
participant P as Alice Public Key
participant V as Verify
B->>D: 받은 문서
B->>S: 받은 서명
D->>V: SHA256 해싱 → 해시B
S->>P: 공개키로 복호화 → 해시A
V->>V: 해시A == 해시B?
V->>B: 검증 결과
Bob은 두 가지 작업을 합니다:
만약 해시A와 해시B가 같다면, 두 가지가 동시에 증명됩니다:
이게 결국 이거였습니다. 전자서명은 "누가" 보냈고 "변조되지 않았다"는 두 가지를 동시에 증명하는 기술이었던 거죠.
개념만 읽으면 이해한 것 같다가도 금방 까먹습니다. 코드로 직접 해보니까 와닿더라고요.
const crypto = require('crypto');
// 1. 키 쌍 생성 (Alice의 개인키와 공개키)
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048, // 2048비트 RSA
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
console.log("Alice의 공개키:\n", publicKey);
console.log("Alice의 개인키:\n", privateKey);
// 2. Alice가 문서에 서명
const message = "Bob에게 100원 송금";
const sign = crypto.createSign('SHA256');
sign.update(message);
sign.end();
const signature = sign.sign(privateKey, 'hex');
console.log("\n서명 (Signature):", signature);
console.log("서명 길이:", signature.length, "문자");
// 3. Bob이 서명 검증
const verify = crypto.createVerify('SHA256');
verify.update(message);
verify.end();
const isValid = verify.verify(publicKey, signature, 'hex');
console.log("\n검증 결과:", isValid); // true
// 4. 공격자 Eve가 문서를 변조하면?
const verifyTampered = crypto.createVerify('SHA256');
verifyTampered.update("Bob에게 1000원 송금"); // 100원 → 1000원 변조!
verifyTampered.end();
const isValidTampered = verifyTampered.verify(publicKey, signature, 'hex');
console.log("변조된 문서 검증:", isValidTampered); // false
// 5. Eve가 다른 사람 개인키로 서명하면?
const { publicKey: evePublic, privateKey: evePrivate } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048
});
const eveSign = crypto.createSign('SHA256');
eveSign.update(message); // 같은 문서
eveSign.end();
const eveSignature = eveSign.sign(evePrivate, 'hex');
const verifyEve = crypto.createVerify('SHA256');
verifyEve.update(message);
verifyEve.end();
const isValidEve = verifyEve.verify(publicKey, eveSignature, 'hex');
console.log("Eve의 서명을 Alice 공개키로 검증:", isValidEve); // false
이 코드를 돌려보면서 깨달은 게 있습니다. 서명은 문서와 키가 모두 일치해야만 검증이 통과된다는 점입니다. 문서를 조금이라도 바꾸면 해시가 완전히 달라져서 검증 실패. 다른 사람의 개인키로 서명하면 공개키 매칭이 안 돼서 검증 실패. 두 가지 보안이 동시에 작동하는 겁니다.
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
# 키 생성
key = RSA.generate(2048)
private_key = key
public_key = key.publickey()
# 서명 생성
message = b"Bob에게 100원 송금"
hash_obj = SHA256.new(message)
signature = pkcs1_15.new(private_key).sign(hash_obj)
print(f"서명: {signature.hex()}")
# 서명 검증
try:
hash_verify = SHA256.new(message)
pkcs1_15.new(public_key).verify(hash_verify, signature)
print("✅ 검증 성공! Alice가 서명했고, 문서가 변조되지 않았습니다.")
except ValueError:
print("❌ 검증 실패! 위조되었거나 변조되었습니다.")
# 변조 시도
tampered_message = b"Bob에게 1000원 송금"
try:
hash_tampered = SHA256.new(tampered_message)
pkcs1_15.new(public_key).verify(hash_tampered, signature)
print("✅ 변조된 문서 검증 성공")
except ValueError:
print("❌ 변조된 문서 검증 실패 (예상된 결과)")
Python 코드는 더 명시적입니다. pkcs1_15는 RSA 서명 표준 중 하나인데, 예외 처리로 검증 성공/실패를 판단합니다. try-catch 구조가 "서명 검증은 성공 아니면 실패"라는 이분법적 특성을 잘 보여줍니다.
처음엔 "왜 문서를 바로 암호화하지 않고 해시를 만들어서 암호화하지?"가 이해 안 갔습니다. 번거롭게 왜 한 단계를 더 거치나 싶었죠.
그런데 큰 파일로 테스트해보니 이유를 알겠더군요.
const crypto = require('crypto');
const fs = require('fs');
// 10MB 파일 생성
const largeFile = Buffer.alloc(10 * 1024 * 1024, 'a');
// 방법 1: 전체 파일 암호화 (느림)
console.time('직접 서명');
// RSA는 데이터 크기 제한이 있어서 큰 파일은 직접 서명 불가!
// 이론적으로도 엄청 느림
console.timeEnd('직접 서명');
// 방법 2: 해시 후 서명 (빠름)
console.time('해시 후 서명');
const hash = crypto.createHash('SHA256').update(largeFile).digest();
console.log('해시 크기:', hash.length, 'bytes'); // 32 bytes
// 이제 32바이트만 서명하면 됨
console.timeEnd('해시 후 서명');
RSA 암호화는 데이터 크기에 제한이 있습니다. 2048비트 RSA는 최대 245바이트까지만 암호화할 수 있습니다. 그 이상은 여러 블록으로 나눠서 암호화해야 하는데, 이러면 엄청 느려집니다.
반면 SHA256 해시는 입력이 1바이트든 10GB든 항상 32바이트 출력을 만듭니다. 32바이트만 RSA로 암호화하면 되니까 빠른 겁니다.
해시의 또 다른 중요한 특성은 "눈사태 효과"입니다. 입력을 1비트만 바꿔도 출력이 완전히 달라집니다.
import hashlib
def hash_string(s):
return hashlib.sha256(s.encode()).hexdigest()
msg1 = "Bob에게 100원 송금"
msg2 = "Bob에게 101원 송금" # 0 → 1, 한 글자만 변경
msg3 = "Bob에게 100원 송금 " # 끝에 공백 하나 추가
print(f"원본: {hash_string(msg1)}")
print(f"1글자변경: {hash_string(msg2)}")
print(f"공백추가: {hash_string(msg3)}")
# 출력:
# 원본: a3c7f912b54d8e9c7f8a6b3d2e1f4c5a...
# 1글자변경: df82b1c3a9f7e6d5c4b3a2918f7e6d5c...
# 공백추가: 8f2e9d4c5b6a7f8e9d0c1b2a3f4e5d6c...
완전히 다른 해시가 나옵니다. 이게 중요한 이유는, 공격자가 "원래 문서와 같은 해시를 갖는 가짜 문서"를 만들기가 사실상 불가능하기 때문입니다. 이걸 "충돌 저항성(collision resistance)"이라고 합니다.
전자서명은 RSA만 있는 게 아닙니다. 요즘은 ECDSA(Elliptic Curve Digital Signature Algorithm)도 많이 씁니다. 나는 비트코인 공부하다가 ECDSA를 처음 알게 되었습니다.
const crypto = require('crypto');
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048 // 2048비트 = 256바이트 키
});
// 공개키 크기
const publicKeyPEM = publicKey.export({ type: 'spki', format: 'pem' });
console.log('RSA 공개키 크기:', publicKeyPEM.length, 'bytes');
RSA는 오래되고 검증된 알고리즘입니다. 이해하기 쉽고, 구현도 많고, 호환성이 좋습니다. 하지만 키 크기가 큽니다. 2048비트 RSA가 표준인데, 이게 256바이트입니다.
const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'secp256k1' // 비트코인이 쓰는 곡선
});
const publicKeyPEM = publicKey.export({ type: 'spki', format: 'pem' });
console.log('ECDSA 공개키 크기:', publicKeyPEM.length, 'bytes');
ECDSA는 타원곡선 암호를 사용합니다. 256비트 ECDSA가 3072비트 RSA와 비슷한 보안 수준을 제공합니다. 키가 작고, 서명이 빠르고, 모바일/IoT에 적합합니다. 비트코인, 이더리움이 ECDSA를 씁니다.
나는 이렇게 이해했습니다:
둘 다 수학적으로 안전하지만, 용도에 따라 선택합니다. HTTPS는 주로 RSA, 블록체인은 ECDSA를 씁니다.
웹 개발하다가 JWT(JSON Web Token)를 만날 때마다 전자서명이 나옵니다.
const jwt = require('jsonwebtoken');
const payload = {
userId: 123,
username: 'alice',
role: 'admin'
};
const privateKey = `-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----`;
// JWT 생성 (RS256 = RSA + SHA256)
const token = jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '1h'
});
console.log('JWT:', token);
// JWT 검증
const publicKey = `-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----`;
try {
const decoded = jwt.verify(token, publicKey);
console.log('검증 성공:', decoded);
} catch (err) {
console.log('검증 실패:', err.message);
}
JWT는 세 부분으로 나뉩니다:
서명 덕분에 클라이언트가 payload를 조작할 수 없습니다. 조작하면 서명 검증이 실패하니까요. 이게 전자서명의 "무결성 보장" 기능입니다.
npm 패키지를 배포할 때도 서명을 합니다.
# npm 패키지에 서명
npm publish --provenance
# 패키지 검증
npm audit signatures
npm 7부터 패키지 서명을 지원합니다. 배포자가 개인키로 서명하면, 사용자가 공개키로 검증해서 "이 패키지가 정말 공식 배포자가 만든 게 맞다"를 확인할 수 있습니다. 악의적인 미러 사이트가 패키지를 조작해도 서명 검증이 실패하니까 안전합니다.
# Docker Content Trust 활성화
export DOCKER_CONTENT_TRUST=1
# 이미지 push (자동으로 서명됨)
docker push myrepo/myimage:latest
# 이미지 pull (자동으로 서명 검증)
docker pull myrepo/myimage:latest
Docker Content Trust는 Notary라는 서명 시스템을 씁니다. 이미지를 푸시할 때 자동으로 서명하고, 풀할 때 자동으로 검증합니다. 중간자 공격으로 악성 이미지를 받는 걸 방지합니다.
# GPG 키 생성
gpg --gen-key
# Git에 GPG 키 설정
git config --global user.signingkey YOUR_KEY_ID
# 커밋에 서명
git commit -S -m "Add authentication feature"
# 서명 검증
git log --show-signature
# 출력:
# commit abc123 (HEAD -> main)
# gpg: Signature made Fri Apr 23 10:30:00 2025 KST
# gpg: Good signature from "Alice <alice@example.com>"
# Author: Alice <alice@example.com>
# Date: Fri Apr 23 10:30:00 2025
#
# Add authentication feature
GitHub에서 "Verified" 초록색 배지를 본 적 있을 겁니다. 이게 GPG 서명입니다. 커밋이 정말 그 개발자가 만든 게 맞다는 걸 증명합니다. 누군가 Alice의 계정을 해킹해서 커밋을 만들어도, Alice의 GPG 개인키가 없으면 "Verified" 배지가 안 뜹니다.
HTTPS를 이해할 때 가장 헷갈렸던 게 "인증서 체인"이었습니다.
Root CA (신뢰할 수 있는 기관, 브라우저에 내장)
↓ 서명
Intermediate CA
↓ 서명
google.com 서버 인증서
브라우저가 google.com에 접속하면:
이게 바로 "신뢰의 체인(Chain of Trust)"입니다. 나는 Root CA를 모르지만, 브라우저가 신뢰하니까 나도 신뢰합니다. Root CA가 Intermediate CA를 신뢰하니까 나도 신뢰합니다. Intermediate CA가 google.com을 신뢰하니까 나도 신뢰합니다.
전자서명 없이는 이 신뢰 체인이 불가능합니다. 각 단계마다 서명이 있어야 "이전 단계가 다음 단계를 보증했다"는 걸 증명할 수 있으니까요.
전자서명을 공부하면서 만난 가장 큰 문제는 이거였습니다. 공개키 자체를 어떻게 신뢰하지?
Alice가 Bob에게 "이게 내 공개키야"라고 보냅니다. 그런데 중간에 Eve가 가로채서 자기 공개키를 보내면? Bob은 Eve의 공개키를 Alice 공개키로 착각하고, Eve의 서명을 Alice 서명으로 믿게 됩니다. 이걸 "중간자 공격(Man-in-the-Middle Attack)"이라고 합니다.
sequenceDiagram
participant A as Alice
participant E as Eve (공격자)
participant B as Bob
A->>E: 내 공개키: PubKey_Alice
E->>E: 가로챔!
E->>B: Alice 공개키라고 속임: PubKey_Eve
B->>B: Eve 공개키를 Alice 공개키로 착각
E->>B: Eve가 서명한 문서
B->>B: "Alice가 서명했네!" (속음)
CA는 이 문제를 해결합니다.
sequenceDiagram
participant A as Alice
participant CA as Certificate Authority
participant B as Bob
A->>CA: 신원 증명 + 공개키
CA->>CA: Alice 신원 확인 (여권, 사업자등록증 등)
CA->>A: 인증서 발급 (Alice 공개키 + CA 서명)
A->>B: 인증서 전송
B->>B: CA 공개키로 서명 검증
B->>B: "CA가 보증했으니 진짜 Alice 공개키다!"
CA의 핵심은 "신뢰할 수 있는 제3자"입니다. CA는 Alice의 신원을 확인한 후, Alice의 공개키에 CA의 서명을 붙여서 인증서를 만듭니다. 이제 Bob은 CA만 신뢰하면 됩니다. CA가 "이게 Alice 공개키 맞아"라고 서명했으니까요.
# Certbot 설치 (Let's Encrypt 클라이언트)
sudo apt install certbot
# 인증서 발급 (도메인 소유 확인 후 발급)
sudo certbot certonly --standalone -d example.com
# 발급된 인증서 확인
sudo cat /etc/letsencrypt/live/example.com/fullchain.pem
Let's Encrypt는 무료 CA입니다. 도메인 소유를 확인하면 자동으로 인증서를 발급해줍니다. 이 인증서에는:
이 두 가지가 들어있습니다. 브라우저가 example.com에 접속하면, 인증서를 받아서 Let's Encrypt 공개키로 서명을 검증합니다. 검증 성공 → "이 공개키는 진짜 example.com 거네" → HTTPS 연결.
이론을 이해했으면, 공격을 시도해보면서 왜 안전한지 체감해야 합니다.
const crypto = require('crypto');
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048
});
// Alice가 원래 문서에 서명
const originalDoc = "Bob에게 100원 송금";
const sign1 = crypto.createSign('SHA256');
sign1.update(originalDoc);
const signature = sign1.sign(privateKey, 'hex');
// Eve가 서명을 복사해서 악의적인 문서에 붙임
const maliciousDoc = "Eve에게 1000원 송금";
const verify = crypto.createVerify('SHA256');
verify.update(maliciousDoc);
const isValid = verify.verify(publicKey, signature, 'hex');
console.log("복사한 서명으로 변조 문서 검증:", isValid); // false
실패 이유: 서명은 문서의 해시를 암호화한 겁니다. 문서가 바뀌면 해시가 바뀌고, 복호화한 해시와 새 문서의 해시가 다릅니다.
// Eve가 개인키 없이 서명을 위조하려고 시도
const fakeSignature = "304502..." // 랜덤 hex
const verify = crypto.createVerify('SHA256');
verify.update("Eve에게 1000원 송금");
const isValid = verify.verify(publicKey, fakeSignature, 'hex');
console.log("위조 서명 검증:", isValid); // false (에러 발생)
실패 이유: RSA 서명은 수학적으로 개인키 없이는 생성 불가능합니다. 공개키로부터 개인키를 계산하려면 큰 소수 인수분해를 해야 하는데, 2048비트 RSA는 현재 기술로는 수백만 년 걸립니다.
import hashlib
# Eve가 원래 문서와 같은 해시를 갖는 악의적인 문서를 만들려고 시도
original = "Bob에게 100원 송금"
original_hash = hashlib.sha256(original.encode()).hexdigest()
# 브루트포스로 같은 해시 찾기 시도
attempts = 0
found = False
# 실제로는 이게 사실상 불가능합니다
# SHA256은 2^256 가능성이 있어서 우주의 나이만큼 시도해도 못 찾음
실패 이유: SHA256의 충돌을 찾는 건 사실상 불가능합니다. 2^256 = 약 10^77 가능성. 우주의 원자 개수가 10^80 정도입니다.
이 세 가지 공격 시나리오를 직접 돌려보면서, "아, 정말 안전하구나"를 체감했습니다.
전자서명을 공부하고 나서 이렇게 정리해봤습니다.
전자서명 = 중세 밀랍 봉인의 디지털 버전밀랍 봉인이 하는 일:
전자서명이 하는 일:
핵심 원리:
내가 받아들인 가장 중요한 개념: 암호화의 목적이 "비밀 유지"만은 아니다. "잠금을 만든 사람을 증명"하는 것도 암호화의 용도다. 이게 전자서명의 본질이었다.
블록체인, HTTPS, npm, Docker, Git... 소프트웨어 세상 곳곳에서 전자서명을 만나게 됩니다. 이제 "전자서명"이라는 단어를 보면, "아, 개인키로 잠그고 공개키로 여는 밀랍 봉인이구나" 하고 바로 이해가 됩니다.