프로그래밍을 처음 배울 때 암호화와 해싱은 보통 “데이터를 알아볼 수 없는 값으로 바꾸는 기술”이라고 배운다.
const encryptedValue = encrypt(plainText);
const hashedValue = hash(plainText);
두 결과 모두 원본을 읽기 어려운 문자열처럼 보인다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 결과의 모양보다 그 값을 나중에 어떻게 사용해야 하는지가 중요해진다.
암호화와 해싱은 데이터를 알아볼 수 없게 만드는 기술이 아니다.
암호화와 해싱은 데이터가 다시 복원되어야 하는지, 비교만 가능해야 하는지에 따라 민감한 정보의 사용 방식과 노출 범위를 제한하는 설계 도구다.
크리스가 온라인 상담 서비스를 개발한다고 생각해 보자.
서비스는 회원 비밀번호, 전화번호, 상담 기록, 비밀번호 재설정 토큰을 다룬다.
이 값들은 모두 민감하지만 사용 방식은 서로 다르다.
| 데이터 | 원본 복원 필요 | 주된 사용 |
|---|---|---|
| 비밀번호 | 아니요 | 로그인 시 일치 여부 확인 |
| 전화번호 | 예 | 상담 일정 안내 |
| 상담 기록 | 예 | 담당 상담사가 내용 확인 |
| 재설정 토큰 | 아니요 | 사용자가 제출한 값과 비교 |
| 이메일 | 예 | 로그인과 알림 전송 |
모든 값을 같은 함수로 처리하면 각 데이터의 책임을 표현할 수 없다.
const protectedPassword = protect(password);
const protectedPhone = protect(phoneNumber);
protect라는 이름만으로는 원본을 복원할 수 있는지, 어떤 키를 사용하는지, 비교는 어떻게 하는지 알 수 없다.
사용 목적을 코드에 드러내는 편이 낫다.
const passwordHash =
await passwordHasher.hash(password);
const encryptedPhoneNumber =
await personalDataCipher.encrypt(phoneNumber);
비밀번호는 나중에 원문으로 읽지 않고 검증에만 사용한다. 전화번호는 권한이 있는 상담 업무에서 다시 읽어야 하므로 암호화한다.
어떤 알고리즘을 고를지 묻기 전에 먼저 물어야 할 질문은 “이 서비스가 원본을 다시 알아야 하는가?”이다.
Base64처럼 데이터를 다른 문자열 형식으로 바꾸는 작업도 암호화처럼 보일 수 있다.
const encodedPhoneNumber =
Buffer.from(phoneNumber).toString("base64");
하지만 Base64는 키 없이 누구나 되돌릴 수 있다.
const phoneNumber =
Buffer
.from(encodedPhoneNumber, "base64")
.toString("utf8");
이 코드는 인코딩된 값을 원래 전화번호로 복원한다.
인코딩은 데이터가 특정 통신이나 저장 형식에 맞도록 표현을 변경한다. 비밀을 보호하는 보안 경계가 아니다.
| 방식 | 목적 | 원본 복원 | 비밀 필요 |
|---|---|---|---|
| 인코딩 | 표현 형식 변환 | 가능 | 없음 |
| 암호화 | 권한 있는 주체만 원본 사용 | 가능 | 암호화 키 |
| 해싱 | 입력에서 고정된 검증값 계산 | 일반적으로 불가능 | 보통 없음 |
| 키 기반 해시 | 비밀키를 사용한 무결성·비교 | 불가능 | HMAC 키 |
URL 인코딩, Base64, 압축은 데이터 보호를 대신하지 않는다. 결과가 읽기 어렵게 보인다는 사실과 안전하게 보호된다는 사실을 구분해야 한다.
서비스가 비밀번호를 암호화해서 저장한다고 생각해 보자.
const encryptedPassword =
await cipher.encrypt(password);
로그인 시 암호문을 복호화한 뒤 사용자의 입력과 비교할 수 있다.
const savedPassword =
await cipher.decrypt(user.encryptedPassword);
const matches = savedPassword === inputPassword;
이 구조에서는 암호화 키를 얻은 공격자나 과도한 권한을 가진 내부 코드가 모든 비밀번호를 원문으로 복원할 수 있다.
서비스가 로그인 과정에서 필요한 것은 기존 비밀번호의 원문이 아니다. 사용자가 이번에 입력한 값이 저장 당시의 값과 같은지에 대한 결과다.
const passwordHash =
await passwordHasher.hash(password);
await userRepository.create({
email,
passwordHash,
});
회원가입 시 비밀번호 전용 해시를 저장한다.
const matches =
await passwordHasher.verify(
inputPassword,
user.passwordHash
);
if (!matches) {
throw new InvalidCredentialsError();
}
로그인 시 저장된 비밀번호를 복원하지 않는다. 새 입력이 기존 해시와 일치하는지만 검증한다.
비밀번호 해싱에는 SHA-256처럼 빠른 범용 해시를 직접 사용하지 않아야 한다.
const passwordHash = sha256(password);
빠른 해시는 파일 무결성 확인에는 유용하지만, 공격자가 많은 비밀번호 후보를 빠르게 시도할 수도 있게 한다.
비밀번호에는 계산 비용과 메모리 비용을 조절할 수 있는 전용 알고리즘을 사용해야 한다.
const passwordHash =
await passwordHasher.hash(password, {
algorithm: "argon2id",
});
실제 구현에서는 검증된 라이브러리가 솔트 생성과 알고리즘 매개변수 저장을 관리하도록 해야 한다. 암호 알고리즘을 직접 구현하지 않는 편이 안전하다.
크리스와 다른 사용자가 우연히 같은 비밀번호를 선택할 수 있다.
솔트가 없다면 같은 입력은 같은 해시를 만들 수 있다.
password123 → same-hash
password123 → same-hash
데이터베이스를 본 사람은 원문을 몰라도 두 사용자가 같은 비밀번호를 쓴다는 사실을 알 수 있다. 미리 계산한 해시 목록도 여러 계정에 재사용할 수 있다.
각 비밀번호에 고유한 무작위 솔트를 사용하면 결과가 달라진다.
password123 + salt-a → hash-a
password123 + salt-b → hash-b
현대적인 비밀번호 해싱 라이브러리는 보통 솔트를 자동으로 생성하고 결과 문자열에 필요한 정보를 함께 저장한다.
const passwordHash =
await passwordHasher.hash(password);
이 한 줄의 결과에는 알고리즘, 매개변수, 솔트, 해시 결과를 검증에 필요한 형식으로 포함할 수 있다.
솔트는 비밀일 필요가 없다. 각 비밀번호마다 고유해야 하고 예측하기 어려운 무작위 값이어야 한다.
페퍼는 솔트와 역할이 다르다.
const passwordHash =
await passwordHasher.hash(
combine(password, passwordPepper)
);
페퍼는 여러 비밀번호에 공통으로 적용할 수 있는 별도의 비밀이다. 데이터베이스와 같은 위치에 저장하면 추가 보호 효과가 사라진다.
| 값 | 계정별 고유 | 비밀 여부 | 일반적인 저장 위치 |
|---|---|---|---|
| 솔트 | 예 | 아니요 | 비밀번호 해시와 함께 |
| 페퍼 | 보통 아니요 | 예 | 비밀 관리 시스템 |
| 비밀번호 | 사용자별 | 예 | 저장하지 않음 |
| 비밀번호 해시 | 사용자별 | 원문은 아니지만 보호 필요 | 데이터베이스 |
페퍼는 선택적인 추가 방어선이지 안전하지 않은 해싱 방식을 보완하는 대체재가 아니다.
해싱을 단방향이라고 설명하면 해시값에서 비밀번호를 절대 알아낼 수 없다고 오해할 수 있다.
공격자는 해시를 수학적으로 되돌리는 대신 후보를 직접 계산해 비교할 수 있다.
후보 password1 계산 → 불일치
후보 password2 계산 → 불일치
후보 password123 계산 → 일치
따라서 비밀번호 해시는 다음 조건을 함께 만족해야 한다.
작업 비용은 무조건 높게 설정하는 값도 아니다.
const startedAt = performance.now();
await passwordHasher.hash(samplePassword);
const elapsedMs =
performance.now() - startedAt;
운영 환경과 비슷한 장비에서 측정해 정상적인 로그인은 처리할 수 있으면서 대량 추측에는 비용이 들도록 조정해야 한다.
작업 비용이 지나치게 높으면 공격자가 로그인 요청을 반복해 서버 자원을 고갈시키는 문제도 생길 수 있다. 해싱 설정은 보안성과 서비스 처리 능력 사이의 판단이다.
상담 담당자는 예약자의 전화번호를 확인해야 한다.
전화번호를 해싱하면 원문을 다시 표시할 수 없다.
const phoneNumberHash = hash(phoneNumber);
이 값은 같은 입력을 비교하는 데는 사용할 수 있지만 고객에게 전화를 걸기 위한 번호를 복원할 수 없다.
복원이 필요한 값에는 암호화를 적용한다.
const encryptedPhoneNumber =
await personalDataCipher.encrypt({
plaintext: phoneNumber,
context: {
field: "patient_phone_number",
patientId,
},
});
암호화 결과는 데이터베이스에 저장한다.
await patientRepository.update(patientId, {
encryptedPhoneNumber,
});
권한이 있는 업무에서만 복호화를 수행한다.
await authorizationService.assertCan({
actor: currentUser,
action: "patient:read_contact",
resource: patient,
});
const phoneNumber =
await personalDataCipher.decrypt(
patient.encryptedPhoneNumber
);
암호화는 접근 권한을 대신하지 않는다. 키를 사용할 수 있는 애플리케이션이라도 현재 사용자가 해당 환자의 연락처를 볼 수 있는지 확인해야 한다.
암호화는 저장소가 단독으로 노출되었을 때 원문을 바로 읽지 못하게 한다. 실행 중인 애플리케이션이 정상적으로 복호화한 뒤의 데이터까지 자동으로 보호하지는 않는다.
암호문을 저장했다고 해서 누군가 값을 변경하지 못하는 것은 아니다.
암호화된 전화번호가 조작되었는데 애플리케이션이 이를 감지하지 못하면 잘못된 데이터로 처리될 수 있다.
인증된 암호화 방식은 기밀성과 무결성을 함께 다룬다.
const encryptedValue =
await cipher.encryptAuthenticated({
plaintext: phoneNumber,
associatedData: {
patientId,
field: "phone_number",
},
});
associatedData는 암호화하지 않더라도 암호문이 특정 환자와 필드에 연결되어 있음을 검증하는 데 사용할 수 있다.
const phoneNumber =
await cipher.decryptAuthenticated({
encryptedValue,
associatedData: {
patientId,
field: "phone_number",
},
});
암호문을 다른 환자의 레코드로 복사하거나 내용을 변경하면 검증에 실패해야 한다.
AES-GCM 같은 검증된 인증 암호화 방식을 적절한 라이브러리를 통해 사용할 수 있다. 알고리즘 이름만 선택한다고 안전해지는 것은 아니다. 키 크기, nonce 생성, 재사용 방지, 오류 처리까지 라이브러리와 키 관리 정책이 함께 책임져야 한다.
다음 코드는 암호화 키를 소스 코드에 포함한다.
const encryptionKey =
"consultation-service-secret-key";
저장소나 빌드 결과가 노출되면 데이터와 키를 함께 얻을 수 있다.
데이터베이스에 암호화 키를 함께 저장하는 방식도 비슷한 문제가 있다.
await settingsRepository.save({
encryptionKey,
});
데이터베이스 백업 하나만 탈취해도 암호문과 복호화 키를 함께 얻을 수 있다.
키는 보호 대상 데이터와 다른 경계에서 관리해야 한다.
const dataKey =
await keyManagementService.getKey({
keyId: encryptedRecord.keyId,
});
실제 운영 환경에서는 클라우드 KMS, 비밀 관리 시스템, HSM처럼 접근 제어와 감사 기능을 제공하는 키 관리 수단을 사용할 수 있다.
키 관리에는 값의 저장 위치뿐 아니라 전체 생명주기가 포함된다.
type EncryptedField = {
keyVersion: number;
ciphertext: string;
nonce: string;
authenticationTag: string;
};
keyVersion을 함께 저장하면 어떤 키로 암호화했는지 식별할 수 있다.
암호화 알고리즘보다 키 관리 실패가 전체 보호를 무효화할 수 있다.
운영 중인 데이터가 기존 키로 암호화되어 있다고 생각해 보자.
type EncryptedPatientData = {
keyVersion: 1;
ciphertext: string;
};
설정에서 키를 즉시 새 값으로 덮어쓰면 기존 데이터를 복호화할 수 없게 된다.
키 버전을 구분해야 한다.
const key = await keyManagementService.getKey({
version: encryptedData.keyVersion,
});
새 데이터는 최신 키로 암호화한다.
const latestKey =
await keyManagementService.getLatestKey();
const encryptedData =
await cipher.encrypt({
plaintext: phoneNumber,
key: latestKey,
keyVersion: latestKey.version,
});
기존 데이터는 읽을 때 다시 암호화하거나 별도의 마이그레이션 작업으로 갱신할 수 있다.
if (
encryptedData.keyVersion <
latestKey.version
) {
const plaintext =
await decryptWithPreviousKey(encryptedData);
await saveWithLatestKey(plaintext);
}
이 코드는 이전 키로 읽은 값을 최신 키로 다시 암호화한다.
키 회전에는 다음 상태가 존재할 수 있다.
| 키 상태 | 용도 |
|---|---|
| 활성 | 새 데이터 암호화와 복호화 |
| 이전 | 기존 데이터 복호화만 허용 |
| 폐기 예정 | 마이그레이션 완료 확인 중 |
| 폐기 | 더 이상 사용할 수 없음 |
| 손상 | 즉시 사용 중지와 영향 조사 필요 |
키 회전 정책은 데이터 마이그레이션, 백업 복원, 장애 대응과 함께 설계해야 한다.
전화번호를 무작위성이 있는 방식으로 암호화하면 같은 번호도 매번 다른 암호문이 나올 수 있다.
0412 345 678 → ciphertext-a
0412 345 678 → ciphertext-b
이 성질은 암호문 사이의 관계 노출을 줄인다. 그러나 다음과 같은 검색은 어려워진다.
SELECT *
FROM patients
WHERE encrypted_phone_number = $1;
새로 암호화한 값이 저장된 암호문과 같다는 보장이 없기 때문이다.
검색을 위해 항상 같은 암호문을 만드는 방식을 사용하면 동일한 값의 반복 패턴이 노출될 수 있다.
전화번호나 이메일처럼 가능한 입력을 추측하기 쉬운 값에 일반 해시만 저장하는 것도 충분하지 않다.
const phoneLookup = sha256(
normalizePhoneNumber(phoneNumber)
);
공격자는 가능한 전화번호를 계산해 저장된 값과 비교할 수 있다.
별도의 비밀키를 사용하는 검색용 HMAC 값을 고려할 수 있다.
const normalizedPhoneNumber =
normalizePhoneNumber(phoneNumber);
const phoneLookup =
await lookupKeyService.hmac(
normalizedPhoneNumber
);
서비스는 원문 표시용 암호문과 정확 일치 검색용 값을 따로 저장한다.
await patientRepository.update(patientId, {
encryptedPhoneNumber,
phoneLookup,
});
각 값의 책임이 다르다.
encryptedPhoneNumber는 권한이 있는 사용자가 원문을 복원할 때 사용한다.phoneLookup은 정규화된 번호의 정확 일치 검색에 사용한다.검색용 값도 정보 노출을 완전히 제거하지는 않는다. 같은 입력이 같은 검색값을 만들기 때문에 중복과 조회 패턴이 드러날 수 있다.
민감한 값을 검색 가능하게 만드는 일은 단순한 편의 기능이 아니다. 필요한 검색 종류와 허용할 정보 노출을 결정하는 설계 판단이다.
비밀번호 재설정 토큰은 사용자가 나중에 다시 제출해야 하는 비밀이다.
서버가 토큰 원문을 다시 보여줄 필요는 없다.
const resetToken =
secureRandomToken();
await passwordResetRepository.create({
userId: user.id,
token: resetToken,
expiresAt,
});
원문을 저장하면 데이터베이스 유출 시 아직 유효한 토큰을 바로 사용할 수 있다.
검색 가능한 식별자와 검증용 해시를 나눌 수 있다.
const resetToken =
secureRandomToken();
const tokenHash =
await tokenHasher.hash(resetToken);
await passwordResetRepository.create({
userId: user.id,
tokenHash,
expiresAt,
usedAt: null,
});
사용자에게는 원문 토큰을 한 번 전달하고 서버에는 검증값만 저장한다.
const resetRequest =
await passwordResetRepository.findActive(
requestId
);
const matches =
await tokenHasher.verify(
presentedToken,
resetRequest.tokenHash
);
제출된 토큰이 저장된 검증값과 일치하는지 확인한다.
토큰 자체에 충분한 무작위성이 있다면 비밀번호와 동일한 느린 해싱 정책이 항상 필요한 것은 아니다. 토큰의 생성 방식, 수명, 조회 구조, 공격 가능성을 기준으로 적절한 HMAC이나 해시 방식을 선택해야 한다.
외부 입력은 검증 전까지 신뢰할 수 없다.
const ResetPasswordSchema = z.object({
requestId: z.string().uuid(),
token: z.string().min(32).max(512),
newPassword: z.string().min(12).max(128),
});
const input =
ResetPasswordSchema.parse(request.body);
형식 검증 후 토큰 일치 여부, 만료 시각, 사용 여부를 각각 확인해야 한다.
if (
resetRequest.expiresAt <= new Date() ||
resetRequest.usedAt !== null
) {
throw new InvalidResetRequestError();
}
해싱은 토큰의 만료와 일회성 사용을 대신하지 않는다.
데이터베이스의 전화번호를 암호화해도 브라우저에서 서버로 평문 HTTP 요청을 보내면 네트워크에서 노출될 수 있다.
Browser → HTTP → Server → Encrypted Database
저장 시 암호화와 전송 시 암호화는 서로 다른 구간을 보호한다.
전송 중 보호: TLS
저장 중 보호: 데이터베이스·필드·백업 암호화
사용 중 보호: 권한·프로세스 격리·최소 노출
상담 서비스에서는 값이 다음 흐름을 이동할 수 있다.
flowchart TD
A[사용자 입력] --> B[TLS로 전송]
B --> C[서버 검증과 권한 확인]
C --> D[필요한 순간에만 평문 사용]
D --> E[암호화해 저장]
각 단계에는 다른 보호가 필요하다.
“데이터가 암호화되어 있다”는 한 문장만으로 전체 흐름이 안전하다고 판단할 수 없다.
전화번호를 데이터베이스에 암호화해도 다음 로그가 남으면 원문이 다른 위치에 저장된다.
logger.info("Patient updated", {
patientId,
phoneNumber,
});
운영 로그를 읽을 수 있는 사람과 외부 로그 시스템이 전화번호를 볼 수 있다.
민감한 값 대신 최소한의 사건 정보만 남겨야 한다.
logger.info("Patient contact updated", {
patientId,
changedFields: ["phoneNumber"],
});
이 로그는 변경 사건을 추적하면서 전화번호 원문은 포함하지 않는다.
필요하다면 일부만 마스킹할 수 있다.
const maskedPhoneNumber =
maskPhoneNumber(phoneNumber);
마스킹은 화면이나 로그의 노출을 줄이는 표현 방식이다. 원본 저장을 보호하는 암호화의 대체재는 아니다.
보호 범위에는 다음 위치도 포함되어야 한다.
Source of Truth만 암호화하고 복사본을 평문으로 남기면 실제 공격 표면은 줄어들지 않을 수 있다.
상담사가 전화번호를 확인하는 순간 애플리케이션은 평문을 다뤄야 한다.
const phoneNumber =
await personalDataCipher.decrypt(
patient.encryptedPhoneNumber
);
이 시점부터 전화번호는 메모리, 응답 객체, 화면에 존재할 수 있다.
따라서 암호화 이후에도 다음 통제가 필요하다.
const patientContact =
currentUser.canCallPatient
? {
phoneNumber:
await decryptPhoneNumber(patient),
}
: {
phoneNumber:
maskEncryptedPhoneNumber(patient),
};
사용 목적에 따라 전체 원문과 제한된 표현을 구분한다.
암호화는 권한, 입력 검증, 감사 로그, 보안 모니터링을 대체하지 않는다. 암호화는 여러 방어선 중 하나다.
환자의 전화번호 원문, 암호문, 검색용 HMAC, 마스킹된 번호가 함께 존재할 수 있다.
type ProtectedPhoneNumber = {
encryptedValue: string;
lookupValue: string;
maskedValue: string;
keyVersion: number;
};
각 값이 독립적인 원본처럼 취급되면 갱신 시 불일치가 발생할 수 있다.
사용자가 전화번호 변경
↓
암호문 갱신 성공
↓
검색값 갱신 실패
↓
이전 번호로 검색되는 문제
하나의 변경 작업에서 관련 값을 함께 갱신해야 한다.
await database.transaction(async (tx) => {
const protectedPhone =
await protectPhoneNumber(newPhoneNumber);
await tx.patients.update(patientId, {
encryptedPhoneNumber:
protectedPhone.encryptedValue,
phoneLookup:
protectedPhone.lookupValue,
encryptionKeyVersion:
protectedPhone.keyVersion,
});
});
이 구조에서 사용자가 제공한 현재 전화번호의 의미는 암호화된 필드가 보존한다. 검색값은 그 원본에서 파생된 조회 구조다.
마스킹된 문자열도 Source of Truth가 아니다.
0412 345 678 → 04•• ••• 678
마스킹된 값에서는 원본을 복원할 수 없으며, 다른 번호가 같은 표시 결과를 만들 수도 있다.
보호된 데이터 구조를 설계할 때는 어느 값이 원본의 의미를 보존하고 어느 값이 검색이나 표시에 사용되는 파생 값인지 명확히 해야 한다.
Base64는 키 없이 되돌릴 수 있는 표현 형식이다.
민감한 데이터 보호에는 적절한 암호화와 키 관리가 필요하다.
복호화 키가 노출되면 모든 비밀번호를 원문으로 얻을 수 있다.
비밀번호는 복원하지 않고 전용 비밀번호 해시로 검증해야 한다.
빠른 범용 해시는 많은 후보를 빠르게 계산할 수 있다.
Argon2id처럼 비밀번호 저장용으로 설계된 알고리즘과 적절한 작업 비용을 사용해야 한다.
같은 비밀번호가 같은 결과를 만들고 대량 공격 비용을 줄일 수 있다.
계정마다 고유한 무작위 솔트를 사용해야 한다.
코드 저장소와 배포 결과가 노출될 때 키도 함께 노출된다.
키는 별도의 키·비밀 관리 경계에서 관리해야 한다.
애플리케이션은 정상 기능을 위해 데이터를 복호화할 수 있다.
복호화 전에도 현재 사용자의 자원 접근 권한을 확인해야 한다.
전화번호와 이메일은 후보를 추측해 해시를 비교하기 쉽다.
검색 요구사항과 위협을 검토하고 비밀키 기반의 검색값을 고려해야 한다.
로그, 백업, 이벤트가 더 쉬운 유출 경로가 될 수 있다.
데이터가 복제되는 모든 위치에 일관된 보호 정책을 적용해야 한다.
프로그래밍을 처음 배울 때는 암호화와 해싱을 데이터를 읽기 어려운 문자열로 바꾸는 기술이라고 이해해도 충분하다.
const protectedValue = protect(value);
입문 단계에서는 변화된 결과의 모양이 비슷해 보일 수 있다.
하지만 실제 서비스에서는 원본을 다시 사용해야 하는지가 가장 중요한 출발점이다.
암호화와 해싱은 데이터를 알아볼 수 없게 만드는 기술이 아니다.
암호화와 해싱은 데이터가 다시 복원되어야 하는지, 비교만 가능해야 하는지에 따라 민감한 정보의 사용 방식과 노출 범위를 제한하는 설계 도구다.