백엔드 서버는 사용자 비밀번호, 인증 토큰, 개인정보, 게임 재화, 결제 정보 등 다양한 데이터를 처리한다. 이 데이터를 안전하게 관리하려면 암호학(Cryptography)의 기본 원리를 이해해야 한다.
암호학은 단순히 데이터를 암호화하는 기술이 아니다. 데이터의 기밀성, 무결성, 인증, 부인 방지 등을 제공하는 보안 기술의 집합이다.
이번 글에서는 백엔드 개발자가 반드시 알아야 할 다음 개념을 정리한다.
- Hash와 암호화의 차이
- 비밀번호 해싱과 안전한 저장 방식
- Salt와 Hash의 관계
- 대칭키와 비대칭키 암호화
- 디지털 서명과 키 관리
Hash와 암호화의 차이
Hash란?
해시(Hash)는 임의의 길이를 가진 입력 데이터를 고정된 길이의 출력값으로 변환하는 함수다.
import { createHash } from "node:crypto";
const input = "hello world";
const hash = createHash("sha256")
.update(input)
.digest("hex");
console.log(hash);
SHA-256을 사용하면 입력 데이터의 크기와 관계없이 256비트의 해시값이 생성된다. 16진수 문자열로 표현하면 64자리다.
해시 함수는 다음과 같은 특성을 가진다.
- 결정성: 같은 입력에는 항상 같은 출력이 생성된다.
- 고정 길이 출력: 입력 크기와 관계없이 정해진 길이의 결과를 반환한다.
- 역산의 어려움: 해시값만으로 원본 입력을 복원하기 어렵다.
- 충돌 저항성: 서로 다른 입력이 같은 해시값을 만드는 충돌을 찾기 어려워야 한다.
해시는 원본 데이터를 복원하기 위한 기능이 아니다. 입력값의 지문을 생성하는 데 목적이 있다.
단, 해시값만으로 원본을 복원하기 어렵다는 사실이 모든 입력값을 안전하게 보호한다는 의미는 아니다. 비밀번호처럼 후보가 제한된 데이터는 가능한 값을 반복해서 해싱하여 추측할 수 있다.
암호화(Encryption)란?
암호화는 평문(Plaintext)을 암호문(Ciphertext)으로 변환하여 권한이 없는 사람이 내용을 알아볼 수 없도록 하는 기술이다.
일반적으로 복호화에 필요한 키가 존재한다.
평문: hello world
|
| 암호화 + 암호화 키
v
암호문: 8f3a... (예시)
|
| 복호화 + 복호화 키
v
평문: hello world
암호화는 적절한 키를 이용하면 원본 데이터를 복원할 수 있다는 점에서 해시와 다르다.
Hash와 암호화 비교
| 구분 | Hash | 암호화 |
|---|---|---|
| 주요 목적 | 무결성 검증, 지문 생성 | 기밀성 보호 |
| 원본 복원 | 일반적으로 불가능 | 적절한 키로 가능 |
| 키 사용 | 일반 해시는 불필요 | 일반적으로 필요 |
| 대표 알고리즘 | SHA-256, SHA-3 | AES, RSA, ChaCha20 |
| 대표 사용 사례 | 파일 무결성 검증, 비밀번호 검증 | 개인정보 보호, 통신 데이터 보호 |
여기서 중요한 예외가 있다. HMAC은 비밀 키를 사용하는 메시지 인증 코드이며, 일반적인 해시 함수와 구분해야 한다. 또한 암호화 알고리즘 중에서도 AES-GCM처럼 암호화와 무결성 검증을 함께 제공하는 방식이 있다.
언제 무엇을 사용해야 하는가?
- 사용자의 비밀번호를 저장한다면: Argon2id, scrypt, bcrypt 등의 비밀번호 해싱 알고리즘을 사용한다.
- 데이터베이스에 개인정보를 암호화해 저장한다면: AES-GCM 같은 인증된 암호화 방식을 고려한다.
- 전송된 데이터가 변조되었는지 확인한다면: 디지털 서명, HMAC 또는 인증된 암호화 등 위협 모델에 맞는 방식을 사용한다.
- 파일의 해시를 비교한다면: SHA-256 등의 암호학적 해시를 사용할 수 있다.
단순 SHA-256을 비밀번호 저장에 사용하는 것은 안전한 설계가 아니다. 비밀번호 해싱에는 별도의 알고리즘이 필요하다.
비밀번호 해싱
비밀번호를 평문으로 저장하면 안 되는 이유
다음과 같은 데이터베이스 구조를 가정해 보자.
users
--------------------------------
id username password
1 user1 mypassword123
2 user2 qwer1234
데이터베이스가 유출되면 공격자는 사용자의 비밀번호를 즉시 확인할 수 있다.
사용자가 다른 서비스에서도 같은 비밀번호를 사용했다면 피해가 다른 서비스로 확산될 수 있다.
따라서 비밀번호는 평문으로 저장하지 않는다.
그렇다고 일반적인 SHA-256으로만 변환하여 저장하는 것도 충분하지 않다.
import { createHash } from "node:crypto";
const password = "mypassword123";
const hash = createHash("sha256")
.update(password)
.digest("hex");
위 코드는 비밀번호를 해시값으로 변환하지만, 공격자는 흔히 사용되는 비밀번호를 대량으로 해싱하여 데이터베이스의 해시값과 비교할 수 있다.
SHA-256은 빠른 해시 함수다. 일반적인 데이터 무결성 검증에는 장점이지만, 비밀번호 공격에서는 공격자가 많은 후보를 빠르게 시험할 수 있다는 단점이 있다.
비밀번호 전용 해싱 알고리즘
비밀번호 저장에는 의도적으로 계산 비용을 높이거나 메모리를 많이 사용하도록 설계된 알고리즘을 사용한다.
대표적인 알고리즘은 다음과 같다.
| 알고리즘 | 특징 |
|---|---|
| Argon2id | 메모리 사용량과 계산 비용을 조절할 수 있으며, 일반적으로 우선 고려할 선택지 |
| scrypt | 메모리 집약적으로 설계된 비밀번호 해싱 알고리즘 |
| bcrypt | 오랫동안 널리 사용된 비밀번호 해싱 알고리즘 |
| PBKDF2 | 반복적인 키 유도 연산을 사용하며, 환경과 정책에 따라 활용 가능 |
새로운 시스템에서는 일반적으로 Argon2id를 우선 고려할 수 있다. 다만 실제 알고리즘과 비용 매개변수는 운영 환경의 성능, 보안 정책, 라이브러리 지원을 고려하여 결정해야 한다.
Node.js에서 Argon2id 사용하기
먼저 라이브러리를 설치한다.
npm install argon2
비밀번호를 해싱하는 코드는 다음과 같다.
import argon2 from "argon2";
async function hashPassword(
password: string
): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
}
async function verifyPassword(
password: string,
passwordHash: string
): Promise<boolean> {
return argon2.verify(passwordHash, password);
}
위 매개변수는 예시이며 모든 서비스에 적합한 보편적 권장값은 아니다. 운영 환경에서 실제 로그인 부하를 측정하고 보안 요구사항에 맞춰 조정해야 한다.
로그인 검증은 다음처럼 진행한다.
const passwordHash = await hashPassword(
"mypassword123"
);
// 회원가입 시 passwordHash를 DB에 저장한다.
// 로그인 시 DB에서 가져온 해시값을 사용한다.
const isValid = await verifyPassword(
"mypassword123",
passwordHash
);
if (!isValid) {
throw new Error("이메일 또는 비밀번호가 올바르지 않습니다.");
}
Argon2는 알고리즘과 매개변수, Salt 등의 정보를 인코딩된 결과에 포함할 수 있다. 따라서 일반적으로 애플리케이션에서 Salt를 별도로 생성하고 데이터베이스에 따로 저장할 필요가 없다.
비밀번호 저장 구조
회원가입
|
v
비밀번호 입력
|
v
Argon2id 해싱
|
v
해시 문자열 저장
|
v
데이터베이스
로그인
|
v
비밀번호 입력
|
v
저장된 해시와 검증
|
+-- 일치 --> 인증 성공
|
+-- 불일치 --> 인증 실패
중요한 원칙은 비밀번호를 복호화하여 비교하는 것이 아니라, 입력된 비밀번호가 저장된 해시와 일치하는지 검증한다는 것이다.
Salt와 Hash
Salt란?
Salt는 비밀번호를 해싱하기 전에 함께 사용하는 임의의 값이다.
같은 비밀번호라도 서로 다른 Salt를 사용하면 서로 다른 해시값이 생성된다.
비밀번호: password123
사용자 A
password123 + Salt A
|
v
Hash A
사용자 B
password123 + Salt B
|
v
Hash B
두 사용자가 같은 비밀번호를 사용하더라도 해시값이 같지 않도록 만드는 것이 핵심이다.
Salt가 필요한 이유
Salt가 없다면 같은 비밀번호는 일반적으로 같은 해시값을 만든다.
password123 -> abc123
password123 -> abc123
password123 -> abc123
공격자는 이러한 반복 패턴을 통해 같은 비밀번호를 사용하는 계정을 추정할 수 있다.
또한 미리 계산해 둔 해시값 목록인 레인보우 테이블(Rainbow Table)을 활용하여 비밀번호를 추측할 수 있다.
Salt를 적용하면 다음과 같이 달라진다.
password123 + saltA -> hashA
password123 + saltB -> hashB
password123 + saltC -> hashC
이렇게 하면 동일한 비밀번호에 대한 해시값의 반복을 방지하고, 사전 계산된 해시 목록을 재사용하기 어렵게 만든다.
Salt는 비밀이어야 하는가?
아니다.
Salt는 비밀번호 해시와 함께 데이터베이스에 저장해도 된다.
Salt의 목적은 공격자가 값을 알 수 없게 만드는 것이 아니라, 사용자별 해시 결과를 다르게 만드는 것이다.
users
--------------------------------------------------
id username password_hash
1 user1 $argon2id$...saltA...hashA
2 user2 $argon2id$...saltB...hashB
실제 Argon2 인코딩 문자열의 형식과 구성은 라이브러리에 따라 다를 수 있다.
Salt와 별개로, 비밀번호 해시를 계산할 때 사용하는 서버 측 비밀값인 Pepper를 추가로 고려할 수도 있다. Pepper는 Salt와 달리 데이터베이스 외부의 비밀 관리 시스템 등에 보관해야 한다. 다만 Pepper의 적용 방식과 키 교체 전략은 별도로 설계해야 한다.
Salt와 Pepper의 차이
| 구분 | Salt | Pepper |
|---|---|---|
| 목적 | 사용자별 해시 결과 차별화 | 데이터베이스 유출 시 추가 방어 |
| 사용자별 값 | 일반적으로 사용자마다 다름 | 보통 여러 사용자에게 공통 적용 |
| 공개 여부 | 공개 가능 | 비밀로 관리 |
| 저장 위치 | 해시와 함께 저장 가능 | 데이터베이스 외부의 비밀 저장소 등 |
Salt는 비밀번호 해싱 알고리즘과 함께 자동으로 처리되는 경우가 많다. 직접 구현하기보다는 검증된 라이브러리를 사용하는 편이 안전하다.
대칭키와 비대칭키
암호화 방식은 키를 사용하는 구조에 따라 대칭키 암호화와 비대칭키 암호화로 구분할 수 있다.
대칭키 암호화(Symmetric Encryption)
대칭키 암호화는 암호화와 복호화에 같은 비밀 키를 사용하는 방식이다.
평문
|
| 암호화 + 비밀 키 K
v
암호문
|
| 복호화 + 동일한 비밀 키 K
v
평문
대표적인 알고리즘은 AES와 ChaCha20이다.
장점은 대체로 빠르고 대량의 데이터를 처리하기에 적합하다는 것이다.
단점은 키를 안전하게 공유하고 보관해야 한다는 점이다. 공격자가 키를 획득하면 해당 키로 보호되는 데이터를 복호화할 수 있다.
Node.js에서 AES-256-GCM을 사용하는 예시는 다음과 같다.
import {
createCipheriv,
createDecipheriv,
randomBytes,
} from "node:crypto";
const key = randomBytes(32); // 256비트 키
const iv = randomBytes(12); // GCM에서 흔히 사용하는 IV 크기
function encrypt(plaintext: string) {
const cipher = createCipheriv(
"aes-256-gcm",
key,
iv
);
const ciphertext = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return {
iv: iv.toString("base64"),
ciphertext: ciphertext.toString("base64"),
authTag: authTag.toString("base64"),
};
}
function decrypt(data: {
iv: string;
ciphertext: string;
authTag: string;
}) {
const decipher = createDecipheriv(
"aes-256-gcm",
key,
Buffer.from(data.iv, "base64")
);
decipher.setAuthTag(
Buffer.from(data.authTag, "base64")
);
const plaintext = Buffer.concat([
decipher.update(
Buffer.from(data.ciphertext, "base64")
),
decipher.final(),
]);
return plaintext.toString("utf8");
}
실제 서비스에서는 암호화할 때마다 새로운 IV를 생성하고, 같은 키에서 IV가 중복되지 않도록 관리해야 한다. GCM 인증 태그 검증에 실패하면 복호화를 거부해야 한다.
위 예시의 key는 프로세스 메모리에 생성한 데모용 키다. 서버를 재시작하면 키가 바뀌므로 기존 암호문을 복호화할 수 없다. 운영 환경에서는 키를 안전하게 보관하고 복구할 수 있는 별도 키 관리 체계가 필요하다.
비대칭키 암호화(Asymmetric Encryption)
비대칭키 암호화는 서로 다른 두 키를 사용한다.
- 공개키(Public Key): 다른 사람에게 공개할 수 있는 키
- 개인키(Private Key): 소유자만 안전하게 보관해야 하는 키
일반적인 공개키 암호화 구조에서는 공개키로 암호화한 데이터를 대응하는 개인키로 복호화할 수 있다.
평문
|
| 공개키로 암호화
v
암호문
|
| 개인키로 복호화
v
평문
대표적인 알고리즘으로 RSA와 타원곡선 기반 암호 기술이 있다. 단, 모든 타원곡선 알고리즘이 암호화에 사용되는 것은 아니다. 예를 들어 Ed25519는 디지털 서명에 사용되며, X25519는 키 합의에 사용된다.
비대칭키 방식은 공개키를 배포할 수 있다는 장점이 있지만, 대칭키 방식보다 계산 비용이 큰 경우가 많다.
따라서 대량의 데이터를 공개키 암호화만으로 처리하기보다는 하이브리드 암호화 구조를 사용한다.
대칭키와 비대칭키 비교
| 구분 | 대칭키 | 비대칭키 |
|---|---|---|
| 키 구조 | 같은 비밀 키 사용 | 공개키와 개인키 쌍 |
| 속도 | 일반적으로 빠름 | 일반적으로 상대적으로 느림 |
| 키 공유 | 비밀 키 전달이 문제 | 공개키 배포 가능 |
| 주요 용도 | 데이터 암호화 | 키 교환, 서명, 특정 데이터 암호화 |
| 대표 알고리즘 | AES, ChaCha20 | RSA, Ed25519, X25519 등 |
실제 TLS 통신에서는 공개키 기반 인증과 키 합의 등을 이용해 통신 상대를 인증하고 세션 키를 설정한 뒤, 대칭키 기반의 인증된 암호화로 데이터를 보호한다.
디지털 서명과 키 관리
디지털 서명(Digital Signature)이란?
디지털 서명은 특정 데이터가 서명 이후 변경되지 않았는지 확인하고, 대응하는 개인키의 소유자가 서명에 관여했음을 검증하기 위한 암호학적 기술이다.
일반적인 디지털 서명은 다음과 같은 구조로 동작한다.
서명 생성
--------------------------------
원본 데이터
|
v
개인키로 서명 생성
|
v
데이터 + 디지털 서명
서명 검증
--------------------------------
데이터 + 디지털 서명
|
v
공개키로 검증
|
v
유효 / 무효
서명 생성에는 개인키를 사용하고, 검증에는 대응하는 공개키를 사용한다.
대표적인 서명 알고리즘에는 RSA-PSS, ECDSA, Ed25519가 있다.
디지털 서명이 제공하는 보안 속성
무결성(Integrity)
서명된 데이터가 변경되었다면 검증에 실패하도록 할 수 있다.
인증(Authentication)
신뢰할 수 있는 방식으로 공개키와 소유자의 관계가 확인되어 있다면, 해당 개인키로 생성된 서명임을 확인할 수 있다.
부인 방지(Non-repudiation)
디지털 서명은 부인 방지를 지원하는 기술적 근거가 될 수 있다. 그러나 서명만으로 법적·운영적 부인 방지가 자동으로 보장되는 것은 아니다. 개인키의 통제, 키 탈취 여부, 신원 확인, 감사 기록 등의 조건이 함께 고려되어야 한다.
디지털 서명은 데이터를 암호화하는 기능과 다르다. 서명된 데이터는 공개키만 가진 사람도 검증할 수 있으며, 데이터 자체가 비밀로 유지되는 것은 아니다.
Node.js에서 디지털 서명 구현하기
다음 예시는 Ed25519 키 쌍을 생성하고 데이터를 서명한 뒤 검증한다.
import {
generateKeyPairSync,
sign,
verify,
} from "node:crypto";
const { publicKey, privateKey } =
generateKeyPairSync("ed25519");
const message = Buffer.from(
JSON.stringify({
userId: 1001,
role: "admin",
})
);
// 개인키로 서명 생성
const signature = sign(
null,
message,
privateKey
);
// 공개키로 서명 검증
const isValid = verify(
null,
message,
publicKey,
signature
);
console.log(isValid); // true
서명에 사용한 데이터의 내용이 바뀌면 검증에 실패한다.
const modifiedMessage = Buffer.from(
JSON.stringify({
userId: 1001,
role: "user",
})
);
const modifiedIsValid = verify(
null,
modifiedMessage,
publicKey,
signature
);
console.log(modifiedIsValid); // false
이 예시에서 키 쌍은 실행할 때마다 새로 생성된다. 운영 환경에서는 개인키를 안전하게 보관하고, 검증 측이 신뢰할 수 있는 공개키를 확보할 수 있도록 별도의 배포·인증 체계를 마련해야 한다.
또한 서명 검증은 데이터가 신뢰할 수 있는 출처에서 왔는지 확인하는 데 도움이 되지만, 데이터의 내용 자체가 안전하거나 권한이 있다는 사실까지 보장하지는 않는다. 예를 들어 유효한 서명이 있는 요청이라도 서버에서 권한을 별도로 검사해야 한다.
JWT와 디지털 서명의 관계
백엔드 개발에서 디지털 서명은 JWT(JSON Web Token)의 서명 검증과도 연결된다.
JWT는 일반적으로 Header, Payload, Signature의 세 부분으로 구성된다.
Header.Payload.Signature
서명 방식의 JWT는 헤더와 페이로드를 인코딩한 뒤, 지정된 알고리즘과 키를 사용하여 서명을 생성한다.
예를 들어 HS256은 비밀키를 사용하는 HMAC 기반 서명 방식이고, RS256은 RSA 기반 디지털 서명 방식이다.
두 방식은 키 관리 구조가 다르다.
| 구분 | HS256 | RS256 |
|---|---|---|
| 방식 | 대칭키 기반 MAC | 비대칭키 기반 서명 |
| 생성 | 비밀키로 서명값 생성 | 개인키로 서명 |
| 검증 | 동일한 비밀키 필요 | 공개키로 검증 가능 |
| 주요 고려사항 | 비밀키 공유 범위 | 개인키 보호와 공개키 배포 |
HS256에서는 서명 검증에 사용하는 비밀키를 가진 주체가 새로운 유효 서명을 생성할 수도 있다. 반면 RS256에서는 공개키만 가진 검증 서버가 서명값을 검증할 수 있지만, 새로운 서명을 생성하려면 개인키가 필요하다.
중요한 점은 JWT의 서명은 페이로드를 암호화하지 않는다는 것이다. 일반적인 서명 JWT의 페이로드는 디코딩하여 내용을 확인할 수 있으므로 비밀번호나 민감한 개인정보를 넣어서는 안 된다.
또한 JWT를 검증할 때는 서명만 확인하는 것이 아니라 허용된 알고리즘, 만료 시간(exp), 발급자(iss), 대상(aud) 등 필요한 클레임을 함께 검증해야 한다.
키 관리(Key Management)
암호학 알고리즘을 올바르게 선택해도 키를 안전하게 관리하지 못하면 전체 보안이 무너질 수 있다.
예를 들어 AES-256-GCM을 사용하더라도 암호화 키를 소스 코드에 하드코딩하거나 Git 저장소에 커밋하면 공격자가 키를 획득할 수 있다.
// 잘못된 예시
const ENCRYPTION_KEY =
"my-secret-encryption-key";
실제 암호화 키는 임의로 생성하고, 적절한 비밀 관리 시스템을 통해 보관해야 한다.
키 관리의 핵심 원칙
안전한 키 생성
암호학적으로 안전한 난수 생성기를 사용한다. Node.js에서는 crypto.randomBytes() 등을 활용할 수 있다.
키와 데이터의 분리
암호화된 데이터와 암호화 키를 동일한 접근 권한과 저장 위치에 무방비하게 두지 않는다.
최소 권한 원칙
각 서비스가 실제로 필요한 키에만 접근할 수 있도록 권한을 제한한다.
키 교체(Rotation)
키가 노출되었거나 정책상 교체가 필요한 경우 새로운 키를 도입하고, 기존 데이터의 복호화 및 재암호화 전략을 마련한다.
키 폐기와 복구
사용하지 않는 키를 안전하게 폐기하고, 키 손실로 데이터를 복구할 수 없게 되는 상황을 방지하기 위해 적절한 백업 및 복구 절차를 설계한다.
감사 로그
키에 대한 접근과 사용 이력을 기록하고 이상 접근을 탐지한다. 단, 로그에 비밀키나 민감한 데이터를 직접 기록해서는 안 된다.
운영 환경에서 활용할 수 있는 도구
- AWS KMS: 암호화 키의 생성, 보호, 사용 권한 관리 등을 지원한다.
- AWS Secrets Manager: 데이터베이스 비밀번호, API 키 등 애플리케이션 비밀값의 관리에 활용할 수 있다.
- HashiCorp Vault: 비밀값과 암호화 키 관리, 접근 제어 등을 지원한다.
- HSM(Hardware Security Module): 높은 수준의 키 보호가 필요한 환경에서 암호학적 키를 보호하고 연산을 수행하는 데 사용한다.
도구마다 목적이 다르므로 모든 비밀값을 하나의 도구에 무조건 넣기보다는 키의 용도, 위협 모델, 운영 비용, 접근 제어 요구사항을 기준으로 선택해야 한다.
백엔드 서버에서의 실전 적용
실제 서버에서는 하나의 암호학 기술만으로 보안을 해결하지 않는다. 데이터의 목적에 따라 서로 다른 기술을 조합한다.
| 보호 대상 | 권장 접근 방식 |
|---|---|
| 사용자 비밀번호 | Argon2id 등 비밀번호 전용 해싱 |
| 데이터베이스 개인정보 | AES-GCM 등 인증된 암호화와 안전한 키 관리 |
| API 요청의 인증 | 세션 또는 적절히 검증된 토큰 |
| JWT 서명 | HS256, RS256, EdDSA 등 요구사항에 맞는 방식 |
| 서비스 간 메시지 인증 | HMAC 또는 디지털 서명 |
| 파일 무결성 검증 | SHA-256 등 암호학적 해시 |
| TLS 통신 | 검증된 TLS 구현과 적절한 인증서 관리 |
게임 서버를 예로 들면 사용자 비밀번호는 Argon2id로 해싱해 저장하고, 개인정보는 필요에 따라 암호화하며, JWT를 사용한다면 서명과 만료 시간 등을 검증한다. 게임 재화와 결제 내역은 암호화만으로 보호되는 것이 아니므로 데이터베이스 권한, 트랜잭션, 감사 기록, 서버 권한 검증도 함께 적용해야 한다.