이번에 회사 업무의 일부를 확장/분리하는 업무를 맡았습니다.
프로젝트의 세팅부터 해야 되는 작업이었기에 제가 주도적으로 개발할 수 있었는데요.
이 과정에서 작업한 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의 문제점을 고려해서 첫 번째 개선을 적용했습니다.
- 클린 코딩에 맞춰 최신 컨벤션을 적용합니다.
- 불필요한 객체 생성과 validation을 최소화합니다.
- 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 은 공통 라이브러리로 분리할 수 있도록 범용적으로 설계되어야 한다고 생각하기에 두 가지 관점을 추가했습니다.
- null과 공백 문자열의 후속 처리는 메소드를 호출하는 곳에서 합니다.
- 프로젝트에 의존적인 커스텀 에러보다는, 사용하는 곳에서 핸들링할 수 있는 범용 에러를 반환합니다.
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);
}
}
'Series > 실전!' 카테고리의 다른 글
Repository 가볍게 관리하기 (0) | 2022.08.01 |
---|---|
레거시 코드 개선하기 with delegate pattern (0) | 2022.06.05 |
Datadog 에서 GraphQL 모니터링 맛보기 (0) | 2021.12.14 |
느슨한 결합도의 설계를 위해! (2) (0) | 2021.12.04 |
느슨한 결합도의 설계를 위해! (1) (0) | 2021.12.03 |