1. 정렬하고자 하는 객체에  Comparable 인터페이스를 구현한다.

2. Collections.sort 함수로 정렬한다.



public class SwitchInfo  implements Comparable<SwitchInfo> {


private int id;

private double   power;

public SwitchInfo(int id ){

this.id = id;

}


public double getPower() {

return power;

}


public void setPower(double power) {

this.power = power;

}


@Override

public int compareTo(SwitchInfo si) {

if (this.power >  si.power) {   // 내림차순 , 오름차순으로 하려면 < 으로~

return -1;

} else if (this.power == si.power) {

return 0;

} else {

return 1;

}

}

}



ArrayList<SwitchInfo> switches =  group.getSwitches();


Collections.sort(switches);  // Power 를 내림차순으로  정렬


 종종 어플리케이션은 백그라운드에서 정해진 시간간격으로 특정한 일을 하고 싶을때가 있습니다. 그때 사용하도록 아래와 같이 3가지 다른 스케쥴링하는 방법을 소개합니다.

  • simple thread 
  • TimerTask 
  • ScheduledExecutorService

1. simple thread

굳이 다른거 공부할필요없이 쓰레드를 직접 만들어서 단순하고 직관적으로 동작하게함. 

쓰레드 실행시키고 무한루프 돌면서리 run 내부에 sleep 으로 대기

01.public class Task1 {
02.public static void main(String[] args) {
03.
04.final long timeInterval = 1000;
05.Runnable runnable = new Runnable() {
06.public void run() {
07.while (true) {
08.// ------- code for task to run
09.System.out.println("Hello !!");
10.// ------- ends here
11.try {
12.Thread.sleep(timeInterval);
13.catch (InterruptedException e) {
14.e.printStackTrace();
15.}
16.}
17.}
18.};
19.Thread thread = new Thread(runnable);
20.thread.start();
21.}
22.}


2. the Timer and TimerTask

1번것은 매우 빠르고 쉬운데 몇가지 기능들이 없어서 불편을 초래할수도있는데 TimerTask 는 아래와 같은 기능을 추가해준다.

  • 태스크를 시작할때와 취소할때를 통제할수있게함.
  • 처음 시작할때 타이밍을 원하는데로 할수있음. 딜레이시킨다거나..

스케쥴링이 목적인 Timer class 와 태스크를 감싸는데 사용하는 TimerTask 클래스가 있는데 TimerTask 안에는 실행을 위한 run() 메소드가 있다. Timer 인스턴스는 여러가지 테스크를 실행할수 있으며 thread-safe 하다.

Timer 생성자가 불리워지면 , 하나의 쓰레드를 생성하고 이것은 태스크를 스케쥴링하기위해 사용된다. 여기서는  Timer#scheduleAtFixedRate 를 사용하였다.

01.import java.util.Timer;
02.import java.util.TimerTask;
03.public class Task2 {
04.public static void main(String[] args) {
05.
TimerTask task = new TimerTask() {
06.@Override
07.public void run() {
08.// task to run goes here
09.System.out.println("Hello !!!");
10.}
11.};
12.
Timer timer = new Timer();
13.long delay = 0;
14.long intevalPeriod = 1 1000;
15.// schedules the task to be run in an interval
16.timer.scheduleAtFixedRate(task, delay,
17.intevalPeriod);
18.// end of main
19.}

 JDK 1.3 부터 지원됨.

3. ScheduledExecutorService

이것은 Java SE5 의  java.util.concurrent 에서 소개된 유틸인데,  아래와 같은 기능상 

이득을 취할수있다.

  • Timer 들의 싱글쓰레드와 비교하여 쓰레드풀로서 실행된다.
  • 처음 실행시 딜레이를 제공하며 매우 유연하다.
  • 타임 인터벌을 제공하기위해  멋진 conventions 을 제공한다.
  • 보다 정확한 타임 인터벌 의 태스크 수행 

 ScheduledExecutorService#scheduleAtFixedRate 를 사용하였다.

01.import java.util.concurrent.Executors;
02.import java.util.concurrent.ScheduledExecutorService;
03.import java.util.concurrent.TimeUnit;
04.public class Task3 {
05.public static void main(String[] args) {
06.Runnable runnable = new Runnable() {
07.public void run() {
08.// task to run goes here
09.System.out.println("Hello !!");
10.}
11.};
12.ScheduledExecutorService service = Executors
13..newSingleThreadScheduledExecutor();
14.service.scheduleAtFixedRate(runnable, 01, TimeUnit.SECONDS);
15.}
16.}


4. scheduleAtFixedRate()   /   scheduleWithFixedDelay()의   차이


service.scheduleAtFixedRate(runnable, 0, 30, TimeUnit.SECONDS);

//0   : 시작 딜레이 (바로 시작하라) 
//30  : 3초에 한번 실행 


- scheduleAtFixedRate() 
 시작딜레이시  첫번째 실행을 시작으로 지정한 차이만큼 딜레이를 가지고 반복해서  실행 한다.

scheduleWithFixedDelay() 
시작딜레이(initialDelay) 이후 첫번째 실행을 시작으로 해당 command의 동작이 종료된 이후 다음 실행 시간까지 지정한 시간만큼 딜레이를 가지면서 반복 실행된다.


'Java' 카테고리의 다른 글

자바의 런타임 계열 예외와 checked 예외  (0) 2015.05.15
Java ArrayList 객체 정렬  (0) 2015.05.14
Java Time/ Date / Calendar example  (0) 2015.05.13
Java Time,Data 클래스의 문제점과 JAVA 8  (0) 2015.05.13
자바 List 순회  (0) 2015.05.12

// 간단한 현재 날짜/시간 얻기


import java.util.Date;

  

public class DateDemo {

   public static void main(String args[]) {

       

   Date date = new Date();  

       System.out.println(date.toString());

   }

}


Mon May 04 09:51:52 CDT 2009




// 날짜/시간  데이터 포맷팅 


import java.util.*;

import java.text.*;


public class DateDemo {

   public static void main(String args[]) {


      Date dNow = new Date( );

      SimpleDateFormat ft =  new SimpleDateFormat (

"E yyyy.MM.dd 'at' hh:mm:ss a zzz");


      System.out.println("Current Date: " + ft.format(dNow));

   }

}


Current Date: Sun 2004.07.18 at 04:14:09 PM PDT




// Printf 를 이용한 데이터 포맷팅 !


import java.util.Date;


public class DateDemo {


  public static void main(String args[]) {

     // Instantiate a Date object

     Date date = new Date();


     // display time and date using toString()

     String str = String.format("Current Date/Time : %tc", date );


     System.out.printf(str);

  }

}

Current Date/Time : Sat Dec 15 16:37:57 MST 2012




//Calendar 를 통한  시간 비교 ( 현재 시간이  과거에 기록된 시간에  비해 몇초 지났나? ) 

public boolean isAdjustedTarget(int reforceTimeSec){
if(isAdjusted == false)  // 수요조절된적이 없으면 조절대상이 됨. 
return true;
Calendar currentDate = Calendar.getInstance();
System.out.println(currentDate.get(Calendar.YEAR));
System.out.println(currentDate.get(Calendar.MONTH) + 1);
System.out.println(currentDate.get(Calendar.DATE));
System.out.println(currentDate.get(Calendar.HOUR));
System.out.println(currentDate.get(Calendar.MINUTE));
System.out.println(currentDate.get(Calendar.SECOND));
if((currentDate.getTimeInMillis() - endAdjustedTime.getTimeInMillis())  < (reforceTimeSec * 1000))
return false;
return true;
}






Simple DateFormat format codes:


CharacterDescriptionExample
GEra designatorAD
yYear in four digits2001
MMonth in yearJuly or 07
dDay in month10
hHour in A.M./P.M. (1~12)12
HHour in day (0~23)22
mMinute in hour30
sSecond in minute55
SMillisecond234
EDay in weekTuesday
DDay in year360
FDay of week in month2 (second Wed. in July)
wWeek in year40
WWeek in month1
aA.M./P.M. markerPM
kHour in day (1~24)24
KHour in A.M./P.M. (0~11)10
zTime zoneEastern Standard Time
'Escape for textDelimiter
"Single quote`



Date and Time Conversion Characters:

CharacterDescriptionExample
cComplete date and timeMon May 04 09:51:52 CDT 2009
FISO 8601 date2004-02-09
DU.S. formatted date (month/day/year)02/09/2004
T24-hour time18:05:19
r12-hour time06:05:19 pm
R24-hour time, no seconds18:05
YFour-digit year (with leading zeroes)2004
yLast two digits of the year (with leading zeroes)04
CFirst two digits of the year (with leading zeroes)20
BFull month nameFebruary
bAbbreviated month nameFeb
mTwo-digit month (with leading zeroes)02
dTwo-digit day (with leading zeroes)03
eTwo-digit day (without leading zeroes)9
AFull weekday nameMonday
aAbbreviated weekday nameMon
jThree-digit day of year (with leading zeroes)069
HTwo-digit hour (with leading zeroes), between 00 and 2318
kTwo-digit hour (without leading zeroes), between 0 and 2318
ITwo-digit hour (with leading zeroes), between 01 and 1206
lTwo-digit hour (without leading zeroes), between 1 and 126
MTwo-digit minutes (with leading zeroes)05
STwo-digit seconds (with leading zeroes)19
LThree-digit milliseconds (with leading zeroes)047
NNine-digit nanoseconds (with leading zeroes)047000000
PUppercase morning or afternoon markerPM
pLowercase morning or afternoon markerpm
zRFC 822 numeric offset from GMT-0800
ZTime zonePST
sSeconds since 1970-01-01 00:00:00 GMT1078884319
QMilliseconds since 1970-01-01 00:00:00 GMT1078884319047


There are other useful classes related to Date and time. For more details, you can refer to Java Standard documentation.

'Java' 카테고리의 다른 글

Java ArrayList 객체 정렬  (0) 2015.05.14
자바 스케쥴링 & 타이머 방법들  (0) 2015.05.14
Java Time,Data 클래스의 문제점과 JAVA 8  (0) 2015.05.13
자바 List 순회  (0) 2015.05.12
자바에서 Map 순회  (0) 2015.05.12

http://helloworld.naver.com/helloworld/textyle/645609 링크 


네이버 비즈니스 플랫폼 웹플랫폼개발랩 정상혁

Java의 기본 SDK에서 날짜와 시간을 다루는 java.util.Date 클래스와 java.util.Calendar 클래스는 사용하기 불편하다는 악평이 자자합니다. 이를 답답하게 여긴 사람들이 이 클래스를 대체하려고 Joda-Time 같은 오픈소스 라이브러리를 만들기도 했습니다. 많이 늦었지만 다행히 JDK 8에서는 개선된 날짜와 시간 API가 제공됩니다.

이 글에서는 Java의 날짜와 시간 API의 문제점이 무엇이었는지 되짚어 보고, 여러 오픈소스 라이브러리와 JDK 8에서는 문제점이 어떻게 개선되었는지 확인해 보겠습니다.

Java 클래스에 담긴 제도의 역사

우선 java.util.Calendar 클래스와 java.util.Date 클래스 등으로 간단한 예제를 작성해 보겠다. 평범한 예제보다는, 날짜와 시간 계산이 사회 제도나 과학과 복잡하게 얽혀있음을 보여주는 예제를 만들어 보았다.

1582년 10월 4일의 다음 날은?

<예제 1>에서는 UTC(Universal Time Coordinated, 세계협정시) 시간대를 기준으로 1582년 10월 4일에 하루를 더한 날짜가 10월 5일인 것을 테스트하고 있다. JUnit과 Fest Assertions 라이브러리[1]를 활용했다.

예제 1 1일 후 구하기

public class OldJdkDateTest {

@Test
public void shouldGetAfterOneDay() {
TimeZone utc = TimeZone.getTimeZone("UTC");
Calendar calendar = Calendar.getInstance(utc);
calendar.set(1582, Calendar.OCTOBER , 4);
String pattern = "yyyy.MM.dd";
String theDay = toString(calendar, pattern, utc);
assertThat(theDay).isEqualTo("1582.10.04");


calendar.add(Calendar.DATE, 1);
String nextDay = toString(calendar, pattern, utc);
assertThat(nextDay).isEqualTo("1582.10.05");
}

private String toString(Calendar calendar, String pattern, TimeZone zone) {
SimpleDateFormat format = new SimpleDateFormat(pattern);
format.setTimeZone(zone);
return format.format(calendar.getTime());
}

이 테스트는 실패한다. <예제 1>에서 계산한 1582년 10월 4일의 다음 날은 1582년 10월 15일이다. 따라서 마지막 줄을 다음과 같이 고쳐야 테스트를 통과한다.

예제 2 <예제 1>의 마지막 줄 수정

assertThat(nextDay).isEqualTo("1582.10.15");

1582년에서 실종된 10일은 그레고리력을 처음 적용하면서 율리우스력에 의해 그동안 누적된 오차를 교정하기 위해서 건너뛴 기간이다. 태양의 황경이 0도가 되는 춘분이 1582년에는 10일 정도 어긋나게 되었다. 교황 그레고리우스 13세는 더 정교한 그레고리력을 1582년 10월 15일에 처음 적용했고, 10월 5 ~ 14일의 날짜는 그 해 달력에서 제외시켰다. 율리우스력은 4년마다 윤년을 두지만, 그레고리력에서는 4년마다 윤년을 두되 매 100번째 해는 윤년이 아니고, 매 400번째 해는 윤년이라는 차이가 있다.

<예제 1>에서 Calendar.getInstance() 메서드는 java.util.GregorianCalendar 클래스의 인스턴스를 반환한다. GregorianCalendar 클래스는 그레고리력과 율리우스력을 같이 구현하고 있고, setGregorianChange() 메서드로 두 역법의 전환 시점을 지정할 수 있다. 그런데 AD 4년의 3월 1일 이전에는 윤년을 불규칙하게 두었기 때문에 GregorianCalendar 클래스로 구한 날짜는 정확하지는 않다. 이런 설명은 GregorianCalendar 클래스의 API 문서[2]에 나와 있다.

Calendar.getInstance() 메서드는 GregorianCalendar 외에도 Locale 정보에 따라서 JapaneseImperialCalendar, BuddhistCalendar 등도 반환한다. 역사와 천문학이 복합적으로 담긴 클래스라 할 만하다.

서울 1988년 5월 7일 23시의 1시간 후는?

<예제 3>에서는 'Asia/Seoul' 시간대에서 '1988.05.07 23:00'의 1시간 후가 '1988.05.08 00:00'임을 테스트하고 있다.

예제 3 1시간 후 구하기

@Test
public void shouldGetAfterOneHour() {
TimeZone seoul = TimeZone.getTimeZone("Asia/Seoul");
Calendar calendar = Calendar.getInstance(seoul);
calendar.set(1988, Calendar.MAY , 7, 23, 0);
String pattern = "yyyy.MM.dd HH:mm";
String theTime = toString(calendar, pattern, seoul);
assertThat(theTime).isEqualTo("1988.05.07 23:00");

calendar.add(Calendar.HOUR_OF_DAY, 1);
String after1Hour = toString(calendar, pattern, seoul);
assertThat(after1Hour).isEqualTo("1988.05.08 00:00");
}

이 테스트도 실패한다. 이상하게도 1시간 후는 5월 8일 새벽 1시이다. 이는 그 시기에 서울에 적용된 일광절약시간제(Daylight Saving Time), 즉 서머타임 때문이다. 서머타임이 시작되는 시점에서는 1시간을 건너뛴다. 해당 시간대가 서머타임 적용 시간대인지는 TimeZone.inDaylightTime() 메서드로 확인할 수 있다. <예제 3>의 마지막 2줄을 다음과 같이 바꾸면 테스트를 통과하고, 이 시간대에 일어난 일을 좀 더 잘 설명할 수 있다.

예제 4 <예제 3>의 마지막 줄 수정

assertThat(seoul.inDaylightTime(calendar.getTime())).isTrue(); 
assertThat(after1Hours).isEqualTo("1988.05.08 01:00");

그러나 이 결과에도 여전히 의문이 남는다. 위키백과에 정리된 한국 표준시 자료[3]나 과거의 보도 기사[4]를 찾아보면 그 해 서머타임이 시작된 시간은 5월 8일 새벽 2시였다. 시간대 데이터베이스(timezone database)의 오류일까? 아니면 시간대 데이터베이스에서 기록한 시간에 특별한 이유가 있는 것일까? 아직 정확한 답은 찾지 못했다.

우리나라는 1988년 이후로는 지금까지 서머타임을 실시하지 않고 있지만 2009년에 정부가 적극적으로 도입을 검토하기도 했었다.[5] 그리고 미국은 2007년부터 서머타임을 한 달 더 늘려서 시행하고 있다. 이렇게 서머타임은 국가마다 계속 변화하는 제도인데 Java는 그런 데이터를 어디에서 참조하고 있을까?

앞에서 말한 시간대 데이터베이스라는 곳에 그런 데이터가 정리된다. tzdata, IANA Time Zone database, Olson database 등 다양한 이름으로 불리는 이 정보는 Java 외에도 Oracle, PHP 등 다양한 플랫폼에서 활용되는 국제 표준 데이터이다. Java는 운영체제에 의존하지 않고 독립적으로 시간대 데이터를 업데이트한다. 즉, 운영체제의 시간대 패치가 업데이트되지 않아도 Java는 최신 정보를 유지할 수 있고, 운영체제의 시간대 패치는 Java의 시간대 정보에 영향을 미칠 수 없다. 그리고 TZUpdater라는 도구로 JRE 전체를 업그레이드하지 않고 시간대 데이터만 최신으로 갱신하는 방식도 지원한다.[6]

JRE의 최신 시간대 데이터의 변경 이력을 보면 요르단, 리비아 등에서 일어난 변경 사항이 2013년에도 반영된 것을 확인할 수 있다.[7] 아마 우리나라 제도에 변화가 생긴다면 이 데이터가 잘 갱신되었는지 유심히 살펴봐야 할 것이다.

서울 1961년 8월 9일 23시 59분의 1분 후는?

<예제 5>는 '1961.08.09 23:59'의 1분 후가 '1961.08.10 00:00'임을 테스트하고 있다.

예제 5 1분 후 구하기

@Test
public void shouldGetAfterOneMinute() {
TimeZone seoul = TimeZone.getTimeZone("Asia/Seoul");
Calendar calendar = Calendar.getInstance(seoul);
calendar.set(1961, Calendar.AUGUST, 9, 23, 59);
String pattern = "yyyy.MM.dd HH:mm";
String theTime = toString(calendar, pattern, seoul);
assertThat(theTime).isEqualTo("1961.08.09 23:59");

calendar.add(Calendar.MINUTE, 1);
String after1Minute = toString(calendar, pattern, seoul);
assertThat(after1Minute).isEqualTo("1961.08.10 00:00");
}

이 테스트도 실패한다. 신기하게도 23시 59분의 1분 후는 0시 30분이다. 다음과 같이 마지막 줄을 수정하면 테스트를 통과할 수 있다.

예제 6 <예제 5>의 마지막 줄 수정

assertThat(after1Minute ).isEqualTo("1961.08.10 00:30"); 

1961년 8월 10일은 대한민국의 표준시가 UTC+8:30에서 현재와 같은 UTC+9:00로 변경된 시점이다. 일제 강점기 동안 UTC+9:00이었던 표준시가 해방 이후 1954년에 UTC+8:30으로 바뀌었다가 1961년에 다시 UTC+9:00으로 바뀐다.[8]이 표준시 변경 때문에 30분을 건너뛰게 된 것이다.

1961년 당시 최고 권력 기관이었던 국가재건최고회의는 표준시를 일본과 동일하게 바꾸기로 결정했다. 최근에는 일제의 잔재에서 벗어난다는 의미에서 다시 한 번 표준시를 바꾸자는 의견도 나오고 있다.[9] 우리나라 근현대사와 관련이 있는 예제라고 할 만하다.

협정세계시 2012년 6월 30일 23시 59분 59초의 2초 후는?

<예제 7>은 UTC(협정세계시) '2012.06.30 23:59:59'의 2초 후가 '2012.07.01 00:00:01'인 것을 테스트하고 있다.

예제 7 2초 후 구하기

@Test
public void shouldGetAfterTwoSecond() {
TimeZone utc = TimeZone.getTimeZone("UTC");
Calendar calendar = Calendar.getInstance(utc);
calendar.set(2012, Calendar.JUNE, 30, 23, 59, 59);
String pattern = "yyyy.MM.dd HH:mm:ss";
String theTime = toString(calendar, pattern, utc);
assertThat(theTime).isEqualTo("2012.06.30 23:59:59");

calendar.add(Calendar.SECOND, 2);
String afterTwoSeconds = toString(calendar, pattern, utc);
assertThat(afterTwoSeconds).isEqualTo("2012.07.01 00:00:01");
}

지금까지의 예제와는 다르게 위의 테스트는 잘 통과한다. 별로 특별할 것이 없다면 이번 예제는 왜 넣었을까? 2012년 6월 30일은 가장 최근에 '윤초'가 적용된 때이다. 즉 <예제 7>의 결과는 윤년이나 서머타임과는 달리 Java에서 윤초가 Calendar 연산에 적용되지 않는다는 것을 보여 준다.

윤년보다는 다소 낯선 윤초가 필요한 이유는 다음과 같다. UTC는 세슘 원자의 진동수에 바탕을 둔 원자시계가 기준이고, UT1(세계시)은 지구의 움직임을 관찰한 결과가 기준이다. 원자시계는 일정한 반면에 지구의 움직임은 미세하게나마 불규칙적이므로 이 둘 사이에는 오차가 발생한다. 윤초는 그 둘의 오차를 보정하기 위하여 추가하는 1초이다. 국제지구자전사업(IERS, International Earth Rotation Service)이라는 기관에서 윤초 수정에 대해 결정한다.

최근에는 윤초를 폐지하자는 주장도 일어나고 있다.[10] 윤초가 컴퓨터 시스템에서 복잡한 문제를 일으킬 수 있기 때문이다. 대부분의 시스템에서 시간은 데이터 정렬, 복제에 핵심적인 키 역할을 한다. 윤초의 적용으로 만약 같은 초가 반복된다면 그 사이의 데이터가 엉킬 가능성이 크다.

윤초가 마지막으로 적용된 2012년 6월 30일에는 RedditFoursquareYelpLinkedIn 등 많은 기업이 장애를 겪었다. Linux + Java 환경의 시스템이 많았고, Cassandra, Hadoop, Elasticsearch 등 데이터 저장, 검색 플랫폼에서 CPU를 100% 사용하는 문제가 발생했다고 한다.[11] 국내에서도 Hadoop을 사용하는 시스템에서 유사한 문제가 많이 발생했다.[12] Linux 커널과 Java의 복합적인 문제로 추정된다. 대부분 시간 재설정, 서버 재시작 등으로 이 문제를 해결한 듯하다. Google은 윤초 적용 이전에 점진적으로 시간을 더해가는 'leap smear'라는 기법으로 이런 장애를 예방했다.[13]

API 문서에 따르면 Date 클래스가 UTC를 정확히 반영하는지 여부는 JVM(Java Virtual Machine)의 실행 환경에 따라 다르다고 한다. 대부분의 현대적인 운영체제에서 모든 경우에 하루는 86,400초(24 × 60 × 60초)이고, 컴퓨터 시계의 대부분은 윤초를 반영할 정도로 정교하지는 못하다고 언급하고 있다.[14] 참고로 System.currentTimeMillis() 메서드는 1970년 1월 1일 이후로 지나간 밀리초를 반환하는데, Windows, Android 등의 운영체제에서 테스트한 결과로는 그 기간 중의 윤초가 특별히 더해지진 않는다.[15]

뒤에서 소개할 Joda-Time과 JSR-310을 포함해서, 모든 환경에서 윤초를 명시적으로 지원하는 Java 라이브러리는 아직 보이지 않는다. 어쨌든 윤초 동기화 때는 운영체제, Java, 미들웨어, 애플리케이션의 상호작용이 불안정해질 가능성이 높다는 점은 분명하다.

JDK의 기본 날짜 클래스의 문제점

앞의 예제로 날짜와 시간 계산이 생각보다 어렵고 고려해야 할 것도 많으며 깊이 이해하기 위해서는 배경 지식도 많이 필요한 영역임을 확인했다. 그런데 이 분야의 어려움은 별도로 치더라도 Calendar 클래스와 Date 클래스는 문제가 많다.

불변 객체가 아니다( not immutable)

VO(Value Object)는 값에 의해 동등성이 판단되는 객체이다.[16] VO는 완전한 불변 객체일 때 별칭 문제, 스레드 불안정성 등의 부작용에서 자유롭고 여러 객체에서 공유되어도 안전하다.[17] 날짜, 돈 등의 객체는 VO의 대표적인 예로 자주 제시된다. C#, Python 같은 언어에서는 날짜 클래스가 한번 생성된 이후에는 내부 속성을 바꿀 수 없다.

불행히도 Java의 기본 날짜, 시간 클래스는 불변 객체가 아니다. 앞의 코드에서 Calendar 클래스에 set 메서드를 호출해서 날짜를 지정하고, 다시 같은 객체에 set(int,int) 메서드를 호출해서 수행한 날짜 연산 결과는 같은 인스턴스에 저장되었다. Date 클래스에도 값을 바꿀 수 있는 set 메서드가 존재한다. 이 때문에 Calendar 객체나 Date 객체가 여러 객체에서 공유되면 한 곳에서 바꾼 값이 다른 곳에 영향을 미치는 부작용이 생길 수 있다. 『Effective Java 2nd Edition』(2008)의 저자 Joshua Bloch도 Date 클래스는 불변 객체여야 했다고 지적했다.[18]

이를 안전하게 구현하려면 이들 객체를 복사해서 반환하는 기법을 권장한다. <그림 1>에서 보이는 코드의 startTime 필드는 내부의 Date 객체를 외부에서 조작할 수 있기 때문에 악의적인 클라이언트 코드에 의해서 착취당할 수 있다. endTime 필드처럼 방어복사 기법을 써서 새로운 객체를 생성해서 반환하는 구현이 바람직하다.[19]

b5ea6781add0d12bcf67d7dd07f0281f.png

그림 1 Date의 방어 복사 기법과 FindBugs의 취약점 지적

이런 취약점은 정적분석 도구로 발견해낼 수도 있다. FindBugs의 다음 규칙은 <그림 1>의 startTime 필드와 같이 취약한 코드를 경고해 준다.

  • EI_EXPOSE_REP[20]
  • EI_EXPOSE_REP2[21]

<그림 1>에서 취약성이 있는 코드의 아래에 추가한 주석은 FindBugs에서 보여 주는 경고문를 그대로 옮긴 문장이다.

int 상수 필드의 남용

Calendar를 사용한 날짜 연산은 <예제 8>과 같이 int 상수 필드를 사용한다.

예제 8 초 더하기 코드

calendar.add(Calendar.SECOND, 2); 

첫 번째 파라미터에 Calendar.JUNE과 같이, 전혀 엉뚱한 상수가 들어가도 이를 컴파일 시점에서 확인할 방법이 없다. 이 뿐만 아니라 Calendar 클래스에는 많은 int 상수가 쓰였는데, 이어서 설명할 월, 요일 지정 등에서도 많은 혼란을 유발한다.

헷갈리는 월 지정

앞에서 1582년 10월 4일을 지정하는 코드는 다음과 같았다.

예제 9 10월 지정 코드

calendar.set(1582, Calendar.OCTOBER , 4); 

그런데 월에 해당하는 Calendar.OCTOBER 값은 실제로는 '9'이다. JDK 1.0에서 Date 클래스는 1월을 0으로 표현했고, JDK 1.1부터 포함된 Calendar 클래스도 이러한 관례를 답습했다. 그래서 1582년 10월 4일을 표현하는 코드를 다음과 같이 쓰는 실수를 많은 개발자들이 반복하고 있다.

예제 10 실수로 쓰기 쉬운 10월 지정 코드

calendar.set(1582, 10 , 4); 

또는 일부러 가독성을 높이기 위해서 10월을 10-1로 표현한 기법을 쓰는 사람도 있다.

예제 11 10월 지정 코드의 한 방식

calendar.set(1582, 10 - 1 , 4); 

<예제 12>는 이로 인해서 일어날 수 있는 실수를 보여 주는 코드이다.[22] <예제 12>의 테스트는 통과한다.

예제 12 1999년 12월 31일을 지정하려다 2000년으로 넘어간 코드

@Test
public void shouldGetDate() {
Calendar calendar = Calendar.getInstance();
calendar.set(1999, 12, 31);
assertThat(calendar.get(Calendar.YEAR)).isEqualTo(2000);
assertThat(calendar.get(Calendar.MONTH)).isEqualTo(Calendar.JANUARY);
assertThat(calendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(31);
}

1999년 12월 31일을 지정하려 했으나, 12월의 상수값은 11이므로 직접 숫자 12를 대입하면 2000년 1월 31일로 넘어간다. 숫자 12 대신 11 혹은 Calendar.DECEMBER 상수로 지정해야 1999년 12월 31일이 된다.

13월을 의미하는 12를 넣어도 Calendar.set() 메서드가 오류를 반환하지 않기 때문에 이런 실수를 인지하기 더욱 어렵다. calendar.setLenient(false) 메서드를 호출하면 잘못된 월이 지정된 객체에서 IllegalArgumentException을 던져 준다. 그렇게 지정해도 Calendar.set() 메서드가 호출되는 시점이 아니라, Calendar.get() 메서드가 호출될 때 Exception이 발생한다는 점도 주의해야한다.

참고로 FindBugs에서는 0 ~ 11을 벗어난 월을 지정할 때 경고를 보여 주기도 한다.

33a08b5a6c7868b3c54ffa8d048421f9.png

그림 2 Calendar에 잘못된 월 지정 코드와 FindBugs의 경고

일관성 없는 요일 상수

<예제 13>은 2013년 1월 1일이 수요일임을 확인하는 코드이다.

예제 13 요일 확인하기

@Test
@SuppressWarnings("deprecation")
public void shouldGetDayOfWeek() {
Calendar calendar = Calendar.getInstance();
calendar.set(2014, Calendar.JANUARY, 1);

int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
assertThat(dayOfWeek).isEqualTo(Calendar.WEDNESDAY);
assertThat(dayOfWeek).isEqualTo(4);
Date theDate = calendar.getTime();
assertThat(theDate.getDay()).isEqualTo(3);
}

Calendar.get(Calendar.DAY_OF_WEEK) 함수에서 반환한 요일은 int 값으로, 일요일이 1로 표현된다. 따라서 수요일은 4이고, 보통 Calendar.WEDNESDAY 상수와 비교해서 확인한다. 그런데 calendar.getTime() 메서드로 Date 객체를 얻어와서 Date.getDay() 메서드로 요일을 구하면 일요일은 0, 수요일은 3이 된다. 두 개의 클래스 사이에 요일 지정값에 일관성이 없는 것이다.

Date.getDay() 메서드는 요일을 구하는 메서드로는 이름이 모호하기도 하다. 현재는 사용하지 않는(deprecated) 메서드라서 그나마 다행이다.

Date와 Calendar의 불편한 역할 분담

JDK 1.0 시절에는 Date 클래스가 날짜 연산을 지원하는 유일한 클래스였다. JDK 1.1 이후부터 Calendar 클래스가 포함되면서 날짜간의 연산, 국제화 지원 등은 Calendar 클래스에서 주로 담당하고 Date 클래스의 많은 기능이 사용되하지 않게(deprecated)되었다.

특정 시간대의 날짜를 생성한다거나, 년/월/일 같은 날짜 단위의 계산은 Date 클래스만으로는 수행하기 어렵기 때문에 날짜 연산을 위해서 Calendar 객체를 생성하고, 다시 Calendar 객체에서 Date 객체를 생성한다. 최종 결과에는 불필요한 중간 객체를 생성해야 하는 셈인데, 쓰기에도 번거롭고, Calendar 클래스는 생성 비용이 비싼 편이기 때문에 비효율적이기도 하다.[23] 불편함을 덜기 위해 실무에서는 Date의 연산에 Apache commons Lang 라이브러리에 있는 DateUtils 클래스의 plusDays() 메서드나 plusMonth() 메서드 같은 메서드를 주로 활용한다. 그러나 DateUtils 클래스를 쓰더라도 중간 객체로 Calendar를 생성하는 것은 마찬가지다.

날짜와 시간을 모두 저장하는 클래스의 이름이 'Date'라는 점도 다소 아쉽다. Calendar.getTime() 메서드도 Date 타입을 반환하는데 메서드 이름만 봐서는 반환 타입을 예측하기가 힘들다.

오류에 둔감한 시간대 ID지정

<예제 14>는 시간대의 ID를 'Asia/Seoul'대신 'Seoul/Asia'로 잘못 지정한 코드다.

예제 14 잘못 지정한 시간대 ID

@Test
public void shouldSetGmtWhenWrongTimeZoneId(){
TimeZone zone = TimeZone.getTimeZone("Seoul/Asia");
assertThat(zone.getID()).isEqualTo("GMT");
}

그러나 이 코드는 오류가 발생하지 않고, 'GMT'가 ID인 시간대가 지정된 것처럼 테스트를 통과한다. 이런 특성 때문에 찾기 어려운 버그가 생길 수도 있다.

java.util.Date 하위 클래스의 문제

java.util.Date 클래스를 상속한 하위 클래스에도 문제가 많다.

java.sql.Date 클래스는 상위 클래스인 java.util.Date 클래스와 이름이 같다. 이 클래스를 두고 Java 플랫폼 설계자는 클래스 이름을 지으면서 깜빡 존 듯하다는 조롱까지 나왔다.[24] 그리고 이 클래스는 Comparable 인터페이스에 대한 정의를 클래스 선언에서 하지 않았기 때문에 Comparable과 관련된 Generics 선언을 복잡하게 만들었다.[25]

java.sql.TimeStamp 클래스는 java.util.Date 클래스에 나노초(nanosecond) 필드를 더한 클래스이다. 이 클래스는 equals() 선언의 대칭성을 어겼다. Date 타입과 TimeStamp 타입을 섞어 쓰면 a.equals(b)가 true라도 b.equals(a)는 false인 경우가 생길 수 있다.[26]

Java의 개선된 날짜, 시간 API

좋은 API는 오용하기 어려워야 하고, 문서가 없어도 쉽게 사용할 수 있어야 한다.[27] 그러나 Java의 기본 API는 문서를 열심히 보기 전까지는 제대로 사용하기 어렵다.

이런 문제점 때문에 JDK의 날짜, 시간 API를 대체하는 라이브러리가 많이 나와 있다. 대표적으로 다음과 같은 것들이 있다.

Joda-Time

Joda-Time은 기본 JDK를 대체하는 날짜와 시간 API 중 가장 널리 쓰인다. 앞에서 나왔던 <예제 1>, <예제 3>, <예제 5>, <예제 7>, <예제 12>, <예제 13>, <예제 14> 등을 테스트를 통과하는 상태로 Joda-Time으로 옮기면 다음과 같다.

예제 15 Joda-Time으로 날짜 연산

public class JodaTimeTest {

@Test // 예제1, 2: 1일 후 구하기
public void shouldGetAfterOneDay() {
Chronology chrono = GregorianChronology.getInstance();
LocalDate theDay = new LocalDate(1582, 10, 4, chrono);
String pattern = "yyyy.MM.dd";
assertThat(theDay.toString(pattern)).isEqualTo("1582.10.04");

LocalDate nextDay = theDay.plusDays(1);
assertThat(nextDay.toString(pattern)).isEqualTo("1582.10.05");
}

@Test // 예제1, 2: 1일 후 구하기.
public void shouldGetAfterOneDayWithGJChronology() {
Chronology chrono = GJChronology.getInstance();
LocalDate theDay = new LocalDate(1582, 10, 4, chrono);
String pattern = "yyyy.MM.dd";
assertThat(theDay.toString(pattern)).isEqualTo("1582.10.04");

LocalDate nextDay = theDay.plusDays(1);
assertThat(nextDay.toString(pattern)).isEqualTo("1582.10.15");
}

@Test // 예제3, 4: 1시간 후 구하기
public void shouldGetAfterOneHour() {
DateTimeZone seoul = DateTimeZone.forID("Asia/Seoul");
DateTime theTime = new DateTime(1988,5,7,23,0, seoul);
String pattern = "yyyy.MM.dd HH:mm";
assertThat(theTime.toString(pattern)).isEqualTo("1988.05.07 23:00");
assertThat(seoul.isStandardOffset(theTime.getMillis())).isTrue();

DateTime after1Hour = theTime.plusHours(1);
assertThat(after1Hour.toString(pattern)).isEqualTo("1988.05.08 01:00");
assertThat(seoul.isStandardOffset(after1Hour.getMillis())).isFalse();
}

@Test // 예제 5, 6: 1분 후 구하기
public void shouldGetAfterOneMinute() {
DateTimeZone seoul = DateTimeZone.forID("Asia/Seoul");
DateTime theTime = new DateTime(1961, 8, 9, 23, 59, seoul);
String pattern = "yyyy.MM.dd HH:mm";
assertThat(theTime.toString(pattern)).isEqualTo("1961.08.09 23:59");

DateTime after1Minute = theTime.plusMinutes(1);
assertThat(after1Minute.toString(pattern)).isEqualTo("1961.08.10 00:30");
}


@Test // 예제 7: 2초 후 구하기
public void shouldGetAfterTwoSecond() {
DateTimeZone utc = DateTimeZone.forID("UTC");
DateTime theTime = new DateTime(2012, 6, 30, 23, 59, 59, utc);
String pattern = "yyyy.MM.dd HH:mm:ss";
assertThat(theTime.toString(pattern)).isEqualTo("2012.06.30 23:59:59");

DateTime after2Seconds = theTime.plusSeconds(2);
assertThat(after2Seconds.toString(pattern)).isEqualTo("2012.07.01 00:00:01");
}


@Test // 예제 12: 1999년 12월 31일을 지정하는 코드
public void shouldGetDate() {
LocalDate theDay = new LocalDate(1999, 12, 31);

assertThat(theDay.getYear()).isEqualTo(1999);
assertThat(theDay.getMonthOfYear()).isEqualTo(12);
assertThat(theDay.getDayOfMonth()).isEqualTo(31);
}

@Test (expected=IllegalFieldValueException.class) // 예제 12 : 1999년 12월 31일을 지정하는 코드의 실수
public void shouldNotAcceptWrongMonth() {
new LocalDate(1999, 13, 31);
}

@Test // 예제 13: 요일 확인하기
public void shouldGetDayOfWeek() {
LocalDate theDay = new LocalDate(2014, 1, 1);

int dayOfWeek = theDay.getDayOfWeek();
assertThat(dayOfWeek).isEqualTo(DateTimeConstants.WEDNESDAY);
assertThat(dayOfWeek).isEqualTo(3);
}

@Test(expected=IllegalArgumentException.class) // 예제 14: 잘못 지정한 시간대 ID
public void shouldThrowExceptionWhenWrongTimeZoneId(){
DateTimeZone.forID("Seoul/Asia");
}
}

<예제 15>에서 볼 수 있는 특징은 아래와 같다.

  • LocalDate, DateTime 등으로 지역 시간과 시간대가 지정된 시간을 구분했다. LocalDate와 LocalTime으로 날짜와 시간을 별도의 클래스로 구분할 수도 있다.
  • plusDays, plusMinutes, plusSeconds 등 단위별 날짜 연산 메서드를 LocalDate, DateTime 클래스에서 지원한다. 메서드가 호출된 객체의 상태를 바꾸지 않고 새로운 객체를 반환한다. 불변 객체이다.
  • 월의 int 값과 명칭이 일치한다. 1월은 int 값 1이다.
  • GregorianChronology를 썼을 때는 1582년 10월을 특별하게 취급하지는 않는다. GJChronology를 사용하면 JDK의 GregorianCalendar와 같이 10월 4일 다음 날이 10월 15일로 나온다.
  • 서머타임 기간이면 DateTimeZone.isStandardOffset() 메서드의 반환값이 false이다.
  • 13월 같이 잘못 된 월이 넘어가면 객체 생성 시점에서 IllegalFieldValueException을 던진다.
  • 요일 상수는 일관되게 사용한다.
  • 잘못 된 시간대 ID 지정에는 IllegalArguementException을 던진다.

그밖에 Joda-Time에서는 시간 간격에 대한 개념을 섬세하게 정의하고 Duration, Period, Interval 등으로 역할을 분담한 클래스로 구현했다.

그레고리력과 율리우스력뿐만 아니라 불교, 이슬람교, 콥트교회, 에티오피아의 달력까지도 지원한다. 다양한 달력은 org.joda.time.chrono.BaseChronology 클래스의 하위 클래스로 구현되어 있다.

Joda-Time은 Jar 파일에 별도로 시간대 데이터베이스를 포함하고 있다. 그래서 JDK에서 참조하는 시간대 데이터베이스와는 별도로 정보를 갱신할 수도 있다. Jar의 압축을 해제하면 {root}/src/java/org/joda/time/tz/src 폴더에 시간대 정보 파일이 있다. 이 폴더를 덮어쓰면 특정 애플리케이션만의 시간대 정보를 정의할 수도 있다.

Joda-Time은 다음과 같이 다른 언어로 래핑되거나 포팅되어 있기도 하다.

Spring 프레임워크에서도 Joda-Time을 기본으로 지원한다. Spring-web-mvc 프레임워크는 사용자가 입력한 문자열을 원하는 객체로 변환할 때 Converter라는 인터페이스를 활용하는데, 클래스 패스에 Joda-Time이 포함되어 있으면 이 라이브러리의 객체를 변화하는 Converter 구현체를 자동으로 등록한다.[28]

Hibernate 프레임워크에서도 Joda-Time을 쓸 수 있다. Joda-time-hibernate 모듈(http://www.joda.org/joda-time-hibernate)을 이용하면 데이터베이스에 저장된 TIMESTAMPE 같은 타입을 Date 클래스와 같은 JDK의 기본 클래스대신 Joda-Time의 클래스로 매핑할 수 있다.

JSR-310: 새로운 Java의 날짜 API

2014년에 최종 배포되는 JDK 8에는 JSR-310이라는 표준 명세로 날짜와 시간에 대한 새로운 API가 추가되었다.[29] 앞에서 설명한 Joda-Time에 가장 많은 영향을 받았고, 그 밖에 Time and Money 라이브러리나 ICU 등 여러 오픈소스 라이브러리를 참고했다고 한다.

앞의 <예제 15>를 JDK 8의 ZonedDateTime 등을 이용해서 작성하면 다음과 같다.

예제 16 JSR-310을 이용한 날짜 연산

public class Jsr310Test {
@Test // 예제 1, 2: 1일 후 구하기
public void shouldGetAfterOneDay() {
LocalDate theDay = IsoChronology.INSTANCE.date(1582, 10, 4);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd");
assertThat(theDay.format(formatter)).isEqualTo("1582.10.04");

LocalDate nextDay = theDay.plusDays(1);
assertThat(nextDay.format(formatter)).isEqualTo("1582.10.05");
}

@Test // 예제 3, 4: 1시간 후 구하기
public void shouldGetAfterOneHour() {
ZoneId seoul = ZoneId.of("Asia/Seoul");
ZonedDateTime theTime = ZonedDateTime.of(1988, 5, 7, 23, 0, 0, 0, seoul);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
assertThat(theTime.format(formatter)).isEqualTo("1988.05.07 23:00");
ZoneRules seoulRules = seoul.getRules();
assertThat(seoulRules.isDaylightSavings(Instant.from(theTime))).isFalse();

ZonedDateTime after1Hour = theTime.plusHours(1);
assertThat(after1Hour.format(formatter)).isEqualTo("1988.05.08 01:00");
assertThat(seoulRules.isDaylightSavings(Instant.from(after1Hour))).isTrue();
}

@Test // 예제5, 6: 1분 후 구하기
public void shouldGetAfterOneMinute() {
ZoneId seoul = ZoneId.of("Asia/Seoul");
ZonedDateTime theTime = ZonedDateTime.of(1961, 8, 9, 23, 59, 59, 0, seoul);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
assertThat(theTime.format(formatter)).isEqualTo("1961.08.09 23:59");

ZonedDateTime after1Minute = theTime.plusMinutes(1);
assertThat(after1Minute.format(formatter)).isEqualTo("1961.08.10 00:30");
}

@Test // 예제 7: 2초 후 구하기
public void shouldGetAfterTwoSecond() {
ZoneId utc = ZoneId.of("UTC");
ZonedDateTime theTime = ZonedDateTime.of(2012, 6, 30, 23, 59, 59, 0, utc);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss");
assertThat(theTime.format(formatter)).isEqualTo("2012.06.30 23:59:59");

ZonedDateTime after2Seconds = theTime.plusSeconds(2);
assertThat(after2Seconds.format(formatter)).isEqualTo("2012.07.01 00:00:01");
}


@Test // 예제 12: 1999년 12월 31일을 지정하는 코드
public void shouldGetDate() {
LocalDate theDay = LocalDate.of(1999, 12, 31);

assertThat(theDay.getYear()).isEqualTo(1999);
assertThat(theDay.getMonthValue()).isEqualTo(12);
assertThat(theDay.getDayOfMonth()).isEqualTo(31);
}

@Test(expected=DateTimeException.class) // 예제 12: 1999년 12월 31일을 지정하는 코드의 실수
public void shouldNotAcceptWrongDate() {
LocalDate.of(1999, 13, 31);
}

@Test // 예제 13: 요일 확인하기
public void shouldGetDayOfWeek() {
LocalDate theDay = LocalDate.of(2014, 1, 1);

DayOfWeek dayOfWeek = theDay.getDayOfWeek();
assertThat(dayOfWeek).isEqualTo(DayOfWeek.WEDNESDAY);
}
@Test(expected=ZoneRulesException.class) // 예제 14: 잘못 지정한 시간대 ID
public void shouldThrowExceptionWhenWrongTimeZoneId(){
ZoneId.of("Seoul/Asia");
}
}

'java.time.*' 패키지로 시작하지만, 거의 Joda-Time과 유사한 모습을 보여 준다. 다음과 같은 특징이 있고 Joda-Time에서 개선된 점도 많다.

  • DateTime 클래스대신 ZoneDateTime 클래스가 사용된다. 시간대 정보를 가지고 있는 클래스임을 더욱 명확히 표현하려 한 듯하다.
  • 요일 클래스는 Enum 상수로 제공한다. 잘못 지정하거나 혼동할 여지가 없다.
  • 생성자 대신 of() 메서드 같은 static factory 메서드를 많이 사용한다. DateTimeFormatter.ofPattern(), Instant.from() 등이 그 예이다. static factory 메서드는 가독성 있는 이름을 따로 붙일 수 있고, 생성자와는 달리 한번 생성된 객체를 재활용할 수도 있다.[30]
  • Joda-Time보다 클래스별 역할이 더 세분화되었다. ZoneRules 같은 클래스가 그 예이다.
  • 서머타임 기간이면 TimeZoneRules.isDaylightSavings() 메서드의 반환값이 true이다.
  • 잘못 지정돤 시간대 ID에는 ZoneRulesException을 던진다.
  • 잘못 된 월 지정에는 객체 생성 시점에서 DateTimeException을 던진다.

그 밖에도 여러 장점이 있다. Calendar, Date, Joda-Time의 시간 클래스가 밀리초(millisecond) 단위의 정밀성을 가졌던 반면, JSR-310의 클래스는 나노초까지 다룰 수 있다. 시계의 개념도 도입되어서 현재 시간과 관련된 기능을 테스트할 때도 유용한다. java.time.Clock 클래스의 하위 클래스로 SystemClock, FixedClock 등이 제공된다.

이미 Spring 프레임워크 4.0에서는 JSR-310을 기본으로 지원한다. ZoneDateTime 등의 타입이 Controller의 메서드 파라미터로 선언되면 사용자가 입력한 문자열을 날짜 객체로 변환해 준다. JDK 8과 JSR-310 명세가 논의된 지 오래되어서인지 이 기능은 2012년부터 계획되어 있었다.[31]

JSR-310을 JDK 7에서 쓸 수 있는 백포트 모듈도 존재한다. pom.xml 파일에 다음과 같이 의존성을 추가하면 사용할 수 있다.

예제 17 JSR-310의 백포트 모듈의 의존성 선언

<dependency> 
<groupId>org.threeten</groupId>
<artifactId>threetenbp</artifactId>
<version>0.8.1</version>
</dependency>

패키지가 'java.time.*' 대신 'org.threeten.bp.*'라는 점을 제외하면 대부분의 클래스가 동일하다. 다만 백포트 모듈의 ZonedDateTime.toString(DateTimeFormatter) 메서드가 java.time 패키지에서는 ZonedDateTime.format(DateTimeFormatter)으로 바뀌는 등 미묘한 차이가 존재하기는 한다.

마치며

본문에 나온 예제는 모두 다음 주소에서 전체 소스를 확인할 수 있다.

우리 생활과 밀접하게 연관되어 있으면서도 많은 역사가 반영되어 있기에, 날짜 클래스는 도메인 자체가 쉬운 편은 아니다. 시차나 각국의 제도 변경을 의식해야 하는 국제화 시대에서는 더욱 그렇다. 그런 어려움을 Java의 불편한 API들이 더 가중시키고 있었지만 너무나도 오랫동안 개선되지 않았다.

JSR-310는 지금으로부터 무려 7년 전인 2007년에 처음 제안된 명세였다. 이제서야 최종 공개를 앞두고 있는 것이 아쉽기는 하지만, Java의 다른 명세를 보더라도 이 느린 속도가 아주 놀랍지는 않다. 한번 공개된 API는 영원하고, 특히 날짜에 관해서는 초창기의 시행착오가 너무도 뼈아팠기에 그만큼 신중할 수 밖에 없지 않았을까? 필자도 API 설계를 가끔 고민하는 입장에서 그런 신중함을 누릴 수 있는 상황이 부럽기도 하다.

'Java' 카테고리의 다른 글

자바 스케쥴링 & 타이머 방법들  (0) 2015.05.14
Java Time/ Date / Calendar example  (0) 2015.05.13
자바 List 순회  (0) 2015.05.12
자바에서 Map 순회  (0) 2015.05.12
자바 성능 관련 이슈들 모음  (0) 2015.05.05
  1. For loop
  2. For loop (Advance)  (앵간하면 이거 쓰자) 
  3. While loop
  4. Iterator loop

              

for (int i = 0; i < list.size(); i++) {

System.out.println(list.get(i));

}

 

for (String temp : list) {

System.out.println(temp);

}

 

 int j = 0;

while (list.size() > j) {

System.out.println(list.get(j));

j++;

}

 

 Iterator<String> iterator = list.iterator();

while (iterator.hasNext()) {

System.out.println(iterator.next());

}

'Java' 카테고리의 다른 글

자바 스케쥴링 & 타이머 방법들  (0) 2015.05.14
Java Time/ Date / Calendar example  (0) 2015.05.13
Java Time,Data 클래스의 문제점과 JAVA 8  (0) 2015.05.13
자바에서 Map 순회  (0) 2015.05.12
자바 성능 관련 이슈들 모음  (0) 2015.05.05

HashMap, TreeMap,LinkedHashMap, Hashtable 등등에 공통사용.


Method #1: 엥간하면 이거 (For Each)  사용 (java5 이상) 


Map<Integer, Integer> map = new HashMap<Integer, Integer>();

for (Map.Entry<Integer, Integer> entry : map.entrySet()) {

          int key   = entry.getKey();

int value =  entry.getValue();

}


For-Each loop 는  NullPointerException 를 던지기때문에 null 체크를 해야한다. 



Method #2:  key, value 둘중하나만 사용하면 이거 사용


Map<Integer, Integer> map = new HashMap<Integer, Integer>();


//iterating over keys only

for (Integer key : map.keySet()) {

    System.out.println("Key = " + key);

}


//iterating over values only

for (Integer value : map.values()) {

    System.out.println("Value = " + value);

}



Method #3:  예전 자바라면 이거 사용 (iterator 를 굳이 사용할 필요없음) 


Using Generics:


Map<Integer, Integer> map = new HashMap<Integer, Integer>();

Iterator<Map.Entry<Integer, Integer>> entries = map.entrySet().iterator();

while (entries.hasNext()) {

    Map.Entry<Integer, Integer> entry = entries.next();

    System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());

}


Without Generics:


Map map = new HashMap();

Iterator entries = map.entrySet().iterator();

while (entries.hasNext()) {

    Map.Entry entry = (Map.Entry) entries.next();

    Integer key = (Integer)entry.getKey();

    Integer value = (Integer)entry.getValue();

    System.out.println("Key = " + key + ", Value = " + value);

}



Method #4: 엥간하면 사용하지 말기를..


Map<Integer, Integer> map = new HashMap<Integer, Integer>();

for (Integer key : map.keySet()) {

    Integer value = map.get(key);

    System.out.println("Key = " + key + ", Value = " + value);

}


'Java' 카테고리의 다른 글

자바 스케쥴링 & 타이머 방법들  (0) 2015.05.14
Java Time/ Date / Calendar example  (0) 2015.05.13
Java Time,Data 클래스의 문제점과 JAVA 8  (0) 2015.05.13
자바 List 순회  (0) 2015.05.12
자바 성능 관련 이슈들 모음  (0) 2015.05.05


도서관에 있는 책들과 인터넷 서핑을 통해 모은 정보들을 간략하게 정리해 봅니다. 

부담없이 쭈욱 읽어가면 될듯..  프린트해서 심심할때 읽어보셔도~



자바 애플리케이션 성능 튜닝의 도(道)
http://d2.naver.com/helloworld/184615 펌

이 글은 월간 "마이크로소프트웨어" 2012년 9월호에 "자바 애플리케이션 성능 튜닝의 도(道)"라는 제목으로 실린 글입니다. 편집 과정을 거치며 일부 내용이 책에 실린 내용과 다를 수 있습니다.

자바 애플리케이션의 성능을 튜닝하는 작업은 자바 및 JVM에 대한 지식과 수많은 튜닝 기법, 다양한 환경과 상황에 대한 경험 등을 필요로 한다. 그러나 이 모든 내용을 짧은 지면에서 소개하기에는 무리이니 이 글에서는 성능 튜닝 작업의 상세한 내용보다는 튜닝에 필요한 배경 지식과 튜닝 순서, JVM의 각종 옵션 및 튜닝 접근 방법 등의 간략한 소개를 통해 성능 튜닝의 전반적인 흐름과 방법론에 대해 살펴보도록 하자. 특히 자바 애플리케이션의 여러 도메인 중에서 인터넷 서비스를 위한 웹 애플리케이션에 중점을 두고 설명해나가겠다.

모든 애플리케이션이 튜닝을 필요로 하는 것은 아니다. 충분한 성능을 내고 있다면 굳이 추가적인 노력을 들일 필요는 없다. 하지만 방금 디버깅을 마친 애플리케이션이라고 해서 항상 목표 성능만큼 동작해 줄 것이라고 기대할 수는 없고 그렇게 기준치 이하로 성능이 미달될 때 튜닝이 필요해진다. 구현 언어에 상관없이 애플리케이션을 튜닝하는 것은 상당한 전문적인 지식과 높은 집중을 요구하는 일인데다, A라는 애플리케이션을 튜닝했을 때 사용했던 방법을 B라는 애플리케이션을 튜닝할 때 재활용할 수 있는 것도 아니다. 애플리케이션마다 고유한 동작이 있고 컴퓨터 자원을 사용하는 형태가 다르기 때문이다.

애플리케이션을 튜닝하기 위해서는 애플리케이션 작성 지식보다 좀 더 근본적이고 포괄적인, 예를 들면 버추얼머신이나 OS, 컴퓨터 아키텍처 등에 대한 지식이 필요하다. 이런 지식을 바탕으로 애플리케이션 도메인에 집중해야 수월한 튜닝이 가능하다.

자바 애플리케이션 튜닝이란 경우에 따라 GC 같은 JVM 옵션 값 변경만으로 충분할 수도 있고 아예 코드를 수정해야 할 때도 있다. 어느 방법을 선택하든 우선 자바 애플리케이션 수행 과정을 모니터링해야 한다.

그래서 이 글에서는 '어떻게 모니터링을 하는가', '어떻게 JVM 옵션을 주어야 하는가', '코드 수정 필요 판단은 어떻게 하는가'를 중심으로 살펴보도록 하겠다.

자바 애플리케이션 성능 튜닝에 필요한 지식

JVM상에서 동작하는 자바 애플리케이션의 튜닝을 위해서는 JVM의 동작 과정에 대한 이해가 필요하다. 여기서 말하는 JVM 동작 과정에 대한 지식이란 크게 Garbage Collection(이하 GC)에 대한 것과 HotSpot에 대한 지식을 꼽을 수 있다. 물론 GC나 HotSpot 지식만으로 모든 자바 애플리케이션에 대한 성능 튜닝을 할 수 있는 것은 아니지만 성능에 영향을 미치는 대부분의 요소는 이 두 가지에 속한다.

JVM의 원활한 동작 환경을 만들기 위해서는 OS가 각 프로세스에 자원을 분배하는 방식에 대한 이해가 필요하다. 자바 애플리케이션 성능 튜닝을 위해서는 JVM 자체는 물론 OS나 하드웨어에 대한 이해도 필요하다는 것이다. OS 관점에서 볼 때 JVM 또한 하나의 애플리케이션 프로세스라는 점을 염두에 두도록 하자. 덧붙여 자바 언어 도메인에 대한 지식도 중요하다. Lock이나 Concurrency에 대한 이해는 물론 클래스 로딩이나 객체 생성에 대한 지식 또한 중요도가 높다.

자바 애플리케이션 성능 튜닝을 할 때는 이러한 지식들을 종합해 접근해야 한다.

성능 튜닝 과정

<그림 1>은 찰리 헌트와 비아누 존 두 사람의 공동저서인 'Java Performance'에서 인용한 순서도로 자바 애플리케이션 성능 튜닝 과정을 표현한 것이다.

javaapplication1

그림 1 자바 애플리케이션 성능 튜닝 과정

자바 애플리케이션 성능 튜닝 과정은 한 번에 통과하는 과정이 아니라 튜닝 완결까지 몇 번이고 계속 반복할 수도 있다. 기대 성능 수치 설정 또한 마찬가지다. 튜닝 과정을 통해 기대 성능 수치를 하향해야 할 때도 있고 오히려 기대 성능 수치를 상향할 때도 있다.

JVM 배포 모델이란 하나의 JVM에서 자바 애플리케이션을 동작시킬 것인지 여러 JVM에서 자바 애플리케이션을 동작시킬 것인지 결정하는 것으로 가용성, 응답 반응성, 관리 편의성 등에 따라 변경될 수 있다.

JVM이 여러 서버에서 동작하는 경우에도 한 서버에서 여러 개의 JVM을 동작하도록 하거나 서버마다 각각의 JVM을 동작하게 할 수도 있다.

물론 하나의 서버에 몇 개의 JVM이 동작할 것인가는 서버의 코어 개수와 애플리케이션의 특성 등에 따라 결정되겠지만 응답 반응성 관점에서 양자를 비교해볼 때, 같은 애플리케이션일 경우 2GB의 힙을 사용하는 경우가 8GB 크기의 힙을 사용하는 것보다 풀 GC에 걸리는 시간이 짧아 응답 반응성에 유리하다. 하지만 8GB 힙을 사용하면 2GB보다 풀 GC 발생 간격이 그만큼 줄어들 것이고 내부 캐시를 사용하는 애플리케이션이라면 히트율을 높여 응답 반응성을 높일 수 있다.

즉 하나의 장점을 선택했을 때 그 선택에 뒤따르는 단점을 극복할 수 있는 방법을 고려해야 적합한 배포 모델을 결정할 수 있다.

JVM 선택이란 32bit JVM을 사용할 것이냐 64bit JVM을 사용할 것이냐에 대한 결정이다. 동일 조건이라면 32bit JVM을 선택하는 것이 좋다. 32bit JVM이 64bit JVM보다 수행 성능이 좋기 때문이다. 32bit JVM은 논리적 최대 사용 가능 힙 크기가 4GB로, 이보다 큰 크기의 힙을 사용할 필요가 있을 때 64bit JVM을 사용하는 것이 좋다(단 32bit OS/64bit OS 모두 실제 사용 할당 크기는 2~3GB 정도다).

표 1 성능 비교 자료(출처)

BenchmarkTime [sec]Factor
C++ Opt231.0x
C++ Dbg1978.6x
Java 64-bit1345.8x
Java 32-bit29012.6x
Java 32-bit GC1064.6x
Java 32-bit SPEC GC893.7x
Scala823.6x
Scala low-level672.9x
Scala low-level GC582.5x
Go 6g1617.0x
Go Pro1265.5x

이제 작성한 애플리케이션을 가동해 성능을 측정하자. 이 과정에서 시스템 모니터링 도구나 프로파일링 도구를 사용해 GC 튜닝, OS 설정 변경, 코드 수정 등의 작업을 한다.

응답 반응성을 위한 튜닝과 처리량을 위한 튜닝은 별개의 작업일 수 있다. 단위 시간당 처리량이 많더라도 풀 GC 등을 위해 때때로 긴 'stop the world' 현상이 발생한다면 응답 반응성이 낮아지게 된다. 또한 일정 부분 트레이드 오프가 발생할 수 있음을 고려해야 한다. 이런 트레이드 오프는 응답 반응성과 처리량 사이의 관계에만 있지는 않음을 염두에 두자. 적은 메모리 사용을 위해 CPU 자원을 더 사용해야 하거나 응답 반응성이나 처리량 손실을 감수해야 할 수도 있고 반대의 경우도 발생한다. 그러므로 우선순위를 설정해 접근해야 한다.

<그림 1>의 순서도는 Swing 애플리케이션을 포함한 포괄적인 자바 애플리케이션에 대한 성능 튜닝 접근 방법이기에 인터넷 서비스를 위한 서버 애플리케이션을 작성할 때는 적합하지 않다. <그림 1>을 바탕으로 인터넷 서비스에 맞는 절차를 만들면 <그림 2>와 같은 순서도가 된다.

javaapplication2

그림 2 인터넷 서비스 자바 애플리케이션 권장 튜닝 절차

<그림 2>를 참고해 각각의 절차를 수행하기 위해 필요한 일을 알아보도록 하자.

JVM 옵션

웹 애플리케이션 서버 위주로 JVM 옵션 지정 방법을 설명하겠다. 모든 경우라고 할 수는 없지만 대부분의 웹 서버 애플리케이션에서 가장 좋은 GC 알고리즘은 Concurrent Mark Sweep GC다. 이는 낮은 딜레이가 중요하기 때문인데, 물론 Concurrent Mark Sweep을 사용할 경우에는 fraction이 발생해 경우에 따라 매우 긴 Stop the World 현상이 발생할 수도 있다. 하지만 이 역시 New 영역의 크기나 fraction ratio를 조정해 해결할 수 있는 경우가 많다.

전체 힙 사이즈의 크기 지정만큼 New 영역의 크기 지정 또한 중요하다. XX:NewRatio 옵션을 이용해 전체 힙 크기 중 New 크기의 비율을 지정하거나 XX:NewSize 옵션을 사용해 원하는 크기만큼의 New 영역 크기를 지정하는 것이 좋다. 대부분의 객체는 생존 시간이 길지 않기 때문에 New 영역 크기 지정이 중요해진다. 웹 애플리케이션에서 캐시 데이터를 제외한 대부분의 객체는 HttpRequest에 대한 HttpResponse가 만들어지는 시간에 생성된다. 보통 이 시간은 1초를 넘지 않기에 객체의 생존 시간도 1초가 되지 않는다. 만약 New 영역의 크기가 크지 않다면 새로 생성되는 객체의 자리를 위해 Old 영역으로 이동돼야 하고 Old 영역에 대한 GC 비용은 New 영역에 대한 GC 비용보다 상당히 크기 때문에 충분한 New 영역 크기를 잡아줘야 한다.

다만 일정 수치 이상으로 New 영역의 크기가 커지면 오히려 응답 반응성이 떨어지는 문제가 발생할 수 있으므로 주의하자. New 영역에 대한 GC는 기본적으로 어느 한 서바이버 영역에서 다른 서바이버 영역으로 복사하는 것이기 때문이다. 또한 Old 영역뿐만 아니라 New 영역에 대한 GC를 할 때에도 Stop the World 현상은 발생한다. New 영역이 커지면 상대적으로 서바이버 영역의 크기도 커져 그만큼 복사해야 할 데이터의 크기도 늘어난다. 이런 특성을 감안해 New 영역의 크기를 정할 때는 HotSpot JVM의 OS별 NewRatio를 참고하는 것이 좋다.

표 2 OS와 옵션별 NewRatio

OS & option디폴트 –XX:NewRatio
Sparc –server2
Sparc –client8
x86 –server8
x86 –client12

NewRatio를 지정하면 전체 힙 크기 중에서 1/(NewRatio + 1) 만큼이 New 영역의 크기가 된다. Sparc server의 NewRatio가 유독 작은 것을 알 수 있는데 기본값을 정하던 당시 x86보다 Sparc 시스템을 하이엔드 용도로 사용했기 때문이다. 요즘은 x86 서버 사용이 흔해졌고 성능 또한 향상됐기 때문에 Sparc server에 준하는 값인 2 또는 3 정도를 지정하는 것이 좋다.

NewRatio 대신 NewSize와 MaxNewSize를 지정할 수도 있다. NewSize에서 지정한 값만큼 New 영역이 생성됐다가 MaxNewSize에서 지정한 만큼 New 영역이 커진다. Eden이나 서바이버 또한 지정된 또는 기본 비율에 따라 같이 커진다. Xs와 Xmx 크기를 같게 하는 것처럼 NewSize와 MaxNewSize 또한 같게 지정하는 것이 좋다.

NewRatio와 NewSize를 지정했을 때는 둘 중 큰 값을 사용하기 때문에 힙이 생성됐을 때 최초의 New 영역의 크기는 다음과 같다.

min(MaxNewSize, max(NewSize, heap/(NewRatio+1)))

전체 힙과 New 영역의 적합한 크기를 한 번에 알 수는 없다. 웹 서버 애플리케이션을 기준으로 <표 3>과 같은 JVM 옵션으로 자바 애플리케이션을 가동해보는 것을 권한다. 이 옵션들로 성능을 모니터링한 후 더 적합한 GC 알고리즘이나 옵션으로 변경하자.

표 3 모니터링 후 옵션변경 예시

종류옵션
동작 모드-sever
전체 힙 크기-Xms와 –Xmx의 값을 같게
New 영역 크기-XX:NewRatio 2~4 정도의 값
-XX:NewSize=?
–XX:MaxNewSize=?
NewRatio 대신 NewSize를 지정하는 것도 좋다.
Perm 크기-XX:PermSize=256m
-XX:MaxPermSize=256m
성능에 영향을 미치지 않으므로 동작에 문제가 없을 정도만 지정한다.
GC 로그-Xloggc:$CATALINA_BASE/logs/gc.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
GC로그를 남기는 것은 특별히 Java 애플리케이션 수행 성능에 영향을 미치지 않는다. 가급적이면 GC 로그를 남기는 것이 좋다.
GC 알고리즘-XX:+UseParNewGC
-XX:+CMSParallelRemarkEnabled
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=75
일반적으로 권할만한 설정일 뿐이다. 애플리케이션 특성에 따라 다른 선택이 더 좋을 수 있다.
OOM 에러 발생 시 힙 덤프 생성-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=$CATALINA_BASE/logs
OOM 발생 이후 조치-XX:OnOutOfMemoryError=$CATALINA_HOME/bin/stop.sh 또는
-XX:OnOutOfMemoryError=$CATALINA_HOME/bin/restart.sh 힙 덤프를 남긴 뒤, 관리 정책에 맞게 적합한 동작을 취할 수 있도록 한다.

애플리케이션 성능 측정

애플리케이션 성능을 측정하기 위해 파악해야 할 정보는 다음과 같다.

  • TPS(Transaction Per Second)/OPS(Operation Per Second): 개념적으로 해당 애플리케이션의 성능을 이해하는데 필요한 정보다.
  • RPS(Request Per Second): 엄밀한 의미에서 응답 반응성과는 다르지만 RPS를 응답 반응성으로 이해해도 큰 무리는 없다. 사용자가 원하는 결과를 보기 위해 기다려야 하는 시간을 알 수 있다.
  • RPS 표준편차: 가급적 고른 RPS가 나오도록 할 필요가 있다. 편차가 발생한다면 GC 튜닝이나 연동 시스템에 대한 점검이 필요하다.

정확한 성능 수치를 위해 충분히 워밍업된 상태에서 측정하는 것이 필요하다. HotSpot JIT에 의해 바이트 코드가 컴파일된 상태가 되기를 기대하기 때문인데, nGrider를 이용해 통상 10분 이상 특정 기능에 대한 부하를 준 뒤 성능 수치를 측정하는 것이 좋다.

본격적인 튜닝

본격적으로 사례별 접근 방법을 알아보자.

Stop the World 시간이 길다

Stop the World 시간이 긴 이유는 적합하지 않은 GC 옵션 때문일 수도 있지만 잘못된 구현 때문일 수도 있다. 프로파일러나 힙 덤프 결과를 통해 힙을 차지하고 있는 객체의 종류와 생성 개수를 확인해보고 적합여부를 판단한다. 불필요한 객체가 많이 생성돼 있다면 코드를 수정하는 것이 좋다.

객체 생성 과정에 특별한 문제가 없다면 GC 옵션을 변경하자. 적합한 GC 옵션 조정을 위해서는 충분한 시간 동안 확보한 GC 로그가 필요하다. 어떤 상황에서 긴 Stop the World가 일어나는지 파악하자.

GC는 객체를 얼마나 많이 생성하느냐보다는 생성된 객체가 얼마나 오래 남아있는가가 더 중요하다. 즉 객체가 보다 빨리 GC 대상이 될수록 Stop the World 시간은 줄어들 가능성이 높다.

객체가 빨리 GC 되게 만드는 팁은 다음과 같다.

  • 객체의 크기를 가급적 작게 유지한다.
  • Collection이나 기타 Container 형태의 자료구조 안에서 배열의 크기를 변경하는 작업은 가급적 피하자.
  • SoftReference는 사용하지 않는 게 좋다.

CPU 사용률이 낮다

TPS가 낮은데 CPU 사용률도 낮다면 blocking time이 원인이다. 이 경우 연동 시스템의 문제나 동시성(concurrency) 문제일 수 있다. 스레드 덤프 결과 분석이나 프로파일러를 이용해 확인할 수 있다. 상용 프로파일러를 이용하면 매우 정밀한 lock 분석을 할 수 있지만 대부분의 경우 jvisualvm에 있는 CPU 분석만으로도 충분한 결과를 얻을 수 있다.

CPU 사용률이 높다

TPS가 낮은데 CPU 사용률만 높다면 효율적이지 못한 구현 때문일 가능성이 높다. 이 경우 프로파일러를 이용한 병목 지점 파악이 유효하다. jvisualvm이나 eclipse의 TPTP, JProbe 등을 이용해 분석하자.

튜닝 접근 방법

애플리케이션을 튜닝할 때는 먼저 성능 튜닝이 필요한지 파악해야 한다. 성능 측정 과정은 매우 고되고 언제나 좋은 결과를 얻을 수 있다는 보장도 없기 때문에 충분한 목표 성능을 만족하고 있다면 굳이 튜닝을 하지 않는 것이 효율적이다.

  • 문제는 단 한 곳에 있고 그 하나만 수정하면 된다: 파레토 이론은 성능 튜닝에도 적용된다. 문제는 반드시 하나라는 의미보다는 가장 성능에 영향을 미치는 하나에만 집중해 접근할 필요가 있다는 뜻으로 해석하자. 하나에 집중해서 해결하고 난 다음에 다른 문제 해결을 위해 노력하도록 하자.
  • 풍선 효과: 무엇을 얻기 위해 무엇을 포기해야 하는지 결정해야 한다. 캐시를 적용해 응답 반응성을 높일 수는 있지만 캐시의 크기가 커지면 풀 GC 시간이 길어질 수 있다. 적은 메모리 사용량을 선택하면 대개 처리 용량이나 응답 반응 시간이 나빠진다. 하나를 선택하면 하나를 포기해야 한다는 것을 염두에 두고 우선순위를 정해 선택하자.

여기까지 자바 애플리케이션 성능 튜닝 방법을 정리해 봤다. 성능 측정에 대한 구체적인 절차를 만들다보니 세부적인 정보를 제외하고 설명하기도 했지만 자바 웹 서버 애플리케이션을 튜닝하기 위한 대부분의 경우는 만족시킬 수 있을 것이라 생각한다.

참고: 성능 튜닝 도구

성능 튜닝 작업을 위해서는 Java 애플리케이션의 성능을 측정하고 실행 상태를 모니터링할 다양한 도구가 필요하다. JDK에 내장된 명령 도구인 jstat, jmap, jstack, jhat도 유용하지만 그 외에도 다양한 도구가 있어 소개한다.

프로파일링 도구

JProbe, Yourkit 등의 상용 제품이 유명한데 대부분의 프로파일링 도구는 상용제품으로, 오픈소스나 공개된 프로파일링 도구는 거의 없는 형편이다.

  • Eclipse TPTP: 현재는 개발이 중단된 상태이나 공개된 프로파일링 도구 중 꽤 쓸만한 편이다.
  • JVisualVM: JDK에 포함된 기본 도구로 GC 분석, 힙 덤프 및 스레드 덤프 생성, 스레드 모니터링 등의 다양한 용도로 사용할 수 있다. 내장된 샘플러 도구를 통해 간단한 프로파일링이 가능하다.

성능 측정용 도구

성능 측정용 도구로는 HP의 LoadRunner가 가장 유명하다. 그러나 상용제품으로 꽤 비싼 가격이므로 본문에서 언급한 nGrinder를 소개한다.

  • nGrinder: NHN에서 제작해 공개한 오픈소스로 기존 오픈소스 성능 측정 도구인 Grinder의 불편한 점을 보완하고 통합 환경을 제공한다.

GC 로그 분석 도구

본문의 <표 3>처럼 GC 로그를 남겼다면 다양한 GUI 도구를 이용해 GC 추이를 분석할 수 있다.

  • Hpjmeter: HP에서 개발 배포하는 자바 성능 분석 도구로 Heap Dump 분석, 모니터링 등의 여러 기능을 가지고 있는 멀티툴이지만 GC 로그를 매우 깔끔하게 보여주므로 GC 로그 분석기로도 사용하기 좋다.
  • GC Viewer: 오픈소스로 개발된 GC 로그 뷰어다.
  • IBM Pattern Modeling and Analysis Tool for Java Garbage Collector: IBM developerworks에서 개발해 공개한 GC 로그 뷰어다.
  • JVisualVM의 VisualGC plugin: JVisualVM 내에 탑재된 GC 모니터링 플러그인이다. 현재의 GC 동작을 모니터링하기에 유용하다.

힙 덤프 분석 도구

Stop the World 시간이 길거나 기타 이유로 성능이 나쁘다고 여겨질 때 힙 덤프를 얻어 분석하는 것도 효과적이다.

  • Eclipse Memory Analyzer: 흔히 이클립스 MAT이라고 부르는 이클립스 기반의 메모리 분석기다. 이클립스 플러그인으로 설치해 사용할 수도 있고 이클립스 RCP로 된 스탠드 얼론 프로그램으로 사용할 수도 있다.
  • IBM HeapAnalyzer: IBM developerworks에서 개발해 공개하고 있는 힙 메모리 분석기다.

------------------------------------------------------------------------------------


자바 성능 의 9가지 오해 (http://www.infoq.com/articles/9_Fallacies_Java_Performance)


1. Java is slow  (?)


90년대 말이나 2000년대 초까지는 자바가 느린 것이 사실일 수도 있다. 하지만 현재는 JVM과 JIT Compiler의 향상으로 상당한 속도 향상을 가져왔다. 많은 경우 C++ 만큼의 성능 향상을 가져왔다.


2. A single line of Java means anything in isolation (?)


다음 코드를 보자 

MyObject obj = new MyObject();

자바개발자가 보기에 명백히  객체를 할당하고 적당한 생성자를 작동시키는게 틀림없다. 네가 저 코드에서 무엇인가 성능개선점이 있다고 확신하여  지지고 볶고하지마라..저 코드라인은 전혀 실행되지 않을 수도있다.  이 얘기를  왜하냐면 미리 직관적으로 성급한 자바 성능개선을 하지 말라는거다. 대신 항상 네 코드를 빌드하고 네 코드의 성능상 중요지점이 어디인지 아는데 관심을 기울이라는 얘기이다. 


3. A microbenchmark means what you think it does (?)


위에 보다시피 적은 규모의 코드에 대해서 성능향상을 꾀하려고하는것은 바보짓이다. 큰 단위에서 바라보라.

microbenchmarks 는 굉장하게 어렵다. 자바 플랫폼은 굉장히 복잡하다. 대부분의 microbenchmarks 는

뻘짓이다.


4. Algorithmic slowness is the most common cause of performance problems (?)


네가 짠 알고리즘,로직이 성능구린것의 주된 이유가 될 가능성은 별로 없다.
대신해서 가비지컬렉션, 데이타베이스 엑세스 및 잘못된 설정이 솔루션을 망칠 가능성이 대부분이다.
알고리즘 및 자료구조는 나중에 살펴봐라.


5. Caching solves everything (?)


"컴퓨터 과학에서의 모든 문제들은 하나의 레이어를 추가해서 풀수있다." <--- 굉장한 명언이다.

어떤 구린시스템이 있을때 그 시스템 면전에서 욕하고 협박하지 말고 앞단에 하나의 레이어를 덧대는것을 생각해보라. 물론

이 해법은 꽤 복잡하다 (전체 아키텍처를 알아야하거나 다음 개발자에게 더 안좋은 상황을 선사할수도 있겠다..)

Caching 레이어를 추가할때 항상 이 레이어가 제대로 될 것인지 생각하라. 


6. All apps need to be concerned about Stop-The-World (?)


가비지컬렉션 로그 쓰는것자체가 쓰레드에 굉장한 부담이 될수있다는것을 알고 써라.


7. Hand-rolled Object Pooling is appropriate for a wide range of apps (?)


In summary, object pooling should only be used when GC pauses are unacceptable, and intelligent attempts at tuning and refactoring have been unable to reduce pauses to an acceptable level.


8. CMS is always a better choice of GC than Parallel Old (?)


CMS 가 너의 정확한 GC 전략이라고 결정짓기 전에 너는 STW 가 받아드려질수 없거나 조절될수 없다는것을 확신하라.
JDK7 기준 5가지 GC 전략
  • Serial GC
  • Parallel GC
  • Parallel Old GC(Parallel Compacting GC)
  • Concurrent Mark & Sweep GC(이하 CMS)
  • G1(Garbage First) GC
CMS 소개(http://helloworld.naver.com/helloworld/helloworld/1329)

 stop-the-world 시간이 매우 짧다. 모든 애플리케이션의 응답 속도가 매우 중요할 때 CMS GC를 사용하며, Low Latency GC라고도 부른다.그런데 CMS GC는 stop-the-world 시간이 짧다는 장점에 반해 다음과 같은 단점이 존재한다.
  • 다른 GC 방식보다 메모리와 CPU를 더 많이 사용한다.
  • Compaction 단계가 기본적으로 제공되지 않는다.

따라서, CMS GC를 사용할 때에는 신중히 검토한 후에 사용해야 한다. 그리고 조각난 메모리가 많아 Compaction 작업을 실행하면 다른 GC 방식의 stop-the-world 시간보다 stop-the-world 시간이 더 길기 때문에 Compaction 작업이 얼마나 자주, 오랫동안 수행되는지 확인해야 한다.


9. Increasing the heap size will solve your memory problem (?)


JVM 의 다른 파라미터들을 튜닝하거나 힙사이즈를 바꾸기전에 객체할당과 생존기간에 대한 매커니즘을 이해하라. 



-------------------------------------------------------------------------------------


GC 튜닝  (http://rajalo.tistory.com/entry/java-GC-%EA%B4%80%EB%A0%A8)


1. RMI 사용시 주기적인 FULL GC  발생 

: 주기적으로  system.gc() 를 호출하는데 이것은 STOP-THE WORLD 식의 FULL GC 를  유발한다. 

해결책: 1.  아예 실행안되게 2. 인터벌을 길게 3. GC콜이 Concurrent 방식으로 이루어지게 함. 


2. Paging in/out에 의한 GC 지연

해결책 : 1. Java Max heap size 를 줄임. (OS자체의 메모리 부족으로 페이징이 자주 일어나는거 같다) 


3. Heap 여유 공간이 충분한데도 OutOfMemoryException이  일어난다.

자바의 메모리공간에 대한 이해가 필요하다. (Parmanent Space 와 힙이 2가지임. 객체용, 네이티브용) 

C++ 과 다르게 자바는 실행시간에 리플렉션을 통해 코드를 끌어오므로 Parmanent Space 가 막 늘어날수가있음. 

또 쓰레드에 대한 정보는 네이티브힙에서 관리하는데 쓰레드가 너무 많아지면 이게 네이티브힙이 버티질 못한다.

해결책 : 1. Parmanent Space 크기를 늘린다.(로그에 이게 문제라면)  2. 쓰레드풀을 사요하여 네이티브힙의 크기를 안정화시킨다.


4. 큰  뉴 제네레이션에 의한 FULL DC 발생.

New Generation 이 크면 minor DC 에는 유리하지만 FULL DC에는 불리할 확률이 높다는것을 알고 적당한 크기를 찾자


------------------------------------------------------------------------------------

가비지 컬렉션에서의 메모리 누수 (http://minjang.egloos.com/2372567)

.

메모리 누수는 GC라 해도 완벽히 잡아낼 수 없다. 메모리 누수를 다시 두 개로 나눠 생각하면:

  • 접근 불가능한 객체에 대한 누수(lost objects): 더 이상 해당 객체에 접근을 할 수 없지만 반환되지 않은 것들
  • 더 이상 사용하지 않는 객체에 대한 누수(useless objects): 도달은 가능한데 더 이상 사용하지 않는 것들

핵심은 두 번째 해당하는 녀석인데, 먼저 여기서 아주 간략히 GC의 작동 원리부터 이야기 하면, 사실 별 것 없다. 근본 원리는 할당 받은 메모리에 접근할 수 있는 경로가 더 이상 존재하지 않는다면 이 메모리는 사용되지 않는 것으로 간주하고 GC가 자동적으로 반환하는 것이다. 코드까지 쓰기는 귀찮고(…) 대표적인 예로 메모리를 할당 받은 변수가 지역 변수라서 해당 스코프를 벗어나면 더 이상 접근 불가능한 경우가 있다.

그러나 문제는 두 번째에 해당하는 문제, 즉 도달은 가능한데 더 이상 사용하지 않는 객체에 대한 메모리 누수이다. 조금 극단적인 경우지만 이런 서버 프로그램의 예를 생각해보자.


  1. 클라이언트가 접속할 때 마다 Client라는 자료 구조를 하나 할당하고 전역 리스트에 이 Client 객체를 넣는다.
  2. 클라이언트가 접속을 끊으면 해당 Client 자료 구조를 리스트에서 삭제한다.

만약 이렇게만 작동한다면 GC는 똑똑히 작동한다. 2번 과정에서 더 이상 해당 Client 자료 구조에 대한 접근이 사라지므로 GC는 이 객체를 해제할 수 있다. C/C++ 처럼 명시적으로 Client 객체를 free/delete 할 필요는 없다.

그러나 문제는 프로그램이 복잡해지면 2번 과정에 실수가 있을 수 있다. 분명 더 이상 객체가 사용되지 않는데 프로그램 어딘가에 이 객체로 접근할 수 있는 길이 남아 있는 것이다. 극단적으로 이야기 하면 프로그래머가 2번 과정을 실수로 빼먹는 경우다. 아무리 이 Client 객체가 사용되지 않지만 전역 리스트에 자리를 차지하고 있으니 GC는 이것이 언젠가는 사용될 것이라고 보수적으로 판단해서 삭제를 할 수 없다.

어떤 메모리가 오랫동안 사용되지 않는다 하더라도 GC는 이 메모리 영역을 해제할 수 없다. 극단적으로 어떤 객체가 백 만년 뒤에 쓰인다면? 이건 구현이 어려워서가 아니라 아예 불가능한 영역이 있다. 미래를 예측할 수 없기 때문이다. GC 구현 기법 중 Generational GC 등이 있는데 이건 접근 불가능한 객체들을 효율적으로 찾기 위한 것이지, 이렇게 더 이상 사용되지 않는, 그런데 도달은 가능한 객체에 대한 누수를 해결하는 것이 아니다.

따라서 어떤 객체로 접근할 수 있는 방법이 있다면 GC는 아무것도 할 수 없고 실제 큰 자바 프로젝트(예를 들어 느려터진 이클립스)에서는 메모리 누수가 많이 일어난다. Managed 환경에서도 메모리 사용을 프로파일링 하는 도구는 많이 있고 메모리 누수 검출 도구도 많이 있다. .NET Memory ProfilerJRockit 같은 것이 일단 있고 C/C++ 쪽은 익히 아는 DevPartner나 GNU 환경에서는 Valgrind 같은 것들이 있다.

위키에도 이런 내용이 잘 있다. 물리적 메모리 누수(lost objects)와 논리적 메모리 누수(useless objects)로 분류하고 GC는 후자에 대해서는 아무것도 할 수 없다.




Garbage Collection과 Statement Pool

NHN 게임서비스기술지원팀 최동순 


잘 설정한 Statement Pool 개수는 GC(Garbage Collection) 전문가의 튜닝이 부럽지 않습니다. 이 글에서는 Statement Pool의 개수가 GC 과정에 미치는 영향을 살펴보고 어떻게 Statement Pool 개수를 설정하는지 설명합니다.


Statement Pool 개수를 살펴야 하는 이유

JDBC Statement Pool의 크기를 기본값으로 설정해 사용하는 경우가 많다. 물론 기본값을 사용해도 특별한 문제가 없는 경우가 많다. 그러나 제대로 설정한 Statement Pool 개수는 GC 튜닝을 한만큼의 효과가 있을 수 있다. Statement Pool 값을 기본값으로 설정해 사용하고 있다면, 메모리 사용을 좀 더 최적화하고 싶을 때 GC 튜닝을 시도하기에 앞서 올바른 Statement Pool 값이 무엇인지 고민해 보자.

"Java Garbage Collection"에서 다룬 것처럼 Java에서는 Garbage Collector를 설계할 때 weak generational hypothesis 를 전제로 한다. NHN 웹 서비스는 특별한 경우가 아니면 대부분 늦어도 300ms 이내에 응답을 줄 수 있어야 한다. 그렇기 때문에 일반적인 스탠드얼론 형태의 애플케이션보다 NHN의 웹 서비스가 위 전제에 더 부합하고 있음을 알 수 있다.


HTTP 요청에서 응답까지 GC가 일어나는 과정

Tomcat 같은 웹 컨테이너와 다양한 프레임워크를 이용하여 웹 서비스를 개발할 때에 개발자가 직접 생성한 객체의 수명은 보통 아주 짧거나 아주 길거나 둘 중에 하나이다.

웹 개발자가 주로 작성하는 코드는 Interceptor, Action, BO, DAO 인데, 이런 코드에서 생성되는 객체는 HTTP 요청이 왔을 때 응답을 주기까지의 매우 짧은 시간만 살아 있다. 그래서 이런 객체는 대부분 Young GC 때 수거된다.

물론 singleton 객체같이 Tomcat 라이프사이클과 같을 정도로 아주 길게 살아 있는 객체도 있을 것이다. 이런 객체는 Tomcat이 가동된 후 얼마 지나지 않아 모두 Old 영역으로 Promotion되어 있을 것이다.

그런데 jstat 등으로 웹 애플리케이션을 지속적으로 모니터링해 보면 Young GC 때 Old 영역으로 Promotion되는 객체가 항상 있다.

이런 객체는 대부분 컨테이너와 프로젝트에서 사용하는 프레임워크에서 성능 향상을 위해 사용하는 캐시에 저장하여 사용하는 객체이다. 이렇게 캐싱되는 객체는 시간적인 문제가 아닌 cache hit ratio에 따라서 GC 대상 여부가 결정되기 때문에 hit ratio가 100%가 아닌 이상은 아무리 Young GC 주기를 길게 설정해도 Old로 Promotion되는 것을 막을 수 없다.

이런 캐시 중에서 메모리 사용량에 가장 큰 영향을 주는 것은 Statement Pool이다. 만약 iBatis를 사용하고 있다면 모든 SQL을 preparedStatement로 처리하는 iBatis의 특성상 Statement Pool을 사용한다.

만약 사용하는 SQL의 개수에 비하여 Statement Pool의 크기가 작다면, cache hit ratio가 낮게 될 것이고 캐시 유지 비용이 발생하게 될 것이다. Old 영역에서 계속 있어도 되는 (reachable 또는 있는 게 좋은) 객체가 GC의 대상이 되어 회수되고, 이후 HTTP 요청 처리 과정에서 다시 생성된 다음 캐싱되어 Old 영역까지 Promotion되는 것이다. 이런 과정으로 Full GC 주기에 영향을 주게 된다.


Statement 객체의 크기

하나의 Statement 객체의 크기는 그 Statement가 처리하는 SQL 코드 길이에 비례한다고 보아도 무리가 없다. 길고 복잡한 SQL을 예로 든다고 해도 약 500바이트 내외가 될 것이다. 객체 크기가 작아 Full GC 주기에 별 영향을 줄 것 같지 않지만, 실제로는 그렇지 않다.

JDBC 스펙을 살펴 보면 다음 그림처럼 각 커넥션이 자신의 Statement Pool을 각각 가지는 구조이다. 즉 하나의 Statement 객체 크기가 500바이트 정도로 작더라도 커넥션 수가 많으면 그에 비례하여 heap을 점유하게 된다.

sp

그림 1 Connection과 Statement Pool과의 관계

Statement Pool의 cache hit ratio가 Full GC에 미치는 영향

cache hit ratio가 Full GC에 미치는 영향을 알아보기 위해 간단한 테스트 프로그램을 제작하였다. cache hit ratio가 하나는 100%가 되도록 하고 다른 하나는 50%가 되도록 했다. 그리고 동일한 부하를 주었을 때 다음 표와 같은 결과가 나왔다.

Young GC가 발생한 횟수는 둘 다 비슷하지만, Full GC의 경우는 cache hit ratio가 100%이면 Young GC 때 Old로 Promotion되는 객체의 양이 적어 한 번만 Full GC가 발생한다. 반면 cache hit ratio가 50%이면, Statement Pool에 캐싱되었다가 다시 LRU 방식으로 풀(pool)에서 제거되고 다음 요청에서 다시 캐싱되는 방식이기 때문에 Young GC 때 Old로 Promotion되는 Statement 객체의 개수가 많아져 총 4번의 Full GC가 발생하였다.

표 1 cache hit ratio = 100%

...

OC

OU

YGC

FGC

FGCT

GCT

10688.0

6940.9

532

1

0.190

1.274

...

10688.0

6940.9

532

1

0.190

1.274

 

표 2 cache hit ratio = 50%

...

OC

OU

YGC

FGC

FGCT

GCT

...

10240.0

7092.7

554

4

0.862

2.253

...

10240.0

7412.0

555

4

0.862

2.255

 

또 한 가지 언급하고 싶은 내용은, cache hit ratio가 50%인 상황은 앞서 소개한 weak generational hypothesis의 2번째 항목에 위배되는 상황이라는 것이다. cache hit ratio가 낮아서 빈번히 풀에 등록되었다가 제거되는 현상이 반복된다는 것은 이미 Old 영역에 있는 풀에서 Young 영역에 생성된 Statement 객체에 대한 참조를 가지게 되는 것으로 card table marking 기법 으로 별도로 reference를 관리하게 되어서 GC 시에 추가적인 부담이 발생하게 된다.


마치며

Oracle과 MySQL에 대한 Statement Pool 기본값은 500으로 설정해도 충분할 것이다. 그 이상의 SQL이 사용된다면 충분한 크기로 늘리는 것이 시스템의 효율을 높이는 방법이다 .

하지만 필요보다 높은 수를 설정하는 것도 문제가 있다. 그만큼 많은 메모리를 사용하게 되고, OOME(Out Of Memory) 발생 가능성도 높아지기 때문이다. SQL 개수가 1만 개, 커넥션 개수가 50개인 상황을 가정하고 계산해 보면 메모리 사용량이 250MB 정도가 된다(500 byte * 50 * 10,000 = 250 MB).

운영중인 서비스의 Xmx 설정을 확인하여 OOME 발생 가능성 여부는 쉽게 판단할 수 있을 것으로 생각한다.


http://helloworld.naver.com/helloworld/textyle/4717)


'Java' 카테고리의 다른 글

자바 스케쥴링 & 타이머 방법들  (0) 2015.05.14
Java Time/ Date / Calendar example  (0) 2015.05.13
Java Time,Data 클래스의 문제점과 JAVA 8  (0) 2015.05.13
자바 List 순회  (0) 2015.05.12
자바에서 Map 순회  (0) 2015.05.12

+ Recent posts