개요
최근 진행한 Kotlin 기반 HTTP 서버를 구축하며, 동일한 비즈니스 로직(Text 저장/출력/삭제)을 4가지 방식(순수 JDBC, MyBatis, Exposed, JPA)으로 각각 구현해 보았다.
아래는 그 내용을 정리한 결과물이다.
GitHub 링크 : https://github.com/proLmpa/HTTP-Server/commit/d6c648b5bf89ae93f1ca6e628426674f66612cdd
프로젝트 내 HTTP Text 요청 흐름
연결 방식에 상관없이, 프로젝트 내에 구현된 JDBC 구현 방안은 아래의 순서에 따라 동작한다.\
- TextHandler 는 HTTP 요청의 유효성을 검증하고, 해당 서비스를 요청한다.
- TextService 는 요청된 repository 기능을 불러온다.
- PersistenceFacotry 는 제공된 환경변수 값(PERSISTENCE_TYPE)에 따라 구체적인 repository를 제공한다.
class TextHandler(
private val textService: TextService
) {
fun create(request: HttpRequest): HttpResponse {
if (request.method != HttpMethod.POST) {
return HttpResponse.methodNotAllowed()
}
val textId = request.pathParams["id"]
?: return HttpResponse.badRequest("id path parameter is required")
val body = request.body
?: return HttpResponse.badRequest("request body is required")
val text = body.toString(Charsets.UTF_8)
textService.save(textId, text)
return HttpResponse.created("/text/$textId")
}
...
}
class TextService(
private val textRepository: TextRepository
) {
fun save(id: String, value: String) {
textRepository.save(id, value)
}
fun find(id: String): String? = textRepository.find(id)
fun findAll(): Map<String, String> = textRepository.findAll()
fun delete(id: String): Boolean = textRepository.delete(id)
}
object PersistenceFactory {
fun createTextRepository(database: Database, type: PersistenceType): TextRepository {
return when (type) {
PersistenceType.JDBC -> JdbcTextRepository(database.dataSource)
PersistenceType.MYBATIS -> MybatisTextRepository(createSqlSessionFactory(database.dataSource))
PersistenceType.EXPOSED -> ExposedTextRepository(database.dataSource)
PersistenceType.JPA -> JpaTextRepository(createEntityManagerFactory(database))
}
}
...
}
PersistenceFactory를 통한 추상화가 이번 프로젝트의 핵심이었다.
이 구조 덕분에 비즈니스 로직(TextService)은 하부 구현이 무엇인지 알 필요가 없었다.
(이는 안드로이드에서 Clean Architecture를 적용할 때 Data Layer만 갈아끼우는 것과 같은 원리다.)
환경 변수(PERSISTENCE_TYPE) 하나로 기술 스택을 자유롭게 변환하며 테스트할 수 있다는 점이 이 구조의 가장 큰 장점이다.
JDBC 연결 방식
순수 JDBC (DataSource를 통한 Raw SQL)
순수 JDBC repository는 DataSource에서 직접 연결을 획득하여 prepared statements를 통해 raw 쿼리를 전송한다.
- 예) 사용자의 POST /text/{id} 요청은 save() 내 raw 쿼리를 실행시킨다.
class JdbcTextRepository (
private val dataSource: DataSource
) : TextRepository {
override fun save(id: String, value: String) {
dataSource.connection.use { connection ->
connection.prepareStatement(
"""
insert into text (id, value)
values (?, ?)
on conflict (id) do update set value = excluded.value
""".trimIndent()
).use { statement ->
statement.setString(1, id)
statement.setString(2, value)
statement.executeUpdate()
}
}
}
...
}
- 특징
- 복잡한 대량의 Batch 처리 시, 가장 먼저 고려되는 기본이다.
- SQL이 코드 안에 문자열로 존재한다. → 컴파일 시점에 쿼리 오류를 포착할 수 없다.
- use 확장 함수를 사용하여 리소스 해제(close)가 깔끔해졌지만, set...() 반복 설정 절차가 여전히 존재한다.
MyBatis SQL Mapper
MyBatis 기반 repository는 SqlSessionFactory와 MyBatis mapper XML을 사용하여 MyBatis 세션을 시작하고 XML에 정의된 SQL 매핑 구문을 실행한다.
- 예) 사용자 요청 시, XML mapper를 호출하기 위한 MyBatis 세션을 시작하여, 해당하는 SQL 쿼리를 찾아 실행한다.
class MybatisTextRepository(
private val sessionFactory: SqlSessionFactory
) : TextRepository {
override fun save(id: String, value: String) {
sessionFactory.openSession(true).use {
it.getMapper(TextMapper::class.java).insert(id, value)
}
}
...
}
interface TextMapper {
fun insert(@Param("id") id: String, @Param("value") value: String)
fun select(@Param("id") id: String): String?
fun selectAll(): List<TextRecord>
fun delete(@Param("id") id: String): Int
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="httpserver.repository.mybatis.TextMapper">
<insert id="insert">
insert into text (id, value)
values (#{id}, #{value})
on conflict (id) do update set value = excluded.value
</insert>
<select id="select" resultType="string">
select value from text where id = #{id}
</select>
<select id="selectAll" resultType="httpserver.repository.mybatis.TextRecord">
select id, value from text order by id
</select>
<delete id="delete">
delete from text where id = #{id}
</delete>
</mapper>
- 특징
- SQL과 코드를 분리하는 XML mapper 기반이다.
- DBA가 따로 있거나, SQL 튜닝이 프로젝트의 핵심일 때 효과적이다.
- 복잡한 통계 쿼리나 join을 작성하기엔 ORM보다 직관적이다.
- 대신 XML이라는 별도 관리 지점이 발생한다.
- Type-safe하지 않기 때문에 Kotlin의 장점을 100% 활용하지 못한다.
Exposed DSL
Exposed 기반 repository는 DataSource DB 연결 후 transactional DSL을 실행한다.
class ExposedTextRepository(dataSource: DataSource) : TextRepository {
init {
Database.connect(dataSource)
}
override fun save(id: String, value: String) {
transaction {
val inserted = Texts.insertIgnore {
it[Texts.id] = id
it[Texts.value] = value
}
if (inserted.insertedCount == 0) {
Texts.update({ Texts.id eq id }) {
it[Texts.value] = value
}
}
}
}
...
}
object Texts : Table("text") {
val id = varchar("id", 255)
val value = text("value")
override val primaryKey = PrimaryKey(id)
}
- 특징
- 코드로 SQL을 직접 구현한다.
- 쿼리 자체를 Type-safe하게 작성할 수 있다. (Kotlin-DSL의 장점 극대화)
- 컴파일 시점에 오타를 잡을 수 있고, Table 객체 정의 방식이 선언적이다.
- 복잡한 분석 쿼리나 DB 벤더 특화 SQL에서는 MyBatis가 더 직관적일 수 있다.
JPA (Hibernate)
JPA repository는 JPA의 EntityManager를 사용하여 TextEntity를 찾고, 합치고, 제거한다.
TextEntity는 다른 repository 구현에서 사용한 것과 동일한 text 테이블에 매핑된다.
class JpaTextRepository(
private val emf: EntityManagerFactory
) : TextRepository {
override fun save(id: String, value: String) {
val em = emf.createEntityManager()
try {
em.transaction.begin()
val entity = em.find(TextEntity::class.java, id) ?: TextEntity(id, value)
entity.value = value
em.merge(entity)
em.transaction.commit()
} finally {
if (em.transaction.isActive) {
em.transaction.rollback()
}
em.close()
}
}
...
}
@Entity
@Table(name = "text")
class TextEntity() {
@Id
var id: String = ""
@Column(nullable = false)
var value: String = ""
constructor(id: String, value: String) : this() {
this.id = id
this.value = value
}
}
- 특징
- DB를 테이블이 아닌 객체로 다룬다. ("서버 사이드 개발의 표준")
- Dirty-Checking(변경 감지) 기능을 제공하여 비즈니스 로직 개발에만 집중할 수 있다.
- 학습 곡선이 가장 높다. Persistence Context나 N+1 문제를 모르면 지옥을 볼 수 있다.
- 실제 도메인이 복잡해질 수록 연관관계 설계, fetch 전략, 영속성 전이 설정이 난이도를 높인다.
요약 정리
| 구분 | Type-Safe | 생산성 | 트랜잭션 특징 | 사고 모델 |
| JDBC | 하 | 낮음 | 명시적, Connection 단위 | 명령형 |
| MyBatis | 중 | 보통 | SqlSession 단위 | SQL 중심 |
| Exposed | 상 | 높음 | transaction {} 블록 기반 | DSL 기반 |
| JPA | 최상 | 매우 높음 | Persistence Context + flush / commit | 객체 상태 기반 |
※ 특히 JPA의 commit 시점 flush / Dirty Checking은 Exposed·MyBatis와 사고 모델이 완전히 다르다.
- 동일한 save 로직에 대해 JDBC / MyBatis / Exposed는 "명령형 쓰기"에 가깝다면,
- JPA는 "상태 변경 → commit 시점 반영"이라는 사고 전환이 필요하다.
JPA도 단일 CRUD + 1차 cache-hit 에서는 JDBC와 큰 차이가 없다.
JPA의 성능 문제는 주로 { flush 시점 / batch 처리 / N+1 / Entity 수명 관리 } 에서 발생한다.
'Backend' 카테고리의 다른 글
| [Spring] Spring 이해하기(1) - POJO, Java Bean, Spring Bean (0) | 2026.02.17 |
|---|---|
| [Backend] Spring Data JPA 환경 내, @Lob + ByteArray 조합으로 인한 bytea / oid 혼동 문제 (1) | 2026.02.12 |
| [Backend] 9주차 내용 정리 (0) | 2026.01.22 |
| [Backend] Kotlin 기반 HTTP 서버 구현 (0) | 2026.01.16 |
| [Backend] 8주차 내용 정리 (0) | 2025.12.31 |