Exposed의 문제를 expose한다.

박희중·2023년 4월 16일
1

Kotlin

목록 보기
1/4
post-thumbnail

Exposed를 사용하면서 겪은 다양한 문제들을 expose하겠다.

우선 Exposed에 대해 설명하겠다.

Exposed란?

Exposed는 Kotlin언어로 작성된 ORM(Object Relational Mapping) 라이브러리다.
Exposed는 SQL 쿼리를 작성하는데, JDBC 드라이버를 사용하여 데이터베이스와 상호작용한다.
다양한 데이터베이스 시트템을 지원하며, 객체-관계 매핑 기능과 트랜잭션 관리, 마이그레이션 등의 기능을 제공한다.
Java에서 JPA를 사용하듯이 Kotlin에서는 Exposed를 사용할 수 있다.
Exposed는 가볍고 쉽게 사용할 수 있으며, Kotlin으로 쓰여져 있기 때문에 Kotlin의 모든 기능을 활용할 수 있다.
하지만 Exposed는 일부 데이터베이스 시스템에서는 지원하지 않는 기능도 있으며, 비교적 개발된 지 얼마되지 않아서 불완전하다는 평도 있다.


1. BatchInsert

우선 BatchInsert를 사용해야하는 이유에 대해 먼저 알아보겠다.

BatchInsert를 사용하는 이유

만약 한 테이블에 100건의 record를 insert 해야하는 상황을 가정한다.
보통 insert를 한다면 한 record를 insert하는 SQL문을 하나씩 실행시키는 과정을 반복적으로 100번 수행한다고 한다.

[Insert]

INSERT INTO Table (column1, column2, column3) VALUES (1, 'a', 'b')
INSERT INTO Table (column1, column2, column3) VALUES (2, 'a', 'b')
INSERT INTO Table (column1, column2, column3) VALUES (3, 'a', 'b')
INSERT INTO Table (column1, column2, column3) VALUES (4, 'a', 'b')
INSERT INTO Table (column1, column2, column3) VALUES (5, 'a', 'b')
...

이렇게 데이터베이스에 한 레코드씩 insert할때는 다양한 Overhead(추가 작업량, 부하)가 존재한다.

  1. Transaction logging Overhead
    트랜잭션 로깅은 데이터베이스 시스템이 트랜잭션을 추적하고, 트랜잭션을 복구하기 위해 필요한 정보를 기록하는 것이다. 데이터베이스 시스템이 동작 중에 문제가 발생하여 시스템이 다운되는 경우, 트랜잭션 로그를 사용하여 시스템을 다시 복구할 수 있습니다. 트랜잭션 로그에는 트랜잭션의 시작 시간, 종료 시간, 수행된 SQL 쿼리 등의 정보를 포함하고 있으며 데이터베이스 시스템에서 안정적인 트랜잭션 처리를 위해 중요한 작업이다. 다만 각 레코드를 삽입할 때마다 트랜잭션 로깅이 발생하며, 이는 성능을 느리게 만든다.
  1. index update Overhead
    index는 데이터베이스에서 데이터를 검색하기 위한 구조이며, 데이터베이스에 저장된 데이터의 변경으로 인해 인덱스도 변경된다.
    인덱스 업데이트는 데이터베이스에서 특정 레코드의 추가, 수정, 삭제 등의 작업이 수행될 때 발생한다. 이러한 작업이 수행되면, 해당 작업에 대한 인덱스 업데이트 작업이 자동으로 수행된다.
    이는 추가적인 작업을 필요로 하기 때문에 데이터베이스의 성능을 떨어뜨리는 원인 중 하나이다.
  1. Network Overhead
    레코드를 insert할 때마다 데이터베이스 서버와 통신하므로 Network Overhead가 발생한다.

이렇듯 각 레코드를 삽입할 때마다 발생하는 이러한 Overhead들은 데이터베이스 작업 속도를 느리게 만든다.


하지만 batchInsert를 사용한다면 말이 달라진다.

[BatchInsert]

INSERT INTO Table (column1, column2, column3) VALUES 
(1, 'b', 'c'),
(2, 'b', 'c'),
(3, 'b', 'c'),
(4, 'b', 'c'),
(5, 'b', 'c'),
...

batchInsert는 여러 개의 레코드를 한 번에 insert하므로, 각 레코드를 insert할 때마다 발생하는 Overhead들이 줄일 수 있다.
또한, 여러 개의 레코드를 한 번에 insert하는 만큼 데이터베이스에 대한 I/O 작업을 줄일 수 있다.

다음과 같은 이유들로 batch insert를 사용하면 보다 데이터베이스 작업의 성능을 높일 수 있다.
batch insert는 대량의 데이터를 처리해야 하는 경우에 특히 유용하다.


문제 파악

위와 같은 이유로 batchInsert를 사용하여 multi-row를 한 번에 insert하려고 하였다.

[Kotlin-Exposed 예시코드]

fun saveUserList(userList: List<User>): List<User> {
return UserTable.batchInsert(userList) { user ->
	this[UserTable.name] = user.name
    this[UserTable.age] = user.age
    this[UserTable.mobile] = user.mobile
}.map { it.toUser() }

위와 같은 함수로 batchInsert를 실행해보았지만 인텔리제이에 찍히는 실행로그는 단지 개별 insert를 여러번하는 것으로 나타나서 이상한 점을 느꼈다.

DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aaa', 10, '010-0000-0001')
DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aab', 20, '010-0000-0002')
DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aac', 30, '010-0000-0003')
DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aad', 40, '010-0000-0004')
DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aae', 50, '010-0000-0005')

인텔리제이 로그와 실제 MySQL에서 실행된 쿼리 로그는 다를 수 있으므로 실제 데이터베이스에 반영된 쿼리를 찾아보았으나 역시 개별 insert를 단지 여러번하는 것으로 보였다.

Query	INSERT INTO User (name, age, mobile) VALUES ('aaa', 10, '010-0000-0001')
Query	INSERT INTO User (name, age, mobile) VALUES ('aab', 20, '010-0000-0002')
Query	INSERT INTO User (name, age, mobile) VALUES ('aac', 30, '010-0000-0003')
Query	INSERT INTO User (name, age, mobile) VALUES ('aad', 40, '010-0000-0004')
Query	INSERT INTO User (name, age, mobile) VALUES ('aae', 50, '010-0000-0005')

따라서 Exposed에서 batchInsert를 실행하면 의도했던

INSERT INTO TABLE (...) VALUES (...), (...), (...), (...), (...)
형태로 insert를 하는 것이 아니다.


단지

INSERT INTO TABLE (...) VALUES (...)
INSERT INTO TABLE (...) VALUES (...)
INSERT INTO TABLE (...) VALUES (...)
INSERT INTO TABLE (...) VALUES (...)

형태로 개별 insert를 여러번 실행시키는 방식이라 batchInsert의 이점을 가질 수 없다.

일반적으로 Exposed, JPA와 같은 ORM에서 batchInsert를 사용하는 경우에는 여러 개의 레코드를 한 번에 삽입할 수 있는 insert문으로 SQL을 생성하여 데이터베이스에 전달한다. 하지만 이렇게 생성된 SQL문은 여전히 JDBC 드라이버의 제한에 의해 실제 batchInsert를 수행하지 않을 수 있다. JDBC 드라이버는 SQL문을 한 번에 하나씩 보내고, 이를 수행한 후 결과를 반환한다. 따라서 batchInsert로 생성된 SQL문도 JDBC 드라이버가 이를 하나씩 insert하는 방식으로 처리하므로 실제로는 개별 insert로 처리된다.


rewriteBatchedStatements

실제 insert를 batchInsert 하기 위해서는 JDBC 드라이버에서 제공하는 rewriteBatchedStatements옵션을 활성화해야한다.

url=jdbc:mysql://localhost:3306/heejung?rewriteBatchedStatements=true


  • rewritebatchedstatements 옵션이란?

    rewritebatchedstatements는 JDBC 드라이버의 옵션 중 하나로, Batch Insert의 성능을 향상시키는 기능이다.
    일반적으로 Batch Insert 작업은 PreparedStatement의 addBatch() 메소드를 사용하여 각각의 insert 작업을 추가하고, executeBatch() 메소드를 사용하여 일괄 insert 작업을 수행한다. 그러나 이 방식으로 일괄 insert 작업을 수행할 때, 각각의 insert 작업마다 SQL 쿼리를 재작성해야하는 오버헤드가 발생한다.
    "rewritebatchedstatements=true"옵션을 사용하면, JDBC 드라이버에서 일괄 insert 작업을 처리할 때 각각의 SQL쿼리를 재작성하는 대신, 일괄 insert 작업을 수행하기 위한 최적화 SQL 쿼리를 생성한다. 따라서 SQL 쿼리를 재작성하지 않아도 되므로 오버헤드를 줄일 수 있다.

    참고: MySQL Documentation: 6.3.13 Performance Extensions


  • rewriteBatchedStatements=true 추가후 다시 추출한 MySQL 쿼리
Query	INSERT INTO User (name, age, mobile) VALUES 
('aaa', 10, '010-0000-0001'),
('aab', 20, '010-0000-0002'),
('aac', 30, '010-0000-0003'),
('aad', 40, '010-0000-0004'),
('aae', 50, '010-0000-0005'),

위 쿼리를 통해 옵션을 추가 후 의도했던 batchInsert가 잘 작동하는 것을 볼 수 있다.


하지만 인텔리제이 로그에는 그대로 개별 insert로 출력이 되는 모습이다

DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aaa', 10, '010-0000-0001')
DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aab', 20, '010-0000-0002')
DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aac', 30, '010-0000-0003')
DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aad', 40, '010-0000-0004')
DEBUG Exposed - INSERT INTO User (name, age, mobile) VALUES ('aae', 50, '010-0000-0005')

-> 인텔리제이 로그로 찍히는 Exposed 쿼리는 실제 데이터베이스에 쏘는 쿼리가 아니라
Exposed Kotlin 코드를 beautify하는 것뿐인가?



2. Enum

User 테이블에서 mobileCarrier(통신사)가 SKT인 데이터만 select하려고 한다.

data class User(
	val uid: Int,
    val name: String,
    val age: Int,
    val mobileCarrier: MobileCarrierEnum
)
enum class MobileCarrierEnum(
	val code: Int,
) {
	SKT(1),
    KT(2),
    LG(3),
    ;
	companion object {
    	fun fromCode(code: Int): MobileCarrierEnum? {
        	return values().firstOrNull { it.code == code }
        }
    }
}
fun getUserByMobileCarrier(mc: MobileCarrierEnum): List<User> {
	return UserTable
    	.select { UserTable.mobileCarrier eq mc.SKT }
        .map { it.toUser() }
}

구조가 위와 같을 때 인텔리제이 Exposed 로그로 출력되는 쿼리는 아래에 해당한다

select *
from user
where mobile_carrier = 'SKT'

위 쿼리 로그는 잘못된 쿼리인데 이유는 아래에서 확인하자.


  • User TablemobileCarrier 컬럼 (Exposed)
Object UserTable : Table("User") {
  	...
	val mobileCarrier = customEnumeration(
		name = "mobile_carrier",
    	fromDb = { value -> MobileCarrierEnum.fromCode(value as Int)!! },
    	toDb = { it.code }
	)
}

fromDb와 toDb가 int자료형인 code를 주고 받는 모습을 볼 수 있다.

따라서 DB에서 User 테이블 mobile_carrier 컬럼의 자료형은 int이므로 실제 쿼리는 아래에 해당한다.

select *
from user
where mobile_carrier = 1

결론

-> 따라서 Exposed 로그 쿼리는 실제 DB에서 실행되는 쿼리가 아니라
단지 Exposed Kotlin 코드를 beautify하는 것이라는 결론




참고: MySQL Documentation: 6.3.13 Performance Extensions

profile
백엔드 엔지니어 박희중입니다.

0개의 댓글