Exposed๋ JetBrains์์ ๋ง๋ Kotlin ์ธ์ด ๊ธฐ๋ฐ์ ORM ํ๋ ์์ํฌ์ ๋๋ค. Exposed๋ ๋ ๊ฐ์ง ๋ ๋ฒจ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค access๋ฅผ ์ ๊ณตํฉ๋๋ค. SQL์ ๋งคํ ํ DSL ๋ฐฉ์, ๊ฒฝ๋ํํ DAO ๋ฐฉ์์ ์ ๊ณตํ๋ฉฐ ๊ณต์์ ์ผ๋ก H2, MySQL, MariaDB, Oracle, PostgreSQL, SQL Server, SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ฅผ ์ง์ํฉ๋๋ค.
์ ๋ ๊ฐ์ธ์ , ํ์ฌ ์ ๋ฌด์์ Spring Data JPA๋ฅผ ์ฃผ๋ก ์ฌ์ฉํ๊ณ ์์ต๋๋ค. JPA๊ฐ ๊ฐ์ ธ๋ค์ฃผ๋ ํฐ ์ฅ์ ์ด ๋ง์ ์ ๊ทน์ ์ผ๋ก ์ฌ์ฉํ๊ณ ์์ง๋ง ํน์ ์ํฉ์์ ํ์ด๋ฒ๋ค์ดํธ์ ๋จ์ ์ด ์์ด ์ด๋ฅผ ๋ณด์ํ๊ธฐ ์ํด์ Exposed๋ฅผ ์ฌ์ฉํ๊ณ ์์ต๋๋ค. ํด๋น ๋ด์ฉ์ Batch Insert ์ฑ๋ฅ ํฅ์๊ธฐ 1ํธ - With JPA, Batch Insert ์ฑ๋ฅ ํฅ์๊ธฐ 2ํธ - ์ฑ๋ฅ ์ธก์ ์์ ํฌ์คํ ํ ๋ฐ ์์ต๋๋ค.
version: '3'
services:
db_mysql:
container_name: mysql.local
image: mysql/mysql-server:5.7
environment:
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: 'exposed_study'
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
ports:
- '3366:3306'
volumes:
- './volumes/mysql/default:/var/lib/mysql'
command:
- 'mysqld'
- '--character-set-server=utf8mb4'
- '--collation-server=utf8mb4_unicode_ci'
- '--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION'
$ docker-compose up -d
Exposed์์ ์ง์ํด ์ฃผ๋ MySQL ๊ธฐ๋ฐ์ผ๋ก ์งํํ๊ธฐ ์ํด์ ํด๋น ํ๊ฒฝ์ Docker๋ก ๊ตฌ์ฑํฉ๋๋ค.
dependencies {
implementation("mysql:mysql-connector-java")
implementation("org.jetbrains.exposed:exposed-core:0.31.1")
implementation("org.jetbrains.exposed:exposed-dao:0.31.1")
implementation("org.jetbrains.exposed:exposed-jdbc:0.31.1")
implementation("org.jetbrains.exposed:exposed-java-time:0.31.1")
}
ํ์ํ ์์กด์ฑ์ ์ถ๊ฐํฉ๋๋ค.
class ExposedGettingStarted {
private val config = HikariConfig().apply {
jdbcUrl = "jdbc:mysql://localhost:3366/exposed_study?useSSL=false&serverTimezone=UTC&autoReconnect=true&rewriteBatchedStatements=true"
driverClassName = "com.mysql.cj.jdbc.Driver"
username = "root"
password = ""
}
private val dataSource = HikariDataSource(config)
...
}
HikariConfig
์ ์์์ ์์ฑํ MySQL ์ ๋ณด๋ฅผ ์
๋ ฅํ์ฌ DataSource
๋ฅผ ์์ฑํฉ๋๋ค.
object Payments : LongIdTable(name = "payment") {
val orderId = long("order_id")
val amount = decimal("amount", 19, 4)
}
class ExposedGettingStarted {
@Test
fun `exposed DSL`() {
// connection to MySQL
Database.connect(dataSource)
transaction {
// Show SQL logging
addLogger(StdOutSqlLogger)
// CREATE TABLE IF NOT EXISTS payment (id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, amount DECIMAL(19, 4) NOT NULL)
SchemaUtils.create(Payments)
// INSERT INTO payment (amount, order_id) VALUES (1, 1)
// ...
(1..5).map {
Payments.insert { payments ->
payments[amount] = it.toBigDecimal()
payments[orderId] = it.toLong()
}
}
// UPDATE payment SET amount=0 WHERE payment.amount >= 0
Payments.update({ amount greaterEq BigDecimal.ZERO })
{
it[amount] = BigDecimal.ZERO
}
// SELECT payment.id, payment.order_id, payment.amount FROM payment WHERE payment.amount = 0
// Payment(amount=1.0000, orderId=1)
Payments.select { amount eq BigDecimal.ZERO }
.forEach { println(it) }
// DELETE FROM payment WHERE payment.amount >= 1
Payments.deleteWhere { amount greaterEq BigDecimal.ONE }
// DROP TABLE IF EXISTS payment
SchemaUtils.drop(Payments)
}
}
}
Payments
๊ฐ์ฒด์ ํ
์ด๋ธ ์ ๋ณด๋ฅผ ์์ฑ ํ์ฌ ํ
์ด๋ธ์ ์์ฑํ์ฌ Insert, Select, Update, Delete๋ฅผ ํ๊ณ ํ
์ด๋ธ์ Drop ํฉ๋๋ค. SQL๋ฅผ ๋งคํํ DSL ๋ฐฉ์์ผ๋ก ์ฝํ๋ฆฐ ์ฝ๋ ๋ฒ ์ด์ค๋ก SQL์ ์กฐ์ํ ์ ์์ต๋๋ค.
class Payment(id: EntityID<Long>) : LongEntity(id) {
companion object : LongEntityClass<Payment>(Payments)
var amount by Payments.amount
var orderId by Payments.orderId
}
class ExposedGettingStarted {
@Test
fun `exposed DAO`() {
// connection to MySQL
Database.connect(dataSource)
transaction {
// Show SQL logging
addLogger(StdOutSqlLogger)
// CREATE TABLE IF NOT EXISTS payment (id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, amount DECIMAL(19, 4) NOT NULL)
SchemaUtils.create(Payments)
// INSERT INTO payment (amount, order_id) VALUES (1, 1)
// ...
(1..20).map {
Payment.new {
amount = it.toBigDecimal()
orderId = it.toLong()
}
}
// UPDATE payment SET amount=0 WHERE id = 1
// ...
Payment.all()
.forEach { it.amount = BigDecimal.ZERO }
// SELECT payment.id, payment.order_id, payment.amount FROM payment WHERE payment.amount >= 1
// Payment(amount=1.0000, orderId=1)
Payment.find { amount eq BigDecimal.ONE }
.forEach { println(it) }
// DELETE FROM payment WHERE payment.id = 1
// ...
Payment.all()
.forEach { it.delete() }
// DROP TABLE IF EXISTS payment
SchemaUtils.drop(Payments)
}
}
}
DSL ๊ฐ์ฒด์ธ Payments
๊ธฐ๋ฐ์ผ๋ก DAO Payment
๋ฅผ ๊ฐ์ฒด๋ฅผ ์์ฑํฉ๋๋ค. DAO๋ payment
ํ
์ด๋ธ์ ๋ํ Data Access Object์ ๊ธฐ๋ฅ์ ์ ๋ดํ๊ฒ ๋ฉ๋๋ค. Data JPA์ Repository์ ๋น์ทํ ๊ฐ๋
์
๋๋ค.
Exposed์์๋ Spring Boot๋ฅผ ์ง์ํ๋ exposed-spring-boot-starter ์์กด์ฑ์ ์ ๊ณตํ๊ณ ์์ต๋๋ค.
implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.31.1")
์คํ๋ง ๋ถํธ์์ ๊ณต์์ ์ผ๋ก ์ง์ํ๋ ์์กด์ฑ์ ์๋๊ธฐ ๋๋ฌธ์ ๋ฒ์ ์ ๋ช ํํ๊ฒ ๋ช ์ํด์ผ ํฉ๋๋ค.
spring:
datasource:
url: jdbc:mysql://localhost:3366/exposed_study?useSSL=false&serverTimezone=UTC&autoReconnect=true&rewriteBatchedStatements=true&logger=Slf4JLogger&profileSQL=false&maxQuerySizeToLog=100000
username: root
password:
driver-class-name: com.mysql.cj.jdbc.Driver
exposed:
generate-ddl: true
# excluded-packages: com.example.exposedstudy
logging.level.Exposed: debug
datasource
์ ๋ํ ์ค์ ์ ์คํ๋ง ๋ถํธ์์ ์ฌ์ฉํ๋ ๋ฐฉ์ ๊ทธ๋๋ก ์ฌ์ฉํ ์ ์๊ณ generate-ddl
์ค์ ์ด ํ์ฑํ๋์ด ์๋ ๊ฒฝ์ฐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์คํค๋ง๋ฅผ ์์ฑํ๊ณ , ํน์ ์คํค๋ง๋ฅผ ์ ์ธํ๊ณ ์ถ์ ๊ฒฝ์ฐ excluded-packages
์ค์ ์ผ๋ก ์ ์ธํ ์ ์์ต๋๋ค. ์ค์ ์ฝ๋๋ฅผ ๋ณด๋ฉด ๋งค์ฐ ๋จ์ํฉ๋๋ค. logging.level.Exposed: debug
๊ฒฝ์ฐ ๋ณ๋์ ์ค์ ์์ด Show SQL Log๋ฅผ ๋ณผ ์ ์์ต๋๋ค.
@Configuration
@AutoConfigureAfter(DataSourceAutoConfiguration::class)
@EnableTransactionManagement
open class ExposedAutoConfiguration(private val applicationContext: ApplicationContext) {
@Value("\${spring.exposed.excluded-packages:}#{T(java.util.Collections).emptyList()}")
private lateinit var excludedPackages: List<String>
@Bean
open fun springTransactionManager(datasource: DataSource) = SpringTransactionManager(datasource)
@Bean
@ConditionalOnProperty("spring.exposed.generate-ddl", havingValue = "true", matchIfMissing = false)
open fun databaseInitializer() = DatabaseInitializer(applicationContext, excludedPackages)
}
open class DatabaseInitializer(private val applicationContext: ApplicationContext, private val excludedPackages: List<String>) : ApplicationRunner, Ordered {
override fun getOrder(): Int = DATABASE_INITIALIZER_ORDER
companion object {
const val DATABASE_INITIALIZER_ORDER = 0
}
private val logger = LoggerFactory.getLogger(javaClass)
@Transactional
override fun run(args: ApplicationArguments?) {
val exposedTables = discoverExposedTables(applicationContext, excludedPackages)
logger.info("Schema generation for tables '{}'", exposedTables.map { it.tableName })
logger.info("ddl {}", exposedTables.map { it.ddl }.joinToString())
SchemaUtils.create(*exposedTables.toTypedArray())
}
}
fun discoverExposedTables(applicationContext: ApplicationContext, excludedPackages: List<String>): List<Table> {
val provider = ClassPathScanningCandidateComponentProvider(false)
provider.addIncludeFilter(AssignableTypeFilter(Table::class.java))
excludedPackages.forEach { provider.addExcludeFilter(RegexPatternTypeFilter(Pattern.compile(it.replace(".", "\\.") + ".*"))) }
val packages = AutoConfigurationPackages.get(applicationContext)
val components = packages.map { provider.findCandidateComponents(it) }.flatten()
return components.map { Class.forName(it.beanClassName).kotlin.objectInstance as Table }
}
์คํ๋ง ํํ์์ผ๋ก ์ ์ธ ์ํฌ excludedPackages
๋ฅผ List๋ก ๋ฐ๊ณ , generate-ddl
์ฌ๋ถ์ ๋ฐ๋ผ DatabaseInitializer
๋น์ ๋ฑ๋ก์ฌ๋ถ๋ฅผ ๊ฒฐ์ ํฉ๋๋ค. ๋ง์ฝ ํด๋น ๋น์ ๋ฑ๋กํ๊ฒ ๋๋ฉด DatabaseInitializer
๊ฐ์ฒด๊ฐ ApplicationRunner
์ ๊ตฌํํ๊ณ ์๊ธฐ ๋๋ฌธ์ ์คํ๋ง ์ดํ๋ฆฌ์ผ์ด์
์ด ์คํํ๋ ๊ฒฝ์ฐ run()
๋ฉ์๋์์ ์คํค๋ง๋ฅผ ์์ฑํ๊ฒ ๋ฉ๋๋ค. (excludedPackages
๋ ์ ์ธ)
run()
๋ฉ์๋์ @Transactional
์ด๋
ธํ
์ด์
์ด ์๋ ๊ฒ์ ๋ณผ ์ ์์ต๋๋ค. ์ด๊ฒ์ spring-transaction
๋ชจ๋์ ํตํด์ TransactionSynchronizationManager
๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์คํ๋ง์ ํธ๋์ญ์
๋ฉ์ปค๋์ฆ์ ๊ทธ๋๋ก ์ฌ์ฉํ ์ ์๋ค๋ ์๋ฏธ ์
๋๋ค.
์คํ๋ง์ ํธ๋์ญ์ ๋๊ธฐํ ๋ฉ์ปค๋์ฆ ํ ๋น์ ์คํ๋ง 3.1, 361 ํ์ด์ง ์คํ๋ง์ ์์ ๊ฐ์ ๋ฐฉ์์ผ๋ก ํธ๋์ญ์ ๋๊ธฐํ๋ฅผ ์งํํฉ๋๋ค. ํด๋น ๋ฐฉ์์ ํธ๋์ญ์ ์ ์์ํ๊ธฐ ์ํด ๋ง๋ Connection ์ค๋ธ์ ํธ๋ฅผ ํน๋ณํ ์ ์ฅ์์ ๋ณด๊ดํด๋๊ณ , ์ดํ์ ํธ์ถ๋๋ ๋ฉ์๋์์ ์ ์ฅ๋ Connection์ ๊ฐ์ ธ๋ค๊ฐ ์ฌ์ฉํฉ๋๋ค.
@SpringBootTest
@Transactional
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@ActiveProfiles("test")
open class ExposedTestSupport
class ExposedGettingStartedInSpringBoot : ExposedTestSupport() {
@Test
fun `exposed DAO`() {
// connection to MySQL
// Database.connect(dataSource) ์คํ๋ง Bean์ DataSource๋ฅผ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์ ์ฃผ์
// transaction { ์คํ๋ง @Transactional ์ผ๋ก ํธ๋์ญ์
์ ์์ํ๊ธฐ ๋๋ฌธ์ ์ฃผ์
// Show SQL logging
// addLogger(StdOutSqlLogger) logging.level.Exposed: debug ์ผ๋ก Show SQL logging ํ์ธ
// CREATE TABLE IF NOT EXISTS payment (id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, amount DECIMAL(19, 4) NOT NULL)
// SchemaUtils.create(Payments) generate-ddl: true ์ผ๋ก ์คํค๋ง ์์ฑ
// INSERT INTO payment (amount, order_id) VALUES (1, 1)
// ...
(1..20).map {
Payment.new {
amount = it.toBigDecimal()
orderId = it.toLong()
}
}
// UPDATE payment SET amount=0 WHERE id = 1
// ...
Payment.all()
.forEach { it.amount = BigDecimal.ZERO }
// SELECT payment.id, payment.order_id, payment.amount FROM payment WHERE payment.amount >= 1
// Payment(amount=1.0000, orderId=1)
Payment.find { amount eq BigDecimal.ONE }
.forEach { println(it) }
// DELETE FROM payment WHERE payment.id = 1
// ...
Payment.all()
.forEach { it.delete() }
// DROP TABLE IF EXISTS payment
// SchemaUtils.drop(Payments)
// }
}
@Test
fun `exposed DSL`() {
// connection to MySQL
// Database.connect(dataSource) ์คํ๋ง Bean์ DataSource๋ฅผ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์ ์ฃผ์
// transaction { ์คํ๋ง @Transactional ์ผ๋ก ํธ๋์ญ์
์ ์์ํ๊ธฐ ๋๋ฌธ์ ์ฃผ์
// Show SQL logging
// addLogger(StdOutSqlLogger) logging.level.Exposed: debug ์ผ๋ก Show SQL logging ํ์ธ
// CREATE TABLE IF NOT EXISTS payment (id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, amount DECIMAL(19, 4) NOT NULL)
// SchemaUtils.create(Payments) generate-ddl: true ์ผ๋ก ์คํค๋ง ์์ฑ
// INSERT INTO payment (amount, order_id) VALUES (1, 1)
// ...
(1..5).map {
Payments.insert { payments ->
payments[amount] = it.toBigDecimal()
payments[orderId] = it.toLong()
}
}
// UPDATE payment SET amount=0 WHERE payment.amount >= 0
Payments.update({ amount greaterEq BigDecimal.ZERO })
{
it[amount] = BigDecimal.ZERO
}
// SELECT payment.id, payment.order_id, payment.amount FROM payment WHERE payment.amount = 0
// Payment(amount=1.0000, orderId=1)
Payments.select { amount eq BigDecimal.ZERO }
.forEach { println(it) }
// DELETE FROM payment WHERE payment.amount >= 1
Payments.deleteWhere { amount greaterEq BigDecimal.ONE }
// DROP TABLE IF EXISTS payment
// SchemaUtils.drop(Payments)
// }
}
}
DataSource
๋ ์คํ๋ง Bean์ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์ ์ ๊ฑฐํ์ผ๋ฉฐ, transaction { ... }
์ผ๋ก ํธ๋์ญ์
์ ์์ํ๋ ์ฝ๋๋ฅผ ์คํ๋ง์ @Transactional
์ผ๋ก ๋์ฒดํ์ต๋๋ค. ๋ํ SchemaUtils.create(Payments)
์ผ๋ก ์คํค๋ง๋ฅผ ์์ฑํ๋ ๋ถ๋ถ์ generate-ddl: true
์์ฑ ํ์ผ๋ก ๋์ฒดํ์ต๋๋ค. ๊ทธ๋ฆฌ๊ณ ExposedTestSupport
๊ฐ์ฒด์ @Transactional
๊ฐ ์์ด ํ
์คํธ ์ฝ๋์ ์ต์ข
๋ฐ์ดํฐ๋ ๋ชจ๋ Rollback์ ์งํํ๊ฒ ๋ฉ๋๋ค. Spring ํ๊ฒฝ์์ Exposed๋ฅผ ์ฌ์ฉํ๊ฒ ๋๋ฉด ๋ณด๋ค ๊ฐ๊ฒฐํ๊ฒ ์ฌ์ฉ ๊ฐ๋ฅํฉ๋๋ค.
@Test
fun `batch insert`() {
val data = (1..10).map { it }
Books.batchInsert(
data,
ignore = false,
shouldReturnGeneratedValues = false
) {
this[Books.writer] = 1L
this[Books.title] = "$it-title"
this[Books.price] = it.toBigDecimal()
this[Books.createdAt] = LocalDateTime.now()
this[Books.updatedAt] = LocalDateTime.now()
}
}
Exposed
๋ batchInsert()
๋ฉ์๋๋ฅผ ์ง์ํ๊ธฐ ๋๋ฌธ์ ์ฝ๊ฒ batch insert๋ฅผ ์งํํ ์ ์์ต๋๋ค. Mysql์ ๊ฒฝ์ฐ JDBC ๋๋ผ์ด๋ฒ์ rewriteBatchedStatements=true
์์ฑ์ ๋ฐ๋์ ์
๋ ฅํด์ผ batch insert๊ฐ ๊ฐ๋ฅํฉ๋๋ค. shouldReturnGeneratedValues
๊ฐ์ false๋ก ์ง์ ํ๋ฉด auto_increment
์ผ๋ก ์ฆ๊ฐ๋ ID ๊ฐ์ ๊ฐ์ ธ์ค์ง ์๊ธฐ์ ์ฑ๋ฅ์ด ํฅ์๋ ์ ์์ต๋๋ค.
๋ก๊ทธ์ ์ถ๋ ฅ๋๋ SQL์ batch insert๊ฐ ์งํ๋์ง ์๊ณ ๊ฐ๋ณ insert๋ก ์ถ๋ ฅ ๋ฉ๋๋ค.
ํ์ง๋ง ์ค์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋ก๊ทธ๋ฅผ ํ์ธํด๋ณด๋ฉด batch insert๊ฐ ์ ์์ ์ผ๋ก ๋์ํ๋ ๊ฒ์ ํ์ธํ ์ ์์ต๋๋ค.
SQL Log ํ์ธ ๋ฐฉ๋ฒ
show variables like 'general_log%';
ํ์ธ ํด์general_log
๊ฐ OFF์ธ ๊ฒฝ์ฐset global general_log = 'ON';
์ค์ ์ดํgeneral_log_file
๊ฒฝ๋ก์ ๋ก๊ทธ ํ์ผ ํ์ธ
Variable_name | Value |
---|---|
general_log | OFF |
general_log_file | /var/lib/mysql/2eb41ec6a5fe.log |
object Books : LongIdTable("book") {
val writer = reference("writer_id", Writers)
val title = varchar("title", 150)
val price = decimal("price", 10, 4)
val createdAt = datetime("created_at")
val updatedAt = datetime("updated_at")
}
object Writers : LongIdTable("writer") {
val name = varchar("name", 150)
val email = varchar("email", 150)
val createdAt = datetime("created_at")
val updatedAt = datetime("updated_at")
}
reference์ ํตํด์ ๊ฐ์ฒด์ ์ฐ๊ด ๊ด๊ณ๋ฅผ ์ฐธ์กฐํ ์ ์๊ฒ ํ ์ ์์ต๋๋ค.
@Test
fun `join`() {
val writerId = insertWriter("yun", "yun@asd.com")[Writers.id].value
(1..5).map {
insertBook("$it-title", BigDecimal.TEN, writerId)
}
// SELECT book.id, book.title, book.price, writer.`name`, writer.email FROM book INNER JOIN writer ON writer.id = book.writer_id
(Books innerJoin Writers)
.slice(
Books.id,
Books.title,
Books.price,
Writers.name,
Writers.email,
)
.selectAll()
.forEach {
it.fieldIndex
println("bookId: ${it[Books.id]}, title: ${it[Books.title]}, writerName: ${it[Writers.name]}, writerEmail: ${it[Writers.email]}")
}
}
DSL ๊ธฐ๋ฐ์ผ๋ก ์กฐ์ธ SQL๋ก ์ฝ๊ฒ ์์ฑํ ์ ์์ต๋๋ค.