본문 바로가기

카테고리 없음

[DB] Exposed 사용해보기

Exposed란?

Kotlin용 ORM 프레임워크이자 JDBC 드라이버를 사용하는 경량 SQL 라이브러리. Kotlin DSL을 활용하여 SQL을 보다 직관적으로 작성할 수 있으며, DAO 방식도 지원하여 객체지향적인 데이터 접근이 가능합니다.

지원 데이터베이스

  • MariaDB
  • MySQL
  • Oracle
  • Postgres
  • Microsoft SQL
  • SQLITE
  • H2 (versions 2.x; 1.x version은 deprecated 예정)
  • (Also, PostgreSQL using the pgjdbc-ng JDBC driver)

Exposed 모듈

  • exposed-core - 기본 모듈로, DSL API와 매핑 기능을 포함
  • exposed-crypt - 암호화된 데이터를 데이터베이스에 저장할 수 있도록 추가 컬럼 타입을 제공하며, 클라이언트 측에서 인코딩/디코딩 지원
  • exposed-dao - DAO API 제공
  • exposed-java-time - Java 8 Time API 기반의 날짜 및 시간 확장 기능 제공
  • exposed-jdbc - Java JDBC API 기반의 전송 계층 구현
  • exposed-jodatime - JodaTime 라이브러리를 기반으로 한 날짜 및 시간 확장 기능 제공
  • exposed-json - JSON 및 JSONB 데이터 타입 확장 기능 제공
  • exposed-kotlin-datetime - kotlinx-datetime 기반의 날짜 및 시간 확장 기능 제공
  • exposed-money - "javax.money:money-api"의 MonetaryAmount를 지원하는 확장 기능 제공
  • exposed-spring-boot-starter - Hibernate 대신 Exposed를 ORM으로 활용할 수 있도록 지원하는 Spring Boot 스타터

지원 데이터베이스 접근 방식

  • DSL 방식
  • DAO 방식

Exposed 사용 예시

DSL & DAO 사용 권장 케이스

DSL 방식

  • 복잡한 조인이나 집계가 필요한 경우
  • 성능이 중요한 쿼리
  • 동적 쿼리가 필요한 경우
  • 데이터 분석이나 리포트 생성

DAO 방식

  • 단순한 CRUD 작업
  • 객체 중심의 비즈니스 로직
  • 작은 규모의 데이터 처리
  • 타입 안전성이 중요한 경우

DSL & DAO 방식 예시

예시 데이터

object Users : Table() {
    val id: Column<String> = varchar("id", 10)
    val name: Column<String> = varchar("name", length = 50)
    val cityId: Column<Int?> = (integer("city_id") references Cities.id).nullable()

    override val primaryKey = PrimaryKey(id, name = "PK_User_ID") // name is optional here
}

object Cities : Table() {
    val id: Column<Int> = integer("id").autoIncrement()
    val name: Column<String> = varchar("name", 50)

    override val primaryKey = PrimaryKey(id, name = "PK_Cities_ID")
}

// DAO 방식을 사용하기 위한 Entity
class City(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<City>(Cities)

    var name by Cities.name
}

object Users : StringIdTable() {
    val name = varchar("name", 50)
    val city = reference("city_id", Cities).nullable()
}

 

단순 조회

transaction {
    // DSL
    Users.selectAll().map {
        it[Users.id] to it[Users.name]
    }.forEach { (id, name) ->
        println("ID: $id, Name: $name")
    }
    
    // DAO
    User.all().forEach {
        println("ID: ${it.id}, Name: ${it.name}, City: ${it.city?.name}")
    }
}

 

Where를 사용한 조회

transaction {
    // DSL
    Users.select { Users.name eq "Alice" }
        .map { it[Users.id] to it[Users.name] }
        .forEach { (id, name) -> println("ID: $id, Name: $name") }
    
    // DAO
    User.find { Users.name eq "Alice" }.forEach {
        println("ID: ${it.id}, Name: ${it.name}, City: ${it.city?.name}")
    }
}

 

In절을 사용한 조회

transaction {
    // DSL
    Users.select { Users.id inList listOf("user1", "user2", "user3") }
        .map { it[Users.id] to it[Users.name] }
        .forEach { (id, name) -> println("ID: $id, Name: $name") }
        
    // DAO
    User.find { Users.id inList listOf("user1", "user2", "user3") }.forEach {
        println("ID: ${it.id}, Name: ${it.name}, City: ${it.city?.name}")
    }
}

 

Join & Group By를 사용한 조회

transaction {
    // DSL
    (Users innerJoin Cities)
        .slice(Cities.name, Users.id.count())
        .selectAll()
        .groupBy(Cities.name)
        .map { it[Cities.name] to it[Users.id.count()] }
        .forEach { (cityName, count) -> println("City: $cityName, User Count: $count") }
        
    // DAO
    // GROUP BY 같이 복잡한 쿼리는 DSL 방식 이용 추천
}

 

Left Join을 사용한 조회

transaction {
    // DSL
    (Users leftJoin Cities)
        .selectAll()
        .map { it[Users.id] to it[Users.name] to it[Cities.name] }
        .forEach { (user, city) -> println("User: ${user.first}, Name: ${user.second}, City: $city") }
        
    // DAO
    User.all().forEach {
        val cityName = it.city?.name ?: "No city"
        println("User: ${it.id}, Name: ${it.name}, City: $cityName")
    }
}

 

페이징 조회

transaction {
    // DSL
    Users.selectAll()
        .limit(10, offset = 20)
        .map { it[Users.id] to it[Users.name] }
        .forEach { (id, name) -> println("ID: $id, Name: $name") }
        
    // DAO
    User.all().limit(10, offset = 20).forEach {
        println("ID: ${it.id}, Name: ${it.name}, City: ${it.city?.name}")
    }
}

 

데이터 삽입

transaction {
    // DSL
    Users.insert {
        it[id] = "user123"
        it[name] = "John Doe"
        it[cityId] = 1
    }
    
    // DAO
    val city = City.new {
        name = "Seoul"
    }

    User.new("user123") {
        name = "John Doe"
        this.city = city
    }
}

 

데이터 변경

transaction {
    // DSL
    Users.update({ Users.id eq "user123" }) {
        it[name] = "John Updated"
        it[cityId] = 2
    }
    
    // DAO
    val user = User.findById("user123")
    user?.apply {
        name = "John Updated"
        city = City.findById(2)
    }
}

 

데이터 삭제

transaction {
    // DSL
    Users.deleteWhere { Users.id eq "user123" }
    
    // DAO
    User.findById("user123")?.delete()
}

사용시 참고할 점

  • 트랜잭션(transaction)이 필수
    • Exposed는 모든 데이터베이스 작업이 트랜잭션 내에서 실행되도록 설계됨
    • 불필요한 트랜잭션을 줄이려면 readOnly = true 옵션 사용 가능
    •  
    • transaction(readOnly = true) { Users.selectAll().forEach { println(it[Users.name]) } }
  • 여러 데이터베이스 동시 사용
    • Database.connect()를 각각 설정하여 동시에 여러 DB 연결 가능
    • transaction을 중첩하여 사용
  • 스키마 지정 가능
    • 테이블 선언 시 Table("schema.table_name")으로 스키마 지정 가능

참조

Exposed Github -https://github.com/JetBrains/Exposed?tab=readme-ov-file

Exposed Document - https://www.jetbrains.com/help/exposed/home.html

커뮤니티 등등 - https://www.reddit.com/r/Kotlin/comments/1byw4rc/help_me_understand_exposeds_design_choices/?rdt=51834

반응형