Hibernate에서 암호화 컬럼을 제대로 구현하는 법
JPA로 암호화 컬럼을 구현해야 할 때 대부분 AttributeConverter부터 찾습니다. 예제도 많고, 코드도 짧고, Spring Bean으로 등록하면 @Autowired도 바로 되니까요. 저도 처음엔 그렇게 시작했는데, 운영하다 보면 이런 얘기들이 슬슬 나옵니다. "이 컬럼이랑 저 컬럼은 키를 분리해야 한다", "컨버터 클래스가 대여섯 개를 넘어가는데 맞나요", "배포하고 나서 키 오류가 났는데 왜 시작할 때는 멀쩡했죠". 그 순간부터 AttributeConverter가 조금 불편해지기 시작합니다.
AttributeConverter의 구조적 한계
@Component
public class EncryptionConverter implements AttributeConverter<String, String> {
@Autowired
private EncryptionService encryptionService;
@Override
public String convertToDatabaseColumn(String value) {
return encryptionService.encrypt(value); // 키가 하나로 고정됨
}
@Override
public String convertToEntityAttribute(String dbValue) {
return encryptionService.decrypt(dbValue);
}
}
AttributeConverter는 변환 로직만 담당하고, 지금 어떤 엔티티의 어떤 필드를 처리하는지 알 방법이 없어요. 컨텍스트가 없습니다. 그러다 보니 컬럼마다 키를 다르게 쓰려면 PhoneNumberEncryptionConverter, EmailEncryptionConverter 이런 식으로 클래스를 하나씩 만들어야 해요. Long 타입 컬럼이 생기면 AttributeConverter<Long, String>도 하나 추가하고요. 컬럼 늘어나면 Converter도 같이 늘어납니다.
키 검증도 비슷한 문제가 있어요. EncryptionService 생성자에 키 목록을 주입받아서 부트 시점에 검증하는 건 됩니다. 근데 그 키 목록을 어딘가에 따로 선언해야 한다는 게 번거로워요. 어떤 컬럼이 어떤 키를 쓰는지는 각 Converter 클래스 안에 흩어져 있어서 한눈에 볼 수 있는 곳이 없거든요. 새 컬럼 추가할 때 키 목록 업데이트를 빠뜨려도 티가 안 나고, 그 컬럼만 조용히 검증 없이 넘어갑니다.
UserType + DynamicParameterizedType
Hibernate에는 UserType이라는 더 낮은 레이어의 인터페이스가 있어요. JPA 표준이 아니라 Hibernate 전용이긴 한데, Spring에서 JPA 쓴다면 어차피 Hibernate 쓰는 거니까 사실 별 상관없습니다. UserType은 Hibernate 초창기부터 있던 커스텀 타입 매핑 인터페이스고, AttributeConverter는 JPA 2.1에서 나중에 추가된 더 단순한 버전이에요. 간단한 변환엔 AttributeConverter로 충분한데, 더 세밀하게 제어하고 싶을 때 UserType을 꺼내게 됩니다.
여기에 DynamicParameterizedType을 같이 구현하면, Hibernate가 초기화할 때 setParameterValues()를 호출하면서 해당 필드에 붙은 어노테이션 정보를 넘겨줍니다. 이게 핵심이에요.
public class EncryptionType implements UserType<Object>, DynamicParameterizedType {
public static final Set<String> REGISTERED_SECRET_KEYS = ConcurrentHashMap.newKeySet();
private String secretKeyName;
private Class<?> fieldType;
@Override
public void setParameterValues(Properties parameters) {
ParameterType reader = (ParameterType) parameters.get(PARAMETER_TYPE);
this.fieldType = reader.getReturnedClass();
EncryptedColumn ann = findAnnotation(parameters, EncryptedColumn.class);
if (ann != null) {
this.secretKeyName = ann.key();
}
// key()가 없으면 컬럼명을 기본값으로
if (Strings.isBlank(secretKeyName)) {
this.secretKeyName = reader.getColumns()[0];
}
REGISTERED_SECRET_KEYS.add(this.secretKeyName);
}
@Override
public Object nullSafeGet(ResultSet rs, int position,
SharedSessionContractImplementor session, Object owner) throws SQLException {
String value = rs.getString(position);
if (value == null) return null;
String keyValue = getSecretsManager(session).getSecretValue(secretKeyName);
String decrypted = decrypt(value, keyValue);
return convert(decrypted, fieldType);
}
@Override
public void nullSafeSet(PreparedStatement st, Object value, int index,
SharedSessionContractImplementor session) throws SQLException {
if (value == null) {
st.setNull(index, Types.VARCHAR);
return;
}
String keyValue = getSecretsManager(session).getSecretValue(secretKeyName);
st.setString(index, encrypt(value.toString(), keyValue));
}
private Object convert(String value, Class<?> type) {
if (type == Long.class || type == long.class) return Long.parseLong(value);
if (type == Integer.class || type == int.class) return Integer.parseInt(value);
return value;
}
private SecretsManager getSecretsManager(SharedSessionContractImplementor session) {
var registry = session.getFactory().getServiceRegistry();
var beans = registry.getService(ManagedBeanRegistry.class);
return beans.getBeanContainer() != null
? beans.getBean(SecretsManager.class).getBeanInstance()
: SpringContextHolder.getBean(SecretsManager.class);
}
private <A extends Annotation> A findAnnotation(Properties parameters, Class<A> type) {
ParameterType reader = (ParameterType) parameters.get(PARAMETER_TYPE);
for (Annotation ann : reader.getAnnotationsMethod()) {
if (type.isInstance(ann)) return type.cast(ann);
}
return null;
}
}
setParameterValues()는 앱 뜰 때 Hibernate가 엔티티 메타데이터를 파싱하면서 호출돼요. 이 시점에 secretKeyName과 fieldType이 확정되고, 필요한 키를 레지스트리에 올려둡니다.
AttributeConverter였다면 Long 컬럼 하나 생길 때마다 AttributeConverter<Long, String>을 따로 만들어야 했는데, UserType은 reader.getReturnedClass()로 필드 타입을 알 수 있어서 convert() 한 곳에서 처리됩니다.
getSecretsManager()에서 getBeanContainer()가 null이면 SpringContextHolder로 폴백해요. Spring Boot + JPA를 그냥 쓰면 Hibernate가 Spring 빈 컨테이너랑 연결돼 있어서 null이 나올 일이 없는데, 간혹 그 연결이 빠진 환경도 있거든요. SpringContextHolder는 그럴 때 쓰는 안전망으로, ApplicationContextAware를 구현해서 ApplicationContext를 정적으로 들고 있는 빈이에요.
@EncryptedColumn 합성 어노테이션
@Type을 포함한 합성 어노테이션으로 만들면, 필드에 어노테이션 하나만 달면 끝이에요.
@Retention(RetentionPolicy.RUNTIME)
@Type(value = EncryptionType.class)
public @interface EncryptedColumn {
String key() default "";
}
엔티티는 이렇게 씁니다.
@Entity
@Table(name = "users")
public class User {
@EncryptedColumn(key = "users.phone_number")
private String phoneNumber;
@EncryptedColumn(key = "users.email")
private String email;
}
@Entity
@Table(name = "orders")
public class Order {
@EncryptedColumn(key = "orders.receiver_phone")
private String receiverPhone;
}
키 이름을 테이블명.컬럼명으로 통일하면, 코드만 봐도 어떤 컬럼이 어떤 키를 쓰는지 바로 보여요. key()를 생략하면 컬럼명이 그대로 키 이름이 됩니다.
혹시 users.phone_number 키가 노출되더라도 orders.receiver_phone은 다른 키니까 영향이 없어요. 피해가 컬럼 단위로 딱 잘립니다.
검색용 단방향 해시도 같은 방식으로 만들었어요.
@EncryptedColumn(key = "users.phone_number")
private String phoneNumber; // 복호화 가능, 화면 표시용
@HashedColumn(seed = "users.phone_number")
private String phoneNumberHash; // 단방향, WHERE 검색용
원본은 @EncryptedColumn으로 암호화해서 넣고, 검색은 @HashedColumn으로 뽑은 해시로 하는 식이에요.
AWS Secrets Manager 연동
키 이름을 테이블명.컬럼명으로 쓰기로 했다면, Secrets Manager에도 같은 구조로 저장해두면 됩니다. 키 이름이 그대로 시크릿의 필드명이 되는 거라서 매핑이 따로 필요 없어요.
# AWS Secrets Manager에 저장된 시크릿 (JSON)
{
"users.phone_number": "k1Xq9...",
"users.email": "p2Yr8...",
"orders.receiver_phone": "m3Zs7..."
}
SecretsManager는 앱 시작할 때 시크릿을 한 번 읽어서 Map<String, String>으로 들고 있어요.
@Component
public class SecretsManager {
private final Map<String, String> secretValues;
public SecretsManager(SecretsManagerProperties props) {
SecretsManagerClient client = SecretsManagerClient.builder()
.region(Region.of(props.getRegionName()))
.build();
GetSecretValueResponse response = client.getSecretValue(
GetSecretValueRequest.builder()
.secretId(props.getSecretName())
.build()
);
this.secretValues = objectMapper.readValue(response.secretString(), Map.class);
}
public String getSecretValue(String keyName) {
return secretValues.get(keyName);
}
}
EncryptionType은 Hibernate 레이어라 Spring 빈을 직접 모릅니다. 그래서 ManagedBeanRegistry를 통해 SecretsManager를 꺼내서 키를 조회하고, AES 암호화를 합니다.
부트 시점에 키 검증
setParameterValues()에서 모은 키 목록을 활용해서, 앱이 완전히 뜨기 전에 키를 한 번 다 확인합니다. ApplicationRunner를 쓰면 SpringApplication.run() 끝나기 전에 실행되고, 예외를 던지면 앱이 그냥 안 떠요.
알고 싶은 건 딱 하나, Secrets Manager에 이 키 이름이 있는가입니다.
public class EncryptionKeyValidator implements ApplicationRunner {
private final SecretsManager secretsManager;
@Override
public void run(ApplicationArguments args) {
Set<String> keys = EncryptionType.REGISTERED_SECRET_KEYS;
List<String> missingKeys = keys.stream()
.filter(key -> secretsManager.getSecretValue(key) == null)
.toList();
if (!missingKeys.isEmpty()) {
throw new IllegalStateException(
"Missing encryption keys in Secrets Manager: " + missingKeys
);
}
}
}
정리
| AttributeConverter | UserType | |
|---|---|---|
| 표준 | JPA (이식성 높음) | Hibernate 전용 |
| 필드별 파라미터 | 불가 | DynamicParameterizedType으로 가능 |
| 타입별 클래스 | 타입마다 별도 작성 필요 | 하나로 처리 |
| 어노테이션 | @Convert 별도 필요 | @Type 포함 합성 가능 |
| 키 목록 관리 | 별도 선언 필요 | 어노테이션에서 자동 수집 |
암호화 키가 서비스 전체에서 하나로 고정이라면 AttributeConverter가 훨씬 단순하고 충분해요. 키를 컬럼별로 나눠야 한다거나, 키 검증을 코드 수준에서 강제하고 싶을 때, 그때 UserType을 한 번 고려해보세요.
@EncryptedColumn(key = "users.phone_number") 한 줄로 어떤 키를 쓰는지 선언하고, 앱이 뜨는 시점에 그 키들이 Secrets Manager에 전부 있는지 자동으로 확인하는 흐름이 갖춰지면, 암호화 관련 실수가 운영에서 터지는 일이 확실히 줄어드는 걸 느꼈습니다.