유틸리티 함수를 정적에서 인스턴스로 변경한 이유

devty·2025년 6월 16일

SpringBoot

목록 보기
10/11

저의 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 설정 없이 사용할 수 있어 편리해 보였습니다.
  • 하지만 곧 문제가 드러났습니다.

정적 메서드의 문제점

  1. 테스트 어려움
    • calculateAgeLocalDateTime.now()를 직접 호출합니다.
    • 테스트에서 시간을 고정하려면 PowerMock 같은 복잡한 도구가 필요습니다.
    • 이는 테스트 코드의 가독성과 실행 속도를 떨어뜨렸습니다.
      class DateTimeUtilsStaticTest : FunSpec({
          test("나이 계산") {
              // now() 모킹 불가, 비결정적 테스트
              // PowerMock으로 now()를 2025-06-13으로 고정해야 함
          }
      })
    • 이런 비결정적 테스트는 신뢰도를 낮췄고 의존성 제어 원칙을 위배했습니다.
  2. 확장성 부족
    • 정적 메서드는 의존성 주입(DI)을 지원하지 않습니다.
    • 예를 들어 calculateAge가 사용자별 시간대 서비스와 연동해야 한다면 로직을 새로 작성해야 했습니다.
    • Spring Boot의 DI를 활용할 수 없어 확장이 어려웠습니다.
  3. 유지보수성 문제
    • 모든 로직이 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)
            }
        }
    }
    

변경의 이점

  1. 테스트 용이성
    • 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() }
          }
      }
  2. Spring DI 활용
    • @Component로 등록해 Time을 주입받고 다른 빈과 통합 가능.
    • 사용자 시간대 서비스와 연동 시 확장 용이.
  3. 확장성 향상
    • 인스턴스 메서드는 오버라이딩이나 새 구현체로 교체 가능.
    • parseBirthDate에 커스텀 포맷 추가 가능.
  4. 책임 분리
    • 의존성 있는 로직(calculateAge)과 없는 로직(convertDateAsStringStatic)을 분리해 코드 가독성과 유지보수성 개선.
  5. 호환성 유지
    • 기존 정적 메서드를 companion object에 남겨 기존 코드 변경 최소화.

정적 메서드는 언제 써야 하나?

  • 정적 메서드는 의존성 없는 단순 로직에만 적합합니다.
  • 사용 적합
    • 순수 함수 (convertDateAsStringStatic)
    • Spring DI 불필요한 환경 (CLI 도구)
  • 사용 부적합
    • now() 같은 의존성 있는 로직 (테스트 어려움)
    • Spring에서 DI나 확장성 필요한 경우
    • 테스트 코드 작성이 중요한 복잡한 로직

결론

  • DateTimeUtils를 정적 메서드에서 인스턴스 메서드로 변경한 이유는 테스트 용이성, Spring DI 활용, 확장성, 유지보수성 때문입니다.
  • 정적 메서드는 의존성 없는 단순 로직에만 쓰고 Spring Boot와 TDD 환경에서는 인스턴스 메서드와 DI를 우선하세요.
  • 그럼 확장 가능한 코드를 작성하는 데 큰 도움이 됩니다.
profile
지나가는 개발자

0개의 댓글