Series/실전!

Custom LocalDateUtils 리팩토링

Hyunec 2022. 1. 4. 01:08

이번에 회사 업무의 일부를 확장/분리하는 업무를 맡았습니다.
프로젝트의 세팅부터 해야 되는 작업이었기에 제가 주도적으로 개발할 수 있었는데요.
이 과정에서 작업한 Legacy의 공통 Util 리팩토링의 일부를 기록해봅니다.

AS-IS의 문제점

// 0. 이 프로젝트에 의존적인 로직이 존재합니다.
//  ex) 입력 값이 파싱할 수 없는 isEmpty() 라면 빈 값을 반환. (에러가 아니라)
@UtilityClass
public class LocalDateUtils {

 // 1. LOCAL_DATE_FORMAT_WITH_HYPHEN, LOCAL_DATE_FORMAT 이 String 변수로 존재합니다.
 public static final String LOCAL_DATE_FORMAT_WITH_HYPHEN = "yyyy-MM-dd";
 public static final String LOCAL_DATE_FORMAT = "yyyyMMdd";

 // 2. NO_HYPHEN 은 매개 변수를 통해 받고 있지만,
 //  WITH_HYPHEN 은 명시된 메소드 이름을 사용하기에 일관성을 해칩니다.
 public static String toString(final LocalDate attribute, final String format) {
  if (attribute == null) {
   return "";
  }
  // 3. DateTimeFormatter 객체가 매번 재생성되고 있습니다.
  return attribute.format(DateTimeFormatter.ofPattern(format));
 }
 
 public static String toStringWithNoHyphen(final LocalDate attribute) {
  if (attribute == null) {
   return "";
  }
  return attribute.format(DateTimeFormatter.ofPattern(LOCAL_DATE_FORMAT));
 }

 public static LocalDate from(final String attribute) {
  // 4. isEmpty() 와 같기에 스페이스가 존재하는 문자열을 체크하지 못합니다. (ex. "  ")
  if (!StringUtils.hasLength(attribute)) {
   return null;
  }
  final String format = getFormat(attribute);
  validationCheck(format, attribute);
  return LocalDate.parse(attribute, DateTimeFormatter.ofPattern(format));
 }

 private static String getFormat(final String attribute) {
  // 5. 매직 넘버를 통해 비교하고 있으며, 가독성이 좋지 못합니다.
  return attribute.length() == 10 ? LOCAL_DATE_FORMAT_WITH_HYPHEN : LOCAL_DATE_FORMAT;
 }

 // 6. validation 목적이 명시적이지 않습니다.
 private static void validationCheck(final String format, final String localDate) {
  // 7. SimpleDateFormat 은 다중 스레드 환경에서 권장되지 않습니다.
  final SimpleDateFormat df = new SimpleDateFormat(format);
  df.setLenient(false);
  try {
   df.parse(localDate);
  } catch (final ParseException e) {
   log.error("LocalDateParse Error : ", e);
   throw new RuntimeException();
  }
 }
}

개선 1

AS-IS의 문제점을 고려해서 첫 번째 개선을 적용했습니다.

  1. 클린 코딩에 맞춰 최신 컨벤션을 적용합니다.
  2. 불필요한 객체 생성과 validation을 최소화합니다.
  3. public 메소드 개수와 사용법을 표준화합니다,
    • 확장성을 고려하여 메소드 이름보다는 enum을 매개 변수로 활용하여 로직을 분리합니다.
public class LocalDateUtils {

 @Getter
 @RequiredArgsConstructor
 public enum LocalDateFormatterType {
  WITH_HYPHEN(DateTimeFormatter.ISO_LOCAL_DATE),
  NO_HYPHEN(DateTimeFormatter.ofPattern("yyyyMMdd")),
  ;

  private final DateTimeFormatter formatter;
 }


 public static String toString(final LocalDate attribute, final LocalDateFormatterType type) {
  if (attribute == null) {
   return "";
  }

  return attribute.format(type.formatter);
 }

 public static LocalDate from(final String attribute) {
  if (!StringUtils.hasLength(attribute)) {
   return null;
  }

  try {
   return LocalDate.parse(attribute);
  } catch (final DateTimeParseException ex) {
   return LocalDate.from(LocalDateFormatterType.NO_HYPHEN.formatter.parse(attribute));
  } catch (final Exception ex) {
   log.error("### LocalDateParse Error : ", ex);
   throw new RuntimeException(); // 차후 적절한 에러로 치환
  }
 }
}

개선 2

하지만 이렇게 적용하고 보니 Fommatter 로직도 enum에 응집시키고 싶었습니다.
그리고 Util 은 공통 라이브러리로 분리할 수 있도록 범용적으로 설계되어야 한다고 생각하기에 두 가지 관점을 추가했습니다.

  1. null과 공백 문자열의 후속 처리는 메소드를 호출하는 곳에서 합니다.
  2. 프로젝트에 의존적인 커스텀 에러보다는, 사용하는 곳에서 핸들링할 수 있는 범용 에러를 반환합니다.
public class LocalDateUtils {

 @RequiredArgsConstructor
 public enum LocalDateFormatter {
  WITH_HYPHEN(
    DateTimeFormatter.ISO_LOCAL_DATE::format,
    input -> LocalDate.parse(input)
  ),
  NO_HYPHEN(
    input -> DateTimeFormatter.ofPattern("yyyyMMdd").format(input),
    input -> LocalDate.from(DateTimeFormatter.ofPattern("yyyyMMdd").parse(input))
  );

  private final Function<LocalDate, String> toString;
  private final Function<String, LocalDate> toLocaldate;
 }

 public static String toString(final LocalDate input, final LocalDateFormatter formatter) {
  return Optional.ofNullable(input)
    .map(formatter.toString)
    .orElseThrow(IllegalArgumentException::new);
 }

 public static LocalDate toLocalDate(final String text) {
  try {
   return WITH_HYPHEN.toLocaldate.apply(text);
  } catch (final DateTimeParseException ex) {
   return NO_HYPHEN.toLocaldate.apply(text);
  } catch (final Exception ex) {
   log.error("### toLocalDate Error : ", ex);
   throw ex;
  }
 }
}

마지막으로 개선간 사용한 테스트 케이스를 남기며 글을 마칩니다.

@DisplayName("LocalDateUtils")
class LocalDateUtilsTest {

 @DisplayName("문자열 -> LocalDate")
 @ParameterizedTest
 @CsvSource(
   value = {
     "2020-01-01",
     "2020-01-31",
     "2021-12-01",
     "2021-12-31",
     "20200101",
     "20200131",
     "20211201",
     "20211231"
   }
 )
 void toLocalDate(final String input) {
  final LocalDate localDate = LocalDateUtils.toLocalDate(input);
  assertThat(localDate).isNotNull();
  log.info("### {}", localDate);
 }

 @DisplayName("문자열 -> LocalDate 오류")
 @ParameterizedTest
 @CsvSource(
   value = {
     "''",
     "'  '",
     "2020-01-00",
     "2020-01-32",
     "2021-1201",
     "202112-31",
     "20200100",
     "20200132",
     "202112011",
     "2021123"
   }
 )
 void toLocalDate_error(final String input) {
  assertThrows(DateTimeParseException.class, () -> LocalDateUtils.toLocalDate(input));
 }

 @DisplayName("문자열 -> LocalDate null 오류")
 @ParameterizedTest
 @CsvSource(value = ",")
 void toLocalDate_error_null(final String input) {
  assertThrows(NullPointerException.class, () -> LocalDateUtils.toLocalDate(input));
 }

 public static Stream<LocalDate> LOCAL_DATES() {
  return Stream.of(
    LocalDate.of(2020, 1, 1),
    LocalDate.of(2020, 1, 31),
    LocalDate.of(2021, 12, 1),
    LocalDate.of(2021, 12, 31)
  );
 }

 @DisplayName("LocalDate -> 문자열 WITH_HYPHEN")
 @ParameterizedTest
 @MethodSource("LOCAL_DATES")
 void toString1(final LocalDate input) {
  final String string = LocalDateUtils.toString(input, LocalDateFormatter.WITH_HYPHEN);
  assertThat(string).isNotBlank();
  log.info("### {}", string);
 }

 @DisplayName("LocalDate -> 문자열 NO_HYPHEN")
 @ParameterizedTest
 @MethodSource("LOCAL_DATES")
 void toString2(final LocalDate input) {
  final String string = LocalDateUtils.toString(input, LocalDateFormatter.NO_HYPHEN);
  assertThat(string).isNotBlank();
  log.info("### {}", string);
 }
}