๐ 2025๋ ์ฝํ๋ง ํ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ 5์ข ์๋ฒฝ ๊ฐ์ด๋
๊ด๋ฆฌ์
2์ผ ์
๐ 2025๋ ์ฝํ๋ง ํ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ 5์ข ์๋ฒฝ ๊ฐ์ด๋
๐ฏ ํ ์ค ์์ฝ
QueryDSL ๋์ ๋ญ ์จ์ผ ํ ๊น? ์นด์นด์ค, ๋ค์ด๋ฒ, ํ ์ค๊ฐ ์ ํํ Kotlin + Spring Boot ํ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ค์ ์ฝ๋์ ํจ๊ป ์๊ฐํฉ๋๋ค!
๐ค ์๋ฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๊ทธ๋๋ก ์ฐ๋ฉด ์ ๋๋?
์์งํ ๋ง์๋๋ฆฌ๋ฉด, ์ฝํ๋ฆฐ์ ์ง์ง ๋งค๋ ฅ์ 50%๋ ๋ชป ๋๋ผ๊ณ ์๋ ๊ฒ๋๋ค!
- "QueryDSL ๋ฉํ๋ชจ๋ธ ์์ฑ์ด ๋๋ฌด ๋ฒ๊ฑฐ๋ก์..."
- "Mockito๊ฐ ์ฝํ๋ฆฐ suspend ํจ์๋ฅผ ๋ชป ๋ชจํนํด..."
- "JUnit์ผ๋ก ์ฝ๋ฃจํด ํ ์คํธํ๊ธฐ ๋๋ฌด ๋ณต์กํด..."
์ด์ ์ฝํ๋ฆฐ ๋ค์ดํฐ๋ธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ก ๊ฐ์ํ ์๊ฐ์ ๋๋ค! ๐ฅ
1๏ธโฃ Kotlin JDSL - QueryDSL์ ์๋ฒฝํ ๋์ฒด์
๐ฏ ์ JDSL์ธ๊ฐ?
LINE์์ ๋ง๋ Kotlin JDSL์ QueryDSL์ ๋ชจ๋ ์ฅ์ ์ ๊ฐ์ง๋ฉด์๋ ๋ ๊ฐ๋จํฉ๋๋ค!
QueryDSL vs JDSL ๋น๊ต:
ํน์ง | QueryDSL | Kotlin JDSL |
---|---|---|
๋ฉํ๋ชจ๋ธ | Qํด๋์ค ์์ฑ ํ์ | KProperty ์ง์ ์ฌ์ฉ |
์ค์ | ๋ณต์กํ Gradle ์ค์ | Spring Boot Starter |
์ ์ง๋ณด์ | ์ปค๋ฎค๋ํฐ ์์กด | LINE ๊ณต์ ์ง์ |
์ฝํ๋ฆฐ ์นํ์ฑ | Java ์ฐ์ ์ค๊ณ | Kotlin Native ์ค๊ณ |
๐ป ์ค์ ์ฌ์ฉ๋ฒ
์์กด์ฑ ์ถ๊ฐ
dependencies {
implementation("com.linecorp.kotlin-jdsl:spring-data-kotlin-jdsl-starter:3.4.1")
}
Repository ๊ตฌํ
@Repository
interface UserRepository : JpaRepository<User, Long>, KotlinJdslJpqlExecutor {
// QueryDSL ๋ฐฉ์
// fun findActiveUsers(): List<User> {
// return queryFactory
// .selectFrom(QUser.user)
// .where(QUser.user.status.eq("ACTIVE"))
// .fetch()
// }
// JDSL ๋ฐฉ์ - ๋ฉํ๋ชจ๋ธ ๋ถํ์
fun findActiveUsers() = findAll {
select(entity(User::class))
from(entity(User::class))
where(col(User::status).eq("ACTIVE"))
}
// ๋ณต์กํ ์กฐ์ธ๋ ๊ฐ๋จํ๊ฒ
fun findUsersWithOrders() = findAll {
selectNew<UserWithOrderDto>(
col(User::id),
col(User::name),
count(Order::id)
)
from(entity(User::class))
leftJoin(User::orders)
groupBy(col(User::id))
having(count(Order::id).gt(5))
}
}
๐ข ์ค์ ๋์ ์ฌ๋ก
- Spoqa: QueryDSL โ JDSL ๋ง์ด๊ทธ๋ ์ด์ (2024๋ ๊ณต์ ๋ฐํ)
- LINE ๋ด๋ถ: ๋ค์ ์๋น์ค์์ ํ์ฉ
- ์คํ์์ค ์ปค๋ฎค๋ํฐ: ์ ์ง์ ๋์ ํ์ฐ
2๏ธโฃ Komapper - ์ปดํ์ผ ํ์ SQL ๊ฒ์ฆ์ ํ๋ช
๐ฏ ์ Komapper์ธ๊ฐ?
๋ฐํ์ SQL ์๋ฌ๋ ์ด์ ๊ทธ๋ง! Komapper๋ ์ปดํ์ผ ์์ ์ SQL์ ๊ฒ์ฆํฉ๋๋ค.
์ฃผ์ ํน์ง:
- ์ปดํ์ผ ํ์ SQL ํ์ ์ฒดํฌ โ
- ๋ฐฐ์น ์์ ์ต์ ํ (Hibernate๋ณด๋ค 20% ๋น ๋ฆ)
- R2DBC ๋ค์ดํฐ๋ธ ์ง์
- Spring Boot 3 ์๋ฒฝ ํตํฉ
๐ป ์ค์ ์ฌ์ฉ๋ฒ
Entity ์ ์
@KomapperEntity
@Table("users")
data class User(
@KomapperId
@Column("user_id")
val id: Long = 0,
@Column("username")
val username: String,
@Column("created_at")
val createdAt: LocalDateTime = LocalDateTime.now()
)
Repository ๊ตฌํ
@Repository
class UserRepository(private val db: Database) {
// ์ปดํ์ผ ํ์์ SQL ๊ฒ์ฆ!
fun findByUsername(username: String): User? {
val query = QueryDsl.from(u)
.where { u.username eq username }
.singleOrNull()
return db.runQuery(query)
}
// ๋ฐฐ์น INSERT - ๋งค์ฐ ๋น ๋ฆ
suspend fun batchInsert(users: List<User>) {
val query = QueryDsl.insert(u).batch(users)
db.runQuery(query)
}
// ๋์ ์ฟผ๋ฆฌ๋ ํ์
์ธ์ดํ
fun search(criteria: SearchCriteria): List<User> {
val query = QueryDsl.from(u).where {
criteria.username?.let { u.username contains it }
criteria.minAge?.let { u.age gte it }
}
return db.runQuery(query)
}
}
๐ฏ Komapper ์ฃผ์ ํน์ง
- ์ปดํ์ผ ํ์ ๊ฒ์ฆ: ๋ฐํ์ ์๋ฌ ๋ฐฉ์ง
- R2DBC ์ง์: ๋น๋๊ธฐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ
- ๋ฐฐ์น ์ต์ ํ: ๋์ฉ๋ ๋ฐ์ดํฐ ์ฒ๋ฆฌ์ ์ ๋ฆฌ
- Spring Boot 3: ์๋ฒฝํ ํตํฉ ์ง์
3๏ธโฃ Kotest - JUnit์ ์ด์ ์๋
๐ฏ ์ Kotest์ธ๊ฐ?
JUnit์ ํ๊ณ๋ฅผ ๋ฐ์ด๋๋ ์ฝํ๋ฆฐ ๋ค์ดํฐ๋ธ ํ ์คํ !
JUnit vs Kotest ๋น๊ต:
๊ธฐ๋ฅ | JUnit 5 | Kotest |
---|---|---|
์ฝ๋ฃจํด | ๋ณ๋ ์ค์ ํ์ | ๋ค์ดํฐ๋ธ ์ง์ |
์คํ ์คํ์ผ | 1๊ฐ์ง (@Test) | 10๊ฐ์ง ์คํ์ผ |
ํ๋กํผํฐ ํ ์คํ | ์ธ๋ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ | ๋ด์ฅ ์ง์ |
DSL | ์ด๋ ธํ ์ด์ ๊ธฐ๋ฐ | ํจ์ํ DSL |
๐ป ์ค์ ์ฌ์ฉ๋ฒ
๋ค์ํ ์คํ ์คํ์ผ
// 1. FunSpec (๊ฐ๋จํ ํ
์คํธ)
class UserServiceTest : FunSpec({
test("์ฌ์ฉ์ ์์ฑ ์ฑ๊ณต") {
val service = UserService()
val user = service.create("๊น์ฝํ๋ฆฐ")
user.name shouldBe "๊น์ฝํ๋ฆฐ"
user.id shouldBeGreaterThan 0
}
test("์ค๋ณต ์ฌ์ฉ์๋ช
์์ธ") {
val service = UserService()
service.create("๊น์ฝํ๋ฆฐ")
shouldThrow<DuplicateUsernameException> {
service.create("๊น์ฝํ๋ฆฐ")
}
}
})
// 2. BehaviorSpec (BDD ์คํ์ผ)
class OrderServiceTest : BehaviorSpec({
Given("์ฃผ๋ฌธ์ด ์์ ๋") {
val order = Order(id = 1, status = "PENDING")
When("๊ฒฐ์ ๋ฅผ ์๋ฃํ๋ฉด") {
order.complete()
Then("์ํ๊ฐ COMPLETED๋ก ๋ณ๊ฒฝ๋๋ค") {
order.status shouldBe "COMPLETED"
}
}
When("์ฃผ๋ฌธ์ ์ทจ์ํ๋ฉด") {
order.cancel()
Then("์ํ๊ฐ CANCELLED๋ก ๋ณ๊ฒฝ๋๋ค") {
order.status shouldBe "CANCELLED"
}
}
}
})
// 3. StringSpec (๊ฐ์ฅ ๊ฐ๊ฒฐ)
class CalculatorTest : StringSpec({
"1 + 1 = 2" {
(1 + 1) shouldBe 2
}
"๋ฌธ์์ด ์ฐ๊ฒฐ" {
"Hello, " + "World!" shouldBe "Hello, World!"
}
})
์ฝ๋ฃจํด ํ ์คํธ
class AsyncServiceTest : FunSpec({
test("๋น๋๊ธฐ API ํธ์ถ").config(timeout = 5.seconds) {
val service = AsyncService()
// suspend ํจ์ ์ง์ ํ
์คํธ!
val result = service.fetchDataAsync()
result shouldNotBe null
result.size shouldBeGreaterThan 0
}
test("๋์ ์คํ ํ
์คํธ") {
val service = AsyncService()
// 100๊ฐ ๋์ ์คํ
val results = (1..100).map { id ->
async { service.process(id) }
}.awaitAll()
results.distinct().size shouldBe 100
}
})
ํ๋กํผํฐ ๊ธฐ๋ฐ ํ ์คํ
class PropertyTest : FunSpec({
test("๋ชจ๋ ์์์ ๋ํด ์ฑ๋ฆฝ") {
checkAll<Int> { num ->
whenever(num > 0) {
(num * 2) shouldBeGreaterThan num
}
}
}
test("๋ฌธ์์ด ์ญ์ ํ
์คํธ") {
checkAll<String> { str ->
str.reversed().reversed() shouldBe str
}
}
})
4๏ธโฃ MockK - Mockito๋ ์์ด๋ผ
๐ฏ ์ MockK์ธ๊ฐ?
์ฝํ๋ฆฐ์ ์ํด ์ฒ์๋ถํฐ ์ค๊ณ๋ ๋ชจํน ๋ผ์ด๋ธ๋ฌ๋ฆฌ!
Mockito vs MockK ๋น๊ต:
๊ธฐ๋ฅ | Mockito | MockK |
---|---|---|
Final ํด๋์ค | inline mock ํ์ | ๊ธฐ๋ณธ ์ง์ |
Suspend ํจ์ | ์ง์ ๋ถ๊ฐ | coEvery/coVerify |
DSL | Java ์คํ์ผ | Kotlin DSL |
์ธ์ด ์นํ์ฑ | Java ์ฐ์ | Kotlin ์ ์ฉ |
๐ป ์ค์ ์ฌ์ฉ๋ฒ
๊ธฐ๋ณธ ๋ชจํน
@Test
fun `์ฌ์ฉ์ ์กฐํ ํ
์คํธ`() {
// Given
val repository = mockk<UserRepository>()
val service = UserService(repository)
every { repository.findById(1L) } returns User(1L, "๊น์ฝํ๋ฆฐ")
// When
val user = service.getUser(1L)
// Then
user.name shouldBe "๊น์ฝํ๋ฆฐ"
verify(exactly = 1) { repository.findById(1L) }
}
Suspend ํจ์ ๋ชจํน
@Test
fun `๋น๋๊ธฐ API ๋ชจํน`() = runTest {
// Mockito๋ ์ด๊ฒ ์ ๋จ!
val client = mockk<ApiClient>()
coEvery { client.fetchData() } returns listOf("A", "B", "C")
val service = DataService(client)
val result = service.processData()
result shouldBe listOf("A", "B", "C")
coVerify { client.fetchData() }
}
๊ณ ๊ธ ๊ธฐ๋ฅ
// 1. Spy (๋ถ๋ถ ๋ชจํน)
@Test
fun `์ค์ ๊ฐ์ฒด ๋ถ๋ถ ๋ชจํน`() {
val service = spyk(UserService())
every { service.validate(any()) } returns true
// ๋๋จธ์ง ๋ฉ์๋๋ ์ค์ ๊ตฌํ ์ฌ์ฉ
service.create("ํ
์คํธ") shouldNotBe null
}
// 2. Capturing (์ธ์ ์บก์ฒ)
@Test
fun `๋ฉ์๋ ์ธ์ ์บก์ฒ`() {
val repository = mockk<UserRepository>()
val slot = slot<User>()
every { repository.save(capture(slot)) } returns Unit
val service = UserService(repository)
service.create("๊น์ฝํ๋ฆฐ")
slot.captured.name shouldBe "๊น์ฝํ๋ฆฐ"
}
// 3. Relaxed Mock (์๋ ๊ธฐ๋ณธ๊ฐ)
@Test
fun `Relaxed ๋ชจ๋`() {
val repository = mockk<UserRepository>(relaxed = true)
// ๋ชจ๋ ๋ฉ์๋๊ฐ ๊ธฐ๋ณธ๊ฐ ๋ฐํ (์ค์ ๋ถํ์)
repository.findAll() // ๋น ๋ฆฌ์คํธ ๋ฐํ
repository.count() // 0 ๋ฐํ
}
5๏ธโฃ Exposed - JetBrains์ SQL DSL
๐ฏ ์ Exposed์ธ๊ฐ?
JetBrains๊ฐ ๋ง๋ ์ฝํ๋ฆฐ SQL ๋ผ์ด๋ธ๋ฌ๋ฆฌ!
- JPA ์์ด ํ์ ์ธ์ดํ SQL
- DSL๊ณผ DAO ํจํด ๋ชจ๋ ์ง์
- ํธ๋์ญ์ ๊ด๋ฆฌ ๊ฐํธ
๐ป ์ค์ ์ฌ์ฉ๋ฒ
// ํ
์ด๋ธ ์ ์
object Users : IntIdTable() {
val name = varchar("name", 50)
val email = varchar("email", 100).uniqueIndex()
val age = integer("age")
}
// ์ฌ์ฉ
class UserService {
fun createUser(name: String, email: String, age: Int) = transaction {
Users.insert {
it[Users.name] = name
it[Users.email] = email
it[Users.age] = age
}
}
fun findAdults() = transaction {
Users.select { Users.age greaterEq 18 }
.map {
User(
it[Users.id].value,
it[Users.name],
it[Users.email],
it[Users.age]
)
}
}
}
๐ข ์ค์ ๋์ ํํฉ
๊ฒ์ฆ๋ ๋์ ์ฌ๋ก
- Spoqa: Kotlin JDSL ๋ง์ด๊ทธ๋ ์ด์ ์ฑ๊ณต ์ฌ๋ก
- LINE: ์์ฒด ๊ฐ๋ฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๋ด๋ถ ํ์ฉ
- JetBrains: Exposed๋ฅผ ๋ค์ ํ๋ก์ ํธ์ ์ฌ์ฉ
์ปค๋ฎค๋ํฐ ๋ํฅ
- ์คํ์์ค: GitHub์์ 1,000+ ํ๋ก์ ํธ ์ฌ์ฉ
- ์คํํธ์ : Spring Boot + Kotlin ์กฐํฉ์ผ๋ก ํ์ฐ
- ๊ฐ๋ฐ์: QueryDSL ๋์์ผ๋ก ๊ด์ฌ ์ฆ๊ฐ
๐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ํน์ฑ ๋น๊ต
๋ผ์ด๋ธ๋ฌ๋ฆฌ | ์ฃผ์ ๊ฐ์ | ๋ฌ๋ ์ปค๋ธ | ์ํ๊ณ |
---|---|---|---|
JDSL | ๋ฉํ๋ชจ๋ธ ๋ถํ์ | โญโญโญ | LINE ์ง์ |
Komapper | ์ปดํ์ผ ํ์ ๊ฒ์ฆ | โญโญโญโญ | ํ๋ฐํ ๊ฐ๋ฐ |
Exposed | JetBrains ํ์ง | โญโญ | ์์ ์ |
Kotest | ๋ค์ํ ์คํ | โญโญโญ | ํฐ ์ปค๋ฎค๋ํฐ |
MockK | ์ฝํ๋ฆฐ ๋ค์ดํฐ๋ธ | โญโญ | ํ์ค ๋๊ตฌ |
๐ ๋ง์ด๊ทธ๋ ์ด์ ๊ฐ์ด๋
QueryDSL โ JDSL
// Before (QueryDSL)
queryFactory
.selectFrom(QUser.user)
.where(QUser.user.age.gt(18))
.fetch()
// After (JDSL)
findAll {
select(entity(User::class))
from(entity(User::class))
where(col(User::age).gt(18))
}
JUnit โ Kotest
// Before (JUnit)
@Test
fun testSomething() {
assertEquals(2, 1 + 1)
}
// After (Kotest)
test("1 + 1 = 2") {
(1 + 1) shouldBe 2
}
Mockito โ MockK
// Before (Mockito)
when(repository.findById(1L)).thenReturn(user)
// After (MockK)
every { repository.findById(1L) } returns user
๐ญ ๋ง๋ฌด๋ฆฌ
2025๋ , ์์ง๋ ์๋ฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ง ์ฐ๊ณ ๊ณ์ ๊ฐ์?
์ฝํ๋ฆฐ์ ์ง์ ํ ๋งค๋ ฅ์ ์ฝํ๋ฆฐ์ ์ํด ๋ง๋ค์ด์ง ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ธ ๋ ๋น๋ฉ๋๋ค.
ํนํ QueryDSL์ฒ๋ผ ์ ์ง๋ณด์๊ฐ ์ค๋จ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ๊ณ์ ์ธ ์ด์ ๋ ์์ต๋๋ค.
JDSL์ด๋ Komapper๋ก ๊ฐ์ํ๋ฉด ๋ ๊ฐ๊ฒฐํ๊ณ ๋น ๋ฅธ ์ฝ๋๋ฅผ ์์ฑํ ์ ์์ต๋๋ค.
์ฌ๋ฌ๋ถ์ ์ด๋ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ ํํ์ค ๊ฑด๊ฐ์? ๋๊ธ๋ก ๊ฒฝํ์ ๊ณต์ ํด์ฃผ์ธ์! ๐
์ด ๊ธ์ด ๋์์ด ๋์ จ๋ค๋ฉด ์ข์์์ ๊ณต์ ๋ถํ๋๋ฆฝ๋๋ค! โค๏ธ
๐ ์ฐธ๊ณ ์๋ฃ
๋๊ธ 0๊ฐ
์์ง ๋๊ธ์ด ์์ต๋๋ค
์ฒซ ๋ฒ์งธ ๋๊ธ์ ์์ฑํด๋ณด์ธ์!