16 serialDate 리팩터링

Seunghee Ryu·2023년 12월 30일
0

클린 코드

목록 보기
16/18

  • 날짜를 표현하는 SerialDate 클래스를 살펴보고 리팩터링 한다

돌려보자

  • SerialDateTests라는 클래스가 있지만 모든 경우를 커버하지 않기 때문에 단위 테스트 케이스를 구현한다
  • 새 단위 테스트는 92% 정도의 코드 커버리지를 가진다
  • 테스트 케이스를 통해 일부를 변경한다

고쳐보자

클래스 이름에 관한 고찰

  • 클래스 선언에 Serial이라는 단어가 들어가는 이유는 일련번호(serial number)를 사용해 클래스를 구현했기 때문이다
  • serialDate라는 이름이 서술적이지 못하다고 생각한다
  • SerialDate라는 이름은 구현을 암시하지만 실상은 추상 클래스이기 때문에 구현을 암시할 필요가 없다
    - 고민 끝에 DayDate라는 용어로 변경한다

MonthConstants를 상속한다

  • MonthConstants 클래스는 달을 정의하는 static final 상수 모음
  • monthConstants는 enum으로 정의해야 마땅하다
  • 달을 int로 받던 메서드는 Month로 받도록 한다
public abstract class DayDate implements Comparable, Serializable {
    public static enum Month {
        JANUARY(1),
        FEBRUARY(2),
        MARCH(3),
        APRIL(4),
        MAY(5),
        JUNE(6),
        JULY(7),
        AUGUST(8),
        SEPTEMBER(9),
        OCTOBER(10),
        NOVEMBER(11),
        DECEMBER(12);

        Month(int index) {
            this.index = index;
        }

        public static Month make(int monthIndex) {
            for (Month m : Month.values()) {
                if (m.index == monthIndex)
                    return m;
            }
            throw new IllegalArgumentException("Invalid month index " + monthIndex);
        }
        public final int index;
    }
}

serialVersionUID 변수는 직렬화를 제어한다

  • 자동 제어가 되지 않기 때문에 해당 변수를 당분간 삭제한다

불필요한 주석은 삭제한다

DayDate 클래스가 표현할 수 있는 최초와 최후 날짜를 의미하는 변수를 좀 더 깔끔하게 표현하기 위해 아래와 같은 코드로 만들어준다

public static final int EARLIEST_DATE_ORDINAL = 2;      // 1/1/1900
public static final int LATEST_DATE_ORDINAL = 2958465;  // 12/31/9999
  • EARLIEST_DATE_ORDINAL이 0이 아닌 2인 이유는 엑셀에서 날짜를 표시하는 방법과 관계있다고 하지만 이 부분이 SpreadsheetDate의 구현과 관련되지 DayDate와는 아무 상관이 없다
  • 따라서 SpreadsheetDate 클래스로 옮겨져야 한다

450page 104행과 107행

  • MINIMUM_YEAR_SUPPORTED와 MAXIMUM_YEAR_SUPPORTED 두 변수를 살펴봤을 때 DayDate는 추상 클래스이므로 구체적인 구현 정보를 포함할 필요가 없어서 두 변수를 SpreadsheetDate 클래스로 옮기려고 했다
  • 하지만 RleativeDayOfWeekRule 클래스에서도 두 변수를 getDate로 넘어온 인수 year가 올바른지 확인할 목적으로 사용함을 알 수 있었다
  • 이에 따라 DayDate 자체를 훼손하지않으면서 구현 정보를 전달할 방법이 필요하다
  • 살펴보면 getDate 메서드로 넘어오는 인수는 DayDate 인스턴스가 아니지만 getDate는 DayDate 인스턴스를 반환하는 것을 알 수 있고 이는 어디선가 인스턴스를 생성하는 부분이 존재함을 의미한다
  • 일반적으로 기반 클래스는 파생 클래스를 모르는 것이 좋기 때문에 추상 팩토리 패턴을 적용하여 DayDate 인스턴스를 생성하는 DayDateFactory를 만들어 해결한다
public abstract class DayDateFactory {
    private static DayDateFactory factory = new SpreadsheetDateFactory();
    public static viud setInstance(DayDateFactory factory) {
        DayDateFactory.factory = factory;
    }

    protected abstract DayDate _makeDate(int ordinal);
    protected abstract DayDate _makeDate(int day, DayDate.Month month, int year);
    protected abstract DayDate _makeDate(int day, int month, int year);
    protected abstract DayDate _makeDate(java.util.Date date);
    protected abstract int _getMinimumYear();
    protected abstract int _getMaximumYear();

    public static DayDate makeDate(int ordinal) {
        return factory._makeDate(ordinal);
    }

    public static DayDate makeDate(int day, DayDate.Month month, int year) {
        return factory._makeDate(day, month, year);
    }

    public static DayDate makeDate(int day, int month, int year) {
        return factory._makeDate(day, month, year);
    }

    public static DayDate makeDate(java.util.Date date) {
        return factory._makeDate(date);
    }

    public static int getMinimumYear() {
        return factory._getMinimumYear();
    }

    public static int getMaximumYear() {
        return factory._getMaximumYear();
    }
}
  • 위 클래스에서 createInstance 메서드를 makeDate라고 좀 더 서술적으로 변경했다.
public class SpreadsheetDateFactory extends DayDateFactory {
    public DayDate _makeDate(int ordinal) {
        return new SpreadsheetDate(ordinal);
    }

    public DayDate _makeDate(int day, DayDate.Month month, int year) {
        return new SpreadsheetDate(day, month, year);
    }

    public DayDate _makeDate(int day, int month, int year) {
        rturn new SpreadsheetDate(day, month, year);
    }

    public DayDate _makeDate(Date date) {
        final GregorianCalendar calendar = new GregorianCalendar();
        calendar.setTime(date);
        return new SpreadsheetDate(
            calendar.get(Calendar.DATE),
            DayDate.Month.make(calendar.get(Calendar.MONTH) + 1),
            calendar.get(Calendar.YEAR));
    }

    protected int _getMinimumYear() {
        return SpreadsheetDate.MINIMUM_YEAR_SUPPORTED;
    }

    protected int _getMaximumYear() {
        return SpreadsheetDate.MAXIMUM_YEAR_SUPPORTED;
    }
}
  • MINIMUM_YEAR_SUPPORTED와 MAXIMUM_YEAR_SUPPORTED 변수를 적절한 위치인 SpreadsheetDate로 옮긴다

해당 부분의 요일 상수들은 enum형이여야 하므로 이에 맞게 수정해준다.

해당 부분의 변수 이름만으로 설명이 충분하기 때문에 주석 삭제

  • 주석은 최소화 하는것이 좋다
  • 적절하게 접근 제어자를 이용, 사용하지 않는 변수 제거
  • 변수가 한 곳에서만 사용될 경우 사용 위치와 최대한 가깝게 옮긴다

달에서 주를 의미하는 부분을 아래와 같이 enum으로 변환한다

public enum WeekInMonth {
    FIRST(1), SECOND(2), THIRD(3), FOURTH(4), LAST(0);
    public final int index;

    WeekInMonth(int index) {
        this.index = index;
    }
}

모호한 상수를 수정한다

  • INCLUDE_NONE, FIRST, SECOND, BOTH 상수는 수학적으로 개구간, 반개구간, 폐구간을 의미하기 때문에 이를 더 잘 나타낼 수 있도록 enum 이름을 DateInterval로 결정하고 CLOSED, CLOSED_LEFT, CLOSED_RIGHT, OPEN으로 수정한다

enum 형태 적용

  • 주어진 날짜를 기준으로 특정 요일을 계산할때 사용되는 상수를 enum으로 변경한다
  • 직전 요일은 LAST, 다음 요일은 NEXT, 가까운 요일은 NEAREST로 정의하고 enum 이름을 WeekdayRange로 한다

불필요한 부분을 삭제한다

  • 사용하지 않는 변수와 기본 생성자, 주석, final 키워드 등을 삭제한다

for 루프안에 if문 두번을 || 연산자를 이용해 if문을 하나로 만든다

weekDayCodeToString 메서드를 Day로 옮기고 toSring이라 명명한다

  • weekDayCodeToString : 넘어온 주 중 일자를 표현하는 문자열 반환
public enum Day {
    MONDAY(Calendar.MONDAY),
    TUESDAY(Calendar.TUESDAY),
    WEDNESDAY(Calendar.WEDNESDAY),
    THURSDAY(Calendar.THURSDAY),
    FRIDAY(Calendar.FRIDAY),
    SATURDAY(Calendar.SATURDAY),
    SUNDAY(Calendar.SUNDAY);

    public final int index;
    private static DateFormatSymbols dateSymbols = new DateFormatSymbols();

    Day(int day) {
        index = day;
    }

    public static Day make(int index) throws IllegalArgumentException {
        for (Day d : Day.values())
            if (d.index == index)
                return d;
        throw new IllegalArgumentException(
            String.format("Illegal day index: %d.", index));
    }

    public static Day parse(String s) throws IllegalArgumentException {
        String[] shortWeekdayNames = 
            dateSymbols.getShortWeekdays();
        String[] weekDayNames =
            dateSymbols.getWeekdays();
        
        s = s.trim();
        for (Day day : Day.values()) {
            if (s.equalsIgnoreCase(shortWeekdayNames[day.index])) ||
                s.equalsIgnoreCase(weekDayNames[day.index])) {
                return day;
            }
        }
        throw new IllegalArgumentException(
            String.format("%s is not a valid weekday string", s));
    }

    public String toString() {
        return dateSymbols.getWeekdays()[index];
    }
}

이름을 서술적으로 변경한다

  • getMonths라는 메서드 두 개에서 첫 번째가 두 번째 getMonths를 호출한다
  • 두 번째 getMonths를 호출하는 메서드는 첫 번째 getMonths 메서드 뿐이기 때문에 이 둘을 합쳐 단순화 하고, 이름을 좀 더 서술적으로 변경하였다
public static String[] getMonthNames() {
    return dateFormatSymbols.getMonth();
}

isValidMonthCode 메서드는 Month enum을 만들면서 불필요해졌기 때문에 삭제한다

메서드를 추가한다

  • monthCodeToQuarter 메서드는 기능 욕심으로 보인다
  • 이는 뚜렷한 목적없이 상수를 나열하고 return 하는 방식으로 좋지 못한 방법이다 - 따라서 아래와 같은 메서드를 Month에 추가해주었다
public int quarter() {
    return 1 + (index-1)/3;
}
  • 이렇게 수정하게 되면 Month가 너무 커지게 되므로 Day enum과 일관성을 유지하도록 DayDate에서 분리해 독자적인 파일로 만들어 준다.

인수로 플래그를 보내지 않도록 변경한다

  • monthCodeToString이라는 메서드 두 개가 나오는데 한 메서드가 다른 메서드를 호출하며 플래그를 넘긴다
  • 메서드 인수로 플래그는 바람직하지 못하기 때문에 이름을 변경하고 단순화하여 Month enum으로 옮긴다
public String toString() {
    return dateFormatSymbols.getMonths()[index - 1]; // return monthCodeTodString(month, false)
}

public String toShortString() {
    return dateFormatSymbols.getShortMonths()[index - 1];
}

단순화한다

  • 문자열을 달 코드로 반환하는 stringToMonthCode 메서드의 이름을 변경하고 앞선 과정들처럼 Month enum으로 옮기고 단순화 하는 과정을 거친다
public static Month parse(String s) {
    s = s.trim();
    for (Month m : Month.values())
        if (m.matches(s))
            return m;
    
    try {
        return make(Integer.parseInt(s));
    }
    catch (NumberFormatException e) {}
    throw new IllegalArgumentException("Invalid month " + s);
}

private boolean matches(String s) {
    return s.equalsIgnoreCase(toString()) ||
        s.equalsIgnoreCase(toShortString());
}

넘어온 년도가 윤년인지 확인하는 isLeapYear 메서드는 더 서술적인 표현을 이용하여 수정해준다

public static boolean isLeapYear(int year) {
    boolean fourth = year % 4 == 0;
    boolean hundredth = year % 100 == 0;
    boolean fourHundredth = year % 400 == 0;
    return fourth && (!hundreth || fourHundredth);
}

윤년 횟수를 반환해주는 leapYearCount는 DayDate에 속하지 않기 때문에 분리해준다

단순화한다

  • 윤년을 고려해 마지막 일자를 반환하는 메서드인 lastDayOfMonth 에서 사용하는 LAST_DAY_OF_MONTH 배열은 Month enum에 속하므로 메서드도 여기로 옮기고 단순하게 고쳐주었다.
public static int lastDayOfMonth(Month month, int year) {
    if (month == Month.FEBRUARY && isLeapYear(year))
        return month.lastDay() + 1;
    else
        return month.lastDay();
}

재정의 가능성이 있는 함수는 인스턴스 메서드로 변경한다

  • 받아온 기준 날짜에 넘어온 일자를 더해 새로운 날짜를 만들어주는 addDays 메서드에서 DayDate 변수를 사용하는데 해당 함수를 재정의할 가능성이 있기 때문에 인스턴스 메서드로 변경한다
  • 해당 메서드가 호출하는 toSerial 메서드의 이름을 toOrdinal로 변경하고 단순화 한다.
public DayDate addDays(int days) {
    return DayDateFactory.makeDate(toOrdinal() + days);
}
  • 기준 날짜에 넘어온 달을 더해 새로운 날짜를 만드는 addMonths 메서드 에서도 위와 같이 인스턴스 메서드로 변경해주고, 읽기 쉬운 형태로 수정하였다.
public DayDate addMonths(int months) {
    int thisMonthAsOrdinal = 12 * getYear() + getMonth().index - 1;
    int resultMonthAsOrdinal = thisMonthAsOrdinal + months;
    int resultYear = resultMonthAsOrdinal / 12;
    Month resultMonth = Month.make(resultMonthAsOrdinal % 12 + 1);

    int lastDayOfResultMonth = lastDayOfMonth(resultMonth, resultYear);
    int resultDay = Math.min(getDayOfMonth(), lastDayOfResultMonth);
    return DayDateFactory.makeDate(reusltDay, resultMonth, resultYear);
}
  • 이렇게 정적 메서드를 인스턴스 메서드로 바꾸게 되면 date.addDays(5)라는 표현이 date 객체를 변경하지않고 새 DayDate 인스턴스를 반환한다는 사실을 나타낼 수 있는지 여부가 불확실 하다
DayDate date = DateFactory.makeDate(5, Month.DECEMBER, 1952);
date.addDays(7); // 날짜를 일주일만큼 뒤로 미룬다.
  • 위와 같이 코드를 작성하게 되면 addDays가 date객체를 변경했다고 생각할 수 있기 때문에 이를 해결하고자 이름을 변경해준다
DayDate date = oldDate.plusDays(5);

인스턴스 메서드 변경과 중복 제거를 한다

  • 넘어온 주 중 일자 범위에 해당하면서 기준 날짜보다 빠른 마지막 날짜를 반환하는 getPreviousDayOfWeek 함수를 단순화하고 임시 변수 설명을 이용해 가독성을 높인다
  • 인스턴스 메서드로 변경하고 중복된 부분을 제거한다
  • 마찬가지로 getFollowingDayOfWeek 도 구조가 같기 때문에 같은 방법으로 수정해준다.
  • 또한 getNearestDayOfWeek 메서드도 위의 두 메서드를 수정한것과 같은 구조를 가지도록 고쳐준다.
public DayDate getPreviousDayOfWeek(Day targetDayOfWeek) {
    int offsetToTarget = targetDayOfWeek.index - getDayOfWeek().index;
    if (offsetToTarget >= 0)
        offsetToTarget -= 7;
    return plusDays(offsetToTarget);
}

public DayDate getFollowingDayOfWeek(Day targetDayOfWeek) {
    int offsetToTarget = targetDayOfweek.index - getDayOfWeek().index;
    if (offsetToTarget <= 0)
        offsetToTarget += 7;
    return plusDays(offsetToTarget);
}

public DayDate getNearestDayOfWeek(final Day targetDay) {
    int offsetToThisWeeksTarget = targetDay.index - getDayOfWeek().index;
    int offsetToFutureTarget = (offsetToThisWeeksTarget + 7) % 7;
    int offsetToPreviousTarget = offsetToFutureTarget - 7;
    if (offsetToFutureTarget > 3)
        return plusDays(offsetToPreviousTarget);
    else
        return plusDays(offsetToFutureTarget);
}
  • 현재 달의 마지막 일자를 반환해주는 getEndOfCurrentMonth 메서드를 인스턴스 메서드로 고치고 이름을 수정해준다.
public DayDate getEndOfMonth() {
    Month month = getMonth();
    int year = getYear();
    int lastDay = lastDayOfMonth(month, year);
    return DayDateFactory.makeDate(lastDay, month, year);
}

불필요한 코드는 삭제한다

  • 월 중 주를 반환해주는 함수인 weekInMonthToString과 relativeToString 메서드는 테스트 케이스 이외에 다른 코드를 호출하지 않기 때문에 해당 메서드와 테스트케이스를 삭제한다

추상 클래스 DayDate의 추상 메서드를 살펴본다

  • toSerial 메서드 : 날짜에 대한 직렬번호 반환
    - 처음에 toOrdinal로 변경했지만 getOrdinalDay가 더 적합한 이름이라 판단하여 변경해준다.
  • toDate 메서드 : DayDate를 java.util.date 반환
    - SpreadsheetDate에서 구현한 toDate를 살펴보면 메서드는 SpreadsheetDate에 의존하지 않기 때문에 해당 코드를 DayDate 메서드로 옮겨 준다
  • getDayOfWeek 도 SpreadsheetDate 구현에 의존성을 갖는지 확인해본다 - getDayOfWeek 함수는 SpreadsheetDate의 서수 날짜 시작 요일에 논리적 의존성을 가지기 때문에 이 부분은 DayDate 메서드로 옮길 수 없다
    - 이를 대신하여 물리적 의존성을 가질 수 있도록 DayDate에 getDayOfWeekForOrdinalZerof라는 추상 메서드를 구현하고 SpreadsheetDate에서 Day.SATURDAY를 반환하도록 구현해준다
    - 논리적 의존성 : 다른 함수에서 사용하는 변수를 사용하여 의존성을 갖는 것
    - 물리적 의존성 : 변수 대신 함수를 이용해서 함수 간 데이터를 공유하는 것
public Day getDayOfWeek() {
    Day startingDay = getDayOfWeekForOrdinalZero(); // 물리적 의존성
    int startingOffset = startingDay.index - Day.SUNDAY.index;
    return Day.make((getOrdinalDay() + startingOffset) % 7 + 1);
}
  • getDayOfWeek 메서드를 DayDate로 옮긴 후 getOrdinalDay와 getDayOfWeekForOrdinalZero를 호출하도록 변경해 줄 수 있게 된다
  • 위와 같은 방법으로 compare 메서드도 DayDate로 옮겨주고 불분명한 이름을 변경하고 이에 맞는 테스트 케이스를 추가하는 리팩토링을 진행한다
  • isInRange 함수도 DayDate로 끌어올리고 if문 연쇄 대신에 enum 형식을 취해 if문 연쇄를 삭제해준다
public enum DateInterval {
    OPEN {
        public boolean isIn(int d, int left, int right) {
            return d > left && d < right;
        }
    },
    CLOSED_LEFT {
        public boolean isIn(int d, int left, int right) {
            return d >= left && d < right;
        }
    },
    CLOSED_RIGHT {
        public boolean isIn(int d, int left, int right) {
            return d > left && d <= right;
        }
    },
    CLOSED {
        public boolean isIn(int d, int left, int right) {
            return d >= left && d <= right;
        }
    };

    public abstract boolean isIn(int d, int left, int right);
}

public boolean isInRange(DayDate di, DayDate d2, DateInterval interval) {
    int left = Math.min(d1.getOrdinalDay(), d2.getOrdinalDay());
    int right = Math.max(d1.getOrdinalDay(), d2.getOrdinalDay());
    return interval.isIn(getOrdinalDay(), left, right);
}

결론

  • 너무 오래된 주석을 간단하게 고치고 개선한다
  • enum을 모두 독자적인 소스 파일로 옮긴다
  • 정적 변수와 정적 메서드를 새 클래스로 이동시킨다
  • 일부 추상 메서드를 DayDate 클래스로 끌어올린다
  • Month.make를 Month.fromInt로 변경하고 다른 enum도 똑같이 변경한다
    - 모든 enum에 toInt() 접근자를 생성하고 index 필드를 private로 정의한다
  • 새 메서드를 생성하고 중복을 삭제한다
  • 여기저기서 사용되던 숫자 1을 삭제했다
  • 테스트 커버리지 수치가 낮아지게 되었다

보이스카우트 규칙을 따라 체크아웃한 코드보다 좀 더 깨끗한 코드를 체크인하게 되었다

개인적인 감상

  • 처음에 import 문은 java.text.와 java.util.로 줄여도 된다는 말이 나와서 모든 것을 임포트하면 느려지지 않을까 하는 의문이 생겼다
    - 컴파일을 할 때엔 느려질 수 있으나 런타임 시에는 속도에 영향을 주지 않는 것으로 판단된다
  • 테스트 커버리지를 100%로 만들지 않아도 된다는 것을 알 수 있었다
    - 테스트 커버리지 100%를 맞추려고 하다보면 테스트를 통과하기 위한 코드를 짜게 될 가능성이 있다
    - 테스트 때문에 로직을 변경하는 것은 바람직하지 않다

0개의 댓글