Spring Boot + JPA 환경에서 사용자 개인정보 암복호화 및 안전한 검색 구현 (AES-256 & Blind Index)
서비스를 개발하고 운영하다 보면 개인정보보호법 및 보안 컴플라이언스(K-ISMS, ISO 27001 등)에 따라 주민등록번호, 계좌번호, 전화번호, 이메일과 같은 개인정보의 안전한 암호화 저장이 필수가 됩니다.
단순히 “DB에 저장하기 전에 암호화하고, 꺼내올 때 복호화하면 되지 않나?”라고 생각하기 쉽지만, 실무에서는 다음과 같은 복잡한 문제들에 직면합니다:
- 비즈니스 로직 침투 문제: Service 레이어나 Controller 레이어마다 일일이 암복호화 코드가 들어가면 코드가 지저분해지고 누락 실수가 발생합니다.
- 보안성(암호화 알고리즘): 동일한 평문이 항상 같은 암호문으로 치환되는 취약한 ECB 모드 대신 안전한 AES-256-GCM을 적용해야 합니다.
- 암호화된 데이터 검색 문제 (가장 중요): AES-GCM은 무작위 IV(Initialization Vector)를 사용하므로 매번 암호문이 바뀝니다. 따라서
WHERE phone = :encryptedPhone검색이 불가능해집니다.
이번 포스팅에서는 Spring Boot 2.4.3 + Spring Security + Spring Data JPA 환경에서 JPA AttributeConverter를 통해 투명하게 암복호화를 처리하고, Blind Index(블라인드 인덱스) 기법으로 암호화된 개인정보를 빠르게 검색하는 실무 아키텍처를 정리합니다.
환경 정보
- Spring Boot 2.4.3 / Hibernate 5.4.27.Final (Java EE 기반 →
javax.*패키지 사용)- Java 11 (LTS)
javax.crypto/javax.security— JCA/JCE는 Java SE 영역이므로 Spring Boot 버전에 무관하게 동일합니다.⚠️ Java 버전 주의사항
record키워드는 Java 16+ 정식 지원 (Java 14~15는 preview). Java 11에서는 사용 불가.HexFormat클래스는 Java 17+에서만 사용 가능. 본 포스팅은 Java 11 호환 코드로 작성합니다.- Spring Boot 3.x로 전환 시
javax.*→jakarta.*패키지 전환이 필요합니다.
전체 아키텍처 개요
graph TD
A[CryptoConfig] -->|@Bean| B[AesGcmCrypto]
A -->|@Bean| C[BlindIndexUtil]
A -->|@Bean| D[PasswordEncoder]
E[HibernateConfig] -->|SpringBeanContainer 연결| F[PrivacyEncryptConverter]
B -->|@Qualifier 생성자 주입| F
F -->|@Convert 자동 암복호화| G[Member Entity]
C -->|이메일·전화 인덱스 생성| H[MemberService]
D -->|비밀번호 해싱| H
H -->|save / findBy...Index| I[(Database)]
G --> I
HibernateConfig가 없으면 Hibernate 5에서PrivacyEncryptConverter에 Spring 의존성이 주입되지 않습니다.@Qualifier("aesGcmCrypto")로 빈 이름을 명시하면 키 로테이션 시 다수의AesGcmCrypto빈이 등록돼도 충돌이 발생하지 않습니다.
1. 의존성 설정 (pom.xml)
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.3</version>
<relativePath/>
</parent>
<dependencies>
<!-- Web MVC + Jackson (JSON 역직렬화에 필수) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA / Hibernate 5.4.27.Final -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Security (PasswordEncoder) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring ORM — SpringBeanContainer 포함 (HibernateConfig에서 필요)
spring-boot-starter-data-jpa가 transitively 포함하므로 보통 자동으로 제공됨.
명시적으로 선언하면 의존성 의도가 명확해짐. -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
</dependency>
<!-- Bean Validation (입력값 검증: @NotBlank, @Email 등) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
2. 단방향 vs 양방향 암호화 기준 정립
개인정보 필드의 성격에 따라 암호화 방식이 완전히 달라져야 합니다.
| 구분 | 대상 데이터 | 방식 | 권장 알고리즘 | 복호화 필요 여부 |
|---|---|---|---|---|
| 단방향 암호화 | 비밀번호 | 해시 (Hash + Salt) | BCrypt, Argon2, PBKDF2 | 불가능 (단방향) |
| 양방향 암호화 | 전화번호, 이메일, 계좌번호, 주민번호 등 | 대칭키 암호화 | AES-256-GCM | 필요 (복호화 가능) |
- 비밀번호: 절대 복호화할 수 없어야 하므로 Spring Security의
PasswordEncoder(BCryptPasswordEncoder)를 사용합니다. - 개인정보: SMS 발송, 본인 인증, 이메일 발송 등을 위해 원문 복원이 필요하므로 안전한 AES-256-GCM 대칭키 암호화를 사용합니다.
3. 왜 AES-256-GCM인가?
과거에는 AES/CBC/PKCS5Padding 모드를 주로 사용했으나, CBC 모드는 데이터 무결성 검증을 위해 별도의 HMAC을 붙여야 하고 패딩 오라클 공격(Padding Oracle Attack)에 취약할 수 있습니다.
반면 AES-GCM (Galois/Counter Mode)은:
- AEAD (Authenticated Encryption with Associated Data)를 지원하여 암호화와 무결성/인증 검증(Authentication Tag)을 동시에 수행합니다.
- 패딩을 사용하지 않아 패딩 공격으로부터 안전합니다.
- 매번 12바이트의 무작위 IV(Nonce)를 생성하여 암호문과 함께 보관하므로, 동일한 평문이라도 매번 완전히 다른 암호문이 생성되어 빈도 분석 공격을 원천 차단합니다.
4. AES-256-GCM 암복호화 컴포넌트 (순수 POJO)
유틸리티 클래스에 @Component나 @Value를 직접 붙이지 않고 순수 POJO로 작성합니다.
이렇게 하면 스프링 컨텍스트를 띄우지 않고도 초경량 단위 테스트를 작성할 수 있습니다.
package com.example.security.crypto;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
// 스프링 어노테이션 없는 순수 POJO — CryptoConfig에서 @Bean으로 등록
public class AesGcmCrypto {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_LENGTH = 128; // bit
private static final int IV_LENGTH = 12; // 12 bytes recommended for GCM
private final SecretKey secretKey;
private final SecureRandom secureRandom = new SecureRandom();
/**
* @param secretKeyBase64 Base64 인코딩된 32바이트(256비트) AES 키
* 생성: openssl rand -base64 32
*/
public AesGcmCrypto(String secretKeyBase64) {
byte[] decodedKey = Base64.getDecoder().decode(secretKeyBase64);
if (decodedKey.length != 32) {
throw new IllegalArgumentException(
"AES-256 키는 반드시 32바이트(256비트)여야 합니다. 현재: " + decodedKey.length + "바이트");
}
this.secretKey = new SecretKeySpec(decodedKey, ALGORITHM);
}
/**
* 평문 -> AES-256-GCM 암호화 (IV + CipherText + Tag) -> Base64
*
* <p><b>빈 문자열 처리 정책</b>: null은 null로, "" (빈 문자열)은 ""로 그대로 반환합니다.
* DB에 빈 문자열이 저장될 수 있는 경우, 서비스 레이어에서 빈 문자열 입력을 사전에 차단하세요.
*/
public String encrypt(String plainText) {
if (plainText == null || plainText.isEmpty()) {
return plainText; // null → null, "" → "" (암호화 없이 그대로 반환)
}
try {
byte[] iv = new byte[IV_LENGTH];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
// IV(12바이트) + 암호문(가변) 결합 후 Base64 인코딩
ByteBuffer buf = ByteBuffer.allocate(iv.length + cipherText.length);
buf.put(iv);
buf.put(cipherText);
return Base64.getEncoder().encodeToString(buf.array());
} catch (Exception e) {
throw new IllegalStateException("데이터 암호화 중 오류 발생", e);
}
}
/**
* Base64 암호문 -> IV 분리 -> AES-256-GCM 복호화 -> 평문
*
* <p><b>빈 문자열 처리 정책</b>: null은 null로, "" (빈 문자열)은 ""로 그대로 반환합니다.
* encrypt()와 동일한 정책으로 대칭성을 유지합니다.
*/
public String decrypt(String encryptedBase64) {
if (encryptedBase64 == null || encryptedBase64.isEmpty()) {
return encryptedBase64; // null → null, "" → "" (복호화 없이 그대로 반환)
}
try {
byte[] cipherMessage = Base64.getDecoder().decode(encryptedBase64);
if (cipherMessage.length < IV_LENGTH) {
throw new IllegalArgumentException("유효하지 않은 암호문 길이입니다.");
}
ByteBuffer buf = ByteBuffer.wrap(cipherMessage);
byte[] iv = new byte[IV_LENGTH];
buf.get(iv);
byte[] cipherText = new byte[buf.remaining()];
buf.get(cipherText);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new IllegalStateException("데이터 복호화 중 오류 발생", e);
}
}
}
5. Blind Index 유틸 (순수 POJO)
주의:
Mac인스턴스는 스레드 안전하지 않습니다. 매 호출마다Mac.getInstance()로 새 인스턴스를 생성하는 것이 올바른 패턴입니다.SecretKeySpec은 불변(Immutable) 객체이므로 필드로 공유해도 안전합니다.
AES 키 vs HMAC 키 처리 방식 비교
| 키 종류 | 생성 명령 | CryptoConfig에서의 처리 |
이유 |
|---|---|---|---|
AES 키 (secret-key) |
openssl rand -base64 32 |
Base64.decode() → 32바이트 |
AES는 정확히 32바이트 필요 |
HMAC 키 (blind-index-key) |
openssl rand -hex 64 |
getBytes(UTF_8) → 64바이트 |
HMAC-SHA256은 32바이트 이상이면 유효 |
이메일과 전화번호의 정규화 규칙은 달라야 합니다
잘못된 접근: 단일 메서드에서 점(.)까지 제거하면 이메일이 깨집니다.
// ❌ 잘못된 정규화 예시
"user@example.com" → "user@examplecom" (도메인의 점을 제거해버림!)
올바른 접근: 도메인 유형별 정규화 메서드를 분리합니다.
package com.example.security.crypto;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
// 스프링 어노테이션 없는 순수 POJO — CryptoConfig에서 @Bean으로 등록
public class BlindIndexUtil {
private static final String HMAC_SHA256 = "HmacSHA256";
private static final int MIN_KEY_BYTES = 32; // 최소 32바이트(256비트)
// SecretKeySpec은 불변(Immutable) — 필드 공유 안전
private final SecretKeySpec keySpec;
/**
* @param indexKey 최소 32바이트 이상의 HMAC 키 원문(UTF-8)
* 생성: openssl rand -hex 64 (64 hex chars = 64 ASCII bytes)
* 주의: AES 키처럼 Base64 디코딩하지 않고 UTF-8 바이트 그대로 사용합니다.
*/
public BlindIndexUtil(String indexKey) {
byte[] keyBytes = indexKey.getBytes(StandardCharsets.UTF_8);
if (keyBytes.length < MIN_KEY_BYTES) {
throw new IllegalArgumentException(
"Blind Index 키는 최소 32바이트(256비트) 이상이어야 합니다. 현재: "
+ keyBytes.length + "바이트. openssl rand -hex 64 로 생성하세요.");
}
this.keySpec = new SecretKeySpec(keyBytes, HMAC_SHA256);
}
/**
* 이메일용 블라인드 인덱스 생성.
* 정규화: 앞뒤 공백 제거 + 소문자 변환만 적용.
* 이메일 주소의 점(.)과 @ 기호는 도메인 식별자이므로 제거하지 않습니다.
*
* <p>예: "User@Example.COM" → "user@example.com"
*/
public String generateEmailIndex(String email) {
if (email == null || email.trim().isEmpty()) {
return null;
}
String normalized = email.trim().toLowerCase();
return computeHmac(normalized);
}
/**
* 전화번호용 블라인드 인덱스 생성.
* 정규화: 선두의 + 기호(국제번호)는 유지하고, 나머지는 숫자만 남깁니다.
* 중간·끝의 + 기호는 제거하여 비정상 입력을 방어합니다.
*
* <p>예: "010-1234-5678" → "01012345678"
* <p>예: "+82-10-1234-5678" → "+821012345678"
*/
public String generatePhoneIndex(String phoneNumber) {
if (phoneNumber == null || phoneNumber.trim().isEmpty()) {
return null;
}
String trimmed = phoneNumber.trim();
// 선두의 + 기호만 유지 (국제번호 형식), 나머지는 숫자만 추출
boolean hasLeadingPlus = trimmed.startsWith("+");
String digits = trimmed.replaceAll("[^0-9]", "");
String normalized = hasLeadingPlus ? "+" + digits : digits;
return computeHmac(normalized);
}
/**
* HMAC-SHA256 해시 계산 공통 메서드.
* Mac은 스레드 안전하지 않으므로 호출마다 새 인스턴스를 생성합니다.
*
* @return HEX 소문자 64자리 문자열 (HMAC-SHA256 = 32바이트 = HEX 64자)
*/
private String computeHmac(String normalized) {
try {
// Mac은 스레드 안전하지 않으므로 매번 새 인스턴스 생성
Mac mac = Mac.getInstance(HMAC_SHA256);
mac.init(keySpec);
byte[] hashBytes = mac.doFinal(normalized.getBytes(StandardCharsets.UTF_8));
// Java 11 호환 HEX 변환 (HexFormat은 Java 17+에서만 사용 가능)
StringBuilder sb = new StringBuilder(hashBytes.length * 2);
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new IllegalStateException("블라인드 인덱스 생성 실패", e);
}
}
}
6. JPA AttributeConverter — Spring Boot 2.x의 핵심 함정
JPA의 AttributeConverter를 활용하면 엔티티 필드가 DB에 저장될 때 자동으로 암호화되고, 조회될 때 자동으로 복호화됩니다.
Hibernate 5 (Spring Boot 2.x) 에서의 주입 문제
Spring Boot 2.x가 사용하는 Hibernate 5.4.27.Final에서는 AttributeConverter 인스턴스를 기본적으로 Hibernate가 직접 생성합니다. 즉, @Component를 붙여도 Spring이 관리하는 인스턴스가 아니라 Hibernate가 별도로 만든 인스턴스가 사용되므로, 일반적인 Spring 생성자 주입이 동작하지 않습니다.
// 함정: Spring Bean과 Hibernate가 사용하는 인스턴스가 다른 객체!
[Spring 컨텍스트] [Hibernate 내부]
@Component Bean → 주입됨 new PrivacyEncryptConverter() ← 이 인스턴스를 실제로 사용
→ crypto = null
→ NullPointerException 발생!
Spring Boot 3.x (Hibernate 6.x)에서는 Spring의
BeanContainer통합이 개선되어HibernateConfig없이도 생성자 주입이 기본 동작합니다.
해결책: HibernatePropertiesCustomizer로 SpringBeanContainer 연결
HibernatePropertiesCustomizer를 통해 Hibernate에 Spring의 BeanContainer를 등록하면, Hibernate가 AttributeConverter 인스턴스를 생성할 때 Spring 컨텍스트에서 가져오게 되어 생성자 주입이 정상 동작합니다.
빈 초기화 순서:
CryptoConfig→AesGcmCryptoBean 등록 →HibernateConfig가SpringBeanContainer연결 → Hibernate가PrivacyEncryptConverter생성 시 Spring에서AesGcmCrypto를 가져옴. Spring Boot의 자동 구성이 순서를 보장합니다.
package com.example.security.config;
import org.hibernate.cfg.AvailableSettings;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.hibernate5.SpringBeanContainer;
import java.util.Map;
@Configuration
public class HibernateConfig implements HibernatePropertiesCustomizer {
private final ConfigurableListableBeanFactory beanFactory;
public HibernateConfig(ConfigurableListableBeanFactory beanFactory) {
this.beanFactory = beanFactory;
}
/**
* Hibernate에 Spring의 BeanContainer를 등록합니다.
* 이를 통해 AttributeConverter 등 Hibernate 내부 객체에 Spring 의존성 주입이 가능해집니다.
* spring-orm 모듈의 SpringBeanContainer를 사용합니다.
*/
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(
AvailableSettings.BEAN_CONTAINER,
new SpringBeanContainer(beanFactory)
);
}
}
이제 PrivacyEncryptConverter에서 생성자 주입이 정상 동작합니다:
package com.example.security.crypto;
import javax.persistence.AttributeConverter; // Spring Boot 2.x: javax.*
import javax.persistence.Converter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
// autoApply = false (기본값) — 명시적으로 @Convert 어노테이션을 붙인 필드에만 적용
// ⚠️ autoApply = true 로 변경하면 모든 String 타입 필드에 자동 적용되어 의도치 않은 암호화가 발생합니다!
@Converter
@Component
public class PrivacyEncryptConverter implements AttributeConverter<String, String> {
private final AesGcmCrypto crypto;
// @Qualifier로 빈 이름을 명시합니다.
// 키 로테이션 프로파일 활성화 시 AesGcmCrypto 빈이 여러 개 등록되어도
// "aesGcmCrypto" 빈만 정확히 주입받아 NoUniqueBeanDefinitionException을 방지합니다.
public PrivacyEncryptConverter(@Qualifier("aesGcmCrypto") AesGcmCrypto aesGcmCrypto) {
this.crypto = aesGcmCrypto;
}
@Override
public String convertToDatabaseColumn(String attribute) {
if (attribute == null) return null;
return crypto.encrypt(attribute);
}
@Override
public String convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
return crypto.decrypt(dbData);
}
}
이제 엔티티 클래스에서 암호화가 필요한 필드에 @Convert(converter = PrivacyEncryptConverter.class) 어노테이션만 붙여주면 끝납니다.
7. 보안 설정 집중화: @Configuration + @Bean (CryptoConfig)
Q. 유틸 클래스에
@Component를 직접 붙이지 않고 왜@Configuration + @Bean방식을 쓸까요?
- 보안 설정의 가시성:
CryptoConfig클래스 하나만 열면 시스템 내의 모든 암호화 키 주입, 알고리즘, 빈을 한눈에 확인할 수 있습니다. (보안 감사에 유리)- 순수 단위 테스트: 유틸이 POJO이므로
new AesGcmCrypto("base64key...")한 줄로 스프링 컨텍스트 없이 단위 테스트가 가능합니다.- CGLIB 프록시 오버헤드 방지: 유틸 클래스 자체에
@Configuration을 붙이면 불필요한 프록시가 생성됩니다.
package com.example.security.config;
import com.example.security.crypto.AesGcmCrypto;
import com.example.security.crypto.BlindIndexUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class CryptoConfig {
/**
* AES-256 암호화 키 (Base64 디코딩 후 32바이트)
* 생성: openssl rand -base64 32
* 빈 이름: "aesGcmCrypto" — PrivacyEncryptConverter가 @Qualifier로 이 이름을 참조합니다.
*/
@Bean
public AesGcmCrypto aesGcmCrypto(@Value("${app.crypto.secret-key}") String secretKey) {
return new AesGcmCrypto(secretKey);
}
/**
* Blind Index HMAC 키 (UTF-8 바이트, 최소 32바이트)
* 생성: openssl rand -hex 64 (64 hex chars = 64 ASCII bytes)
* 주의: AES 키와 달리 Base64 디코딩하지 않고 UTF-8 원문 그대로 사용합니다.
*/
@Bean
public BlindIndexUtil blindIndexUtil(@Value("${app.crypto.blind-index-key}") String indexKey) {
return new BlindIndexUtil(indexKey);
}
// PasswordEncoder도 한 곳에서 관리 — 보안 관련 빈 집중화
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
8. 엔티티(Entity) 설계
비밀번호는 PasswordEncoder로 단방향 해싱하고, 전화번호와 이메일은 양방향 암호화 및 Blind Index를 적용합니다.
설계 원칙: JPA 엔티티는 Spring Bean이 아닙니다.
@Autowired를 엔티티 필드에 붙여도 Spring이 주입해 주지 않습니다. 엔티티를 순수 도메인 객체로 유지하기 위해BlindIndexUtil을 생성자에 직접 전달하는 대신, 미리 계산된 인덱스 값을 받습니다.
package com.example.domain.member;
import com.example.security.crypto.PrivacyEncryptConverter;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*; // Spring Boot 2.x: javax.*
@Entity
@Getter
@Table(
name = "member",
uniqueConstraints = {
@UniqueConstraint(name = "uk_member_email_index", columnNames = "email_index"),
@UniqueConstraint(name = "uk_member_phone_index", columnNames = "phone_index")
},
indexes = {
@Index(name = "idx_member_email_index", columnList = "email_index"),
@Index(name = "idx_member_phone_index", columnList = "phone_index")
}
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
// 단방향 암호화 (Spring Security BCrypt) — 평문 절대 저장 금지
@Column(nullable = false)
private String password;
// 양방향 암호화 (JPA Converter 자동 암복호화)
@Convert(converter = PrivacyEncryptConverter.class)
@Column(nullable = false, length = 500)
private String email;
// 검색용 블라인드 인덱스 (HMAC-SHA256) — VARCHAR(64) = HEX(32바이트)
@Column(name = "email_index", nullable = false, length = 64)
private String emailIndex;
// 양방향 암호화 (JPA Converter 자동 암복호화)
@Convert(converter = PrivacyEncryptConverter.class)
@Column(nullable = false, length = 500)
private String phoneNumber;
// 검색용 블라인드 인덱스 (HMAC-SHA256)
@Column(name = "phone_index", nullable = false, length = 64)
private String phoneIndex;
/**
* 정적 팩터리 메서드 — 순수 도메인 객체 생성.
*
* <p><b>중요</b>: 이 메서드는 {@code Member} 클래스 내부에 선언되어 있으므로
* {@code private} 필드에 직접 접근할 수 있습니다. 외부 클래스에서 이 패턴을 복사하면
* 컴파일 에러가 발생합니다.
*
* <p>BlindIndexUtil 의존성 없이 미리 계산된 인덱스 값을 받아 순수 도메인 객체를 유지합니다.
* 인덱스 계산은 Service 레이어의 책임입니다.
*/
public static Member create(String name, String encodedPassword,
String email, String emailIndex,
String phoneNumber, String phoneIndex) {
Member member = new Member(); // 같은 클래스 내부이므로 protected 생성자 접근 가능
member.name = name;
member.password = encodedPassword;
member.email = email;
member.emailIndex = emailIndex;
member.phoneNumber = phoneNumber;
member.phoneIndex = phoneIndex;
return member;
}
/**
* 연락처 변경 — 미리 계산된 인덱스 값을 받아 업데이트합니다.
* 인덱스 계산은 Service 레이어의 책임입니다.
*/
public void updateContact(String email, String emailIndex,
String phoneNumber, String phoneIndex) {
this.email = email;
this.emailIndex = emailIndex;
this.phoneNumber = phoneNumber;
this.phoneIndex = phoneIndex;
}
}
9. DTO 및 Repository, Service, Controller 구현
DTO
Java 11 + Jackson 주의:
@RequiredArgsConstructor로 생성된final필드 전체 생성자는 Jackson이 인식하지 못합니다. Jackson은 기본적으로 인수 없는 기본 생성자로 역직렬화하기 때문입니다.
Java 11에서는@JsonCreator+@JsonProperty로 Jackson이 사용할 생성자를 명시합니다.
package com.example.domain.member;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
// Java 11 호환 DTO — @JsonCreator로 Jackson 역직렬화 명시
// Java 16+ 사용 시 record로 교체 가능 (record는 @JsonCreator 불필요)
@Getter
public class MemberRegisterRequest {
@NotBlank(message = "이름은 필수입니다.")
@Size(max = 100)
private final String name;
@NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
private final String password;
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "유효한 이메일 형식이어야 합니다.")
private final String email;
@NotBlank(message = "전화번호는 필수입니다.")
@Pattern(regexp = "^[+]?[0-9\\-() ]+$", message = "유효한 전화번호 형식이어야 합니다.")
private final String phoneNumber;
// Jackson이 @RequestBody 역직렬화 시 이 생성자를 사용
// @RequiredArgsConstructor는 @JsonProperty를 포함하지 않으므로 직접 선언합니다.
@JsonCreator
public MemberRegisterRequest(
@JsonProperty("name") String name,
@JsonProperty("password") String password,
@JsonProperty("email") String email,
@JsonProperty("phoneNumber") String phoneNumber) {
this.name = name;
this.password = password;
this.email = email;
this.phoneNumber = phoneNumber;
}
}
package com.example.domain.member;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
// 응답 DTO는 Jackson이 getters로 직렬화하므로 @RequiredArgsConstructor 그대로 사용 가능
@Getter
@RequiredArgsConstructor
public class MemberResponse {
private final Long id;
private final String name;
private final String email; // 복호화된 평문
private final String phoneNumber; // 복호화된 평문
// ⚠️ 개인정보 로깅 방지 — toString()에서 이메일·전화번호 마스킹 필수
@Override
public String toString() {
return "MemberResponse{id=" + id + ", name='" + name + "', "
+ "email='***', phoneNumber='***'}";
}
}
MemberRepository
package com.example.domain.member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
// 블라인드 인덱스로 B-Tree 인덱스 활용 초고속 조회
Optional<Member> findByEmailIndex(String emailIndex);
Optional<Member> findByPhoneIndex(String phoneIndex);
// 중복 가입 사전 검증용
boolean existsByEmailIndex(String emailIndex);
boolean existsByPhoneIndex(String phoneIndex);
}
MemberService
package com.example.domain.member;
import com.example.security.crypto.BlindIndexUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final BlindIndexUtil blindIndexUtil;
@Transactional
public Long register(MemberRegisterRequest request) {
// ① 이메일·전화번호 각각 도메인에 맞는 정규화로 인덱스 생성
String emailIndex = blindIndexUtil.generateEmailIndex(request.getEmail());
String phoneIndex = blindIndexUtil.generatePhoneIndex(request.getPhoneNumber());
// ② 블라인드 인덱스로 중복 사전 검증 (DB UniqueConstraint에만 의존하지 않음)
if (memberRepository.existsByEmailIndex(emailIndex)) {
throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
}
if (memberRepository.existsByPhoneIndex(phoneIndex)) {
throw new IllegalArgumentException("이미 사용 중인 전화번호입니다.");
}
String encodedPassword = passwordEncoder.encode(request.getPassword());
// ③ 미리 계산된 인덱스를 엔티티에 전달 — 엔티티는 BlindIndexUtil 의존성 없음
// email, phoneNumber는 save() 시점에 PrivacyEncryptConverter가 자동 암호화
Member member = Member.create(
request.getName(),
encodedPassword,
request.getEmail(),
emailIndex,
request.getPhoneNumber(),
phoneIndex
);
try {
return memberRepository.save(member).getId();
} catch (DataIntegrityViolationException e) {
// Race Condition 대응: 사전 검증 통과 후 다른 스레드가 먼저 저장한 경우
// DB의 UniqueConstraint가 최후의 방어선으로 동작하며 이 예외로 잡아 친절한 메시지 반환
throw new IllegalArgumentException("이미 사용 중인 이메일 또는 전화번호입니다.");
}
}
public MemberResponse findByEmail(String plainEmail) {
// 평문 이메일 → 블라인드 인덱스 변환 → B-Tree 인덱스 검색
String emailIndex = blindIndexUtil.generateEmailIndex(plainEmail);
Member member = memberRepository.findByEmailIndex(emailIndex)
.orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다."));
// getEmail() 호출 시 PrivacyEncryptConverter가 자동 복호화하여 평문 반환
return new MemberResponse(
member.getId(),
member.getName(),
member.getEmail(),
member.getPhoneNumber()
);
}
@Transactional
public void updateContact(Long memberId, String newEmail, String newPhoneNumber) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("회원을 찾을 수 없습니다."));
String newEmailIndex = blindIndexUtil.generateEmailIndex(newEmail);
String newPhoneIndex = blindIndexUtil.generatePhoneIndex(newPhoneNumber);
// 변경 사항이 없으면 조기 반환 — 불필요한 DB 쓰기 방지
if (newEmailIndex.equals(member.getEmailIndex())
&& newPhoneIndex.equals(member.getPhoneIndex())) {
return;
}
// 변경 전 중복 검증 (본인 제외)
memberRepository.findByEmailIndex(newEmailIndex)
.filter(found -> !found.getId().equals(memberId))
.ifPresent(found -> { throw new IllegalArgumentException("이미 사용 중인 이메일입니다."); });
memberRepository.findByPhoneIndex(newPhoneIndex)
.filter(found -> !found.getId().equals(memberId))
.ifPresent(found -> { throw new IllegalArgumentException("이미 사용 중인 전화번호입니다."); });
// 미리 계산된 인덱스와 평문을 전달 — Converter가 save 시점에 자동 암호화
member.updateContact(newEmail, newEmailIndex, newPhoneNumber, newPhoneIndex);
// Hibernate 5에서 AttributeConverter가 붙은 필드의 더티 체킹은
// 구현에 따라 신뢰성이 다를 수 있으므로 명시적으로 save()를 호출합니다.
memberRepository.save(member);
}
}
Controller + @Valid + 전역 예외 처리
@Valid는 Controller 파라미터에 붙이고, 검증 실패 및 비즈니스 예외는 @RestControllerAdvice에서 일괄 처리합니다.
package com.example.web;
import com.example.domain.member.MemberRegisterRequest;
import com.example.domain.member.MemberResponse;
import com.example.domain.member.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/members")
public class MemberController {
private final MemberService memberService;
// @Valid — MemberRegisterRequest의 @NotBlank, @Email 등을 활성화
@PostMapping
public ResponseEntity<Long> register(@RequestBody @Valid MemberRegisterRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(memberService.register(request));
}
@GetMapping("/search")
public ResponseEntity<MemberResponse> findByEmail(@RequestParam String email) {
return ResponseEntity.ok(memberService.findByEmail(email));
}
@PatchMapping("/{memberId}/contact")
public ResponseEntity<Void> updateContact(
@PathVariable Long memberId,
@RequestParam String email,
@RequestParam String phoneNumber) {
memberService.updateContact(memberId, email, phoneNumber);
return ResponseEntity.noContent().build();
}
}
package com.example.web;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
// @Valid 검증 실패 — 각 필드별 오류 메시지 반환
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
// Race Condition 등 DB UniqueConstraint 위반 — 최후의 방어선
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<Map<String, String>> handleDuplicateKey(DataIntegrityViolationException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "이미 사용 중인 이메일 또는 전화번호입니다."));
}
// 비즈니스 규칙 위반 (중복 가입, 회원 없음 등)
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleIllegalArg(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
}
}
10. 환경 설정 (application.yml) 및 키 관리
암호화 키와 블라인드 인덱스 키는 절대 소스코드나 Git 저장소에 커밋되면 안 되며, 환경변수나 Secret Manager(AWS Secrets Manager, HashiCorp Vault 등)를 통해 주입받아야 합니다.
application.yml
app:
crypto:
# AES-256 암호화 키 (Base64, 32바이트) — 환경변수로 주입
# 생성: openssl rand -base64 32
secret-key: ${CRYPTO_SECRET_KEY}
# Blind Index HMAC 키 (UTF-8 원문, 최소 32바이트) — 환경변수로 주입
# 생성: openssl rand -hex 64
# 주의: AES 키와 달리 Base64 디코딩하지 않고 UTF-8 원문 그대로 사용합니다.
blind-index-key: ${BLIND_INDEX_KEY}
암호화 키 생성 방법 (Terminal / OpenSSL)
# AES-256용 32바이트 랜덤 키 생성 (Base64 → CRYPTO_SECRET_KEY에 사용)
openssl rand -base64 32
# Blind Index용 HMAC 키 생성 (HEX 64자 = UTF-8 64바이트 → BLIND_INDEX_KEY에 사용)
# AES 키와 달리 Base64 디코딩 없이 문자열 그대로 사용하므로 hex 출력을 사용합니다.
openssl rand -hex 64
11. 키 로테이션(Key Rotation) 전략
실무에서 암호화 키는 보안 정책에 따라 주기적으로 교체해야 합니다. 이미 암호화된 DB 데이터가 대용량으로 쌓여있다면 다음 전략을 고려합니다.
기본 전략: 배치 재암호화 (Batch Re-encryption)
[구 키로 암호화된 데이터] → 복호화 → [평문] → 새 키로 암호화 → [DB Update]
- 새 키를 환경변수에 추가(
CRYPTO_SECRET_KEY_NEW)하되, 구 키는 아직 유지합니다. - 마이그레이션 배치 Job을 작성하여 레코드를 청크(chunk) 단위로 읽어와 구 키로 복호화 → 새 키로 재암호화 → Update합니다.
- 배치 완료 후 환경변수를 새 키로 교체하고 서버를 재시작합니다.
@Qualifier로 두 개의 암호화 빈 구분하기
배치 처리 시 구 키와 새 키를 동시에 사용하려면 @Qualifier로 빈 이름을 구분합니다.
⚠️
key-rotation프로파일 활성화 시AesGcmCrypto빈이aesGcmCrypto,oldCrypto,newCrypto세 개가 됩니다.
PrivacyEncryptConverter는@Qualifier("aesGcmCrypto")로 정확한 빈만 주입받으므로NoUniqueBeanDefinitionException충돌이 발생하지 않습니다.
package com.example.security.config;
import com.example.security.crypto.AesGcmCrypto;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile("key-rotation") // 키 로테이션 배치 실행 시에만 활성화
public class KeyRotationConfig {
@Bean("oldCrypto")
public AesGcmCrypto oldAesGcmCrypto(
@Value("${app.crypto.old-secret-key}") String oldKey) {
return new AesGcmCrypto(oldKey);
}
@Bean("newCrypto")
public AesGcmCrypto newAesGcmCrypto(
@Value("${app.crypto.new-secret-key}") String newKey) {
return new AesGcmCrypto(newKey);
}
}
package com.example.batch;
import com.example.domain.member.Member;
import com.example.domain.member.MemberRepository;
import com.example.security.crypto.AesGcmCrypto;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
// @RequiredArgsConstructor 미사용 — @Qualifier는 Lombok이 처리하지 못하므로 생성자를 직접 선언합니다.
@Service
public class KeyRotationService {
private final AesGcmCrypto oldCrypto;
private final AesGcmCrypto newCrypto;
private final MemberRepository memberRepository;
private final JdbcTemplate jdbcTemplate;
public KeyRotationService(
@Qualifier("oldCrypto") AesGcmCrypto oldCrypto,
@Qualifier("newCrypto") AesGcmCrypto newCrypto,
MemberRepository memberRepository,
JdbcTemplate jdbcTemplate) {
this.oldCrypto = oldCrypto;
this.newCrypto = newCrypto;
this.memberRepository = memberRepository;
this.jdbcTemplate = jdbcTemplate;
}
/**
* JPA Pageable로 페이지 단위 조회 — DB 방언에 무관하게 동작합니다.
* (Hibernate가 각 DB 방언에 맞는 페이징 SQL을 자동 생성)
*
* <p>PrivacyEncryptConverter가 JPA 조회 시 구 키(aesGcmCrypto)로 자동 복호화합니다.
* 복호화된 평문을 새 키로 재암호화한 뒤 JdbcTemplate으로 직접 업데이트합니다.
* (JPA save()는 Converter가 다시 암호화하므로 여기서는 JdbcTemplate 직접 업데이트 사용)
*/
@Transactional
public int rotateChunk(int page, int size) {
// JPA Pageable — DB 방언 무관 (MySQL LIMIT/OFFSET 아님)
Page<Member> members = memberRepository.findAll(PageRequest.of(page, size));
for (Member member : members.getContent()) {
// member.getEmail(): Converter가 구 키로 자동 복호화하여 평문 반환
String newEncEmail = newCrypto.encrypt(member.getEmail());
String newEncPhone = newCrypto.encrypt(member.getPhoneNumber());
// JdbcTemplate으로 직접 업데이트 — Converter를 거치지 않아 이중 암호화 방지
jdbcTemplate.update(
"UPDATE member SET email = ?, phone_number = ? WHERE id = ?",
newEncEmail, newEncPhone, member.getId()
);
}
return members.getNumberOfElements();
}
}
고급 전략: Envelope Encryption
AWS KMS, GCP KMS, HashiCorp Vault와 같은 Key Management Service를 사용하면:
- 데이터 암호화에 사용하는 DEK(Data Encryption Key)를 KMS의 KEK(Key Encryption Key)로 한번 더 암호화하여 DB에 저장합니다.
- 키 교체 시 DEK만 재암호화하면 되므로 전체 데이터 재처리가 불필요합니다.
- 대규모 운영 서비스의 표준적인 접근 방식입니다.
12. 실무 체크리스트 및 주의사항
- DB 컬럼 길이 고려:
IV(12 bytes) + CipherText + Auth Tag(16 bytes)→ Base64 인코딩으로 길이가 크게 늘어납니다.- 원문이 15글자라도 암호문은 60~80자 이상이므로 컬럼 길이를 넉넉히(
VARCHAR(500)) 잡아야 합니다.
@Converter(autoApply = true)절대 금지:autoApply = true로 설정하면 모든String타입 필드에 자동으로 암호화가 적용됩니다.name,password등 암호화해서는 안 되는 필드까지 암호화되어 기능이 완전히 망가집니다.- 반드시 기본값인
autoApply = false를 유지하고@Convert(converter = PrivacyEncryptConverter.class)를 필요한 필드에만 명시하세요.
- 이메일/전화번호 정규화 규칙 분리:
- 이메일의 점(
.)과@기호는 도메인 식별자이므로 제거하면 안 됩니다. - 전화번호는 선두
+만 유지하고 나머지는 숫자만 남깁니다. generateEmailIndex()와generatePhoneIndex()를 반드시 구분해서 사용하세요.
- 이메일의 점(
- Race Condition 대응:
- 중복 사전 검증 후 저장 사이에 다른 스레드가 먼저 저장할 수 있습니다.
DataIntegrityViolationException을 catch하여 DBUniqueConstraint를 최후의 방어선으로 활용하세요.
- 배치/Native Query 사용 시 주의:
AttributeConverter는 JPA/Hibernate 영속화 시점에만 발동합니다.- MyBatis, JDBC Template, Spring Batch Native Query 사용 시에는
AesGcmCrypto를 직접 호출하여 암호화해야 합니다.
- 로깅 및 마스킹:
- DTO의
toString()에서 개인정보가 노출되지 않도록 마스킹 처리합니다. @JsonIgnore,@JsonProperty(access = WRITE_ONLY)등으로 API 응답에서도 불필요한 노출을 막으세요.
- DTO의
- Blind Index 정규화 일관성:
- 인덱스 생성 시와 검색 시 반드시 동일한 정규화 로직을 적용해야 합니다.
"010-1234-5678"과"01012345678"이 동일한 인덱스를 생성해야 검색이 정확합니다.
정리
Spring Boot 2.4.3, Spring Security, JPA 환경에서 개인정보 보호는 다음 네 축으로 완성할 수 있습니다:
- 비밀번호: Spring Security의
BCryptPasswordEncoder로 강력한 단방향 해싱. - 개인정보 암복호화:
AES-256-GCM+ JPAAttributeConverter(@Qualifier생성자 주입) +HibernateConfig(SpringBeanContainer)를 통해 비즈니스 로직을 오염시키지 않는 투명한 암복호화. - 개인정보 검색: HMAC 기반 Blind Index 컬럼으로 암호화 보안성을 유지하면서도 B-Tree 인덱스 기반의 초고속 검색 보장. 이메일/전화번호 각각의 특성에 맞는 정규화 규칙 분리 적용.
- 아키텍처 완성도: 암호화 유틸을 순수 POJO로 작성하고
@Configuration + @Bean패턴으로 관리하여 단위 테스트의 독립성과 보안 설정의 집중화를 달성. Race Condition 방어,@JsonCreator기반 Jackson 호환,@Qualifier기반 키 로테이션으로 실전 품질을 확보.
댓글남기기