세 개념은 자주 섞여 쓰이지만 목적이 전혀 다릅니다. 표로 구분합니다.
| 구분 | 목적 | 원본 복원 | 예 |
|---|---|---|---|
| 인코딩 | 데이터 형식 변환 | 항상 가능 | Base64, URL 인코딩 |
| 해시 | 무결성·비밀번호 검증 | 불가능(일방향) | SHA-256, PBKDF2 |
| 암호화 | 기밀성 보호, 필요 시 복원 | 키가 있으면 가능 | AES-GCM, RSA |
Base64 는 암호화가 아닙니다. 누구나 디코딩할 수 있는 인코딩일 뿐이므로, "Base64 로 암호화했다"는 표현은 코드 리뷰에서 반드시 지적해야 할 오해입니다.
법령 조문을 전부 외울 필요는 없지만, 개발자가 코드로 직접 구현해야 하는 부분은 명확합니다. 네 가지로 요약합니다.
이 중 "암호화하여 저장"에 해당하는 항목을 이 레슨에서 AES-256-GCM 으로 구현합니다. 접근 기록 보관은 별도 로깅·감사 체계의 영역이라 이 레슨 범위 밖입니다.
GCM(Galois/Counter Mode) 은 암호화와 무결성 검증을 동시에 제공하는 인증 암호화(AEAD) 방식입니다. 네 요소로 구성됩니다.
IV 재사용은 GCM 에서 가장 치명적인 실수입니다. 같은 키로 같은 IV 를 두 번 쓰면 암호문에서 평문 일부와 인증키가 복원될 수 있어, 반드시 SecureRandom 으로 매번 새로 생성해야 합니다.
기존 시스템에서 AES/CBC/PKCS5Padding 을 쓰는 경우를 여전히 자주 봅니다. CBC 는 무결성 검증이 없어서, 공격자가 암호문을 조작한 뒤 패딩 오류 메시지 유무로 평문을 한 바이트씩 복원하는 패딩 오라클 공격에 취약합니다.
신규 개발은 예외 없이 GCM 을 씁니다. 기존 CBC 암호문은 한꺼번에 재암호화하기보다, 읽을 때 CBC 로 복호화하고 쓸 때 GCM 으로 다시 암호화하는 지연 마이그레이션이 실무에서 부담이 적습니다.
키를 코드나 Git 저장소에 넣는 것은 암호화를 하지 않은 것과 같습니다. 실무에서 쓰는 방법을 우선순위로 정리합니다.
CRYPTO_KEY_V2 등)로 배포 시 주입키 교체(key rotation) 는 유출 의심이나 정기 보안 정책에 따라 발생합니다. 새 키로 새 데이터를 암호화하면서 기존 암호문은 그대로 두고, 접두어(v1:, v2:)로 어떤 키를 썼는지 구분해 점진적으로 재암호화하는 방식이 안전합니다.
이 레슨의 저장 형식은 "v{버전}:" + Base64(IV 12바이트 + 암호문 + 태그 16바이트) 입니다. 버전 접두어 덕분에 여러 키가 섞여 있어도 복호화 시 올바른 키를 자동으로 선택할 수 있습니다.
Oracle VARCHAR2 컬럼 길이는 원문 길이 기준으로 넉넉히 잡아야 합니다. 대략적인 계산식은 아래와 같습니다.
Base64 길이 = ceil((IV 12 + 원문바이트 + 태그 16) / 3) * 4
저장 길이 = 버전 접두어 길이 + 1(콜론) + Base64 길이예를 들어 주민등록번호(13바이트) 는 접두어 포함 저장 길이가 약 59자입니다. 여유를 두어 VARCHAR2(100) 정도로 잡는 편이 안전합니다.
GCM 암호문은 IV 가 매번 달라 같은 평문도 다른 암호문이 되므로, 암호화된 컬럼을 WHERE 컬럼 = ? 으로 직접 검색할 수 없습니다. 해결책은 별도의 검색용 해시 컬럼입니다.
같은 값을 HMAC-SHA256 으로 해시하면 항상 같은 결과가 나오므로, 이 해시를 별도 컬럼에 저장하고 검색은 해시 컬럼으로 합니다. 다만 HMAC 은 동등 비교만 가능하고, LIKE 부분 검색이나 정렬은 지원하지 않는다는 한계가 있습니다.
TypeHandler 를 등록하면 매퍼 XML 이나 서비스 코드에서 암복호화를 신경 쓰지 않아도 됩니다. setParameter 에서 암호화하고 getResult 에서 복호화하는 구조입니다.
@MappedTypes(String.class)
public class EncryptedStringHandler extends BaseTypeHandler<String> {
private final Crypto crypto; // 생성자로 주입
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
String parameter, JdbcType type) throws SQLException {
ps.setString(i, crypto.encrypt(parameter));
}
@Override
public String getNullableResult(ResultSet rs, String column)
throws SQLException {
String token = rs.getString(column);
return token == null ? null : crypto.decrypt(token);
}
}매퍼 컬럼에 typeHandler="EncryptedStringHandler" 를 지정하면 애플리케이션 코드는 평문만 다루고, DB 에는 암호문만 저장됩니다.
암호화는 저장소 보호, 마스킹은 화면·로그 노출 방지로 역할이 다릅니다. 두 개는 동시에 필요하고 서로 대체할 수 없습니다.
콜센터 상담원 화면에 주민번호를 그대로 띄우면, DB 는 암호화됐어도 화면 캡처나 로그 유출로 개인정보가 노출됩니다. 조회 화면은 항상 마스킹된 값을 기본으로 하고, 필요한 경우만 별도 권한 확인 후 복호화된 값을 보여줍니다.
예외 메시지나 디버그 로그에 평문 개인정보를 그대로 찍는 실수가 흔합니다. log.debug("rrn={}", rrn) 같은 코드는 운영 로그 서버에 평문이 영구히 남습니다.
힙 덤프도 같은 문제입니다. 장애 분석용 힙 덤프에는 그 시점 메모리의 모든 문자열이 그대로 담기므로, 가능하면 평문 보유 시간을 최소화하고 char[] 를 다 쓴 뒤 지우는 방식을 검토합니다.
SecureRandom 과 new Random() 은 반드시 구분해야 합니다. Random 은 시드만 알면 다음 값을 예측할 수 있어 IV·키 생성에 쓰면 안 되고, 암호학적으로 안전한 SecureRandom 만 사용합니다.
Spring Boot 프로젝트에서 application.yml 의 DB 비밀번호를 평문으로 두면 Git 저장소나 배포 파일에 그대로 노출됩니다. Jasypt 라이브러리를 쓰면 password: ENC(암호화된값) 형태로 설정 파일에 저장할 수 있습니다.
spring:
datasource:
password: ENC(3sQK...)
jasypt:
encryptor:
password: ${JASYPT_KEY} # JVM 옵션이나 환경 변수로 주입암호화 키(JASYPT_KEY) 는 설정 파일이 아닌 JVM 실행 옵션(-Djasypt.encryptor.password=...) 이나 환경 변수로 주입해야 설정 파일만 유출됐을 때도 안전합니다. 비밀번호 자체의 해시 저장(PBKDF2) 은 인증 01 레슨에서 다루므로 이 레슨은 반복하지 않습니다.