시작하며
안녕하세요, 카카오페이손해보험 플랫폼기술팀의 휴입니다.
카카오페이손해보험은 고객의 데이터를 안전하게 보관하기 위해 초기부터 암호화 모듈을 지속적으로 보강하며 운영해왔습니다. 하지만 암호화 모듈의 의존 라이브러리 대부분 초창기 버전에 머물러 있었습니다. 최근 애플리케이션들이 최신 버전으로 업그레이드되면서 애플리케이션과 암호화 모듈이 가지고 있는 라이브러리들의 호환성 문제로 AEM(Application Error Monitoring)이 울리는 가슴 아픈 사례들이 있었습니다.
암호화 모듈을 개선하기 위해 코드를 살펴보니 구조가 유틸리티 클래스와 정적 메서드 위주로 짜여 있어, 변화하는 보안 규정을 수용하며 확장하기엔 한계가 분명했습니다. 이에 도메인 개념을 기반으로 리팩토링 하는 것을 팀원들에게 제안하였습니다.
리팩토링 과정에서 깨달은 것은 암호화를 제대로 이해하지 않고서는 설계 자체가 어렵다는 사실이었습니다. 이 글에서는 암호화 알고리즘을 사용하는 엔지니어라면 반드시 알아야 할 암호화 기본기를 정리하고, 실무에 적용한 노하우를 소개합니다.
암호화의 발전
고전 암호화
IQ 테스트를 해본 적이 있나요? IQ 테스트 문제 중에는 주어진 패턴에서 규칙을 찾아 정답을 고르는 유형이 많습니다. 간단한 퀴즈 하나를 풀어 볼까요? 아래 문자열이 뜻하는 의미는 무엇일까요?
lblbpqbz
정답은 kakaopay입니다. 알파벳을 한 글자씩 뒤로 이동(shift)시키는 고전적 트릭, 즉 시저 암호(Caesar cipher) 를 적용한 결과죠.
시저 암호는 기원전 1세기 율리우스 카이사르가 사용한 것으로 전해지는, 가장 오래된 단일 치환 암호입니다.
고전 암호는 알고리즘(치환·전치 규칙) 자체를 비밀로 삼는 방식이라 패턴이 단순합니다. 우리도 짱구(?)를 조금만 돌려 봤을 때 정답을 알아낼 수 있죠.
암호문만 가지고도 아래와 같은 기법으로 사람이 직접 해독할 수 있습니다.
- 브루트포스 : 시저 암호라면 alphabet 25개의 이동 값을 모두 시도하면 끝
- 빈도 분석 : 글자 출현 빈도를 통계적 분포(예: 영어는 E, T, A 빈도가 높음)와 대조
실제 사례로 1586년 바빙턴 음모를 들 수 있습니다(영국 국립 기록원(The National Archives)에서 제공하는 교육 자료). 스코틀랜드 여왕 메리 스튜어트는 엘리자베스 1세 암살 계획을 주고받으며 단일 치환 + 코드표를 섞은 노미네이클레이터(nomenclator) 암호를 사용했습니다. 그러나 엘리자베스의 정보국에서 일한 암호 해독관 토머스 펠립스가 빈도 분석으로 이를 해독했고, 그 내용이 반역의 증거가 되어 메리 스튜어트는 처형되었습니다.
이 사건은 “알고리즘이 아니라 키를 숨겨야 한다”는 현대 암호학의 대전제를 일깨운 대표적 사례로 자주 인용됩니다.
현대 암호화
고전 암호는 “공격자가 알고리즘을 모른다”는 가정 아래 설계되었습니다. 그러나 현실에서는 알고리즘이 언제든 노출될 수 있고, 실제로 빈도 분석이나 브루트 포스 공격으로 쉽게 깨질 수 있습니다.
이에 현대 암호학은 케르크호프(Kerckhoffs) 원리를 받아들여 다음과 같은 철학으로 전환되었습니다. 위키 백과를 보면 아래와 같은 문구들을 확인할 수 있습니다.
적은 시스템을 알고 있다 (The enemy knows the system)“라는 말로 표현했는데 ..
암호체계의 안전성은 키의 비밀성에만 의존해야한다
케르크호프스의 원리는 비밀로 유지되는 것들이 만약 의도치 않게 드러나게 되더라도 이를 변경하는 비용이 가장 적게 들어야만 한다는 사실을 지적하고 있다.
제가 이해한 케르크호프의 원리는 보안이라는 것은 완벽한 방어는 없기 때문에 문제가 발생했을 때 빠르게 수정할 수 있도록 설계해야한다는 의미입니다. 알고리즘을 수정하는 것보다는 키를 변경하는 것이 문제를 빠르게 해결할 수 있겠죠?
그래서 현재 암호화 방식은 키를 기반으로 암호화 하는 방식으로 만들어졌습니다. 그 중에서도 현재 널리 쓰이는 키 기반 암호 방식은 대칭키 암호(symmetric) 와 비대칭키 암호(asymmetric) 로 나뉩니다.
대칭키 암호화는 암호화와 복호화에 동일한 키를 사용하는 방식을 말합니다. 대표적인 알고리즘으로 AES가 있습니다. 대칭키 암호화의 가장 큰 특징은 암호화할 때와 복호화할 때 같은 키를 사용한다는 점입니다.
코드 예시는 아래와 같습니다.
@Test
fun `AES 알고리즘으로 암호화와 복호화를 수행할 수 있다`() {
// 128비트 AES 키를 생성한다.
val keyGen = KeyGenerator.getInstance("AES")
keyGen.init(128)
val aesKey = keyGen.generateKey()
// 12바이트 길이의 IV를 랜덤으로 생성한다.
val ivBytes = ByteArray(12)
.apply { SecureRandom.getInstanceStrong().nextBytes(this) }
// GCM 모드와 NoPadding 전략으로 암호화한다.
val plainBytes = "KakaoPay".toByteArray()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, aesKey, GCMParameterSpec(128, ivBytes))
val cipherText = cipher.doFinal(plainBytes)
// IV와 암호문을 합친다.
val message = ivBytes + cipherText
// 복호화 시에는 IV와 암호문을 분리한다.
val originIv = message.sliceArray(0 until 12)
val originCipherText = message.sliceArray(12 until message.size)
// 동일한 모드와 키, IV로 복호화한다.
val cipherDec = Cipher.getInstance("AES/GCM/NoPadding")
cipherDec.init(Cipher.DECRYPT_MODE, aesKey, GCMParameterSpec(128, originIv))
val plainAgain = cipherDec.doFinal(originCipherText)
plainAgain shouldBe plainBytes
}
** IV: 같은 평문과 같은 키를 사용하더라도 매번 다른 암호문을 만들기 위해 추가되는 랜덤 값입니다.
** GCM: AES 알고리즘으로 암호화할 때, 평문을 블록 단위로 분리해 여러 연산을 수행하고, 이를 연결해가는 과정을 chaining(연쇄화)라고 합니다. CBC, CFB, GCM 등 여러 chaining 방식을 선택할 수 있으며, 여기서는 GCM 모드를 사용했습니다.
** Padding: 블록 단위로 나누다 보면 마지막 블록이 부족해질 수 있는데, 이를 채우는 과정을 패딩(Padding)이라고 합니다. 패딩을 어떻게 처리할지 선택해야 합니다.
비대칭키 암호화는 암호화 키와 복호화 키를 서로 다른 키를 사용하는 방식을 말합니다.대표적인 알고리즘으로는 RSA 알고리즘이 있습니다. 비대칭 키 암호화 방식에서도 우리가 주목해야하는 점은 암호화할 때는 공개키를 사용하고 복호화할 때는 개인키를 사용한다는 점입니다.
RSA 알고리즘은 키를 발급할 때 본인이 개인키를 가지고 있고 상대방에게 공개키를 주면 되기 때문에 개인키를 외부로 유출하지 않는 이상 본인만 복호화할 수 있습니다. 그렇기 때문에 AES 알고리즘에 비해 키 관리에 대한 편의성이 좀 더 낫다고 볼 수 있습니다.
RSA를 암호화/복호화하는 코드의 예시는 아래와 같습니다.
@Test
fun `RSA 알고리즘을 통해 암호화와 복호화할 수 있다`() {
// 키사이즈(2048)로 공개키·비밀키 생성한다.
val keyGen = KeyPairGenerator.getInstance("RSA")
keyGen.initialize(2048)
val keyPair = keyGen.genKeyPair()
val publicKey = keyPair.public
val privateKey = keyPair.private
// ECB(블록 체인 모드 없음을 의미)와 OAEP 패딩 방식으로 암호문 생성한다.
val plainText = "Hello"
val plainBytes = plainText.toByteArray(Charsets.UTF_8)
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
cipher.init(Cipher.ENCRYPT_MODE, publicKey, SecureRandom())
val cipherBytes = cipher.doFinal(plainBytes)
val encodedCipherText = Base64.getEncoder().encodeToString(cipherBytes)
// 암호화와 같은 방식으로 복호화한다.
val decodedCipherBytes = Base64.getDecoder().decode(encodedCipherText)
cipher.init(Cipher.DECRYPT_MODE, privateKey, SecureRandom())
val decryptedBytes = cipher.doFinal(decodedCipherBytes)
val decryptedText = decryptedBytes.toString(Charsets.UTF_8)
decryptedText shouldBe plainText
}
그렇다면 RSA 알고리즘은 무적일까요? 결론적으로 말하면 아닙니다. 그 이유는 크게 두가지가 있습니다.
- 암호화할 수 있는 평문 길이의 한계
RSA 알고리즘은 수학적 연산으로 공개키와 개인키를 만듭니다. 그래서 수학적 조건이 들어가기 때문에 비트 길이에 따라 제한됩니다.
- AES보다 느린 속도
RSA 알고리즘은 수학적 연산을 통해 암호화, 복호화가 이루어지는데 이 때 사용하는 수가 어마어마하게 큰 수입니다. RSA 알고리즘에서 다루는 숫자 모듈러스 n은 그 크기가 약 1.6 × 10^616 ~ 3.2 × 10^616(10진수 617자리) 사이입니다. 10^616라는 숫자가 얼마나 큰지 가늠이 안될 수 있으니 재미삼아 비교표를 보도록 하겠습니다.
대상 | 원자수 |
---|---|
수소 1원자 | 10 ^ 0 |
오가네손(주기율표에서 가장 무거운 원소) | 10 ^ 2 |
골프공 1 개 (≈ 45 g) | ≈ 2 × 10²⁵ |
성인 1 명 (≈ 70 kg) | ≈ 7 × 10²⁷ |
지구 전체 | ≈ 1 × 10⁵⁰ |
우리은하(미리내) | ≈ 1 × 10⁶⁵ |
관측 가능한 우주 | ≈ 1 × 10⁸⁰ |
2048bit RSA 키의 모듈러스 n | ≈ 1.6 × 10^616 ~ 3.2 × 10^616 |
우주 전체 원자를 10⁵³⁶ 번 모은 숫자가 모듈러스 n의 크기입니다. 현실적으로 상상할 수 없는 정도의 크기라는 것을 알 수가 있습니다.
하이브리드 암호화
대칭키와 비대칭키의 차이를 단순화하면, 속도와 키 관리의 편의성이라고 할 수 있습니다.
대칭키는 키길이가 128/256 bit로 CPU 레지스터(128 bit SIMD)와 잘 맞아 연산이 굉장히 빠르지만 비대칭키는 위에서 설명했듯이 말도 안되는 크기의 숫자로 계산하기 때문에 상대적으로 속도가 느립니다. (대략적으로 수백 ~수천배 차이가 난다고 알려져 있음)
반면 비대칭키는 복호화하는 쪽에서만 개인키를 보관하면 되지만, 대칭키 방식은 키를 미리 안전하게 공유해야 하므로 비대칭키 방식이 키 관리 측면에서 더 편리합니다.
이런 특징을 보완해서 만든 방식이 하이브리드 방식입니다. 하이브리드 방식은 내가 보호하고자하는 원문은 대칭키 방식으로 암호화하고 대칭키를 비대칭키로 암호화하여 키 배포 자체도 안전하게 하고 복잡한 원문도 빠르게 암호화/복호화할 수 있게 만든 방식입니다.
오늘날 하이브리드 암호화는 TLS, JWT, VPN, PGP등의 프로토콜에서 사용하는 기본 패턴이 되었습니다.
봉투 암호화
봉투 암호화는 하이브리드 암호화라는 개념을 가지고 구체적으로 실무에 적용하기 위한 암호화 방법론 중에 하나입니다. 봉투 암호화에서는 데이터를 암호화하기 위한 키(Data Encryption Key, DEK)와 DEK를 암호화하기 위한 키(Key Encryption Key, KEK)로 구성됩니다. 이 두 키를 가지고 데이터 암호화와 키 암호화를 하고 보관합니다. 즉, 데이터(암호문)와 해당 데이터를 암호화할 때 사용한 키(암호문)를 마치 하나의 봉투처럼 묶어서 관리합니다. 봉투 암호화를 제공해주는 서비스로는 AWS KMS, Google Cloud KMS, Azure Key Vault등이 있습니다.
AWS에서는 KEK를 고객 마스터 키(Customer Master Key CMK)라고 부르고 별도의 키로 암호화하여 보관합니다. 이를 통해 원문을 데이터 키로 빠르게 암호화/복호화하면서 데이터 키는 AWS를 통해 안전하게 관리할 수 있습니다.
AWS KMS Service는 마스터키를 AWS가 저장해놓고 사용자는 데이터와 데이터키를 암호화된 상태로 저장합니다. 그리고 데이터 키를 AWS KMS Service를 통해 복호화하고 복호화된 데이터키로 데이터를 암호화하거나 복호화하는 과정을 가집니다. 이 과정에서 사용자는 데이터키도 모르고 마스터키도 모르기 때문에 사용자의 실수로 데이터 키나 마스터 키를 복호화된 상태로 유출하는 위험을 최대한 줄일 수 있습니다.
그리고 키 회전 역시 AWS를 활용하여 할 수 있고 누가 데이터 키를 암호화/복호화를 했는지도 CloudTrail를 이용하여 쉽게 기록하고 추적할 수 있습니다.
AWS는 Java 플랫폼의 개발자가 쉽게 개발할 수 있도록 SDK를 지원하고 있습니다. SDK 1.x 버전은 2025년 12월 31일에 지원이 중단될 예정이므로 2.x 버전에 따라 사용하는 방법에 대해서 Kotlin 코드로 소개하도록 하겠습니다.
V2 SDK 예제
// 프로파일을 읽어 인증 정보를 가져온다.
// 인증 정보에는 aws_arn_role, aws_access_key_id, aws_secret_access_key, aws_session_token등이 포함된다.
val profileCredentials = ProfileCredentialsProvider.create("profile-A")
// credential 정보를 가지고 KmsClient 정보를 생성한다.
val kmsClient = KmsClient.builder()
.region(Region.AP_NORTHEAST_2)
.credentialsProvider(profileCredentials)
.build()
// Base64로 디코딩된 암호화된 데이터키를 복호화 요청한다.
val decryptRequest = DecryptRequest.builder()
.ciphertextBlob(SdkBytes.fromByteArray(decodedEncryptedDataKey))
.build()
val descryptedDataKey = kmsClient.decrypt(decryptRequest)
// 복호화된 데이터키로 복호화 진행
// 생략
JPA/Hibernate 스펙을 활용한 모듈 구현
지금까지 봉투 암호화가 무엇인지 그리고 왜 봉투 암호화까지 발전할 수 밖에 없었는지에 대해서 알아봤습니다. 추가적으로 카카오페이손해보험에서 암호화 모듈을 비즈니스에 어떻게 쉽게 적용했는지에 대해서 소개해드리도록 하겠습니다. JPA에 스펙 상 활용할 수 있는 구현 방법은 2가지와 Hiberanate에서 제공해주는 스펙을 활용한 1가지 방법이 있었습니다.
- Entity LifeCycle(PrePersist등) 콜백을 활용한 구현
- AttributeConverter를 활용한 구현
- UserType과 ParameterizedType를 활용한 구현
위 3가지 방법으로 구현했을 때의 경험을 공유해드리도록 하겠습니다.
AOP를 이용한 암호화 모듈 구현
Spring에는 AOP(Aspect-Oriented Programming)라는 개념이 존재합니다. 핵심 비즈니스 로직과는 별개이지만, 여러 클래스나 메서드에 중복적으로 들어가게 되는 코드를 별도로 관리하면서 손쉽게 적용하기 위한 기술입니다. Spring AOP를 직접 사용하지 않고 JPA의 이벤트 리스너 기능을 활용하여 AOP 개념을 적용했습니다.
먼저, Pointcut
에 해당하는 암호화 대상 필드가 필요합니다. 대상 필드를 지정하기 위해 Crypto
라는 클래스를 정의하겠습니다.
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class Crypto
그리고 Pointcut
에서 언제 공통 로직을 수행하게 할 것인지 결정을 해야되는데 우리는 데이터(평문)를 DB에 저장하고 조회할 때 암호화/복호화를 해야하기 때문에 Spring Data JPA에서 제공하는 @PrePerist, @PreUpdate, @PostLoad를 사용하도록 하겠습니다.
- @PrePerist : 1차 캐시에 저장하기 직전
- @PreUpdate : 1차 캐시에 업데이트하기 직전
- @PostLoad : DB → 1차캐시에 로드된 이후
실제로 수행될 작업인 Advice
를 encrypt()
메서드와 decrypt()
메서드에 구성합니다.
그러면 암호화/복호화라는 공통 관심사를 처리하는 모듈( Aspect
)은 EncryptionListener에 아래와 같이 구성할 수 있습니다.
class EncryptionListener {
/**
* 데이터 삽입 or 업데이트 시 엔티티의 특정 필드 값을 변경한다.
*/
@PrePersist
@PreUpdate
fun encrypt(entity: Any) {
// 자바 필드에 접근 후 set 메서드를 통해 기존 데이터를 암호화
}
/**
* 데이터 조회 시 엔티티의 특정 필드 값을 변경한다.
*/
@PostLoad
fun decrypt(entity: Any) {
// 자바 필드에 접근 후 set 메서드를 통해 기존 데이터를 복호화
}
}
AOP가 적용될 대상 객체인 Target Object
는 아래와 같이 적용하고 공통 로직과 핵심 로직을 연결하는 Weaving
은 @EntityListeners를 통해 연결합니다.
@Entity
@EntityListeners(EncryptionListener::class)
class EncryptClass1(
@Id
val id: String,
@Crypto
val encryptedText: String,
)
위와 같이 구성하면 비즈니스 로직에서 암호화/복호화 로직을 직접적으로 사용하지 않고 반복적으로 사용할 필요 없이 유용하게 코드를 작성할 수 있습니다.
하지만 이 방식에는 치명적인 오류가 발생하는 것을 확인하였습니다. 데이터를 조회할 때 @PostLoad 이벤트
가 감지 되고 그에 따라 필드에 데이터베이스에 존재하는 값을 복호화하여 1차 캐시에 저장하게 됩니다. 1차 캐시가 처음 인식 했을 때 값은 복호화된 데이터가 아닌 암호화된 데이터입니다. 그렇기 때문에 1차 캐시 내부적으로 값이 변경
되었다고 판단하여 update 문이 발생하는 것을 확인하였습니다. 데이터를 조회할 때마다 암호화된 데이터를 계속 암호화하는 문제가 발생하게 된 것이죠. 아쉽게도 Entity Life Cycle에는 PreLoad
라는 주기가 없어 이 문제를 해결할 수 없었습니다.
이러한 구현 실패를 통해서 Entity Life Cycle 자체에서 Entity 값 자체를 수정하는 것은 예측하기 어려운 side effect를 남길 수 있다는 것을 경험하였습니다.
AttributeConverter를 활용한 구현
AttributeConverter는 Entity가 Table의 데이터와 매핑이 이루어질 때 실제 데이터를 변경할 수 있는 인터페이스입니다. jakarta.persistence
에 정의된 JPA(JSR338) 스펙의 AttributeConverter
인터페이스 코드를 보면 아래와 같습니다.
package jakarta.persistence;
/**
* A class that implements this interface can be used to convert
* entity attribute state into database column representation
* and back again.
* Note that the X and Y types may be the same Java type.
*
* @param <X> the type of the entity attribute
* @param <Y> the type of the database column
*/
public interface AttributeConverter<X,Y> {
/**
* Converts the value stored in the entity attribute into the
* data representation to be stored in the database.
*
* @param attribute the entity attribute value to be converted
* @return the converted data to be stored in the database
* column
*/
public Y convertToDatabaseColumn (X attribute);
/**
* Converts the data stored in the database column into the
* value to be stored in the entity attribute.
* Note that it is the responsibility of the converter writer to
* specify the correct <code>dbData</code> type for the corresponding
* column for use by the JDBC driver: i.e., persistence providers are
* not expected to do such type conversion.
*
* @param dbData the data from the database column to be
* converted
* @return the converted value to be stored in the entity
* attribute
*/
public X convertToEntityAttribute (Y dbData);
}
convertToDatabaseColumn()
메서드는 persist()
, merge()
, flush()
시점에 호출 되면서 실질적으로 entity의 데이터를 쿼리로
변경할 때 발생합니다. convertToEntityAttribute()
는 쿼리 결과를 Entity로 매핑할 때 발생합니다. 이 특징을 이용해서 많이 사용하는 것이 Entity에는 Enum Class로 선언해놓고 실제 DB에서는 다른 값으로 매핑할 때 많이 사용합니다.
- Enum Class로 돈 단위 enum 정의
enum class MoneyUnit(val symbol: String) {
USD("$"),
EUR("€"),
KRW("₩")
}
- Enum 값이 Entity와 DB를 오갈 때 변경해주는 AttributeConverter 코드
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
@Converter(autoApply = false)
class MoneyUnitConverter : AttributeConverter<MoneyUnit, String> {
// 엔티티 → DB 저장 전 호출: enum → 기호 문자열
override fun convertToDatabaseColumn(attribute: MoneyUnit?): String? =
attribute?.symbol
// DB 조회 후 엔티티에 채워넣기 전 호출: 기호 문자열 → enum
override fun convertToEntityAttribute(dbData: String?): MoneyUnit? =
dbData?.let { symbol ->
MoneyUnit.values().find { it.symbol == symbol }
?: throw IllegalArgumentException("Unknown MoneyUnit symbol: $symbol")
}
}
- Entity 사용 예시
import jakarta.persistence.*
import java.math.BigDecimal
@Entity
@Table(name = "payment")
data class Payment(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val amount: BigDecimal,
@Convert(converter = MoneyUnitConverter::class)
val unit: MoneyUnit
)
위 예시와 같이 AttributeConverter 내에서 암복호화 코드를 아래와 같이 추가하여 암호화/복호화를 반복적으로 할 필요 없이 쉽게 적용할 수 있었습니다.
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
@Converter
class CryptoConverter : AttributeConverter<String, String> {
override fun convertToDatabaseColumn(attribute: String?): String? {
return CryptoStrategy.encrypt(attribute)
}
override fun convertToEntityAttribute(dbData: String?): String? {
return CryptoStrategy.decrypt(dbData)
}
}
AttributeConverter를 사용했을 때는 1차 캐시를 신경쓰지 않아도 되는 스펙이라 복잡하게 생각할 것이 없어 구현이 간단하다는 장점이 있었습니다. 다만, AttributeConverter 자체가 추가적인 Parameter를 넘길 수 없고 타입이 제한적이기 때문에 다양한 상황에서 일반적으로 사용하기에는 아쉬운 부분이 있었습니다.
UserType과 ParameterizedType를 활용한 구현
UserType과 ParameterizedType은 JPA 스펙이 아니라 JPA의 구현체인 Hibernate에서 제공해주는 인터페이스입니다. 먼저, ParameterizedType 인터페이스를 살펴보겠습니다.
import java.util.Properties;
/**
* Support for parameterizable types. A {@link UserType} or {@link UserCollectionType}
* may be made parameterizable by implementing this interface. Parameters for a type
* may be set by using a nested type element for the property element in the mapping
* file, or by defining a typedef.
*
* @author Michael Gloegl
*/
public interface ParameterizedType {
/**
* Gets called by Hibernate to pass the configured type parameters to
* the implementation.
*/
void setParameterValues(Properties parameters);
}
주석을 보면 UserType에 Parameter를 제공해주기 위한 인터페이스라고 설명이 되어 있습니다. AttributeConverter와는 다르게 Paramter를 제공해줄 수 있는 장점이 있어서 UserType과 ParameterizedType을 활용하여 구현을 해보았습니다.
import org.hibernate.engine.spi.SharedSessionContractImplementor
import org.hibernate.usertype.ParameterizedType
import org.hibernate.usertype.UserType
import java.io.Serializable
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.Types
import java.util.Properties
class CryptoType : UserType<String>, ParameterizedType {
private lateinit var keyType: KeyType
// DB 매핑 타입
override fun getSqlType(): Int = Types.VARCHAR
// 클래스 매핑 타입
override fun returnedClass(): Class<String> = String::class.java
// 매퍼 초기화 시점에 호출 (한 번만)
override fun setParameterValues(parameters: Properties?) {
this.keyType = parameters?.getProperty("keyType")
?.let { KeyType.valueOf(it) }
?: KeyType.DEFAULT
}
// DB -> 엔티티 직렬화 직전
override fun nullSafeGet(
rs: ResultSet,
position: Int,
session: SharedSessionContractImplementor,
owner: Any?
): String? {
val cipher = rs.getString(position) ?: return null
return Encryptor.decrypt(cipher, keyType)
}
// 엔티티 -> DB 바인딩 직전
override fun nullSafeSet(
st: PreparedStatement,
value: String?,
index: Int,
session: SharedSessionContractImplementor
) {
if (value == null) {
st.setNull(index, this.sqlType)
} else {
val cipher = Encryptor.encrypt(value, keyType)
st.setString(index, cipher)
}
}
override fun isMutable(): Boolean = false
override fun deepCopy(value: String?): String? = value
override fun disassemble(value: String?): Seri애alizable? = value
override fun assemble(cached: Serializable?, owner: Any?): String? = cached as String?
override fun replace(original: String?, target: String?, owner: Any?): String? = original
override fun equals(x: String?, y: String?): Boolean = x == y
override fun hashCode(x: String): Int = x.hashCode()
}
@Entity
@Table
class TypeEncryptClass(
@Id
val id: String,
@Type(
value = CryptoType::class,
parameters = [
Parameter(name = "keyType", value = "TEST")
]
)
// CryptoType에 sqlType을 VARCHAR로 선언했지만 실제 DB가 TEXT면 아래와 같이 선언하면 됨.
@Column(columnDefinition = "TEXT")
val utterance: String?,
)
암호화/복호화를 자동으로 하기위해 주요하게 봐야되는 메서드는 setParameterValues
, nullSafeGet
, nullSafeSet
3가지 입니다. setParameterValues
메서드는 Hibernate가 SessionFactory
(또는 EntityManagerFactory
)를 빌드하면서, 엔티티 매핑을 읽어 UserType
을 인스턴스화할 때 호출됩니다. 이 시점에 @Type(parameters=…)
또는 @TypeDef(parameters=…)
로 정의해 둔 값을 Properties
형태로 전달받아, 내부 필드에 저장해 둘 수 있습니다. 이후 nullSafeGet
/nullSafeSet
단계에서 이 값을 참조해서 DB와 자바 객체 간의 변환을 수행합니다. 이 방법은 Parameter를 넘길 수 있다는 장점이 있지만, 구현 방법이 그 전에 비해 복잡할 수도 있고 Paramter를 넘길 때 TypeSafe하게 넘길 수 없다는 단점이 있었습니다.
결론적으로, 저는 세 가지 방법 중 AttributeConverter와 UserType/ParameterizedType 두 가지를 제공하여 상황에 따라 사용자가 선택할 수 있도록 구성했습니다. AttributeConverter는 Parameter를 넘기지는 못하지만 사용자 입장에서는 좀 더 쉽게 사용할 수 있고 JPA 표준이기 때문에 JpaRepository에서 사용하는 Derived Query Methods에 대해서 자동으로 처리해준다는 장점이 있습니다. 하지만 추가적인 정보(Parameter)를 넘기지 못하니 필요에 따라 UserType/ParamterizedType을 활용하여 사용할 수 있게 제공하였습니다.
마치며
지금까지 현대 암호학이 왜 봉투 암호화라는 현실적인 구현 전략으로 발전할 수밖에 없었는지, 그리고 이를 실무에 적용할 때 AWS KMS 같은 관리형 키 서비스가 어떻게 활용되는지를 그리고 실무에서 어떤 노력을 통해 쉽게 구현하도록 노력했는지를 살펴보았습니다. 키 관리 보안 규정이라는 현실적인 제약을 기술적으로 풀어내려면, 단순히 코드를 잘 짜는 것 이상의 노력이 필요했습니다. 규정 그 자체에 대한 깊은 이해는 물론, 암호화라는 도메인에 대한 근본적인 고민이 함께 따라야 했습니다.
이 과정을 통해 개발자는 단지 기술만 이해하는 사람이 아니라, 자신이 풀고자 하는 문제의 도메인까지 깊이 이해해야 한다는 점을 다시 한번 깨달았습니다.
특히 암호화와 같은 보안 분야는 기술적 정확성과 더불어 맥락에 대한 이해가 필수적인 영역입니다. 개발은 단순히 최신 기술을 따라가기보다, 도메인의 본질을 이해하고 그에 맞는 설계를 할 수 있는 역량이 더욱 중요해지고 있습니다. 이번 글이 여러분에게도 그런 인사이트를 제공하는 계기가 되었기를 바랍니다.
긴 글 읽어주셔서 감사합니다.