저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의

'쓰레드풀링'에 해당되는 글 2건

  1. 2001.01.23 진보된 쓰레드 풀링 기법 구현 (9)
  2. 2001.01.07 자바 어플리케이션의 성능 향상 (2)

이 글은 오래된 글로서, 자바 concurrent api의 Executor를 사용하는 것이 더 좋은 선택이다. Executor에 대한 내용은 Java Concurrency: Executor와 Callable/Future 링크에서 확인할 수 있다.


상황에 따라 쓰레드의 개수를 동적으로 증감시키는 쓰레드 풀링을 구현해본다.

쓰레드 풀링의 필요성

자바의 특징 중의 하나를 꼽으라면 언어 차원에서 제공하는 멀티 쓰레드 기능이다. 쓰레드는 프로세스에 비해 다음과 같은 특징을 갖고 있다.

  • 한 개의 쓰레드를 생성하는 데 드는 비용(시간+자원)은 한 개의 프로세스를 생성하는 데 드는 비용보다 적다.
  • 멀티 프로세스를 스케쥴링하는 것 보다 멀티 쓰레드를 스케쥴링 하는 것이 더 간단하다.
  • 쓰레드는 하나의 프로세스안에서 수행되므로 처음부터 공유 메모리를 갖지만, 멀티 프로세스 사이에 메모리를 공유하기 위해서는 공유 메모리를 별도로 생성해야 한다.
프로세스에 비해 쓰레드가 갖는 이러한 장점들 때문에 쓰레드의 사용은 점차 일반화되어 가고 있으며, 자바에서 쓰레드의 사용은 필수적인 요소가 되어 가고 있다. (실제로 앞으로 나올 아파치 웹서버 2.0은 프로세스 단위가 아닌 쓰레드 단위로 사용자의 요청을 처리하고 있다.) 비록 쓰레드가 프로세스에 비해 자원의 사용량이나 성능 면에서 뛰어난 것이 사실이지만, 쓰레드를 사용하는데는 일정량의 시간과 자원이 소비되며 따라서 사용할 수 있는 쓰레드의 개수에는 제한이 있기 마련이다. 또한, 동시에 수행되는 쓰레드의 개수가 많아질수록 쓰레드 스케쥴링하는 데 소비되는 시간은 많이지게 되며, 이는 결국 어플리케이션의 성능을 저하시키는 요인이 되기도 한다.

이러한 문제점을 해결하기 위해 사용되는 것이 바로 쓰레드 풀링이다. 일반적으로 쓰레드 풀링은 다음과 같은 특징을 갖는다.

  • 미리 생성되어 있는 쓰레드를 재사용함으로써 쓰레드를 생성하는 데 소비되는 시간을 줄인다.
  • 동시에 생성될 수 있는 쓰레드의 개수를 제한함으로써 시스템의 자원 소비량을 제한한다.
여기서 두번째 특징은 어플리케이션의 전체 성능을 일정 수준으로 유지하는 데 중요한 역할을 한다. 예를 들어, 웹 서버를 구현한다고 하자. 이 때 클라이언트가 문서를 요청할 때 마다 하나의 쓰레드를 생성하여 그 요청을 처리하도록 구현할 수 있다. 이 경우 동시에 100명이 요청을 하게 되면 100개의 쓰레드를 동시에 생성하게 된다. 만약 한 개의 쓰레드를 생성하는 데 소비되는 메모리가 200K 라면, 한 순간에 20000K(약 20M)의 메모리가 소모되는 것이다. 메모리 뿐만 아니라 100개의 쓰레드를 스케쥴링하기 위해서 많인 시간을 소비하게 된다. 만약 동시 접속자수가 1000명인 사이트라면 한 순간에 200M의 메모리를 사용하게 된다. 문제는 생성된 쓰레드가 클라이언트의 요청을 처리하면 끝나고, 이후에 1000명이 서비스를 요청하면 또 다시 1000개의 쓰레드를 생성한다는 점이다. 즉, 또 다시 200M의 메모리가 필요하게 되는 것이다. 어플리케이션의 전체적인 성능이 저하될 것은 불보듯 뻔하다. 심지어 시스템이 다운되는 경우도 발생한다.

쓰레드 풀링을 사용하게 되면, 이러한 문제가 어느 정도 해결된다. 만약 동시에 수행될 수 있는 쓰레드의 개수를 100개로 제한했다고 해 보자. 이 경우 1000개의 클라이언트가 서비스를 요청한다고 해도 100개의 쓰레드만이 동시에 수행되며, 따라서 메모리 사용량은 20M 정도로 유지된다. 물론, 1000 명의 클라이언트를 동시에 처리하지 않기 때문에 평균적인 응답 시간은 길어지겠지만 어플리케이션의 안정성은 매우 크게 향상된다. 또한 동시 사용자 수가 많지 않다면, 이미 생성되어 있는 쓰레드를 사용하기 때문에 평균적인 응답 시간은 빨라지게 된다.

쓰레드 풀의 구현

앞 부분을 통해서 쓰레드 풀링이 왜 필요한지 알게 되었을 것이다. 이제부터는 쓰레드 풀링의 구현에 대해서 알아보자. 일반적으로 책이나 인터넷상에 공개된 쓰레드 풀링 클래스는 필요에 따라 동시에 수행되는 쓰레드의 개수를 증가시키는 경우는 있어도, 필요한 것에 비해 많은 쓰레드가 수행될 경우 쓰레드의 개수를 감소시키는 경우는 거의 없는 것 같다. 그래서 이 글에서는 간단하게 쓰레드의 개수를 상황에 따라 감소시키는 쓰레드 풀링을 구현할 것이다. 이 글에서 구현할 쓰레드 풀링은 WorkQueue 클래스, ThreadPool 클래스와 PooledThread 클래스 그리고 AleadyClosedException 클래스로 구성되어 있다. 여기서 PooledThread 클래스는 ThreadPool 클래스의 이너(inner) 클래스로서 실제 수행되는 쓰레드에 해당한다.

WorkQueue 클래스

WorkQueue 클래스는 쓰레드 풀 속에 있는 쓰레드들이 수행할 작업을 저장하고 있는 큐이다. 클래스의 이름에서도 알 수 있듯이 이 클래스는 Queue의 역할을 한다. enqueue() 메소드를 사용하여 수행해야 할 작업을 큐에 저장하며, dequeue() 메소드를 사용하여 수행할 작업을 큐로부터 읽어올 수 있다. 또한, close() 메소드를 호출함으로써 WorkQueue의 사용을 끝내도록 하고 있다. WorkQueue 클래스의 소스 코드는 다음과 같다. 소스 코드는 자체는 매우 간단하므로 특별한 설명은 하지 않겠다.

package javacan.thread.pool;

import java.util.LinkedList;

/**
 * 쓰레드가 수행할 작업을 저장하는 큐.
 *
 * @author 최범균, madvirus@tpage.com
 */
public class WorkQueue {
   
   /**
    * 쓰레드가 수행할 작업을 저장한다.
    */
   private LinkedList workList = new LinkedList();
   
   /**
    * WorkQueue의 사용이 끝났는지의 여부를 나타낸다.
    */
   private boolean closed = false;
   
   /**
    * 큐에 새로운 작업을 삽입한다.
    */
   public synchronized void enqueue(Runnable work) 
          throws AleadyClosedException {
      if (closed) {
         throw new AleadyClosedException();
      }
      workList.addLast(work);
      notify();
   }
   
   /** 
    * 큐에 저장된 작업을 읽어온다.
    */
   public synchronized Runnable dequeue() 
          throws AleadyClosedException, InterruptedException {
      while( workList.size() <= 0 ) {
         wait();
         if ( closed ) {
            throw new AleadyClosedException();
         }
      }
      return (Runnable)workList.removeFirst();
   }
   
   public synchronized int size() {
      return workList.size();
   }
   
   public synchronized boolean isEmpty() {
      return workList.size() == 0;
   }
   
   public synchronized void close() {
      closed = true;
      notifyAll();
   }
}

위 코드에서 큐를 구현하기 위해 java.util.LinkedList 클래스를 사용한 이유는 Vector에서 원소를 삭제할 때 발생하는 성능 문제가 LinkedList 클래스에서는 발생하지 않기 때문이다. Vector의 성능 문제에 대한 문제는 JavaCan의 또 다른 기사인 "자바 어플리케이션 성능 향상"을 참조하기 바란다.

ThreadPool 클래스

이제 WorkQueue 클래스를 이용하여 쓰레드 풀링을 구현한 javacan.thread.pool.ThreadPool 클래스에서 대해서 살펴보자. 먼저 ThreadPool 클래스의 소스 코드부터 살펴보자.

package javacan.thread.pool;

/**
 * 실제로 사용되는 쓰레드 풀 클래스.
 * 내부적으로 WorkQueue를 이용하여 쓰레드가 수행해야 할 작업을 저장한다.
 *
 * @author 최범균, era13@hanmail.net
 */
public class ThreadPool extends ThreadGroup {
   public static final int DEAFULT_MAX_THREAD_COUNT = 30;
   public static final int DEAFULT_MIN_THREAD_COUNT = 0;
   public static final int DEFAULT_INITIAL_THREAD_COUNT = 10;
   
   /**
    * 허용되는 idel 쓰레드의 개수
    */
   public static final int DEFAULT_ALLOWED_IDLE_COUNT = 5;
   
   /**
    * 수행할 작업을 저장한다.
    */
   private WorkQueue pool = new WorkQueue();
   
   /**
    * 최소한 생성되어 있어야 할 쓰레드의 개수
    */
   private int minThreadCount;
   
   /**
    * 최대로 생성할 수 있는 쓰레드의 개수
    */
   private int maxThreadCount;
   
   /**
    * 현재 생성되어 있는 쓰레드의 개수
    */
   private int createdThreadCount = 0;
   
   /**
    * 현재 실제 작업을 수행하고 있는 쓰레드의 개수
    */
   private int workThreadCount = 0;
   
   /**
    * 현재 작업을 수행하고 있지 않은 쓰레드의 개수
    * idleThreadCount = createdThreadCount - workThreadCount
    */
   private int idleThreadCount = 0;
   
   /**
    * 풀에서 허용되는 idle 쓰레드의 개수
    */
   private int allowedIdleCount = 0;
   
   /**
    * 쓰레드 풀이 닫혔있는지의 여부
    */
   private boolean closed = false;
   
   private static int groupId = 0;
   private static int threadId = 0;
   
   /**
    * ThreadPool을 생성한다.
    * @param initThreadCount 초기에 생성할 쓰레드 개수
    * @param maxThreadCount 생성할 수 있는 최대 쓰레드 개수
    * @param minThreadCount 최소한 생성되어 있어야 할 쓰레드의 개수
    * @param allowedIdleCount 풀에서 허용되는 Idle 쓰레드의 개수
    */
   public ThreadPool(int initThreadCount, int maxThreadCount,
                     int minThreadCount, int allowedIdleCount) {
      super(ThreadPool.class.getName()+Integer.toString(groupId++) );
      
      if (minThreadCount < 0) minThreadCount = 0; // 최소 쓰레드 개수 검사
      if (initThreadCount < minThreadCount)
         initThreadCount = minThreadCount; // 초기 쓰레드 개수 검사
      if (maxThreadCount < minThreadCount 
         || maxThreadCount < initThreadCount)
            maxThreadCount = Integer.MAX_VALUE; // 최대 쓰레드 개수 검사
      
      if (allowedIdleCount < 0) allowedIdleCount = DEFAULT_ALLOWED_IDLE_COUNT;
      
      this.minThreadCount = minThreadCount;
      this.maxThreadCount = maxThreadCount;
      this.createdThreadCount = initThreadCount;
      this.idleThreadCount = initThreadCount;
      this.allowedIdleCount = allowedIdleCount;
      
      for (int i = 0 ; i < this.createdThreadCount ; i++ ) {
         new PooledThread().start();
      }
   }
   
   public ThreadPool(int initThreadCount, int maxThreadCount, int minThreadCount) {
      this(initThreadCount, maxThreadCount, minThreadCount, DEFAULT_ALLOWED_IDLE_COUNT);
   }
   
   /**
    * 큐에 작업할 객체를 삽입한다.
    *
    * @work 쓰레드가 수행할 작업
    */
   public synchronized void execute(Runnable work) throws AleadyClosedException {
      if (closed) throw new AleadyClosedException();
      
      // 현재 상태 파악 후, 필요하다면 쓰레드 개수를 증가시킨다.
      increasePooledThread();
      pool.enqueue( work );
   }
   
   /**
    * 쓰레드 풀을 종료한다.
    */
   public synchronized void close() throws AleadyClosedException {
      if (closed) throw new AleadyClosedException();
      closed = true;
      pool.close();
   }
   
   /**
    * 필요하다면 PooledThread의 개수를 증가한다.
    */
   private void increasePooledThread() {
      synchronized(pool) {
         // 수행해야 할 작업의 개수가 놀고 있는 쓰레드 개수보다 많다면,
         // 그 차이만큼 쓰레드를 생성한다.
         if (idleThreadCount == 0 && createdThreadCount < maxThreadCount) {
            new PooledThread().start();
            createdThreadCount ++;
            idleThreadCount ++;
         }
      }
   }
   
   private void beginRun() {
      synchronized(pool) {
         workThreadCount ++;
         idleThreadCount --;
      }
   }
   
   /**
    * 쓰레드를 종료할 지의 여부를 나타낸다.
    * @return 쓰레드가 계속 수행해야 하는 경우 false를 리턴,
    *         쓰레드를 종료하고자 할 경우 true를 리턴.
    */
   private boolean terminate() {
      synchronized(pool) {
         workThreadCount --;
         idleThreadCount ++;
         
         if (idleThreadCount > allowedIdleCount && createdThreadCount > minThreadCount) {
            // idle 쓰레드의 개수가 10개를 넘기고, 
            // 현재 생성되어 있는 쓰레드의 개수가 minThreadCount 보다 큰 경우
            createdThreadCount --;
            idleThreadCount --;
            
            return true;
         }
         return false;
      }
   }
   
   /**
    * 큐로부터 작업(Runnable 인스턴스)을 읽어와 run() 메소드를 수행하는 쓰레드
    */
   private class PooledThread extends Thread {
      
      public PooledThread() {
         super(ThreadPool.this, "PooledThread #"+threadId++);
      }
      
      public void run() {
         try {
            while( !closed ) {
               Runnable work = pool.dequeue();
               
               beginRun();
               work.run();
               if (terminate() ) {
                  break; // <- idle 쓰레드의 개수가 많을 경우 쓰레드 종료
               }
            }
         } catch(AleadyClosedException ex) {            
         } catch(InterruptedException ex) {            
         }
      }
   } // end of PooledThread
   
   public void printStatus() {
      synchronized(pool) {
         System.out.println("Total Thread="+createdThreadCount);
         System.out.println("Idle  Thread="+idleThreadCount);
         System.out.println("Work  Thread="+workThreadCount);
      }
   }
}

먼저 WorkQueue 클래스의 인스턴스를 나타내는 pool 필드가 정의된 것을 알 수 있다. 또한, 필드로는 int 타입인 maxThreadCount, minThreadCount, createdThreadCount, workThreadCount, idleThreadCount, allowedIdleCount가 있다. 이 필드들은 각각 차례대로 최대로 생성될 수 있는 쓰레드의 개수, 최소한 생성되어 있어야 할 쓰레드의 개수, 현재 생성되어 있는 쓰레드의 개수, 현재 작업을 수행하고 있는 쓰레드의 개수, 현재 작업을 수행하고 있지 않은 쓰레드의 개수, 허용되는 쉬는 쓰레드의 개수이다. ThreadPool 클래스의 생성자는 이 값들을 초기화한 후, 생성자에서 지정한 개수만큼의 PooledThread 클래스를 생성한 후, PooledThread의 start() 메소드를 호출한다.

PooledThread 클래스는 풀 속에 저장된 쓰레드로서 사용자가 ThreadPool.execute(Runnable) 메소드를 통해서 큐에 저장한 인스턴스를 읽어와 그 인스턴스의 run() 메소드를 호출한다. PooledThread 클래스의 run() 메소드에서 이러한 작업이 이루어진다. run() 메소드를 보면 beginRun()과 terminate() 메소드를 호출하는 것을 알 수 있는 데, 이 두 메소드는 idleThreadCount와 workThreadCount의 값을 알맞게 조절한다. 또한, terminate() 메소드는 쓰레드를 계속해서 수행할지의 여부를 결정한다. 만약 이 메소드가 true를 리턴하게 되면, 해당 쓰레드는 더 이상 작업을 수행하지 않고 종료하게 된다.

ThreadPool 클래스의 execute() 메소드를 살펴보면 pool.enqueue() 메소드를 사용하여 Runnable 인스턴스를 WorkQueue에 삽입하기 전에 increasePooledThread() 메소드를 호출하여, 필요할 경우 또 다른 PooledThread를 생성하여 동시에 수행되는 쓰레드의 개수를 증가시킨다. 쓰레드 풀링의 구현이 약간 이해가 안 될지도 모르지만, PooledThread가 쓰레드라는 점과 PooleThread의 개수를 조절하는 것이 ThreadPool 클래스라는 점을 유념하면서 소스 코드를 분석해보면 그리 어렵지 않게 이해할 수 있을 것이다.

실제로 ThreadPool 클래스의 사용은 다음과 같이 매우 간단하다.

ThreadPool pool = new ThreadPool(5, 40, 0, 5);
pool.execute(  ...  );
pool.execute(  ...  );
.....
pool.close();

AleadyClosedException 클래스

AleadyClosedException 클래스는 WorkQueue 클래스와 ThreadPool 클래스가 닫힌 상태에 있을 때 enqueue()나 dequeue() 또는 execute() 메소드 등을 호출할 때 발생하는 예외 클래스이다. 이 클래스의 소스 코드는 다음과 같다.

package javacan.thread.pool;

/**
 * WorkQueue 클래스의 enqueue(Runnable work) 메소드와 dequeue() 메소드를 호출할 때,
 * 이미 WorkQueue가 닫힌 상태일 경우 발생한다.
 *
 * 또한, ThreadPool 클래스의 
 *
 */
public class AleadyClosedException extends Exception {

   public AleadyClosedException(String msg) {
      super(msg);
   }
   
   public AleadyClosedException() {
      super();
   }
}

ThreadPool 클래스의 사용

실제로 ThreadPool 클래스를 사용하는 간단한 예제 어플리케이션인 TestPool 클래스를 살펴보자. TestPool 클래스의 소스 코드는 다음과 같다.

import javacan.thread.pool.*;

public class TestPool {
   static int count = 0;
   static long sleepTime = 0;
   static ThreadPool pool = null;
   
   public static void main(String[] args) {
      pool = new ThreadPool(Integer.parseInt(args[0]),  // 초기 생성
                    Integer.parseInt(args[1]),  // max
                    Integer.parseInt(args[2]),  // min
                    Integer.parseInt(args[3]) ); // 허용되는 idle 개수
      sleepTime = Long.parseLong(args[4]);
      
      try {
         for (int i = 0 ; i < 15 ; i ++ ) {
            pool.execute(new Runnable() {
               public void run() {
                  pool.printStatus();
                  int local = count++;
                  
                  try {
                     Thread.currentThread().sleep( sleepTime );
                     System.out.println("Test "+local);
                  } catch(Exception ex) {
                     ex.printStackTrace();
                  }
               }
            } );
            try {
               Thread.currentThread().sleep(10);
            } catch(Exception ex) {}
         }
         try {
            Thread.currentThread().sleep(10000);
         } catch(Exception ex) {}
         
         pool.close();
      } catch(AleadyClosedException ex) {
         ex.printStackTrace();
      }
   }
}

이 클래스를 수행해보면 ThreadPool이 생성한 전체 쓰레드의 개수, 쉬는 쓰레드의 개수 등이 어떻게 변경되는 지 관찰할 수 있을 것이다.

결론

ThreadPool 클래스는 쓰레드 풀링을 제공함으로써 전체적인 어플리케이션의 성능을 향상시켜줄 뿐만 아니라 상황에 따라 알맞게 풀 속에서 수행되는 쓰레드의 개수를 증감시킴으로써 효율적으로 자원을 사용할 수 있도록 해준다. 여러분이 작성할 어플리케이션에서 알맞게 ThreadPool 클래스를 수정한다면 좀더 좋은 기능을 제공해주는 쓰레드 풀링을 사용할 수 있을 것이다.

관련링크:


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. java초보 2012.05.11 16:44 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요~
    혹시 Spring3.0저자분이신가요?
    맞는다면 책 정말 잘보고 있습니다.
    이 포스트도 정말 유용하게 보고갑니다.
    멀티쓰레드 이용해서 프로그램 만들고 있는데
    효과적으로 자원을 조정해 쓸 수 있도록 하는데 애먹고 있엇거든요.
    너무 멋지게 짜놓으셔서 이 소스 참고좀 하겠습니다^^;
    감사합니다~~

    • 최범균 madvirus 2012.05.14 11:11 신고  댓글주소  수정/삭제

      넵, 스프링 3.0 책을 쓴 최범균입니다.
      그리고, 이 코드는 오래된 코드이니 이것 보다는 Apache Commons Pool을 사용하시면 풀링을 보다 쉽게 구현할 수 있구요, 쓰레드 풀이 필요하시다면 자바에서 기본으로 제공하는 Executor를 사용하시는 것이 조금 더 쉽습니다.

  2. park 2013.05.09 18:34 신고  댓글주소  수정/삭제  댓글쓰기

    저도 이 블로그를 보고 작업하던 로직에 넣었습니다.
    제가 생각하던 로직이 여기 그대로 있네요. 너무 감사합니다.
    좋은글 잘읽고 가요~

  3. Youtopia 2015.02.01 02:03 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요.
    .NET에서 자바로 전향하려는 개발자입니다.

    .NET도 그렇게 깊게 오래 한건 아니고, ASP 조금 하다가 안드로이드 조금 하다가
    이제 자바로 전향하려 합니다.
    틈틈이 공부하고 있는데 많은 도움이 되네요.
    글 잘보고 갑니다.

    • 최범균 madvirus 2015.02.02 14:19 신고  댓글주소  수정/삭제

      닷넷, 안드로이드 경험이 있으시면 자바를 사용하는 데에는 크게 문제가 없으실 것 같네요. 요즘은 다양한 언어/환경을 구사할 수 있는 분들이 이쁨(?) 받는 시대가 된 것 같아요.

  4. 김태우 2015.11.27 00:41 신고  댓글주소  수정/삭제  댓글쓰기

    궁금한게 있습니다.
    c언어 같은 경우는 IOCP서버 처럼 적정 스레드를 만들어 코어를 잘 사용할 수 있는데,
    자바 가상머신 위에서 돌아가는 자바언어에서도 스레드를 여러개 만든다고 여러 코어가 일을 할 수 있나요?
    서버를 만들고 있는데 가상머신을 돌리는 스레드가 모든 일을 맡아서 하는게 아닌가 걱정됩니다.

    • 최범균 madvirus 2015.11.27 14:04 신고  댓글주소  수정/삭제

      여러 코어를 사용합니다. 가상 머신을 돌리는 쓰레드 1개가 다시 스케줄링 하는 방식은 아닙니다. 단, 쓰레드 별로 특정 CPU를 사용하도록 지정할 수는 없다고 알고 있습니다.

  5. 김상훈 2016.11.16 10:48 신고  댓글주소  수정/삭제  댓글쓰기

    쓰레드에 관해서 보러 왔다가 저작권을 보니 스프링4.0 프로그래밍입문 책 쓰신분이셨군요 갖고있는 책이라서 이름이 낯이 익었다 싶었습니다 글 잘보고 갑니다

올바른 코딩 규칙, 클래스의 올바른 사용을 통해 성능 저하 요소를 없앨 수 있으며 오브젝트 풀링을 함께 사용함으로써 어플리케이션의 전체적인 성능을 향상시킬 수 있다.

자바와 성능 문제

오늘날 자바는 엔터프라이즈 시장에서 확고히 자리잡고 있다. MS에서 닷넷이라는 새로운 플랫폼을 내 놓긴 했지만, 현재 비주얼 스튜디오 닷넷 베타 버전이 나와 있을 뿐이며, 기본적인 닷넷 플랫폼은 2002년에 완성될 예정이며, 2005년 정도나 되야 닷넷 플랫폼과 관련된 모든 것들이 완성될 것으로 예상된다. 따라서 향후 몇 년간은 엔터프라이즈 시장에서, 특히 대규모 프로젝트에서는 EJB/서블릿/JSP를 통해 힘을 받은 자바의 독주가 예상되고 있다.

이처럼 자바가 세상의 주도적인 언어로 자리잡아 가고 있지만, 아직도 자바가 헤어나지 못하고 있는 부분이 있다. 그것은 바로 성능과 관련된 문제이다. 실제로 많은 개발자들이 자바가 처음 세상에 나온 이후부터 계속해서 자바의 성능에 대해 불평을 해 왔다. 실제로 자바를 이용하여 잘 작성된 프로그램은 C++/C를 이용하여 잘 작성된 프로그램 만큼의 성능을 내지는 못하는 것이 사실이다. 이것은 자바가 인터프리터 언어라는 점을 감안하면 어쩔 수 없는 것일지도 모르겠다. 하지만, 실제로 자바에서 대부분의 성능 문제는 자바 때문이라기 보다는 프로그램 그 자체에 있다. 올바른 방법으로 프로그래밍을 할 경우 그렇지 않은 경우에 비해 많은 성능 향상을 일으킬 수 있다. 예를 들어, 자바 어플리케이션에서 데이터베이스 커넥션 풀링을 사용할 경우 시스템 자원의 활용도를 높일 수 있을 뿐만 아니라 애플리케이션은 더욱 견고하고 플랫폼에 독립적이게 된다.

이처럼 성능을 향상시키도록 코딩을 하기 위해서는 java.util.Vector나 java.lang.String과 같은 기본적인 부분에서부터 쓰레드 풀링과 데이터베이스 커넥션 풀링과 같은 것들을 전반적으로 확실하게 이해하고 있어야 한다. 이 글에서는 자바 프로그래밍에서 성능을 향상시키기 위한 몇가지 규칙에 대해서 살펴볼 것이다.

String vs. StringBuffer

자바에서 가장 많이 사용되는 것이 있다면 아마 java.lang.String일 것이다. 하지만, String 클래스는 가장 비효율적으로 사용되고 있는 클래스 중의 하나이다. 다음의 코드를 살펴보자.

String s1 = "Testing String";
String s2 = "Concatenation Performance";
String s3 = s1 + " " + s2;

많은 자바 개발자들은 위 코드가 비효율적이라는 것을 알고 있으며, 이처럼 String을 계속해서 더해나가야 하는 경우에는 StringBuffer 객체를 사용하는 것이 더 좋다는 사실도 알고 있다. 아마 다음과 같이 StringBuffer를 사용하여 위 코드를 대체할 것이다.

StringBuffer s = new StringBuffer();
s.append("Testing String");
s.append(" ");
s.append("Concatenation Performance");
String s3 = s.toString();

아마도 여러분은 StringBuffer를 사용한 위 코드가 앞에서 String 객체를 직접 연결한 것보다 더 효율적이라고 생각할 것이다. 하지만, 그 생각은 틀렸다! 여러분은 아마도 지금 StringBuffer를 사용하는 것이 각각의 String 객체보다 더 효율적이지 않다면, 왜 대부분의 사람들은 StringBuffer를 사용하는 것이 성능이 좋다라고 강조하는 지 의아해 할지도 모른다. 물론, StringBuffer 클래스를 사용하는 것이 각각의 String 객체를 사용하는 것 보다 더 효율적이다. 단, 성능이 좋은 경우는 StringBuffer 클래스를 알맞게 사용했을 때의 얘기이다. 이해를 돕기 위해 StringBuffer 클래스의 기본 생성자를 살펴보자.

public StringBuffer() {
this(16);
}

위 코드는 StringBuffer가 16개의 글자를 저장할 수 있다는 것을 의미한다. 이제 StringBuffer의 append() 메소드를 살펴보자.

    public synchronized StringBuffer append(String str) {
if (str == null) {
    str = String.valueOf(str);
}

int len = str.length();
int newcount = count + len;
if (newcount > value.length)
    expandCapacity(newcount);
str.getChars(0, len, value, count);
count = newcount;
return this;
}

append() 메소드는 먼저 새로 추가할 String의 길이를 구한다. 그리고 현재 StringBuffer에 저장되어 있는 문자열의 길이(count)와 새로 추가할 문자열의 길이(len)의 합이 StringBuffer가 저장할 수 있는 용량(value.length)보다 크면 expandCapacity()를 호출한다. expandCapacity()는 메로리에 새로운 저장 공간을 생성한 후, 기존의 내용을 새로운 공간에 저장한다. 즉, 새로운 객체가 생성되는 것이다. 바로 이 점을 올바르게 알고서 StringBuffer 클래스를 사용해야 한다. 앞에서 StringBuffer를 사용하여 더하고자 하는 문자열은 "Testing String"과 " ", 그리고 "Concatenation Performance" 였다. 이 문자열을 모두 연결하면 " Testing String Concatenation Performance"가 되며, 이것의 길이는 16자가 넘어간다. 따라서 expandCapacity() 메소드를 통해 새로운 객체를 생성하게 되는 것이다. 이렇게 되면 StringBuffer를 사용한 경우나 사용하지 않은 경우나 별 차이가 없게 된다.

그렇다면 어떻게 해야 하는가? 이미 그 해답을 알았을 것이다. 바로 StringBuffer를 생성할 때 알맞은 저장 용량을 지정해주어야 해야 하는 것이다. 즉, 다음과 같이 코드를 변경해주면 된다.

StringBuffer s = new StringBuffer(45);
s.append("Testing String");
s.append(" ");
s.append("Concatenation Performance");
String s3 = s.toString();

다시 한번 말하지만, StringBuffer의 저장용량을 알맞게 지정해 줄 때에 비로서 효율적으로 메모리를 사용할 수 있다는 점을 기억하자!

java.util.Vector 클래스

String 다음으로 많이 사용되는 클래스가 있다면 바로 java.util.Vector일 것이다. Vector는 객체들을 저장하고 있는 리스트라고 할 수 있다. 배열과 비슷하게, 인덱스를 사용하여 Vector가 저장하고 있는 객체에 접근할 수 있다. 하지만, 배열과 달리 Vector가 저장할 수 있는 객체의 수는 가변적이다. 즉, Vector를 생성한 이후에 새로운 객체를 추가하거나 삭제할 수 있다. 또한, 인덱스를 사용하여 지정된 위치에 객체를 삽입하거나 지정된 위치에 있는 객체를 삭제할 수도 있다. Vector는 객체를 저장하기 위해서 내부적으로 배열을 사용한다. 즉, 배열의 길이가 저장할 수 있는 객체의 수가 되는 것이다. 만약 그 배열의 길이보다 더 많은 수를 저장해야 한다면 어떻게 될까? Vector는 StringBuffer와 비슷하게 기존의 배열보다 더 긴 새로운 배열을 만들고 기존 배열에 있는 내용을 그대로 복사하게 된다. 따라서 Vector를 생성할 때 알맞게 Vector의 저장 용량을 지정해주는 것이 좋다. 참고로 Vector 클래스의 기본 생성자는 저장 용량의 크기를 10으로 한다.

Vector 클래스에서 성능에 또 다른 문제가 될 수 있는 메소드가 바로 add(index, obj) 메소드이다. 여기서 index는 obj 객체를 저장할 인덱스를 나타낸다. 예를 들어, 가장 앞에 새로운 객체를 추가하고 싶다고 해 보자. 이 경우 다음과 같이 프로그램할 것이다.

Object obj = new Object();
Vector v = new Vector(7);
v.add(0, obj);

그렇다면 왜 add(index, obj) 메소드가 문제가 될 수 있는 지 살펴보자. add(index, obj) 메소드는 내부적으로 insertElementAt() 메소드를 호출한다. 이 메소드가 하는 역할을 index 값으로 명시된 위치에 obj 객체를 삽입하는 것이다. 이를 그림으로 도식화하면 다음과 같다.


위 그림에서 ob0 부터 ob4는 이미 Vector에 저장되어 있는 객체를 의미하며, 5번째와 6번째는 어떤 객체도 할당되지 않았음을 나타낸다. Vector의 맨 앞에 객체를 삽입하게 되면, 그림에서 보듯이 0번째 이후에 있는 모든 객체들이 하나씩 뒤로 이동하게 된다. 이렇게 하나씩 뒤로 이동시키기 위해서는 뒤에서부터 차례대로 모든 객체의 위치를 변경해주어야 한다. 위의 그림처럼 Vector에 소수의 객체가 저장되어 있는 경우에는 성능에 별다른 문제가 발생하지 않겠지만, 만약 저장되어 있는 객체가 수천, 수만에 이른다면 중간에 어떤 객체를 삽입하는 것, 특히 맨 앞에 삽입하는 것은 성능에 문제가 될 수 있다. 따라서 어떤 특정한 위치에 반드시 삽입해야 하는 경우가 아니라면 Vector의 맨 뒤에 객체를 삽입하는 것이 성능에 있어 효율적이다.

이와 비슷한 문제가 객체를 삭제할 때에도 발생한다. Vector의 맨 뒤에 있는 객체를 삭제하는 것에 비해 중간에 있는 객체를 삭제하는 것이 아무래도 Vector에 있는 더 많은 객체를 이동하게 만든다. 이말은 맨 앞에 있는 객체를 삭제하는 것에 비해 맨 뒤에 있는 객체를 삭제하는 것이 더 빠르다는 것을 의미한다.

Vector 클래스에서 많이 사용하는 메소드 중의 하나로 indexOf(Object)가 있다. 이 메소드는 특정한 객체가 저장된 인덱스를 구해준다. 성능에 주의를 기울이지 않을 경우, 어떤 특정한 객체를 삭제하기 위해서 indexOf() 메소드를 사용하여 다음과 같이 코딩하는 경우가 있다.


얼핏 보면 위 코드가 별다른 문제가 없는 것으로 보일 것이다. 하지만, indexOf(Object) 메소드와 remove(Object) 메소드는 둘다 순차탐색을 사용한다. indexOf(Object) 메소드를 통해서 이미 obj 객체가 저장된 위치를 알았음에도 불구하고 remove(Object) 메소드에서 또 다시 순차탐색을 하는 것이다. 저장된 객체의 개수가 많을 경우 순차 탐색은 좋은 성능을 발휘하지 못하는데, 이러한 순차탐색을 두번이나 한다는 것은 많은 성능 저하를 일으키는 부분이 될 수 있다. 따라서 이미 삭제할 객체의 인덱스를 알고 있는 경우에는 다음과 같이 remove(int) 메소드를 사용하여 객체를 삭제하는 것이 성능 저하를 일으키지 않는다.

SomObject obj = ...;
int i = v.indexOf(obj);
if(i != -1)
v.remove(i);

이를 좀더 개선하면 다음과 같다.

SomObject obj  = ...;
v.remove(s);

Vector 클래스를 사용하여 개발을 하다보면 size() 메소드를 매우 빈번하게 사용하게 된다. size() 메소드를 사용하는 부분 중에는 Vector에 있는 모든 객체를 참조하고 싶은 경우가 많다. 예를 들어, 채팅 프로그램을 개발할 경우 현재 방에 있는 모든 사람한테 특정한 메시지를 보내는 경우가 있는데, 이러한 경우에는 다음과 같은 형태로 프로그래밍하게 된다.

for (int i = 0 ; i < vec.size() ; i++ ) {
ChatServerWorker worker = (ChatServerWorker) vec.get(i);
worker.sendMessage("...");
}

여기서 성능 저하를 일으킬 수 있는 부분은 바로 for ( ; ; ) 문이다. vec.size() 메소드가 for 구문안에 위치함으로써 vec.size() 메소드는 Vector의 크기만큼 호출된다. 만약 십만개의 객체가 저장되어 있다면 size() 메소드도 십만번 호출되는 것이다. 프로그램 코드 중 실제로 전체 수행시간의 70 퍼센트 이상을 for 문이나 while 문과 같은 반복문이 차지한다는 점을 감안해보면 이처럼 반복문에서 같은 값을 구하기 위해 매번 특정한 작업을 수행한다는 것은 성능을 저하시키는 원인이 될 수 있다. 이처럼 반복문에서의 성능 저하를 줄이기 위해서는 위 코드를 다음과 같이 변경해야 한다.

int s = vec.size();
for (int i = 0 ; i < s ; i++ ) {
ChatServerWorker cp = (ChatServerWorker) vec.get(i);
worker.sendMessage("...");
}

이처럼 간단하게 변경만 해 주어도 여러분이 작성한 애플리케이션은 CPU를 좀 더 효과적으로 사용할 수 있게 된다.

객체의 재활용: 오브젝트 풀링

오늘날 많은 웹 어플리케이션을 개발할 때 많이 사용되는 제품이 바로 어플리케이션 서버이다. BEA의 웹로직이나 IBM의 웹스피어와 같은 제품이 바로 어플리케이션 서버이다. 이러한 어플리케이션 서버들은 대부분 은행이나 증권 사이트와 같은 대형 프로젝트에서 사용되고 있다. 이러한 대형 사이트는 동시 접속자 수가 수백/수천/수만에 이르며, 따라서 어플리케이션 서버는 성능을 향상시키기 위해 내부적으로 많은 것들을 지원하고 있다. 어플리케이션 서버들이 성능을 향상시키기 위해 많이 사용하는 것 중의 하나가 바로 오브젝트 풀링이다. 오브젝트 풀링이 가장 많이 사용되는 부분을 손꼽으라면 데이터베이스 커넥션 풀링, 쓰레드 풀링 그리고 EJB에서의 빈 컴포넌트 풀링이다. 특히 데이터베이스 커넥션 풀링은 어플리케이션 서버를 사용하지 않는 프로젝트에서도 성능을 향상시키기 위해 많이 사용되고 있다.

오브젝트 풀링은 기존에 생성된 객체를 재사용하는 것이 주목적이다. 오브젝트 풀링의 기본 형태는 풀(pool)로부터 미리 생성해 놓은 객체를 구해서 사용하고, 사용이 끝나면 다시 그 객체를 풀 속에 넣는 것이다. 즉, 풀은 사용가능한 객체를 저장하고 있는 저장소가 된다. 이 글에서는 오브젝트 풀링을 어떻게 구현하는 지에 대해서는 언급하지 않을 것이다. 각각의 풀링이 어떻게 구현하는 지 알고 싶은 사람은 관련 링크를 참고하기 바란다. 가장 많이 사용되는 데이터 베이스 커넥션 풀링과 쓰레드 풀링이 어떻게 어플리케이션의 성능을 향상시켜주는 지에 대해서 알아보자.

데이터베이스 커넥션 풀링

오늘날 웹 어플리케이션을 비롯한 대부분의 어플리케이션이 데이터베이스에 데이터를 저장하고 데이터베이스로부터 데이터를 읽어온다. 일반적으로 데이터베이스를 사용하기 위해서는 "데이터베이스 연결 - 필요한 작업 수행 - 연결 해제"의 3 단계를 거치게 된다. 여기서 데이터베이스에 연결할 때는 적지 않은 시간을 필요로 한다. 특히 어플리케이션과 데이터베이스가 서로 다른 호스트에 존재할 경우에는 데이터베이스에 연결하기 위해서 소켓접속을 필요로 하며 따라서 그 만큼 더 많은 시간을 필요로 한다. 동시 사용자 수가 적을 경우에는 별다른 문제가 발생하지 않을 수도 있지만, 동시 사용자 수가 수백, 수천명에 이른다면? (여기서 동시 사용자는 1초보다도 더 적은 시간에 접속하는 사용자의 수를 의미한다). 어플리케이션은 수백 수천의 요청에 대해 각각 하나씩의 데이터베이스 커넥션을 생성하려 할 것이다. 하지만, 이렇게 수백, 수천의 커넥션을 동시에 생성하기 위해서는 많은 시간을 필요로 하며, 시스템 자원 역시 상당량 소모될 것이다.

데이터베이스 커넥션 풀링은 이처럼 데이터베이스에 연결하기 위해 소모되는 시간을 줄임으로써 어플리케이션이 클라이언트의 요청에 대해 빠르게 응답할 수 있도록 해 준다. 뿐만 아니라 동시에 접속되어 있는 커넥션의 개수를 일정하게 유지함으로써 시스템 자원을 효과적으로 사용할 수 있게 된다.

쓰레드 풀링

웹 어플리케이션을 개발하는 경우 쓰레드 풀링을 직접적으로 사용해야 하는 경우는 극히 드물다고 할 수 있다. 하지만, 웹 서버나 파일서버, 또는 어플리케이션 서버와 같이 동시에 많은 양의 클라이언트 요청을 처리해야 하는 경우에는 상황이 좀 다르다. 예를 들어, 여러분의 웹 서버에 동시에 100명이 서비스를 요청했다고 해 보자. 여러분이 사용하는 웹 서버가 쓰레드 풀링을 사용하지 않는다면 각각의 클라이언트에 대해서 한 개의 쓰레드가 생성될 것이다. 즉, 100개의 쓰레드가 생성되는 것이다. 자바 가상 머신의 구현에 따라 다르지만 하나의 쓰레드가 많을 경우 200k 정도의 메모리를 차지한다. 따라서 100개의 쓰레드를 사용하려면 20,000K 정도의 메모리가 필요한 것이다. 이 수치는 20M에 가까운 수치다. 대형 웹 사이트의 경우 매 순간마다 천명 이상의 요청이 웹 서버에 들어올 수 있으며, 만약 하나의 요청당 하나의 쓰레드가 생성된다면 쓰레드를 생성하는 데 드는 메모리만 200M 이상의 될 수 있다. 여기서 더욱 문제가 되는 점은 쓰레드의 실행이 종료되어도 쓰레드와 관련된 객체들이 곧 바로 가비지 콜렉션되지 않고 메모리에 남아 있다는 점이다. 클라이언트의 요청은 계속해서 들어올 것이며, 기존에 생성된 쓰레드와 관련된 객체는 가비지로 남아 있는 채, 또 다른 객체를 메모리에 할당하게 된다. 따라서, 빠른 시간내에 메모리는 가비지로 차게 되며, 이는 빈번한 가비지 콜렉션을 발생시키는 원인이 된다. 즉, 쓰레드를 생성하고 가비지 콜렉션을 수행하는 데 적지 않은 시간이 소모되는 것이다. 또한, 수백/수천개의 쓰레드가 생성되면 자바 가상 머신은 그러한 쓰레드를 스케쥴링 하는데 많은 시간을 소모하게 된다. 쓰레드를 스케쥴링하는 데 CPU를 많이 사용하면 그 만큼 실제 작업을 수행할 수 있는 시간이 줄어들게 되는 것이며, 이는 사용자의 요청에 대한 느린 응답으로 연결된다. 결과적으로 배보다 배꼽이 더 큰 상황이 발생하는 것이다.

쓰레드와 관련된 성능 저하는 쓰레드 풀링을 사용함으로써 많은 부분 해결할 수 있다. 미리 풀 속에 사용가능한 쓰레드를 일정 개수 생성한 후, 쓰레드가 필요할 때 마다 풀에 저장되어 있는 풀을 사용하기 때문에 쓰레드를 생성하는 데 소비되는 시간을 줄일 수 있으며, 따라서 쓰레드 관련 객체의 생성/삭제에 따른 가비지 객체의 생성 및 빈번한 가비지 콜렉션 수행에 따른 시간 소비를 줄일 수 있다. 또한, 일정 개수만큼의 쓰레드만을 유지하기 때문에 쓰레드를 스케쥴링 하는 데 소비되는 시간도 일정하다. 이처럼 불필요하게 낭비되는 시간을 줄임으로써 어플리케이션은 클라이언트의 요청에 대해 좀 더 빠르게 응답할 수 있을 것이다.

결론

이번 글에서는 좋은 코딩 규칙을 통해서 성능 저하 부분을 없애고 오브젝트 풀링을 통해서 성능을 향상시키는 것에 대해서 알아보았다. 여기서 코딩 규칙과 관련된 부분은 대다수의 개발자들이 쉽게 간과하는 부분이지만, 코딩을 조금만 더 신경써서 하면 성능 저하를 상당히 줄일 수 있다는 점에서 매우 중요하다고 할 수 있다. 특히, String, StringBuffer 그리고 Vector와 같이 많이 사용되는 클래스가 내부적으로 어떻게 동작하는 지 알고 있어야 하며, 그렇게 함으로써 적은 노력으로 성능 저하 요소를 상당부분 없앨 수 있다. 코딩 규칙 뿐만 아니라 오브젝트 풀링을 사용함으로써 전체적인 어플리케이션의 응답속도가 향상될 뿐 아니라 전체적인 어플리케이션의 처리량, 즉 throughput을 증가시킬 수 있다.

이제, 자신들이 만든 어플리케이션이 성능이 좋지 않다고 해서 무턱대고 자바를 탓해서는 안 되는 때가 온거 같다는 생각이 든다. 이제, 자바 개발자들은 올바른 코딩 습관을 몸에 익히고 클래스를 올바르게 사용함으로써 개발하는 어플리케이션의 성능 저하 요소를 없애야 하며, 또한 오브젝트 풀링을 비롯한 다양한 방법을 통해서 성능 향상을 꾀할 수 있도록 노력해야 한다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 최창원 2014.06.04 20:42 신고  댓글주소  수정/삭제  댓글쓰기

    좋은내용 감사합니다.^^

  2. 최창원 2014.06.04 20:42 신고  댓글주소  수정/삭제  댓글쓰기

    좋은내용 감사합니다.^^