리스코프 치환 원칙

오늘도 어김없이 객체지향의 5대 원칙 중에 리스코프 치환의 원칙을 살펴보도록 하자. 이것만 하면 이제 단일책임원칙만 마무리하면 된다. 리스코프 치환의 원칙이라.. 말은 어렵지만 내용은 이해하는 자체는 어려운 편은 아니다. 5대원칙들 중에 나머지들은 이름만 봐도 대충 감을 잡을 수 있었지만 이 리스코프 치환 원칙은 이름만 보고는 전혀 감이 오지 않는다. 이름만 봐도 거부감이 들어서 맨 나중에 살펴본 내용이다. (그럼 단일책임 부터 했어야 했나..)

그럼 일단 리스코프 치환의 원칙의 정의를 보자.

상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

라고 하지만 정의를 봐도 전혀 이해가 가지 않는다. 그러니 어서 코드를 보자. 리스코프 치환원칙에서 가장 유명한 직사각형 정삼각형 예제가 있으니 일단 그걸 살펴보도록 하자.

public class Rectangle {
    protected double width;
    protected double height;

    public void setWidth(double width) {
        this.width = width;
    }
    public double getWidth() {
        return this.width;
    }
    public void setHeight(double height) {
        this.height = height;
    }
    public double getHeight() {
        return this.height;
    }
    public double getArea() {
        return this.getWidth() * this.getHeight();
    }
}

간단한 예제이다. 직사각형의 면적 구하는 공식은 가로 * 세로이다. 특별한 내용 없이 아주 완벽한(?) 코드이다.
우리는 다음과 같이 사용할 수 있다.

public class DoWork {

  public void work() {
    Rectangle rectangle = new Square();
    rectangle.setHeight(5);
    rectangle.setWidth(4);
    System.out.println(rectangle.getArea())
  }
}

public class Main {
  public static void main(String[] args) {
    Main main = new Main();
    main.work();
  }
}

문제 없이 우리는 20의 값을 출력할 수 있다. 만약 면적이 20이 아니면 예외를 던지라고 요구사항을 받았다 가정해보자. 그래서 우리는 다음과 같이 코드를 변경하였다.

public class DoWork {
  public void work() {
    Rectangle rectangle = new Rectangle();
    rectangle.setHeight(5);
    rectangle.setWidth(4);
    if(!isCheck(rectangle)) {
      throw new RuntimeException();
    }
  }

  public boolean isCheck(Rectangle rectangle) {
    return rectangle.getArea() == 20;
  }
}

public class Main {
  public static void main(String[] args) {
    Main main = new Main();
    main.work();
  }
}

여기 까지도 문제 없이 우리는 20이 아닐 경우 예외를 던진다. 꽤 시간이 흘러 또 다시 요구사항이 추가되었다. 정사각형을 추가로 만들어 달란 요구사항이다. 그래서 우리는 이렇게 생각 할 수 있다. 직사각형을 상속받아 정사각형을 구현할 수 있지 않을까 하고 구현해보도록 하자.

public class Square extends Rectangle {
    @Override
    public void setWidth(double width) {
        this.width = width;
        this.height = width;
    }
    @Override
    public void setHeight(double height) {
        this.height = height;
        this.width = height;
    }    
}

우리는 별다른 생각없이 setWidth 와 setHeight를 오버라이딩 받았다. 정사각형은 가로 세로가 같으니 동일하게 값을 넣어 주었다. 그리고 나서 다시 실행 코드를 변경해보다.

public class DoWork {
  public void work() {
    Rectangle rectangle = new Square();
    rectangle.setHeight(5);
    rectangle.setWidth(4);
    if(!isCheck(rectangle)) {
      throw new RuntimeException();
    }
  }

  public boolean isCheck(Rectangle rectangle) {
    return rectangle.getArea() == 20;
  }
}

public class Main {
  public static void main(String[] args) {
    Main main = new Main();
    main.work();
  }
}

그리고 나서 우리는 Rectangle 대신에 Square 클래스를 사용 하였다. 하지만 우리는 원하는 결과를 얻지 못하고 예외를 던져버린다. 이것이 바로 리스코프 치환의 원칙을 어겨서 일어난 일이다. 이제야 조금 이해 가지 않는가?

다시 정의를 살펴보면 조금 이해가 될 싶다. 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

그럼 위의 문제를 어떻게 해결할까? 실제 직사각형과 정사각형 문제는 개념적으로 상속 관계에 있는 것처럼 보이지만 구현에서는 상속 관계가 아닐 수도 있다는 것을 보여 주고 있다. 개념상 정사각형은 높이와 폭이 같은 직사각형이므로 상속을 받는게 괜찮은 방법이라고 생각하지 모르겠지만 실제 프로그램에서의 이둘은 상속 관계로 묶을 수 없다는 것이다. 만약 isCheck() 메서드 같은 기능이 필요하다면 상속보다는 별개의 타입으로 구현해 주는 것이 맞다.

이 경우 말고도 상위 타입에서 지정한 리턴 코드(값) 등을 하위 타입이 상속받아 잘 못된 코드를 리턴한다면 이것 역시 리스코프치환 원칙을 어기는 일이 된다.

예를들자면 어떤 파일을 읽는데 파일의 데이터가 없다면 -1 리턴한다고 가정해보자. (실제로 InputStream 경우에도 -1을 리턴한다)

class FileCopy {
  public void copy(InputStream inputStream) {
    while (inputStream.read(data) != -1){
      // blabla
    }
  }
}

위와 같이 InputStream의 read() 메서드는 파일의 데이터가 없을 경우에 -1을 리턴한다. 하지만 만약에 InputStream의을 상속받아 구현한 하위 클래스가 파일의 데이터가 없을 경우 -1이 아닌 다른 값을 리턴하면 어떻게 될까?

class SomeInputStream extends InputStream {
  @Override
  public int read(byte[] data) throws IOException {
    //blabla
    return 0; //파일의 데이터가 없을 경우
  }
}

이러면 FileCopy의 copy 메서드는 결코 끝나지 않는 무한루프가 돈다. 이와 같은 문제가 발생하는 이유는 하위 클래스가 상위 클래스의 규칙을 지키지 않았기 때문이다. 이제는 조금 이해가 가지 않는가? 이게 바로 리스코프 치환의 원칙을 의미한다. 내용 자체는 그리 어려운 내용은 아닌 듯 싶다. 이름이 왜케 어려운건지..

리스코프 치환의 원칙은 기능의 명세(계약) 에 대한 내용이다. 기능 실행의 계약과 관련해서 흔히 발생하는 위반 사례로는 다음과 같다.

  1. 명시된 명세에서 벗어난 값을 리턴한다.
  2. 명시된 명세에서 벗어난 익셉션을 발생시킨다.
  3. 명시된 명세에서 벗어난 기능을 수행한다.

리턴 값은 0이나 또는 그 이상을 리턴하도록 정의되어 있는데 하위 타입에서 음수값을 리턴한다거나 IOException을 발생시킨다고 했는데 기타 다른 타 Exception을 발생시킨다던가 하는 위반사례들이 있으면 구현한 코드는 비정상적으로 동작할 수 있기에 하위 타입은 상위타입에서 정의한 명세에 벗어나지 않도록 주의해야 한다.

이렇게 오늘은 리스코프 치환의 원칙에 대해서 살펴봤다. 다음에는 마지막으로 단일책임원칙에 대해서 알아보도록 하자!

개방 폐쇄 원칙

오늘은 저번시간에 이어 개방 폐쇄 원칙(Open-clased principle) 대해서 알아보도록 하자. 예제를 고민하느라 책도 많이 참고 했고 인터넷 정보도 많이 활용했다. 계속 이 원칙들을 살펴봐야 되는데 중간중간에 다른 포스팅을 하느라 저번 포스팅이 어디있는지도 모르겠다. 오늘도 역시 단일책임원칙은 못한다.

개방 폐쇄 원칙 (OCP)

조금 모순이 있는 단어같다. 개방과 폐쇄가 공존한다. 하지만 여기에는 깊은 뜻이다. 일단 개방 폐쇄 원칙이 무엇인지 살펴보자. 개방 폐쇄 원칙이란 (OCP) 확장에는 열려 있어야 하고, 변경에는 닫혀있어야 한다. 라고 정의 되어있다. 흠.. 무슨 이런 어이 없는 말을 할까하는데 좀 더 풀어서 이야기 하면 다음과 같다.
기능을 변경 또는 확장할 수 있으면서 그 기능을 사용하는 코드는 수정하지 않아야 한다. 라고 하면 좀 더 와닿을까? 근데 그게 가능할까나 어떻게 변경하거나 확장하면서 그를 사용하는 코드는 수정하지 않아야 한다니 말이다. 먼저 글(의존 역전의 원칙) 을 읽었다면 가능해 보인다. 왜나면 의존역전의 원칙을 잘 지키면 개방 폐쇄 원칙도 알아서 잘 지켜지니 말이다.

일단 우리는 어떤 파일을 읽어와 엑셀로 다운로드 한다는 기능이 있다고 가정하자. 그걸 코드로 작성해보면 다음과 같다.

public class ExcelController {

    private final FileByteReader fileByteReader = new FileByteReader();

    public void download() {
        byte[] read = fileByteReader.read();

        //blabla
    }
}

public class FileByteReader {
    public byte[] read() {
        //파일을 읽는다.
        return new byte[0];
    }
}

아주 간단한 코드를 작성했다. ExcelController는 다운로드 하는 부분의 로직이 들어가면 되고 FileByteReader 클래스는 파일을 읽어와 byte 배열로 리턴해주는 코드가 들어가면 되겠다. ExcelController 클래스는 FileByteReader 클래스를 사용하고 있다. 그러다 어느날 Database에서 값을 꺼내 다운로드 하라는 요청이 들어왔다. 그럼 우리는 다음과 같이 변경해야 한다.

public class ExcelController {

  private final DataBaseByteReader dataBaseByteReader = new DataBaseByteReader();

  public void download() {
    byte[] read = dataBaseByteReader.read();

    //blabla
  }
}

public class DataBaseByteReader {
  public byte[] read() {
    //데이터 베이스에서 값을 가져온다.
    return new byte[0];
  }
}

우리는 DataBaseByteReader 클래스를 생성하고 다시 ExcelController에서 사용되었던 FileByteReaderDataBaseByteReader 클래스로 변경해야 한다. 요구사항이 변경될 때 마다 계속 ExcelController 클래스를 변경해야 한다. 이것은 개방 폐쇄 원칙을 지키지 않아 일어난 일이다. 눈치 빠른 사람들은 눈치 챗겟지만 인터페이스를 이용하면 좀 더 확장성 있게 되지 않을까 생각 한다. 그럼 인터페이스를 만들어보자.

public interface ByteReader {
  byte[] read();
}

public class DataBaseByteReader implements ByteReader {
  @Override
  public byte[] read() {
    //데이터 베이스에서 값을 가져온다
    return new byte[0];
  }
}

public class FileByteReader implements ByteReader {
  @Override
  public byte[] read() {
    //파일을 읽는다.
    return new byte[0];
  }
}

ByteReader 라는 인터페이스에 read() 라는 메서드를 만들고 구현은 해당 클래스의 맞게 개발하면 된다. 그럼 한번 사용해보자.

public class ExcelController {

  private final ByteReader byteReader = new DataBaseByteReader();

  public void download() {
    byte[] read = byteReader.read();

    //blabla
  }
}

인터페이스를 이용하여 DataBaseByteReader 클래스를 생성하였다. 하지만 아직까지 FileByteReader 클래스로 변경해야 된다면 ExcelController의 클래스를 변경해야 한다. 아직까지 완벽한 개방폐쇄가 되지 않았다. 엇 이건 저번에 봤던 의존역전원칙과 비슷한 구석이 많다고 생각 들 수 있다. 맞다. 저번에도 말했지만 의존역전원칙을 잘 지키면 개방폐쇄원칙도 자연스레 지키게 된다.

다시 코드를 변경해 보자.

public class ExcelController {

  private final ByteReader byteReader;

  public ExcelController(ByteReader byteReader) {
    this.byteReader = byteReader;
  }

  public void download() {
    byte[] read = byteReader.read();

    //blabla
  }
}

ByteReader 인터페이스는 사용하는 클라이언트에서 결정하면 된다. 만약 또 다시 요구사항이 추가 되었다고 생각해보자. 이번에는 DataBase에서 값을 가져오는 것이 아니고 타 API를 이용해서 가져와야 한다고 가정해보자.

public class RequestByteReader implements ByteReader {

  @Override
  public byte[] read() {
    //api 호출
    return new byte[0];
  }
} 

API를 통해 값을 가져와 Byte 배열로 가져오는 RequestByteReader 클래스를 만들었다. 그리고 ExcelController를 변경하려고 보니까 그럴 필요가 없어졌다. 왜냐하면 ExcelController 클래스는 직접적으로 어떤 클래스를 사용한게 아니라 의존성 주입을 받고 있기에 ExcelController는 변경이 일어나지 않는다. 이로써 우리는 수정과 확장은 하였지만 코드는 변경하지 않은 개방폐쇄 원칙을 따르게 된 것이다.
개방폐쇄원칙은 꼭 인터페이스만 사용하라는 법은 없다. 상속을 통해서도 개방폐쇄원칙을 지킬 수 있다.

class HttpServlet {

  protected void doGet(Request request) {
    response.send(400);
  }
  protected void doPost(Request request) {
    response.send(400);
  }
  // etc..
  public void service(Request request) {
    if(method.equals("get")) {
      doGet(request);
    } else {
      doPost(request);
    }
  }
}

어떤 request는 받는 클래스가 있다고 생각해보자. service()라는 메서드는 요청을 받아서 각각의 상태에 맞게 doGet과 doPost로 전달하는 역할을 한다고 치자. 기본적으로 각각의 상태코드는 400을 리턴한다. 하지만 만약 계정 정보를 조회하는 요구사항이 들어왔다고 가정해보자. 그리고 계정정보의 조회의 상태코드는 get일 경우 에만 200의 상태코드를 리턴해야 한다고 하면 다음과 같이 할 수 있다.

class AccountServlet extends HttpServlet {

  @Override
  protected void doGet(Request request) {
    response.send(200);
  }
}

이렇게 HttpServlet 클래스는 변경하지 않고 확장해 나갔던 이유는 바로 개방폐쇄 원칙을 잘 지켜서 할 수 있는 일이 되었다. 가만 보면 위의 코드는 어느 디자인 패턴과 비슷해보인다. 맞다. 디자인 패턴 중 템플릿 메서드 패턴을 사용한 것이다. 템플릿 메서드 패턴은 상위 클래스에서 실행할 기본 코드를 만들고 하위 클래스에서 필요에 따라 확장해가는 패턴으로 아주 유용하게 쓰인다. 나중에 기회가 된다면 패턴들도 한번씩 살펴보기로 하자.
실제 위의 코드는 java servlet의 HttpServlet 클래스를 간단하게 구현해 본 것이다. HttpServlet 클래스는 템플릿 메서드 패턴으로 구현되어 있다.

이렇게 오늘은 객체지향의 5대 원칙 중 개방폐쇄의 원칙을 살펴봤다. 내용자체는 그리 어려운 말은 아니지만 실제로 구현하려면 많이 생각하고 구현해야 될 듯 싶다. 언제쯤 단일책임원칙의 예제가 생각날까.. 리스코프 치환원칙은 아주 유명한 예제가 있어서 그걸 기반으로 설명하면 될 듯 싶은데.. 단일 책임 원칙은.. 왜이렇게 생각이 나지 않을까

일단 아마도 다음시간에는 리스코프 치환원칙을 살펴볼 듯 싶다. 그럼 오늘은 이만.

java의 Date

오늘도 어김없이 java와 관련된 이야기를 포스팅 하려한다. 요 근래 자주 java와 관련있는 포스팅만 하는 것 같다. 다른 것들도 해야 되는데.. 아무튼 오늘은 java의 날짜와 관련된 포스팅을 한다.

java의 날짜 관련API(Date, Calendar 기타 등) 설계부터가 잘못 되었고 구현조차 잘못된 클래스로 java의 가장 악명 높은 대표적인 클래스이다. 기존의 날짜 관련 API들은 문서없이 사용하기 쉽지 않고 일관성도 없는데다 알 수 없는 상수들을 남발한다. 한개씩 살펴보자.

이상한 월 상수

@Test
public void constantTest() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, 6, 18);
  SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
  assertThat(format.format(calendar.getTime())).isEqualTo("2017-06-18");
}

위는 Calendar 클래스를 이용해서 오늘날짜인 2017.6.18일을 지정하였다. 누가 봐도 올바른 코드라고 생각할 듯 하다. 하지만 이상하게도 이 테스트는 통과하지 못한다. 왜나면 월을 0부터 시작했기 때문이다. 아무리 개발자라고 1월을 0으로 표시하는 건 좀 그렇다. 이 코드를 정상 작동 시키려면 아래와 같이 코드를 변경해야 한다.

calendar.set(2017, 5, 18);

그래서 IEDA에서는 경고를 보여준다. Calendar에 있는 상수를 사용하라는 경고인 듯 싶다. 월을 지정할 때는 Calendar의 상수를 가져와서 쓰는 것이 정신건강상 나을 듯하다.

calendar.set(2017, Calendar.JUNE, 18);

실질적으로 Calendar.JUNE 값은 5를 의미한다. 헷갈린다 헷갈려..

일관성 없는 요일 상수

생각하는 사람 나름이겠지만 요일을 상수로 지정하여 개발한다고 치자. 월요일부터 일요일까지의 숫자로 표현한다면 어떤게 가장 이상적일까? 필자와 생각이 다를 수도 있겠지만 필자 같은 경우에는 1을 월요일 7을 일요일로 지정했을 듯 하다. 물론 0을 일요일로 생각할 수도 있겠지만 필자생각엔 그닥 좋은 방법은 아니라고 생각된다.

@Test
public void constantTest1() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, Calendar.JUNE, 18);
  int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
  assertThat(dayOfWeek).isEqualTo(7);
}

위의 코드는 요일을 상수로 가져오는 그런 코드이다. 여기서도 마음에 들지 않는 코드가 있다. 바로 Calendar.DAY_OF_WEEK 이 아이다. 저 아이도 7이라는 상수 값이다. 7은 뭘 의미할까? 요일이 7개라 7로 했나. 차라리 Enum 타입이였다면 좋았겠지만 저 클래스는 Enum 타입이 나오기전 아주아주 오래된 클래스라 그럴수 없었던 것으로 보인다.
아무튼 다시 돌아와 위 코드를 테스트를 해보자. 위의 테스트는 성공할까? 다들 알겠지만 위는 테스트를 실패한다. 음 그럼 아주 드물게 일요일을 0 으로 생각할 수도 있으니 0으로 코드를 바꾸어서 테스트를 해보자.

@Test
public void constantTest1() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, Calendar.JUNE, 18);
  int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
  assertThat(dayOfWeek).isEqualTo(0);
}

0으로 바꾸어봤지만 또 다시 테스트에 실패한다. 이게 무슨 일인가.. 위 테스트를 성공시키려면 아래와 같이 변경해야 된다.

@Test
public void constantTest1() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, Calendar.JUNE, 18);
  int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
  assertThat(dayOfWeek).isEqualTo(1);
}

위에서 말했다시피 2017년 6월 18일은 일요일인데 요일 상수는 1을 리턴한다. 그래 이것까지 넓고 넓은 우리 개발자들은 이해 할 수 있었다고 치자. 하지만 더 어이 없는건 다음에 있다.

@Test
public void constantTest2() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, Calendar.JUNE, 18);
  assertThat(calendar.getTime().getDay()).isEqualTo(1);
}

위의 코드는 calendar를 Date로 반환하여 다시 요일을 테스트하는 그런 코드이다. 요일에 일요일이 1이라고 했으니 당연히 테스트를 통과 하겠지? 안타깝게 이 테스트는 다시 실패로 돌아간다. 그 이유는 Date의 getDay() 메서드는 우리가 생각했듯이 일요일을 0으로 판단하다. (하지만 필자는 7이 더 나은듯) 이렇게 날짜 관련 API의 일관성이 매우 떨어진다.
참고로 Date 에 getDay() Deprecated 메서드이다.

동등성 (equality)

자바 개발자라면 필독서인 effective java에는 이런 내용이 있다. equals 메서드 규약을 설명하는 내용이다. 5가지 정도 되니 잠깐 살펴보자.

  1. 반사성 : null이 아닌 참조 x가 있을때 x.equals(x)는 true를 반환한다. x.equals(x) = true
  2. 대칭성 : null이 아닌 참조 x와 y가 있을 때 x.equals(y)는 y.equals(x) 가 true일때만 true를 반환한다. x.equals(y) = true , y.equals(x) = true
  3. 추이성 : null이 아닌 참조 x,y,z가 있을 때 x.equals(y)가 true이고 x.equals(z)가 true면 x.equals(z)도 true이다. x.equals(y) = true, y.equals(z) = true, x.equals(z) = true
  4. 일관성 : null이 아닌 참조 x,y가 있을 때 equals 통해 비교되는 정보에 아무 변화가 없다면 호출 결과는 횟수에 상관 없이 항상 같아야 된다. x.equals(y) = true
  5. null 아닌 참조 x에 대하여 x.equals(null)은 항상 false이다. x.equals(null) = false

위의 언급한 5가지가 equals를 구현하는 메서드 규약이다. 일반적으로 상속관계에서 깨지기 쉽다. (그래서 상속이 그리 좋은 것만은 아니다. 상속보다는 조립을..) java의 Date와 Timestamp 클래스가 그 대표적인 예이다.

@Test
public void symmetryTest() {
  long now = System.currentTimeMillis();
  Timestamp timestamp = new Timestamp(now);
  Date date = new Date(now);
  assertThat(date.equals(timestamp)).isTrue();
  assertThat(timestamp.equals(date)).isTrue();
}

Date와 Timestamp 클래스는 상속 관계의 클래스이다. Timestamp 클래스의 상위 클래스가 Date 클래스가 이다. 그래서 비교를 했을경우 date.equals(timestamp)true 라면 timestamp.equals(date)true를 반환해야 한다. 하지만 위의 테스트 코드는 실패로 돌아간다. date.equals(timestamp)true로 리턴하지만 timestamp.equals(date) 경우에는 false를 리턴한다. 대칭성이 맞지 않아 equals 규약을 어긴 꼴이 된 셈이다. 둘다 true 이거나 둘다 false 이어야만 대칭성을 어기지 않는 셈이 되는 것이다.

불변이 아니다. (mutable)

가장 개발이 잘못 이루워진 부분이다. Date와 Calendar 이외에 날짜 관련 API들은 불변이 아니다. 불변이 아니기 때문에 멀티 스레드에 안전하지 못하다. 보통은 다른 언어의 경우에는 날짜와 돈 관련 API들은 모두 불변으로 개발 되어 있다. 하지만 java에서는 그렇지 못하다. effective java를 다시 언급하자면 저자인 Joshua Bloch도 Date 클래스는 불변이였어야 했다고 지적했다. 그래서 만약 필요 하다면 방어적 본사본을 만들라 라고 한다. 아래 코드처럼 말이다. 아래의 Period는 불변의 객체이다. 생성자를 호출할 경우에도 다시 Date를 생성하고 getter를 호출할 경우에도 다시 Date를 생성해서 만든다.

public class Period {
  private final Date start;
  private final Date end;
  public Period(Date start, Date end) {
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());
  }

  public Date getStart() {
    return new Date(start.getTime());
  }

  public Date getEnd() {
    return new Date(end.getTime());
  }
}

위와 같이 경우에는 좀 더 안전한 클래스가 된다. 가능한 불변의 객체를 만드는 것이 가장 이상적이다.

SimpleDateFormat

java 의 Date 클래스를 특정 포맷에 맞게 변환해주는 아주 괜찮은 API이다. 사용하기도 쉽게 개발자가 원하는 포맷 그대로 변환해 줘서 더할 것도 없다. 하지만 여기도 문제점이 많다.

@Test
public void simpleDateFormatTest()  {
  Date date = new Date();
  SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  String now = format.format(date);
  try {
    format.parse(now);
  } catch (ParseException e) {
    e.printStackTrace();
  }
}

일단 첫 번째로 불필요한 checked exception을 사용한다. 굳이 저기에 checked exception 을 던질 필요가 있나 싶다. 그리고 제대로 되지 않는 값을 넘겨도 exception을 던지지 않는다. 그럼 뭐하러 checked exception으로 던지는지 의문이다. checked exception은 자제하는 것이 좋다. 아니 특별한 이유가 아니라면 쓰지 않는게 정신건강상 좋다.

@Test
public void simpleDateFormatTest()  {
  try {
    Date parse = format.parse("2012-18-22 33:11:37");
    System.out.println(parse);
  } catch (ParseException e) {
    e.printStackTrace();
  }
}

위 코드를 보긴엔 이상한 코드이다. 우리 달력에는 18월달이 없으므로 에러를 내뱉는다고 생각할 수도 있다. 그게 ParseException의 목적이 아닌가? 생각되는데 말이다. 하지만 이 코드는 정상 작동하며 실제로는 2013년 6월이 출력된다.

SimpleDateFormat 클래스의 두번째 문제인데 Date 클래스와 동일하다. 멀티 스레드에 안전하지 못하다. 가장 큰 문제가 아닌가 싶다. SimpleDateFormat 문서에는 아래와 같은 주의가 있다.

Date formats are not synchronized.
It is recommended to create separate format instances for each thread.
If multiple threads access a format concurrently, it must be synchronized externally.

Date formats은 동기화되지 않는다. 각각의 스레드별로 인스턴스를 만드는 것이 낫다. 만약 multiple threads가 동시에 접근할 경우에는 외부에서 동기화 해야 한다. 라고 설명한다.

아래의 예를 살펴보자.

private SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

@Test
public void threadNotSafeTest() {
  Runnable r[] = new Runnable[10];
  for (int i = 0; i < r.length; i++) {
    r[i] = () -> {
        String str = "2017-06-18 20:12:22";
        try {
          Date d = format.parse(str);
          String str2 = format.format(d);
        } catch (ParseException e) {
          e.printStackTrace();
        }
    };
    new Thread(r[i]).start();
  }
}

thread 10개를 생성하여 실행하였다. 하지만 이 코드는 아래와 같은 에러를 발생한다.

java.lang.NumberFormatException: multiple points

멀티 스레드에서 안전하지 못하다. 이 경우에는 외부에서 동기화 시켜줘야 한다.

private SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

@Test
public void threadNotSafeTest() {
  Runnable r[] = new Runnable[10];
  for (int i = 0; i < r.length; i++) {
    r[i] = () -> {
      String str = "2017-06-18 20:12:22";
      synchronized (format) {
        try {
          Date d = format.parse(str);
          String str2 = format.format(d);
        } catch (ParseException e) {
          e.printStackTrace();
        }
      }
    };
    new Thread(r[i]).start();
  }
}

그레고리력

그레고리력은 현재 세계적으로 통용되는 양력으로, 1582년에 교황 그레고리오 13세가 율리우스력을 개정하여 이 역법을 시행했기 때문에 그레고리력이라고 부른다. 율리우스력의 1년 길이는 365.25일이므로 천문학의 회귀년 365.2422일보다 0.0078일(11분 14초)이 길어서 128년마다 1일의 편차가 났다. 위키 출처. 자세한 내용은 위키를 참고하길 바란다.

율리우스력에 의해 누적된 오차를 교정하기 위해 10일 정도를 건너 뛰었다. 그래서 1582년 10월 4일 다음일이 1582년 10월 15일이다.

@Test
public void gregorianCalendarTest() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(1582, Calendar.OCTOBER , 4);
  calendar.add(Calendar.DATE, 1);
  SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
  String gregorian = format.format(calendar.getTime());
  assertThat(gregorian).isEqualTo("1582-10-05");
}

위와 같이 1582년 10월 4일 다음날을 계산하는 코드이다. 당연히 성공할 것 처럼 보이지만 테스트는 실패를 한다. 왜냐하면 java의 Date는 그레고리력을 이용해서 계산하기 때문에 1582년 10월 4일 다음날은 1582년 10월 15일이다. 그래서 위의 코드는 아래 코드처럼 변경해야 된다.

assertThat(gregorian).isEqualTo("1582-10-15");

java의 Date 클래스는 이렇게 많은 문제점들이 있다. 이 외에도 timeZone Id를 만들때도 이상한 값을 넣어도 에러를 내뱉지 않고 java.sql의 Date와 java.util의 Date가 클래스명이 같다(java.sql.Date는 java.util.Date를 상속 받고 있다.)는 이유로 이름을 지을때 깜빡 존 듯하다는 조롱까지 나왔다.

이렇게 java의 Date 클래스는 많은 문제가 있다. 되도록이면 사용하지 않는 것을 추천하고 java8에 나온 LocalDateTime이라는 클래스가 위의 모든 문제들을 해결했다고 해도 과언이 아니다. 만약 java8을 쓰지 못한다면 joda-time을 사용하길 권장한다. 물론 joda time 만드신 분이 오라클 주도하에 java8의 LocalDateTime을 만들어서 비슷한 구석이 많다. android 개발자들은 date4j를 사용하길 추천한다. joda-time 보다 가벼워서 android 개발자한테는 joda-time보다는 좀 더 낫지 않을까 생각된다.

이렇게 오늘 java의 Date 클래스의 문제점들을 살펴봤다.