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

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의
클래스로더를 이용하여 어플리케이션의 라이브러리를 캐싱하는 기술에 대해서 살펴본다.

애플릿과 캐싱(Caching)

지난 3회까지의 아티클에서는 클래스로더가 무엇이며, 어떻게 동작하는 지 그리고 커스텀 클래스로더를 작성하는 방법에 대해서 알아보았다. 이번 4번째 아티클에서는 자바 애플릿이 사용하는 클래스 라이브러리를 로컬 시스템에 캐싱한 후 클래스로더를 사용하여 캐싱한 라이브러리를 사용하는 방법에 대해서 살펴보도록 하자.

지금은 플래쉬와 DHTML과 같은 기술덕에 애플릿이 많이 사용되고 있지는 않지만, 인트라넷 환경을 구축하거나 ASP(Application Service Provider)를 구현하는 경우에 애플릿은 여전히 매력적인 솔루션 중의 하나이다.

현재 자바 애플릿을 이용하여 ASP 서비스를 제공하는 대표적인 경우가 바로 넷피스(http://www.netffice.com)이다. 넷피스는 최초로 실행할 경우 필요한 모듈을 로컬 시스템에 캐싱한다. 애플릿의 특징 중의 하나는 동적으로 필요한 클래스를 서버로부터 다운로드 받아 클라이언트 웹 브라우저에서 애플릿을 실행하는 것인데, 캐싱의 경우는 이러한 애플릿의 특징에 역행한다고 할 수 있다. 하지만, 캐싱을 하게 되면 다음과 같은 장점을 얻을 수 있게 된다.

  • 애플릿이 필요로 하는 모듈의 다운로드 시간을 줄일 수 있다.
애플릿이 필요로 하는 클래스 파일(또는 Jar나 Zip 파일)을 다운로드 하는 시간을 없앨 수 있다는 것은 곧 애플릿의 수행속도가 그 만큼 빨라진다는 것을 의미한다. 하지만, 캐싱을 할 때에는 캐싱되어 있는 데이터가 변경될 경우 그 변경된 내용을 캐시에 반영헤 주어야 한다. 즉, 애플릿이 필요로 하는 모듈이 변경될 경우 캐시를 위한 저장소에 저장되어 있는 모듈을 변경해줄 수 있는 방법이 필요하다.

로컬 시스템에 저장되어 있는 모듈이 변경되었는 지의 여부를 판단하는 방법에는 여러 가지가 있을 수 있으나, 그 중 가장 손쉬운 방법은 버전을 사용하는 것이다. 일반적으로 버전은 나중에 나온 버전에 기존에 있던 버전보다 더 큰값을 갖는다. 예를 들어, 많은 개발자들이 선호하는 에디터인 울트라 에디터는 6.0이나 7.0과 같이 숫자를 사용하여 버전을 표시하며, 울트라 에디터 뿐만 아니라 대부분의 어플리케이션이 숫자를 사용하여 버전을 표시하고 있다. 이처럼 숫자로 버전을 표시하게 되면 변경되었는지의 여부를 쉽게 판단할 수 있다는 장점이 있다.

애플릿은 이러한 버전을 사용하여 현재 로컬에서 사용되고 있는 모듈의 변경여부를 판단할 수 있게 되며, 따라서 버전이 변경되었을 경우(또는 버전이 높아졌을 경우) 서버로부터 가장 최신의 모듈을 읽어올 수 있게 된다.

애플릿과 클래스로더 생성 그리고 보안

로컬 시스템에 캐시되어 있는 모듈(Jar 파일 또는 Zip 파일)로부터 필요한 클래스를 로딩하기 위해서는 URL로부터 클래스로더를 읽어올 수 있는 java.net.URLClassLoader 클래스를 사용해야 한다. 하지만, 애플릿은 새로운 클래스로더를 생성할 수 없도록 제한되어 있다. 또한, 애플릿은 로컬 파일시스템으로부터 파일을 읽을 수 없고 로컬 파일시스템에 파일을 저장할 수도 없다.

이러한 애플릿과 관련된 제약은 애플릿과 관련된 보안 문제 때문에 발생한다. 이러한 보안과 관련된 애플릿의 제약은 서명된(Signed) 애플릿을 통해서 해결할 수 있다. 현재 웹 브라우저는 웹 브라우저만의 인증 방식을 사용하고 있으며, 자바2 부터는 보안 관련 부분을 개발자가 세밀하게 설정할 수 있도록 하고 있다. 또한, 자바2 부터는 인증서를 사용하여 서명된 애플릿은 어플리케이션과 완전히 같은 권한을 갖도록 하고 있다. 특히, 자바 플러그인 1.3 부터는 RSA를 기반으로한 서명된 애플릿을 실행할 수 있도록 하고 있다. 이를 통해 좀더 쉽게 서명된 애플릿을 배포할 수 있게 되었다.

이 글은 클래스로더와 관련된 글이므로 보안과 관련된 문제에 대해서는 자세히 언급하지 않겠다. 서명된 애플릿과 관련된 내용이 궁금한 독자들은 관련 링크를 참조하기 바란다.

캐싱의 구현

애플릿에서 캐싱을 구현하는 것은 크게 다음과 같은 과정을 통해서 이루어진다.

  1. 로컬 파일 시스템에 모듈이 캐시되어 있는 지 검사한다.
  2. 캐시되어 있다면 모듈의 버전을 검사한다.
    1. 로컬의 모듈 버전이 서버의 모듈 버전보다 낮다면 3-i, 3-ii를 실행한다.
    2. 로컬의 모듈 버전과 서버의 모듈 버전이 같다면 과정 4를 실행한다.
  3. 캐시되어 있지 않다면,
    1. 모듈을 서버로부터 읽어와 로컬 파일시스템에 저장한다.
    2. 버전 정보를 로컬 파일 시스템에 저장한다.
  4. 캐시된 파일로부터 클래스를 읽어오는 클래스 로더를 생성한다.
  5. 새로 생성한 클래스로더를 사용하여 모듈로부터 필요한 클래스를 읽어온다.
애플릿은 1에서 5까지의 과정만을 책임지게 되며, 실제 어플리케이션은 애플릿이 읽어오는 모듈을 통해서 실행되게 된다.

애플릿의 구현

애플릿은 크게 초기화(init() 메소드), 캐싱 검사 및 버전 검사(start() 메소드와 isUpdated() 메소드), 모듈 캐싱(cacheApplicationJar() 메소드), 클래스로더 생성(initClassLoader() 메소드) 그리고 실제 어플리케이션의 실행(startApplication() 메소드)의 5가지 부분으로 구성된다.

먼저 초기화 부분은 다음과 같다.

public void init() {
   // 화면 구성
   setLayout(new BorderLayout());
   messageLabel = new Label();
   add(messageLabel, BorderLayout.CENTER);
   
   cacheDirectory = System.getProperty("user.home");;
   appVersion = getParameter("appVersion");
   
   applFile = new File(cacheDirectory+File.separator+"appl.jar");
   versionFile = new File(cacheDirectory+File.separator+"appl.version");   
}

위 코드에서 앞 부분은 간단하게 진행 상황을 표시해줄 수 있는 GUI를 생성하고, 그 이후로는 모듈의 캐싱 및 버전과 관련된 필드를 초기화한다. 여기서 보여줄 에제는 해당 모듈을 사용자의 홈 디렉티리에 appl.jar 라는 이름으로 캐싱하며, 버전 정보는 홈 디렉토리의 appl.version 파일에 기록한다. System.getProperty("user.home") 을 이용하여 사용자의 홈 디렉토리를 구할 수 있다. 여기서 getParameter("appVersion") 메소드를 호출하는 데, 이는 서버에 있는 모듈의 버전을 구할 때 사용된다.

init() 메소드를 통해 초기화 과정이 이루어지면 애플릿의 start() 메소드가 호출된다. 예제에서는 start() 메소드에서 모듈의 캐싱여부와 버전을 체크하게 된다. start() 메소드는 다음과 같다.

public void start() {
   try {
      if (!checked) {
         // 현재 appl.jar가 캐쉬되어 있는 지 검색한다.
         if (!applFile.exists()) { // 캐쉬되어 있지 않다면,
            cacheApplicationJar();
         } else {
            // 캐쉬되어 있다면, 버전을 검사한다.
            if(isUpdated()) { // 버전이 올라갔다면
               cacheApplicationJar();
            }
         }
         checked = true;
      }
      initClassLoader();
      startApplication();
      messageLabel.setText("어플리케이션을 실행하였습니다.");
   } catch(IOException ex) {
      messageLabel.setText("IOException 발생");
   } catch(SecurityException ex) {
      ex.printStackTrace();
      messageLabel.setText("SecurityException 발생");
   } catch(Exception ex) {
      messageLabel.setText("기타 예외: "+ex.getMessage());
   }
}

위 코드에서 applFile.exists() 메소드는 현재 모듈이 로컬 파일시스템에 존재하는 지 판단한다. 존재할 경우에는 isUpdate() 메소드를 사용하여 캐시되어 있는 모듈의 버전과 서버에 있는 모듈의 버전을 검사한다. isUpdate() 메소드는 다음과 같다.

private boolean isUpdated() throws IOException {
   BufferedReader br = null;
   try {
      br = new BufferedReader(new FileReader(versionFile));
      String version = br.readLine();
      if (version != null) {
         int localVersion = Integer.parseInt(version);
         int serverVersion = Integer.parseInt(appVersion);
         if (localVersion < serverVersion)
            return true;
         else
            return false;
      }
      return false;
   } finally {
      if (br != null) try { br.close(); } catch(IOException ex) {}
   }
}

isUpdate() 메소드에서 로컬 파일 시스템의 사용자 홈디렉토리에 있는 appl.version 파일에 기록되어 있는 버전과 파라미터를 통해서 입력받은 버전을 비교한다. 만약 로컬 파일 시스템에 캐싱되어 있는 모듈이 서버에 있는 모듈보다 이전 버전이라면 true를 리턴하고 그렇지 않을 경우 false를 리턴하게 된다. 참고로 버전은 정수형으로 표시된다.

start() 메소드에서 모듈을 새로 캐싱해야 할 경우에는 cacheApplicationJar() 메소드를 호출한다. cacheApplicationJar() 메소드는 서버로부터 필요한 모듈을 읽어와 로컬 파일 시스템에 기록하는 역할을 한다. cacheApplicationJar() 메소드는 다음과 같다.

private void cacheApplicationJar() throws IOException {
   // 서버로부터 최신 버전의 appl.jar 파일을 읽어온 후,
   // 로컬 파일 시스템에 저장한다.
   BufferedInputStream is = null;
   BufferedOutputStream os1 = null;
   BufferedWriter os2 = null;
   
   try {
      messageLabel.setText("캐싱하고 있습니다.");
      System.out.println("캐싱하고 있습니다.");
      URL applJar = new URL(getDocumentBase(), "appl.jar");
      is = new BufferedInputStream(applJar.openStream());
      os1 = new BufferedOutputStream(new FileOutputStream(applFile) );
      byte[] buffer = new byte[1024]; // 1K 버퍼 사용
      int readByte = -1;
      while( (readByte = is.read(buffer, 0, buffer.length)) != -1) {
         os1.write(buffer, 0, readByte);
      }
      os2 = new BufferedWriter(new FileWriter(versionFile));
      os2.write(appVersion);
      messageLabel.setText("캐싱을 완료했습니다.");
      System.out.println("캐싱을 완료했습니다.");
   } finally {
      if (is != null) try { is.close(); } catch(IOException ex) {}
      if (os1 != null) try { os1.close(); } catch(IOException ex) {}
      if (os2 != null) try { os2.close(); } catch(IOException ex) {}
   }
}

cacheApplicationJar() 메소드는 URL.openStream() 클래스를 사용하여 필요한 모듈을 서버로부터 읽어온다. URL.openStream() 메소드를 통해서 구해진 IOStream으로부터 읽혀진 데이터는 출력 스트림을 통해서 로컬 파일 시스템에 저장된다. 그리고 버전 정보 역시 이 시점에서 같이 저장된다.

캐싱 작업이 끝나면 start() 메소드는 initClassLoader() 메소드를 호출한다. initClassLoader() 메소드는 캐싱된 모듈을 읽을 때 사용된다. initClassLoader() 메소드는 다음과 같다.

private void initClassLoader() throws MalformedURLException {
   if (classLoader == null) {
      URL[] urls = { applFile.toURL() };
      classLoader = new URLClassLoader(urls);
   }
}

캐싱된 모듈을 읽어올 때 사용하는 클래스로더는 java.net.URLClassLoader 클래스이다. 일단, 클래스로더를 생성하면 모듈로부터 필요한 클래스를 읽어와 어플리케이션을 구동하면 된다. 이는 startApplication() 메소드를 통해서 이루어진다. startApplication() 메소드는 다음과 같다.

private void startApplication() {
   try {
      Class applExecutor = classLoader.loadClass("com.javacan.appl.ApplicationDriver");
      applExecutor.newInstance();
   } catch(ClassNotFoundException ex) {
      messageLabel.setText("클래스 발견 못함");
   } catch(InstantiationException ex) {
      messageLabel.setText("인스턴스 생성 못함");
   } catch(IllegalAccessException ex) {
      messageLabel.setText("에외 발생: "+ex.getMessage());
   }
}

여기서 startApplication() 메소드는 이름이 "com.javacan.appl.ApplicationDriver"인 클래스를 로딩한 후, 그 클래스의 새로운 인스턴스를 생성한다. 따라서 모듈에 있는 com.javacan.appl.ApplicationDriver 클래스는 생성자에서 필요한 어플리케이션을 시작하면 된다.

이로써 애플릿은 필요한 모든 역할을 수행하게 된다. 이제 애플릿과 관련된 보안 문제를 해결하자. 이 글은 클래스로더와 모듈 개념을 설명하기 위한 것이므로 서명된 애플릿을 작성하는 방법을 사용하지 않고, 대신 설정 파일을 통해서 보안 문제를 해결해보도록 하자. 자바2 SDK에 포함되어 있는 appletviewer는 기본적으로 자바2의 보안 모델을 따르기 때문에, 다음과 같은 설정 파일을 사용함으로써 애플릿 테스트와 관련된 보안 문제를 해결할 수 있다. 이 파일을 all.policy 라고 하자.

grant {
   permission java.io.FilePermission "<<ALL FILES>>", "write";
   permission java.io.FilePermission "<<ALL FILES>>", "read";
   permission java.util.PropertyPermission "user.home", "read";
   permission java.lang.RuntimePermission "createClassLoader";
};

보안과 관련된 설정 파일을 지정하는 것에 대한 내용은 관련 링크의 자바 보안 관련 링크를 참조하기 바란다.

애플릿은 다음과 같은 HTML을 통해서 실행된다. 이 HTML 문서를 appl.html이라고 하자.

<html>
<head><title>Test</title></head>
<body>
<H1>애플릿 테스트</H1>
<applet code="ReadyApplet.class" archive="readyapplet.jar"
        width="500" height="50">
<param name="appVersion" value="1">
</applet>
</body>
</html>

readapplet.jar 파일은 ReadyApplet.class를 JAR 파일로 묶은 것이다. 위 HTML 코드에서 이름이 'appVersion'인 파라미터의 값이 서버에 있는 모듈의 버전을 나타낸다. 즉, 이 값에 따라 로컬 파일 시스템에 캐싱되어 있는 모듈을 새로운 모듈로 대체할 것인가가 결정된다.

테스트 모듈1

지금까지 애플릿에서 모듈을 처리하는 것에 대해서 알아보았으므로, 이제 실제로 어떻게 동작하는 지 알아보기 위해 예제를 작성해보자. 다음은 모듈에서 사용될 com.javacan.appl.ApplicationDriver 클래스이다.

package com.javacan.appl;

import java.awt.*;
import java.awt.event.*;

public class ApplicationDriver {
    public ApplicationDriver() {
        startApplication();
    }
   
    private void startApplication() {
        ApplFrame appl = new ApplFrame();
        appl.show();
    }
}

ApplicationDriver 클래스의 생성자는 startApplication() 메소드를 호출하고, startApplication() 메소드는 ApplFrame 클래스의 인스턴스를 생성한 후, 화면에 보여준다. com.javacan.appl.ApplFrame 클래스는 다음과 같다.

package com.javacan.appl;

import java.awt.*;
import java.awt.event.*;

public class ApplFrame extends Frame implements ActionListener {
   
   public ApplFrame() {
      super("Version 1");
      setLayout(new BorderLayout());
      TextArea ta = new TextArea(10, 30);
      add(ta, BorderLayout.CENTER);
      Button btn = new Button("닫기");
      add(btn, BorderLayout.SOUTH);
      
      btn.addActionListener(this);
      
      pack();
   }
   
   public void actionPerformed(ActionEvent e) {
      this.setVisible(false);
   }
}

ApplFrame 클래스는 java.awt.Frame을 상속받았으면, 내부에 한개의 TextArea와 한개의 Button을 갖고 있다. ApplicationDriver.java와 ApplFrame.java를 컴파일 하자. 이 때, c:\mywork\com\javacan\appl에 이 두 클래스 파일이 존재한다고 할 경우 c:\mywork 디렉토리에서 다음과 같이 JAR 실행파일을 이용하여 이 두 클래스를 Jar 파일로 묶도록 하자.

c:\mywork>jar cvf appl.jar com\*

그러면, c:\mywork 디렉토리에 appl.jar 파일이 생성될 것이다. 이 appl.jar 파일을 웹 서버가 문서의 루트디렉토리에 복사해 넣도록 하자. 그리고 앞에서 작성한 appl.html 파일을 같은 디렉토리에 복사하다. 그런 후, 다음과 같이 AppletViewer를 사용하여 appl.html을 실행해보자.

appletviewer -J"-Djava.security.policy=all.policy" http://localhost:8080/appl.html

여기서 -Djava.security.policy=all.policy 는 보안과 관련된 부분을 설정하기 위해 all.policy 파일을 사용하겠다는 것을 의미한다. all.policy 파일은 앞에서 작성한 파일이다.

이와 같이 실행하면 도스창에 다음과 같은 내용이 출력될 것이다.

캐싱하고 있습니다.
캐싱을 완료했습니다.

그리고 애플릿은 다음과 같은 새로운 Frame을 생성한다.


이제 AppletViewer를 종료했다가 다시 실행해보자. 그러면 캐싱 과정이 실행되지 않을 것이다. 실제로 캐싱된 파일은 윈도우즈98을 사용할 경우 c:\windows 디렉토리에 위치하게 된다.

테스트 모듈2

이제 모듈을 수정한 후, 변경된 모듈을 캐싱하는 지 살펴보자. 새로운 모듈의 ApplFrame 클래스는 다음과 같다.

package com.javacan.appl;

import java.awt.*;
import java.awt.event.*;

public class ApplFrame extends Frame implements ActionListener {
   
   private TextArea ta;
   private Button btn1;
   private Button btn2;
   
   public ApplFrame() {
      super("Version 2");
      setLayout(new BorderLayout());
      ta = new TextArea(10, 30);
      add(ta, BorderLayout.CENTER);

      btn1 = new Button("지우기");
      btn2 = new Button("닫기");
      
      Panel panel = new Panel();
      panel.setLayout(new GridLayout(1,2));
      panel.add(btn1);
      panel.add(btn2);
      
      add(panel, BorderLayout.SOUTH);
      
      btn1.addActionListener(this);
      btn2.addActionListener(this);
      
      pack();
   }
   
   public void actionPerformed(ActionEvent e) {
      if (e.getSource() == btn1) {
         ta.setText("");
      } else {
         this.setVisible(false);
      }
   }
}

앞에서 작성한 ApplFrame과 달리 새로운 ApplFrame 클래스는 Button을 두개 갖고 있다. 이제 새로운 ApplFrame.java를 컴파일 한 후, 앞에서 만찬가지로 ApplicationDriver.class와 ApplFrame.class 파일을 appl.jar로 묶도록 하자. 새로 생성한 appl.jar 파일을 웹서버가 문서를 읽을 때 사용하는 디렉토리에 알맞게 위치시킨 후 appl.html의 <param> 태그를 다음과 같이 변경하자.

<param name="appVersion" value="2">

모듈의 버전을 "1"에서 "2"로 변경한 것을 알 수 있다. 이제 다시 AppletViewer로 appl.html을 실행해보자. 그러면 다음과 같은 결과가 출력될 것이다.


위 결과를 보면 새로 변경된 모듈이 실행된 것을 알 수 있다. 도스창을 보면 캐싱하고 있다는 메시지가 출력되는 것을 알 수 있다.

결론

이번 클래스로더와 관련된 네번째 아티클에서는 클래스로더를 이용하여 캐싱한 모듈을 읽어와서 실행하는 것에 대해서 살펴봤다. 모듈을 캐싱함으로써 매번 모듈을 로딩할 필요가 없어졌으며, 애플릿을 실행할 때에 변경된 모듈을 새로 캐싱함으로써 늘 새로운 모듈을 사용하여 애플릿을 실행할 수 있다는 것을 알게 되었을 것이다.

이처럼 클래스로더와 캐싱을 사용하는 것은 애플릿 뿐만 아니라 어플리케이션에서도 사용할 수 있으며, 좀 더 발전시켜 어플리케이션을 실행하는 도중에, 즉, 런타임에 모듈을 동적으로 변경하도록 할 수도 있을 것이다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

자바2의 딜리게이션 클래스로딩에 알맞은 커스텀 클래스로더를 작성해본다.

커스텀 클래스로더의 구현

지난 두 번의 기사에서는 자바의 동적인 클래스 로딩과 자바2의 클래스로더 딜리게이션 모델, 그리고 자바2에서 제공하는 기본적은 클래스로더에 대해서 알아보았다. 이 두 기사를 통해서 여러분은 어느 정도 자바에서 클래스로딩이 어떻게 이루어지며 클래스로더가 어떻게 동작하는지에 대해서 이해했을 것이다.

커스텀 클래스 로더는 이미 서블릿 엔진이나 JSP 엔진에서 사용되고 있으며, 대부분의 어플리케이션 서버는 자신만의 커스텀 클래스로더를 구현하여 사용하고 있다. 특히, 서블릿 리로딩은 커스텀 클래스 로더를 사용하지 않으면 구현하기 힘든 기능이다. 또한, ASP(Application Service Provider)에 대한 관심이 높아지면서 자바를 이용하여 ASP를 구현하는 사례가 늘고 있으며, 이 경우 커스텀 클래스 로더의 사용은 필수적이라 할 수 있다. (실제로 초기에 나온 넷피스의 경우 커스텀 클래스 로더를 사용하여 구현되었다.) 이 글에서는 커스텀 간단한 커스텀 클래스 로더를 구현해볼 것이며, 다음 번에 커스텀 클래스 로더를 이용한 어플리케이션의 동적 업그레이드에 대해서 살펴볼 것이다.

'클래스로더 1, 동적인 클래스 로딩과 클래스로더'에서 살펴봤듯이, 자바2에서 커스텀 클래스로더를 구현하기 위해서는 다음의 두 가지가 필요하다.

  1. java.lang.ClassLoader 클래스를 상속받는다.
  2. findClass() 메소드를 알맞게 구현한다.
실제로 이 과정은 그렇게 어렵지 않으며, findClass() 메소드에서 알맞게 클래스를 읽어오는 것이 가장 힘든 부분이라면 힘든 부분이다. 여기서는 파일 시스템의 특정 디렉토리로부터 클래스를 읽어오는 FileClassLoader 클래스를 작성해보자. FileClassLoader 클래스의 소스 코드는 다음과 같다.

package javacan.custom.classloader;

import java.io.*;

public class FileClassLoader extends ClassLoader {
   
   private String root;
   
   /**
    * @param rootDir 클래스를 읽어올 루트 디렉토리
    */
   public FileClassLoader(String rootDir) throws FileNotFoundException {
      super(FileClassLoader.class.getClassLoader() );
      
      File f = new File(rootDir);
      if (f.isDirectory())
         root = rootDir;
      else
         throw new FileNotFoundException(rootDir+" isn't a directory");
   }
   
   /**
    * @param name 검색할 클래스 이름
    */
   public Class findClass(String name) throws ClassNotFoundException {
      try {
         String path = root + File.separatorChar + 
                       name.replace('.', File.separatorChar) + ".class";
         
         FileInputStream file = new FileInputStream(path);
         byte[] classByte = new byte[file.available()];
         file.read(classByte);
         
         return defineClass(name, classByte, 0, classByte.length);
      } catch(IOException ex) {
         throw new ClassNotFoundException();
      }
   }
}

FileClassLoader 클래스의 생성자는 FileClassLoader가 클래스를 읽어올 디렉토리를 파라미터로 입력받는다. 예를 들어, FileClassLoader 클래스의 생성자에 파라미터로 "c:\classes\custom"을 넘겨주었다고 할 경우, "somepackage.AnyClass" 라는 클래스를 읽어올 때에는 "c:\classes\custom\somepackage" 디렉토리에 있는 AnyClass.class 파일로부터 클래스의 정의를 읽어온다.

생성자에서 눈여겨 볼 부분은 상위 클래스의 생성자를 호출하는 부분이다.

super(FileClassLoader.class.getClassLoader() );

첫번째 글에서 자바2의 클래스로더는 딜리게이션 모델을 사용하고 있으며, 부모 클래스로더에게 클래스 로딩 작업을 위임한다고 한 것을 기억할 것이다. 위 코드는 바로 부모 클래스로더를 지정하는 부분다. 부모 클래스로더를 지정함으로써 FileClassLoader는 오직 자신과 관련된 클래스로딩만 신경쓰면 되며, 나머지 클래스의 로딩은 부모 클래스로더에게 위임하면 된다.

실제로 요청한 클래스를 읽어오는 부분은 findClass() 메소드이다. 일반적으로 findClass() 메소드는 다음과 같은 두 과정을 처리하게 된다.

  • 특정 자원(예를 들어, JAR, ZIP, 네트워크)으로부터 클래스의 정의를 읽어와 byte[] 배열에 저장한다.
  • defineClass() 메소드를 사용하여 Class 객체를 생성한 후 리턴한다.
FileClassLoader 클래스의 findClass() 메소드를 살펴보면, 생성자를 통해서 입력받은 루트 디렉토리 이름과 파라미터로 전달받은 클래스 이름을 사용하여 읽어올 파일을 결정한다. 그런 후, 그 파일로부터 클래스의 정의를 읽어와 byte[] 배열에 저장한 다음 defineClass() 메소드를 호출하여 Class 객체를 생성하고 리턴한다.

커스텀 클래스로더 테스트

실제로 FileClassLoader가 어떻게 동작하는 지 알아보기 위해 간단한 예제를 살펴보자. 이 글에서는 FileClassLoader가 CLASSPATH가 아닌 다른 디렉토리에 있는 FirstHello 클래스와 SecondHello 클래스를 로딩하도록 할 것이다. FirstHello 클래스는 다음과 같다.

package javacan.test;

public class FirstHello {

   public FirstHello() {
      printInfo();
      this.init();
   }
   
   public void printInfo() {
      System.out.println("FirstHello");
   }
   
   protected void init() {
      SecondHello sh = new SecondHello();
      sh.printInfo();
   }
}

FirstHello 클래스는 init() 메소드에서 SecondHello 클래스를 사용하며, SecondHello 클래스는 다음과 같다.

package javacan.test;

public class SecondHello {
   
   public SecondHello() {
   }
   
   public void printInfo() {
      System.out.println("SecondHello");
   }
}

FirstHello 클래스와 SecondHello 클래스는 단순히 문자열을 출력해준다. 이제 실제로 FileClassLoader를 사용하여 FirstHello 클래스와 SecondHello 클래스를 로딩하는 TestProgram 클래스를 살펴보자.

import javacan.custom.classloader.FileClassLoader;

public class TestProgram {

   public static void main(String[] args) throws Exception {
      if (args.length < 1) {
         System.out.println("[Usage] java TestProgram <directory>");
         System.exit(1);
      }
      
      FileClassLoader loader = new FileClassLoader(args[0]);
      
      Class klass = loader.loadClass("javacan.test.FirstHello");
      Object obj = klass.newInstance();
      
      System.out.println(obj.getClass().getName() );
   }
}

TestProgram 클래스의 main() 메소드를 살펴보면, FileClassLoader 인스턴스를 생성한 후, loadClass() 메소드를 사용하여 javacan.test.FirstHello 클래스를 로딩하는 것을 알 수 있다. loadClass() 메소드에 대한 내용은 이 연재의 첫번째 기사를 참조하기 바란다. 로딩한 클래스는 Class 객체의 레퍼런스인 klass에 할당되며, klass.newInstance() 를 통해서 FirstHello 클래스의 인스턴스를 생성한다.

이제 테스트 환경을 꾸며보자. 먼저 지금까지 작성한 네 개의 클래스가 각각 다음과 같은 디렉토리에 위치시키자.

FileClassLoader.class : c:\classes\normal\javacan\custom\classloader
TestProgram.class : c:\classes\normal
FirstHello.class : c:\classes\custom\javacan\test
SecondHello.class : c:\classes\custom\javacan\test

이제 CLASSPATH 환경 변수를 다음과 같이 설정하자.

SET CLASSPATH=c:\classes\normal

즉, JVM이 기본적으로 사용하는 클래스로더는 c:\classes\normal에 위치한 FileClassLoader 클래스와 TestProgram 클래스로더만 로딩할 수 있을 뿐, FirstHello와 SecondHello 클래스는 로딩할 수 없도록 해 놓는 것이다. 이제 TestProgram 클래스를 실행시켜보자. 그럼 다음과 같은 결과가 출력될 것이다.

C:\>java -cp c:\classes\normal TestProgram c:\classes\custom
FirstHello
SecondHello
javacan.test.FirstHello

여기서 'FirstHello'와 'SecondHello'는 모두 FileClassLoader에 의해 로딩된 FirstHello와 SecondHello 클래스에서 출력한 문장이다. TestProgram.main() 메소드에서 FirstHello 클래스의 인스턴스를 생성하는 문장은 다음 부분이다.

Object obj = klass.newInstance();

이를 통해 FirstHello 클래스의 인스턴스가 생성된다. 인스턴스를 생성한다는 것은 생성자가 호출된다는 것을 의미하며, 따라서 FirstHello 클래스의 생성자가 실행된다. 여기서 중요한 점은 TestProgram 클래스에서는 FirstHello와 SecondHello 클래스를 직접적으로 사용할 수 없다는 점이다. 왜냐면 FirstHello 클래스와 SecondHello 클래스는 TestProgram 클래스를 로딩한 클래스로더(즉, sun.misc.Launcher$AppClassLoader)가 로딩할 수 없기 때문이다.

자원의 로딩

마지막으로 클래스로더가 자원을 로딩할 때 사용하는 findResource() 메소드에 대해서 살펴보자. 여기서 자원은 클래스들이 사용하는 이미지 파일이나 오디오 파일 등이 될 수 있다. 커스텀 클래스로더가 완벽해지기 위해서는 알맞게 자원을 찾을 수 있는 기능을 제공해야 한다.

자바2에서, ClassLoader 클래스는 클래스로딩과 비슷한 방법으로 딜리게이션 모델을 사용하여 자원을 위치시킨다. 커스텀 클래스로더는 클래스 로딩과 마찬가지로 자신만의 자원 검색 방법을 findResource() 메소드에 구현하기만 하면 된다. 예를 들어, 앞에서 작성한 커스텀 클래스로더인 FileClassLoader에 자원을 찾을 수 있는 기능을 추가하고자 할 경우 다음과 같이 findResource() 메소드를 알맞게 오버라이딩 하면 된다.

   protected java.net.URL findResource(String name) {
      File resource = new File(root + File.separatorChar + name);
      if (resource.exists()) {
         try {
            resource.toURL();
         } catch(MalformedURLException ex) {
            // do nothing
         }
      }
      return null;
   }

실제로 클래스로더를 이용하여 자원의 URL을 읽어오는 어플리케이션은 getResource() 메소드를 사용한다. 이 메소드는 loadClass() 메소드와 마찬가지로 먼저 부모 클래스로더로부터 자원을 검색한 후, 없다면 findResource() 메소드를 사용하여 요청한 자원을 검색한다. 사용자들은 클래스로더의 getResource() 메소드 뿐만 아니라 getResources() 메소드를 사용하여 동시에 여러개의 자원을 참조할 수도 있다.

시스템의 클래스패스에 잇는 자원을 검색하고자 할 경우에는 ClassLoader.getSystemResource() 메소드를 사용하면 된다. 예를 들어, 클래스패스에 있는 "javacan.properties"라는 파일을 참조하길 원할 경우 다음과 같이 하면 된다.

java.net.URL dbURL = ClassLoader.getSystemResource("tpage.properties");

이 메소드는 시스템 클래스로더(일반적으로, sun.misc.Launcher$AppClassLoader)를 사용하여 필요한 자원을 검색하며, 자원이 발견되지 않더라도 커스텀 클래스로더에 자원 검색을 위임(딜리게이션)하지 않는다.

결론

이번 글에서는 지정한 디렉토리로부터 클래스를 로딩하는 FileClassLoader 클래스를 작성해보았다. 비록 이 클래스가 간단하긴 하지만 커스텀 클래스로더를 구현하는 방법을 보여주고 있으며, 프로젝트에 커스텀 클래스로더가 필요할 경우 어렵지 않게 커스텀 클래스로더를 구현할 수 있을 것이다.

실제로 자바를 이용하여 웹 기반의 ASP(Application Service Provier)를 구축할 경우 필요한 라이브러리의 캐시 기능, 동적인 라이브러리의 업데이트 등의 이유 때문에 커스텀 클래스로더의 구현은 필수적이라 할 수 있다. 다음에는 웹 어플리케이션에서 사용하는 라이브러리를 로컬에 캐싱하고 버전이 변경될 때 마다 자동으로 라이브러리를 로딩하는 예제를 살펴볼 것이다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

자바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에서는 커스텀 클래스로더를 작성하는 것에 대해 알아볼 것이다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

동적인 클래스 로딩

자바는 동적으로 클래스를 읽어온다. 즉, 런타임에 모든 코드가 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에서 기본적으로 제공하는 클래스로더에 대해서 알아보기로 한다.

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. JunkMan 2013.05.10 09:51 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 정보 감사합니다.

  2. bakgaksi 2015.03.03 03:21 신고  댓글주소  수정/삭제  댓글쓰기

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

  3. 들개 2015.07.30 13:41 신고  댓글주소  수정/삭제  댓글쓰기

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