PostgreSQL jsonb 타입을 Hibernate에서 처리하기: hypersistence-utils 활용
PostgreSQL jsonb 타입을 Hibernate에서 처리하기: hypersistence-utils 활용
들어가며
Spring Boot와 Hibernate를 사용하여 PostgreSQL의 jsonb 타입 컬럼을 다루다 보면 타입 불일치 문제를 마주하게 됩니다. 특히 Entity에서 Map<String, Any>? 타입으로 매핑하려고 할 때, Hibernate가 자동으로 jsonb를 처리하지 못해 character varying 타입으로 변환하려다 실패하는 경우가 발생합니다.
이번 글에서는 실제 프로젝트에서 겪은 문제와 해결 과정을 공유하고, hypersistence-utils 라이브러리를 활용한 간단하고 효과적인 해결 방법을 소개합니다.
PostgreSQL JSONB
PostgreSQL의 jsonb 타입은 JSON 데이터를 바이너리 형식으로 저장하여 빠른 검색과 인덱싱을 지원합니다. 하지만 Hibernate에서 이를 직접 처리하는 것은 쉽지 않습니다. hypersistence-utils 라이브러리는 이러한 문제를 해결하기 위해 Hibernate의 UserType 메커니즘을 활용합니다.
문제 상황
조직(Organization) 정보를 관리하는 Entity에서 settings 필드를 PostgreSQL의 jsonb 타입으로 저장하려고 했습니다. 초기 구현에서는 다음과 같은 에러가 발생했습니다:
ERROR: column "settings" is of type jsonb but expression is of type character varying
Hint: You will need to rewrite or cast the expression.
초기 코드
@Entity @Table(name = "organizations") data class OrganizationEntity( @Id @Column(name = "id", length = 26) val id: String, @Column(name = "name", nullable = false, length = 200) val name: String, @Column(name = "settings", columnDefinition = "jsonb") val settings: Map<String, Any>?, // ❌ 타입 불일치 문제 발생 // ... 기타 필드들 )
Hibernate는 Map<String, Any>? 타입을 자동으로 jsonb로 변환하지 못하고, 기본적으로 character varying (VARCHAR)로 처리하려고 시도했습니다. 이로 인해 PostgreSQL에서 타입 불일치 에러가 발생했습니다.
시도했던 해결 방법들
1. AttributeConverter 사용
JPA의 AttributeConverter를 사용하여 수동으로 JSON 직렬화/역직렬화를 처리하려고 했습니다:
@Converter class JsonbMapConverter : AttributeConverter<Map<String, Any>?, String?> { private val objectMapper = ObjectMapper() override fun convertToDatabaseColumn(attribute: Map<String, Any>?): String? { return attribute?.let { objectMapper.writeValueAsString(it) } } override fun convertToEntityAttribute(dbData: String?): Map<String, Any>? { return dbData?.let { objectMapper.readValue(it, object : TypeReference<Map<String, Any>>() {}) } } }
하지만 이 방법도 Hibernate가 SQL에 CAST(? AS jsonb)를 자동으로 추가하지 못해 같은 에러가 발생했습니다.
2. @JdbcTypeCode 사용
Hibernate 6의 @JdbcTypeCode 어노테이션을 사용해보았습니다:
@JdbcTypeCode(SqlTypes.JSON) @Column(name = "settings", columnDefinition = "jsonb") val settings: Map<String, Any>?,
이 방법도 PostgreSQL의 jsonb 타입과 완벽하게 호환되지 않아 문제가 지속되었습니다.
최종 해결: hypersistence-utils 라이브러리
여러 시도 끝에 hypersistence-utils-hibernate-63 라이브러리를 발견했습니다. 이 라이브러리는 Hibernate 6과 PostgreSQL의 jsonb 타입을 완벽하게 지원합니다.
1. 의존성 추가
build.gradle.kts에 다음 의존성을 추가합니다:
dependencies { // Hibernate Types for JSON support implementation("io.hypersistence:hypersistence-utils-hibernate-63:3.7.3") }
2. Entity 수정
@Type(JsonType::class) 어노테이션을 사용하여 jsonb 타입을 매핑합니다:
package com.sclass.lms.adapter.outbound.persistence.entity import com.sclass.lms.domain.model.Organization import com.sclass.lms.domain.model.OrganizationId import com.sclass.lms.domain.model.OrganizationStatus import io.hypersistence.utils.hibernate.type.json.JsonType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import org.hibernate.annotations.Type import java.time.Instant @Entity @Table(name = "organizations") data class OrganizationEntity( @Id @Column(name = "id", length = 26) val id: String, @Column(name = "name", nullable = false, length = 200) val name: String, @Column(name = "subdomain", nullable = false, unique = true, length = 100) val subdomain: String, @Column(name = "domain", unique = true, length = 255) val domain: String?, @Column(name = "logo_url", length = 500) val logoUrl: String?, @Type(JsonType::class) @Column(name = "settings", columnDefinition = "jsonb") val settings: Map<String, Any>?, @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) val status: OrganizationStatus, @Column(name = "created_at", nullable = false) val createdAt: Instant, @Column(name = "updated_at", nullable = false) val updatedAt: Instant, ) { fun toDomain(): Organization { return Organization( id = OrganizationId.from(id), name = name, subdomain = subdomain, domain = domain, logoUrl = logoUrl, settings = settings, // JsonType이 자동으로 변환 status = status, createdAt = createdAt, updatedAt = updatedAt, ) } companion object { fun fromDomain(organization: Organization): OrganizationEntity { return OrganizationEntity( id = organization.id.value, name = organization.name, subdomain = organization.subdomain, domain = organization.domain, logoUrl = organization.logoUrl, settings = organization.settings, // JsonType이 자동으로 변환 status = organization.status, createdAt = organization.createdAt, updatedAt = organization.updatedAt, ) } } }
핵심 포인트
-
@Type(JsonType::class)어노테이션:io.hypersistence.utils.hibernate.type.json.JsonType을 사용하여jsonb타입을 자동으로 처리합니다. -
타입 안정성: Entity에서
Map<String, Any>?타입을 직접 사용할 수 있어 타입 안정성을 유지합니다. -
자동 변환:
JsonType이 자동으로Map<String, Any>?↔jsonb변환을 처리하므로 수동 직렬화/역직렬화가 필요 없습니다.
hypersistence-utils 라이브러리 내부 동작 원리
hypersistence-utils 라이브러리가 어떻게 동작하는지 더 깊이 이해하기 위해 내부 구현을 살펴보겠습니다.
1. UserType 인터페이스 구현
JsonType은 Hibernate의 UserType 인터페이스를 구현합니다. UserType은 Hibernate가 기본적으로 지원하지 않는 타입을 처리하기 위한 확장 메커니즘입니다.
// JsonType의 핵심 구조 (의사 코드) public class JsonType implements UserType { // SQL 타입 정의 @Override public int[] sqlTypes() { return new int[]{Types.OTHER}; // PostgreSQL의 jsonb는 OTHER 타입으로 처리 } // 반환될 Java 클래스 타입 @Override public Class returnedClass() { return Map.class; // 또는 제네릭 타입에 따라 결정 } // DB에서 Java 객체로 변환 (역직렬화) @Override public Object nullSafeGet(ResultSet rs, String[] names, SessionImplementor session, Object owner) { String json = rs.getString(names[0]); if (json == null) return null; // Jackson을 사용하여 JSON 문자열을 Java 객체로 변환 return objectMapper.readValue(json, Map.class); } // Java 객체를 DB로 변환 (직렬화) @Override public void nullSafeSet(PreparedStatement st, Object value, int index, SessionImplementor session) { if (value == null) { st.setNull(index, Types.OTHER); return; } // Jackson을 사용하여 Java 객체를 JSON 문자열로 변환 String json = objectMapper.writeValueAsString(value); // PostgreSQL의 jsonb 타입으로 명시적 캐스팅 st.setObject(index, json, Types.OTHER); } }
2. 데이터 변환 흐름
hypersistence-utils의 JsonType은 Hibernate의 UserType 메커니즘을 통해 Java 객체와 PostgreSQL jsonb 타입 간의 변환을 처리합니다. 아래는 이 변환 과정을 시각화한 것입니다.
Hibernate Architecture
저장 시 (INSERT/UPDATE)
Java Map<String, Any>
↓ (nullSafeSet 호출)
Jackson ObjectMapper.writeValueAsString()
↓
JSON 문자열: {"key": "value"}
↓
PreparedStatement.setObject(index, json, Types.OTHER)
↓
PostgreSQL: CAST(? AS jsonb)
↓
jsonb 컬럼에 저장
조회 시 (SELECT)
PostgreSQL jsonb 컬럼
↓
ResultSet.getString()
↓
JSON 문자열: {"key": "value"}
↓
Jackson ObjectMapper.readValue(json, Map.class)
↓
Java Map<String, Any>
3. PostgreSQL jsonb 타입 처리
PostgreSQL의 jsonb 타입은 바이너리 형식으로 저장되지만, JDBC 드라이버는 이를 문자열로 변환하여 전달합니다. JsonType은 이 문자열을 받아서 Java 객체로 변환합니다.
핵심은 PreparedStatement.setObject()를 사용할 때 Types.OTHER를 지정하고, PostgreSQL 드라이버가 자동으로 CAST(? AS jsonb)를 추가하도록 하는 것입니다.
4. Hibernate 6에서의 변화
Hibernate 6에서는 @TypeDef 어노테이션이 제거되고, @Type 어노테이션에 직접 클래스를 지정하는 방식으로 변경되었습니다.
Hibernate 5.x (구방식):
@TypeDef(name = "jsonb", typeClass = JsonType::class) @Type(type = "jsonb") val settings: Map<String, Any>?
Hibernate 6.x (신방식):
@Type(JsonType::class) val settings: Map<String, Any>?
이 변경으로 인해:
- 타입 안정성 향상: 컴파일 타임에 타입 체크 가능
- 코드 간결성:
@TypeDef선언 불필요 - 리팩토링 용이: 클래스 이름 변경 시 자동으로 반영
5. Jackson 통합
JsonType은 내부적으로 Jackson의 ObjectMapper를 사용합니다. Spring Boot를 사용하는 경우, 애플리케이션 컨텍스트에 등록된 ObjectMapper Bean을 자동으로 사용하거나, 기본 설정으로 새 인스턴스를 생성합니다.
// JsonType 내부 (의사 코드) private ObjectMapper objectMapper = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
이를 통해:
- JSON 직렬화/역직렬화의 일관성 보장
- Spring Boot의 Jackson 설정 자동 적용
- 커스텀 직렬화/역직렬화 로직 추가 가능
6. 변경 감지 (Dirty Checking)
Hibernate의 변경 감지 메커니즘이 JsonType과 함께 작동하는 방식:
- Entity의
settings필드에 새로운Map할당 - Hibernate가 이전 값과 새 값을 비교 (Deep Copy 비교)
- 값이 변경되었다고 판단되면
nullSafeSet()호출 - 트랜잭션 커밋 시 UPDATE SQL 생성 및 실행
// 예시: settings 필드 업데이트 val organization = organizationRepository.findById(id) organization.settings = mapOf("theme" to "dark", "language" to "ko") organizationRepository.save(organization) // 트랜잭션 커밋 시 UPDATE 실행
7. 데이터베이스별 자동 매핑
JsonType은 데이터베이스 방언(Dialect)에 따라 자동으로 적절한 타입을 사용합니다:
- PostgreSQL:
jsonb타입 (바이너리 형식, 인덱싱 지원) - MySQL:
json타입 (텍스트 형식) - Oracle:
json또는clob타입 - H2:
varchar타입 (개발/테스트 환경)
이를 통해 동일한 코드로 여러 데이터베이스를 지원할 수 있습니다.
8. 성능 고려사항
JsonType의 성능 특성:
- 직렬화/역직렬화 오버헤드: Jackson을 사용한 변환은 CPU 집약적
- 메모리 사용: JSON 문자열이 메모리에 임시로 생성됨
- 네트워크 전송: PostgreSQL 드라이버가 jsonb를 문자열로 변환하여 전송
대용량 JSON 데이터를 다룰 때는:
- 필요한 필드만 조회 (Projection 사용)
- JSON 인덱싱 활용 (PostgreSQL의 GIN 인덱스)
- 캐싱 전략 고려
장점
1. PostgreSQL jsonb 타입 유지
jsonb 타입을 그대로 사용할 수 있어 PostgreSQL의 JSON 기능(인덱싱, 쿼리 등)을 최대한 활용할 수 있습니다.
2. 간단한 코드
수동 JSON 직렬화/역직렬화 코드가 필요 없어 코드가 간결해집니다.
3. 타입 안정성
Entity에서 Map<String, Any>? 타입을 직접 사용하여 컴파일 타임에 타입 체크가 가능합니다.
4. Hibernate 6 완벽 지원
hypersistence-utils-hibernate-63는 Hibernate 6을 완벽하게 지원하며, 최신 기능을 활용할 수 있습니다.
주의사항
-
Hibernate 버전 호환성:
hypersistence-utils-hibernate-63는 Hibernate 6.3을 대상으로 합니다. 다른 버전의 Hibernate를 사용한다면 해당 버전에 맞는 라이브러리를 사용해야 합니다. -
Jackson 의존성:
JsonType은 내부적으로 Jackson을 사용하므로, 프로젝트에 Jackson 의존성이 필요합니다. Spring Boot를 사용한다면 기본적으로 포함되어 있습니다.
정리
PostgreSQL의 jsonb 타입을 Hibernate에서 처리하는 것은 생각보다 까다로운 작업입니다. hypersistence-utils 라이브러리를 사용하면 간단하고 안전하게 jsonb 컬럼을 Map<String, Any>? 타입으로 매핑할 수 있습니다.
이 방법을 통해:
- ✅ PostgreSQL의
jsonb타입을 그대로 활용 - ✅ 타입 안정성 유지
- ✅ 간결한 코드 작성
- ✅ 자동 JSON 변환
모든 요구사항을 만족하는 해결책을 찾을 수 있었습니다.