티스토리 뷰
지난 글에 이어, 이번에는 데이터베이스를 연동해 볼 것이다.
Ktor는 스프링과 다르게 데이터베이스 커넥션풀을 기본적으로 제공하지 않으므로, HikariCP를 수동으로 설정해야 한다.
또한 JPA를 사용할 수 없으므로 같은 회사에서 제작한 코틀린 전용 ORM인 Exposed를 사용한다.
소스코드
https://github.com/mopil/ktor-server
# build.gradle.kts
MySQL (8.0.33)
implementation("mysql:mysql-connector-java:$mysqlVersion")
Exposed (0.41.1)
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
Exposed에는 두 가지 스타일을 지원하는데, QueryDSL처럼 쿼리 빌더느낌으로 사용가능한 DSL 스타일과 JPA 엔티티처럼 사용가능한 DAO 스타일 두 가지를 지원한다.
스프링 개발때와 마찬가지로 베이스로는 DAO스타일로 엔티티를 작성하고, 복잡한 동적쿼리 부분은 DSL 스타일로 작성하면 좋을 것 같다.
Exposed는 Spring Data JPA와는 다르게 기본 CRUD 리포지토리 구현체를 제공해주지 않아 전부 다 직접 구현해야 하는 귀찮음이 있지만... QueryDSL + JPA를 하나의 ORM으로 사용가능한 점은 좀 편한 것 같기도 하다. (빨리 정식 버전이 나왔으면 좋겠다. 추가로 공식 홈페이지가 없는 것도 좀 허접해 보이니까 빨리 홈페이지나 나왔으면...)
사용법은 깃허브 위키에 정리되어 있으니 참고하면 되겠다.
https://github.com/JetBrains/Exposed/wiki
HikariCP
implementation("com.zaxxer:HikariCP:$hikariCpVersion")
HikariCP는 데이터베이스와의 연결(커넥션)을 미리 생성해 두고 사용하는 일종의 스레드 풀 같은 역할을 해주는 라이브러리다.
요청이 들어올 때마다 디비와 연결을 맺고 끊고 하면 시간도 오래 걸리고 비효율적이기 때문
여담으로 스프링부트는 이 HikariCP를 기본적으로 내장하고 있다.
# DatabaseConfig
common > config 디렉터리에 위치해 있다. 전체코드는 다음과 같다.
fun Application.configureDatabase() {
connectDatabase()
if (isResetTables()) dropAndCreateTables()
if (isSetDummyData()) setDummyData()
}
object DatabaseUtils {
private val tables = arrayOf(Users, Orders, Products)
private val log = logger()
fun connectDatabase() {
val hikari = HikariDataSource(
HikariConfig().apply {
jdbcUrl = getDataSource("url")
username = getDataSource("username")
password = getDataSource("password")
driverClassName = getDataSource("driver-class-name")
maximumPoolSize = getDataSource("max-pool-size").toInt()
isAutoCommit = false
transactionIsolation = "TRANSACTION_REPEATABLE_READ"
validate()
}
)
Database.connect(hikari)
log.info("Database is successfully connected.")
}
fun dropAndCreateTables() = transaction {
SchemaUtils.drop(*tables)
SchemaUtils.create(*tables)
log.info("${tables.size} tables are successfully dropped and created.")
}
suspend fun <T> dbQuery(
block: () -> T
): T = withContext(Dispatchers.IO) {
transaction {
if (isDevEnv()) addLogger(StdOutSqlLogger)
block()
}
}
}
내용이 좀 길어 보이지만 천천히 살펴보자.
우선 conf 파일의 데이터베이스 부분을 살펴보자
database {
datasource {
driver-class-name = "com.mysql.cj.jdbc.Driver"
url = "jdbc:mysql://localhost:3306/test"
username = "root"
password: "1102"
max-pool-size = 10
}
reset-on-boot = true
set-dummy-data = true
}
datasource는 디비연동을 위한 기본정보들이고 밑의 reset-on-boot, set-dummy-data는 커스텀으로 만든 (ddl-auto와 비슷) 것이다.
다시 본 소스코드로 돌아오면,
fun Application.configureDatabase() {
connectDatabase()
if (isResetTables()) dropAndCreateTables()
if (isSetDummyData()) setDummyData()
}
Application.kt의 module 확장함수에 적용할 데이터베이스 설정용 확장함수다.
주요 역할은 디비를 연결하는 것이고, conf 설정파일에 따라서 테이블을 초기화할 것인지, 더미데이터를 적재할 것인지도 설정 가능하게끔 구현해 봤다.
fun connectDatabase() {
val hikari = HikariDataSource(
HikariConfig().apply {
jdbcUrl = getDataSource("url")
username = getDataSource("username")
password = getDataSource("password")
driverClassName = getDataSource("driver-class-name")
maximumPoolSize = getDataSource("max-pool-size").toInt()
isAutoCommit = false
transactionIsolation = "TRANSACTION_REPEATABLE_READ"
validate()
}
)
Database.connect(hikari)
log.info("Database is successfully connected.")
}
HikariCP를 활용하여 데이터베이스 기본정보를 읽어와 연결을 진행하는 함수다.
getDataSource() 함수는 application.conf에서 해당 값들을 애플리케이션 레벨로 읽어서 가져온다.
object ApplicationConfigUtils {
private fun getConfigProperty(path: String) =
HoconApplicationConfig(ConfigFactory.load()).property(path).getString()
fun getDataSource(key: String) = getConfigProperty("database.datasource.$key")
fun isDevEnv() = getConfigProperty("environment") == "dev"
fun isResetTables() = getConfigProperty("database.reset-on-boot") == "true"
fun isSetDummyData() = getConfigProperty("database.set-dummy-data") == "true"
}
fun dropAndCreateTables() = transaction {
SchemaUtils.drop(*tables)
SchemaUtils.create(*tables)
log.info("${tables.size} tables are successfully dropped and created.")
}
개발환경에서 Ktor를 부트 시킬 때마다 테이블을 초기화해 주는 메서드이다.
위에서 isResetTables() 함수를 통해서 application.conf의 설정 값 중, reset-on-boot가 true일 때만 동작하도록 구현했다.
suspend fun <T> dbQuery(
block: () -> T
): T = withContext(Dispatchers.IO) {
transaction {
if (isDevEnv()) addLogger(StdOutSqlLogger)
block()
}
}
dbQuery는 고차함수로, Exposed에서는 트랜잭션을 걸 때, transaction이라는 고차함수를 사용해야 한다.
여기에 공통 로직을 추가하기 위해서 한번 wrapping 해줬다. isDevEnv()를 통해서 개발환경이면 sql로그가 남기도록 했고
코투린 스코프를 사용한다.
사용법은 대강 이런 식으로 사용한다.
override suspend fun save(request: CreateProductRequest) = dbQuery {
Product.new {
this.name = request.name
this.description = request.description
this.price = request.price
}
}
이렇게 해서 데이터베이스 연동을 모두 마쳤다. Jackson과 예외처리는 소스코드만 봐도 이해가 쉬울 것이니 따로 설명하진 않을 예정이다.
다음 글에서는 Exposed를 활용해서 BaseEntity와 DAO스타일 엔티티, DSL스타일 쿼리 빌더를 사용하여 간단한 CRUD와 검색 쿼리를 작성할 것이다.