
저의 Spring Boot와 Kotlin 프로젝트에서 DateTimeUtils 유틸리티 클래스는 날짜와 시간 관련 공통 로직을 제공합니다.
처음에는 모든 메서드를 companion object의 정적 메서드로 구현했지만 테스트 코드 작성의 어려움과 확장성 문제로 인스턴스 메서드로 변경했습니다.
왜 이런 변화를 선택했는지 테스트 코드 예시와 함께 살펴봅니다.
DateTimeUtils는 모든 로직을 정적 메서드로 구현했습니다.calculateAge와 시간을 포맷팅하는 convertDateAsString은 다음과 같았습니다.class DateTimeUtils {
companion object {
fun calculateAge(birthDate: LocalDate): Int? {
val now = LocalDateTime.now(ZoneId.of("UTC")).toLocalDate()
return try {
Period.between(birthDate, now).years
} catch (e: Exception) {
null
}
}
fun convertDateAsString(localDate: LocalDate): String {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
return localDate.format(formatter)
}
}
}
DateTimeUtils.calculateAge(date))하고 Spring DI 설정 없이 사용할 수 있어 편리해 보였습니다.calculateAge는 LocalDateTime.now()를 직접 호출합니다.PowerMock 같은 복잡한 도구가 필요습니다.class DateTimeUtilsStaticTest : FunSpec({
test("나이 계산") {
// now() 모킹 불가, 비결정적 테스트
// PowerMock으로 now()를 2025-06-13으로 고정해야 함
}
})calculateAge가 사용자별 시간대 서비스와 연동해야 한다면 로직을 새로 작성해야 했습니다.companion object에 몰려 있어 코드가 복잡해졌습니다.calculateAge)과 없는 로직(convertDateAsString)이 섞여 책임 분리가 어려웠습니다.DateTimeUtils를 인스턴스 메서드 중심으로 리팩토링했습니다.Time 인터페이스를 주입받아 시간 의존성을 관리하고 @Component로 Spring 빈으로 등록했습니다interface Time {
fun now(): LocalDateTime
}
@Component
class SystemTime : Time {
override fun now(): LocalDateTime = LocalDateTime.now()
}
@Component
class DateTimeUtils(private val time: Time) {
fun calculateAge(birthDate: LocalDate): Int? {
val now = time.now().toLocalDate()
return try {
Period.between(birthDate, now).years
} catch (e: Exception) {
null
}
}
fun parseBirthDate(birthday: String?): LocalDate? {
if (birthday.isNullOrBlank()) return null
return try {
LocalDate.parse(birthday, DateTimeFormatter.ofPattern("yyyy-MM-dd"))
} catch (e: DateTimeParseException) {
throw IllegalArgumentException("Invalid date format: $birthday", e)
}
}
companion object {
fun convertDateAsStringStatic(localDate: LocalDate): String {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
return localDate.format(formatter)
}
}
}
Time을 모킹해 시간을 고정할 수 있습니다.@ExtendWith(MockKExtension::class)
class DateTimeUtilsTest {
@MockK lateinit var time: Time
lateinit var dateUtils: DateTimeUtils
@BeforeEach
fun setup() {
dateUtils = DateTimeUtils(time)
}
@Test
fun `유효한 생일을 파싱하면 올바른 LocalDate를 반환한다`() {
// given
val birthday = "1995-01-01"
val expected = LocalDate.of(1995, 1, 1)
// when
val result = dateUtils.parseBirthDate(birthday)
// then
assertEquals(expected, result)
verify(exactly = 0) { time.now() }
}
@Test
fun `빈 생일을 파싱하면 null을 반환한다`() {
// given
val birthday = ""
// when
val result = dateUtils.parseBirthDate(birthday)
// then
assertNull(result)
verify(exactly = 0) { time.now() }
}
@Test
fun `유효한 생일로 나이를 계산하면 현재 시간 기준 올바른 나이를 반환한다`() {
// given
val birthDate = LocalDate.of(1995, 1, 1)
every { time.now() } returns LocalDateTime.of(2025, 6, 13, 12, 15)
// when
val result = dateUtils.calculateAge(birthDate)
// then
assertEquals(30, result)
verify(exactly = 1) { time.now() }
}
@Test
fun `미래의 생일로 나이를 계산하면 null을 반환한다`() {
// given
val birthDate = LocalDate.of(2030, 1, 1)
every { time.now() } returns LocalDateTime.of(2025, 6, 13, 12, 15)
// when
val result = dateUtils.calculateAge(birthDate)
// then
assertNull(result)
verify(exactly = 1) { time.now() }
}
}@Component로 등록해 Time을 주입받고 다른 빈과 통합 가능.parseBirthDate에 커스텀 포맷 추가 가능.calculateAge)과 없는 로직(convertDateAsStringStatic)을 분리해 코드 가독성과 유지보수성 개선.companion object에 남겨 기존 코드 변경 최소화.convertDateAsStringStatic)now() 같은 의존성 있는 로직 (테스트 어려움)DateTimeUtils를 정적 메서드에서 인스턴스 메서드로 변경한 이유는 테스트 용이성, Spring DI 활용, 확장성, 유지보수성 때문입니다.