http://www.jfree.org/jcommon/ 에서 제공하는 JCommon 라이브러리 중 org.jfree.date라는 패키지 속에 위치한 SerialDate라는 클래스에 대해 이야기 한다.
SerialDate는 날짜를 표현하는 자바 클래스다. 이미 자바에서는 다양한 클래스를 제공하지만, 하루 중 시각, 시간대에 무관하게 특정 날짜를 표기하기 위해, 즉 시간 기반 날짜 클래스가 아닌 순수 날짜 클래스를 만들고자 SerialDate를 만들었다고 한다.
/**
* An abstract class that defines our requirements for manipulating dates,
* without tying down a particular implementation.
* <P>
* Requirement 1 : match at least what Excel does for dates;
* Requirement 2 : the date represented by the class is immutable;
* <P>
* Why not just use java.util.Date? We will, when it makes sense. At times,
* java.util.Date can be *too* precise - it represents an instant in time,
* accurate to 1/1000th of a second (with the date itself depending on the
* time-zone). Sometimes we just want to represent a particular day (e.g. 21
* January 2015) without concerning ourselves about the time of day, or the
* time-zone, or anything else. That's what we've defined SerialDate for.
* <P>
* You can call getInstance() to get a concrete subclass of SerialDate,
* without worrying about the exact implementation.
*
* @author David Gilbert
*/
SerialDateTests 라는 클래스는 단위 테스트 케이스 몇개를 포함한다. 해당 클래스를 실행하면 실패하는 경우는 없다. 하지만 코드를 살펴보면 모든 경우를 점검하지 않았다는 사실을 알 수 있다.
테스트 케이스가 확인하지 않는 조건이 있다면 해당 테스트는 불완전하다.
여기서는 생성한 함수를 호출하지 않는 경우가 생긴다.
그래서 클로버를 이용해 실행 코드와 실행하지 않는 코드를 확인한 결과 SerialDateTests는 50%의 경우만 테스트함을 확인할 수 있었다.
따라서 더 높은 코드 커버리지를 위해 단위 테스트 케이스를 구현하고, 해당 단위 테스트에서 실패한 경우(주석으로 처리한 부분)를 성공시킬 수 있도록 원본 클래스를 리팩터링한다.
BobsSerialDateTest.java의 23행에서 63행 까지 주석
todo assertEquals(MONDAY, stringToWeekdayCode("monday"));
assertEquals(MONDAY, stringToWeekdayCode("MONDAY"));
BobsSerialDateTest.java의 43행과 45행 주석
위를 고려하여 기존 stringTomonthCode 메서드를 아래와 같이 고친다
if ((result < 1) || result > 12) {
result = -1;
for (int i = 0; i < maonthNames.length; i++) {
if (s.equalsIgnoreCase(shortMonthNames[i])) {
result = i + 1;
break;
}
if (s.equalsIgnoreCase(monthNames[i])) {
result = i + 1;
break;
}
}
}
그리고 아래 테스트 케이스는 SerialDate의 getFollowingDayOfWeek 메서드에 버그를 드러낸다. 2004년 12월 25일은 토요일이었으므로 다음 토요일은 2005년 1월1일이다. 하지만 테스트를 돌려보면 12월 25일을 다음 토요일로 반환하는 버그를 보여준다.
assertEquals(d(1, JANUARY, 2005), getFollowingDayOfWeek(SATURDAY, d(25, DECEMBER, 2004)));
이는 경계 조건 오류이므로, 아래와 같이 메서드를 수정해준다.
if (baseDow >= targetWeekday) { //if (baseDOW > targetWeekday) {
이 부분은 이전에 수정을 거친 코드다.
* 12-Nov-2001 : IBD requires setDescription() method, now that NotableDate
* class is gone (DG); Changed getPreviousDayOfWeek(),
* getFollowingDayOfWeek() and getNearestDayOfWeek() to correct
* bugs (DG);
버그는 서로 모이는 경향이 있기 때문에 해당 함수를 철저하게 테스트 할 필요가 있다.
또한, 잘 짜여진 테스트 케이스는 실패 패턴이 유사해질 수 있다. 앞선 경우와 같이 경계 조건 오류로 인한 부분을 수정해보자.
int delta = targetDOW = base.getDayOfWeek();
int positiveDelta = delta + 7;
int adjust = positiveDelta % 7;
if (adjust > 3)
adjust -= 7;
return SerialDate.addDays(adjust, base);
위와 같이 변경한 SerialDate는 모든 테스트케이스를 통과했기 때문에 SerialDate 코드를 올바르게 고쳐보자!
코드를 수정하면서 단위 테스트는 계속해서 수행한다!!
법적인 정보는 필요하기 때문에 라이선스 정보와 저작권은 보존하고 변경 이력은 불필요하기 때문에 삭제한다.
import 문은 java.text. 와 java.util.로 줄인다.
Javadoc 주석은 HTML 태그를 사용한다. 그러나 하나의 소스코드에 여러 언어를 사용하는 것은 지양하는 방식이기 때문에 주석 전부를 < pre> 로 감싸는 편이 좋다.
이 부분은 클래스 선언 부분이다. 클래스 이름이 SerialDate인 이유는 해당 클래스가 일련 번호를 사용해서 구현했기 때문이다.
// 증거!!
/**
* Returns the serial number for the date, where 1 January 1900 = 2 (this
* corresponds, almost, to the numbering system used in Microsoft Excel for
* Windows and Lotus 1-2-3).
*
* @return the serial number for the date.
*/
저자는 '일련번호' 라는 용어가 서술적이지 못한 이름이라고 여긴다.
또한, SerialDate라는 이름이 구현을 암시하며 (숨기는 편이 낫다) 추상화 수준이 올바르지 못하다고 생각해서 Date나 Day라는 이름이 적합하다 생각한다.
하지만 이미 Java에는 수많은 Date, Day 클래스가 있기 때문에 SerialDate 대신 DayDate라는 이름을 사용하기로 한다.
MonthConstants는 상수 모음에 불과하다. 상수는 상속하는것이 좋지 않다. 따라서 enum 형식으로 정의하는 편이 낫다.
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;
}
}
이렇게 enum 형식으로 변경하게 되면 int로 달을 받던것을 Month로 받을 수 있으며 이에 따라 중복을 줄이기 위해 수정이 필요하게 된다.
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인 이유는 엑셀에서 날짜를 표시하는 방법과 관계있다고 하지만, (496쪽 71~76 참고) 이 부분이 SpreadsheetDate의 구현과 관련되지 DayDate와는 아무 상관이 없다. 따라서 SpreadsheetDate 클래스로 옮겨져야 한다. 실제로도 SpreadsheetDate 클래스에서만 두 변수를 사용함을 확인 가능하다.
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 형태로 바꿔준다. 해당 부분의 상수는 주어진 날짜를 기준으로 특정 요일을 계산할때 사용된다. 직전 요일은 LAST, 다음 요일은 NEXT, 가까운 요일은 NEAREST로 정의하고 enum 이름을 WeekdayRange로 한다.
for 루프안에 if문 두번을 || 연산자를 이용해 if문을 하나로 만든다.
stringToWeekdayCode 메서드는 DayDate 클래스에 속하지 않는 사실상 Day의 구문 분석 메서드이므로 이를 Day로 옮긴다. 실제로 Day라는 개념은 DayDate에 존재하지 않으므로 DayDate 클래스에서 빼낸 다음 독자적인 소스파일로 만든다.
weekDayCodeToString 메서드를 Day로 옮기고 toSring이라 명명한다.
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 메서드도 위의 두 메서드를 수정한것과 같은 구조를 가지도록 고쳐준다.
final 키워드 (const 와 유사 : 변경 불가능한 상수) 를 제거하고 변수 선언
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의 추상 메서드를 살펴보자.
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 메서드 (469page 902 ~ 913)도 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을 모두 독자적인 소스 파일로 옮겼다.
정적 변수(dateFormatSymbols)와 정적 메서드(getMonthNames,isLeapYear, lastDayOfMonth)를 DateUtil이라는 새 클래스로 옮겼다.
일부 추상 메서드를 DayDate 클래스로 끌어올렸다.
Month.make를 Month.fromInt로 변경했다. 다른 enum도 똑같이 변경했다. 또한 모든 enum에 toInt() 접근자를 생성하고 index 필드를 private로 정의했다.
plusYears와 plusMonths에 중복을 correctLastDayOfMonth라는 새 메서드를 통해 중복을 없애 주었다.
사용하던 숫자 1을 없앴다. 모두 Month.JANUARY.toInt() 혹은 Day.SUNDAY.toInt()로 적절히 변경했다. SpreadsheetDate 코드를 살펴보고 알고리즘을 조금 손봤다.
이러한 과정을 통해 클래스 자체의 크기가 줄어 테스트 하지 않는 코드의 비중이 커졌기 때문에 코드 커버리지가 감소하는 결과를