Home

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가 엔티티 메타데이터를 파싱하면서 호출돼요. 이 시점에 secretKeyNamefieldType이 확정되고, 필요한 키를 레지스트리에 올려둡니다.

AttributeConverter였다면 Long 컬럼 하나 생길 때마다 AttributeConverter<Long, String>을 따로 만들어야 했는데, UserTypereader.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
            );
        }
    }
}

정리

AttributeConverterUserType
표준JPA (이식성 높음)Hibernate 전용
필드별 파라미터불가DynamicParameterizedType으로 가능
타입별 클래스타입마다 별도 작성 필요하나로 처리
어노테이션@Convert 별도 필요@Type 포함 합성 가능
키 목록 관리별도 선언 필요어노테이션에서 자동 수집

암호화 키가 서비스 전체에서 하나로 고정이라면 AttributeConverter가 훨씬 단순하고 충분해요. 키를 컬럼별로 나눠야 한다거나, 키 검증을 코드 수준에서 강제하고 싶을 때, 그때 UserType을 한 번 고려해보세요.

@EncryptedColumn(key = "users.phone_number") 한 줄로 어떤 키를 쓰는지 선언하고, 앱이 뜨는 시점에 그 키들이 Secrets Manager에 전부 있는지 자동으로 확인하는 흐름이 갖춰지면, 암호화 관련 실수가 운영에서 터지는 일이 확실히 줄어드는 걸 느꼈습니다.