딜리버리 암호화 모듈 개발기
딜리버리에서 사용하고있는 암호화모듈을 개발한 경험을 공유합니다.
들어가며
안녕하세요. 딜리버리플랫폼개발팀 강준영입니다.
2023년 9월 새로운 배송시스템을 오픈했었는데요,
이때 사용했던 암호화 모듈을 개발했던 경험을 공유드리고자 합니다.
왜 암호화 모듈을 직접 개발했을까요?
당시, 다음과 같이 암호화 모듈에 바라는 점들을 수집했었습니다.
- 암/복호화 과정은 DB에서 하지 않고 애플리케이션에서 처리한다.
- 암호키 생성은 직접 관리하지 않아야 하며 하루에도 여러 번 교체 발급 가능했으면 좋겠다.
- 엔티티 조회/저장 시 자동으로 암/복호화가 되었으면 좋겠다.
- 새 배송시스템 오픈 전에 모든 기존 저장된 평문 데이터도 암호화 마이그레이션을 할것
만약 오픈을 목전에 앞둔 상황에서 성능이나 보안 같은 심각한 문제가 발생한다면 일정에 영향을 줄 수 있습니다.
실제로 몇몇 오픈소스 라이브러리 후보에는 다음과 같이 취약점이 보고되어 있었고, 취약점의 경중을 떠나 다소 부담스러웠습니다.

© 2025. Kurly. All rights reserved.
또한, 향후에 어떠한 요구사항이 추가될지 알 수 없습니다.
아래는 이해를 돕기 위해 제가 상상해본 가상의 요구사항들인데요, 어쩌면 대처가 불가능할 수도 있습니다.
- 처리 속도를 더 높여주세요.
- 저장용량을 더 절약해주세요.
- 암호화방식을 변경하는 API를 만들어주세요.
결국 직접 만드는 편이 모듈을 이해하기도 좋고 제어하기도 용이할 것이라 판단하여 암호화모듈 cowcow-enc를 만들게 되었습니다.
엔티티 자동 암/복호화 방식 결정
암호화 모듈 고객의 입장에서 사용성을 고려했을 때, 엔티티 자동 암/복호화
가 필요하다고 생각했고 여러 구현 방법 중 다음 3가지 후보가 있었습니다.
- 1안. JPA @PostLoad
- 2안. Hibernate PreLoad event
- 3안. JPA @Converter
결론은 3안으로 결정되었는데, 과정이 그리 순탄하진 않았습니다.

1안. JPA @PostLoad - ❌
PostLoad를 이용하면 로드가 된 이후 시점을 알 수 있어, 이 시점에 자동으로 복호화하도록 만들 수 있습니다.
복호화된 데이터를 관리할 @Transient 필드를 둬서 복호화값을 보관해두고 가져다 쓸 수 있습니다.
아쉬운 점은 엔티티를 작성하는 개발자가 고려할 부분이 많아 실수를 할 우려가 있습니다.
@Entity
@EntityListeners(Listener.class)
class User {
@Column(name = "encrypted_data")
private String encryptedData; // ❌ 암호화된 데이터. 일절 관심 없는 필드가 존재함
@Transient
private String decryptedData; // ❌ 복호화된 데이터. 개발자가 직접 작성해줘야 함
}
// ❌ 엔티티 로딩후 복호화 처리. 개발자가 직접 작성해야 함
class Listener {
@PostLoad
public void postLoad(User user) {
user.decryptedData = crypto.decrypt(encryptedData);
}
}
아래처럼 하나의 필드를 쓰면서 PostLoad 시점에 평문으로 바꿔치는 기발한 방식을 이용해보기로 했습니다.
덕분에 전혀 관심없던 암호화된 데이터 필드를 제거할 수 있었습니다. (Listener를 개발해야 하는 건 여전히 남아있지만 못 본체 하겠습니다!)
@Entity
@EntityListeners(Listener.class)
class User {
@Column(name = "encrypted_data")
private String data; // 1. 암호화된 상태로 로딩되지만,
}
// ❌ 엔티티 로딩후 복호화 처리. 개발자가 직접 작성해야 함
class Listener {
@PostLoad
public void postLoad(User user) {
user.data = crypto.decrypt(encryptedData); // 2. 이 시점에 평문으로 바꿔치면 되겠군!
}
}

하지만 조회만 했음에도 flush시점에 update가 날아가는 현상이 발견되었습니다.
값이 바뀌면서 dirty로 체크되었기 때문입니다.
2안. Hibernate PreLoad event - ❌
"로딩시점을 감지하여 암호화 데이터를 복호화 데이터로 교체한다"는 전략은 1안과 동일합니다.
Hibernate의 PreLoadEventListener
를 구현하는 방식입니다.
엔티티 로딩 직전, 구체적인 정보를 이벤트로 수신할수 있습니다.
무엇보다 로딩 전의 시점이라, 복호화로 필드의 값이 변경되더라도 dirty로 처리되지 않았습니다.
@Component
public class EncryptionListener implements PreLoadEventListener {
@Override
public void onPreLoad(PreLoadEvent event) {
Object[] state = event.getState();
String[] propertyNames = event.getPersister().getPropertyNames();
Object entity = event.getEntity();
// 리플렉션을 이용하여 필드별로 복호화 처리를 담당하는 메서드
crypto.decrypt(state, propertyNames, entity);
}
}
update도 발생하지 않고 모든 게 다 잘 동작하면서 성공하는 듯했습니다!

기쁨은 Spring Boot 2.x에서 개발할 때까지만이었습니다.
배송시스템은 3.0.4로 출시하기로 했고, 버전을 올리자 없던 오류가 발생했습니다.
복호화에 필요한 PreLoad시점을 제대로 활용할 수 없는 Hibernate 6.x의 버그였습니다.
6.2.0에서야 고쳐졌고, 해당 버전이 정식으로 Spring Boot 3.1에 포함되었습니다. (23년 5/18 릴리즈) Hibernate 이슈 HHH-16350
Hibernate 종속성만 다른 버전으로 픽스할 수도 있었지만, 선호하는 방법은 아니었습니다.
결국 다른 대안을 찾아보기로 했습니다.
3안. JPA @Converter - 결정 👍
많이들 사용하고 계시는 AttributeConverter를 구현하는 방식입니다.
아래는 cowcow-enc에 번들로 들어있는 String을 암/복호화하는 컨버터입니다.
내부에는 암호화 서비스인 EnvelopService로 암/복호화를 위임합니다.

© 2025. Kurly. All rights reserved.
사용법은 엔티티 내 암호화 대상 필드 위에 @Convert(converter = 컨버터.class)
를 추가하면 됩니다.
Converter를 이용한 암/복호화 방식은 몇 가지 소소한 장점들이 있습니다.
- 리플렉션을 사용하지 않음
- 원하는 어떤 타입이라도 매핑할 수 있음
- Hibernate PreLoad보다 대중성 있음
또한, 아래와 같이 jpql(또는 QueryDSL)에서 Dto(또는 Interface)로 일부 필드만 조회하더라도 컨버터를 거치기 때문에 복호화된 상태로 로딩이 된다는 편의성이 있습니다.

© 2025. Kurly. All rights reserved.
암호화 방식
데이터를 암/복호화할 수 있는 암호키는 관리가 매우 중요한데요,
사용자의 애플리케이션에서는 암호키라는 게 있는지조차 모르게 할 수가 있는데, 바로 봉투암호화입니다.
암호키가 봉투 속에 데이터와 함께 동봉되어 있기 때문입니다.
봉투암호화 적용
봉투암호화에 동작에 대해 간략히 설명드리면 다음과 같습니다.
- 클라우드공급업체에 있는 마스터키로부터
암호키
와암호화된 암호키
를 발급받는다. 암호키
로 평문을 암호화하여암호화된 데이터
를 만든다.암호화된 데이터
와암호화된 암호키
를 봉투에 담는다.- 암호화에 사용된
암호키
는 바로 폐기한다.

© 2025. Kurly. All rights reserved.
위의 동작방식 덕분에 암호키 탈취가 매우 어렵고, 탈취되더라도 극히 일부의 데이터만 볼 수 있습니다.
종단 간 송수신시 암호화가 필요하다면 그냥 봉투째 송신하면 된다는 장점도 있습니다.
봉투암호화에 대한 자세한 설명은 아래 잘 설명된 링크가 있어 첨부합니다.
참고 자료:
봉투크기 최적화
봉투암호화를 하려면 암호키 길이 같은 부수적인 정보를 함께 저장해야 합니다.
범용성이 중요한 오픈소스는 다음과 같이 많은 정보를 관리할 수밖에 없습니다.

© 2025. Kurly. All rights reserved.
하지만 직접 만든 모듈은 많은 경우 없애거나 줄일 수 있어 헤더 영역을 최적화할 수 있었습니다.
덕분에 보안수준은 유지하면서도 크기를 10%~30% 정도 절감했습니다.
base64 제거
당시 암호화 바이트를 base64 인코딩하여 varchar(또는 text)으로 저장하는 경우가 많았습니다.
base64 인코딩된 바이트는 원본보다 0.33배가 더 크고 인코딩/디코딩하는데 오버헤드가 발생합니다.
postgresql, mysql과 같은 DB는 바이트 전용 데이터 타입이 있기 때문에 바이트 타입으로 저장하기로 했습니다.
정합성의 영향을 줄 수도 있고 선례를 찾지 못했던 조금은 부담스러운 결정이었지만, 엣지 케이스를 꼼꼼하게 테스트하면 조금은 안도감을 얻을 수 있습니다.
배포
우리 팀은 cowcow라는 소소한 모듈들을 관리하는 저장소가 있습니다.
아래 캡처를 보면 우리의 cowcow-enc가 보입니다.
그리고 이렇게 만들어져 배포된 cowcow 모듈들을 사용자의 애플리케이션 환경에서 테스트해볼 수 있는 cowcow-tests가 있습니다.

© 2025. Kurly. All rights reserved.
cowcow에서 개발하면 딜리버리 서비스 전체에 기여할 수 있습니다.
마치 오픈소스 생태계에서 개발하는 기분이 들지만, 오픈소스만큼 부담스럽지 않습니다. (클로즈드 오픈소스 정도..)
종속성 최소화
gradle은 의존성 충돌 문제를 해결하기 위해 여러 가지 노력을 하고 있습니다.
- 어떠한 종속성을 추가하면, 그 종속성이 가진 종속성도 가져옵니다.
- 이때 여러 버전의 종속성이 존재할 수 있으며, gradle은 의존성 그래프로 모든 종속성을 탐색한 후, 가장 높은 버전을 선택하여 해결합니다.
- api나 implementation을 이용하여 전이 여부를 제어할 수 있습니다. (compile classpath만 해당하지만요)
버전충돌이 해결된다는 것은 결국 누군가는 의도치 않은 버전으로 동작하게 된다는 것입니다.
실제로 문제 발생까지 이어지는 것은 흔하진 않지만, 이로 인해 발생하는 유명한 이슈로는 NoSuchMethodException, ClassNotFoundException 예외가 있습니다.
결국 종속성은 최대한 적은 것이 최선이며, 가능한 꼭 필요한 것만 추가하는 것이 좋습니다.
아래와 같이 cowcow-enc는 compileClasspath에는 아무것도 노출되지 않으며, runtimeClasspath에는 4개만 노출되도록 했습니다.

테스트
엣지케이스 테스트
Property based test 도구를 사용하면 광범위한 입력값으로 엣지케이스를 쉽게 테스트할 수 있습니다.
아래는 fixture-monkey를 이용해 랜덤값을 1000번 암호화/복호화 처리가 문제없는지 테스트하는 코드입니다.

생성된 plainText 값들은 다음과 같이 완전 무작위입니다. 덕분에 개발자가 미처 생각치 못했던 입력값도 테스트할수 있게 됩니다.

fixture-monkey에 내장된 jqwik의 EdgeCasesMode.FIRST를 이용하면 경계값 우선 테스트를 할수도 있습니다.
문자열의 경우는 null, 빈 문자열, 공백
등을 먼저 생성하고, 만약 숫자라면 0, -1, max integer, min integer
등을 먼저 생성합니다.

© 2025. Kurly. All rights reserved.
사용자의 애플리케이션 환경에서 테스트
실제 사용자의 애플리케이션에서 cowcow-enc 모듈이 잘 동작하는지 cowcow-tests 모듈에서 미리 테스트해볼 수 있습니다.
사용자의 애플리케이션 환경이 모듈에도 영향을 줄 수 있기 때문에 이러한 테스트는 도움이 됩니다.
종속성 버전은 충돌이 해결되면서 바뀌기도 하지만, Spring Boot dependency management에 의해 호환성이 좋은 특정 버전으로 조정되기도 합니다.
우리는 이미 2.x에서 잘 되다가 3.x에서 동작하지 않는 아픔을 겪어봤습니다. 😭
아래의 그림은 cowcow-tests의 autoconfigure 버전이, cowcow-enc로부터 전이된 버전 3.2.6이 아니라, cowcow-tests 의 Spring Boot 버전 3.1.4에 맞춰 다운그레이드 되었습니다.

이제 cowcow-tests에서도 호환이 잘 되는지 테스트를 했으니, 3.1.4도 지원된다고 (쪼오끔 더) 자신있게 말할 수 있습니다.
성능테스트
IntelliJ Profiler를 이용하여 추가 설치 없이 클릭 한 번으로 간단하게 모듈의 성능을 측정할 수 있습니다.
이하 모든 테스트는 M1 맥북프로 16GB에서 Xms256m, 서버모드, G1 GC를 기반으로 진행하였습니다.
아래와 같이 300만 개의 plainText를 처리하는 중 암호화는 5.58초, 복호화는 5.36초 소요되는 것을 볼 수 있습니다.

© 2025. Kurly. All rights reserved.
- 처음 100번을 버린 것은, JVM이 웜업되기 전의 처리 구간을 제외하기 위함입니다.
- 캡처에는 없지만, plainText의 총 용량은 평균 290MB 정도였습니다.
아래는 위의 암/복호화 처리를 11분 이상 지속했을 때의 CPU와 heap memory입니다.
CPU도 튀지 않고 heap memory도 잘 정리되고 있습니다.

마치며
여전히 부족하지만, 암호화에 대해 조금이나마 이해할 수 있었습니다.
중요한 결정이 필요한 순간마다 팀원들과 함께 고민하며 최선의 선택을 찾아가는 값진 경험을 할 수 있었습니다.
덕분에 cowcow-enc 모듈은 여러 딜리버리 서비스에 적용되어 사용되고 있으며 앞으로도 계속 발전해 나갈 것입니다.
끝까지 읽어주셔서 감사합니다.
