안드로이드에서는 java.time.LocalDateTime이나 java.time.Instant로 날짜·시간을 다루면 되지만, 이 API는 JVM 표준 라이브러리에 속해 있어서 iOS 타겟인 commonMain에서는 쓸 수 없습니다. iOS 쪽에서는 NSDate, NSCalendar를 쓰는데, 두 플랫폼의 시간 모델을 그대로 노출하면 공통 로직에서 분기가 계속 생깁니다. JetBrains에서 만든 kotlinx-datetime으로 이 문제를 정리한 과정을 적습니다.
왜 필요한가
commonMain에 "마감일이 지났는지 확인" 같은 로직을 두려고 하면, 날짜를 어떤 타입으로 받을지부터 막힙니다. java.time.Instant를 인자로 받게 만들면 iOS 쪽 actual 구현에서 변환 코드가 필요하고, 반대로 각 플랫폼 타입을 그대로 넘기면 expect/actual로 감싸야 할 함수가 계속 늘어납니다. kotlinx-datetime은 플랫폼에 의존하지 않는 Instant, LocalDateTime, LocalDate 타입을 commonMain에 제공해서, 날짜 계산 로직 자체를 공통 코드로 옮길 수 있게 해줍니다.
핵심 개념
kotlinx-datetime이 다루는 타입은 크게 세 가지입니다.
Instant— 타임존과 무관한, 특정 순간을 나타내는 값(에포크 기준 초 단위)LocalDateTime— 타임존 정보가 없는 "그냥 날짜와 시간"(2026-08-28T14:30:00같은 값)TimeZone—Instant와LocalDateTime을 서로 변환할 때 필요한 지역 정보
Instant를 특정 타임존 기준의 LocalDateTime으로 바꾸거나, 그 반대로 바꾸는 게 기본 사용 패턴입니다.
// composeApp/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
}
}
}
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
val now = Clock.System.now() // Instant
val localNow = now.toLocalDateTime(TimeZone.currentSystemDefault())
println(localNow) // 2026-08-28T14:30:00 형태
Clock.System.now()가 안드로이드에서는 System.currentTimeMillis()를, iOS에서는 NSDate를 내부적으로 사용하지만, commonMain 코드는 이 차이를 전혀 신경 쓸 필요가 없습니다.
실전 예시: 마감일 계산 로직
할 일 항목에 마감일(Instant)을 저장해두고, 오늘 기준으로 남은 일수를 계산하는 함수를 commonMain에 작성합니다.
// commonMain/domain/DueDateCalculator.kt
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import kotlinx.datetime.toLocalDateTime
class DueDateCalculator(
private val clock: Clock = Clock.System,
private val timeZone: TimeZone = TimeZone.currentSystemDefault(),
) {
fun daysRemaining(dueAt: Instant): Int {
val today = clock.now().toLocalDateTime(timeZone).date
val dueDate = dueAt.toLocalDateTime(timeZone).date
return today.daysUntil(dueDate)
}
fun isOverdue(dueAt: Instant): Boolean = daysRemaining(dueAt) < 0
}
clock을 생성자 인자로 받아두면 테스트에서 고정된 시각을 주입할 수 있습니다.
// commonTest/domain/DueDateCalculatorTest.kt
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlin.test.Test
import kotlin.test.assertEquals
class DueDateCalculatorTest {
private val fixedClock = object : Clock {
override fun now(): Instant = Instant.parse("2026-08-28T00:00:00Z")
}
private val calculator = DueDateCalculator(clock = fixedClock)
@Test
fun `마감일이 이틀 뒤면 2를 반환한다`() {
val dueAt = Instant.parse("2026-08-30T00:00:00Z")
assertEquals(2, calculator.daysRemaining(dueAt))
}
}
서버와 날짜를 주고받을 때는 Instant를 ISO 8601 문자열로 직렬화합니다. kotlinx-serialization과 함께 쓰면 별도 컨버터 없이 바로 연동됩니다.
// commonMain/network/TodoDto.kt
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class TodoDto(
val id: String,
val title: String,
val dueAt: Instant, // "2026-08-30T00:00:00Z" 형태로 직렬화됨
)
겪은 문제: LocalDateTime을 직접 비교하다 타임존 버그
처음에는 daysRemaining을 Instant 두 개를 직접 빼는 방식으로 구현했습니다.
// 잘못된 초기 구현
fun daysRemaining(dueAt: Instant): Int {
val diff = dueAt - clock.now()
return diff.inWholeDays.toInt()
}
이 코드는 UTC 자정 기준 24시간 단위로 차이를 계산하기 때문에, 오후 11시에 확인하면 실제로는 내일이 마감일이어도 "0일 남음"이 아니라 음수가 나오는 경우가 있었습니다. 사용자 타임존 기준 "날짜"가 아니라 절대 시간 차이를 계산했던 게 원인이었습니다. Instant를 타임존 기준 LocalDate로 변환한 뒤 daysUntil로 날짜 단위 차이를 구하도록 바꾸고 나서, 시간대와 무관하게 "오늘 기준 며칠 남았는지"가 정확하게 나왔습니다.
장단점 정리
장점
Instant,LocalDateTime같은 공통 타입으로 commonMain에 날짜 계산 로직을 그대로 둘 수 있음Clock을 인터페이스로 주입받는 구조라 테스트에서 시각을 고정하기 쉬움kotlinx-serialization과 바로 연동돼서 서버 통신용 DTO에 추가 변환 코드가 필요 없음
단점
- 날짜 포맷팅(
yyyy년 MM월 dd일같은 로케일별 표기)은 아직 라이브러리가 다루지 않아서, 화면에 표시할 문자열은 결국 플랫폼별로 만들어야 함 Instant와LocalDateTime을 헷갈리면 타임존 버그가 나기 쉬운데, 컴파일 타임에는 걸러지지 않음
날짜를 서버와 주고받거나 commonMain에서 날짜 계산 로직을 공유해야 하는 프로젝트라면, 화면 표시용 포맷팅만 플랫폼별로 남기고 나머지는 kotlinx-datetime으로 옮기는 편이 분기를 줄이는 데 도움이 됩니다.