Java/Tuning2009. 2. 23. 18:32

※이 문서는 Java Performance and Scalability Volume1(by Dov Bulka)라는 책의

4:Caching 읽고 나름대로 정리한 것입니다.

 

Preface

 프로그램 작성시 성능에 많은 영향을 미치는 부분을 찾아서 이를 개선하고자 아마도 다음의 중의 한가지를 있을 것이다. 먼저 처리 과정을 자세히 살펴봐서 핵심을 찾아내어 부분을 나름대로 개선하는 방법이 있을 것이고, 두번째로는 불필요한 처리 작업을 피할 있는 좋은 방법을 찾아내서 아예 처리할 필요가 없게 만드는 방법이 있다. 물론 비용이 많이 드는 작업을 피할 있는 후자가 훨씬 좋은 방법일 것이다. 

 프로그램 상에서 처리해야 값들은 그것들의 예상 수명에 따라 크게 세가지 범주로 나줄 있다.

Static : 응용프로그램이 실행되는 동안 절대로 값이 변하는 않는다. 예로 서버는 응답 헤더 부분에 항상 자신의 이름을 넣어 보내는데 서버가 실행 되는 동안에는 서버 이름이 변할 일은 없다.

Pure dynamic : 이러한 값들은 어떤 시점에서라도 계속 변하며 매번 재처리 되어야 한다. 캐슁을 하기에 별로 좋은 값들은 아니다.

Semidynamic : 지속적으로 변하긴 하되 상대적으로 수명을 가지는 값이다. 예로 전자 상거래 사이트를 돌아다니는데 거기서 보여지는 가격 정보는 그렇게 자주 바뀌지는 않는다. 매번 요청할 마다 DB에서 데이터를 새로 가져온다면 비싼 비용을 들여야 것이다. 이러한 가격은 캐쉬해 놓고 주기적으로 갱신하면 된다.

 

 Static, Semidynamic 값들은 특성상 최적화 하기에 좋은 대상이다. 이번 장에서는 캐슁으로 알려져 있는 이러한 최적화 방법들을 살펴보겠다.

 

1. Cache File Contents

 HTTP 요청의 많은 부분은 주로 정적인 HTML 이미지 파일이다. 그런 면으로 본다면 서버는 일종의 파일 서버 역할을 한다고도 있다. 파일들은 바뀌기는 하지만 그리 자주 변경되지는 않으므로 semidynamic 범주로 있다. 그렇다면 변경되지도 않았는데 요청할 마다 매번 파일 시스템을 통해 새로 읽어와서 클라이언트에게 파일의 내용을 보내준다면 서버는 과도한 I/O 인해 성능이 많이 나빠질 것이다. 그래서 대부분의 상업적인 서버들은 나름대로의 캐쉬 기법을 사용한다. 간단한 예를 보자.

 FileInfo 클래스는 우리가 저장하고자 하는 파일의 정보를 가지고 있다. 간단히 하기 위해 파일의 내용만을 신경쓰기로 한다.

 

class FileInfo

       private String content;

       private long lastUpdate;

      

FileInfo(String c, long lu){

             content = c;

             lastUpdate = lu;

}

public void setContent(String c){

   content = c;

}

       public void setLastUpdate(long lu){

   lastUpdate = lu;

}

public String getContent(){

   return content;

}

public void getLastUpdate(){

   return lastUpdate;

}

}

 

캐쉬는 Hashtable 이용해서 구현한다. 파일의 이름을 키로 해서 FileInfo 객체를 저장하기로 한다. 서버가 최초로 x.html 요청 받으면 서버는 파일 시스템에서 해당 파일을 읽은 FileInfo 객체를 새로 생성하고 안에 x.html 담는다. FileInfo 객체는 다음에 다시 사용하기 위해 캐쉬 객체에 삽입된다. 코드는 아래와 같다.

 

class FileCache{

       private Hashtable ht = new Hashtable(1001);

       private long cacheRefresh = 1000; //1000ms

      

       FileCache() {}

      

FileCache(long refresh){

             cacheRefresh = refresh;

}

public void put(File file, FileInfo fi){

   ht.put(file.getName(), fi);

}

public FileInfo get(File file){

   FileInfo fi = (FileInfo)ht.get(file.filename());

    if (fi == null) return null;

 

   long now = System.currentTimeMillis();

   if((now-fi.getLastUpdate()) > cacheRefresh){

          ht.remove(file.getName());

          return null;

}

return fi;

}

}

 같은 파일에 대해 계속적으로 요청이 들어온다면 해당 파일이 캐쉬되어 있으므로 비용이 많이 드는 파일 시스템 I/O 필요가 없다. cacheRefresh 값이 있으므로 일정 시간이 지나서 다시 요청이 들어오면 파일을 새로 읽어 들일 것이다. 정기적으로 I/O 하는 것이 초당 1000번씩 하는 것보다 빠른 것은 당연하다.

 

2. Design Caching into Your API

 개발자로서 당신은 Application Programming Interface(API) 작성자나 사용자 중의 하나일 것이다. API 사용자로써 종종 API에서 불필요하거나 중복되는 작업을 많이 하는데도 이를 개선하기 위해 아무 것도 없는 상황을 만날 때가 있다. 필자는 다른 그룹이 작성한 API 인해 이러한 상황을 만난 적이 있다. 현재 액티브 상태인 서블릿의 개수를 알아내고 서블릿에 대한 요청 수를 알기 위해 다음과 같은 코드를 사용하였다.

       public void servletActive(){

              long time = System.currentTimeMillis();

             ...

       }

       public void incTotalRequests(){

             long time = System.currentTimeMillis();

             ...

       }

  가지 통계적인 이유로 인해 event-logging 작업을 하는 메소드들은 타임스탬프가 필요했으므로 메소드들은 호출될 때마다 System.currentTimeMillis() 통해서 타임스탬프를 얻었다. 그러나 여기에는 두가지 중요한 문제가 있었다.

System.currentTimeMillis() 매우 비싸다. 자바는 system clock 의존하기 때문에 이를 위해서는 반드시 native call 필요하다. java에서 Java Native Interface(JNI) 거쳐가는 것은 비용이 들기에 System.currentTimeMillis() 드는 비용을 가볍게 보아서는 안된다.

클라이언트 코드는 이러한 메소드들을 복수로 호출한다. 불필요하게 System.currentTime-Millis()   반복적으로 호출될 것이다.

 

 예를 들어 서블릿 요청을 받은 후에 다음과 같이 했다고 하자.

       servletData.servletActive();

       servletData.incTotalRequests();

 이러한 경우라면 하나의 타임스탬프만 받아서 재사용할 있는데도 API 설계자가 그러한 메소드를 제공하지 않았으므로 달리 방법이 없게 된다. 그래서 만약 당신이 API 작성자라면 API 사용 패턴을 미리 예측해서 캐쉬나 다른 성능 향상 기법을 사용할 있도록 API 제공한다면 매우 바람직할 것이다. 만약 아래와 같은 메소드를 제공한다면

public void servletActive(long time){

             ...

       }

       public void incTotalRequests(long time){

             ...

       }

타임스탬프를 캐쉬하여 재사용할 있다.

long time = System.currentTimeMillis();

servletData.servletActive(time);

       servletData.incTotalRequests(time);

 

3. Precompute

 루프내에서 변치 않는 값을 미리 계산해 놓는 것도 고전적인 최적화 방법의 하나이다. 이는 루프가 실행되는 동안 값이 변하지 않으므로 static value 범주에 들어간다. 예를 보자.

       for(int i=0; i<100; i++){

             a[i] = m*n;

       }

m*n 루프내에서 변하지 않는다(loop invariant). 그러므로 매번 값을 계산하는 것은 비효율적이다. 가장 명확한 최적화는 m*n 루프 밖으로 빼는 것이다.

int p = m*n;

for(int i=0; i<100; i++){

             a[i] = p;

       }

 

4. Relax Granularity

 지금 몇시죠?’ 라고 누군가 물어본다면 우리는 대충 시간과 분만을 이야기 것이다. 초까지 얘기해 주지 않았다고 트집잡을 사람은 아마 정신병원에서 탈출한 사람밖에 없을 것이다. 이렇듯 가끔 요구하는 정확도에 따라 필요한 일의 양이 결정될 때가 있다. 굳이 초까지 필요가 없는데 초까지 계산을 하고 이를 말해준다면 이는 낭비이다. 허락하는 만큼 융통성을 가지고 대처를 한다면 불필요한 계산량을 줄일 있다. 소수점 이하 자리수는 필요가 없는데 매번 서너자리까지 계산해서 최종적으로 반올림을 한다면 얼마나 낭비인가. 본론으로 들어가자.

 Date 클래스는 랭귀지에 상관없이 처리하기에 매우 비싼 자원이다. 현재 시간을 물어본다면 1970 1 1 이후 현재 시간까지를 초로 바꾼 다음 매우 복잡한 처리 과정을 거쳐서 ‘Fri Jul 02 16:38:41 PDT 1998’ 같이 변환해주어야 한다. 게다가 자바에서는 locale 까지도 반영을 하니 현재 시간 하나를 얻기 위해 얼마나 많은 비용을 지불해야 하는지 짐작할 있다.

 웹서버가 HTTP 응답을 보낼 헤더에 현재시간을 덧붙인다. 원시적인 방법으로 구현한다면 매번 요청이 있을 때마다 현재 시간을 계산해서 보낼 것이다.

       String dateHeader = “Date: “+(new Date());

초당 1000번의 요청이 들어온다면 웹서버는 아마 1초에 1000 현재 시간을 계산해야 것이다. 그런데 매번 요청이 있을 때마다 정확한 시간을 보내줄 필요가 있을까? 그렇지 않다고 본다. 1 이내의 허용 오차만 허용해도 우리는 1초에 한번씩만 현재 시간을 계산하고 안에서의 응답 헤더에는 같은 시간을 보내주어도 무방할 것이다. 아래의 코드는 현재 시간을 1 간격으로 캐쉬함으로써 이러한 아이디어을 구현한 것이다.

 

       class LazyDate{

             private long lastCheck = 0; //Never checked before

             private String today;

             private long cacheRefresh = 1000; // 1 second

            

             public LazyDate(){}

 

             public LazyDate(long refresh){

                    cacheRefresh = refresh;

                    update();

             }

             public String todayDate(){

                    long now = System.currentTimeMillis();

                    if ((now-lastCheck) > cacheRefresh){

                           update();

                    }

                    return today;

             }

             public void update(){

                    lastCheck = System.currentTimeMillis();

                    today = (new Date()).toString();

             }

}

 

웹서버 코드에서는 LazyDate single instance 생성한 새로운 요청이 있을 때마다 todayDate() 메소드를 호출하면 된다.

       LazyDate today = new LazyDate(1000); //1 second refresh time

       ...

       while(true){ //While server is active

   ...

             String dateHeader = “Date: “ + today.todayDate();

   ...

} 

 근데 과연 얼마나 성능의 차이가 날까? 예제1 2 통해 비교해보자.

(예제1) 소요시간 : 28,000 ms

String date = null;

for(int i=0; i<100000;i++){

      date = (new Date()).toString();

}

(예제2) 소요시간 : 40 ms

String date = null;

LazyDate today = new LazyDate();

for(int i=0; i<100000;i++){

      date = today.todayDate();

}

 

 많은 차이가 남을 있다. LazyDate Date 클래스가 long integer 이용하여 해당 지역마다 다른 문자열 포맷으로 변환하는 과도한 작업도 감소시키고 새로 생성되는 객체의 수도 감소시키므로 시간의 정확도에 대한 요구가 완화될수록 훨씬 좋은 성능을 제공한다.

 

출처 : 너구리님 워드파일

Posted by Huikyun