콘텐츠로 이동

Bluetape4k Exposed Part 4: JSON, 암호화, 다이얼렉트

실험실 형태의 작업대에서 작은 로봇 작업자들이 데이터 모듈, 계측 패널, 분석 장치를 다루는 3차원 디오라마
컬럼 하나에도 직렬화, 암호화, 다이얼렉트 처리가 더해지면 독립된 설계 경계가 필요합니다.

Part 4는 저장소보다 낮은 계층을 다룹니다. 실제 서비스를 개발하면 테이블 정의와 함께 해결해야 할 문제가 계속 생깁니다. JSON 컬럼을 문자열로 다룰지, 암호화된 값을 검색할 수 있어야 하는지, 좌표와 벡터 같은 데이터베이스별 타입을 서비스 코드에서 직접 다룰지 결정해야 합니다.

JSON 컬럼, 암호화 컬럼, 측정 단위 컬럼, 데이터베이스별 공간·벡터 함수를 분리한 이유는 관련 처리가 서비스 코드에 분산되는 것을 막기 위해서입니다. 이러한 기능은 테이블 정의 가까이에 있어야 의도가 드러나지만, 매번 직접 구현하면 일관성을 유지하기 어렵습니다.

도메인 모델에서 JSON 코덱, 암호화 컬럼, 측정 단위 컬럼, PostgreSQL, MySQL 8, 분석용 다이얼렉트를 거쳐 SQL DSL로 이어지는 구조
반복되는 직렬화, 암호화, 다이얼렉트 세부 사항은 컬럼 타입과 확장 함수에 모읍니다.

JSON 컬럼: 문자열이 아니라 타입으로

섹션 제목: “JSON 컬럼: 문자열이 아니라 타입으로”

JSON 컬럼을 제공하는 목적은 JSON 사용을 장려하는 데 있지 않습니다. JSON이나 JSONB를 사용해야 할 때 문자열이 서비스 코드까지 노출되지 않도록 경계를 정하는 데 있습니다. exposed-jackson2, exposed-jackson3, exposed-fastjson2는 JSON과 JSONB 컬럼을 Kotlin 타입으로 다루게 합니다.

data class UserSettings(
val theme: String = "light",
val notifications: Boolean = true,
val language: String = "ko",
)
object Users : IdTable<Long>("users") {
val name = varchar("name", 100)
val settings = jackson<UserSettings>("settings")
}

JSON을 String으로 다루면 처음에는 간단해 보입니다. 그러나 검증, 마이그레이션, 경로 쿼리, 직렬화 설정이 서비스마다 분산되기 쉽습니다. 컬럼 타입으로 정의하면 테이블 선언만으로도 데이터의 의도가 드러납니다.

val theme = Users.settings.jsonPath<String>("$.theme")
val query = Users
.selectAll()
.where { Users.settings.jsonContains("theme", "dark") }

이러한 확장 함수가 모든 문제를 해결하지는 않습니다. JSON 스키마의 변경 관리는 여전히 별도로 설계해야 합니다. 다만 직렬화, 경로 쿼리 생성, 조회 결과의 타입 복원 책임을 한곳에 모을 수 있습니다.

민감 정보의 암호화는 후속 작업으로 미루기 쉽습니다. 그러나 운영 데이터가 쌓인 뒤 암호화를 도입하면 단순한 스키마 마이그레이션을 넘어 기존 데이터를 안전하게 이전해야 합니다.

exposed-tink는 이러한 경계를 Google Tink 기반 AEAD와 Deterministic AEAD 컬럼으로 제공합니다.

val persistedAead = TinkAead(keysetHandleOf(loadSecret("users-aead-keyset-json")))
val persistedDaead = TinkDeterministicAead(keysetHandleOf(loadSecret("users-daead-keyset-json")))
object Users : IntIdTable("users") {
val name = varchar("name", 100)
// 검색 불필요: 매번 다른 암호문
val memo = tinkAeadVarChar("memo", 512, persistedAead).nullable()
// 검색 필요: 결정적 암호문, 인덱스 사용 가능
val email = tinkDaeadVarChar("email", 512, persistedDaead).index()
}

먼저 요구사항에 따라 AEAD와 DAEAD 중 어떤 방식을 사용할지 정해야 합니다.

모드장점제한
AEAD동일 평문도 매번 다른 암호문, 패턴 노출 감소WHERE col = value 검색 불가
DAEAD동일 평문은 동일 암호문, 동등 조건 검색과 컬럼 타입이 지원하는 인덱스 사용 가능결정적이므로 패턴 분석 위험이 남음

검색할 필요가 없는 메모, 비고, 토큰 페이로드에는 AEAD가 적합합니다. 이메일이나 주민등록번호처럼 동등 조건 검색이 필요한 값에는 DAEAD를 검토합니다. 암호화 방식을 정할 때는 데이터의 민감도뿐 아니라 필요한 쿼리도 함께 고려해야 합니다.

코드에서는 두 방식의 차이가 더 분명합니다. DAEAD 컬럼은 결정적 암호화를 사용하므로 같은 평문에서 같은 암호문이 생성되고, 동등 조건 검색과 인덱스를 사용할 수 있습니다.

transaction {
Users.insert {
it[name] = "홍길동"
it[memo] = "VIP 고객" // AEAD: 검색하지 않는 민감 정보
it[email] = "hong@example.com" // DAEAD: 검색해야 하는 식별자
}
val byEmail = Users
.selectAll()
.where { Users.email eq "hong@example.com" }
.singleOrNull()
}

반대로 AEAD 컬럼은 동일한 평문에서도 매번 다른 암호문을 생성합니다. 따라서 다음 쿼리로는 의도한 결과를 얻을 수 없습니다.

transaction {
// memo는 tinkAeadVarChar로 선언된 비결정적 암호화 컬럼입니다.
// 새 nonce로 다시 암호화되므로 동등 조건 검색 대상으로 쓰면 안 됩니다.
val notReliable = Users
.selectAll()
.where { Users.memo eq "VIP 고객" }
.toList()
}

측정 단위 컬럼도 같은 원칙에서 출발합니다. 금액, 길이, 무게, 시간은 숫자만으로 의미를 확정할 수 없습니다. 10이 10원인지 10달러인지, 10m인지 10cm인지 코드 리뷰 때마다 추론해서는 안 됩니다.

측정 단위 컬럼은 값과 단위를 함께 다루게 합니다. 데이터베이스에는 기준 단위로 변환한 숫자를 저장하지만, 도메인 코드에서는 Measure<T>, Temperature, TemperatureDelta 같은 타입으로 복원하므로 단위 해석을 컬럼 경계에 고정할 수 있습니다.

PostgreSQL: PostGIS, pgvector, 범위 타입

섹션 제목: “PostgreSQL: PostGIS, pgvector, 범위 타입”

PostgreSQL은 다양한 기능을 제공하지만, 구현 세부 사항이 서비스 코드에 노출되기 쉽습니다. PostGIS, pgvector, 범위 타입을 사용할 때마다 SQL 리터럴과 JDBC 타입 처리를 직접 구현하면 쿼리의 의도보다 연동 코드가 더 많이 드러납니다. exposed-postgresql은 PostgreSQL 전용 기능을 작은 확장으로 캡슐화합니다.

기능
PostGISgeoPoint, geoPolygon, stDistance, stWithin, stContains
pgvectorvector("embedding", 384), 코사인 거리 검색
범위 타입시간 범위 컬럼과 쿼리 확장 함수
object DocumentTable : Table("documents") {
val id = integer("id").primaryKey()
val title = varchar("title", 200)
val embedding = vector("embedding", 384)
}

PostGIS도 테이블 정의 옆에서 바로 읽히게 둘 수 있습니다.

object LocationTable : Table("locations") {
val id = integer("id").primaryKey()
val name = varchar("name", 100)
val point = geoPoint("point")
val area = geoPolygon("area")
}
transaction {
val nearby = LocationTable
.select(LocationTable.name)
.where { LocationTable.point.stDWithin(searchPoint, 0.5) }
.toList()
val inside = LocationTable
.select(LocationTable.name)
.where { LocationTable.point.stWithin(polygonArea) }
.toList()
}

SQL 리터럴과 JDBC 타입 등록 같은 세부 사항이 서비스 코드에 들어오면 데이터베이스 연동 책임과 비즈니스 로직의 경계가 흐려집니다. 다이얼렉트 모듈은 이러한 처리를 쿼리를 작성하는 코드보다 낮은 계층에 격리합니다.

MySQL 8에서 공간 데이터를 일관되게 처리하려면 관련 규칙을 한곳에 모아야 합니다. exposed-mysql8은 MySQL 8.0 이상의 공간 컬럼과 공간 조건식을 JTS Geometry 타입으로 다룹니다. 기본 좌표계는 WGS84(SRID 4326)입니다.

object Locations : LongIdTable("locations") {
val name = varchar("name", 255)
val point = geoPoint("point")
val area = geoPolygon("area")
}
Locations
.selectAll()
.where { Locations.area.stContains(Locations.point) }
.toList()

좌표 순서처럼 사소해 보이는 규칙도 오류의 원인이 됩니다. wgs84Point(lng, lat) 같은 확장 함수는 경도와 위도의 순서를 명시해 이러한 오류를 방지합니다.

분석용 데이터베이스 다이얼렉트

섹션 제목: “분석용 데이터베이스 다이얼렉트”

BigQuery, ClickHouse, Trino, DuckDB 같은 분석용 데이터베이스는 OLTP 데이터베이스와 실행 방식이 다릅니다. 커넥터, 타입, 리터럴, 페이징, 함수의 차이가 서비스 코드에 노출되면 애플리케이션 로직과 분석 SQL 연동 코드가 섞입니다.

bluetape4k-exposed의 다이얼렉트 모듈은 데이터베이스별 확장을 사용하는 코드 가까이에 두면서도, 반복되는 처리는 모듈 내부에 캡슐화합니다.

확장코드에서 볼 것
exposed-bigqueryBigQuery REST API 실행, pageToken 기반 Flow 조회
exposed-clickhouseClickHouse 커넥터와 타입 특성
exposed-trino연합 쿼리 경계
exposed-duckdb임베디드 분석과 로컬 테스트
exposed-timefold-solver-persistence솔버 영속화 통합

BigQuery 확장은 JDBC 트랜잭션 의미론을 제공하지 않습니다. Exposed DSL을 SQL 생성기로 재사용하고, 실제 실행은 BigQuery REST API가 담당합니다.

val context = BigQueryContext.create(
bigquery = bigqueryClient,
projectId = "my-project",
datasetId = "my-dataset",
)
with(context) {
val rows = Events
.selectAll()
.where { Events.region eq "kr" }
.withBigQuery()
.toList()
Events.selectAll()
.withBigQuery()
.toFlow()
.collect { row ->
println(row[Events.region])
}
}

Part 4의 결론은 데이터베이스의 고급 기능을 피하자는 것이 아닙니다. 필요한 기능을 사용하되, 구현 세부 사항을 서비스 코드에 분산하지 말고 테이블 정의와 쿼리 확장 함수 근처에 모아야 합니다.

  • JSON은 문자열이 아니라 도메인 타입으로 다룹니다.
  • 암호화 방식은 검색 필요 여부에 따라 AEAD와 DAEAD로 구분합니다.
  • 측정값은 단위 없는 숫자로 노출하지 않습니다.
  • PostgreSQL, MySQL, 분석용 데이터베이스별 기능은 다이얼렉트 모듈로 격리합니다.

다음 글에서는 이러한 기능을 Spring Boot, 캐시, 멀티테넌시 운영 예제와 결합합니다. 타입 안정성만으로는 운영 경계를 검증할 수 없으므로 테넌트 간 데이터 누출 테스트와 성능 차트도 함께 살펴봅니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.