소요 시간: 약 30분

TL;DR
  • commonMain — 모든 플랫폼에서 공유하는 Kotlin 코드
  • expect — 공통 모듈에서 선언, actual — 각 플랫폼에서 구현
  • jvmMain, jsMain — 플랫폼 특화 코드
  • 한 번 작성한 비즈니스 로직을 JVM(백엔드/Android)과 JS(프론트엔드)에서 재사용

대상 독자: Kotlin 중급 이상의 개발자 선수 지식: 환경 설정, 기본 Kotlin 문법


Kotlin Multiplatform(KMP)으로 비즈니스 로직을 여러 플랫폼에서 공유합니다. 날짜 포매터를 공통 모듈로 작성하고, JVM과 JS에서 각각 구현하는 미니 프로젝트를 진행합니다.

Step 1 — 프로젝트 구조 이해#

KMP 프로젝트에서 코드는 소스 세트(source set)로 구성됩니다.

shared/
├── build.gradle.kts
└── src/
    ├── commonMain/kotlin/          # 모든 플랫폼 공유 코드
    ├── commonTest/kotlin/          # 공통 테스트
    ├── jvmMain/kotlin/             # JVM 전용 구현
    ├── jvmTest/kotlin/
    ├── jsMain/kotlin/              # JS 전용 구현
    └── jsTest/kotlin/
graph TD
    A["commonMain<br>(공통 비즈니스 로직)"] --> B["jvmMain<br>(Android / 서버)"]
    A --> C["jsMain<br>(브라우저 / Node.js)"]
    A --> D["nativeMain<br>(iOS / 데스크탑)"]

그림: KMP 소스 셋 분기 구조 — commonMain에서 jvmMain·jsMain·nativeMain으로 플랫폼별 코드가 분리되는 Kotlin Multiplatform 프로젝트 구성을 보여줍니다.

Step 2 — 프로젝트 생성#

IntelliJ IDEA에서 FileNewProjectKotlin MultiplatformLibrary 선택.

또는 build.gradle.kts를 직접 작성합니다.

// settings.gradle.kts
rootProject.name = "kmp-date-formatter"
include(":shared")
// shared/build.gradle.kts
plugins {
    kotlin("multiplatform") version "2.0.0"
}

group = "com.example"
version = "0.1.0"

kotlin {
    jvm {
        jvmToolchain(17)
        testRuns["test"].executionTask.configure {
            useJUnitPlatform()
        }
    }

    js(IR) {
        browser {
            testTask {
                useKarma {
                    useChromeHeadless()
                }
            }
        }
        nodejs()
    }

    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
            }
        }
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test"))
            }
        }
        val jvmMain by getting
        val jvmTest by getting
        val jsMain by getting
        val jsTest by getting
    }
}

Step 3 — 공통 인터페이스 정의 (expect)#

expect은 “이 선언은 각 플랫폼에서 구현되어야 한다"는 계약입니다.

// shared/src/commonMain/kotlin/com/example/datetime/DateFormatter.kt
package com.example.datetime

import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime

// expect 선언 — 각 플랫폼이 구현 제공
expect class PlatformDateFormatter() {
    fun formatDate(date: LocalDate): String
    fun formatDateTime(dateTime: LocalDateTime): String
    fun formatRelative(date: LocalDate): String   // "3일 전", "오늘" 등
}

// 공통 로직 — expect를 활용하는 비즈니스 로직
class DateService(private val formatter: PlatformDateFormatter = PlatformDateFormatter()) {

    fun describeDate(date: LocalDate): String {
        val relative = formatter.formatRelative(date)
        val formatted = formatter.formatDate(date)
        return "$relative ($formatted)"
    }

    fun describeDateTime(dateTime: LocalDateTime): String {
        return formatter.formatDateTime(dateTime)
    }
}
// shared/src/commonMain/kotlin/com/example/datetime/DateUtils.kt
package com.example.datetime

import kotlinx.datetime.*

// 플랫폼 독립적인 공통 유틸리티
fun LocalDate.isWeekend(): Boolean {
    val day = this.dayOfWeek
    return day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY
}

fun LocalDate.daysUntil(other: LocalDate): Long {
    return (other.toEpochDays() - this.toEpochDays()).toLong()
}

fun LocalDate.nextWeekday(): LocalDate {
    var next = this.plus(1, DateTimeUnit.DAY)
    while (next.isWeekend()) {
        next = next.plus(1, DateTimeUnit.DAY)
    }
    return next
}

data class DateRange(val start: LocalDate, val end: LocalDate) {
    val durationDays: Long get() = start.daysUntil(end)
    operator fun contains(date: LocalDate): Boolean = date >= start && date <= end
}

Step 4 — JVM 구현 (actual)#

// shared/src/jvmMain/kotlin/com/example/datetime/DateFormatter.jvm.kt
package com.example.datetime

import kotlinx.datetime.*
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale

actual class PlatformDateFormatter {
    private val locale = Locale.KOREAN
    private val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
        .withLocale(locale)
    private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
        .withLocale(locale)

    actual fun formatDate(date: LocalDate): String {
        val javaDate = java.time.LocalDate.of(date.year, date.monthNumber, date.dayOfMonth)
        return javaDate.format(dateFormatter)
    }

    actual fun formatDateTime(dateTime: LocalDateTime): String {
        val javaDateTime = java.time.LocalDateTime.of(
            dateTime.year, dateTime.monthNumber, dateTime.dayOfMonth,
            dateTime.hour, dateTime.minute, dateTime.second
        )
        return javaDateTime.format(dateTimeFormatter)
    }

    actual fun formatRelative(date: LocalDate): String {
        val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
        val diff = today.daysUntil(date)
        return when {
            diff == 0L -> "오늘"
            diff == 1L -> "내일"
            diff == -1L -> "어제"
            diff > 0 -> "${diff}일 후"
            else -> "${-diff}일 전"
        }
    }
}

Step 5 — JS 구현 (actual)#

// shared/src/jsMain/kotlin/com/example/datetime/DateFormatter.js.kt
package com.example.datetime

import kotlinx.datetime.*

actual class PlatformDateFormatter {
    // JS에서는 Intl.DateTimeFormat API 사용 (kotlin-js 통해 접근)
    actual fun formatDate(date: LocalDate): String {
        // 한국어 형식으로 포맷
        return "${date.year}${date.monthNumber}${date.dayOfMonth}일"
    }

    actual fun formatDateTime(dateTime: LocalDateTime): String {
        return "${formatDate(dateTime.date)} " +
               "${dateTime.hour.toString().padStart(2, '0')}:" +
               "${dateTime.minute.toString().padStart(2, '0')}"
    }

    actual fun formatRelative(date: LocalDate): String {
        val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
        val diff = today.daysUntil(date)
        return when {
            diff == 0L -> "오늘"
            diff == 1L -> "내일"
            diff == -1L -> "어제"
            diff > 0 -> "${diff}일 후"
            else -> "${-diff}일 전"
        }
    }
}

Step 6 — 공통 테스트#

공통 테스트는 모든 플랫폼에서 실행됩니다.

// shared/src/commonTest/kotlin/com/example/datetime/DateUtilsTest.kt
package com.example.datetime

import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class DateUtilsTest {
    @Test
    fun `토요일은 주말이다`() {
        val saturday = LocalDate(2026, 5, 16)   // 토요일
        assertTrue(saturday.isWeekend())
    }

    @Test
    fun `월요일은 주말이 아니다`() {
        val monday = LocalDate(2026, 5, 11)     // 월요일
        assertFalse(monday.isWeekend())
    }

    @Test
    fun `날짜 범위에 포함 여부 확인`() {
        val range = DateRange(
            start = LocalDate(2026, 5, 1),
            end = LocalDate(2026, 5, 31)
        )
        assertTrue(LocalDate(2026, 5, 15) in range)
        assertFalse(LocalDate(2026, 6, 1) in range)
    }

    @Test
    fun `daysUntil 계산`() {
        val from = LocalDate(2026, 5, 1)
        val to = LocalDate(2026, 5, 10)
        assertEquals(9L, from.daysUntil(to))
    }

    @Test
    fun `nextWeekday는 주말을 건너뛴다`() {
        val friday = LocalDate(2026, 5, 15)     // 금요일
        val nextWorkday = friday.nextWeekday()
        assertEquals(LocalDate(2026, 5, 18), nextWorkday)  // 월요일
    }
}

Step 7 — JVM 메인 (사용 예시)#

// shared/src/jvmMain/kotlin/com/example/Main.kt
package com.example

import com.example.datetime.*
import kotlinx.datetime.*

fun main() {
    val formatter = PlatformDateFormatter()
    val service = DateService(formatter)

    // 날짜 포맷
    val today = Clock.System.now()
        .toLocalDateTime(TimeZone.currentSystemDefault()).date

    println("오늘: ${formatter.formatDate(today)}")
    // 예: 오늘: 2026년 5월 13일

    // 상대적 날짜
    val nextWeek = today.plus(7, kotlinx.datetime.DateTimeUnit.DAY)
    println("다음 주: ${service.describeDate(nextWeek)}")
    // 예: 다음 주: 7일 후 (2026년 5월 20일)

    // 주말 확인
    println("오늘 주말 여부: ${today.isWeekend()}")

    // 날짜 범위
    val projectPeriod = DateRange(
        start = today,
        end = today.plus(30, kotlinx.datetime.DateTimeUnit.DAY)
    )
    println("프로젝트 기간: ${projectPeriod.durationDays}일")
}

Step 8 — 빌드 및 테스트#

# 공통 테스트 실행 (JVM + JS)
./gradlew :shared:allTests

# JVM만 테스트
./gradlew :shared:jvmTest

# JS만 테스트
./gradlew :shared:jsTest

# JVM 실행
./gradlew :shared:jvmRun

빌드 결과물

# JVM용 JAR
shared/build/libs/shared-jvm-0.1.0.jar

# JS용 (Node.js/브라우저)
shared/build/js/packages/kmp-date-formatter-shared/
expect/actual 적용 가능한 대상

expect/actual은 클래스 외에도 다음에 적용할 수 있습니다.

  • 최상위 함수: expect fun currentTimeMs(): Long
  • 오브젝트: expect object Platform
  • 타입 별칭: expect class IOException
  • 어노테이션: expect annotation class Serializable
KMP가 적합한 경우
  • 유효성 검사, 비즈니스 규칙 — 백엔드/프론트 동일 로직
  • 날짜/시간 처리, 단위 변환, 포맷 규칙
  • API 응답 파싱 모델 (data class)
  • 알고리즘, 계산 로직

반면 UI, 파일 시스템, 네트워크 하위 계층은 플랫폼 특화 코드가 더 적합합니다.

핵심 정리
  • commonMain — 플랫폼 독립 공통 코드 (비즈니스 로직, 모델, 유틸)
  • expect — 공통 모듈에서 선언, 각 플랫폼이 actual로 구현
  • jvmMain, jsMain — 플랫폼 특화 API 사용 가능
  • 공통 테스트는 모든 플랫폼에서 동시에 실행되어 일관성을 보장
  • kotlinx-datetime 같은 공식 멀티플랫폼 라이브러리 활용으로 개발 범위 확장

테스트 작성#

KMP의 공통 테스트는 commonTest 소스셋에 작성하며, kotlin("test") 의존성만으로 JVM과 JS 양쪽에서 동일한 테스트를 실행합니다. build.gradle.ktscommonTest 소스셋에 이미 포함된 의존성을 확인합니다.

// shared/build.gradle.kts — commonTest 소스셋
val commonTest by getting {
    dependencies {
        implementation(kotlin("test"))  // assertEquals, assertNotNull 등 포함
    }
}

commonTest 소스셋 구조

shared/src/
├── commonMain/kotlin/com/example/datetime/
│   ├── DateFormatter.kt   (expect 선언)
│   └── DateUtils.kt       (공통 유틸)
└── commonTest/kotlin/com/example/datetime/
    ├── DateRangeTest.kt   (공통 테스트 — JVM/JS 양쪽 실행)
    └── DateServiceTest.kt

kotlin.test 기본 단언 사용

kotlin.testassertEquals, assertNotNull, assertTrue, assertFailsWith 등을 제공합니다. 플랫폼별 임포트 없이 commonTest에서 바로 사용할 수 있습니다.

// shared/src/commonTest/kotlin/com/example/datetime/DateRangeTest.kt
package com.example.datetime

import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue

class DateRangeTest {

    @Test
    fun `DateRange durationDays는 두 날짜 사이 일수를 반환한다`() {
        val range = DateRange(
            start = LocalDate(2026, 5, 1),
            end   = LocalDate(2026, 5, 31)
        )
        assertEquals(30L, range.durationDays)
    }

    @Test
    fun `contains로 날짜 포함 여부를 확인한다`() {
        val range = DateRange(
            start = LocalDate(2026, 5, 1),
            end   = LocalDate(2026, 5, 31)
        )
        assertTrue(LocalDate(2026, 5, 15) in range)
        assertFalse(LocalDate(2026, 6, 1) in range)
        assertFalse(LocalDate(2026, 4, 30) in range)
    }

    @Test
    fun `nextWeekday는 주말을 건너뛰어 다음 평일을 반환한다`() {
        val friday   = LocalDate(2026, 5, 15)   // 금요일
        val expected = LocalDate(2026, 5, 18)   // 다음 월요일
        val actual   = friday.nextWeekday()
        assertEquals(expected, actual)
        assertNotNull(actual)
    }
}

DateService 공통 단위 테스트

DateServicePlatformDateFormatter를 사용하므로 expect/actual 구현이 필요합니다. 공통 로직만 검증할 때는 DateRange와 같은 순수 공통 클래스를 대상으로 합니다.

// shared/src/commonTest/kotlin/com/example/datetime/DateUtilsExtTest.kt
package com.example.datetime

import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class DateUtilsExtTest {

    @Test
    fun `토요일과 일요일은 주말이다`() {
        val saturday = LocalDate(2026, 5, 16)
        val sunday   = LocalDate(2026, 5, 17)
        assertTrue(saturday.isWeekend())
        assertTrue(sunday.isWeekend())
    }

    @Test
    fun `평일은 주말이 아니다`() {
        val monday    = LocalDate(2026, 5, 11)
        val wednesday = LocalDate(2026, 5, 13)
        assertFalse(monday.isWeekend())
        assertFalse(wednesday.isWeekend())
    }

    @Test
    fun `daysUntil은 음수도 반환한다`() {
        val from = LocalDate(2026, 5, 10)
        val to   = LocalDate(2026, 5, 1)
        assertEquals(-9L, from.daysUntil(to))
    }
}

JVM과 JS 양쪽에서 동시에 테스트를 실행합니다.

# 모든 플랫폼에서 공통 테스트 실행
./gradlew :shared:allTests

# JVM에서만 실행
./gradlew :shared:jvmTest

# JS에서만 실행
./gradlew :shared:jsTest
kotlin.test 주요 단언 함수
함수설명
assertEquals(expected, actual)두 값이 같은지 검증
assertNotNull(value)null이 아닌지 검증
assertTrue(condition)조건이 참인지 검증
assertFalse(condition)조건이 거짓인지 검증
assertFailsWith<T> { }특정 예외가 발생하는지 검증

kotlin.test는 JVM에서 JUnit 5로, JS에서 Mocha로 매핑되므로 별도 플랫폼 설정이 필요하지 않습니다.

다음 단계#