**R2DBC에서 enum매핑 문제를 겪으며 느낀점 ** #
R2DBC를 사용하면서 처음 크게 막혔던 부분 중 하나는 enum 매핑이었다. JPA를 사용할 때는 enum 필드를 비교적 자연스럽게 저장하고 읽어왔기 때문에, R2DBC에서도 비슷하게 동작할 것이라고 생각했다. 하지만 실제로는 그렇지 않았고, 특히 PostgreSQL의 native enum 타입을 사용할 때는 생각보다 더 명시적인 설정이 필요했다. Spring Data R2DBC는 기본적으로 Enum을 String으로 변환해 저장하며, Postgres native enum을 그대로 사용하려면 기본 매핑을 그대로 두면 안 된다.
왜 JPA에서는 덜 의식했을까 #
처음에는 “JDBC는 enum을 자동으로 잘 처리해주는데 R2DBC는 왜 이걸 못하지?”라고 생각했다. 그런데 정확히 말하면, 내가 편하게 느꼈던 이유는 JDBC 드라이버 자체가 특별히 잘해줬기 때문이라기보다 JPA/Hibernate가 enum 매핑을 추상화해줬기 때문에 가깝다. Jakarta Persistence는 @Enumerated를 통해 enum을 데이터베이스에 저장하는 방식을 정의하고 있고, enum을 문자열이나 ordinal로 매핑할 수 있도록 지원한다. 즉, JPA 환경에서는 ORM 계층이 enum 매핑을 상당 부분 감싸주고 있었던 셈이다.
Spring Data R2DBC의 기본 동작 #
Spring Data R2DBC는 enum을 기본적으로 문자열(String) 로 다룬다. 공식 문서의 기본 타입 매핑 표에도 Enum -> String으로 명시되어 있다. 그래서 데이터베이스 컬럼이 varchar 계열이라면 별다른 설정 없이 동작할 수 있지만, PostgreSQL의 native enum 컬럼을 사용하고 있다면 이 기본 동작만으로는 원하는 방식으로 처리되지 않을 수 있다. 이 지점이 JPA를 쓰다가 R2DBC로 넘어왔을 때 가장 헷갈렸던 부분이었다. “enum을 자동으로 처리하지 않는다”기보다, 내가 기대한 방식과 프레임워크의 기본값이 달랐다고 보는 편이 더 정확하다.
JDBC vs R2DBC의 차이와 enum 매핑은 같은 문제가 아니다 #
JDBC와 R2DBC의 가장 큰 차이는 분명 동기/블로킹 모델과 비동기/리액티브 모델의 차이에 있다. 하지만 내가 겪은 enum 매핑 문제를 이 차이만으로 설명하는 것은 정확하지 않았다. 처음에는 “R2DBC는 데이터를 스트림으로 흘려보내야 하니 복잡한 변환 로직을 최소화한 것 아닐까?”라고 생각했지만, Spring Data R2DBC 역시 MappingR2dbcConverter와 CustomConversions를 통해 변환 로직을 제공한다. 즉, R2DBC라서 변환 로직이 없는 것이 아니라, 기본 enum 매핑 정책과 Postgres native enum 지원 방식이 따로 존재하는 것이다. enum 문제의 핵심은 비동기 패러다임 자체보다 기본 매핑 정책과 DB 고유 타입의 연결 지점에 있었다.
드라이버에 EnumCodec을 등록했는데도 에러가 난 이유 #
PostgreSQL R2DBC 드라이버는 EnumCodec을 제공한다. 공식 문서에 따르면 EnumCodec은 Postgres enum 타입을 Java enum 타입에 매핑하고, Postgres 결과를 Java enum 인스턴스로 materialize할 수 있다. 그래서 처음에는 드라이버에 enum 타입만 등록하면 끝날 줄 알았다. 실제로 드라이버 수준에서는 이것이 맞는 방향이다.
하지만 Spring Data R2DBC를 함께 사용하는 경우에는 이야기가 조금 달라진다. Spring Data R2DBC는 기본적으로 enum을 문자열로 변환하려고 하기 때문에, 드라이버는 native enum을 기대하고 있는데 상위 매핑 계층은 문자열을 넘기려는 상황이 생길 수 있다. Spring 공식 문서도 Postgres처럼 native enum을 지원하는 데이터베이스에서는 기본 Enum.name() 기반 변환 대신, EnumWriteSupport를 상속한 @Writing converter를 등록하고 드라이버 레벨에서도 enum 타입을 설정해야 한다고 설명한다. 내가 겪었던 “enum과 varchar를 비교하려고 해서 생긴 에러”는 드라이버가 고장 났다기보다, Spring Data의 기본 enum 처리와 드라이버의 native enum 처리 방향이 서로 맞지 않았던 문제로 보는 편이 더 정확하다.
예를 들어 Postgres enum을 그대로 사용하려면 다음처럼 드라이버와 Spring Data 양쪽을 함께 맞춰야 했다.
드라이버 레벨: EnumCodec 등록 Spring Data 레벨: EnumWriteSupport 기반 쓰기 converter 등록
스프링 데이터 R2dbc에서는 드라이버에 내장된 메커니즘을 사용하여 열거형을 처리하는 경우 EnumWriteSupport를 등록해야 한다고 합니다. 하지만 제 경험에 따르면 Spring Data R2dbc를 사용할 때 쓰기는 자동으로 처리할 수 있지만 Postgres에서 열거형을 읽으려면 읽기 변환기가 필요합니다.
Spring Data R2dbc를 사용하지 않는경우 EnumCodec에 등록하는것만으로도 충분하지만 Spring Data R2dbc를 사용하는경우 어플리케이션 레벨에서도 따로 커스텀 변환기를 생성해줘야한단다
@Bean
override fun r2dbcCustomConversions(): R2dbcCustomConversions {
val converters: MutableList<Converter<*, *>?> = ArrayList<Converter<*, *>?>()
converters.add(ContractTypeConverter(ContractType::class.java))
converters.add(StringToEnumConverter(ContractType::class.java))
converters.add(ChainTypeConvert(ChainType::class.java))
converters.add(StringToEnumConverter(ChainType::class.java))
converters.add(TokenTypeConvert(TokenType::class.java))
converters.add(StringToEnumConverter(TokenType::class.java))
return R2dbcCustomConversions(storeConversions, converters)
}
읽기 변환기도 꼭 필요할까 #
이 부분은 공식 문서와 실제 경험을 구분해서 써야 한다. 공식 문서 기준으로는 Postgres native enum을 쓰기 위해 쓰기 변환기와 드라이버 설정이 핵심이다. 반면 읽기 변환기는 프로젝트의 구성, 컬럼 타입, 매핑 경로, 사용하는 API에 따라 추가로 필요할 수도 있다. 실제 내 경우에는 Spring Data R2DBC를 사용할 때 읽기 변환기까지 등록하는 편이 안정적이었다. 다만 이것을 “항상 읽기 변환기도 필수다”라고 일반화하기보다는, 내 프로젝트에서는 읽기 변환기까지 등록해 해결했다고 쓰는 편이 더 정확하다. 드라이버 문서 자체는 EnumCodec이 Postgres 결과를 Java enum으로 materialize할 수 있다고 설명하고 있기 때문이다.
제네릭 변환기로 정리하려 했던 이유 #
enum 타입이 여러 개이다 보니, 각 enum마다 읽기/쓰기 변환기를 모두 별도로 만드는 것이 번거롭게 느껴졌다. 그래서 제네릭 기반으로 공통화하려고 했지만, 런타임에는 제네릭 타입 정보가 지워지기 때문에 실제 enum 타입을 그대로 보존하기 어려웠다. 결국 내가 선택한 방식은 enumType을 명시적으로 넘기는 구조였다. 이 부분은 R2DBC 자체의 문제라기보다, 자바/코틀린의 타입 시스템과 제네릭 소거 특성에서 오는 제약에 더 가깝다.
커스텀 변환기의 오버헤드에 대해 #
처음에는 “매번 변환기를 거치면 오히려 오버헤드가 생기지 않을까?”라는 생각도 했다. 이런 의문 자체는 자연스럽지만, 적어도 enum 정도의 단순 변환은 보통 I/O 비용에 비해 훨씬 작은 경우가 많다. Spring Data 문서도 명시적 converter를 통해 기본 매핑을 바꾸거나 최적화할 수 있다고 설명하고 있다. 따라서 enum 변환기를 두는 것을 reactive의 장점을 해치는 비용으로 단정하기보다는, 정확한 타입 매핑과 데이터 일관성을 위한 필요한 구성으로 보는 편이 맞다. 실제 성능 영향은 추측보다 측정으로 판단하는 것이 적절하다.
정리 #
이번 경험을 통해 느낀 점은 분명했다. JPA에서는 enum 매핑이 ORM 계층에 의해 비교적 자연스럽게 처리되기 때문에, 개발자가 DB 타입과 매핑 전략을 깊게 의식하지 않고 넘어갈 때가 많다. 반면 Spring Data R2DBC에서는 기본 enum 매핑이 String 중심이고, PostgreSQL native enum을 쓰려면 드라이버와 매핑 계층 양쪽을 함께 맞춰야 한다. 그래서 R2DBC가 enum을 “못 다룬다”기보다, 기본 매핑 정책이 다르고 더 명시적인 설정을 요구한다고 보는 편이 정확했다.
결국 이 문제는 “JDBC는 자동이고 R2DBC는 수동”의 단순한 차이보다는, JPA가 추상화해주던 부분을 Spring Data R2DBC에서는 더 직접 다뤄야 했던 경험에 가까웠다. 그래서 R2DBC를 쓰면서 “갓 JPA”라는 생각이 들었던 건 사실이지만, 정확히는 JDBC 드라이버가 대단했다기보다 ORM의 추상화가 그만큼 강력했던 것이라고 정리하는 편이 맞겠다.