Kotlin에서 JDBC API 사용하기
JDBC(Java Database Connectivity)는 JVM 위에서 돌아가는 애플리케이션이 데이터베이스와 상호작용하는 데 근간이 되는 API입니다.
이번 포스팅에서는 간단한 예제를 통해서 Kotlin에서 JDBC API를 어떻게 사용하는지에 대해서 살펴보도록 하겠습니다.
JDBC API 패키지
JDBC API는 java.sql
과 javax.sql
, 이렇게 두 개의 패키지로 구성되어 있는데요.
java.sql
패키지는 데이터베이스 연결, SQL 쿼리 실행, 결과 집합 처리에 필요한 클래스와 인터페이스를 제공합니다.
본 포스팅에서 사용할 Connection
, Statement
, ResultSet
과 같은 JDBC API의 기본 구성 요소가 모두 java.sql
패키지에 속합니다.
javax.sql
패키지는 java.sql
패키지가 확장되었다고 보시면 되는데요.
데이터 소스(data source)와 커넥션 풀(connection pool)을 통해 더 효율적인 데이터베이스 연결 관리 기능을 제공합니다.
또한 분산 트랜잭션을 지원하여 여러 데이터베이스와 트랜잭션(transaction)을 관리할 수 있습니다.
이 두 패키지는 모두 Java의 표준 API이기 때문에 Kotlin에서도 별도 설치가 필요없습니다.
데이터에비스 드라이버 설치
관계형(relational) 데이터베이스 시장에는 Oracle Database나 MS SQL Server와 같은 기업용 제품부터 PostgreSQL, MySQL, MariaDB, SQLite, H2와 같은 오픈소스까지 매우 다양한 엔진이 있는데요.
프로젝트에 사용하는 데이터베이스 엔진에 맞는 드라이버 패키지를 반드시 설치해주셔야 합니다.
예를 들어, PostgreSQL을 사용하신다면 org.postgresql:postgresql
패키지를 설치해야 합니다.
dependencies {
implementation("org.postgresql:postgresql:42.7.4")
}
본 포스팅을 따라오실 때는 별도의 데이터베이스 구성 작업이 필요없는 H2와 같은 메모리 데이터베이스를 사용하시면 좋을 것 같습니다.
dependencies {
implementation("com.h2database:h2:2.3.232")
}
데이터베이스 리소스 반납
JDBC API를 통해 획득한 데이터에비스 관련 리소스는 사용 후에는 반드시 풀어줘야 합니다. 그러지 않으면 메모리 유수(memory leak) 문제로 이어질 수 있거든요.
Java에서는 이러한 리소스 반납을 보통 try
문을 이용해서 처리하는데 코드가 읽기 어려워진다는 단점이 있습니다.
하지만 Kotlin에서는 use()
라는 매우 유용한 확장 함수가 있어서 깔끔하게 이 부분을 처리할 수 있습니다.
데이터베이스 연결
데이터베이스를 사용하려면 제일 먼저 데이터베이스와 연결을 맺어야겠죠? JDBC API는 어떤 데이터베이스 엔진을 사용하든 동일한 방식으로 연결할 수 있도록 도와줍니다.
DriverManager
클래스의 getConnection()
메서드에 인자로 데이터베이스 접속 정보를 넘겨서 호출하면 Connection
타입의 객체를 얻을 수 있습니다.
예를 들어, PostgreSQL을 사용하신다면 데이터페이스 이름이 포함된 URL과 사용자 이름과 암호를 넘기면 됩니다.
val url = "jdbc:postgresql://localhost:5432/<데이터베이스 이름>"
val user = "<사용자 이름>"
val password = "<암호>"
DriverManager.getConnection(url, user, password).use { connection ->
println(if (connection.isValid(0)) "데이터베이스 연결 성공" else "데이터베이스 연결 실패")
}
SQLite나 H2와 같은 파일이나 메모리 기반 데이터베이스는 url
만 넘겨도 충분합니다.
import java.sql.DriverManager
val url = "jdbc:h2:mem:test"
DriverManager.getConnection(url).use { connection ->
println(if (connection.isValid(0)) "데이터베이스 연결 성공" else "데이터베이스 연결 실패")
}
데이터베이스 연결 성공
테이블 생성
데이터베이스와 연결을 맺었으니 지금부터 SQL 쿼리를 실행할 수 있습니다.
JDBC API는 SQL 쿼리 실행을 위해서 Statement
와 PreparedStatement
클래스를 제공합니다.
우선 Statement
를 사용해서 테이블을 하나 생성한 후에 초기 데이터를 적재해보겠습니다.
Connection
인스턴스의 createStatement()
함수를 호출하면 Statement
인스턴스를 얻을 수 있습니다.
이 인스턴스를 상대로 execute()
메서드를 호출하면 됩니다.
테이블 생성을 위해서 CREATE TABLE
문을 사용하고, 데이터 적재를 위해서 INSERT
문을 사용하였습니다.
connection.createStatement().use { statement ->
statement.execute(
"""CREATE TABLE fruits (
id INT AUTO_INCREMENT NOT NULL,
name VARCHAR(128) NOT NULL,
price INT)"""
).also {
println("테이블 생성 완료")
}
statement.execute(
"""INSERT INTO fruits (name, price) VALUES
('Apple', 3000),
('Banana', 1000),
('Cherry', 5000),
('Date', 4000),
('Elderberry', 7000)"""
).also {
println("데이터 적재 완료")
}
}
테이블 생성 완료
데이터 적재 완료
범용적으로 쓰이는 execute()
메서드 데이터 반환 여부에 따라 참 또는 거짓을 반환하기 때문에 반환값이 크게 유용하지 않습니다.
구체적인 SQL 쿼리 실행 결과가 필요할 때는 앞으로 다룰 executeQuery()
나 executeUpdate()
메서드를 사용해야 합니다.
데이터 조회
데이터를 조회할 때는 Statement
인스턴스의 executeQuery()
메서드를 호출해야합니다.
쿼리의 실행 결과는 ResultSet
인스턴스에 담겨서 반환됩니다.
예를 들어, SELECT
문을 사용해서 과일 테이블에서 가격이 5,000원 보다 싼 과일 레코드(record)를 찾아보겠습니다.
while
문으로 루프를 돌면서 각 레코드를 칼럼(column) 별로 읽어올 수 있습니다.
val query = "SELECT * FROM fruits WHERE price < 5000"
connection.createStatement().use { statement ->
statement.executeQuery(query).use { resultSet ->
while (resultSet.next()) {
val id = resultSet.getInt("id")
val name = resultSet.getString("name")
val price = resultSet.getInt("price")
println("ID: $id, Name: $name, Price: $price")
}
}
}
ID: 1, Name: Apple, Price: 3000
ID: 2, Name: Banana, Price: 1000
ID: 4, Name: Date, Price: 4000
데이터 변경
데이터를 변경할 때는 Statement
인스턴스의 executeUpdate()
메서드를 호출해야합니다.
쿼리의 실행 결과로 몇 개의 데이터가 변경이 되었는지가 반환됩니다.
3,000원이 넘는 과일 레코드를 삭제하고, 바나나의 가격으로 10,000원으로 올려보겠습니다.
val deleteQuery = "DELETE fruits WHERE price > 3000"
connection.createStatement().use { statement ->
statement.executeUpdate(deleteQuery).also {
println("$it 개의 데이터 삭제")
}
}
val updateQuery = "UPDATE fruits SET price = 10000 WHERE name = 'Banana'"
connection.createStatement().use { statement ->
statement.executeUpdate(updateQuery).also {
println("$it 개의 데이터 수정")
}
}
3 개의 데이터 삭제
1 개의 데이터 수정
데이터 추가
Statement
클래스 대신에 PreparedStatement
클래스를 사용하면 좀 더 효과적이고 안전하게 SQL 쿼리를 실행할 수 있습니다.
?
기호로 SQL 쿼리 안에 치환될 수 있는 부분을 표시해놓고, 다른 값을 대입하면서 동일한 SQL 쿼리를 여러 번 실행할 수 있습니다.
SQL 주입(injection) 공격도 방어해주기 때문에 보안 측면에서도 PreparedStatement
클래스를 쓰는 것이 권장됩니다.
Connection
인스턴스의 prepareStatement()
메서드를 호출하면 PreparedStatement
인스턴스를 얻을 수 있습니다.
이 때 반드시 prepareStatement()
메서드를 호출하면서 SQL 쿼리를 인자로 넘겨야 합니다.
그래야지 SQL 쿼리 실행 전에 ?
자리를 원하는 데이터로 치환해놓을 수 있기 때문입니다.
PreparedStatement
인스턴스를 사용하여 동일한 쿼리로 2개의 다른 과일 레코드를 등록해보겠습니다.
val query = "INSERT INTO fruits (name, price) VALUES (?, ?)"
connection.prepareStatement(query).use { preparedStatement ->
preparedStatement.setString(1, "Fig")
preparedStatement.setInt(2, 2000)
preparedStatement.executeUpdate().also {
println("$it 개의 데이터 삽입")
}
}
connection.prepareStatement(query).use { preparedStatement ->
preparedStatement.setString(1, "Grape")
preparedStatement.setInt(2, 6000)
preparedStatement.executeUpdate().also {
println("$it 개의 데이터 삽입")
}
}
1 개의 데이터 삽입
1 개의 데이터 삽입
전체 코드
본 포스팅에서 작성한 전체 코드와 실행 결과는 아래를 참고 바랍니다.
import java.sql.Connection
import java.sql.DriverManager
fun main() {
val url = "jdbc:h2:mem:test"
DriverManager.getConnection(url).use { connection ->
println(if (connection.isValid(0)) "데이터베이스 연결 성공" else "데이터베이스 연결 실패")
setUpTable(connection)
findData(connection)
updateData(connection)
createData(connection)
}
}
fun setUpTable(connection: Connection) {
connection.createStatement().use { statement ->
statement.execute(
"""CREATE TABLE fruits (
id INT AUTO_INCREMENT NOT NULL,
name VARCHAR(128) NOT NULL,
price INT)"""
).also {
println("테이블 생성 완료")
}
statement.execute(
"""INSERT INTO fruits (name, price) VALUES
('Apple', 3),
('Banana', 1),
('Cherry', 5),
('Date', 4),
('Elderberry', 7)"""
).also {
println("데이터 적재 완료")
}
}
}
fun findData(connection: Connection) {
val query = "SELECT * FROM fruits WHERE price < 5"
connection.createStatement().use { statement ->
statement.executeQuery(query).use { resultSet ->
while (resultSet.next()) {
val id = resultSet.getInt("id")
val name = resultSet.getString("name")
val price = resultSet.getInt("price")
println("ID: $id, Name: $name, Price: $price")
}
}
}
}
fun updateData(connection: Connection) {
val deleteQuery = "DELETE fruits WHERE price > 3"
connection.createStatement().use { statement ->
statement.executeUpdate(deleteQuery).also {
println("$it 개의 데이터 삭제")
}
}
val updateQuery = "UPDATE fruits SET price = 10 WHERE name = 'Banana'"
connection.createStatement().use { statement ->
statement.executeUpdate(updateQuery).also {
println("$it 개의 데이터 수정")
}
}
}
fun createData(connection: Connection) {
val query = "INSERT INTO fruits (name, price) VALUES (?, ?)"
connection.prepareStatement(query).use { preparedStatement ->
preparedStatement.setString(1, "Fig")
preparedStatement.setInt(2, 2)
preparedStatement.executeUpdate().also {
println("$it 개의 데이터 삽입")
}
}
connection.prepareStatement(query).use { preparedStatement ->
preparedStatement.setString(1, "Grape")
preparedStatement.setInt(2, 6)
preparedStatement.executeUpdate().also {
println("$it 개의 데이터 삽입")
}
}
}
데이터베이스 연결 성공
테이블 생성 완료
데이터 적재 완료
ID: 1, Name: Apple, Price: 3000
ID: 2, Name: Banana, Price: 1000
ID: 4, Name: Date, Price: 4000
3 개의 데이터 삭제
1 개의 데이터 수정
1 개의 데이터 삽입
1 개의 데이터 삽입
마치며
이상으로 Kotlin에서 JDBC API를 사용하는 기본적인 방법에 대해서 알아보았습니다.
실제 Kotlin 백엔드 개발에서는 Exposed와 같은 ORM 라이브러리를 사용하시겠지만, 이러한 라이브러리도 결국은 내부적으로 JDBC API에 의존하고 있습니다. 따라서 JDBC API에 대해서 잘 숙지해두시면 디버깅을 하시거나 오류가 발생했을 때 분명히 도움이 되실거라 생각합니다.