주요글: 도커 시작하기
반응형
메시징 시스템의 개요와 JMS에 대해 알아본다.

메시징 시스템

분산 어플리케이션이 급격히 증가하면서 따라서 이전에 발생하지 않았던 동기화, 안정성, 확장성 그리고 보안 등에서 문제가 발생하기 시작하였다. 이에 대한 한가지 해결책은 메시지를 통해 각 컴포넌트 사이의 결합성(coupling)을 약화시키는 메시징 시스템이다.

메시징 시스템은 안정하고, 확장 가능하고 그리고 유연한 분산 어플리케이션을 제작하기 위해 사용된다. 이 글에서는 일반적인 메시징 시스템에 대한 내용과 메시징 시스템의 종류에 대해서 알아보며, 그런 후 개발자들이 JMS(Java Message Service; 자바 메시지 서비스)를 이용하여 메시지 기반의 어플리케이션을 어떻게 작성할 수 있는지에 대해서 알아본다.

메시징 시스템은 분리된 결합되어 있지 않은 어플리케이션이 비동기적으로 신뢰성있게 통신할 수 있도록 해 준다. 메시징 시스템 아키텍처는 일반적으로 각 컴포넌트 사이의 관계를 클라이언트/서버 모델에서 피어-투-피어(peer-to-peer) 관계로 변경한다. 각각의 피어는 다른 피어에 메시지를 전송할 수 있으며, 또한 다른 피어로부터 메시지를 전달받을 수 있다.

메시징 시스템은 고전적인 분산 컴퓨팅 모델에 비해 더욱 강력한 장점을 제공한다. 먼저, 메시징 시스템은 메시지 소비자(message consumers)와 메시지 생산자(message producer)가 약한 결합성(loose coupling)을 갖도록 한다. 메시지 생산자와 소비자는 서로를 거의 알지 못한다. 메시지 소비자에게 있어서, 그 메시지를 누가 생산했고 생선자가 어디에 있는 지 또는 메시지가 언제 생산되었는지의 여부는 문제가 되지 않는다.

이러한 메시징 시스템의 특징은 동적이고, 신뢰성 있고 유연한 시스템을 구현할 수 있도록 해 주며, 그에 따라 시스템의 나머지 부분에 영향을 않고 하위 어플리케이션의 전체적인 구성을 변경할 수 있다.

메시징 시스템의 또 다른 장점은 높은 확장성, 서로 다른 네트워크 사이의 쉬운 통합 그리고 안정성이다.

메시징 시스템의 안정적이고 확장가능한 특징 때문에, 많은 비지니스와 컴퓨팅 사이언스 문제를 해결하기 위해 메시징 시스템을 사용하고 있다. 예를 들어, 메시징 시스템은 워크플로우, 네트워크 관리, 통신 서비스, 고객 관리, 일기 예보 시스템과 같은 다양한 어플리케이션의 기반이 되고 있다. 또한, 메시징 시스템은 통합이 필연적인 분리된 시스템들을 엮어주는 매개체로서의 중요한 역할을 하고 있다.

메시징 시스템의 종류

일반적으로 사용되는 메시징 시스템의 종류에는 출판/구독(Publish/Subscribe) 방식과 포인트-투-포인트(Point-To-Point) 방식의 두 종류가 있다.

출판/구독(Publish/Subscribe) 방식

출판/구독 메시징 시스템은 메시지의 전송에 참여하고 있는 생산자(producer)와 소비자(consumer)가 이벤트를 사용하여 통신한다. 생산자는 이벤트를 "출판(즉, 발생)"하고 소비자는 자신이 관심을 갖고 있는 이벤트를 "구독"하여 그 이벤트를 소비한다. 생산자는 메시지를 특정한 토픽과 연관시키며, 그 메시지는 그 메시지와 관련된 토픽에 등록한 소비자에게 전달된다.

점대점(Point-To-Point) 방식

점대점(Point-To-Point) 메시징 시스템에서 메시지는 개개의 소비자에게 전달되며, 각각의 소비자는 들어오는 메시지를 저장하는 큐를 갖고 있다. 메시징 어플리케이션은 지정된 큐에 메시지를 보내고, 클라이언트는 큐로부터 메시지를 읽어들인다.

Java Message Service

자바 메시지 서비스는 J2EE(Java 2 Enterprise Edition)의 부분이며, 자바 개발자들이 엔터프라이즈 메시지 시스템의 공통적인 특징에 접근하기 위해 사용할 수 있는 표준 API를 제공한다. JMS는 출판/구독 모델과 포인트-투-포인트 모델을 지원하며, 임의의 자바 객체로 구성된 메시지 타입을 생성할 수 있도록 해 준다.

디자인 목적

JMS의 기본적인 설계 목적은 메시징 시스템의 클라이언트가 하부의 메시징 시스템 프로바이더에 독립적으로 사용할 수 있는 일관된 인터페이스 집합을 제공하는 것이다.

JMS는 머신 아키텍처와 운영체제에 상관없이 클라이언트 어플리케이션이 이식성을 갖도록 해줄 뿐만 아니라, 또한 메시징 제품에 상관없는 이식성을 갖도록 해 준다. JMS로 작성된 클라이언트 애플리케이션은 JMS 호환 메시징 시스템에서 변경할 필요 없이 사용할 수 있다.

JMS는 또한 다음과 같은 목적으로 설계되었다.

  • 메시징 시스템 프로바이더가 그들의 제품을 위해 JMS API를 구현하기 위해 필요한 노력을 최소화시켜준다.
  • 대부분의 일반적인 메시징 시스템 기능을 제공한다.
많은 메시징 시스템 벤더들은 그들의 제품에 맞게 JMS를 구현하였으며, 따라서 자바를 사용하여 시스템의 기능에 접근할 수 있도록 하고 있다.

JMS 클라이언트는 자바의 특징을 사용한다

JMS 클라이언트는 자바를 기반으로 하고 있기 때문에 JDBC, 자바빈 컴포넌트, JNDI, JTA 또는 JavaMail 등의 기존의 존재하는 자바 API를 사용할 수 있다.

JMS의 세부 내용

이제부터 JMS를 사용한 메시징 시스템의 클라이언트를 작성하는 것에 대해서 좀 더 세부적으로 알아보도록 하자. 먼저 알아볼 내용은 메시징 시스템의 가장 기본 요소인 메시지이다.

메시지

메시징 시스템에서 어플리케이션 간의 통신의 핵심은 메시지이며, 따라서 JMS를 사용하는 개발자들은 메시지에 대해 이해하고 있어야 한다. 메시징 시스템에 따라 메시지의 정의가 다양하기 하지만, JMS는 메시지를 설명하고 접근하는 통합된 의미를 사용하고 있다. JMS에서 메시지는 세 부분으로 구성되어 있다.

  • 메시지 헤더:
    메시지를 구분하기 위해 사용된다. 예를 들어, 주어진 메시지가 특정 구독자에게 알맞은 것인지를 판단하기 위해 헤더를 사용한다.
  • 프로퍼티:
    어플리케이션에 특정한 값, 프로바이더에 특정한 값 그리고 추가적인 헤더 필드를 제공하기 위해 사용된다.
  • 몸체:
    메시지의 내용을 저장한다. TestMessage나 ObjectMessage와 같은 다양한 포맷을 지원한다.
TextMessage

TextMessage는 Strign 객체를 감싸고 있다. 이 객체는 단지 문자열만 전송할 때 유용하게 사용할 수 있다. 앞으로 많은 메시징 시스템들이 XML 기반으로 될 것으로 예상되기 때문에, TextMessage는 대부분의 메시징 시스템에서 지원될 것이다.

TextMessage 객체를 생성하는 것은 매우 간단하며, 다음과 같은 두줄만으로 생성할 수 있다.

TextMessage message = session.createMessage();
message.setText("hello world!");

이런 방법으로 생성된 TextMessage 객체는 메시징 시스템에 출판할 준비가 된 것이다.

ObjectMessage

ObjectMessage 객체는 그 이름에서 알 수 있듯이 일반적인 자바 객체를 감싸고 있는 메시지이다. 모든 직렬화가능한 자바 객체를 ObjectMessage로 사용할 수 있다. 만약 하나의 메시지에 다중의 객체를 전송해야 한다면, List나 Set과 같은 콜렉션(Collection)객체를 사용하면 된다. 이 경우, 콜렉션 객체는 직렬화가능한 객체만을 저장하고 있어야 한다.

ObjectMessage 객체는 다음과 같이 생성한다.

ObjectMessage message = session.createObjectMessage();
message.setObject(somObject);

JNDI와 관련해서 알아야 할 점

J2EE의 다른 API와 마찬가지로 JMS는 필요한 자원을 찾기 위해서 JNDI(Java Naming and Directory Interface)를 사용한다. JNDI에 대한 내용은 이 글의 범위를 넘어서므로, JNDI에 대해서 자세히 알고 싶다면 JNDI 홈페이지를 참조하기 바란다.

JMS 클라이언트 작성

일반적은 JMS 클라이언트는 다음의 기본적인 3 단계를 통해서 작성된다.

  1. 메시징 시스템 프로바이더와 연결된 커넥션을 생성한다.
  2. 메시지를 전송하기 받기 위한 세션을 생성한다.
  3. 메시지를 생성하고 받기 위해 MessageProducer와 MessageConsumer를 생성한다.
이 과정을 수행하면, 메시지를 생성하는 클라이언트는 메시지를 생성한 후 특정 주제로 그 메시지를 출판할 수 있다. 반면에 메시지를 소비하는 클라이언트는 자신이 관심을 갖는 주제와 관련된 메시지를 기다리고 있다가 메시지가 도착하면 그것을 소비한다.

실제로 어떻게 이런 것들이 이루어지는 알아보기 위해 출판/구독 메시징 시스템에서 특정 주제와 관련된 메시지를 출판하는 메시지 생산자에 대해 살펴보기로 하자. 좀더 쉽게 알아볼 수 있도록 하기 위해 예외 처리와 관련된 코드는 생략하였다.

메시징 시스템 프로바이더에 연결하기

커넥션(Connection)은 클라이언트가 하부의 메시징 시스템에 접근할 수 있도록 해 주며, 자원 할당과 관리를 수행한다. ConnectionFactory를 사용하여 커넥션을 생성한다. ConnectionFactory는 일반적으로 JNDI를 사용하여 위치시킨다.

다음 코드는 커넥션 생성 단계와 관련된 부분을 보여주고 있다.

Context messaging = new InitialContext();
// JNDI 콘텍스트를 구한다.
TopicConnectionFactory topicConnectionFactory = 
        (TopicConnectionFactory)messaging.lookup("TopicConnectionFactory");
TopicConnection topicConnection =
       topicConnectionFactory.createTopicConnection();

세션 생성

세션은 메시지 생산과 소비를 위한 콘텍스트를 제공하는 경량의 JMS 객체이다. 메시지 생산자와 소비자를 생성할 때 세션을 사용하여 또한 메시지를 생성할 때에도 세션을 사용한다.

TopicSession session =
     topicConnection.createTopicSession(false, Session.CLIENT_ACKNOWLEDGE);

createTopicSession() 메소드에 전달되는 두 파라미터는 각각 트랜잭션과 메시지 인식을 제어한다.

토픽 지정

토픽(주제, 그룹 또는 채널이라고도 한다)은 JNDI를 통해서 위치시킬 수 있다. 토픽은 전송되거나 수신된 메시지를 분류한다. 출판/구독 시스템에서, 구독자는 주어진 토픽을 구독하며, 출판자는 그들이 출판한 메시지를 특정한 토픽과 연관시킨다.

다음은 "StockData"라고 불리는 토픽을 생성하는 코드를 보여주고 있다.

Topic stockTopic = messaging.lookup("StockData");

커넥션 시작

위의 과정을 거치는 동안에 메시지의 흐름은 초기화하는 동안에 예측할 수 없는 행동을 하지 못하도록 하기 위해 억제된다. 일단 초기화가 끝나면, 커넥션은 반드시 메시지 이름을 시작해야 한다.

topicConnection.start();

메시지 생산자 생성

출판/구독 시스템에서, 생산자는 주어진 토픽에 메시지를 출판한다. 다음 코드는 출판자를 생성하고 이어서 간단한 텍스트 메시지를 출판하는 것을 보여주고 있다.

TopicPublisher publisher =
     session.createPublisher(stockTopic);
TextMessage message = session.createMessage();
message.setText("kosdaq: 101");

publisher.publish(message);

비슷한 과정을 통해서 구독자를 생성할 수 있으며, 또한 점대점 시스템을 위한 JMS 클라이언트를 생성할 수 있다.

결론

이 글에서는 메시징 기반의 어플리케이션을 작성하기 위해 JMS를 사용하는 것과 관련된 기본적인 개념에 대해서 살펴보았다. JMS 호환 시스템의 목록은 "JMS 호환 벤더 목록"을 참고하기 바란다.

관련링크:
  1. boolsee 2014.03.18 17:22

    저같은 초보를 위한 좋은 글이네요. 잘 읽었습니다. 고맙습니다.

반응형
Character 클래스를 이용하여 문자 관련 문제를 좀더 수월하게 처리할 수 있다.

java.lang.Character 클래스

java.lang.Character 클래스는 기본 테이터 타입은 char에 대한 래퍼 클래스(wrapper class)이다. Integer나 Double과 같은 다른 래퍼 클래스와 마찬가지로, Character 클래스는 기본 데이터 타입의 값을 객체 형식으로 표현하기 위해 사용된다. 따라서, Character 클래스를 사용하여 Vector나 ArrayList와 같은 콜렉션 객체에 char 타입을 나타내는 값을 저장할 수 있다. 뿐만 아니라 Character 클래스는 유니코드 캐릭처를 처리할 때 사용되는 몇몇 메소드와 상수값을 제공하고 있다.

먼저 char 타입에 대한 객체 형식으로서 Character 래퍼 클래스를 사용하는 예제를 살펴보자. 다음 예제는 특정의 char 값에 해당하는 Character 객체를 List 객체에 저장하는 것을 보여주고 있다.

import java.util.*;

public class CharDemo1 {
   public static void main(String args[]) {
      List list = new ArrayList();
      
      list.add(new Character('a'));
      list.add(new Character('b'));
      list.add(new Character('c'));
      
      for (int i = 0; i < list.size(); i++) {
         System.out.println(list.get(i));
      }
   }
}

이 예제에서는 글자 'a', 'b', 'c'를 나타내는 세 개의 Character 객체를 List에 추가한 후, List에 저장된 내용을 차례대로 보여주고 있다.

대부분의 래퍼 클래스와 마찬가지로 Character 클래스 역시 유용하게 사용할 수 있는 다양한 메소드를 제공하고 있다. 그러한 메소드에는 isDigit()이나 isSpaceChar()와 같은 것들이 있다. 예를 들어, 사용자가 입력한 글자가 숫자인지를 판단해야 한다고 해보자. Character 클래스를 사용하지 않을 경우 다음과 같은 형태의 코딩을 해야 한다.

char c = ...;
if (c >= '0' && c <= '9') {
    ...
}

이런 형태의 코드가 올바르게 실행되지 않는 것은 아니지만, 문제의 소지가 있다. 예를 들어, 숫자를 표시하는 글자는 단순히 '0' 부터 '9'까지만 존재하는 것이 아니며, 자바는 ASCII 코드가 아닌 유니코드를 사용하기 때문에 이것이 문제가 발생할 수 있다. 예를 들어, 다음 프로그램을 수행해보자.

public class CharDemo2 {
   public static void main(String args[]) {
      int dig_count = 0;
      int def_count = 0;
      
      for (int i = 0; i <= 0xffff; i++) {
         if (Character.isDigit((char)i)) {
            System.out.print( (char)i );
            dig_count++;
         }
         if (Character.isDefined((char)i)) {
            def_count++;
         }
      }
      
      System.out.println("number of digits = " + dig_count);
      System.out.println("number of defined = " + def_count);
   }
}

이 프로그램을 실행하면 dig_count의 값은 159가 된다. 즉, 자바에서는 숫자로 분류되는 글자가 159개가 존재한다는 것이다. 이때 출력되는 글자에는 '0', '1'과 같이 일반적으로 사용되는 글자도 있지만, 그 외에 숫자로 사용되는 글자들도 출력되는 것을 알 수 있다. (여러분이 사용하는 운영체제가 대부분 한글 Windows일 것이기 때문에, '0'부터 '9'이외의 나머지 숫자를 의미하는 글자들은 '?'로 출력될 것이다.) 이 프로그램의 수행 결과중에 한가지 재미있는 사실은 유니 코드의 전체 범위인 65536 개의 글자 중에 47400 개의 글자만이 정의되어 있다는 것을 보여준다는 점이다.

Character 클래스는 isDigit() 뿐만 아니라 이 외에 대소문자 변환을 해 줄 수 있는 toLowerCase() 메소드와 toUpperCase() 메소드를 제공하고 있다. ASCII 코드를 사용할 경우 알파벳 대문자와 알파벳 소문자의 코드는 정확하게 32(0x20) 만큼 차이가 난다. 하지만, 따라서 알파벳 'A'의 소문자를 구하고 싶다면 다음과 같이 하면 된다.

char aUpper = 'A';
char aLower = (char) (aUpper + 32);

하지만, 모든 언어가 알파벳처럼 대소문자의 코드 값이 32 만큼 차이나는 것은 아니다. 다시 한번 말하지만, 자바는 유니코드를 사용하고 있으며, 더욱 확실하게 대소문자 변환을 하고 싶다면 Character 클래스의 toLowerCase() 메소드와 toUpperCase() 메소드를 사용해야 한다. 예를 들어, 알파벳 'A'를 소문자로 변환하고 싶다면 다음과 같이 하면 된다.

char aUpper = 'A';
char aLower = Character.toLowerCase(aUpper);

이렇게 Character.toLowerCase() 메소드를 사용하면, 직접 32 만큼의 숫자를 더해서 소문자를 구하는 것에 비해 코드가 더욱 간결해지며 또한 의미 역시 명확해진다.

Character 클래스는 또한 글자와 정수값 사이에 변환을 해주는 다양한 메소드를 제공하고 있다. 일반적으로 특정한 기수를 사용하는 문자열을 정수로 변환하고자 할 경우 다음과 같이 Integer 클래스의 parseInt() 메소드를 사용한다.

int value = Integer.parseInt("10", 16);

이 경우 value는 16진수 값인 0x10을 값으로 갖게 된다. Character 클래스는 Integer 클래스와 비슷하게 char 값을 다양한 기수에 기반 정수값으로 변환해주는 메소드인 digit() 메소드를 제공하고 있다. 예를 들어, char 값 'b'가 16진수로 처리할 때 정수값이 어떤 정수값을 갖는 지 알고 싶다면 다음과 같이 Character.digit() 메소드를 사용하면 된다.

int dig = Character.digit('b', 16);

여기서 'b'는 16진수값 0x0b에 해당하며, 따라서 dig는 11을 값으로 갖는다. 정수값을 반대로 특정한 기수에 기반한 글자로 변환할 수도 있으며, 이는 forDigit() 메소드를 사용하여 할 수 있다. 예를 들어, 정수값 11을 16진수값으로 변환할 때 어떤 글자에 해당하는 지 알고 싶다면 다음과 같이 하면 된다.

char cdig = Character.forDigit(11, 16);

여기서 cdig는 16진수에서 10진법의 값 11에 해당하는 'b'를 값으로 갖게 된다.

유니코드 캐릭터의 타입을 지정하기 위해서 유니코드 속성 테이블을 사용할 수 있다. 타입은 구두점, 화폐 기호, 글자와 같은 것으로 분류된다. 예를 들어, 화폐 기호를 나타내는 유니코드 캐릭터의 코드 번호를 16진수로 출력하고 싶다면 다음과 같은 프로그램을 실행하면 된다.

public class CharDemo3 {
   public static void main(String args[]) {
      for (int i = 0; i <= 0xffff; i++) {
         if (Character.getType((char)i) == Character.CURRENCY_SYMBOL) {
            System.out.println((char)i + " = " +Integer.toHexString(i));
         }
      }
   }
}

여기서 Character.getType() 메소드는 파라미터로 전달받은 캐릭터의 타입을 구해준다. 카테고리 타입은 Character 클래스의 상수값으로 정의되어 있으니, 자바 API를 참조하기 바란다. CharDemo3를 실행하면 다음과 같은 결과가 출력될 것이다.

$ = 24
? = a2
...
$ = ff04
¢ = ffe0
£ = ffe1
¥ = ffe5
₩ = ffe6

출력 결과를 보면 '$'나 '₩'처럼 화폐 단위를 나타내는 글자가 Character.CURRENCY_SYMBOL 카테고리로 분류된 것을 알 수 있다.

마지막으로 Character 클래스의 이너(inner) 클래스인 Character.UnicodeBlock 클래스에 대해서 살펴보자. Character.UnicodeBlock 클래스는 관련된 글자들을 하나의 블럭으로 묶을 때 사용된다. 이러한 블럭의 종류에는 HANGUL_SYLLABLES, GREEK, ARMENIAN와 같은 것들이 있으며, 블럭의 종류는 Character.UnicodeBlock 클래스에서 상수값으로 정의되어 있다. 예를 들어, 특정 글자가 한글에 속하는 지 살펴보기 위해서는 다음과 같이 하면 된다.

if (Character.UnicodeBlock.of((char)i) 
        == Character.UnicodeBlock.HANGUL_SYLLABLES
  || Character.UnicodeBlock.of((char)i) 
          == Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO ) {
     // 알맞은 처리
}

다음에 있는 CharDemo4 클래스는 실제로 위 코드를 이용하여 모든 한글 글자를 출력해준다.

public class CharDemo4 {
   public static void main(String args[]) {
      for (int i = 0; i <= 0xffff; i++) {
         if (Character.UnicodeBlock.of((char)i) 
                == Character.UnicodeBlock.HANGUL_SYLLABLES
          || Character.UnicodeBlock.of((char)i) 
                == Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO ) {
            System.out.print((char)i);
         }
      }
   }
}

결론

Character 클래스는 흔히 문자 처리와 관련된 다양한 기능을 제공해주고 있다. 개발자는 이러한 기능을 사용하여 좀더 편리하게 문자 처리를 할 수 있을 것이다. Character 클래스는 이 글에서 설명한 메소드 이외에도 몇몇 유용한 메소드를 제공하고 있으니 API 문서를 참고하기 바란다.

관련링크:
반응형

이 글은 오래된 글로서, 자바 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 클래스를 수정한다면 좀더 좋은 기능을 제공해주는 쓰레드 풀링을 사용할 수 있을 것이다.

관련링크:


  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 프로그래밍입문 책 쓰신분이셨군요 갖고있는 책이라서 이름이 낯이 익었다 싶었습니다 글 잘보고 갑니다

반응형
오픈 소스 프로젝트인 Log4j를 이용하여 자바 어플리케이션에서 빠르고 효과적인 로그 서비스를 구축할 수 있다.

Log4j

오늘날 프로젝트에서 많이 사용되는 웹서버, 어플리케이션 서버, DBMS를 비롯한 대부분의 상업용 어플리케이션은 그 어플리케이션에서 발생하는 사건들을 기록하기 위해 로그를 남기고 있다. 특히, 경험많은 개발자들은 로깅은 어플리케이션 개발 및 유지보수에 있어 중요한 요소임을 지적한다. 실제로 로그 기록을 남김으로써 몇가지 장점을 얻을 수 있다. 무엇보다도 로그는 어플리케이션이 실행되는 동안의 정확한 상황과 상태 정보를 제공한다. 둘째로, 로그 출력을 파일이나 DB와 같은 곳에 기록하여 나중에 로그 결과를 분석할 수 있다. 마지막으로, 개발 기간 중에 로그 패키지를 문제 검사 툴로 사용할 수도 있다.

카테고리, 어펜더 그리고 레이아웃

Log4j는 세 개의 주요 컴포넌트를 갖고 있다.

  • 카테고리(Category)
  • 어펜더(Appender)
  • 레이아웃(Layouts)
개발자는 메시지 타입과 우선순위에 따라 메시지를 기록하고 런타임에 이 메시지들의 포맷을 어떻게 작성하고 어디에 출력할지를 제어할 수 있도록 하기 위해 이 세개의 컴포넌트를 함께 사용한다. 이 세 가지 컴포넌트에 대해 차례대로 알아보도록 하자.

카테고리 계층(Category hierarchy)

몇몇 로깅 API 중 가장 좋은 장점은 특정한 기준에 따라 로그를 기록할지의 여부를 결정할 수 있다는 점이다. 이것은 로그에 기록될 모든 문장은 로깅 API 개발자가 정해 놓은 기준에 따라 분류된다는 것을 의미한다.

Log4j 역시 org.apache.log4j.Category 클래스를 통해서 이러한 분류 기준을 제시하고 있다. Category 클래스는 org.apache.log4j 패키지의 핵심 클래스이며, 카테고리를 나타낸다. 카테고리는 이름을 가진 개체이다. 카테고리의 이름은 자바의 패키지 이름과 비슷한 구조를 갖는다. 즉, 카테고리 이름은 com.javacan과 같은 형태를 가진다. 이 이름에 따라 카테고리는 부모 카테고리와 자식 카테고리로 구분된다. 예를 들어, com.javacan의 부모 카테고리의 이름은 com 이며, 이름이 com.javacan.article인 카테고리는 com.javacan의 자식 카테고리가 되는 것이다. 따라서, 카테고리 사이에는 계층이 형성된다.

계층의 가장 상위에 있는 카테고리를 루트 카테고리라고 하며, 루트 카테고리는 다음과 같은 특징을 갖고 있다.

  • 루트 카테고리는 항상 존재한다.
  • 이름을 사용하여 루트 카테고리를 읽어올 수 없다.
Category 클래스의 static 메소드인 getRoot() 메소드를 사용하여 루트 카테고리를 구할 수 있다. static 메소드인 getInstance() 메소드는 모든 다른 카테고리의 인스턴스를 생성한다. getInstnace() 메소드는 원하는 카테고리의 이름을 파라미터로 입력받는다. Category 클래스의 몇몇 기본 메소드는 다음과 같다.

package org.apache.log4j;

public class Category {

   // Creation & retrieval methods:
   public static Category getRoot();
   public static Category getInstance(String name);

   // printing methods:
   public void debug(String message);
   public void info(String message);
   public void warn(String message);
   public void error(String message);

   // generic printing method:
   public void log(Priority p, String message);

   .......
}

카테고리마다 org.apache.log4j.Priority 클래스에 정의된 값을 사용하여 우선순위를 지정할 수 있다. 현재 Priority 클래스에는 FATAL, ERROR, WARN, INFO, DEBUG의 5개의 우선순위가 정의되어 있다. 나열한 순서대로 우선 순위가 낮아진다. 겉으로 보기에는 제한된 집합을 갖도록 한 이유는 정적인 우선순위 집합보다 더욱 더 유연한 카테고리 계층을 만들 수 있도록 하기 위해서이다. 하지만, Priority 클래스를 상속받아서 자신만의 우선순위를 정의할 수도 있다.

만약 주어진 카테고리가 할당된 우선순위를 갖고 있지 않다면, 카테고리 계층도에서 할당된 우선순위를 갖고 있는 가장 가까운 상위 카테고리로부터 우선순위를 상속받는다. 따라서 루트 카테고리가 할당된 우선순위를 갖고 있을 경우 결과적으로 모든 카테고리를 그 우선순위를 상속받게 된다.

로깅 요청은 카테고리 인스턴스의 출력 메소드 중의 하나를 호출하면 된다. 출력 메소드는 다음과 같다.

  • error()
  • warn()
  • info()
  • debug()
  • log()
출력 메소드는 로깅 요청의 우선순위를 결정한다. 예를 들어, cat가 카테고리 인스턴라고 할 경우, cat.info("....")는 INFO 우선순위로 로깅을 요청한다. 만약 현재 요청한 로깅의 우선순위가 카테고리의 우선순위와 같거나 높으면 그 로깅 요청이 가능하다고 말한다. 그렇지 않을 경우 그 요청은 불가능하다고 한다.

다음은 로깅 요청의 가능/불가능 여부가 어떻게 처리되는 지를 보여주는 예이다.

// 이름이 "com.foo"인 카테고리 인스턴스를 구한다.
Category cat = Category.getInstance("com.foo");

// 카테고리의 우선순위를 설정한다.
cat.setPriority(Priority.INFO);

// WARN >= INFO 이기 때문에, 이 요청은 가능하다.
cat.warn("Low fuel level.");

// DEBUG < INFO 이기 때문에, 이 요청은 불가능하다.
cat.debug("Starting search for nearest gas station.");

// 이름이 "com.foo.Bar"인 카테고리의 인스턴스를 생성한다.
// 이 카테고리는 이름이 "com.foo"인 카테고리를 상속받는다.
// 따라서 이 카테고리 인스턴스는 INFO 우선순위를 갖는다.
Category barcat = Category.getInstance("com.foo.Bar");

// INFO >= INFO 이므로, 이 요청은 가능하다.
barcat.info("Located nearest gas station.");

// DEBUG < INFO 이므로, 이 요청은 불가능하다.
barcat.debug("Exiting gas station search");

같은 이름을 사용하여 getInstance() 메소드를 호출하면 항상 같은 카테고리 오브젝트에 대한 레퍼런스를 리턴한다. 따라서, 일단 특정한 이름을 갖는 카테고리 인스턴스를 설정하면, 그 인스턴스의 레퍼런스를 전달할 필요 없이 프로그램내의 어떤 곳에서든지 그 카테고리의 인스턴스를 읽어올 수 있다. 특정한 순서없이 카테고리를 생성 및 설정할 수 있다. 자식 카테고리를 생성한 이후에 부모 카테코리를 찾고 연결할 수 있다. Log4j 환경은 일반적으로 어플리케이션을 초기화할 때 설정하며, 특히 설정 파일을 읽어오는 시점에서 설정하는 경우가 많다.

Log4j에서 카테고리의 이름은 그 카테고리의 인스턴스를 생성할 때 사용한 클래스의 완전한 이름과 같에 짓는 것이 유용하고 카테고리를 정의하는 직관적인 방법이다. 로그에는 카테고리를 생성할 때 사용한 이름이 기록되기 때문에, 카테고리의 이름이 클래스의 이름과 같도록 하는 방법은 로그 메시지의 출처를 구분하는 데 도움을 준다. 이러한 방법이 일반적으이만, 이러한 방법 이외에도 다양한 방법을 사용하여 카테고리의 이름을 지을 수 있다. Log4j는 가능한 카테고리의 집합을 제한하고 있지 않으며, 개발자는 카테고리의 이름을 원하는 대로 지을 수 있다.

어펜더와 레이아웃

Log4j는 카테고리에 기반하여 로깅 요청의 가능/불가능 여부를 결정하는 기능 뿐만 아니라, 로깅 요청을 다중의 어펜더(appender)에 출력할 수 있다. 여기서 어펜더는 출력의 목적지를 나타낸다. 현재 콘솔, 파일, GUI 컴포넌트, 원격 소켓 서버, NT 이벤트 로거 그리고 원격 유닉스 시스로그 데몬으로 연결되는 어펜더가 존재한다.

카테고리는 다중의 어펜더를 참조할 수 있다. 카테고리에 들어온 각각의 가능한 로깅 요청은 그 카테고리에 있는 모든 어펜더에 전달되며, 뿐만 아니라 카테고리 계층의 상위에 있는 어펜더에도 전달된다. 즉, 어펜더는 카테고리 계층으로부터 상속된다. 예를 들어, 루트 카테고리에 콘솔 어펜더를 추가했다면, 모든 가능한 로깅 요청은 적어도 콘솔에 로그 메시지를 출력할 것이다. 만약 C라고 불리는 카테고리에 파일 어펜더를 추가했다면, C와 C의 자식 카테고리에 대한 가능한 로깅 요청은 콘솔과 파일에 메시지를 출력할 것이다.

출력 목적지 뿐만 아니라 출력 형식도 변경할 수 있다. 각각의 어펜더는 그 어펜더에 출력될 메시지의 형식을 있으며, 이는 그 어펜더와 특정 레이아웃(layout)을 관련시킴으로써 가능해진다. 레이아웃은 사용자가 지정한 값에 따라 로깅 요청의 포맷을 결정하고, 반면에 어펜더는 레이아웃을 통해 알맞은 포맷을 갖춰서 나온 것을 그것의 목적지에 출력한다. Log4j의 표준 배포판에 있는 PatternLayout은 사용자가 C 언어의 printf() 함수와 비슷한 변환 패턴에 따라 출력 포맷을 지정할 수 있도록 해준다.

예를 들어, 변환 패턴이 %r [%t]% -5p %c - %m%n 인 PatternLayout은 다음과 비슷한 결과를 출력한다.

176 [main] INFO org.foo.Bar - Located nearest gas station.

위의 출력 결과는 다음과 같다.

  • 첫번째 필드는 프로그램이 시작한 이후 경과한 시간(1/1000초 단위)을 나타낸다.
  • 두번째 필드는 로그 요청을 한 쓰레드를 나타낸다.
  • 세번째 필드는 로그의 우선순위를 나타낸다.
  • 네번째 필드는 로그 요청과 관련된 카테고리의 이름을 나타낸다.
나머지는 로그에 기록할 메시지이다.

Log4j의 설정

Log4j 환경은 프로그래밍 내에서 직접 설정할 수 있다. 하지만, 설정 파일을 사용함으로써 훨씬 더 유연하게 Log4j 환경을 설정할 수 있다. 현재, XML과 자바의 프로퍼티 형식을 사용하여 설정 파일을 작성할 수 있다.

간단한 어플리케이션을 통해서 설정 파일을 이용하여 Log4j 환경을 설정하는 것에 대하여 알아보자. 먼저 다음은 Log4j를 사용하는 간단한 어플리케이션인 TestLog4j 이다.

import com.foo.Bar;

import org.apache.log4j.Category;
import org.apache.log4j.BasicConfigurator;

public class TestLog4j {
    
    // cat는 이름이 "TestLog4j"인 카테고리 인스턴스를 가리키기 된다.
    static Category cat = Category.getInstance(TestLog4j.class.getName());
    
    public static void main(String[] args) {
        // 콘솔에 로깅하는 간단한 Configuration을 설정
        BasicConfigurator.configure();
        
        cat.info("Entering application.");
        Bar bar = new Bar();
        bar.doIt();
        cat.info("Exiting application.");
    }
}

위 코드에서 사용하는 com.foo.Bar 클래스는 다음과 같다.

package com.foo; 
 
import org.apache.log4j.Category; 
 
public class Bar { 
    static Category cat = Category.getInstance(Bar.class.getName()); 
     
    public void doIt() { 
        cat.debug("Did it again!"); 
    } 

TestLog4j 에서, BasicConfigurator.configure() 메소드를 호출함으로써 다소 간단한 Log4j 셉업을 설정한다. 이 메소드는 루트 카테고리에 PatternLayout.TTCC_CONVERSION_PATTERN을 사용하는 PatternLayout을 사용하고 System.out에 출력하는 FileAppender를 추가한다. 현재, Log4j 1.0.4 버전에서TTCC_CONVERSION_PATTERN의 값은 다음과 같다.

%r [%t] %p %c %x - %m%n

TestLog4j.java의 출력 결과는 다음과 같다.

0 [main] INFO TestLog4j  - Entering application.  
30 [main] DEBUG com.foo.Bar  - Did it again!  
40 [main] INFO TestLog4j  - Exiting application.  

여기서 TestLog4j 클래스에서 BasicConfigurator.configure()를 호출함으로써 Log4j를 설정하기 때문에, Bar와 같이 다른 클래스에서는 단순히 org.apache.log4j.Category 클래스를 임포트하여 사용하기만 하면 된다.

TestLog4j 클래스를 실행할 때 마다 TestLog4j는 Log4j 환경을 매번 갖은 것으로 설정한다. 따라서 로그의 출력 결과도 매번 같은 곳(이 경우 콘솔)으로 전달된다. 하지만, Log4j 환경을 런타임에 설정하길 원하는 경우도 있을 것이다. 이는 설정 파일을 사용함으로써 가능해진다. 다음은 TestLog4j를 약간 변경하여 설정 파일을 사용하여 Log4j 환경을 설정하는 TestLog4j2 클래스의 소스 코드이다.

import com.foo.Bar;

import org.apache.log4j.Category;
import org.apache.log4j.PropertyConfigurator;

public class TestLog4j2 {
    
    static Category cat = Category.getInstance(TestLog4j2.class.getName());
    
    public static void main(String[] args) {
        // BasicConfigurator 대신 PropertyConfigurator 사용
        PropertyConfigurator.configure(args[0]);
        
        cat.info("Entering application.");
        Bar bar = new Bar();
        bar.doIt();
        cat.info("Exiting application.");
    }
}

TestLog4j2 클래스를 보면 PropertyConfigurator 클래스를 사용하여 설정 파일을 분석한 후 로깅을 설정하도록 하고 있다.

TestLog4j 클래스와 TestLog4j2 클래스가 거의 같은 결과를 출력하도록 해주는 예제 설정 파일을 살펴보자. 예제 설정 파일은 다음과 같다.

# 루트 카테고리의 우선순위를 DEBUG로 설정하고, 유일한 어펜더를 A1으로 설정
log4j.rootCategory=DEBUG, A1# A1을 System.out으로 출력하는 FileAppender로 설정
log4j.appender.A1=org.apache.log4j.FileAppender
log4j.appender.A1.File=System.out

# A1은 PatternLayout을 사용한다.
log4j.appender.A1.layout=org.apache.log4j.PatternLayout
log4j.appender.A1.layout.ConversionPattern=%-4r [%t] %-5p %c %x - %m%n

위 내용을 test2.properties 파일로 저장한 후, 다음과 같이 TestLog4j2 클래스를 실행해보자.

java TestLog4j2 test2.properties

그러면 다음과 같은 결과가 출력될 것이다.

0    [main] INFO  TestLog4j2  - Entering application.   
10   [main] DEBUG com.foo.Bar  - Did it again!   
10   [main] INFO  TestLog4j2  - Exiting application.

출력 결과를 보면 앞의 TestLog4j의 출력 결과와 앞의 숫자를 제외한 나머지 부분은 완전히 같다는 것을 알 수 있다. 만약 com.foo 패키지에 속해 있는 컴포넌트의 출력을 보고 싶지 않다면, 다음과 같은 설정 파일을 만들면 된다.

log4j.rootCategory=DEBUG, A1
log4j.appender.A1=org.apache.log4j.FileAppender
log4j.appender.A1.File=System.out
log4j.appender.A1.layout=org.apache.log4j.PatternLayout

# ISO 8601 형식으로 날짜를 출력한다.
log4j.appender.A1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n

# com.foo 패키지에서는 오직 WARN과 같거나 높은 우선순위를 같는 메시지만 출력한다.
log4j.category.com.foo=WARN

이 설정 파일을 test2-1.properties로 저장한 후 TestLog4j2를 다음과 같이 실행해보자.

java TestLog4j2 test2-1.properties

그러면 다음과 같은 결과가 출력된다.

2001-01-18 14:41:45,736 [main] INFO  TestLog4j2 - Entering application.   
2001-01-18 14:41:45,746 [main] INFO  TestLog4j2 - Exiting application.   

출력 결과를 보면 Bar와 관련된 로그 메시지가 출력되지 않는 것을 알 수 있다. com.foo.Bar 카테고리는 우선순위를 할당하지 않았기 때문에, com.foo 카테고리의 우선순위를 상속받으며, test2-1.properties 설정 파일에서 com.foo의 카테고리를 WARN으로 지정하였기 때문에 com.foo.Bar 카테고리는 WARN의 우순선위를 갖는다. Bar.doIt() 메소드에 있는 로그 문장은 DEBUG 우선순위를 갖고 있으며, DEBUG는 WARN 보다 우선 순위가 낮기 때문에, doIt()의 로그 요청은 처리되지 않는다.

이제 다중의 어펜더를 사용하는 설정 파일을 살펴보자.

log4j.rootCategory=debug, stdout, R

log4j.appender.stdout=org.apache.log4j.FileAppender
log4j.appender.stdout.File=System.out
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout

# 콜러의 파일 이름과 라인 번호를 출력하는 패턴
log4j.appender.stdout.layout.ConversionPattern=%5p [%t] (%F:%L) - %m%n

log4j.appender.R=org.apache.log4j.RollingFileAppender
log4j.appender.R.File=example.log

log4j.appender.R.MaxFileSize=100KB
# 한 개의 백업 파일을 유지한다.
log4j.appender.R.MaxBackupIndex=1

log4j.appender.R.layout=org.apache.log4j.PatternLayout
log4j.appender.R.layout.ConversionPattern=%p %t %c - %m%n

이 내용을 test3.propeties 파일에 저장한 후, 'java TestLog4j2 test3.properties'을 실행해보자. 그럼 출력 결과는 다음과 같을 것이다.

 INFO [main] (TestLog4j2.java:14) - Entering application.
DEBUG [main] (Bar.java:9) - Did it again!
 INFO [main] (TestLog4j2.java:17) - Exiting application.

뿐만 아니라 루트 카테고리에 두번째 어펜더를 할당했기 때문에, example.log 파일에 직접적으로 로그 내용이 출력된다. 이 파일의 크기가 100KB에 다다를 경우 자동적으로 example.log 파일은 example.log.1로 옮겨지고, example.log 파일에는 처음부터 로그가 저장되기 시작한다. 여기서 주목할 점은 로깅 기능을 변경하기 위해서 코드를 재 컴파일 할 필요가 없다는 점이다. 설정 파일만 변경함으로써 매우 쉽게 유닉스 시스로그 데몬에 로그를 출력할 수도 있고 모든 com.foo 출력을 NT 이벤트 로거에 전달할 수도 있다.

NDC(Nested Diagnostic Contexts)

오늘날 사용되는 웹 어플리케이션은 다중 클라이언트가 동시에 접근하는 것을 기본적으로 가정하고 있다. 이러한 시스템은 일반적으로 하나의 쓰레드가 하나의 클라이언트를 처리하도록 구현되어 있으며, 로그 시스템의 경우 다중 클라이언트가 접속한 것을 손쉽게 추적하고 더 나아가 디버깅할 수 있도록 구현되어야 한다. 이 경우 클라이언트마다 별도의 카테고리 인스턴스를 생성하도록 할 수 있다. 하지만, 이 방법은 클라이언트 수에 비례하여 카테고리의 인스턴스 개수가 증가할 뿐만 아니라 로깅을 관리하는 데 따른 오버헤드가 발생하게 된다.

또 다른 방법으로는 하나의 카테고리를 사용하여 모든 클라이언트의 로그 기록을 남기는 것이다. 이 때 각각의 로그 요청은 다른 로그 요청과 구별되는 고유의 표식을 가져야 한다. 이러한 고유의 표식을 지정할 수 있도록 하기 위해 Log4j는 org.apache.log4j.NDC 클래스를 제공한다. 참고로 NDC는 Nested Diagnostic Context의 약자이다. NDC 클래스는 다음과 같은 메소드를 제공하고 있다.

// diagnostic을 출력할 때 사용
public static String get();

// NDC에 있는 최상위에 있는 콘텍스트를 제거
public static String pop();

// 현재 쓰레드를 위한 diagnostic 콘텍스트 추가
public static void push(String message);

// 현재 쓰레드에 해당하는 diagnostic 콘텍스트 제거
public static void remove();

여기에 명시한 메소드 뿐만 아니라 그 외의 NDC 클래스에 정의된 다른 메소드 역시 static으로 정의되어 있다. 먼저 위에 나열한 메소드에 대해서 설명하기 전에 NDC 클래스를 이용하는 코드를 살펴보자.

import org.apache.log4j.Category;
import org.apache.log4j.PropertyConfigurator;
import org.apache.log4j.NDC;

public class TestNDC {
    
    static Category cat = Category.getInstance(TestLog4j2.class.getName());
    
    public static void logging(String uniqueId, String message) {
        NDC.push(uniqueId);
        cat.info(message);
        NDC.push(uniqueId+"-"+uniqueId);
        cat.info(message);
        NDC.pop();
        cat.info(message);
        NDC.pop();
    }
    
    public static void main(String[] args) {
        // BasicConfigurator 대신 PropertyConfigurator 사용
        PropertyConfigurator.configure(args[0]);
        
        cat.info("Entering application.");
        
        logging(args[1], "NDC test");
        
        cat.info("Exiting application.");
    }
}

위 코드를 TestNDC.java로 저장한 후, 컴파일 하자. TestNDC에서 사용할 설정 파일인 ndc.properties는 다음과 같다.

log4j.rootCategory=debug, stdout

log4j.appender.stdout=org.apache.log4j.FileAppender
log4j.appender.stdout.File=System.out
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout

log4j.appender.stdout.layout.ConversionPattern=NDC=[%x] Thread=[%t] - %m%n

위 설정 파일 내용에서 %x는 NDC의 현재 스택에 들어가 있는 콘텍스트 정보를 나타낸다. 이 정보는 NDC.push() 메소드를 사용하여 입력한다. 이제 다음과 같이 TestNDC 클래스를 실행해 보자.

java TestNDC ndc.properties uniID

실행 결과는 다음과 같을 것이다.

NDC=[] Thread=[main] - Entering application.    
NDC=[uniID] Thread=[main] - NDC test    
NDC=[uniID uniID-uniID] Thread=[main] - NDC test    
NDC=[uniID] Thread=[main] - NDC test    
NDC=[] Thread=[main] - Exiting application.

위 실행 결과를 보면 먼저 main() 메소드에서 호출한 cat.info() 메소드의 로그 결과에는 %x 부분이 없는 것을 알 수 있다. 이제 logging() 메소드 부분을 살펴보자. TestNDC 클래스는 logging() 메소드 내에서 NDC 클래스를 사용한다. 먼저 logging() 메소드는 NDC.push() 메소드를 호출하여 하나의 컨텍스트 정보를 저장한다. NDC 클래스는 이 정보를 스택을 나타내는 Stack 클래스를 사용하여 저장하며, 이 정보는 로그를 출력할 때 %x 부분에 표시된다. 출력 결과의 두번째 줄을 보면 %x 부분에 NDC.push() 메소드에 전달해준 컨텍스트 정보가 출력되는 것을 알 수 있다. 스택에 저장한 컨텍스트 정보는 NDC.pop() 메소드를 사용하여 차례대로 빼낼 수 있다.

이렇게 추가한 컨텍스트 정보는 각각의 쓰레드마다 별도로 저장된다. 즉, NDC 클래스는 그 클래스의 메소드를 호출한 각각의 쓰레드 마다 별도의 스택을 사용하며, 따라서 각각의 쓰레드는 서로 상대방의 컨텍스트 정보에 영향을 끼치지 않는다. 참고로 NDC 클래스는 "Pattern Languages of Program Design 3"의 "Patterns for Logging Diagnostic Messages" 부분의 Neil Harrison이 정의한 nested diagnostic contexts를 구현하였다.

결론

Log4j API의 장점 중의 하나는 로그를 관리하기 쉽다는 것이다. 일단 로그관련 문장을 코드에 삽입하면, 코드의 변경없이 설정 파일을 통해서 로그를 관리할 수 있다. 또한, 선택적으로 로그 요청의 가능/불가능 여부를 결정할 수 있으며, 사용자가 정의한 형식으로 다양한 출력 대상에 로그 메시지를 전달할 수 있다. 또한, NDC 클래스를 사용함으로써 좀더 분석이 용이한 로그를 기록할 수 있다.

관련링크:
반응형
초보 개발자들이 범하기 쉬운 잘못된 JDBC 프로그래밍 형태와 이에 대응하는 올바른 JDBC 프로그래밍에 대해서 알아본다.

잘못된 예외 처리

JDBC를 처음 접하는 사람들은 대부분 기초 서적에 나와 있는 코딩 스타일을 따라하게 되며, 이러한 책 중 다수가 다음과 같은 형태의 코딩 스타일을 독자들에게 알려주고 있다.

String userId = .. // 어떤 값을 할당
try {
    Class.forName("oracle.jdbc.driver.OracleDriver");
    
    Connection conn = DriverManager.getConnection(
                     "jdbc:oracle:thin:@xxx.111.222.333:1521:madvirus",
                     "user", "password");
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery(
        "select name from Member where id='"+userId+"'");
    if (rs.next()) {
        ....
    }
    ...
    rs.close();
    stmt.close();
    conn.close();
} catch(SQLException ex) {
    // 어떤 처리를 한다.
} catch(ClassNotFoundException ex) {
    // 어떤 처리를 한다.
} catch(... ) {
    // 기타 예외 처리
}

지금 이 글을 보고 있는 사람들 역시 위와 같은 형태로 코딩을 하는 사람들이 많을 것이다. 위와 같은 코딩 스타일을 사용하는 사람들은 대부분 문제점이 없다고 생각하며, 실제로도 문제가 발생하는 경우는 드물다고 할 수 있다. 하지만, 위 코드는 ResultSet, Statement, Connection의 close() 메소드가 호출되지 않을 수도 있다는 문제점을 안고 있다. 예를 들어, 현재 생성된 Statement의 개수가 DBMS가 제한하고 있는 Statement의 개수(즉, 커서의 개수)와 같다고 하자. 이 경우 위 코드의 conn.createStatement() 메소드는 Statement 객체를 생성할 수 없으므로 SQLException을 발생할 것이다. 그렇다면 catch 부분에서 SQLException이 처리된다. 여기서부터 문제점이 발생한다. 여기서 예외가 발생한 시점은 createStatement() 메소드를 호출하는 순간이며, 곧 바로 catch 블럭을 수행하게 된다. 즉, try .. catch .. 블럭이 마지막으로 수행해야 하는 conn.close()를 수행하지 않게 되는 것이다.

Connection 객체의 close() 메소드를 호출하지 않는다면 어떤 문제가 발생할까? 가장 먼저 발생하는 문제는 한정된 시스템 자원을 낭비하게 된다는 점이다. 데이터베이스와의 연결 역시 일정한 시스템 자원을 차지하고 있으며, 이 자원은 close() 메소드를 호출할 때 까지는 반환되지 않는다. 따라서 위와 같이 중간에 예외가 발생하여 close() 메소드를 호출할 수 없는 경우 그 Connection 객체는 계속해서 데이터베이스 연결된 상태로 남아 있게 되며, 결국 그 데이터베이스 연결은 쓸데없이 시스템 자원만을 차지하게 된다. 즉, 시스템 자원을 효율적으로 사용하기는 커녕 오히려 낭비하게 되는 것이다. 만약, 예외가 자주 발생한다면 그 만큼 낭비되는 시스템 자원은 늘어나게 되며, 결국 어플리케이션이 사용할 수 있는 자원이 모자란 상황이 발생하게 된다.

이처럼 자원 부족 현상이 발생하지 않도록 하기 위해서는 예외의 발생 여부에 상관없이 사용한 모든 자원(Connection, Statement, PreparedStatement, ResultSet)의 close() 메소드를 호출할 수 있도록 해야 한다. 이를 하기 위해서는 try .. catch .. finally 블럭을 사용하면 된다. finally 블럭은 try { .. } catch 블럭에서 예외가 발생했는지의 여부에 상관없이 항상 실행된다. 따라서, finally 블럭은 사용한 모든 자원을 반납(close)하기에 가장 알맞은 곳이다. finally 블럭을 사용하여 위 코드를 재구성하면 다음과 같다.

String userId = .. // 어떤 값을 할당
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;

try {
    Class.forName("oracle.jdbc.driver.OracleDriver");
    
    conn = DriverManager.getConnection(
                     "jdbc:oracle:thin:@xxx.111.222.333:1521:madvirus",
                     "user", "password");
    stmt = conn.createStatement();
    rs = stmt.executeQuery(
             "select name from Member where id='"+userId+"'");
    if (rs.next()) {
        ....
    }
} catch(SQLException ex) {
    // 어떤 처리를 한다.
    ....
} catch(ClassNotFoundException ex) {
    // 어떤 처리를 한다.
    ....
} catch(Exception ex) {
    // 기타 예외 처리
} finally {
    if (rs != null) try { rs.close(); } catch(SQLException ex) {}
    if (stmt != null) try { stmt.close(); } catch(SQLException ex) {}
    if (conn != null) try { conn.close(); } catch(SQLException ex) {}
}

close() 메소드를 호출하는 부분이 모두 finally 블럭에 들어간 것을 알 수 있다. 이렇게 finally 블럭에 자원을 반환하는 부분을 위치시킴으로써 예외의 발생 여부에 상관없이 안전하게 자원을 반납할 수 있게 된다. 자원을 안전하게 반환할 수 있다는 것은 여러분이 작성한 어플리케이션이 좀 더 안정적으로 동작하게 된다는 것을 의미한다.

Statement vs. PreparedStatement

이제 자원의 반환과 관련된 문제는 해결되었다. 하지만, 아직도 위 코드는 문제를 안고 있다. 위 코드에서 executeQuery() 메소드를 실행하는 부분을 다시 한번 살펴보자.

rs = stmt.executeQuery("select name from Member where id='"+userId+"'");

여기서 userId가 "era13"이라면 실제로 실행하는 SQL 문은 다음과 같다.

select name from Member where id='era13'

이 문장은 아무 이상이 없는 SQL 문장이다. 하지만 만약 userId가 따옴표(')를 포함하고 있는 "era'13"이라면, 실행하는 SQL 문은 다음과 같이 된다.

select name from Member where id='era'13'

SQL 표준에서 따옴표(')는 특별한 용도로 쓰이며, 따라서 SQL 문장내에 있는 따옴표(')는 알맞게 변경해주어야 한다. 일반적으로 대부분의 DBMS는 위와 같이 문자열이 따옴표를 포함하고 있는 경우 다음과 같이 표현하도록 하고 있다.

select name from Member where id='era''13'

즉, 따옴표를 연속적으로 두 개 사용함으로써 따옴표를 표현하는 것이다. 따옴표를 표시하는 방법은 DBMS마다 다르므로 각각의 DBMS에 알맞게 일일이 따옴표 부분을 변경해주어야 한다. 이것이 어려운 일은 아니지만 귀찮은 일이다. 이처럼 문자열속에 특수 문자가 들어가 있는 경우에는 Statement 대신 PreparedStatement를 사용하는 것이 훨씬 더 안전하다. 예를 들어, 지금까지 살펴본 코드를 PreparedStatement를 사용하여 변경하면 다음과 같이 된다.

String userId = .. // 어떤 값을 할당
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;

try {
    Class.forName("oracle.jdbc.driver.OracleDriver");
    
    conn = DriverManager.getConnection(
                     "jdbc:oracle:thin:@xxx.111.222.333:1521:madvirus",
                     "user", "password");
    pstmt = conn.prepareStatement("select from name from Member where id=?");
        pstmt.setString(1, userId);
    rs = pstmt.executeQuery();
    
    if (rs.next()) {
        ....
    }
} catch(SQLException ex) {
    // 어떤 처리를 한다.
    ....
} catch(ClassNotFoundException ex) {
    // 어떤 처리를 한다.
    ....
} catch(Exception ex) {
    // 기타 예외 처리
} finally {
    if (rs != null) try { rs.close(); } catch(SQLException ex) {}
    if (pstmt != null) try { pstmt.close(); } catch(SQLException ex) {}
    if (conn != null) try { conn.close(); } catch(SQLException ex) {}
}

이와 같이 PreparedStatement를 사용하면 개발자가 일일이 SQL 문장에 있는 특수 문장을 변경해줄 필요가 없으며, 단순히 PreparedStatement 클래스에서 제공하는 setString()이나 setInt(), setObject()와 같은 메소드를 사용하여 값을 지정하면 된다. (PreparedStatement에 대한 자세한 내용은 자바 관련 서적을 참고하기 바란다.) 이처럼 개발자가 일일이 변경해줄 필요가 없으므로써 예상치 못했던 에러가 발생할 확률도 그만큼 줄어들게 된다.

JDBC 드라이버의 로딩

이제 SQL 문장에서 발생할 수 있는 문제도 PreparedStatement를 사용하여 해결할 수 있게 되었다. 하지만, 위 코드는 여전히 문제점을 안고 있다. 위 코드가 회원의 이름을 구해주는 getMemberName() 이라는 메소드의 일부분이라고 생각해보자. getMemberName() 메소드는 다음과 같은 형태를 띌 것이다.

public String getMemberName(String userId) {
    String userName = null;
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    
    try {
        Class.forName("oracle.jdbc.driver.OracleDriver");
        
        conn = DriverManager.getConnection(
                         "jdbc:oracle:thin:@xxx.111.222.333:1521:madvirus",
                         "user", "password");
        pstmt = conn.createStatement("select name from Member where id=?");
        rs = pstmt.executeQuery();
        
        if (rs.next()) {
            userName = rs.getString("name");
        }
    } catch(SQLException ex) {
        // 어떤 처리를 한다.
    } catch(ClassNotFoundException ex) {
        // 어떤 처리를 한다.
    } catch(Exception ex) {
        // 기타 예외 처리
    } finally {
        if (rs != null) try { rs.close(); } catch(SQLException ex) {}
        if (pstmt != null) try { pstmt.close(); } catch(SQLException ex) {}
        if (conn != null) try { conn.close(); } catch(SQLException ex) {}
    }
    return userName;
}

이제 회원의 이름을 사용하고 싶을 경우에는 getMemberName() 메소드를 사용할 것이며, 매번 호출이 있을 때 마다 getMemberName() 메소드는 Class.forName() 을 통해서 같은 JDBC 드라이버를 등록할 것이다. getMemberName() 메소드를 처음 호출할 때와 달리 두번째 이후부터는 JDBC 드라이버를 매번 다시 등록하는 결과를 낳는다. 이미 등록해서 다시 등록할 필요가 없는 JDBC 드라이버를 매번 등록해야 한다는 건 불필요한 일이다.

따라서 이처럼 JDBC 드라이버를 등록하는 부분은 어플리케이션의 초기화 부분에 옮겨놓는 것이 좋다. 즉, 다음과 같은 형태의 초기화 메소드가 있어서 그 메소드에서 처리하는 것이 좋다.

private void init() throws Exception {
    Class.forName("jdbcDriverClass");
}

이렇게 함으로써 getMemberName() 메소드에서 매번 JDBC 드라이버를 등록할 필요가 없어지며, 따라서 비록 적은 시간이긴 하지만 JDBC 드라이버를 등록하는 데 따른 불필요하게 낭비되는 시간을 없앨 수 있다.

결론

이 글에서는 JDBC 프로그래밍을 하면서 잘못하기 쉬운 부분에 대해 살펴보았다. 이 글에서 언급한 내용들만 충실히 지켜도 여러분이 작성한 어플리케이션이 엉뚱하게 실행되는 경우가 상당히 줄어들 것이다. 특히, 예외를 잘못 처리하여 Connection이나 Statement의 close() 호출되지 않아 데이터베이스와 관련된 자원이 낭비되는 경우는 발생하지 않을 것이다.

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

자바와 성능 문제

오늘날 자바는 엔터프라이즈 시장에서 확고히 자리잡고 있다. 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을 증가시킬 수 있다.

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

관련링크:
  1. 최창원 2014.06.04 20:42

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

  2. 최창원 2014.06.04 20:42

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

반응형
커맨드 패턴이 무엇이며, 자바에서 어떻게 구현되는 지 알아본다.

커맨드(Command) 패턴

프로그래밍을 하다보면 사용자가 선택한(또는 입력한) 명령어에 따라 그에 알맞은 처리를 해야 할 때가 있다. 예를 들어, 워드프로세서를 생각해보자. 사용자들은 복사(copy), 잘라내기(cut), 붙여넣기(paste) 기능을 사용한다. 이 때 복사, 잘라내기, 붙여넣기 등은 모두 한번의 명령어에 해당한다. 사용자들은 메뉴나 툴바의 아이콘 또는 키보드 단축키를 사용함으로써 워드 프로세서에 이 명령들을 실행할 것을 요청하며, 워드 프로세서는 사용자가 전달한 명령어에 알맞은 기능을 실행한다. 그리고 대부분의 워드 프로세서는 사용자가 실행한 명령을 취소할 수 있는 '명령 취소(Undo)' 기능을 제공하고 있다. 이러한 취소 기능을 제공하기 위해서는 사용자가 실행한 명령어들을 순서대로 저장할 수 있어야 한다. 이처럼 명령어를 실행하고, 실행한 명령어를 저장하고, 실행한 명령을 취소하고, 재실행하고 또는 그러한 명령어를 처리할 때 사용되는 패턴이 바로 커맨드(Command) 패턴이다.

커맨드 패턴은 사용자가 요구하는 명령어를 객체에 캡슐화(encapsulation)하여 저장한다. 각각의 명령어에 해당하는 객체는 그 명령어에 해당하는 기능을 실행하며, 필요에 따라 '명령 취소' 기능을 제공한다. 명령어와 관련된 사항이 객체에 캡슐화되어 있기 때문에, 사용자들은 단순히 그 명령어 객체를 생성해서 사용하기만 하면 된다. 또한, 이러한 명령어 객체들은 모두 공동의 상위 클래스를 갖고 있다. 이 상위 클래스는 각 명령어 객체의 클래스가 구현해야 할 메소드를 정의하고 있다. 예를 들어, 워드 프로세서에서 사용되는 '복사', '잘라내기', '붙여넣기'에 해당하는 명령어 클래스를 각각 CopyCommand, CutCommand, PasteCommand 라고 하자. 그리고 이 세 클래스가 상속받는 추상 클래스를 AbstractCommand 라고 하자. 이 경우 전체적인 클래스 사이의 관계는 다음 그림과 같을 것이다.


AbstractCommand 클래스는 모든 명령어 클래스가 상속받아야 할 클래스로서 추상 메소드인 execute()와 undo()를 선언하고 있다. 메소드의 이름에서 알 수 있듯이 execute() 메소드는 명령어에 해당하는 기능을 실행하기 위해 호출되는 메소드이며, 따라서 execute() 메소드는 실제 기능을 구현하고 있다. 반면에 undo() 메소드는 '명령 취소'에 해당하는 기능을 제공한다. AbstractCommand 추상 클래스를 상속받는 클래스들은 그 클래스에 알맞도록 execute() 메소드와 undo() 메소드를 구현하면 된다. 예를 들어, CopyCommand 클래스의 execute() 메소드는 사용자가 설정한 블럭에 속한 글자들을 클립보드에 복사할 것이며, undo() 메소드는 클립보드에 저장되어 있는 글자들을 삭제할 것이다.

이제 남은 것은 각각의 명령어 객체를 저장하고 관리해주는 관리자 클래스인 CommandManager 클래스가 필요하다. 이 클래스는 실행되는 명령어를 차례대로 저장하고 있으며, 사용자가 '명령취소'를 요청할 경우 가장 최근에 저장된 명령어 객체의 undo() 메소드를 호출해주는 역할을 한다. 즉, 전체적인 클래스 사이의 관계는 다음과 같다.

 
여기서 Invoker는 워드프로세서의 경우 사용자가 메뉴를 선택하거나 툴바를 클릭했을 때 발생하는 이벤트를 처리해주는 이벤트 리스너(예를 들어, ActionListener나 ItemListener 등)가 되며, ConcreteCommand 클래스는 CopyCommand와 CutCommand와 같이 실제 명령을 실행(구현)하는 명령어 객체를 나타낸다. CommandManager는 AbstractCommand를 관리하는 클래스이다.

커맨드 패턴의 구현

이제 커맨드 패턴이 실제로 어떻게 구현되는 지 살펴보자. 가장 먼저 AbstractCommand 추상 클래스의 코드를 살펴보자.

public abstract class AbstractCommand {
    public final static CommandManager manager = new CommandManager();
    
    /**
     * 이 객체가 캡슐화하고 있는 명령을 수행한다.
     */
    public abstract boolean execute();
    /**
     * execute()를 통해서 수행된 작업을 취소한다.
     */
    public abstract boolean undo();
}

위 코드를 살펴보면 CommandManager의 인스턴스를 static 필드로 갖고 있는 것을 알 수 있다. 이는 Invoker가 단순히 ConcreteCommand 클래스의 인스턴스를 생성만 하면 되도록 하기 위해서이다. 이에 대한 내용은 ConcreteCommand 클래스의 구현 방법을 알아볼 때 설명하기로 한다. 위 코드에서 execute()와 undo() 메소드는 모두 boolean을 리턴값으로 갖는다. execute() 메소드는 실제 명령이 올바르게 수행된 경우 true를 리턴하고, 그렇지 않은 경우 false를 리턴한다. 비슷하게 undo() 메소드는 '명령 취소'가 성공했을 경우 true를 리턴하고, 그렇지 않을 경우 false를 리턴한다.

이제 ConcreteCommand 클래스가 어떻게 구현되었는 지 살펴보자. 예를 들어, PasteCommand 클래스의 경우 다음과 같은 코드를 가질 것이다.

public class PasteCommand extends AbstractCommand {
    ....
    public PasteCommand(Document document, int position) {
        this.document = document;
        this.position = position;
        ....
        manager.executeCommand(this);
    }
    public boolean execute() {
        try {
            document.insertStringCommand(position, pasteString);
        } catch(Exception ex) {
            return false;
        }
        return true;
    }
    public boolean undo() {
        try {
            document.deleteStringCommand(position, pasteString.length() );
        } catch(Exception ex) {
            return false;
        }
        return true;
    }
}

위 코드를 보면 PasteCommand 클래스는 AbstractCommand 클래스에 있는 두 추상 메소드인 execute()와 undo()를 구현한 것을 알 수 있다. 특이할 만한 점은 PasteCommand의 생성자이다. 생성자의 마지막 줄을 보면 CommandManager의 executeCommand() 메소드를 호출하는 것을 알 수 있다. 즉, 명령어 객체를 사용하는 객체들은 CommandManager에 대한 자세한 내용을 알 필요 없이 단순히 명령어 객체를 생성하기만 하면 된다. 예를 들어, '복사' 메뉴와 관련된 java.awt.MenuItem을 생각해보자. 이 MenuItem과 관련된 코드 부분은 다음과 같을 것이다.

Menu menu = new Menu ("편집");
MenuItem pasteMenuItem = new MenuItem("붙여넣기");
menu.add(pasteMenuItem);
pasteMenuItem.addActionListener(new PasteActionListener());

위 코드는 전형적인 메뉴 생성 방법을 나타내고 있다. 위 코드에서 마지막 줄에 있는 PasteActionListener는 ActionListener를 implements한 클래스로서 다음과 같다.

public class PasteActionListener implements ActionListener {

    public void actionPerformed(ActionEvent e) {
        // 현재 document와 position을 구한다.
        .....
        new PasteCommand(document, position); // 명령어 객체 생성
    }
}

이제 사용자들이 메뉴에서 "붙여넣기"를 클릭하면 PasteActionListener의 actionPerformed() 메소드가 호출되고, 이어서 PasteCommand 객체가 생성된다. 그러면 PasteCommand 클래스의 생성자에서 CommandManager의 executeCommand() 메소드를 호출하게 된다. 지금까지의 코드 만으로도 실제 사용자의 입력을 받는 MenuItem 객체와 실제 내부적으로 "붙여넣기"를 처리하는 PasteCommand 객체와 상관이 없다는 것을 알 수 있다. 이제 CommandManager 클래스를 살펴보자. CommandManager 클래스는 다음과 같다.

class CommandManager {
    private static final int MAX_HISTORY_LENGTH = 50;

    private LinkedList history = new LinkedList();
    private LinkedList redoList = new LinedList();

    // 인자로 받은 AbstractCommand를 실행한다.
    // 만약 command가 Undo나 Redo의 인스턴스일 경우에는
    // 각각 '명령 취소'와 '취소 명령 재실행'을 수행한다.
    public void executeCommand(AbstractCommand command) {
        if (command instanceof Undo) {
            undo();
            return;
        }
        if (command instanceof Redo) {
            redo();
            return;
        }
        if (command.execute()) {
            addToHistory(command);
        } else { // execute()가 false를 리턴한 경우, 즉 명령어가 올바르게 실행되지 않은 경우
            // 명령어가 실패했다는 것을 알린다.
        }
    }
    // 바로 이전에 실행한 명령어를 취소한다.
    private void undo() {
        if (history.size() > 0 ) { // 사용자가 실행한 명령어가 있을 경우
            AbstractCommand undoCommand = (AbstractCommand) history.removeFirst();
            undoCommand.undo();
            redoList.addFirst(undoCommand);
        }
    }
    // 바로 이전에 취소한 명령어를 다시 실행한다.
    private void redo() {
        if (redoList.size() > 0) {
            AbstractCommand redoCommand = (AbstractCommand) redoList.removeList();
            redoCommand.execute();
            history.addFirst(redoCommand);
        }
    }
    // history에 사용자가 실행한 명령어를 저장한다.
    private void addToHistory(AbstractCommand command) {
        history.addFirst(command);
        if (history.size() > MAX_HISTORY_LENGTH)
            history.removeLast();
    }
}

먼저 CommandManager 클래스가 가지고 있는 첫번째 필드인 history는 사용자가 입력한 명령어를 순서대로 저장하는 리스트이며, 두번째 필드 redoList 는 사용자가 '실행 취소'한 것을 저장하는 리스트이다. undo() 메소드와 redo() 메소드는 실제로 '명령 취소'와 '취소 명령 재실행'을 수행하고, addToHistory() 메소드는 최근에 수행한 명령어를 history에 수행한다.

이제, CommandManager 클래스의 메소드 중에서 실제로 다른 객체들이 사용하는 executeCommand() 메소드를 살펴보자. 이 메소드는 명령어 객체(즉, AbstractCommand를 상속받은 클래스)를 파라미터로 넘겨 받고 그 command를 실행한다. 여기서 command.execute() 메소드를 실행하는 부분을 살펴보자.

        if (command.execute()) {
            addToHistory(command);
        } else { // execute()가 false를 리턴한 경우, 즉 명령어가 올바르게 실행되지 않은 경우
            // 명령어가 실패했다는 것을 알린다.
        }

여기서 AbstractCommand의 execute() 메소드는 boolean을 리턴하며(기억이 안 난다면, 앞에 있는 AbstractCommand 클래스의 소스 코드를 살펴보라), 만약 true를 리턴하며 addToHistory() 메소드를 사용하여 history 필드에 추가하고, false를 리턴하면 명령어가 실패했음을 알린다.

CommandManager 클래스에서 아직까지 설명하지 않은 것이 있다면 Undo와 Redo이다. Undo와 Redo는 인터페이스이며, 그 정의는 단순히 다음과 같다.

interface Undo {
}

interface Redo {
}

즉, Undo와 Redo는 Serializable 인터페이스와 마찬가지로 단순히 이 두 인터페이스를 implements 한 클래스의 타입이 Redo와 Undo라는 것을 보여준다. '명령 취소'와 관련된 명령어 클래스는 UndoCommand 이며 다음과 같다.

class UndoCommand extends AbstractCommand implements Undo {
    public UndoCommand() {
        manager.execute(this);
    }
    public boolean execute() { return false; }
    public boolean undo() { return false; }
}

RedoCommand 역시 UndoCommand와 비슷하다. 실제로 사용자가 '명령 취소'를 클릭하면 이를 처리하는 이벤트 리스너는 단순히 다음과 같은 코드를 실행하면 된다.

new RedoCommand();

커맨드 패턴을 사용함으로써 사용자로부터 요청을 받는 객체(즉, 메뉴나 툴바)와 실제로 사용자가 필요로 하는 기능을 구현한 객체를 완전히 분리할 수 있게 되었다. 따라서 사용자로부터 요청을 받는 객체는 실제 내부 로직이 어떻게 되는 지 알 필요가 없으며, 내부 로직을 구현한 명령어 객체 역시 사용자로부터 어떻게 요청을 받는 지 알 필요가 없다. 즉, 이 두 객체 사이에 의존관계가 없는 것이다. 따라서 요청을 받는 객체를 변경할 필요 없이 손쉽게 새로운 명령어 객체를 추가할 수 있다.

또 다른 구현 방법

여기서 AbstractCommand가 추상 클래스로 선언된 것은 static final 필드로 CommandManager를 갖고 있기 때문이다. 실제로 명령어 객체를 순서대로 저장하고 명령어 객체의 실행을 취소하는 것 등의 작업이 필요하지 않다면 CommandManager가 필요 없을 것이다. 이런 경우 추상 클래스 대신 인터페이스를 사용하여 AbstractCommand를 대신할 수 있다. 만약 AbstracCommand 대신 사용할 인터페이스가 CommandIF 라고 한다면, CommandIF 인터페이스는 다음과 같이 정의될 것이다.

public interface CommandIF {
    public boolean execute();
}

이제, 명령어 클래스들은 AbtractCommand를 상속받는 대신 CommandIF 인터페이스를 구현할 것이다. 예를 들어, PasteCommand 클래스의 경우 다음과 같이 바뀔 것이다.

public class PasteCommand implements CommandIF {
    .....
    public PasteCommand(Document document, int position) {
        this.document = document;
        this.postion = position;
        // CommandManager와 관련된 부분이 없다!!
    }
    public boolean execute() {
        .....
    }
}

이제, PasteCommand 클래스를 사용하는 PasteListener 클래스의 actionPerformed() 메소드는 다음과 같이 변경된다.

public class PasteActionListener implements ActionListener {

    public void actionPerformed(ActionEvent e) {
        // 현재 document와 position을 구한다.
        .....
        CommandIF command = new PasteCommand(document, position); // 명령어 객체 생성
        command.execute(); // 명령어 실행
    }
}

직접 execute() 메소드를 호출해주는 것을 알 수 있다.

CommandFactory를 이용하여 객체간의 관련성 감소시키기

앞에 있는 PasteActionLister 클래스는 AbstractCommand 클래스를 사용하는 경우나 CommandIF 인터페이스를 사용하는 경우 모두 actionPerformed() 메소드에서 반드시 PasteCommand 클래스를 사용하고 있다. 이제 CopyActionListener 클래스를 생각해보자. 이 클래스는 CopyCommand 클래스를 사용한다는 것을 제외하면 PasteActionListener 클래스와 완전히 동일하다. CutActionListener 역시 마찬가지이다. 그렇다면 어떻게 하면 될까? 가장 먼저 생각할 수 있는 것이 이러한 ActionListener를 다음과 같이 하나로 묶어주는 것이다.

public class CommonActionListener implements ActionListener {

    public void actionPerformed(ActionEvent e) {
        MenuItem mi = (MenuItem)e.getSource();
        CommandIF command = null;
        if (mi.getLabel().equals("복사") ) {
            command = new CopyCommand(...);
            command.execute();
        } else if (mi.getLabel().equals("붙여넣기") ) {
            command = new PasteCommand(...);
            command.execute();
        } else if (mi.getLabel().equals("잘라내기") ) {
            command = new CutCommand(...);
            command.execute();
        }
    }
}

물론, 이것만으로도 MenuItem과 실제 명령어 객체(CommandIF 인터페이스를 구현한 객체 또는 AbstractCommand 추상클래스를 상속받은 객체)와의 관련성을 없앤 채로 CommonActionListener를 사용하여 이 두 객체를 연결시킬 수 있다. 이를 좀더 객체지향적으로 접근한 것이 바로 CommandFactory 클래스이다. CommandFactory 클래스는 CommonActionListener 클래스의 actionPerformed() 메소드에서 if .. else .. 부분을 처리해주며 코드 형태는 다음과 같다.

public class CommandFactory {
    public CommandIF createCommand(String key) {
        CommandIF command = null;
        if (key.equals("복사") ) {
            command = new CopyCommand(...);
        } else if (key.equals("붙여넣기") ) {
            command = new PasteCommand(...);
        } else if (key.equals("잘라내기") ) {
            command = new CutCommand(...);
        }
        return command;
    }
}

실제로, CommandFactory 클래스는 팩토리(Factory) 패턴을 간단하게 구현한 것으로서 팩토리 패턴에 대한 자세한 내용은 패턴 관련 서적을 참조하기 바란다. 이제 CommandFactory 클래스를 사용하면 CommonActionListener는 다음과 같이 간단하게 바뀌게 된다.

public class CommonActionListener implements ActionListener {
    
    private CommandFactory factory = new CommandFactory();
    
    public void actionPerformed(ActionEvent e) {
        MenuItem mi = (MenuItem)e.getSource();
        CommandIF command = factory.getCommand(mi.getLabel());
        command.execute();
    }
}

CommonActionListener 클래스의 소스 코드가 간단해지긴 했지만, 왜 굳이 CommandFactory 클래스를 생성하는 지 의아해 할지도 모르겠다. 위만 보더라도 CommonActionListener에서 모든 걸 다 처리할 수 있기 때문이다. 하지만, 메뉴 뿐만 아니라 단축키를 통해서 사용자들에게 "복사", "잘라내기", "덧붙이기" 기능을 제공하길 원한다고 가정해보자. 이 경우, CommandFactory 객체를 사용하지 않는다면 키보드 이벤트를 처리하는 클래스는 다음과 같은 형태를 취할 것이다.

public class ShortCutListener implements KeyListener {
    public void keyReleased(KeyEvent e) {
        int keyCode = e.getKeyCode();
        int modifier = e.getModifiers();
        CommandIF command = null;
        if (keyCode == .. && modifier ... ) {
            command = new CopyCommand(...);
        } else if (...) {
            command = new CopyCommand(...);
        } else {
            command = new CopyCommand(...);
        }
        command.execute();
    }
    ....
}

ShortCutListener 클래스가 CommonActionListener 클래스와 거의 비슷한 것을 알 수 있다. 즉, 코드가 중복되는 것이다. 이 경우 Command 클래스가 추가되면 ShortCutListener 클래스와 CommonActionListener가 모두 영향을 받게 된다. 또한, CopyCommand 클래스 대신 Copy2Command 클래스를 사용하는 경우에도, 이 두 Listener 클래스는 영향을 받게 된다. 반면에 CommandFactory 객체를 사용하게 되면, Command 클래스가 추가되거나 변경되거나 삭제된다고 해도 오직 CommandFactory 클래스만 영향을 받게 된다. 즉, 두 Listener 클래스는 오직 CommandIF 인터페이스와 CommandFactory 클래스만 알면 될 뿐, 실제 Command 객체들에 대한 자세한 내용을 알 필요가 없는 것이다. 다시 말해서, 객체간의 관련성이 감소하게 된다. (객체 지향적인 설계에서 객체 사이의 관련성을 줄이는 것은 매우 중요하다!)

결론

지금까지 커맨드 패턴의 구현에 대해서 알아보았다. 커맨드 패턴을 통해서 사용자들은 명령어를 입력받는 객체가 그 명령어의 실제 구현에 대해서 알 필요 없이 명령어 객체를 사용하기만 하면 되었다. 또한, 명령어를 입력받는 객체와 처리하는 명령어 객체 사이에서 커맨드 팩토리를 사용함으로써 객체 사이의 관련성을 최대한 줄일 수 있게 되었다. 실제로 커맨드 패턴의 핵심은 명령어를 처리하는 부분을 객체로 캡슐화 함으로써 실제 내부 구현을 다른 객체들로부터 분리하는 것에 있다.

관련링크:
  1. 지니랜드 2009.03.24 10:12

    CommandFactory에 createCommand라는 메소드를 만들고, 아래서는
    factory.getCommand로 쓰셨네요. 둘 중 하나를 수정하셔야 할 듯하네요.^^;

    좋은 글 감사합니다.

  2. 패턴공부중 2013.02.06 06:33

    상세하고 좋은 글입니다.
    위키찾다가 빈약한 설명에 실망하고 들어와서 봤는데
    감사합니다.

  3. 감사합니다 2014.05.16 14:53

    좋은 정보 감사합니다

  4. 감사합니다 2016.01.18 10:48

    정리 잘되서 이해하기쉽습니다^^

반응형
Singleton 패턴을 적용한 매니저 클래스를 이용하여 모델 1 구조보다 견고하고 확장가능한 웹 어플리케이션을 개발하는 것에 대해 알아본다.

JSP 모델 1 구조의 한계

최근에 자바를 사용하여 웹 어플리케이션을 구축하는 개발자들은 JSP/Servlet/EJB 에 대해서 한두번씩은 경험하고 있을 것이다. 특히, 이제 막 웹 어플리케이션 개발 세계에 발을 들인 사람들은 JSP를 공부하고 있을 것이다. 하지만, 아쉽게도 많은 개발자들이 JSP를 이용하여 웹 어플리케이션을 개발할 때, JSP와 자바의 장점을 살리지 못하고 있는 것 같다. 특히, JSP 페이지에서 모든 필요한 비지니스 로직을 구현하는 경우가 많으며, 증권 사이트와 같은 대형 사이트를 구축할 경우에는 종종 JSP 페이지의 소스 코드가 1000 줄이 넘는 경우도 발생하고 있다. 즉, JSP 페이지에서 표현을 위한 HTML 부분을 제외한 나머지 (비지니스) 로직 부분이 소스 코드의 반절 이상을 차지한다는 것이다. 특히, EJB를 사용하지 않는 경우 그 정도는 더 심하다고 할 수 있다. 실제로 대규모가 아닌 사이트의 경우 대부분 EJB를 사용하지 않으며, 따라서 EJB를 사용하지 않는 중소규모의 웹 어플리케이션의 경우 많은 개발자들이 다음과 같은 구조로 개발하곤 한다.


위 그림은 JSP에서 모든 필요로 하는 비지니스 로직을 구현하는 것을 보여주고 있다. 이러한 형태로 웹 어플리케이션을 개발하는 것의 장점은 쉽다는 점이며, 개발 기간이 상대적으로 짧다는 점이다. 하지만, 이러한 모델1 구조는 다음과 같은 단점을 갖게 된다.

  • 동기화 문제: 여러 JSP 페이지에서 동시에 시스템의 동일한 자원에 접근하므로 동기화가 문제가 될 수 있으며, JSP 페이지에서 직접적으로 동기화를 처리할 수 있다고 해도 그리 간단하게 해결되지는 않는다.
  • 디버깅의 어려움: 같은 작업 단위(예를 들어, 게시판 글쓰기/글읽기/목록 보기)에 속한 것들을 여러 개의 JSP 페이지에서 나누어 처리하므로 각 페이지 별로 디버깅해야 한다.
  • 유지/보수의 어려움: 같은 작업 단위에 속한 JSP 페이지들이 각각 비지니스 로직을 갖고 있기 때문에 내부 로직이 변경될 경우 영향을 받는 모든 JSP 페이지를 손봐야 한다.
물론, 이러한 문제는 'JSP-서블릿'으로 구성된 모델2 구조와 EJB를 사용함으로써 많은 부분 해결할 수 있다. 하지만, 서블릿과 JSP의 관계를 견고하고 확장성있게 설계하는 것은 그리 쉽지 않으며, EJB를 사용하여 개발하는 것은 더더욱 쉽지 않다. 또한, 모델 2 구조와 EJB를 사용하기 위해서는 세밀한 컴포넌트 설계가 필요하며 따라서 개발기간이 그 만큼 길어지게 된다.

이러한 문제는 이 글에서 설명할 매니저 클래스를 사용함으로써 해결될 수 있다. 여러분은 매니저 클래스를 사용함으로써 모델 1 구조와 모델 2 구조의 중간적인 형태를 구현할 수 있으며, 따라서 모델 1 구조의 단점을 극복하면서 동시에 모델 2 구조의 장점을 취할 수 있게 된다. 매니저 클래스는 Singleton 패턴을 사용하여 구현하며, 먼저 Singleton 패턴에 대해서 알아보기로 하자.

Singleton 패턴

여러 패턴 중에서 Singleton 패턴은 비교적 구현이 간단하며, 정의는 다음과 같다. (이 글에서는 Singleton 패턴이 왜 필요한지에 대한 설명은 하지 않겠으며, Singleton 패턴의 실제 구현에 초점을 맞추도록 하겠다).

"한 클래스의 인스턴스가 오직 한 개만 존재한다."

이제 좀 더 쉽게 이 말을 설명해보자. 우리는 흔히 특정한 클래스의 인스턴스(또는 객체)를 생성할 때 다음과 같이 new 키워드를 사용한다.

Date date1 = new Date();
Date date2 = new Date();

위 코드에서 첫번째 new 연산과 두번째 new 연산은 서로 다른 Date 클래스의 인스턴스를 생성하며, 따라서 date1과 date2는 서로 다른 인스턴스를 참조하게 된다. 즉, Date 클래스의 인스턴스가 여러개 존재할 수 있는 것이다. 반면에 Singleton 패턴을 적용한 클래스의 경우는 new 키워드를 사용하여 인스턴스를 생성하는 일반적인 객체 생성 방법을 사용하지 않는다. Singleton 패턴을 적용한 클래스는 getInstance()라는 메소드를 사용하여 인스턴스를 구한다. 보통 다음과 같은 형태로 Singleton 패턴을 사용하는 클래스의 인스턴스를 구한다.

SingletonClass sac1 = SingletonClass.getInstance();
SingletonClass sac2 = SingletonClass.getInstance();

여기서 SingletonClass 클래스의 static 메소드인 getInstance()는 단 한 개의 인스턴스를 리턴하며, 따라서 sac1과 sac2는 같은 인스턴스를 참조하게 된다.

Singleton 패턴의 구현

Singleton 패턴을 적용한 클래스는 오직 한 개의 인스턴스만을 갖는다고 하였다. 이 말은 아무곳에서나 new 키워드를 통해서 인스턴스를 생성할 수 있으면 안 된다는 것을 의미한다. 따라서, Singleton 패턴을 구현하기 위해서는 먼저 생성자를 private으로 선언해야 한다. Singleton 패턴을 적용한 클래스가 SingletonClass라고 할 경우 전체적인 클래스의 정의는 다음과 같은 형태를 취한다.

class SingletonClass {
private static SingletonClass instance = new SingletonClass();

public static SingletonClass getInstance() {
return instance;
}
private SingletonClass() {
// 초기화
}

// 기타 다른 메소드의 정의가 온다.
.....
}

위 코드를 보면 static 필드로 instance가 정의되어 있는 것을 알 수 있다. static으로 선언되었기 때문에, instance 필드는 SingletonClass 클래스에 대해서 오직 한 개만 존재하며 따라서 Singleton 패턴의 정의를 만족시킨다. getInstance() 메소드는 단순히 instance 필드를 리턴하면 된다. 이렇게 구현함으로써 SingletonClass 클래스를 사용하는 모든 것들은 오직 한 개의 인스턴스를 공유하게 된다. 여기서 한 가지 짚고 넘어갈 것이 있다면, SingletonClass 클래스가 실제로 사용되는 지의 여부에 상관없이 항상 SingletonClass 클래스의 인스턴스가 생성된다는 점이다. 실제로 인스턴스를 생성할 필요가 있는 시점은 처음으로 getInstance() 메소드가 호출되는 때이며, 따라서 다음과 같이 코드를 변경할 수 있다.

class SingletonClass {
private static SingletonClass instance = null;

public static SingletonClass getInstance() {
if (instance == null)
instance = new SingletonClass();

return instance;
}
private SingletonClass() {
// 초기화
}

// 기타 다른 메소드의 정의가 온다.
.....
}

이제 getInstance()가 처음으로 호출되는 순간에 SingletonClass의 인스턴스가 생성된다. (이처럼 처음부터 인스턴스를 생성하지 않고 필요한 순간에 인스턴스를 생성하는 것을 'Lazy Instantiation' 이라고 하며, 시스템 자원을 효율적으로 사용하기 위해서 많이 사용된다). 하지만, 위 코드를 살펴보면 문제점을 발견할 수 있을 것이다. 바로 동기화이다. 동시에 두 개의 쓰레드가 getInstace()를 호출한다면 어떻게 될까? 알맞게 동기화 처리가 되어 있지 않기 때문에, 두 개의 인스턴스가 생성될 수 있다. 이는 Singleton 패턴의 정의에 어긋나는 것이며, 따라서 멀티 쓰레드 환경에서 문제가 발생하지 않도록 동기화 처리를 해 주어야 한다. 동기화 처리를 하도록 변형된 getInstance() 메소드는 다음과 같다.

public static SingletonClass getInstance() {
if (instance == null)
synchronized(SingletonClass.class) {
if (instance == null)
instance = new SingletonClass();
}
return instance;
}

위 코드에서 if 문을 두번한 것을 알 수 있다. 이 처럼 두 번 체크 하는 것을 'double-check idiom'이라 한다. 이와 관련된 내용은 Doug Lea가 쓴 'Concurrent Programming in Java, Second Edition: Design Principles and Patterns'(Addison Wesley)에 자세한 내용이 있으니 참조하기 바란다. 또한, Lazy Instantiation에 대한 내용은 JavaWorld Tip 65에서 참고할 수 있다.

Singleton 패턴을 이용한 매니저 클래스

여러분이 쇼핑몰을 구축한다고 해보자. 이 웹 어플리케이션은 제품 주문/주문 취소/주문 제품 목록 표시 등의 기능을 제공해야 한다. 여기서 나열한 기능은 모두 "제품 구매"라는 하나의 처리 과정에 속하며, 따라서 하나의 처리 과정에 속하는 기능을 하나의 클래스에서 관리하도록 할 수 있다. 이처럼 특정한 기능들을 제공하고 관리하는 클래스를 매니저 클래스라 할 수 있으며, 보통 클래스 이름에 Manager 라는 단어를 붙이곤 한다. 예를 들어, 회원 관리 기능을 제공하는 클래스를 MemberManager라고 이름지을 수 있다.

웹 어플리케이션은 이처럼 일련의 기능들이 하나의 매니저 클래스로 묶이는 경우가 많다. 예를 들어, 회원 관리/구매 관리/게시판/방명록/배너 광고 관리 등은 각각 별도의 매니저 클래스를 통해서 필요한 기능들을 제공할 수 있을 것이다. 여기서 중요한 점은 매니저 클래스에서 모든 중요한 기능-거의 대부분은 DB 처리일 것이다-을 수행한다는 것이다. 예를 들어, 회원 관리를 생각해보자. 회원 관리에는 회원 추가/회원 탈퇴/회원 정보 변경 등의 기능이 필요하며, MemberManager 클래스가 이러한 기능을 제공한다고 해보자. 이 경우 전체적인 구조는 다음과 같을 것이다.


즉, 회원과 관련된 모든 기능은 MemberManager 클래스를 통해서 이루어지는 것이다. 즉, 매니저 클래스가 모든 기능의 엔트리 포인트(Entry Point; 진입 지점)가 되는 것이다. 이처럼 매니저가 중요한 모든 기능을 제공하는 경우 매니저 클래스의 인스턴스가 두 개 이상 생성되는 것은 문제가 될 수 있으며, 동기화 처리에 있어서 특히 문제가 될 수 있다. 따라서 매니저 클래스는 Singleton 패턴을 적용하여 구현해야 하며, 따라서 대부분의 매니저 클래스는 다음과 같은 형태로 정의된다.

public SomeManager extends SuperClass implements SuperInterface {
private static SomeManager instance = null;

public static SomeManager getInstance() { // Singleton 패턴
if (instance == null)
synchronized(syncObj) {
if (instance == null)
instance = new SingletonClass();
}
return instance;
}
private SomeManager() {
// 초기화
}
public void doSomeFunction1(...) {
// 어떤 기능을 수행한다.
}
public AnyBean getSomeInfo2(...) {
// 어떤 정보를 구한다.
return abean;
}
.....
}

필요에 따라 어떤 기능을 하는 doSomeFunction1()이나 getSomeInfo2()와 같은 메소드의 선언부분에 동기화를 위한 목적으로 synchronized 키워드를 추가할 수 있으며, 또는 메소드 내부의 필요한 곳에서 synchronized() 블럭을 사용하여 동기화를 할 수도 있다. 매니저 클래스는 다음과 같은 형태로 사용하면 된다.

SomeManager sMgr = SomeManager.getInstance();
// 필요한 기능을 하는 메소드 호출
sMgr.doSomeFunction1();
.....

JSP 페이지와 매니저 클래스

이제 마지막으로 JSP 페이지에서 매니저 클래스를 사용하는 것에 대해서 알아보자. JSP 페이지와 매니저 클래스의 관계는 다음 그림과 같다.


위 그림에서 실제 내부 로직은 매니저 클래스에서 담당하며 JSP는 매니저에 데이터를 넘겨주거나(보통 자바빈 컴포넌트를 넘겨준다) 매니저로부터 필요한 데이터를 넘겨받을 것이다. JSP 페이지는 단지 필요한 매니저를 사용하면 될 뿐, 매니저 클래스에서 내부적으로 어떤 자원을 사용하는 지, 내부 로직이 어떻게 되는 지에 대해선 전혀 알 필요가 없다. 또한, 동기화 문제 역시 매니저 클래스 내부에서 처리되므로 JSP는 동기화에 대한 특별한 처리를 할 필요가 없다.

실제로 JSP 페이지에서 매니저를 사용하는 것은 매우 간단하다. 먼저 특정 매니저 클래스의 getInstance()를 호출하여 인스턴스를 구한 후, 필요로 하는 기능을 제공하는 메소드를 호출하면 된다. 예를 들어, 신규 회원 가입을 처리하는 JSP 페이지가 있다고 해 보자. 이 JSP 페이지는 HTML 폼을 통해서 회원의 이름, 사용할 ID, 암호, 주소 등 다양한 정보를 입력받을 것이다. 이때, 신규 회원 가입 JSP 페이지는 다음과 같은 순서로 신규 회원 가입을 처리할 것이다. (여기서, MemberManager 클래스는 회원과 관련된 기능을 제공하는 매니저 클래스이며, MemberBean 클래스는 사용자가 입력한 정보를 저장하는 자바빈 컴포넌트라고 가정한다).

  1. <jsp:useBean>와 <jsp:setProperty> 액션 태그를 사용하여 사용자가 입력한 정보를 자바빈 컴포넌트에 저장한다.
  2. MemberManager 클래스의 Singleton 인스턴스를 구한다.
  3. 매니저의 회원 가입 메소드를 호출한다. 이 때, 1에서 생성한 자바빈 컴포넌트를 인자로 넘겨준다.
이를 JSP 코드로 표시하면 다음과 같을 것이다.

<%@ page import = "com.mypackage.member.MemberManager" %>
<%@ page errorPage = "error_page.jsp" %>
<%-- 자바빈 컴포넌트를 생성해서 사용자가 입력한 값을 저장한다. --%>
<jsp:useBean id="newData" class="com.mypackage.member.bean.MemberBean">
<jsp:setProperty name="newData" property="*" />
</jsp:useBean>
<% MemberManager mMgr = MemberManager.getInstance();
mMgr.registerNewMember(newData); // 새로운 회원 추가
// 예외가 발생할 경우 에러 페이지인 error_page.jsp로 이동
%>
<!-- 실제 표현 부 -->
<html>
....
</html>

회원의 정보를 변경하는 페이지 역시 다음과 같은 구조를 이룰 것이다.

<%@ page import = "com.mypackage.member.MemberManager" %>
<%@ page errorPage = "error_page.jsp" %>
<%-- 자바빈 컴포넌트를 생성해서 사용자가 입력한 값을 저장한다. --%>
<jsp:useBean id="updateData" class="com.mypackage.member.bean.MemberBean">
<jsp:setProperty name=" updateData" property="*" />
</jsp:useBean>
<% MemberManager mMgr = MemberManager.getInstance();
mMgr.updateMemberInfo(newData); // 새로운 회원 추가
// 예외가 발생할 경우 에러 페이지인 error_page.jsp로 이동
%>
<!-- 실제 표현 부 -->
<html>
....
</html>

이 두 JSP가 매우 간단하다는 것을 알 수 있다. 동기화를 비롯한 모든 처리가 매니저를 통해서 이루어지기 때문에 웹 어플리케이션의 전체적인 구조가 견고해진다. 또한, 내부 로직이 변경되거나 MemberBean 클래스가 변경되더라도 단지 MemberManager 클래스만 변경하면 될 뿐, JSP 페이지는 거의 영향을 받지 않는다. 따라서 유지/보수가 JSP에서 비지니스 로직을 직접적으로 구현하는 것에 비해 훨씬 용이하다. 그리고 매니저 클래스를 사용할 경우 JSP에서 모든 걸 구현할 때에 비해 좀 더 손쉽게 모델 2 구조로 확장할 수 있으며, EJB로의 확장 역시 용이하다.

하지만, 아쉽게도 디버깅에 있어서는 아직 별다른 진전을 보이지 못한다. 그저 여러 JSP 페이지가 아닌 하나의 .java 파일을 검사하면 된다는 것에 위안을 삼아야 할 처지이다. 자바 개발자에게 있어서 디버깅은 정말로 골치덩어리인거 같다. 하루라도 빨리 개발자들을 조금이라도 편하게 해줄 자바 디버깅 툴이 나오길 바랄 뿐이다.

결론

Singleton 패턴을 적용한 매니저 클래스는 JSP에서 로직과 표현을 담당하는 모델 1 구조와 로직은 서블릿에서 담당하고 표현은 JSP에서 담당하는 모델 2 구조의 중간 형태를 구현할 수 있도록 해 준다. 매니저 클래스를 사용함으로써 모델 1 구조의 단점을 극복하고 좀 더 견고한 웹 어플리케이션을 개발할 수 있게 되었으며, 차후에 좀 더 손쉽게 웹 어플리케이션을 확장할 수 있게 되었다. 또한 매니저 클래스는 웹 어플리케이션 뿐만 아니라 대부분의 어플리케이션에서 사용할 수 있으므로 여러분의 어플리케이션에서 손쉽게 활용할 수 있을 것이다.

관련링크:
  1. SsanHoCo 2020.05.30 15:51 신고

    감사합니다. 잘보고갑니다

반응형
Properties 클래스를 사용하여 다양한 종류의 어플리케이션에서 손쉽게 사용할 수 있는 범용적인 Configuration 클래스를 구현해본다.

설정 시스템은 왜 필요한가?

대부분의 어플리케이션은 올바르게 실행하기 위해서 몇 가지 설정을 해 주어야 한다. 예를 들어, 웹 서버를 개발할 경우 다음과 같은 질문에 답할 수 있어야 한다.

  • 몇 번 포트를 사용할 것인가? 80 포트? 또는 8080 포트? 아니면 그 외 다른 포트?
  • 웹 문서의 루트는 어느 디렉토리를 사용할 것인가?
  • 기본 문서를 index.html로 할 것인가? 또는 index.htm으로 할 것인가?
물론, 이 세 가지 질문 이외에 다른 많은 질문들이 떠오를 것이다. 여기서 이러한 질문을 던지는 주체는 바로 웹 서버 프로그램이다. 왜냐면, 웹 서버를 실행하기 위해서는 위와 관련된 정보를 반드시 알고 있어야 하기 때문이다. 그렇다면, 여러분은 웹 서버 프로그램에 위 질문에 대한 답을 제공할 수 있어야 한다. 가장 첫번째로 사용할 수 있는 방법은 다음과 같이 프로그램 코드에 직접 위 질문과 관련된 부분을 삽입하는 것이다.

public void run() {
   server = new ServerSocket(80);
   while(true) {
      socket = server.accept();
      new ProcessClient(socket).start();
   }
   .....
}

class ProcessClient implement Thread {
   private String documentRoot = "/user/madvirus/webroot";
   private String[] defaultDocument = new String[] { "index.html", "index.htm" };
   
   public void run() {
      ...
   }
}

이와 같은 방법의 장점은 프로그래밍하기가 매우 쉽다는 점이다. 하지만, 단점이 장점보다는 더 크다. 가장 큰 단점은, 포트 번호나 문서의 루트 디렉토리와 같은 설정 정보를 변경하기 위해서는 소스 코드를 재컴파일해야 한다는 점이다. 여기서 발생되는 또 다른 문제점은 개발시와 배포시에 사용되는 환경이 다르기 때문에, 배포시에 다시 한번 더 소스 코드를 재컴파일해야 하는 과정을 밟아야 한다는 점이다. 이것뿐만이 아니다. 일단 배포가 완전히 이루어졌다고 해도, 실제 어플리케이션을 사용하는 도중에 설정 값을 바꿔야 할 때가 있다. 이러한 경우, 최종 사용자들은 설정할 수 있는 방법이 없으므로, 개발자가 일일이 변경을 해 주어야 한다. 이는 매우 귀찮은 일이 될 수 있다.

이러한 단점을 없애기 위해서 많이 사용되는 방법중의 하나가 바로 설정 파일을 작성하는 것이다. 예를 들어, 아파치 웹 서버의 경우 다음과 같은 형태의 설정 파일을 사용하고 있다.

Port 80
ServerAdmin root@localhost

# ServerRoot: The directory the server's config, error, and log files
# are kept in.
ServerRoot /etc/httpd

# ErrorLog: The location of the error log file. If this does not start
# with /, ServerRoot is prepended to it.
ErrorLog logs/error_log

여기서, 아파치 웹 서버 관리자는 단순히 설정 파일의 특정한 값을 변경하고, 웹 서버를 재가동함으로써 변경된 값을 적용할 수 있다. 예를 들어, 포트 번호를 8080으로 변경하고자 할 경우 위의 Port 다음에 있는 80을 8080으로 변경한 후, 웹 서버를 재가동하면 된다. 여기서 '#'은 주석을 의미하며, 아파치 웹 서버는 '#'으로 시작되는 줄은 분석하지 않는다.

소스 코드에 직접적으로 입력하는 것에 비해서 상당히 진보된 형태임을 알 수 있다. 이 글에서는 java.util 패키지의 Properties 클래스를 사용하여 위와 비슷한 형태의 설정 파일로부터 설정 정보를 읽어오는 클래스인 Configuration 클래스를 작성할 것이다. 이 클래스를 사용함으로써 여러분은 좀 더 손쉽게 어플리케이션에 '설정가능한(configurable) 환경'을 제공할 수 있을 것이다.

설정파일의 형태

먼저 살펴볼 내용은 어플리케이션에서 사용할 설정 파일이다. Configuration 클래스가 인식할 수 있는 설정파일의 형태는 다음과 같다.

# 주석으로 처리되는 줄
key1=value1
key2=value2
.....

각각의 프로퍼티는 'key1=value1'의 형태로 저장된다. 여기서 등호(=)의 왼쪽에 있는 key1은 프로퍼티의 키에 해당하며 value1은 프로퍼티의 값에 해당한다. 아파치 웹 서버의 설정 파일과 마찬가지로 '#'으로 시작되는 줄은 주석으로 처리한다. 웹 서버를 위한 설정 파일의 경우 다음과 같은 형태를 지닐 것이다.

# 웹 서버가 사용할 포트 번호
webserver.port=8080

# 문서의 루트 디렉토리
webserver.root_directory=/home/madvirus/webroot/

# 기본적으로 읽어올 문서
webserver.default_document=index.html,index.htm

단지 웹 서버 뿐만 아니라 여러 곳에서 이러한 형태의 설정 파일을 손쉽게 사용할 수 있을 것이다. 예를 들면, 데이터베이스 커넥션 풀에서 사용할 JDBC 드라이버의 종류라든가 최대로 열수 있는 Connection의 개수 등을 이 설정 파일에서 지정할 수 있을 것이다. 위에서 예로든 설정 파일의 webserver.port=8080 부분을 보면 프로퍼티의 값이 문자열 뿐만 아니라 숫자가 될 수 있다는 것을 알 수 있다. 따라서, 우리가 작성할 Configuration 클래스는 String 타입 뿐만 아니라, int, double, boolean과 같은 타입을 처리할 수 있어야 한다.

java.util.Properties 클래스와 Config 인터페이스

이제 Configuration 클래스에서 핵심적인 역할을 하는 java.util 패키지에 있는 Properties 클래스에 대해서 알아보도록 하자. Properties 클래스는 프로퍼티의 지속적인(persistent) 집합을 나타내며, 프로퍼티의 목록을 스트림으로 저장되거나 스트림으로부터 읽어올 수 있다. java.util.Hashtable 클래스를 상속하고 있기 때문에 각각의 프로퍼티들은 <키, 값>의 형태로 저장되며, 또한 Hashtable의 put() 메소드를 사용하여 손쉽게 프로퍼티를 추가하거나 변경할 수 있다. (하지만, 프로퍼티의 값을 추가하거나 변경하는 것이 그리 좋은 방법은 아니다).

Properties 클래스에서 사용할 메소드는 getProperty()이며, 다음과 같은 두 가지 형태가 존재한다.

public String getProperty(String key)
public String getProperty(String key, String defaultValue)

첫번째 메소드는 key를 키값으로 갖는 프로퍼티가 있을 경우 그 값을 리턴하고, 없다면 null을 리턴한다. 두번째 메소드는 key를 키값으로 갖는 프로퍼티가 있을 경우 그 값을 리턴하고, 없다면 두번째 파리미터로 넘겨준 defaultValue를 리턴한다. getProperty() 메소드 이외에 다른 메소드에 대해 알고 싶다면 Java API 문서를 참조하기 바란다.

Config 인터페이스, AbstractConfiguration 클래스

앞에서 이 글의 목적은 다양한 어플리케이션에서 범용적으로 사용될 수 있는 설정 클래스를 작성하는 것이다. 범용적인 설정 클래스를 작성한다는 것은 여러 종류의 형태로 설정 정보를 입력 받을 수 있어야 한다는 것을 의미한다. 예를 들어, 로컬 파일이 아닌 RMI, HTTP, Corba와 같이 원격에 있는 자원을 사용하여 설정 내용을 읽어올 수 있어야 한다. 그렇다면 Configuration 클래스에서 이 모든 것을 다 처리할 것인가? 답은 '아니오'이다. Configuration 클래스는 단지 로컬에 있는 파일로부터 프로퍼티를 읽어오며, 원격으로부터 프로퍼티를 읽어오는 것은 RMIConfiguration, HTTPConfiguration 그리고 CorbaConfiguration과 같은 별도의 클래스에서 책임을 진다. 이들 클래스들은 모두 다른 자원으로부터 프로퍼티를 읽어오는 것이 다를 뿐 그들의 역할은 모두 같다. 이처럼 여러 개의 클래스들이 공통된 형태의 작업을 하는 경우에 주로 사용되는 것이 있다. 바로 상속이다. 이 절의 제목에서 사용된 AbstractConfig 클래스는 모든 종류의 Configuration 클래스들이 상속받는 추상 클래스이며, Config 인터페이스를 구현하고 있다. 즉, 다음과 같은 형태의 구조를 지닌다.


먼저 Config 인터페이스를 정의해보자. Config 인터페이스는 String, int, long, double, float 형의 프로퍼티의 값을 구해주는 메소드를 선언하고 있다. Config 인터페이스의 소스 코드는 다음과 같다.

package javacan.config;

public interface Config {
   public java.util.Properties getProperties();
   public String getString(String key);
   public int getInt(String key);
   public double getDouble(String key);
   public boolean getBoolean(String key);
}

Config 인터페이스는 보는 것 처럼 간단하다. 단순히 필요한 타입의 프로퍼티 값을 구해주는 네 개의 getXXX(key) 메소드를 정의하고 있다. Configuration 클래스의 사용자들은 Configuration, HTTPConfiguration, RMIConfiguration과 같은 클래스들의 구체적인 구현을 알 필요 없이 단지 Config 인터페이스를 통해서 필요한 프로퍼티의 값을 읽어오기만 하면 된다. 이제 AbstractConfiguration 클래스의 소스를 살펴보자.

package javacan.config;

import java.util.Properties;

public abstract class AbstractConfiguration implements Config {
   /**
    * 모든 속성값을 저장한다..
    */
   protected Properties props = null;
   
   public AbstractConfiguration() {
      // do nothing;
   }
   
   /**
    * 모든 속성 이름을 구한다.
    */
   public Properties getProperties() {
      return props;
   }
   
   /**
    * String 타입 속성값을 읽어온다.
    */
   public String getString(String key) {
      String value = null;
      value = props.getProperty(key);
      
      if (value == null) throw new IllegalArgumentException("Illegal String key : "+key);
      
      return value;
   }
   
   /**
    * int 타입 속성값을 읽어온다.
    */
   public int getInt(String key) {
      int value = 0;
      try {
         value = Integer.parseInt( props.getProperty(key) );
      } catch(Exception ex) {
         throw new IllegalArgumentException("Illegal int key : "+key);
      }
      return value;
   }
   
   /**
    * double 타입 속성값을 읽어온다.
    */
   public double getDouble(String key) {
      double value = 0.0;
      try {
         value = Double.valueOf( props.getProperty(key) ).doubleValue();
      } catch(Exception ex) {
         throw new IllegalArgumentException("Illegal double key : "+key);
      }
      return value;
   }
   
   /**
    * boolean 타입 속성값을 읽어온다.
    */
   public boolean getBoolean(String key) {
      boolean value = false;
      try {
         value = Boolean.valueOf(props.getProperty(key)).booleanValue();
      } catch(Exception ex) {
         throw new IllegalArgumentException("Illegal boolean key : "+key);
      }
      return value;
   }
}

AbstractConfiguration 클래스는 Config 인터페이스의 각 메소드를 구현하고 있다. 또한, 각각의 프로퍼티는 멤버 변수인 props에 저장되어 있다고 가정한다. props는 Properties 클래스이며 각각의 getXXX(key) 메소드는 내부적으로 이 Properties를 사용한다. 여기서 주의할 점은 getXXX()에 throws 구문을 통해서 예외를 던지지 않고 RuntimeException의 한 종류인 IllegalArgumentException을 발생한다는 것이다. 이는 자바에서는 상위 클래스에 있는 메소드를 오버라이딩(overriding) 할 경우 throws 구문을 변경할 수 없기 때문에 그렇게 한 것이다. AbstractConfiguration 클래스의 소스 코드를 보면 AbstractConfiguration 클래스는 자체적으로 Properties 클래스를 초기화하지 않는다는 것을 알 수 있다. 이러한 프로퍼티의 초기화는 AbstractConfiguration 클래스를 상속한 Configuration이나 HTTPConfiguration과 같이 실제로 특정 자원(예를 들면, 로컬 파일)으로부터 프로퍼티 목록을 읽어오는 클래스에서 이루어진다.

Configuration 클래스의 구현

Configuration 클래스는 로컬 파일로부터 설정 정보를 읽어와서 Properties 객체를 초기화한다. 로컬 파일을 읽어오는 방법에는 여러 가지가 있을 수 있으나, 여기서는 CLASSPATH에 있는 파일로부터 설정 정보를 읽어올 것이다. CLASSPATH에 있는 파일을 읽어오기 위해서는 ClassLoader 클래스의 getSystemResource() 메소드를 사용하면 된다. 예를 들어, CLASSPATH에 있는 javacan.properties 파일을 읽고자 한다면, 다음과 같은 코드를 사용하면 된다.

java.net.URL dbURL = ClassLoader.getSystemResource("javacan.properties");
File fileName = new File(dbURL.getFile());

예를 들어, 현재 환경변수 CLASSPATH의 값이 다음과 같다고 해 보자.

CLASSPATH=/home/madvirus/config/

이 경우 javacan.properties 파일은 /home/madvirus/config 디렉토리에 위치하면 된다.

ClassLoader.getSystemResource() 메소드는 현재 사용되는 클래스로더에 따라 다른 형태로 구현될 수 있다. 자바2에서 기본적으로 사용되는 클래스로더의 경우, getSystemResource() 메소드는 CLASSPATH로부터 자원을 읽어온다. 이제 CLASSPATH에 있는 javacan.properties 파일을 읽어올 수 있게 되었다. 이제 남은 것은 그 파일로부터 프로퍼티 목록을 읽어와서 Properties 클래스의 인스턴스를 생성하는 것이다. 이에 대한 설명은 Configuration 클래스의 소스 코드를 본 이후에 계속하도록 하자.

package javacan.config;

import java.io.*;

public class Configuration extends AbstractConfiguration {
   
   /**
    * 설정 파일의 이름을 저장한다.
    */
   private String configFileName;
   
   public Configuration() throws ConfigurationException {
      initialize();
   }
   
   /**
    * 필요한 초기화를 한다.
    */
   private void initialize() throws ConfigurationException {
      java.net.URL dbURL = ClassLoader.getSystemResource("javacan.properties");   
      if (dbURL == null) {
         File defaultFile = new File(System.getProperty("user.home"), "javacan.properties");
         configFileName = System.getProperty("javacan.config.file", defaultFile.getAbsolutePath());
      } else {
         File fileName = new File(dbURL.getFile());
         configFileName = fileName.getAbsolutePath();
      }
      
      try {
         File configFile = new File(configFileName);
         if ( ! configFile.canRead() ) 
            throw new ConfigurationException( "Can't open configuration file: " + configFileName );
         
         props = new java.util.Properties();
         FileInputStream fin = new FileInputStream(configFile);
         props.load(new BufferedInputStream(fin));
         fin.close();
      } catch(Exception ex) {
         throw new ConfigurationException("Can't load configuration file: "+configFileName);
      }
   }
}

Configuration 클래스의 소스를 보면, 단지 생성자와 initialize() 메소드만 정의되어 있다. initialize() 메소드는 CLASSPATH에 있는 javacan.properties 파일로부터 프로퍼티 정보를 읽어온 후 상위 클래스의 멤버인 props에 저장한다. 여기서 눈여겨 볼 부분은 바로 Properties의 load() 메소드를 사용하는 부분이다. Properties 클래스의 load() 메소드는 파라미터로 넘겨준 InputStream으로부터 자동으로 프로퍼티 목록을 만들어낸다. 따라서 개발자가 파일을 분석한 후 일일이 각각의 프로퍼티를 Properties에 추가해줄 필요가 없다.

이제 마지막으로 ConfigurationException 클래스에 대해서 알아보자. 이 클래스는 이름에서도 알 수 있듯이 예외 클래스이며, 다음과 같이 간단하게 정의되어 있다.

package javacan.config;

public class ConfigurationException extends Exception {

   public ConfigurationException() {
      super();
   }

   public ConfigurationException(String s) {
      super(s);
   }
}

테스트

이제 모든 "설정가능한" 어플리케이션을 만들기 위한 모든 준비가 끝났다. Configuration 클래스의 사용법은 다음과 같이 매우 쉽다.

Config conf = new Configuration();
int val1 = conf.getInt("port");
String email = conf.getString("admin_email");

실제로 Configuration 클래스가 올바르게 동작하는 지 테스트하기 위해서는 프로퍼티 목록을 저장하고 있는 설정 파일이 필요하다. 테스트에서 사용할 설정 파일 javacan.properties는 다음과 같다.

webserver.hostname=www.javacan.com
webserver.document_root=/home/madvirus/webroot
webserver.port=8080

이 파일은 CLASSPATH에 위치해야 한다. 예를 들어, 이 파일이 /home/madvirus/conf/ 디렉토리에 있다면, CLASSPATH에 /home/madvirus/conf 디렉토리를 추가해주어야 한다. 이제 테스트를 위한 Test 클래스를 작성해보자. Test 클래스는 다음과 같다.

import javacan.config.*;

public class Test {

   public static void main(String[] args) {
      try {
         Config conf = new Configuration();
         System.out.println("webserver.hostname:"+conf.getString("webserver.hostname") );
         System.out.println("webserver.document_root:"+conf.getString("webserver.document_root") );
         System.out.println("webserver.port:"+conf.getInt("webserver.port") );
      } catch(ConfigurationException ex) {
         ex.printStackTrace();
      }
   }
}

Test 클래스를 실행하면 다음과 같이 결과가 출력될 것이다.

[webmaster@server madvirus]$ java Test
webserver.hostname:www.javacan.com
webserver.document_root:/home/madvirus/webroot
webserver.port:8080
[webmaster@server madvirus]$

문제점

Configuration 클래스가 사용하기 쉽다는 장점은 있으나, 몇 가지 문제점을 갖고 있다.

첫번째 문제점은 Configuration 클래스의 인스턴스를 생성할 때 마다 매번 설정 파일을 읽어온다는 점이다. 실제로 설정 파일이 변경되는 경우는 극히 드물며, 따라서 설정 내용이 필요할 때 마다, 매번 설정 파일을 읽어온다는 것은 비효율적이다.

두번째 문제점은 여러 개의 파일로부터 설정 정보를 읽어올 수 없다는 점이다. 이는 Configuration 클래스를 조금만 변경하면 손쉽게 개선할 수 있을 것이다. 단, 여러 파일로부터 설정 정보를 읽어올 경우 서로 다른 파일에 있는 프로퍼티가 같은 키값을 가질수도 있기 때문에, 키값이 겹치지 않도록 하기 위한 별도의 방법을 사용해야 한다.

세번째 문제점은 설정 파일만으로는 각 프로퍼티 간의 관계를 알 수 없다는 점이다. 물론, 엄밀히 말해서 이것이 문제점이 되지는 않는다. 현재 나와 있는 대부분의 어플리케이션의 설정 파일은 여기서 작성한 설정 파일과 비슷한 형태로 되어있으며, 현재까지 이러한 형태의 설정 파일이 문제가 된 적은 거의 없다. 하지만, 각 프로퍼티 간의 관계를 명시적으로 표시할 수 있다면 설정 파일을 관리하는 것이 지금보다 훨씬 더 간단하고 수월해질 것이다.

네번째 문제점은 세번째 문제점과 유사한 것으로 프로퍼티들의 집합이라는 개념이 존재하지 않는다는 것이다. 예를 들어, 채팅서버와 웹 서버의 프로퍼티를 하나의 설정 파일에 저장할 수 있으며, 이러한 경우에 채팅서버와 관련된 프로퍼티와 웹 서버와 관련된 프로퍼티를 별도의 집합으로 지정할 수 있다면, 훨씬 더 명료하게 설정 파일을 관리할 수 있을 것이다.

이러한 문제점 중에 몇 가지는 XML을 이용하여 손쉽게 해결할 수 있다. 나중에 기회가 된다면 XML을 이용한 설정에 대해서 알아볼 것이다.

결론

이 글에서는 java.util.Properties 클래스를 이용하여 설정 파일로부터 손쉽게 프로퍼티를 읽어오는 것에 대해 알아보았다. Configuration 클래스는 CLASSPATH에 있는 자원으로부터 프로퍼티를 읽어 매우 손쉽게 어플리케이션의 여러 프로퍼티를 설정할 수 있도록 해준다. 비록 몇 가지 문제점이 있긴 하지만, 여러분 스스로 조금만 노력한다면 그러한 문제를 어렵지 않게 해결할 수 있을 것이다.

관련링크:
반응형
XML과 XML 관련 기술에 대해서 간략하게 알아보며, 자바와 XML 사이의 관계에 대해서 알아본다.

XML은 무엇인가?

XML! XML! XML! 만약 여러분이 IT 세상의 흐름과 함께 하고 있다면 XML에 대한 많은 것들에 대해서 들어보았을 것이며, 이제 막 개발자의 세계에 발을 들여놓았다 하더라도 적어도 한번 정도는 'XML'이란 단어를 들어보았을 것이다. 현재 마이크로소프트, 썬 마이크로시스템즈, 오라클, IBM과 같은 IT 분야를 대표하는 대부분의 기업들은 XML을 분석(parsing)하고 변환(transformation)할 수 있는 여러 도구들을 제공하고 있으며, 최근에 각 기업이 내 놓고 있는 제품들은 XML을 여러 형태로 사용하고 있다. 이러한 XML 영역의 증가추세는 앞으로 더욱 가증될 것으로 예상된다. 이번 Article에서는 초보 개발자 뿐만 아니라 아직까지 XML이 무엇인지에 대해서 자세하게 알지 못하는 개발자들을 위해 XML이 무엇인지, 어디서 XML을 사용하는지 그리고 XML을 왜 사용하는 지에 대한 전반적인 내용을 간략하게 알아볼 것이다.

XML의 정의

XML은 'Extensible Markup Language'이다. 우리말로 하자면 '확장가능한 마크업 언어' 정도가 될 것이다. XML은 앞서 나온 SGML과 마찬가지로 다른 언어를 정의할 때 사용되는 메타언어(metalanguage)이다. 하지만, XML은 SGML 보다 훨씬 더 간단하다. 또한, 오늘날 가장 많이 사용되고 있는 마크업 언어인 HTML의 확장성이 거의 없는 반면에 XML은 거의 무한의 확장성을 갖고 있다. 이러한 XML의 확장성은 XML이 문법(grammar)이나 '태그집합(tag set)'을 규정하지 않은 마크업 언어이기 때문에 가능하다. 간단히 비교를 하기 위해 HTML과 비교해보자. 현재 나와 있는 HTML 규약은 사용자가 사용할 수 있는 태그 및 속성의 종류를 제한하고 있다. 다시 말해서, HTML은 미리 정의된 태그 집합과 문법을 갖고 있다. 예를 들어, HTML에서 <table> 태그를 사용할 수는 있지만, <furniturelist> 태그를 사용할 수는 없다. HTML 문서를 사용하는 어플리케이션(거의 웹 브라우저일 것이다)에 대해 <table> 태그는 특정한 의미를 지니며 표의 시작을 나타낼 때 사용되는 반면에, <furniturelist> 태그는 HTML 문서를 사용하는 어플리케이션에 대해 어떤 의미도 갖지 않으며 웹 브라우저는 이 태그를 처리하지 않고 무시할 것이다. 이는 HTML을 정의할 때, HTML 규약에 사용가능한 태그 집합을 정의했기 때문에 그렇다. 따라서 새로운 태그를 추가하거나 불필요한 태그를 삭제하기 위해서는 새로운 버전의 HTML 규약을 발표해야만 한다. 또한, HTML은 언어에 정의된 태그의 올바른 사용법을 정의한 '문법(grammar)'을 갖고 있다. 예를 들어, <tr> 태그는 반드시 <table> 태그에 중첩되어야 하며, <table> 태그는 width, border, cellpadding과 같은 속성의 값을 지정할 수는 있지만 type이라는 속성을 지정할 수는 없다.

반면에 XML은 미리 정의된 태그 집합이나 문법을 규정하고 있지 않기 때문에 HTML과는 달리 완전한 확장성을 가진다. XML 문서 작성자는 원하는 태그를 사용할 수 있으며, 태그에 원하는 속성을 지정할 수 있으며, 원하는 형태로 태그를 중첩시킬 수 있다. 즉, 자신만의 태그 집합과 문법을 만들 수 있다는 것이다. 예를 들어 다음의 간단한 XML 문서를 살펴보자.

<?xml version="1.0" encoding="euc-kr"?>
<furniture-list>
    <table type="B" class="보급형">
        <productName>XX 책상</productName>
        <drawer>4</drawer>
        <hasBookshelf>true</hasBookshelf>
        <hasLamp>true</hasLamp>
        <target>학생</target>
        <price>130000</price>
    </table>
    <chair class="고급형">
        <productName>듀오 의자</productName>
        <target>모두</target>
        <price>35000</price>
    </chair>
    <bed>
        <productName>에이스 침대</productName>
        <size>2</size>
        <target>모두</taget>
        <price>280000</price>
    </bed>
</furniture-list>

예제 XML 문서를 보면 HTML과는 많이 다르다는 것을 알 수 있다. 여기서 사용된 <table>, <size>, <target>과 같은 태그는 문서 작성자가 만든 것이며, 각 태그간의 중첩 관계 역시 작성자에 의해 구성된 것이다. 이것이 바로 XML의 힘이다. 여러분은 XML 규약이 요구하는 일반적인 구조에 따라 XML 문서를 만드는 한, 다양한 방법으로 데이터의 내용을 정의할 수 있으며, 이에 따라 데이터를 표현하는 데 있어 HTML로는 불가능한 유연성을 갖게 된다.

이러한 XML의 유연성은 XML의 가장 큰 장점중의 하나이면서 동시에 단점이 되기도 한다. 한 가지 목적을 위해 여러 다양한 방법을 사용할 수 있기 때문에, 데이터의 표현과 변환을 처리하기 위한 많은 다른 규약들이 존재하며, 이러한 규약을 통해서 XML은 유연성에 의해 발생하는 단점을 보완하고 있다. 실제로 XML 기술이라고 하면, 단순히 XML을 의미하기 보다는 XML 및 XML 관련 기술을 의미하는 경우가 더 많다.

XML을 HTML과 비교할 때, 또 하나의 큰 차이점은 XML은 표현(presentation)을 위한 데이터가 아닌 내용을 위한 데이터라는 점이다. HTML의 경우 <code>와 <strong>은 그 태그의 값이 각각 프로그래밍 코드와 강조된 것이라는 것을 나타내는 내용 기반의 태그인 반면에 <b>와 <i>는 태그는 내용을 어떻게 출력하라는 표현에 중점을 태그이다. 즉, 표현과 내용이 하나의 문서에 혼합되어 있는 것이다. 따라서, XML 문서를 작성할 때 표현을 어떻게 할 것인가에 대해서는 전혀 생각할 필요가 없으며, 단지 내용을 어떻게 XML 문서로 나타낼 것이가엔 대해서만 생각하면 된다.

XML에 대해서 이해하기 위해서는 XML 뿐만 아니라 XML 관련 기술에 대해서도 이해하고 있어야 한다. 이제부터 XML과 관련된 기술들에 대해 간단하게 알아보도록 하자.

XML

XML은 모든 XML 관련 기술의 핵심이다. XML은 핵심 언어 자체를 정의하고 메타데이터 타입의 구조를 정의한다. XML에 기반한 모든 다양한 기술을 통해서 개발자와 콘텐트 관리자들은 데이터 관리와 전송 측면에 있어서 전에 없던 유연성을 제공받게 되었다. 현재 1.0 규약의 권고안(Recommendation)이 나온 상태이다. XML 1.0 규약은 http://www.w3.org/TR/REC-xml에서 참조할 수 있다.

XML 문서는 처리 지시어(Processing Instruction; PI)와 DTD(Document Type Definition; 문서 타입 정의)를 가질 수 있다. PI는 XML 문서를 사용하는 어플리케이션이 특정한 작업을 하도록 지시하는 일종의 명령어이다. DTD는 XML 문서에서 사용할 태그가 따라야 할 문법(사용가능한 태그, 사용가능한 속성, 가능한 태그의 중첩)을 정의한다. 즉, XML 문서는 DTD에 의해 제약받게 된다. 만약 XML 문서가 DTD를 참조하고 있다면, 그 문서는 반드시 DTD에서 지정한 문법에 지정되어 있는 태그와 속성만을 사용해야 하며, DTD에 정의되어 있는 순서대로 각 태그의 순서를 지켜야 하며, DTD에 정의된 중첩 순서대로 각 태그의 중첩 순서를 정해주어야 한다. DTD를 통해서 XML 문서는 모호함을 없앨 수 있게 된다. 예를 들어, 앞의 예제 XML 문서에서 <table> 태그가 책상을 의미하는지 혹은 표를 의미하는지, 그리고 class 속성이 가질 수 있는 값이 어떤 것이 있는 지 어떻게 결정할 수 있는가? DTD를 통해서 이러한 결정들을 쉽게 할 수 있게 된다. 또한, DTD는 XML을 사용하여 데이터를 주고 받는 어플리케이션 사이에서 중요한 역할을 한다. 왜냐면, 두 어플리케이션은 서로를 이해하기 위해서 각 시스템 사이에 협의된 포맷팅(formatting)과 구문을 필요로 하며, DTD가 바로 이러한 것을 제공하기 때문이다.

참고로, DTD는 XML 형식이 아닌 그것만의 규약을 갖고 있다. 예를 들면 DTD는 다음과 같은 형태로 구성되어 있다.

<!ELEMENT furniture-list (table | chair | bed)+>
<!ELEMENT table (productName, drawer, hasBookshelf, hasLamp, target, price) >
<!ATTLIST tale
            type CDATA #REQUIRED
            class (보급형, 고급형) "보급형">
<!ELEMENT productName #PCDATA>
<!ELEMENT drawer #PCDATA>
.....

완전히 XML과 다른 형태로 XML 문서의 문법을 지정하는 것을 알 수 있다. 이에 따라 DTD는 몇 가지 한계점을 갖고 있으며, 이는 다음과 같다.

  • 계층(hierarchy) 개념이 없다. (즉, 개층 개념이 없다!)
  • 이름공간을 유연하게 처리하기 어렵다.
  • XML 문서 사이에 연관성을 줄 수 있는 방법을 갖고 있지 않다.
이러한 한계점이 발생하게 된 원인은 DTD 규약을 처음 작성할 때 지금처럼 많은 곳에서 XML이 사용될 것이라고 예상하지 못했기 때문이며, 따라서 지금처럼 한계를 갖게 되는 것은 어쩌면 당연한 것이다. 하지만, 이러한 한계점은 개발자들을 괴롭히는 요인이 되기도 한다. 따라서 이러한 한계점을 없애야 할 필요성이 생겼으며, 그것들을 해결하기 위해 나온 규약이 바로 XML 스키마(Schema)이다. XML 스키마에 대해서는 잠시 뒤에 알아보기로 하자.

이름공간(Namespace)

이름공간은 요소(element; 일반적으로 태그를 element라고 하며, 요소라고 번역한다)의 접두어(prefix)와 URI 사이의 매핑(mapping)이다. 이름공간은 일반적으로 태그가 속한 이름공간에 따른 이름 충돌 문제를 해결할 때 사용된다. 예를 들어, 앞의 XML 예제에서 <table>, <chair>, <bed> 태그가 상점에서의 판매 가격과 공장도 가격을 표시해야 한다고 해 보자. 이 경우 여러분은 어떤 태그를 사용할 것인가? 이미 <price> 태그가 사용되고 있기 때문에, <factory-price>와 같은 새로운 태그를 사용해야 할 것이다. 만약 할인가격이나 도매가격과 같은 또 다른 형태의 가격이 필요하다면? 필요한 만큼의 <xxx-price> 형식의 태그를 만들어야 할 것이다. 이름공간은 접두어를 사용하여 같은 이름을 갖는 태그를 사용할 수 있도록 해 줌으로써 이러한 문제점을 해결해준다. 예를 들어, 이름공간을 사용하면 상점 판매가, 공장도가격, 도매가격을 각각 <shop:price>, <factory:price>, <wholesale:price>의 태그로 표시할 수 있다. 즉, 같은 이름('price')을 가지는 세 개의 태그를 별도의 이름공간에 속하게 함으로써 충돌없이 같은 이름을 가진 태그(즉, 같은 의미를 갖는 태그)를 사용할 수 있는 것이다. 여기서 세미콜론 앞에 있는 이름이 접두어이며, 각 접두어는 특정한 URI와 연관되어 있다. 이름공간은 XML 문서에서 자주 사용고 있으며 XML 스타일쉬트, XML 스키마를 비롯한 많은 XML 관련 규약에서도 사용되고 있다. 이름 공간 관련 규약은 http://www.w3.org/TR/REC-xml-names/에서 참조할 수 있다.

XSL와 XSLT

XSL은 'Extensible Stylesheet Language'을 의미하며, 한 형식의 XML 데이터를 다른 형식으로 변환하고자 할 때 사용된다. 예를 들어, 하나의 XML 문서를 HTML, PDF, PS 형태로 변환해야 한다고 가정해보자. 이 경우 우리는 XML 문서를 일일이 복사하여 각 포맷에 알맞게 변환해야 할 것이다. XSL은 이렇게 일일이 복사할 필요없이, 이러한 종류의 작업을 수행해주는 스타일쉬트를 정의해주는 방식을 제공한다. 다시 말하면, XSL이 XML 데이터를 표현을 위한 포맷으로 변경해준다는 것이다. 앞에서 XML 문서는 내용을 위한 데이터라고 했던 점을 기억할 것이다. 그렇다면 XML 문서를 어떻게 웹 브라우저와 같은 클라이언트 프로그램에서 표현할 수 있을 것인가? 바로 XSL을 통해서 가능하게 되며, XSL은 내용과 표현을 완전히 분리해준다. 문서를 변환하기 위해 XSL 문서는 '포맷팅 객체(formatting object)'를 포함할 수 있다. 포맷팅 객체는 특정한 이름의 태그이며, 이 태그는 변환할 문서의 타입에 맞는 알맞은 내용으로 변경될 수 있다. 예를 들어 XML 문서를 PDF로 변환할 경우 포맷팅 객체에 해당하는 태그는 PDF에 알맞은 정보로 변경될 것이다.

XSLT는 'Extensible Stylesheet Language Transformation'를 의미하며, 포맷팅 객체가 아닌 완전한 텍스트 기반의 변환을 나타낸다. 일반적으로 XML 문서의 변환은 텍스트 위주로 이루어지기 때문에, 따라서 XSLT가 많이 사용된다.

XML 문서를 다른 형식으로 변환하는 것은 보통 XML 문서에 있는 특정 요소 A를 변환될 문서의 특정 요소 B로 바꾼다는 것을 의미한다. 이러한 변환을 처리하기 위해서는 어떤 요소를 변형할 지, 그리고 요소의 어떤 속성의 값을 처리할 지, 혹은 각 요소의 값에 따라 어떤 형식으로 변환할지를 결정할 수 있어야 한다. 이러한 요소의 선택 문제는 XPath를 통해서 이루어지며 XPath에 대해서는 잠시 뒤에 알아본다.

XSL 1.0 규약은 http://www.w3.org/TR/xsl/에서 참조할 수 있으며, XSLT 1.0 규약은 http://www.w3.org/TR/xslt에서 참조할 수 있다.

XML 스키마(Schema)

앞에서 DTD는 그 자체가 XML로 되어 있지 않으며, 뿐만 아니라 그에 따른 여러가지 한계점들이 발생한다고 하였다. DTD가 XML의 계층 구조를 공유하지 않는 다는 것은 이미 앞에서 DTD의 한계점에서 언급한 바 있다. 이 외에도 DTD는 XML과 같은 방법으로 데이터를 표시할 수 조차 없다. 반면에 DTD 이외에 XSL, XHTML, 이름공간 등의 다른 XML 관련 규약들은 그것의 목적을 표시하기 위해서 XML의 요소, 속성 등을 사용한다. 이러한 상황은 DTD를 다소 이상한 것으로 만들었으며, XML 문서를 어떻게 작성해야 한다는 것을 정의하기 위해 일반적으로 DTD를 사용하기 때문에 어떤 혼동을 일으키기도 했다.

XML 스키마는 XML 문서를 어떻게 작성해야 한다는 것을 정의하기 위해 XML 그 자체를 사용함으로써 DTD가 안고 있던 많은 한계점을 해결하였다. "데이터에 대한 데이터를 정의하는" 방법으로서 XML 그 자체를 사용함으로써 XML 스키마는 계층적 구조를 사용할 수 있으며, 확장성을 갖게 되었으며, 이름공간의 처리 역시 손쉽게 할 수 있게 되었다.

XML 스키마의 요구안을 http://www.w3.org/TR/NOTE-xml-schema-req에서 참조할 수 있다.

XPath

앞에서 XSLT에 대해서 언급할 때, XPath를 사용하여 변환할 대상을 선택하였다. XPath 규약은 XML 문서에 있는 특정한 항목을 어떻게 위치시킬지를 정의하고 있으며, XML 문서에 있는 어떤 '노드(node)'를 참조함으로써 이것을 하게된다. XPath는 XML 문서를 트리로 간주하며, 따라서 여기서 노드는 요소, 속성 또는 텍스트 데이터를 포함한 XML 데이터의 일부를 나타낸다. 실제로 노드를 위치시키기 위해서 XPath는 표현식을 사용한다. 이 표현식이 어떻게 구성되는 지에 대한 내용은 http://www.w3.org/TR/xpath에서 참고할 수 있다.

XQL

XQL은 'Extensible Query Language'를 의미하며, Query에서 알 수 있듯이 XQL은 XML 문서 형식을 사용하여 쉽게 데이터베이스 질의(query)를 표현할 수 있도록 하기 위해 설계된 질의 언어(query language)이다. XQL은 질의(query) 언어를 표현하기 위해 XPath 개념을 사용하고 있다. 왜 XPath 개념을 사용하는지 알아보기 위해 데이터베이스의 특정한 테이블로부터 데이터를 읽어오는 SQL 문장을 생각해보자.

select id, name, password from member_table where id = 'madvirus'

위의 SQL 문장을 보면 member_table이라는 테이블로부터 id 값이 madvirus인 행의 id, name, password 필드값을 읽어오는 것을 알 수 있다. 여기서 중요한 것은 id, name, password나 member_table과 같은 것들이 모두 XML 문서의 특정한 노드로간주될 수 있다는 점이다. (데이터베이스와 XML과의 매핑을 한번 생각해보면 그럴 것이라는 것을 알 수 있을 것이다). 또한, XQL은 질의의 결과 집합을 표준 XML을 사용하여 표시한다. 이때, XML 문서는 XQL에 특정한 태그 집합을 통해서 표현된다.

XSP

XSP라는 단어를 보면서 JSP나 ASP와 비슷한 기술이 아닐까라는 생각을 할 지도 모르겠다. 혹시 XSP가 JSP나 ASP와 비슷하게 서버사이드 스크립트 언어를 나타내는 것이 아닐까라고 생각했다면, 어느 정도 맞게 추측한 것이다. XSP는 'Extensible Server Pages'를 의미한다. XSP는 XML에 기반하고 있으며, 따라서 언어에 독립적이고 웹 페이지와 웹 사이트를 만드는 데 있어서 스크립트 언어 대신 사용될 수 있다. 표현과 내용의 구분에 있어 완전하지 못한 JSP에 비해, XSP는 완전하게 이 둘을 구분해준다. JSP는 JSP 페이지 내에 로직 부분을 담고 있는 반면에 XSP는 로직 부분을 로직쉬트(logicsheet)라는 것에 정의한다. 로직쉬트는 스타일쉬트와 비슷하며, 이를 통해 XSP는 표현의 내요을 완전히 분리해준다. 이러한 구분은 개발자들이 동적이나 동적으로 내용의 생성에만 집중할 수 있도록 해 주고, 반면에 XML과 XSL 제작자들은 XSP 페이지에 적용할 XSL 스타일 쉬트를 변경함으로써 표현과 스타일만을 처리할 수 있도록 해 준다.

XSP는 현재 웹 출판 프레임워크(Web Publishing Framework)인 아파치 코쿤(Cocoon)에 속해 있다. XSP에 대해 자세히 알고자 하는 사람은 http://xml.apache.org/cocoon/xsp.html을 참조하기 바란다.

지금까지 XML과 관련된 기술에 대해서 알아보았다. 이 외에도 XLink, XLL과 같은 많은 XML 관련 규약들이 존재하지만, 여기서는 자바와 관련해서 많이 사용되는 또는 사용될 기술들에 대해서만 알아보았다.

XML의 활용

XML을 어떻게 사용하는가

XML이 아무리 좋은 개념을 갖고 있다고 해도 개발자들이 익숙환 프로그래밍 환경에서 사용할 수 없다면 쓸모 없는 기술에 불과할 것이다. 다행스럽게도 프로그래밍에서 손쉽게 XML을 분석하고, 처리하고, 변환할 수 있도록 해주는 몇몇 API가 발표되었으며, 자바 개발자들은 이러한 API 중에서 알맞은 것을 선택해서 XML을 이용한 자바 프로그래밍을 손쉽게 할 수 있다. 이러한 API에는 SAX, DOM, JAXP, JDOM 등이 있다.

SAX

SAX는 'Simple API for XML'을 의미하며, 그 이름 그대로 XML을 위한 간단한 API를 제공한다. SAX는 XML 데이터를 분석하기 위한 이벤트 기반의 구조를 제공하며, 이러한 구조는 크게 문서를 읽어나가는 과정과 데이터를 사용할 수 있는 부분으로 분리된다. 이벤트는 XML 문서를 순차적으로 처리하는 동안 각 단계에서 발생하며, SAX는 각 이벤트가 발생할 때 호출되는 메소드를 정의하고 있다. 예를 들어, 한 요소의 여는 태그를 만날 경우 startElement() 메소드를 호출하며, 끝 태그를 만날 경우 endElement() 메소드를 호출한다.

SAX는 문서를 읽어나가는 과정에서 발생하는 이벤트를 위한 인터페이스 뿐만 아니라, 잘못된 문서나 비적격(non well-formed) 문서와 같이 XML을 분석하는 과정에서 발생할 수 있는 다양한 상황을 처리할 수 있도록 해 주는 에러와 경고 집합을 정의하고 있다.

DOM

DOM은 'Document Object Model'을 의미한다. SAX가 단지 XML 문서의 데이터에 접근하기 위한 방법을 제공한다면, DOM은 그러한 데이터를 처리하는 방법을 제공하기 위해 설계되었다. DOM은 XML 문서를 트리 형태로 표현한다. 자바를 비롯한 프로그래밍 언어에서는 트리 구조를 쉽게 순회하고 처리할 수 있기 때문에, DOM 트리(XML 문서를 DOM으로 표현한 것을 DOM 트리고 부른다)를 쉽게 처리할 수 있다. SAX와 달리 DOM은 전체 XML 문서를 메모리에 읽어온 후에 DOM 트리를 구성하기 때문에, 한번 문서를 읽으면 매우 빠르게 전체 문서에 접근할 수 있다.

DOM이 전체 XML 문서를 메모리에 읽어온 후에 DOM 트리를 작성한다는 것이 빠르게 XML 문서의 각 요소에 접근할 수 있다는 장점을 제공하긴 하지만, 반면에 결정적인 단점을 제공하기도 한다. DOM은 XML 문서의 크기에 비례한 메모리를 필요로 하기 때문에, XML 문서의 크기가 커질수록 많은 메모리를 요구하게 된다. XML 문서의 매우 클 경우 이는 매우 많은 양의 시스템 자원을 사용하게 되며, 따라서 시스템의 전체적인 성능 저하 현상을 일으키기도 한다.

JAXP

JAXP는 썬이 자바에서 XML 분석을 위해 내 놓은 API이다. JAXP는 SAX와 DOM API를 대신하거나 완성시킨 것은 아니지만 JAXP는 자바 개발자들이 XML API를 좀더 쉽게 사용할 수 있도록 하기 위해 만든 편리한 메소드를 제공하고 있다. JAXP는 이름공간을 지원할 뿐만 아니라 SAX와 DOM 권고안을 따르고 있다. 또한, JAXP는 교체가능(pluggability) 계층을 통해서 XML을 따르는 모든 파서를 사용할 수 있도록 해 준다.

현재 EJB 1.1 규약과 Tomcat은 XML 형식의 설정 및 배치(deployment) 파일을 사용하고 있으며, 앞으로 나올 J2EE 1.3이나 J2SE 1.4에 JAXP가 추가될 것으로 예상된다.

JDOM

현재 나와 있는 XML API 중에서 자바 개발자들에게 가장 흥미를 끌고 있는 API가 있다면, 바로 JDOM이다. JDOM은 일반적으로 SAX와 DOM을 대체할 수 있는 자바 중심적이고 고성능의 API를 제공하고 있으며, DOM이나 SAX에 기반하지 않은 대신 개발자가 DOM의 특징 없이 트리 형태로 XML 문서를 처리할 수 있도록 해 준다. 또한 SAX와 같은 고성능을 제공하기 때문에 분석과 출력을 매우 빠르게 할 수 있도록 해 준다. 또한, DOM과 달리 속성이나 요소 집합을 나타내기 위해서 자바 2의 콜렉션 클래스를 사용한다. (참고로, DOM은 속성이나 요소 집합을 나타내기 위해서 Attributes 또는 Nodelist와 같은 별도의 클래스를 사용한다).

JDOM은 자바에 맞춰서 개발된 API이기 때문에, SAX나 DOM과 달리 자바에 최적화되어 있다. 그 하나의 예로 자바 2의 콜렉션 API를 사용하는 것을 들 수 있다. 또한, JDOM은 이미 증명된 자바 디자인 패턴에 따라 설계되었으며, 직접적으로 클래스의 인스턴스를 생성함으로써 JDOM의 구성 요소(요소, 주석, 속성, 기타 등등)를 생성할 수 있도록 하고 있다.

XML을 어디에 사용하는가

아직까지 XML을 미션 크리티컬한 어플리케이션에서 사용하지는 않고 있다. 하지만, 자바와 비교해보면 XML의 발전 속도는 매우 빠른 편이며, 점차적으로 XML을 사용하는 분야가 증가하고 있다. 실예로, 앞으로 나올 ASP+나 JSP 차기 버전의 경우 페이지 자체를 XML로 작성할 수 있도록 하고 있다. 또한, XML에 있어서 중요한 점은 자바와 찰떡궁합을 이룬다는 점이다. 이에 대해서는 이 Article의 마지막 부분에서 살펴볼 것이다.

XML을 현재 어느 분야에서 사용하고 있는 지에 대해서 살펴보도록 하자.

표현에서의 XML

XML의 가장 큰 장점은 내용과 표현을 분리한다는 점이다. 이는 오늘날과 같이 클라이언트의 종류가 다양한 환경에서 큰 힘을 발휘하게 된다. 예를 들어, 클라이언트의 종류가 웹 브라우저, 휴대전화와 같은 무선 기기, 자바 애플리케이션이라고 해보자. 기존의 방법을 사용하려면 각각의 클라이언트에 대해서 각각 HTML, WML 그리고 자바 애플리케이션에 알맞은 어떤 형태로 제공해야 한다. 내용이 변경되거나 표현부분이 변경되는 경우 모두 각각의 문서를 변경해주어야 한다. 내용을 변경하는 경우에도 이 각각의 문서를 변경해야 한다는 것은 매우 귀찮은 일일 수 있으며, 지원해야 하는 클라이언트의 종류가 세 가지 이상으로 늘어날 경우 이는 관리에 있어서 어려움을 제공하는 원인이 되기도 한다. 또한, 하나의 문서를 변경하기 위해서는 개발자와 페이지 디자이너가 모두 필요하다는 것도 문제가 된다.

XML을 사용하면 이러한 문제의 상당히 많은 부분을 해결할 수 있게 된다. 앞에서 XSL/XSLT에 대해서 설명할 때, XSL/XSLT는 한 형식의 문서를 다른 형식으로 변환해 준다고 하였다. 개발자는 단순히 내용을 저장하고 있는 XML 문서만을 생성하면 되며, 페이지 디자이너는 XML 문서를 HTML, WML 그리고 자바 애플리케이션에 알맞은 형태로 변환해주는 XSL/XSLT를 작성하기만 하면 된다. 만약 사용자가 웹 브라우저를 통해서 접속했다면 XML+HTML로 변환해주는 XSLT를 통해서 HTML 문서를 제공해주며, 휴대전화로 접속했다면 XML+WML로 변환해주는 XSLT를 통해서 WML 문서를 제공해줄 것이다. 즉, 하나의 XML 문서를 통해서 여러개의 표현을 만들어낼 수 있는 것이다. 현재 이러한 기능을 제공해주는 출판 프레임워크가 개발되고 있으며, 대표적인 아파치 코쿤을 예로 들 수 있다.

통신에서의 XML

어플리케이션 사이에서 정보를 주고 받기 위해서 XML을 사용할 수 있다. 각각의 어플리케이션은 자신만의 문서 형식을 작성할 필요가 없으며, 단지 두 어플리케이션이 알고 있는 DTD나 스키마에 맞춰서 XML 문서를 작성하기만 하면 된다. 뿐만 아니라 XML로 정보를 표현하기 때문에 특별한 어플리케이션에 종속되지 않으며 따라서 DTD를 따르는 모든 애플리케이션에서 같은 정보를 사용할 수 있게 된다. 또한, XML로 표현된 정보를 XSL/XSLT를 사용하여 손쉽게 어플리케이션에 특정한 형식으로 변환할 수도 있다.

이러한 XML의 응용 범위는 오늘날 인터넷 비니지스에 있어서 핵심으로 떠오르고 있는 B2B로 확장될 것으로 예상된다. 즉, 어플리케이션 사이에서 뿐만 아니라 기업간에 XML을 통해서 정보를 주고 받을 것이다. 이미 많은 곳에서 XML을 이용하여 기업간에 정보를 주고 받을 수 있는 어플리케이션을 개발하고 있으며 몇몇 제품은 이미 판매되고 있다. 오늘날 기업간에 정보를 주고 받을 때 주로 사용되는 EDI에 비해 XML은 더욱 더 다양한 형태로 정보를 주고 받을 수 있도록 해 준다.

설정에서의 XML

앞에서도 말했듯이 XML은 설정에 있어서 유용하게 활용할 수 있다. 이미 EJB 1.1 규약과 앞으로 정식으로 발펴될 EJB 2.0 규약에서 XML을 사용하여 설정 및 배치 기술자를 정의하고 있으며 서블릿 2.2 역시 XML을 사용하여 설정과 배치 부분을 기술하고 있다. 앞으로 이러한 설정 및 배치와 관련된 곳에서 XML의 사용범위는 점차적으로 증가할 것으로 예상된다.

자바 & XML

마지막으로 자바와 XML과의 관계에 대해서 간략하게 알아보자. 이 두 기술의 관계를 다음의 문구로 간단하게 표현할 수 있다.

Java + XML = Portable Code + Protable Data

자바의 이식성은 별다른 설명이 필요 없을 정도로 자명하다. 자바는 중간 코드인 바이트코드와 JVM을 통해서 거의 완벽한 이식성을 제공하고 있으며, 쓰레드와 Native 메소드와 같은 몇가지 문제점을 제외하고는 거의 모든 플랫폼에서 특별한 문제없이 같은 자바 코드를 사용할 수 있게 되었다.

XML의 이식성은 자바의 이식성보다 더욱 더 완벽에 가깝다. 자바를 실행하기 위해서 단지 플랫폼에 알맞은 JVM이 있으면 되듯이, XML을 사용하기 위해서는 표준 XML을 지원하는 파서, 처리기(Processor) 등이 있으면 된다. XML 데이터 자체는 플랫폼에 어떠한 플랫폼에도 영향을 받지 않는다.

자바는 XML을 사용할 수 있는 풍부한 API를 제공하고 있으며, 따라서 자바와 XML의 조화는 어플리케이션과 데이터에 있어서 완벽한 이식성을 제공해주며, 이는 앞으로 개발될 어플리케이션(특히 엔터프라이즈 어플리케이션)에서 큰 힘이 될 것이다.

XML 파서(Parser)와 처리기(Processor)

XML을 실제 어플리케이션 환경에서 사용하기 위해서는 XML을 분석할 수 있는 파서(parser)와 XSL/XSLT를 사용하여 XML을 변환할 수 있는 XML 처리기가 필요하다. 여기서는 XML을 분석하고 처리할 수 있도록 해 주는 파서와 처리기의 종류를 나열할 것이다.

파서

현재 사용할 수 있는 파서에는 다음과 같은 것들이 있다.

처리기(Processor)

현재 사용할 수 있는 처리기에는 다음과 같은 것들이 있다.

관련링크:
반응형
자바2에서 제공하는 기본적인 클래스로더에 대해서 알아본다.

클래스의 이름 공간

이전 Article에서 설명하지 않은 것이 있어서 JDK에서 기본적으로 제공하는 클래스로더에 대해 알아보기 전에 알아볼 것이 있다. 바로 클래스의 이름 공간에 대한 것이다. 우선 클래스의 이름에 대해서 알아보자. 클래스의 완전한 이름은 패키지와 클래스 이름의 두 부분으로 구성된다. 예를 들어, Vector 클래스의 경우 패키지 이름 java.util과 클래스 이름 Vector가 합쳐져서 완전한 클래스 이름인 java.util.Vector로 구성된다. 이렇게 완전한 이름은 같은 이름을 가진 클래스를 구분할 때 사용한다. 예를 들면, Date 클래스의 경우 java.util과 java.sql의 두 패키지에 속해 있으며, 이 두 클래스를 동시에 사용하고자 할 경우에는 반드시 완전한 클래스 이름(즉, java.util.Date와 java.sql.Date)을 사용하여 구분해야 한다.

클래스의 이름과 관련해서 알아야 할 점이 또 하나 있다. 바로 클래스를 로딩한 클래스로더와 관련한 것이다. 하나의 JVM에서 여러개의 클래스로더를 사용하여 클래스를 로딩할 수 있다. 여기서 생각할 수 있는 점이 같은 클래스를 서로 다른 클래스로더가 로딩할 수 있다는 점이다. 이러한 예로 애플릿을 예로 들 수 있다. 현재 많이 사용하고 있는 웹 브라우저의 경우 각각의 애플릿마다 하나의 클래스로더를 사용하고 있다. 바꿔 말하면, A라는 애플릿과 관련된 클래스 집합을 A'이라고 하고, B라는 애플릿과 관련된 클래스 집합을 B'이라고 할 경우, A'과 B'을 로딩하는 클래스로더가 다르다는 것이다. 만약 A'과 B'에 모두 x.y.Z라는 클래스가 있는 데, A'에 있는 것과 B'에 있는 것이 서로 정의가 다르다고 해 보자. 이 경우, A' 애플릿을 로딩한 이후에 B' 애플릿을 로딩했다면, x.y.Z 클래스는 어떻게 처리될까? 클래스 이름 충돌 문제가 발생하지 않을까? 정답은 발생하지 않는다이다. 이유는 별도의 클래스로더가 x.y.Z를 로딩하기 때문이다. 실제로 자바에서의 클래스 이름은 클래스 이름 공간(name space)라는 개념으로 처리되면, 이 클래스 이름 공간은 다음의 형태로 구성된다.

(패키지, 이름, 클래스로더)

따라서, 제 아무리 같은 클래스라도 클래스로더가 다르면, JVM 내에서 다른 클래스로 처리된다. 필자 역시 예전에 이 부분을 미처 알지 못해서 애플릿 프로그래밍을 할 때에 실수를 한 적이 있다. 애플릿을 사용하여 인트라넷 환경의 엔터프라이즈 시스템을 개발하는 경우, 이 점에 특히 주의해야 할 것이다. 물론, 웹 브라우저의 버전이나 종류에 따라 클래스 로딩이 다르게 동작할 수 있다. 따라서, 하나의 어플리케이션에서 여러 개의 애플릿을 사용하는 경우에는 반드시 대상이 되는 웹 브라우저에서 철저하게 테스트해야 한다.

클래스로더

자바 2의 표준 런타임 라이브러리(jre/lib/rt.jar)는 기본적으로 몇 개의 클래스로더를 제공하고 있다. 이 클래스로더 중에서는 공개적으로 사용할 수 있는 것들이 있고, 공개되지 않고 런타임 라이브러리의 내부적으로만 사용되는 것들도 있다. 이번 Article에서는 이렇게 자바에서 기본적으로 제공하는 클래스로더에 대해서 알아보도록 하자.

java.net.URLClassLoader

URLClassLoader는 지정한 URL로부터 클래스를 로딩할 수 있도록 해 준다. 이 말은 올바른 URL을 사용하는 한, 파일 시스템, HTTP, FTP를 비롯한 모든 형태의 URL로부터 클래스를 로딩할 수 있다는 것을 의미한다. 여기서는 파일 시스템, HTTP, FTP를 통해서 클래스를 읽어오는 것에 대해 알아보자.

먼저, 파일 시스템으로부터 클래스를 로딩하는 것에 대해서 알아보자. URLClassLoader가 일반적으로 사용되는 경우는 파일 시스템으로부터 클래스를 읽어올 때이다. 예를 들어, /usr/classes 디렉토리에서 HelloWorld 클래스를 로딩해서 그 클래스의 인스턴서를 생성하고자 할 경우 다음과 같이 하면 된다.

  import java.net.URL;
  import java.net.URLClassLoader;
  
  public class FileSystemTest {
  
   public static void main(String[] args) throws Exception {
   URL[] urls = { new java.io.File("/usr/classes").toURL(); }
  
   URLClassLoader ucl = new URLClassLoader(urls);
  
   Class klass = ucl.loadClass("HelloWorld");
   Object obj = klass.newInstance();
   // obj를 사용하여 적절한 것을 한다.
   }
  }

위 코드를 보면, URLClassLoader를 생성할 때, URL의 배열을 생성자의 파라미터로 넘겨주는 것을 알 수 있다. URL 배열을 사용하는 것은 여러 개의 URL로부터 클래스를 로딩할 수 있다는 것을 의미한다. 일단 URLClassLoader를 생성하면, loadClass() 메소드를 사용하여 원하는 클래스를 로딩할 수 있고, 이어서 loadClass() 메소드의 리턴 결과인 Class 객체의 newInstance() 메소드를 사용하여 새로운 인스턴스를 생성해서 사용하면 된다. 만약 HelloWorld 클래스가 javacan.exam 패키지에 속한다고 하면, loadClass() 메소드는 다음과 같이 변경될 것이다.

  Class kalss = ucl.loadClass("javacan.exam.HelloWorld");

이 경우, URLClassLoader는 /usr/classes/javacan/exam 디렉토리에서 HelloWorld 클래스를 로딩할 것이다. URLClassLoader 클래스는 파일 시스템의 디렉토리 뿐만 아니라, URL로 Jar 파일이나 Zip 파일을 지정할 경우 자동적으로 Jar 파일과 Zip 파일로부터 클래스를 로딩한다. 이 경우, URL은 다음과 같이 변경될 것이다.

  URL[] urls = { new java.io.File("/usr/lib/madvirus.jar").toURL() };

HTTP 서버와 FTP 서버로부터 클래스를 로딩하는 것 역시 파일 시스템에서 클래스를 로딩하는 것 만큼이나 쉽다. 단지, URL을 다음과 같이 생성만 해 주면 된다.

  new URL("http", "www.hostname.com", "/lib/madvirus.jar")

이 URL을 URLClassLoader를 생성할 때 넘겨주면, URLClassLoader는 http://www.hostname.com/lib/madvirus.jar로부터 클래스를 로딩할 것이다. FTP 서버로부터 클래스를 로딩할 때는 다음과 같이 URL을 생성하면 된다.

  new URL("ftp", "user:password@www.hostname.com:", "/")

여기서 user와 password는 각각 FTP 서버에 연결할 때 사용하는 사용자 계정과 암호이다.

부트스트랩 클래스로더

부트스트랩 클래스로더는 전문적으로 말해서 클래스로더가 아니다. 왜냐면 부트스트랩 클래스로더는 JVM의 네이티브 코드 영역에 존재하며, Object와 같은 코어 자바 클래스를 VM에 로딩할 때 사용되기 때문이다. 부트스트랩 클래스로더는 sun.boot.class.path 프로퍼티에 지정되어 있는 값을 이용하여 자바 런타임 라이브러리를 찾는다. 이 값을 명시적으로 지정하지 않을 경우, [자바 2 디렉토리]/jre/lib/rt.jar 파일로부터 자바 런타임 클래스들을 로딩한다.

JDK 1.0이나 JDK 1.1.x 때부터 착실하게(?) 자바를 공부해왔던 개발자라면 누구나 [JDK디렉토리]/lib/classes.zip 파일을 CLASSPATH 환경변수에 추가해주었을 것이다. 하지만, 자바2에서 JDK 1.0이나 JDK 1.1.x 때와는 달리 CLASSPATH 환경변수나 명령행의 옵션인 -classpath에 자바 런타임 클래스들을 추가해줄 필요가 없다. 왜냐면, 부트스트랩 클래스로더가 자동적으로 읽어오기 때문이다.

sun.misc.Launcher$ExtClassLoader

ExtClassLoader는 익스텐션 클래스로더(extension classloader)라고도 불리며, 자바의 확장 클래스들을 로딩할 때 사용된다. ExtClassLoader는 URLClassLoader 클래스를 상속하며, java.ext.dirs 프로퍼티에서 지정한 디렉토리에 위치한 .jar 파일로부터 클래스를 읽어온다. 이 프로퍼티의 값을 명시적으로 지정하지 않으면, 기본적으로 [자바 2 디렉토리]/jre/lib/ext 디렉토리에 위치한 .jar 파일로부터 클래스를 읽어온다.

sun.misc.Launcher$AppClassLoader

AppClassLoader는 시스템 또는 어플리케이션 클래스로더라고 부르며, java.class.path 프로퍼티에 명시된 경로에서 코드를 로딩하는 클래스로더이다. ExtClassLoader과 마찬가지로 URLClassLoader를 상속하고 있다. CLASSPATH에 있는 각각의 디렉토리나 .jar 파일은 URL로 변환되어 AppClassLoader에 전달되며, AppClassLoader의 생성자에서는 이 URL들을 상위 클래스인 URLClassLoader 생성자에 전달한다.

ClassLoader.getSystemClassLoader() 메소드를 호출할 때, 이 클래스로더가 리턴된다. 개발자가 작성한 대부분의 클래스들은 이 클래스로더를 통해서 로딩된다. 또한, AppClassLoader는 ExtClassLoader를 부로 클래스로더 지정하고 있기 때문에, 어플리케이션에서 기본적으로(즉, AppClassLoader를 통해서) 익스텐션 디렉토리에 있는 Jar 파일로부터 클래스들을 읽어올 수 있다.

sun.applet.AppletClassLoader

이름에서도 알 수 있듯이, AppletClassLoader는 웹 브라우저가 웹 페이지에서 사용되는 애플릿의 바이트 코드를 다운로드 한 후, 그 애플릿을 실행하는 것을 목적으로 하는 클래스로더이다. AppletClassLoader는 URL을 사용하여 HTTP, FTP 또는 파일 시스템으로부터 클래스를 로딩하기 때문에, URLClassLoader를 상속하고 있다. 하지만, 많이 사용하고 있는 웹 브라우저인 IE나 Netscape의 경우, AppletClassLoader가 아닌 그 웹 브라우저만의 애플릿 클래스로더를 구현하고 있기 때문에, 브라우저마다 서로 다른 동작을 보일수도 있다.

java.security.SecureClassLoader

SecureClassLoader 클래스의 주요 목적은 JVM에 바이드코드를 로딩하고 사용하는 것에 대한 보안을 제어하는 것이다. 하지만, 이 클래스는 실제로 클래스 코드를 로딩할 수 있는 안전한 방법을 제공하지 않으며, 다른 클래스로더가 확장할 수 있는 베이스 클래스로서의 역할을 한다. 따라서 이 클래스는 자바 런타임 라이브러이에 있는 많은 클래스로더의 상위 클래스이며, 대표적인 것으로 URLClassLoader를 들 수 있다. 참고적으로, 이 클래스를 추상 클래스이기 때문에, 직접적으로 이 클래스의 인스턴스를 생성해서 사용할 수 없다.

java.rmi.server.RMIClassLoader

RMIClassLoader는 ClassLoader가 아니며, RMI 런타임 시스템에서 클래스의 로딩과 마샬링(marshaling)을 처리해주는 래퍼 클래스(warpper class)이다. 실제로, RMIClassLoader는 sun.rmi.serer.LoaderHandler 클래스와의 간단한 브릿지(bridge)이다. 실제 클래스의 로딩은 LoaderHandler 클래스의 이너(inner) 클래스로 존재하는 로더 클래스들을 통해서 이루어진다. 이 이너 로더 클래스는 URLClassLoader를 상속하고 있다. 실제로 엔터프라이즈 환경에서는 RMI와 관련된 부분에서만 이 클래스로더가 자동적으로 사용될 뿐, 이 클래스로더를 직접적으로 사용하는 경우는 거의 없다. 왜냐면, URLClassLoader 자체가 HTTP, FTP와 같은 URL을 통해서 클래스를 로딩할 수 있도록 해 주기 때문이다.

결론

이번장에서는 자바에서 기본적으로 클래스로더에 대해서 간단하게 알아보았다. 여러분은 이번 Article을 통해서 자바의 기본적인 클래스로더와 실제로 클래스들이 어떻게 JVM 내에서 서로 구분되는 지 알게 되었을 것이다. 다음 Article에서는 커스텀 클래스로더를 작성하는 것에 대해 알아볼 것이다.

관련링크:
반응형
동적인 클래스 로딩

자바는 동적으로 클래스를 읽어온다. 즉, 런타임에 모든 코드가 JVM에 링크된다. 모든 클래스는 그 클래스가 참조되는 순간에 동적으로 JVM에 링크되며, 메모리에 로딩된다. 자바의 런타임 라이브러리([JDK 설치 디렉토리]/jre/lib/rt.jar) 역시 예외가 아니다. 이러한 동적인 클래스 로딩은 자바의 클래스로더 시스템을 통해서 이루어지며, 자바가 기본적으로 제공하는 클래스로더는 java.lang.ClassLoader를 통해서 표현된다. JVM이 시작되면, 부트스트랩(bootstrap) 클래스로더를 생성하고, 그 다음에 가장 첫번째 클래스인 Object를 시스템에 읽어온다.

런타임에 동적으로 클래스를 로딩하다는 것은 JVM이 클래스에 대한 정보를 갖고 있지 않다는 것을 의미한다. 즉, JVM은 클래스의 메소드, 필드, 상속관계 등에 대한 정보를 알지 못한다. 따라서, 클래스로더는 클래스를 로딩할 때 필요한 정보를 구하고, 그 클래스가 올바른지를 검사할 수 있어야 한다. 만약 이것을 할 수 없다면, JVM은 .class 파일의 버전이 일치하지 않을 수 있으며, 또한 타입 검사를 하는 것이 불가능할 것이다. JVM은 내부적으로 클래스를 분석할 수 있는 기능을 갖고 있으며, JDK 1.1부터는 개발자들이 리플렉션(Reflection)을 통해서 이러한 클래스의 분석을 할 수 있도록 하고 있다.

로드타임 동적 로딩(load-time dynamic loading)과 런타임 동적 로딩(run-time dynamic loading)

클래스를 로딩하는 방식에는 로드타임 동적 로딩(load-time dynamic loading)과 런타임 동적 로딩(run-time dynamic loading)이 있다. 먼저 로드타임 동적 로딩에 대해서 알아보기 위해 다음과 코드를 살펴보자.

  public class HelloWorld {
     public static void main(String[] args) {
        System.out.println("안녕하세요!");
     }
  }

HelloWorld 클래스를 실행하였다고 가정해보자. 아마도, 명령행에서 다음과 같이 입력할 것이다.

  $ java HelloWorld

이 경우, JVM이 시작되고, 앞에서 말했듯이 부트스트랩 클래스로더가 생성된 후에, 모든 클래스가 상속받고 있는 Object 클래스를 읽어온다. 그 이후에, 클래스로더는 명령행에서 지정한 HelloWorld 클래스를 로딩하기 위해, HelloWorld.class 파일을 읽는다. HelloWorld 클래스를 로딩하는 과정에서 필요한 클래스가 존재한다. 바로 java.lang.String과 java.lang.System이다. 이 두 클래스는 HelloWorld 클래스를 읽어오는 과정에서, 즉 로드타임에 로딩된다. 이 처럼, 하나의 클래스를 로딩하는 과정에서 동적으로 클래스를 로딩하는 것을 로드타임 동적 로딩이라고 한다.

이제, 런타임 동적 로딩에 대해서 알아보자. 우선, 다음의 코드를 보자.

  public class HelloWorld1 implements Runnable {
     public void run() {
        System.out.println("안녕하세요, 1");
     }
  }
  public class HelloWorld2 implements Runnable {
     public void run() {
        System.out.println("안녕하세요, 2");
     }
  }

이 두 클래스를 Runnable 인터페이스를 구현한 간단한 클래스이다. 이제 실제로 런타임 동적 로딩이 일어나는 클래스를 만들어보자.

  public class RuntimeLoading {
     public static void main(String[] args) {
        try {
           if (args.length < 1) {
              System.out.println("사용법: java RuntimeLoading [클래스 이름]");
              System.exit(1);
           }
           Class klass = Class.forName(args[0]);
           Object obj = klass.newInstance();
           Runnable r = (Runnable) obj;
           r.run();
        } catch(Exception ex) {
           ex.printStackTrace();
        }
     }
  }

위 코드에서, Class.forName(className)은 파리미터로 받은 className에 해당하는 클래스를 로딩한 후에, 그 클래스에 해당하는 Class 인스턴스(로딩한 클래스의 인스턴스가 아니다!)를 리턴한다. Class 클래스의 newInstance() 메소드는 Class가 나타내는 클래스의 인스턴스를 생성한다. 예를 들어, 다음과 같이 한다면 java.lang.String 클래스의 객체가 생성된다.

  Class klass = Class.forName("java.lang.String");
  Object obj = klass.newInstance();

따라서, Class.forName() 메소드가 실행되기 전까지는 RuntimeLoading 클래스에서 어떤 클래스를 참조하는 지 알수 없다. 다시 말해서, RuntimeLoading 클래스를 로딩할 때는 어떤 클래스도 읽어오지 않고, RuntimeLoading 클래스의 main() 메소드가 실행되고 Class.forName(args[0])를 호출하는 순간에 비로서 args[0]에 해당하는 클래스를 읽어온다. 이처럼 클래스를 로딩할 때가 아닌 코드를 실행하는 순간에 클래스를 로딩하는 것을 런타임 동적 로딩이라고 한다.

다음은 RuntimeLoading 클래스를 명령행에서 실행한 결과를 보여주고 있다.

  $ java RuntimeLoading HelloWorld1
  안녕하세요, 1

Class.newInstance() 메소드와 관련해서 한 가지 알아둘 점은 해당하는 클래스의 기본생성자(즉, 파라미터가 없는)를 호출한다는 점이다. 자바는 실제로 기본생성자가 코드에 포함되어 있지 않더라도 코드를 컴파일할 때 자동적으로 기본생성자를 생성해준다. 이러한 기본생성자는 단순히 다음과 같이 구성되어 있을 것이다.

  public ClassName() {
     super();
  }

ClassLoader

자바는 클래스로더를 사용하고, 클래스를 어떻게 언제 JVM으로 로딩하고, 언로딩하는지에 대한 특정한 규칙을 갖고 있다. 이러한 규칙을 이해해야, 클래스로더를 좀 더 유용하게 사용할 수 있으며 개발자가 직접 자신만의 커스텀 클래스로더를 작성할 수 있게 된다.

클래스로더의 사용

이 글을 읽는 사람들은 거의 대부분은 클래스로더를 프로그래밍에서 직접적으로 사용해본 경험이 없을 것이다. 클래스로더를 사용하는 것은 어렵지 않으며, 보통의 자바 클래스를 사용하는 것과 완전히 동일하다. 다시 말해서, 클래스로더에 해당하는 클래스의 객체를 생성하고, 그 객체의 특정 메소드를 호출하기만 하면 된다. 간단하지 않은가? 다음의 코드를 보자.

  ClassLoader cl = . . . // ClassLoader의 객체를 생성한다.
  Class klass = null;
  try {
     klass = cl.loadClass("java.util.Date");
  } catch(ClassNotFoundException ex) {
     // 클래스를 발견할 수 없을 경우에 발생한다.
     ex.printStackTrace();
  }

일단 클래스로더를 통해서 필요한 클래스를 로딩하면, 앞의 예제와 마찬가지로 Class 클래스의 newInstance() 메소드를 사용하여 해당하는 클래스의 인스턴스를 생성할 수 있게 된다. 형태는 다음과 같다.

  try {
     Object obj = klass.newInstance();
  } catch(InstantiationException ex) {
     ....
  } catch(IllegalAccessException ex) {
     ....
  } catch(SecurityException ex) {
     ....
  } catch(ExceptionIninitializerError error) {
     ...
  }

위 코드를 보면, Class.newInstance()를 호출할 때 몇개의 예외와 에러가 발생하는 것을 알 수 있다. 이것들에 대한 내용은 Java API를 참고하기 바란다.

자바 2의 클래스로더

자바 2 플랫폼에서 클래스로더의 인터페이스와 세만틱(semantic)은 개발자들이 자바 클래스로딩 메커니즘을 빠르고 쉽게 확장할 수 있도록 하기 위해 몇몇 부분을 재정의되었다. 그 결과로, 1.1이나 1.0에 맞게 작성된 (커스텀 클래스로더를 포함한) 클래스로더는 자바 2 플랫폼에서는 제기능을 하지 못할 수도 있으며, 클래스로더 사용하기 위해 작성했던 코드를 재작성하는 것이 그렇게 간단하지만은 않다.

자바 1.x와 자바 2에서 클래스로더에 있어서 가장 큰 차이점은 자바 2의 클래스로더는 부모 클래스로더(상위 클래스가 아니다!)를 갖고 있다는 점이다. 자바 1.x의 클래스로더와는 달리, 자바 2의 클래스로더는 부모 클래스로더가 먼저 클래스를 로딩하도록 한다. 이를 클래스로더 딜리게이션 모델(ClassLoader Delegation Model)이라고 하며, 이것이 바로 이전 버전의 클래스로더와 가장 큰 차이점이다.

자바 2의 클래스로더 딜리게이션 모델에 대해 구체적으로 알아보기 위해 로컬파일시스템과 네트워크로부터 클래스를 읽어와야 할 필요가 있다고 가정해보자. 이 경우, 쉽게 로컬파일시스템의 jar 파일로부터 클래스를 읽어오는 클래스로더와 네트워크로부터 클래스를 읽어오는 클래스로더가 필요하다는 것을 생각할 수 있다. 이 두 클래스로더를 각각 JarFileClassLoader와 NetworkClassLoader라고 하자.

JDK 1.1에서, 커스텀 클래스로더를 만들기 위해서는 ClassLoader 클래스를 상속받은 후에 loadClass() 메소드를 오버라이딩하고, loadClass() 메소드에서 바이트코드를 읽어온 후, defineClass() 메소드를 호출하면 된다. 여기서 defineClass() 메소드는 읽어온 바이트코드로부터 실제 Class 인스턴스를 생성해서 리턴한다. 예를 들어, JarFileClassLoader는 다음과 같은 형태를 지닐 것이다.

  public class JarFileClassLoader extends ClassLoader {
     ...
     private byte[] loadClassFromJarFile(String className) {
        // 지정한 jar 파일로부터 className에 해당하는 클래스의
        // 바이트코드를 byte[] 배열로 읽어온다.
        ....
        return byteArr;
     }
     
     public synchronized class loadClass(String className, boolean resolveIt)
        throws ClassNotFoundException {
        
        Class klass = null;
        
        // 클래스를 로드할 때, 캐시를 사용할 수 있다.
        klass = (Class) cache.get(className);
        
        if (klass != null) return klass;
        
        // 캐시에 없을 경우, 시스템 클래스로더로부터
        // 지정한 클래스가 있는 지 알아본다.
        try {
           klass = super.findSystemClass(className);
           return klass;
        } catch(ClassNotFoundException ex) {
           // do nothing
        }
        
        // Jar 파일로부터 className이 나타내는 클래스를 읽어온다.
        byte[] byteArray = loadClassFromJarFile(className);
        klass = defineClass(byteArray, 0, byteArray.length);
        if (resolve)
           resolveClass(klass);
        cache.put(className, klass); // 캐시에 추가
        return klass;
     }
  }

위의 개략적인 코드를 보면, 시스템 클래스로더에게 이름이 className인 클래스가 존재하는 지 요청한다. (여기서 시스템 클래스로더 또는 primordial 시스템 클래스로더는 부트스트랩 클래스로더이다). 그런 후에, 시스템 클래스로더로부터 클래스를 읽어올 수 없는 경우 Jar 파일로부터 읽어온다. 이 때, className은 완전한 클래스 이름(qualified class name; 즉, 패키지이름을 포함한)이다. NetworkClassLoader 클래스 역시 이 클래스와 비슷한 형태로 이루어져 있을 것이다. 이 때, 시스템 클래스로더와 그 외의 다른 클래스로더와의 관계는 다음 그림과 같다.


위 그림을 보면, 각각의 클래스로더는 오직 시스템 클래스로더와 관계를 맺고 있다. 다시 말해서, JarFileClassLoader는 NetworkClassLoader나 AppletClassLoader와는 관계를 맺고 있지 않다. 이제, A라는 클래스가 내부적으로 B라는 클래스를 사용한다고 가정해보자. 이 때, 만약 A 클래스는 네트워크를 통해서 읽어오고, B라는 클래스는 Jar 파일을 통해서 읽어와야 한다면? 이 경우에 어떻게 해야 하는가? 쉽사리 해결책이 떠오르지 않을 것이다. 이러한 문제는 JarFileClassLoader와 NetworkClassLoader 간에 유기적인 결합을 할 수 없기 때문에 발생한다.

자바 2에서는 이러한 문제를 클래스로더 딜리게이션 모델을 통해서 해결하고 있다. 즉, 특정 클래스로더 클래스를 읽어온 클래스로더(이를 부모 클래스로더라고 한다)에게 클래스 로딩을 요청하는 것이다. 다음의 그림을 보자.


이 그림은 자바 2에서 클래스로더간의 관계를 보여주고 있다. 이 경우, NetworkClassLoader 클래스는 JarFileClassLoader가 로딩하고, JarFileClassLoader 클래스는 AppClassLoader가 로딩하였음을 보여준다. 즉, JarFileClassLoader는 NetworkClassLoader의 부모 클래스로더가 되고, AppClassLoader는 JarFileClassLoader의 부모 클래스로더가 되는 것이다.

이 경우, 앞에서 발생했던 문제가 모두 해결된다. A 클래스가 필요하면, 가장 먼저 NetworkClassLoader에 클래스로딩을 요청한다. 그럼, NetworkClassLoader는 네트워크로부터 A 클래스를 로딩할 수 있으므로, A 클래스를 로딩한다. 그런 후, A 클래스는 B 클래스를 필요로 한다. B 클래스를 로딩하기 위해 NetworkClassLoader는 JarFileClassLoader에 클래스 로딩을 위임(delegation)한다. JarFileClassLoader는 Jar 파일로부터 B 클래스를 읽어온 후 NetworkClassLoader에게 리턴할 것이며, 따라서 NetworkClassLoader는 Jar 파일에 있는 B 클래스를 사용할 수 있게 된다. 앞의 JDK 1.1에서의 클래스로더 사이의 관계에 비해 훨씬 발전적인 구조라는 것을 알 수 있다.

앞에서 말했듯이, 자바 2에서는 몇몇 클래스로더 메커니즘을 재정의하였다. 이 때문에, JDK 1.1에서의 클래스로더에 관한 몇몇개의 규칙이 깨졌다. 먼저, loadClass() 메소드를 더 이상 오버라이딩(overriding) 하지 않고, 대신 findClass()를 오버라이딩한다. loadClass() 메소드는 public에서 protected로 변경되었으며, 실제 JDK1.3의 ClassLoader 클래스의 소크 코드를 보면 다음과 같이 정의되어 있다.

  // src/java/lang/ClassLoader.java
  public abstract class ClassLoader {
      /*
       * The parent class loader for delegation.
       */
      private ClassLoader parent;
      
      protected synchronized Class loadClass(String name, boolean resolve)
      throws ClassNotFoundException
      {
          // First, check if the class has already been loaded
          Class c = findLoadedClass(name);
          if (c == null) {
              try {
                  if (parent != null) {
                      c = parent.loadClass(name, false);
                  } else {
                      c = findBootstrapClass0(name);
                  }
              } catch (ClassNotFoundException e) {
                  // If still not found, then call findClass in order
                  // to find the class.
                  c = findClass(name);
              }
          }
          if (resolve) {
              resolveClass(c);
          }
          return c;
      }
      ....
  }

위 코드를 보면 부모 클래스로더로부터 먼저 클래스 로딩을 요청하고, 그것이 실패할 경우(즉, catch 블럭)에 비로소 직접 클래스를 로딩한다. 여기서 그렇다면 부모 클래스는 어떻게 결정되는 지 살펴보자. 먼저 JDK 1.3의 ClassLoader 클래스는 다음과 같은 두 개의 생성자를 갖고 있다.

  protected ClassLoader(ClassLoader parent) {
      SecurityManager security = System.getSecurityManager();
      if (security != null) {
          security.checkCreateClassLoader();
      }
      this.parent = parent;
      initialized = true;
  }
  protected ClassLoader() {
      SecurityManager security = System.getSecurityManager();
      if (security != null) {
          security.checkCreateClassLoader();
      }
      this.parent = getSystemClassLoader();
      initialized = true;
  }

이 두 코드를 살펴보면, 부모 클래스로더를 지정하지 않을 경우, 시스템 클래스로더를 부모 클래스로더로 지정하는 것을 알 수 있다. 따라서 커스텀 클래스로더에서 부모 클래스로더를 지정하기 위해서는 다음과 같이 하면 된다.

  public class JarFileClassLoader extends ClassLoader {
     public JarFileClassLoader () {
        super(JarFileClassLoader.class.getClassLoader());
        // 다른 초기화 관련 사항
     }
     ....
     public Class findClass(String name) {
        // 지정한 클래스를 찾는다.
     }
  }

모든 클래스는 그 클래스에 해당하는 Class 인스턴스를 갖고 있다. 그 Class 인스턴스의 getClassLoader() 메소드를 통해서 그 클래스를 로딩한 클래스로더를 구할 수 있다. 즉, 위 코드는 JarFileClassLoader 클래스를 로딩한 클래스로더를 JarFileClassLoader 클래스로더의 부모 클래스로더로 지정하는 것이다. (실제로 커스텀 클래스로더를 구현하는 것에 대한 내용은 이 Article의 시리중에서 3번째에 알아보기로 한다).

JVM에서 부모 클래스로더를 갖지 않은 유일한 클래스로더는 부트스트랩 클래스로더이다. 부트스트랩 클래스로더는 자바 런타임 라이브러리에 있는 클래스를 로딩하는 역할을 맡고 있으며, 항상 클래스로더 체인의 가장 첫번째에 해당한다. 기본적으로 자바 런타임 라이브러리에 있는 모든 클래스는 JRE/lib 디렉토리에 있는 rt.jar 파일에 포함되어 있다.

결론

이번 Article에서는 자바에서 클래스 로딩이 동적으로 이루어지면, 클래스 로딩 방식에서는 로드타임 로딩과 런타임 로딩의 두 가지 방식이 있다는 것을 배웠다. 그리고 자바 2에서의 클래스로딩이 클래스로더 딜리게이션 모델(Classloader Delegation Model)을 통해서 이루어진다는 점과 이 모델에 자바 1.x에서의 클래스로딩 메커니즘과 어떻게 다르며, 어떤 장점이 있는 지 알아보았다. 다음 Article에서는 자바 2에서 기본적으로 제공하는 클래스로더에 대해서 알아보기로 한다.

  1. JunkMan 2013.05.10 09:51

    좋은 정보 감사합니다.

  2. bakgaksi 2015.03.03 03:21

    초보가 보기엔 무지막지 하네요 한... 6시간 동안 본듯. 그래도 무슨내용인지 모르겠어요 >.<

  3. 들개 2015.07.30 13:41

    다시봐도 정말 멋진 설명입니다. 최범균님 책보면서 jsp를 배웠었는데 ㅎㅎ.
    이렇게 지식공유해주시는 멋진 고수님들이 있어 정말 다행입니다.
    감사한 마음으로 봤습니다. 꾸벅.

  4. violet4795 2019.05.23 21:35 신고

    다른 글을 보며 왜? 라는 물음표를 띄우고 여기서 해결하네요 감사합니다.

+ Recent posts