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

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의
기능을 추가하는 과정에서 Composite 패턴을 적용한 사례를 공유한다.

추가하려던 기능과 최초 구현

대량 배치 작업을 처리하기 위해 하루에 한번씩 정해진 시간에 동작하는 어플리케이션을 개발한 적이 있다. 이 어플리케이션은 전체 작업이 완료되면 처리 결과를 담당자들에게 이메일로 보내주고 있다. 작업 처리기와 이메일로 결과를 발송해주는 부분의 최초 설계는 다음과 같았다.


결과를 리포트 해 주는 역할을 정의한 ReportSender 인터페이스를 정의했고, 메일을 이용해서 리포팅 해 주는 MailReportSender 구현 클래스를 만들었다. Processor는 처리가 완료되면 ReportSender 모듈을 사용해서 리포트를 발송하도록 구현했다. 아래 코드는 Processor의 일부를 보여주고 있다.

public class Processor {
    private ReportSender sender;
    
    public void process() {
        ...
        sender.sendReport(result);
    }
    ...
    public void setReportSender(ReportSender sender) {
        this.sender = sender;
    }
}

Processor와 ReportSender의 조립은 스프링 DI를 이용해서 처리해서, 요구 기능이 변경될 경우 ReportSender 구현체를 쉽게 교체할 수 있도록 하였다.

개발된 어플리케이션이 실제로 서비스에 투입된 후 몇 달 정도 지나서 새로운 요구 사항이 들어왔는데, 그 요구사항은 아래와 같았다.

  • 처리가 올바르게 되었는 지의 여부를 모니터링 시스템에 알려줄 것
즉, 메일로 담당자들에게 리포팅 하는 것 뿐만 아니라 모니터링 시스템에 처리 결과를 알려주어야 했다. 가장 먼저 모니터링 시스템과의 연동을 위한 인터페이스 및 구현 클래스를 개발하였다.

public interface MonitorSystemClient {
    public void sendMessage(String serviceId, String result);
}

public class HttpMonitorSystemClient implements MonitorSystemClient {
    ...
}

MonitorSystemClient 인터페이스를 작성한 뒤 Processor에 다음과 같이 MonitorSystemClient 필드를 추가하고, 처리 결과를 모니터링 시스템에 보낼 수 있도록 하였다.

public class Processor {

    private ReportSender sender;
    private MonitorSystemClient monitorSystemClient;
    
    public void process() {
        ...
        sender.sendReport(result);
        monitorSystemClient.sendMessage(serviceId, result);
    }
    ...
    public void setMonitorSystemClient(MonitorSystemClient monitorSystemClient) {
        this.monitorSystemClient = monitorSystemClient;
    }
}

새로 추가된 인터페이스에 대한 Mock을 만들어 Processor를 테스트 하고, 통합 테스트로 기능이 정상적으로 동작한 것을 확인하였다. 그리고 실 서비스에 적용해서 정상 동작하는 것을 확인하였다. 이 시점에서 클래스 간의 관계를 다음과 같다.


모든 게 잘 동작했는데, 코드가 마음에 들지 않았다. 모니터링 시스템에 메시지를 알려주는 것 역시 리포팅의 한 종류인데, Processor가 굳이 MonitorSystemClient에 의존해야 하나? 리포팅 해 주어야 하는 새로운 요구사항이 추가되면 새로운 의존을 Processor에 또 추가해야 하나? 이런 질문 끝에, 추후 새로운 리포팅 요구사항이 추가되도 Processor의 코드에 영향을 주지 않기 위해 Composite 패턴을 적용하기로 결정했다.

Composite 패턴을 사용하도록 리팩토링

Composite 패턴을 적용하기 위해 아래 코드와 같이 CompositeReportSender 클래스를 작성하였다.

public class CompositeReportSender implements ReportSender {
    private List<ProcessorResultReportSender> senderList;

    public void sendReport(String serviceId, String result) {
        if (senderList != null && !senderList.isEmpty()) {
            for (ProcessorResultReportSender reportSender : senderList) {
                reportSender.sendReport(serviceId, result);
            }
        }
    }

    public void setSenderList(List<ProcessorResultReportSender> senderList) {
        this.senderList = senderList;
    }

}

추가로 MonitorSystemClient를 이용해서 리포트를 전송해주는 MonitorSystemReportSender를 다음과 같이 구현하였다.

public class MonitorSystemReportSender implements ReportSender {
    private MonitorSystemClient monitorSystemClient;
    
    public void sendReport(String serviceId, String result) {
        monitorSystemClient.sendMessage(serviceId, result);
    }
    
    public void setMonitorSystemClient(MonitorSystemClient monitorSystemClient) {
        this.monitorSystemClient = monitorSystemClient;
    }

}

CompositeReportSender가 1개 이상의 ReportSender에 리포트 발송 요청을 할 수 있고 모니터링 시스템에 리포트를 발송해주는 MonitorSystemReportSender를 구현했기 때문에, Processor는 더 이상 MonitorSystemClient에 의존할 필요가 없어졌다. 그래서 Processor에 추가한 MonitorSystemClient에 대한 의존을 제거하였다.

// 다시 원래대로 돌아옴
public class Processor {
    private ReportSender sender;
    
    public void process() {
        ...
        sender.sendReport(result);
    }
    ...
    public void setReportSender(ReportSender sender) {
        this.sender = sender;
    }
}

변경된 결과를 클래스 다이어그램으로 표현하면 다음과 같다.


이제 남은 작업은 두 ReportSender를 (MailReportSender와 MonitorSystemReportSender를) 포함하는 CompositeReportSender를 생성한 뒤 Processor에 전달하는 것이다. 이 프로젝트에서는 스프링을 사용하고 있으므로 다음과 같은 설정 파일을 이용해서 이들 객체를 조립하였다.

<bean id="reportSender" class="CompositeReportSender">
    <property name="senderList">
        <list>
            <ref bean="mailReportSender" />
            <ref bean="monitorSystemReportSender" />
        </list>
    </property>
</bean>

<bean id="mailReportSender" class="MailReportSender" />

<bean id="monitorSystemReportSender" class="MonitorReportSender"  ... />

<bean id="processor" class="Processor">
    <proerty name="reportSender" ref="reportSender" />
</bean>

만약 새로운 ReportSender에게도 리포트 해 주어야 할 경우, 이제는 다음과 같이 신규 ReportSender 구현체만 CompositeReportSender에 추가해 주면 된다.

<bean id="reportSender" class="CompositeReportSender">
    <property name="senderList">
        <list>
            <ref bean="mailReportSender" />
            <ref bean="monitorSystemReportSender" />
            <ref bean="someNewReportSender" />
        </list>
    </property>
</bean>
...
<bean id="someNewReportSender" class="SomeNewReportSender" />
...

지금까지 리팩토링 과정에서 Composite 패턴을 적용한 과정을 설명하였다. 하나의 문제를 해결하는 방법은 다양하게 존재할 수 있는데, 필자는 다양한 방법 중에 기존 코드(더 나아가 설계)들을 최대한 유지하기 위해 Composite 패턴을 적용하였다. (실제로 이 예제의 경우 기능 추가 전과 Composite 패턴 적용 후 변경된 코드는 없고, 새롭게 추가된 클래스만 존재한다.)

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 토비 2008.12.29 18:40 신고  댓글주소  수정/삭제  댓글쓰기

    흥미로운 내용입니다. 스프링을 이용해서 디자인패턴을 자연스럽게 적용시키는 좋은 샘플인 것 같습니다.
    한가지 궁금한 것은 모니터리포트는 그 이전 메일리포트의 처리가 올바르게 되었는지를 확인하는 리포트이니, 메일리포트 처리가 실패했더라도(런타임익셉션 발생등) 모니터리포트가 발송되야 하지 않을까 하는 것입니다. 그런경우 동등한 레벨의 컴포넌트를 그룹핑해서 개별대상처럼 취급한다는 컴포짓패턴은 그리 적합해보이지는 않습니다. 기존 클라이언트(process)와 인터페이스를 유지한 채로라면 그 경우에는 메일모니터에 대해서 AOP를 이용해 일종의 모니터링 애스팩트를 적용하는 것이 더 나아보입니다.

  2. 최범균 madvirus 2008.12.30 09:20 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요. 토비님. 물론 에러에 대한 부분은 CompositeReportSender에서 처리하고 있습니다. 그리고 위 작업을 수행할 때는 두 Actor-즉, 메일 수신자와 모니터 시스템-에 대한 보고라는 관점으로 접근을 했습니다. 만약 모니터 시스템에 대해 보고라는 관점이 아닌 작업 요청이라는 관점으로 접근하게 된다면 또 다른 인터페이스를 도출했을 것 같긴 합니다.

    AOP에 대해서는 전 이견이 있는데, 개인적으로 (이건 어디까지나 개인적인 생각인데) 뭔가 투명하게 적용되어야 할 기능(예를 들어 캐시 처리, 실행 추적 로그)이나 선언적 트랜잭션처럼 편리함이 명확한 경우가 아니면 AOP가 주는 이로움보다 복잡도가 더 커진다고 생각하고 있습니다.

Jetty 서버의 설치 및 기본 사용법을 살펴본다.

Jetty 설치 및 실행

Jetty는 서블릿과 JSP를 지원하는 자바 기반의 WAS 서버이다. Netcraft Web Server 조사에서 따르면 Jetty는 현재 톰캣의 점유율에 대비해서 80% 정도의 점유율을 차지하고 있다고 한다. (Jetty shows strong growh - http://www.theserverside.com/news/thread.tss?thread_id=49010 참고) 실제로 Jetty는 다른 WAS에 비해 가볍고 빠르며, 설정도 더 쉽다. 게다가 Ant나 Maven, 그리고 이클립스와의 연동이 쉬울 뿐더러, WAS를 코드에 임베딩시켜서 동작시킬 수도 있기 때문에 개발 과정에서는 상당한 편리함을 제공한다. (필자 역시 Maven과 Jetty를 연동하여 웹 어플리케이션을 개발하곤 하는데, 늘 느끼는 거지만 정말 편리하다고 생각한다.)

Jetty 설치하기

Jetty의 설치 방법은 톰캣이나 Resin 처럼 필요한 파일을 다운로드 받은 뒤 압축을 풀면 완료된다. 현재 Jetty 버전은 6 으로서 아래 사이트에서 6버전의 Jetty를 다운로드 받을 수 있다.

  • http://docs.codehaus.org/display/JETTY/Downloading+Jetty
이 글을 쓰는 시점에서 최신 버전은 6.1.11 버전이며, 본 글에서는 jetty-6.1.11.zip 파일을 기준으로 설치 과정을 설명할 것이다. 다운로드 받은 파일의 압축을 풀면 기본적인 설치가 완료된다.

Jetty 시작 및 정지

Jetty를 처음 실행한 다음에 Jetty가 올바르게 동작하는 지 확인하려면, Jetty를 설치한 디렉토리에서 다음과 같은 명령어를 실행하면 된다.

C:\devtool\jetty-6.1.11>java -jar start.jar etc/jetty.xml

Jetty 설치 디렉토리에 위치한 etc/jetty.xml 파일은 기본적으로 제공되는 Jetty 설정 파일로서, 위 명령어를 실행하면 실행하면 다음과 비긋한 메시지가 출력되면서 Jetty 웹 서버가 실행되는 것을 확인할 수 있다.

C:\devtool\jetty-6.1.11>java -jar start.jar etc/jetty.xml
2008-09-29 14:43:37.518::INFO:  Logging to STDERR via org.mortbay.log.StdErrLog
2008-09-29 14:43:37.688::INFO:  jetty-6.1.11
2008-09-29 14:43:37.749::INFO:  Deploy C:\devtool\jetty-6.1.11\contexts\javadoc.xml 
-> org.mortbay.jetty.handler.ContextHandler@1bd0dd4{/javadoc,file:/C:/devtool/jetty-
6.1.11/javadoc/}
2008-09-29 14:43:37.798::INFO:  Deploy C:\devtool\jetty-6.1.11\contexts\test.xml 
-> org.mortbay.jetty.webapp.WebAppContext@1feca64{/,C:\devtool\jetty-
6.1.11/webapps/test}
2008-09-29 14:43:37.847::INFO:  Deploy C:\devtool\jetty-6.1.11\contexts\test-
jndi.xml -> org.mortbay.jetty.webapp.WebAppContext@1193779{/test-jndi,C:\devtool\jetty-
6.1.11/contexts/test-jndi.d}
2008-09-29 14:43:38.185::INFO:  No Transaction manager found - if your webapp requires 
one, please configure one.
2008-09-29 14:43:38.250::INFO:  Extract jar:file:/C:/devtool/jetty-6.1.11/webapps/
cometd.war!/ to C:\Users\madvirus\AppData\Local\Temp\Jetty_0_0_0_0_8080_cometd.war__cometd__-
t2qfkl\webapp
2008-09-29 14:43:39.019::WARN:  Unknown realm: Test JAAS Realm
2008-09-29 14:43:39.142::INFO:  Opened C:\devtool\jetty-6.1.11\logs\2008_09_29.request.log
2008-09-29 14:43:39.181::INFO:  Started SelectChannelConnector@0.0.0.0:8080

Jetty를 시작한 뒤 웹 브라우저를 띄워서 http://localhost:8080 을 이용해서 Jetty 서버에 접속하면 다음과 같은 기본 화면을 볼 수 있다.


Jetty를 종료하고 싶을 때에는 Jetty를 구동한 콘솔(도스창)에서 Ctrl+C를 누르면 된다.

Jetty를 실행하는 콘솔과 Jetty를 종료하는 콘솔이 다를 수도 있는데, 이런 경우에는 다음과 같이 STOP.PORT 시스템 프로퍼티와 STOP.KEY 시스템 프로퍼티를 이용해서 Jetty를 시작하면 된다.

java -DSTOP.PORT=8079 -DSTOP.KEY=secret -jar start.jar etc/jetty.xml

STOP.PORT 시스템 프로퍼티는 Jetty 종료 메시지를 기다릴 때 사용할 포트 번호이며, STOP.KEY 시스템 프로퍼티는 올바른 종료 메시지인지의 여부를 구분할 때 사용할 키 값이다. Jetty를 구동한 콘솔이 아닌 다른 콘솔에서 Jetty를 종료할 때에는 Jetty를 시작할 때 명시한 명시한 포트 번호와 키 값, 그리고 --stop 명령행 인자를 사용하여 종료 메시지를 전달할 수 있다.

java -DSTOP.PORT=8079 -DSTOP.KEY=secret -jar start.jar --stop

만약 STOP.PORT 시스템 프로퍼티만 명시하고 STOP.KEY 프로퍼티는 생략한 경우, STOP.KEY 프로퍼티의 값을 임의로 생성해서 표준 출력(콘솔)에 표시한다. 또한, STOP.PORT 프로퍼티의 값을 0으로 지정한 경우, 임의의 포트를 할당한 뒤 표준 출력에서 할당한 포트 값을 표시한다.

컨텍스트 배포(추가)

Jetty 배포판에 포함된 etc/jetty.xml 파일을 사용할 경우, 다음의 두 가지 방법을 이용해서 새로운 컨텍스트를 추가할 수 있다.

  • [JETTY홈]\webapps\ 디렉토리에 war 파일이나 웹 어플리케이션 디렉토리 생성하기
  • [JETTY홈]\contexts\ 디렉토리에 새로운 컨텍스트 설정 파일 추가히기/li>
webapps에 배포하기

webapps 디렉토리에 배포할 경우 war 파일이나 zip 파일을 webapps 디렉토리에 복사해주면 된다. 또는, webapps 디렉토리에 웹 어플리케이션 디렉토리를 복사해주어도 된다. war 파일이나 웹 어플리케이션 디렉토리를 추가한 뒤에는 Jetty를 새로 시작해 주어야 한다. 즉, webapps 디렉토리에는 Hot Deploy가 적용되지 않는다.

Jetty 배포판에 기본적으로 포함된 etc/jetty.xml 파일을 보면 다음과 같은 설정 부분을 확인할 수 있다.

    <Call name="addLifeCycle">
      <Arg>
        <New class="org.mortbay.jetty.deployer.WebAppDeployer">
          <Set name="contexts"><Ref id="Contexts"/></Set>
          <Set name="webAppDir"><SystemProperty name="jetty.home" default="."/>/webapps</Set>
          <Set name="parentLoaderPriority">false</Set>
          <Set name="extract">true</Set>
          <Set name="allowDuplicates">false</Set>
          <Set name="defaultsDescriptor"><SystemProperty name="jetty.home" default="."/>/etc/webdefault.xml</Set>
        </New>
      </Arg>
    </Call>

위 코드는 웹 어플리케이션 디렉토리를 설정하는 부분으로서, 위 코드의 경우 Jetty의 홈디렉토리에 위치한 webapps 디렉토리를 웹 어플리케이션 디렉토리(webAppDir)로 설정하고 있다. WebAppDeployer는 지정한 디렉토리에 위치한 war/zip 파일이나 하위 디렉토리를 웹 어플리케이션으로 배포해주는 기능을 제공하며, 주요 프로퍼티는 다음과 같다.

  • webAppDir - 웹 어플리케이션을 검색할 디렉토리. 파일 경로나 URL로 명시한다. 이 디렉토리에 위치한 .war 파일이나 .zip 파일이 웹 어플리케이션 컨텍스트로 배치된다. 또한, 이름이 CVS가 아닌 디렉토리도 웹 어플리케이션 컨텍스트로 배치된다. 이때, 파일명이나 디렉토리 이름이 컨텍스트의 경로명으로 사용되며, 이름이 "root"인 경우에는 루트 컨텍스트(/)로 사용된다.
  • parentLoaderPriority - 클래스를 로딩할 때 부모 클래스로더에게 먼저 요청할지 아니면 웹 어플리케이션 클래스로더에서 먼저 로딩할지의 여부를 결정한다.
  • extract - 이 값이 true일 경우 war나 zip 파일을 배치하기 전에 먼저 임시 디렉토리에 압축을 푼다.
  • allowDuplicates - 이 값이 false일 경우 동일한 컨텍스트 경로나 war 파일이 이미 배치된 경우, 다시 배치하지 않는다.
  • defaultsDescriptor - 웹 어플리케이션을 설정할 때 사용할 (기본 값을 갖는) 설정 파일을 지정한다.
webapps 디렉토리에 루트 컨텍스트를 배포하고 싶다면 root.war 파일이나 root 디렉토리를 생성한 뒤 웹 어플리케이션을 복사해주면 된다.

contexts에 배포하기

웹 어플리케이션을 배포하는 두번째 방법은 context 디렉토리에 웹 어플리케이션 배치 설정 파일을 추가하는 것이다. etc/jetty.xml 파일을 보면 다음과 같은 설정 부분을 찾을 수 있다.

    <Call name="addLifeCycle">
      <Arg>
        <New class="org.mortbay.jetty.deployer.ContextDeployer">
          <Set name="contexts"><Ref id="Contexts"/></Set>
          <Set name="configurationDir"><SystemProperty name="jetty.home" default="."/>/contexts</Set>
          <Set name="scanInterval">1</Set>
        </New>
      </Arg>
    </Call>

위 코드에서 configurationDir 프로퍼티는 컨텍스트 설정 파일을 검색할 디렉토리를 입력한다. 위 설정의 경우 [JETTY홈]/contexts 디렉토리에 위치하는 컨텍스트 설정 파일을 이용해서 컨텍스트를 추가하게 된다. scanInterval 프로퍼티는 디렉토리 검색 주기를 초 단위로 입력한다. 지정한 시간 단위로 컨텍스트 설정 파일의 추가, 변경, 삭제 여부를 검사하여 이에 맞춰 컨텍스트를 추가하거나 변경하거나 제거한다.

configurationDir 프로퍼티에서 지정한 디렉토리에 위치하는 xml 파일들은 각각 개별 컨텍스트 정보를 설정한다. 웹 어플리케이션을 추가할 때에는 다음과 같은 형식의 설정 파일을 추가해주면 된다.

    <?xml version="1.0"  encoding="ISO-8859-1"?>
    <!DOCTYPE Configure PUBLIC "-//Mort Bay Consulting//DTD Configure//EN" "http://jetty.mortbay.org/configure.dtd">
    
    
    <Configure class="org.mortbay.jetty.webapp.WebAppContext">
    
      <Set name="contextPath">/</Set>
      <Set name="war"><SystemProperty name="jetty.home" default="."/>/webapps/hello.war</Set>
    
    </Configure>

contestPath 프로퍼티는 배포될 컨텍스트의 경로를 입력한다. 루트 컨텍스트인 경우 경로 값으로 '/'를 주면 된다. 다른 컨텍스트는 '/contextpath' 형식으로 컨텍스트 경로를 입력해주면 된다. war 프로퍼티에는 war 파일이나 웹 어플리케이션을 포함하고 있는 디렉토리 경로를 지정해주면 된다. 예를 들어, [JETTY홈]/webapps/hello 디렉토리에 웹 어플리케이션을 배포했다면 다음과 같이 war 프로퍼티의 값을 지정하면 된다.

    <Set name="war"><SystemProperty name="jetty.home" default="."/>/webapps/hello</Set>

맺음말

Jetty의 주요 특징으로는 다음과 같은 것들이 있다.

  • Ant, Maven, 이클립스 툴과의 연동을 지원하기 때문에, 개발 효율성을 증가시켜준다.
  • 간단한 Embedding. 코드에서 직접 Jetty 서버를 실행할 수 있다.
  • Comet 지원
  • 그외 가상 호스트, 응답 데이터 압축, SSL, 아파치 연동, JMX 연동 등 지원
Jetty는 매우 가벼운 웹 컨테이너이고 사용 방법도 매우 간단하다. 또한, Ant/Maven/이클립스 툴과 연동되기 때문에 개발을 효율성을 증가시켜준다. 필자 또한 개발 과정에서 Maven과 Jetty를 연동할 때 테스트 과정의 편리함을 느낄 수 있었다. 만약 톰캣의 대안을 찾고 있다면 Jetty를 고려해보기 바란다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

CommonJ의 Work Manager API에 대해 살펴보고, 쓰레드풀 기반의 WorkManager를 구현해본다.

Work Manager API 소개

CommonJ의 Work Manager API는 BEA와 IBM이 함께 정의한 API로서 서블릿이나 EJB와 같은 환경에서 Concurrent 프로그래밍 API를 제공하기 위한 목적으로 만들어졌다. Work Manager API는 콘테이너의 관리 하에 어플리케이션이 다수의 작업을 동시에 수행할 수 있도록 해 주는 고수준의 모델을 제공한다. 즉, 서블릿 콘테이너나 EJB 컨테이너에 의해 관리되는 환경에서 Thread를 직접 사용하지 않고 쓰레드 프로그래밍을 할 수 있도록 도와준다.

Thread를 직접 사용하지 않고 쓰레드 프로그래밍을 한다는 점에서 Work Manager API는 자바 5에 새롭게 추가된 Executor 인터페이스와 Callable/Future와 비슷하다. 하지만, 자바 5의 Executor 및 Callable/Future가 작업을 실행하는 것에 초점이 맞춰진 모델이라면, Work Manager API는 작업 실행 뿐만 아니라 작업 수용, 완료, 거절 등 작업을 관리하는 기능이 추가된 모델이다. 따라서, 좀 더 정교한 작업 관리 모델을 원한다면 Work Manager API를 사용하는 것을 고려해 볼 수 있다.

Work Manager API의 주요 구성 요소

Work Manager API의 주요 인터페이스는 다음 그림과 같다.


WorkManager 인터페이스는 작업을 관리하는 데 사용되는 메서드를 제공하고 있다. WorkManager.schedule() 메서드는 schedule() 메서드는 수행될 작업 정보를 담고 있는 Work 객체를 전달받으며, 작업의 상태를 확인할 수 있는 WorkItem 객체를 리턴한다. WorkManager 인터페이스를 구현한 클래스는 알맞은 전략에 따라서 schedule() 메서드를 통해 전달받은 Work 객체를 실행하면 된다. 예를 들어, WorkManager는 schedule() 메서드를 통해서 전달받은 Work 객체를 큐에 넣은 뒤 순차적으로 Work 객체를 실행할 수 있을 것이다.

WorkManager의 waitForAll() 메서드는 첫 번째 파라미터로 전달받은 콜렉션에 포함된 WorkItem 목록과 관련된 모든 작업이 끝날 때 까지 대기한다. 만약 지정한 대기 시간안에 콜렉션으로 전달한 작업이 끝나면 waitForAll() 메서드는 true를 리턴하고, 그렇지 않을 경우 false를 리턴한다. waitForAny() 메서드의 경우는 콜렉션과 관련된 작업 중 한 개 이상의 작업이 종료될 때 까지 대기한다. 대기 시간 안에 한 개 이상의 작업이 종료되면 종료된 작업과 관련된 WorkItem을 담은 콜렉션을 리턴하며, 종료된 작업이 없을 경우 크기가 0인 콜렉션을 리턴한다.

waitForAll() 메서드와 waitForAny() 메서드의 두 번째 파라미터는 1/1000초 단위의 대기 시간을 의미하며, 아래의 두 값은 WorkManager에 정의된 상수값으로서 특별한 의미를 갖는다.

  • WorkManager.IMMEDIATE: 완료된 작업이 존재하는 지 검사를 수행하고 곧 바로 결과를 리턴한다.
  • WorkManager.INDEFINITE: 무한정 대기한다. waitForAll()의 경우는 모든 작업이 종료될 때 까지 대기하며, waitForAny()의 경우는 한 개 이상의 작업이 종료될 때 까지 대기한다.
Work 인터페이스는 작업을 표현한다. Work는 Runnable 인터페이스를 상속받고 있으며 작업을 실행하는 코드는 run() 메서드에 위치하게 된다. isDaemon() 메서드는 이 작업이 데몬인지의 여부를 리턴한다. isDaemon() 메서드가 true를 리턴할 경우 해당 작업은 데몬으로 실행되어야 한다는 것을 의미하며, 따라서 WorkManager는 알맞게 해당 작업을 실행해 주어야 한다. release() 메서드는 Work의 작업을 종료할 때 호출되는 메서드로서 작업을 수행하는 동안 사용한 자원 등을 이 메서드에서 반납하면 된다.

WorkItem 인터페이스는 WorkManager.schedule() 메서드가 리턴하는 객체로서, WorkManager에 의해 관리되는 Work의 상태를 확인할 때 사용된다. getResult() 메서드는 연관된 Work 객체를 리턴한다. getStatus() 메서드는 Work의 작업 진행 상태 값을 리턴하며, 진행 상태와 관련된 상수값은 WorkEvent 클래스에 정의되어 있다. 아래 그림은 WorkEvent 클래스에 정의된 상수를 보여주고 있다.


Work Manager API를 사용하는 전형적인 코드 형태는 다음과 같다.

WorkManager workManager = ...; // 사용할 WorkManager를 구함

Work myWork1 = new SomeWork();
Work myWork2 = new SomeWork();

// WorkManager에 작업 실행 요청
WorkItem workItem1 = workManager.schedule(myWork1);
WorkItem workItem2 = workManager.schedule(myWork2);

List<WorkItem> workItems = new ArrayList();
workItems.add(workItem1);
workItems.add(workItem2);

// 작업이 종료될 때 까지 대기
boolean allCompleted = workManager.waitForAll(workItems, WorkManager.INDEFINITE);

if (allCompleted) {
    for (WorkItem workItem : workItems) {
        SomeWork work = (SomeWork)workItem.getResult();
        // 알맞은 후속 처리
    }
} else {
    ...
}

웹스피어나 웹로직과 같은 WAS 서버는 WorkManger 객체를 JNDI로부터 구할 수 있도록 하고 있기 때문에, 이 WAS 서버를 사용할 경우 개발자는 Work 구현 클래스만 알맞게 작성해 주면 WorkManager를 통해서 작업을 실행할 수 있게 된다. 아래 코드는 JNDI로부터 WorkManager를 구하는 코드의 예를 보여주고 있다.

InitialContext ctx = new InitialContext();
String jndiName = "java:comp/env/wm/default";
workManager = (WorkManager)ctx.lookup(jndiName);

WorkListener

앞서 WorkEvent 클래스 다이어그램을 보면 작업은 'ACCEPTED', 'REJECTED', 'STARTED', 'COMPLETED'의 네 가지 상태를 가질 수 있음을 알 수 있다. WorkManager는 작업의 상태가 변경될 때, 관련 이벤트를 이벤트 리스너에 전달하는 방법을 제공하기 위해 다음과 같은 schedule() 메서드를 제공하고 있다.

WorkItem schedule(Work work, WorkListener wl)

위 schedule() 메서드는 첫 번째 파라미터로 전달받은 Work 객체의 상태가 변경될 때 마다 WorkListener에 통지해 주는 기능을 제공한다. 예를 들어, 전달받은 작업의 상태가 ACCEPTED에서 STARTED로 변경되거나, STARTED에서 COMPLETED로 변경될 경우 WorkListener의 알맞은 메서드를 호출해줌으로써 WorkListener가 상태 변화를 통지 받을 수 있도록 하고 있다. 아래 그림은 WorkListener 인터페이스가 어떻게 정의되어 있는 지를 보여주고 있다.


Work Manager API 구현 클래스 직접 만들기

WAS가 제공하는 WorkManager 객체를 이용해서 Work를 실행할 수 있지만, Work Manager API를 직접 구현할 수도 있다. 본 글에서는 자바 5의 Concurrent API를 이용해서 작업을 동시에 실행해주는 WorkManager 구현 클래스를 작성해 보겠다.

Work Manager API를 직접 구현한다는 것은 아래의 인터페이스를 구현하다는 것과 같다.

  • WorkManager 인터페이스
  • WorkItem 인터페이스
  • WorkEvent 인터페이스
WorkManager 인터페이스 구현

본 글에서는 Java Concurrent API가 제공하는 쓰레드 풀 API를 이용해서 WorkManager 인터페이스를 구현할 것이다. 아래 코드는 실제 구현 코드이다.

package net.madvirus.javacan.commonj.wm;

...

/**
 * 쓰레드 풀을 사용해서 작업을 실행한다.
 * 
 * waitForAll() 메서드와 waitForAny() 메서드는 Jonas Boner가 작성한 코드를 참고하여 작성하였다.
 * 
 * @author madvirus
 */
public class ThreadPoolWorkManager implements WorkManager {
    public static final int DEFAULT_POOL_SIZE = 10;

    private ExecutorService executorService;

    public ThreadPoolWorkManager() {
        this(DEFAULT_POOL_SIZE);
    }

    public ThreadPoolWorkManager(int poolSize) {
        executorService = Executors.newFixedThreadPool(poolSize);
    }

    public WorkItem schedule(Work work) throws IllegalArgumentException {
        return schedule(work, null);
    }

    public WorkItem schedule(final Work work, final WorkListener listener)
            throws IllegalArgumentException {
        if (work.isDaemon()) {
            throw new IllegalArgumentException("damon work not supported");
        }

        final DefaultWorkItem workItem = new DefaultWorkItem(work, listener);
        executorService.execute(new Runnable() {
            public void run() {
                try {
                    workItem.setStatus(WorkEvent.WORK_STARTED, null);
                    work.run();
                    workItem.setStatus(WorkEvent.WORK_COMPLETED, null);
                } catch (Throwable e) {
                    workItem.setStatus(WorkEvent.WORK_REJECTED,
                            new WorkException(e));
                }
            }
        });
        return workItem;
    }
    ...

schedule() 메서드는 파라미터로 전달받은 work 객체로부터 DefaultWorkItem 객체를 생성한다. 그런 뒤, ExecutorService를 이용해서 작업을 실행한다. ExecutorService에 전달되는 Runnable 타입의 임의 객체는 DefaultWorkItem의 setStatus() 메서드를 사용해서 작업의 상태를 변경하고, Work.run() 메서드를 호출함으로써 작업을 실행하게 된다.

WorkManager의 waitForAll() 및 waitForAny() 메서드는 다음과 같이 구현하였다. 참고로, 이 코드는 Jonas Boner가 작성한 코드를 그대로 사용하였다.

    @SuppressWarnings("unchecked")
    public boolean waitForAll(Collection workItems, long timeout)
            throws InterruptedException, IllegalArgumentException {
        long start = System.currentTimeMillis();
        do {
            synchronized (this) {
                boolean isAllCompleted = true;
                for (Iterator it = workItems.iterator(); it.hasNext()
                        && isAllCompleted;) {
                    int status = ((WorkItem) it.next()).getStatus();
                    isAllCompleted = status == WorkEvent.WORK_COMPLETED
                            || status == WorkEvent.WORK_REJECTED;
                }
                if (isAllCompleted) {
                    return true;
                }
                if (timeout == IMMEDIATE) {
                    return false;
                }
                if (timeout == INDEFINITE) {
                    continue;
                }
            }
        } while ((System.currentTimeMillis() - start) < timeout);
        return false;
    }

    @SuppressWarnings("unchecked")
    public Collection waitForAny(Collection workItems, long timeout)
            throws InterruptedException, IllegalArgumentException {
        long start = System.currentTimeMillis();
        do {
            synchronized (this) {
                Collection<WorkItem> completed = new ArrayList<WorkItem>();
                for (Iterator it = workItems.iterator(); it.hasNext();) {
                    WorkItem workItem = (WorkItem) it.next();
                    if (workItem.getStatus() == WorkEvent.WORK_COMPLETED
                            || workItem.getStatus() == WorkEvent.WORK_REJECTED) {
                        completed.add(workItem);
                    }
                }
                if (!completed.isEmpty()) {
                    return completed;
                }
            }
            if (timeout == IMMEDIATE) {
                return Collections.EMPTY_LIST;
            }
            if (timeout == INDEFINITE) {
                continue;
            }
        } while ((System.currentTimeMillis() - start) < timeout);
        return Collections.EMPTY_LIST;
    }
}

WorkItem과 WorkEvent 구현

ThreadPoolWorkManager 클래스는 내부적으로 DefaultWorkItem 클래스를 사용하였는데, DefaultWorkItem 클래스는 다음과 같이 구현되었다. 참고로, DefaultWorkItem 클래스와 곧 이어 보여줄 DefaultWorkEvent 클래스 역시 ThreadPoolWorkManager의 waitForAll()/waitForAny() 메서드와 마찬가지로 Jonas Boner가 작성한 코드를 그대로 사용하였다.

public class DefaultWorkItem implements WorkItem {

    protected int status;
    protected Work work;
    protected WorkListener workListener;

    public DefaultWorkItem(Work work, WorkListener workListener) {
        this.work = work;
        this.status = WorkEvent.WORK_ACCEPTED;
        this.workListener = workListener;
    }

    public Work getResult() {
        return work;
    }

    public synchronized void setStatus(final int status,
            final WorkException exception) {
        this.status = status;
        if (workListener != null) {
            switch (status) {
            case WorkEvent.WORK_ACCEPTED:
                workListener.workAccepted(new DefaultWorkEvent(
                        WorkEvent.WORK_ACCEPTED, this, exception));
                break;
            case WorkEvent.WORK_REJECTED:
                workListener.workRejected(new DefaultWorkEvent(
                        WorkEvent.WORK_REJECTED, this, exception));
                break;
            case WorkEvent.WORK_STARTED:
                workListener.workStarted(new DefaultWorkEvent(
                        WorkEvent.WORK_STARTED, this, exception));
                break;
            case WorkEvent.WORK_COMPLETED:
                workListener.workCompleted(new DefaultWorkEvent(
                        WorkEvent.WORK_COMPLETED, this, exception));
                break;
            }
        }
    }

    public synchronized int getStatus() {
        return status;
    }

    @SuppressWarnings("unchecked")
    public int compareTo(Object compareTo) {
        Work work = ((WorkItem) compareTo).getResult();
        if (work instanceof Comparable) {
            Comparable comparableWork1 = (Comparable) work;
            if (work instanceof Comparable) {
                Comparable comparableWork2 = (Comparable) work;
                return comparableWork1.compareTo(comparableWork2);
            }
        }
        return 0;
    }

    public String toString() {
        String statusLabel = null;
        switch (this.status) {
        case WorkEvent.WORK_ACCEPTED:
            statusLabel = "WORK_ACCEPTED";
            break;
        case WorkEvent.WORK_COMPLETED:
            statusLabel = "WORK_COMPLETED";
            break;
        case WorkEvent.WORK_REJECTED:
            statusLabel = "WORK_REJECTED";
            break;
        case WorkEvent.WORK_STARTED:
            statusLabel = "WORK_STARTED";
            break;
        default:
            throw new IllegalStateException("illegal (unknown) status "
                    + statusLabel);
        }
        return work.toString() + ":" + statusLabel;
    }

}

DefaultWorkItem 클래스는 setStatus() 메서드를 제공하고 있으며, 이 메서드를 사용해서 작업의 상태를 변경할 수 있도록 하고 있다. setStatus() 메서드는 작업의 상태가 변경되면, 관련 WorkListener에 변경 내역을 통지한다. 이때 전달되는 WorkEvent의 구현 클래스인 DefaultWorkEvent 클래스는 다음과 같다.

public class DefaultWorkEvent implements WorkEvent {

    private int type;
    private WorkItem workItem;
    private WorkException exception;

    public DefaultWorkEvent(int type, WorkItem item, WorkException exception) {
        this.type = type;
        this.workItem = item;
        this.exception = exception;
    }

    public int getType() {
        return type;
    }

    public WorkItem getWorkItem() {
        return workItem;
    }

    public WorkException getException() {
        return exception;
    }
}

ThreadPoolWorkManager를 이용하여 작업 관리하기

이제 남은 작업 앞서 구현한 WorkManager를 사용해서 작업을 실행하는 것이다. Work 인터페이스를 구현해서 작업을 실행하는 클래스를 만들고, 해당 클래스의 객체를 생성한 뒤 WorkManager의 schedule() 메서드에 전달만 하면 된다. 아래 코드는 사용 예이다.

WorkManager workManager = new ThreadPoolWorkManager(5);
WorkItem workItem1 = workManager.schedule(someWork1);
WorkItem workItem2 = workManager.schedule(someWork2);

List<WorkItem> workItems = new ArrayList<WorkItem>();
workItems.add(workItem1);
workItems.add(workItem2);

boolean allCompleted = workManager.waitForAll(workItems, WorkManager.INDEFINITE);
if (allCompleted) {
    for (WorkItem workItem : workItems) {
        SomeWork someWork = ((SomeWork)workItem.getResult());
        ...
    }
}

Work Manager API가 자바 5부터 새롭게 추가된 Concurrent API와 비슷한 기능을 (즉, 여러 작업을 동시에 실행하는 기능을) 제공하고 있지만, Work Manager API는 작업의 라이프 사이클을 추가로 제공하고 있어 작업에 대한 정교한 처리가 가능하다는 장점을 갖고 있다. 또한, 작업의 상태가 변경될 경우 WorkListener를 통해서 변경 관련 이벤트를 전달받을 수 있기 때문에, WorkManager가 작업을 거부하거나 또는 작업이 실패할 경우 작업을 손쉽게 재시도 할 수 있다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

자바 5부터 새롭게 추가된 Concurrency API 중에서 Executor, 리턴이 가능한 Callable 및 Future에 대해서 살펴본다.

Executor를 이용한 쓰레드 관리

웹 서버와 같이 동시에 다수의 요청을 처리해야 하는 어플리케이션을 개발해야 할 경우 코드는 다음과 같은 형태를 띌 것이다.

while(true) {
    request = acceptRequest();
    Runnable requestHandler = new RequestHandler(request);
    new Thread(requestHandler).start();
}

위 코드가 논리적으로 문제점은 없지만, 다음과 같은 성능상의 문제점을 안고 있다.

  • 소규모의 많은 요청이 들어올 경우 쓰레드 생성 및 종료에 따른 오버헤드가 발생한다.
  • 생성되는 쓰레드 개수에 제한이 없기 때문에 OutOfMemoryError가 발생할 수 있다.
  • 많은 수의 쓰레드가 실행될 경우, 쓰레드 스케줄링에 따른 오버헤드가 발생한다.
이런 문제점 때문에 동시에 다수의 요청을 처리해야 하는 어플리케이션에서는 쓰레드 풀을 사용하여 동시에 실행될 수 있는 쓰레드의 개수를 제한하는 것이 일반적이다.

자바5부터 새롭게 추가된 Concurrency API는 작업을 실행하기 위한 Executor 인터페이스를 제공하고 있으며, 쓰레드 풀, 큐 등 다양한 Executor 구현체를 제공하고 있다. 따라서, 앞서 Thread를 직접 사용할 때의 문제점을 해소할 수 있게 되었다.

Executor를 이용한 쓰레드 관리

Executor와 관련된 주요 API는 다음과 같다.


위 클래스 다이어그램에서 각 인터페이스는 다음과 같은 기능을 정의한다.

  • Executor 인터페이스:
    제공된 작업(Runnable 구현체)을 실행하는 객체가 구현해야 할 인터페이스. 이 인터페이스는 작업을 제공하는 코드와 작업을 실행하는 메커니즘의 사이의 커플링을 제거해준다.
  • ExecutorService 인터페이스:
    Executor의 라이프사이클을 관리할 수 있는 기능을 정의하고 있다. Runnable 뿐만 아니라 Callable을 작업으로 사용할 수 있는 메소드가 추가로 제공된다.
  • ScheduledExecutorService:
    지정한 스케쥴에 따라 작업을 수행할 수 있는 기능이 추가되었다.
Executor 인터페이스

java.util.concurrent.Executor 인터페이스는 작업을 실행하는 클래스가 구현해야 할 인터페이스로서, 다음과 같이 정의되어 있다.

public interface Executor {
    void execute(Runnable command);
}

Executor 인터페이스의 구현체는 execute() 메소드로 전달받은 작업(Runnable 인스턴스)을 알맞게 실행하게 된다. 예를 들어, 쓰레드 풀을 구현한 Executor 구현체는 전달받은 작업을 큐에 넣은 뒤 가용한 쓰레드가 존재할 경우, 해당 쓰레드에 작업을 실행하도록 구현될 것이다.

아래 코드는 앞서 Thread를 사용했던 코드를 Executor를 사용하는 코드로 변환한 것이다.

Executor executor = …; // Executor 구현체 생성
while(true) {
    request = acceptRequest();
    Runnable requestHandler = new RequestHandler(request);
    executor.execute(requestHandler);
}

위 코드는 작업을 생성만 할 뿐 실제로 작업을 실행하지는 않는다. 단지, Executor.execute() 메소드에 생성한 작업을 전달할 뿐이다. 작업을 실제로 실행하는 책임은 Executor에 있으며, 이제 작업을 생성하는 코드에서는 작업이 어떻게 실행되는 지의 여부는 알 필요가 없다. 즉, Executor를 사용함으로써 작업을 생성하는 코드와 작업을 실행하는 메커니즘 사이의 커플링을 없앤 것이다.

ExecutorService 인터페이스

ExecutorService 인터페이스는 다음과 같이 두 가지 종류의 메소드가 추가되었다.

  • Executor의 라이프 사이클을 관리
  • Callable을 작업으로 사용하기 위한 메소드
먼저, 라이프 사이클과 관련된 메소드는 다음과 같다.

  • void shutdown():
    셧다운 한다. 이미 Executor에 제공된 작업은 실행되지만, 새로운 작업은 수용하지 않는다.
  • List<Runnable> shutdownNow():
    현재 실행중인 모든 작업을 중지시키고, 대기중인 작업을 멈추고, 현재 실행되기 위해 대기중인 작업 목록을 리턴한다.
  • boolean isShutdown():
    Executor가 셧다운 되었는 지의 여부를 확인한다.
  • boolean isTerminated():
    셧다운 실행 후 모든 작업이 종료되었는 지의 여부를 확인한다.
  • boolean awaitTermination(long timeout, TimeUnit unit):
    셧다운을 실행한 뒤, 지정한 시간 동안 모든 작업이 종료될 때 까지 대기한다. 지정한 시간 이내에서 실행중인 모든 작업이 종료되면 true를 리턴하고, 여전히 실행중인 작업이 남아 있다면 false를 리턴한다.
예를 들어, 웹 서버를 종료하게 되면 더 이상 클라이언트의 요청을 받아서는 안 되고, 기존에 처리중이던 요청은 지정한 시간내에서 처리해야 할 것이다. 이런 경우 ExecutorService를 사용하면 다음과 같이 종료 과정을 코딩할 수 있다.

executor.shutdown();
try {
    if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
        System.out.println("아직 처리중인 작업 존재");
        System.out.println("작업 강제 종료 실행");
        executor.shutdownNow();
        if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
            System.out.println("여전히 종료하지 않은 작업 존재");
        }
    }
} catch (InterruptedException e1) {
    executor.shutdownNow();
    Thread.currentThread().interrupt();
}
System.out.println("서버 셧다운 완료");

ExecutorService 인터페이스는 작업 수행과 관련해서 추가적으로 메소드를 제공하고 있으며, 이들 메소드는 다음과 같다.

  • <T> Future<T> submit(Callable<T> task)
    결과값을 리턴하는 작업을 추가한다.
  • Future<?> submit(Runnable task)
    결과값이 없는 작업을 추가한다.
  • <T> Future<T> submit(Runnable task, T result)
    새로운 작업을 추가한다. result는 작업이 성공적으로 수행될 때 사용될 리턴 값을 의미한다.
  • <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
    주어진 작업을 모두 실행한다. 각 실행 결과값을 구할 수 있는 Future의 List를 리턴한다.
  • <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
    앞서 invokeAll()과 동일하다. 지정한 시간 동안 완료되지 못한 작업은 취소되는 차이점이 있다.
  • <T> T invokeAny(Collection<? extends Callable<T>> tasks)
    작업울 수행하고, 작업 결과 중 성공적으로 완료된 것의 결과를 리턴한다. 정상적으로 수행된 결과가 발생하거나 예외가 발생하는 경우 나머지 완료되지 않은 작업은 취소된다.
  • <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit)
    invokeAny()와 동일하다. 지정한 시간 동안만 대기한다는 차이점이 있다.
Callable은 Runnable과 비슷한데, 차이점이 있다면 결과 값을 리턴할 수 있다는 점이다. Future는 Callable의 결과값을 구하는 데 사용된다. Callable과 Future에 대해서는 뒤에서 자세히 살펴보도록 하자.

ScheduledThreadPoolExecutor 클래스

ScheduledThreadPoolExecutor 클래스는 ScheduledExecutorService 인터페이스을 구현한 클래스로서 스케줄링 기능을 제공한다. ScheduledThreadPoolExecutor 클래스는 또한 쓰레드 풀을 구현하고 있는 ThreadPoolExecutor 클래스를 상속받고 있기 때문에 쓰레드 풀 기능도 함께 제공한다.

ScheduledThreadPoolExecutor 클래스에 대한 자세한 내용은 아래 사이트를 참고하기 바란다.

  • http://java.sun.com/javase/6/docs/api/java/util/concurrent/ScheduledThreadPoolExecutor.html
Executors 유틸리티 클래스

java.util.concurrent.Executors 클래스는 자바 5가 기본적으로 제공하는 Executor 구현체를 구할 수 있는 메소드를 제공하는 유틸리티이다. 예를 들어, 쓰레드 풀을 구현한 Executor 구현체를 구하고 싶다면 다음과 같은 코드를 사용하면 된다.

Executor executor = Executors.newFixedThreadPool(THREADCOUNT);
while(true) {
    request = acceptRequest();
    Runnable requestHandler = new RequestHandler(request);
    executor.execute(requestHandler);
}

Executors 클래스는 newFixedThreadPool()과 더불어 다음과 같은 메소드를 이용하여 Executor 인스턴스를 구할 수 있도록 하고 있다.

  • ExecutorService newFixedThreadPool(int nThreads)
    최대 지정한 개수 만큼의 쓰레드를 가질 수 있는 쓰레드 풀을 생성한다. 실제 생성되는 객체는 ThreadPoolExecutor 객체이다.
  • ScheduledExecutorService newScheduledThreadPool(int corePoolSize)
    지정한 개수만큼 쓰레드가 유지되는 스케줄 가능한 쓰레드 풀을 생성한다. 실제 생성되는 객체는 ScheduledThreadPoolExecutor 객체이다.
  • ExecutorService newSingleThreadExecutor()
    하나의 쓰레드만 사용하는 ExecutorService를 생성한다.
  • ScheduledExecutorService newSingleThreadScheduledExecutor()
    하나의 쓰레드만 사용하는 ScheduledExecutorService를 생성한다.
  • ExecutorService newCachedThreadPool()
    필요할 때 마다 쓰레드를 생성하는 쓰레드 풀을 생성한다. 이미 생성된 쓰레드의 경우 재사용된다. 실제 생성되는 객체는 ThreadPoolExecutor 객체이다.
Callable과 Future

Runnable 인터페이스는 다음과 같이 정의되어 있다.

public interface Runnable {
    public void run();
}

위 코드에서 볼 수 있듯이 Runnable.run() 메소드는 결과 값을 리턴하지 않기 때문에, run() 메소드의 실행 결과를 구하기 위해서는 공용 메모리나 파이프와 같은 것들을 사용해서 결과 값을 받아야만 했다. 이런 Runnable 인터페이스의 단점을 없애기 위해 추가된 것이 바로 Callable 인터페이스이다.

java.util.concurrent.Callable 인터페이스는 다음과 같이 정의되어 있다.

public Interface Callable<V> {
    V call() throws Exception
}

Callable 인터페이스의 call() 메소드는 결과 값을 리턴하도록 되어 있다. 또한, 자바 5부터 추가된 generic을 사용하여 어떤 타입이든 리턴 값으로 사용할 수 있도록 하였다. 예를 들어, 아래 코드는 Callable 인터페이스를 구현한 간단한 클래스를 보여주고 있다.

public class CallableImpl implements Callable<Integer> {
    public Integer call() throws Exception {
        // 작업 처리
        return result;
    }
}

ExecutorService.submit() 메소드를 사용하면 CallableImpl 클래스를 작업으로 사용할 수 있다. 아래 코드는 ExecutorService.submit() 메소드의 실행 예이다.

ExecutorService executor = Executors.newFixedThreadPool(THREADCOUNT);

Future<Integer> future = executor.submit(new CallableImpl());
Integer result = future.get();

ExecutorService.submit() 메소드는 전달받은 Callable 객체를 내부 메커니즘에 따라 지정한 때에 실행한다. 예를 들어, 위 경우 CallableImpl 객체는 큐에 저장되었다가 가용한 쓰레드가 생길 때 CallblaImpl.call() 메소드가 실행될 것이다.

Callable.call() 메소드가 ExecutorService.submit() 메소드에 전달될 때 곧 바로 실행되는 것이 아니기 때문에 리턴값을 바로 구할 수 없다. 이 리턴값은 미래의 어느 시점에 구할 수 있는데, 이렇게 미래에 실행되는 Callable의 수행 결과 값을 구할 때 사용되는 것이 Future이다. Future.get() 메소드는 Callable.call() 메소드의 실행이 완료될 때 까지 블록킹되며, Callable.call() 메소드의 실행이 완료되면 그 결과값을 리턴한다.

Future 인터페이스는 get() 메소드 외에도 다음과 같은 메소드를 제공하고 있다.

  • V get()
    Callable 등 작업의 실행이 완료될 때 까지 블록킹 되며, 완료되면 그 결과값을 리턴한다.
  • V get(long timeout, TimeUnit unit)
    지정한 시간 동안 작업의 실행 결과를 기다린다. 지정한 시간 내에 수행이 완료되면 그 결과값을 리턴한다. 대기 시간이 초과되면 TimeoutException을 발생시킨다.
  • boolean cancel(boolean mayInterruptIfRunning)
    작업을 취소한다.
  • boolean isCancelled()
    작업이 정상적으로 완료되기 이전에 취소되었을 경우 true를 리턴한다.
  • boolean isDone()
    작업이 완료되었다면 true를 리턴한다.
관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 2010.11.15 15:26  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

  2. 2011.06.25 13:06  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

  3. 오영감 2013.01.07 18:16 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 감사합니다.

  4. 2014.01.21 19:34 신고  댓글주소  수정/삭제  댓글쓰기

    쉽게 설명되어 있네요 감사합니다 ^^

  5. 2014.08.26 09:53 신고  댓글주소  수정/삭제  댓글쓰기

    정말 좋은글 감사합니다. 덕분에 자바 쓰레드 풀에 대한 기본정리를 하게되었네요

  6. Thread 정복 2015.01.31 16:25 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다~ 스레드 풀에 대해서 쉽게 설명이 되어 있어서 덕분에 빨리 이해되었습니다.

  7. 강신원 2015.08.10 23:01 신고  댓글주소  수정/삭제  댓글쓰기

    잘 정리해 놓으신 글 덕에 어려운 개념을 쉽게 이해하게 되었네요.
    고맙습니다. ^^

  8. 2015.09.18 10:08 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 정보 감사합니다.

  9. 리스 2016.07.11 09:55 신고  댓글주소  수정/삭제  댓글쓰기

    3일만에 소켓 서버 구성을 해야 했는데 덕분에 해결 되었습니다. 감사합니다.

  10. 이창현 2016.09.30 16:09 신고  댓글주소  수정/삭제  댓글쓰기

    정말 큰 도움이 되었습니다! 상당한 내공이 느껴지네요. 정리하시는 능력이 참 뛰어나신것 같아요 ^^ 감사합니다!

  11. fasdgoc 2016.11.14 20:15 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다...

    https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/Future.html

    Future는 어떤 비동기적인 계산에 대한 결과를 나타내는 클래스인데 get이 작업 결과를 얻을 때까지 블로킹한다고 합니다. get을 호출하는 쪽도 별도의 스레드인건가??

EHCache를 이용한 기본적인 캐시 구현 방법 및 분산 캐시 구현 방법을 살펴본다.

EHCache의 주요 특징 및 기본 사용법

게시판이나 블로그 등 웹 기반의 어플리케이션은 최근에 사용된 데이터가 또 다시 사용되는 경향을 갖고 있다. 80:20 법칙에 따라 20%의 데이터가 전체 조회 건수의 80%를 차지할 경우 캐시를 사용함으로써 성능을 대폭적으로 향상시킬 수 있을 것이다.

본 글에서는 캐시 엔진 중의 하나인 EHCache의 사용방법을 살펴보고, Gaia 시스템에서 EHCache를 어떻게 사용했는 지 살펴보도록 하겠다.

EHCache의 주요 특징

EHCache의 주요 특징은 다음과 같다.

  • 경량의 빠른 캐시 엔진
  • 확장(scable) - 메모리 & 디스크 저장 지원, 멀티 CPU의 동시 접근에 튜닝
  • 분산 지원 - 동기/비동기 복사, 피어(peer) 자동 발견
  • 높은 품질 - Hibernate, Confluence, Spring 등에서 사용되고 있으며, Gaia 컴포넌트에서도 EHCache를 사용하여 캐시를 구현하였다.
기본 사용법

EHCache를 사용하기 위해서는 다음과 같은 작업이 필요하다.

  1. EHCache 설치
  2. 캐시 설정 파일 작성
  3. CacheManager 생성
  4. CacheManager로부터 구한 Cache를 이용한 CRUD 작업 수행
  5. CacheManager의 종료
EHCache 설치

EHCache 배포판은 http://ehcache.sourceforge.net/ 사이트에 다운로드 받을 수 있다. 배포판의 압축을 푼 뒤, ehcache-1.2.x.jar 파일이 생성되는 데, 이 파일을 클래스패스에 추가해준다. 또한, EHCache는 자카르타의 commons-logging API를 사용하므로, commons-logging과 관련된 jar 파일을 클래스패스에 추가해주어야 한다.

ehcache.xml 파일

EHCache는 기본적으로 클래스패스에 존재하는 ehcache.xml 파일로부터 설정 파일을 로딩한다. 가장 간단한 ehcache.xml 파일은 다음과 같이 작성할 수 있다.

<ehcache>
    <diskStore path="java.io.tmpdir"/>

    <defaultCache
            maxElementsInMemory="10000"
            eternal="false"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            overflowToDisk="true"
            maxElementsOnDisk="10000000"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="120"
            memoryStoreEvictionPolicy="LRU"
            />
    
    <cache name="simpleBeanCache"
            maxElementsInMemory="10"
            eternal="false"
            overflowToDisk="false"
            timeToIdleSeconds="300"
            timeToLiveSeconds="600"
            memoryStoreEvictionPolicy="LRU" />

</ehcache>

위 코드에서 <defaultCache> 태그는 반드시 존재해야 하는 태그로서, 코드에서 캐시를 직접 생성할 때 사용되는 캐시의 기본 설정값을 저장한다. <cache> 태그는 하나의 캐시를 지정할 때 사용된다. name 속성은 캐시의 이름을 지정하며, 코드에서는 이 캐시의 이름을 사용하여 사용할 Cache 인스턴스를 구한다.

설정 파일에 대한 자세한 내용은 뒤에서 살펴보기로 하자.

CacheManager 생성

ehcache.xml 파일을 작성했다면 그 다음으로 할 작업은 net.sf.ehcache.CacheManager 객체를 생성하는 것이다. CacheManager 객체는 다음의 두 가지 방법 중 한가지 방식을 사용하여 생성할 수 있다.

  • CacheManager.create() : 싱글톤 인스턴스 사용
  • new CacheManager() : 새로운 CacheManager 인스턴스 생성
CacheManager.create() 메소드는 싱글톤 인스턴스를 생성하기 때문에 최초에 한번 호출될 때에만 CacheManager의 초기화 작업이 수행되며, 이후에는 동일한 CacheManager 인스턴스를 리턴하게 된다. 아래는 CacheManager.create() 메소드의 사용 예이다.

CacheManager cacheManager = CacheManager.create();

싱글톤 인스턴스가 아닌 직접 CacheManager 객체를 조작하려면 다음과 같이 new를 사용하여 CacheManager 인스턴스를 생성해주면 된다.

CacheManager cacheManager = new CacheManager();

두 방식 모두 클래스패스에 위치한 ehcache.xml 파일로부터 캐시 설정 정보를 로딩한다.

만약 클래스패스에 위치한 ehcache.xml 파일이 아닌 다른 설정 파일을 사용하고 싶다면 다음과 같이 URL, InputStream, 또는 String(경로) 객체를 사용하여 설정 파일의 위치를 지정할 수 있다.

URL configFile = this.getClass().getResource("/ehcache_config_replicate.xml")
CacheManager cacheManager = new CacheManager(configFile);

Cache에 CRUD 수행

CacheManager 인스턴스를 생성한 다음에는 CacheManager 인스턴스로부터 Cache 인스턴스를 구하고, Cache 인스턴스를 사용하여 객체에 대한 캐시 작업을 수행할 수 있게 된다.

Cache 구하기
net.sf.ehcache.Cache 인스턴스는 CacheManager.getCache() 메소드를 사용하여 구할 수 있다.

CacheManager cacheManager = new CacheManager(configFileURL);
Cache cache = cacheManager.getCache("simpleBeanCache");

CacheManager.getCache() 메소드에 전달되는 파라미터는 ehcache.xml 설정 파일에서 <cache> 태그의 name 속성에 명시한 캐시의 이름을 의미한다. 지정한 이름의 Cache 인스턴스가 존재하지 않을 경우 CacheManager.getCache() 메소드는 null을 리턴한다.

Create/Update 작업 수행
Cache 인스턴스를 구한 다음에는 Cache.put() 메소드를 사용하여 캐시에 객체를 저장할 수 있다. 아래 코드는 Cache.put() 메소드의 사용예이다.

Cache cache = cacheManager.getCache("simpleBeanCache");

SimpleBean newBean = new SimpleBean(id, name);
Element newElement = new Element(newBean.getId(), newBean);
cache.put(newElement);

Cache.put() 메소드는 net.sf.ehcache.Element 객체를 전달받는다. Element 클래스는 캐시에 저장될 원소를 나타내며, 키와 값을 사용하여 원소를 표현한다. Element 객체를 생성할 때 첫번째 파라미터는 원소의 키를 의미하며, 두번째 파라미터는 원소의 값을 의미한다.

EHCache는 캐시에 저장될 각각의 객체들을 키를 사용하여 구분하기 때문에, Element 객체를 생성할 때 (의미상) 서로 다른 객체는 서로 다른 키를 사용해야 한다.

Map과 마찬가지로 EHCache가 제공하는 Cache는 삽입을 하거나 기존의 값을 수정할 때 모두 Cache.put() 메소드를 사용한다. 기존에 캐시에 저장된 객체를 수정하길 원한다면 다음과 같이 동일한 키를 사용하는 Element 객체를 Cache.put() 메소드에 전달해주면 된다.

Element newElement = new Element(id, someBean);
cache.put(newElement);
...
Element updatedElement = new Element(id, updatedBean);
cache.put(updatedElement);

Read 작업 수행
Cache에 보관된 객체를 사용하려면 Cache.get() 메소드를 사용하면 된다. Cache.get() 메소드는 키를 파라미터로 전달받으며, 키에 해당하는 Element 객체를 리턴하며 관련 Element과 존재하지 않을 경우 null을 리턴한다. 아래 코드는 Cache.get() 메소드의 사용예이다.

Element element = cache.get(key);
SimpleBean bean = (SimpleBean) element.getValue();

Element.getValue() 메소드는 캐시에 저장된 객체를 리턴한다. 만약 Serializable 하지 않은 객체를 값으로 저장했다면 다음과 같이 Element.getObejectValue() 메소드를 사용하여 값을 구해야 한다.

Element element = cache.get(key);
NonSerializableBean bean = (NonSerializableBean) element.getObjectValue();

Delete 작업 수행
Cache에 보관된 객체를 삭제하려면 Cache.remove() 메소드를 사용하면 된다. 아래 코드는 Cache.remove() 메소드의 사용예이다.

boolean deleted = cache.remove(key);

Cache.remove() 메소드는 키에 해당하는 객체가 존재하여 삭제한 경우 true를 리턴하고, 존재하지 않은 경우 false를 리턴한다.

CacheManager의 종료

사용이 종료된 CacheManager는 다음과 같이 shutdown() 메소드를 호출하여 CacheManager를 종료해야 한다.

cacheManager.shutdown();

Cache 값 객체 사용시 주의사항

캐시에 저장되는 객체는 레퍼런스가 저장된다. 따라서, 동일한 키에 대해 Cache.put()에 전달한 Element의 값과Cache.get()으로 구한 Element의 값은 동일한 객체를 참조하게 된다.

SimpleBean bean = ...;
Element element = new Element(key, bean);
cache.put(element);

Element elementFromCache = cache.get(key);
SimpleBean beanFromCache = (SimpleBean)elementFromCache.getValue();

(bean == beanFromCache); // true
(element == elementFromCache); // false

위 코드에서 Cache.put()에 전달된 element 객체와 Cache.get()으로 구한 elementFromCache 객체는 서로 다른 객체이다. 하지만, 두 Element 객체가 갖고 있는 값은 동일한 객체를 참조하고 있다. 따라서, 캐시에 값으로 저장된 객체를 변경하게 되면 캐시에 저장된 내용도 변경되므로, 캐시 사용시 이 점에 유의해야 한다.

캐시 설정

캐시 설정 파일에 <cache> 태그를 이용하여 캐시를 설정했었다. 캐시 설정과 관련하여 <cache> 태그는 다양한 속성을 제공하고 있는데, 이들 속성에는 다음과 같은 것들이 존재한다.

name 캐시의 이름 필수
maxElementsInMemory 메모리에 저장될 수 있는 객체의 최대 개수 필수
eternal 이 값이 true이면 timeout 관련 설정은 무시되고, Element가 캐시에서 삭제되지 않는다. 필수
overflowToDisk 메모리에 저장된 객체 개수가 maxElementsInMemory에서 지정한 값에 다다를 경우 디스크에 오버플로우 되는 객체는 저장할 지의 여부를 지정한다. 필수
timeToIdleSeconds Element가 지정한 시간 동안 사용(조회)되지 않으면 캐시에서 제거된다. 이 값이 0인 경우 조회 관련 만료 시간을 지정하지 않는다. 기본값은 0이다. 선택
timeToLiveSeconds Element가 존재하는 시간. 이 시간이 지나면 캐시에서 제거된다. 이 시간이 0이면 만료 시간을 지정하지 않는다. 기본값은 0이다. 선택
diskPersistent VM이 재 가동할 때 디스크 저장소에 캐싱된 객체를 저장할지의 여부를 지정한다. 기본값은 false이다. 선택
diskExpiryThreadIntervalSeconds Disk Expiry 쓰레드의 수행 시간 간격을 초 단위로 지정한다. 기본값은 120 이다. 선택
memoryStoreEvictionPolicy 객체의 개수가 maxElementsInMemory에 도달했을 때,모메리에서 객체를 어떻게 제거할 지에 대한 정책을 지정한다. 기본값은 LRU이다. FIFO와 LFU도 지정할 수 있다. 선택

아래 코드는 몇 가지 설정 예이다.

<!--
sampleCache1 캐시. 최대 10000개의 객체를 저장할 수 있으며, 
5분 이상 사용되지 않거나 또는 10분 이상 캐시에 저장되어 있을 경우 
캐시에서 제거된다. 저장되는 객체가 10000개를 넘길 경우, 
디스크 캐시에 저장한다.
-->
<cache name="sampleCache1"
       maxElementsInMemory="10000"
       maxElementsOnDisk="1000"
       eternal="false"
       overflowToDisk="true"
       timeToIdleSeconds="300"
       timeToLiveSeconds="600"
       memoryStoreEvictionPolicy="LFU"
       />

<!--
sampleCache2 캐시. 최대 1000개의 객체를 저장한다. 
오버플로우 된 객체를 디스크에 저장하지 않기 때문에 
캐시에 최대 개수는 1000개이다. eternal이 true 이므로, 
timeToLiveSeconds와 timeToIdleSeconds 값은 무시된다.
-->
<cache name="sampleCache2"
       maxElementsInMemory="1000"
       eternal="true"
       overflowToDisk="false"
       memoryStoreEvictionPolicy="FIFO"
       />

<!--
sampleCache3 캐시. 오버플로우 되는 객체를 디스크에 저장한다.
디스크에 저장된 객체는 VM이 재가동할 때 다시 캐시로 로딩된다.
디스크 유효성 검사 쓰레드는 10분 간격으로 수행된다.
-->
<cache name="sampleCache3"
       maxElementsInMemory="500"
       eternal="false"
       overflowToDisk="true"
       timeToIdleSeconds="300"
       timeToLiveSeconds="600"
       diskPersistent="true"
       diskExpiryThreadIntervalSeconds="600"
       memoryStoreEvictionPolicy="LFU"
       />

분산 캐시

EHCache는 분산 캐시를 지원한다. EHCache는 피어(peer) 자동 발견 및 RMI를 이용한 클러스터간 데이터 전송의 신뢰성 등 분산 캐시를 위한 완전한 기능을 제공하고 있다. 또한, 다양한 옵션을 통해 분산 상황에 맞게 설정할 수 있도록 하고 있다.

참고로, EHCache는 RMI를 이용하여 분산 캐시를 구현하고 있기 때문에, Serializable 한 객체만 분산 캐시에서 사용 가능하다. 키 역시 Serializable 해야 한다.

분산 캐시 구현 방식

EHCache는 한 노드의 캐시에 변화가 생기면 나머지 노드에 그 변경 내용을 전달하는 방식을 사용한다. 즉, 클러스터에 있는 캐시 인스턴스가 n개인 경우, 한번의 변경에 대해 n-1개의 변경 통지가 발생한다.

각 노드의 캐시간 데이터 전송은 RMI를 통해서 이루어진다. EHCache가 데이터 전송 기술로서 RMI를 사용하는 이유는 다음과 같다.

  • 자바에서 기본적으로 제공하는 원격 메커니즘
  • 안정화된 기술
  • TCP 소켓 옵션을 튜닝할 수 있음
  • Serializable 한 객체를 지원하기 때문에, 데이터 전송을 위해 XML과 같은 별도의 포맷으로 변경할 필요가 없음
노드 발견

EHCache는 클러스터에 새로운 노드가 추가돌 경우 해당 노드를 자동적으로 발견하는 방식과, 지정된 노드 목록에 대해서만 클러스터의 노드로 사용하는 방식을 지원하고 있다.

멀티캐스트 방식

멀티캐스트 모드를 사용한 경우, 지정한 멀티캐스트 IP(224.0.0.1~239.255.255.255)와 포트에 참여하는 노드를 자동으로 발견하게 된다. 지정한 IP와 포트에 참여한 노드는 자기 자신을 다른 노드에 통지한다. 이 방식을 사용하면 클러스터에 동적으로 노드를 추가하거나 제거할 수 있다.

노드 목록 지정 방식

클러스터에 포함되는 노드 목록을 지정한다. 동적으로 새로운 노드를 추가하거나 기존 노드를 제거할 수 없다.

분산 캐시 설정

분산 캐시를 사용하기 위해서는 다음과 같은 세 개의 정보를 지정해주어야 한다.

  • CacheManagerPeerProvider - 피어 발견 관련 설정
  • CacheManagerPeerListener - 메시지 수신 관련 설정
  • 캐시별 CacheReplicator - 메시지 생성 규칙 설정
CacheManagerPeerProvider 설정

CacheManagerPeerProvider는 새롭게 추가된 노드를 발견하는 방식을 지정한다.

노드를 자동으로 발견하는 멀티캐스트 방식을 사용하려면 다음과 같이 설정한다.

<cacheManagerPeerProviderFactory
    class="net.sf.ehcache.distribution.RMICacheManagerPeerProviderFactory"
    properties="peerDiscovery=automatic, 
                    multicastGroupAddress=230.0.0.100, multicastGroupPort=1234" />

위 코드에서 properties 속성의 값에 사용된 프로퍼티는 다음과 같다.

peerDiscovery automatic으로 지정하면 멀티캐스트 방식을 사용한다.
multicaseGroupAddress 멀티캐스트 IP
multicaseGroupPort 포트 번호

하나의 클러스터에 포함될 노드들은 동일한 멀티캐스트 IP와 포트 번호를 사용해야 한다.

클러스터에 참여할 노드 목록을 지정하는 IP 방식을 사용하려면 다음과 같이 설정한다.

<cacheManagerPeerProviderFactory
    class="net.sf.ehcache.distribution.RMICacheManagerPeerProviderFactory"
    properties="peerDiscovery=manual, 
                    rmiUrls=//server2:12345/cache1|//server2:12345/cache2" />

위 코드에서 properties 속성의 값에 사용된 프로퍼티는 다음과 같다.

peerDiscovery manual로 지정한 IP 지정 방식이다.
rmiUrls 분산 노드에 참여할 서버 및 캐시 목록을 지정한다. 현재 노드의 정보는 포함시켜서는 안 된다.

이 경우, rmiUrls에 명시된 포트 번호는 뒤에 살펴볼 CacheManagerPeerListener가 사용할 포트 번호를 지정해주어야 한다.

CacheManagerPeerListener 설정

노드를 발견하는 방식을 지정했다면, 다음으로 할 작업은 클러스터에 있는 다른 노드에서 발생한 변경 정보를 수신할 때 사용할 포트 번호를 지정하는 것이다. 다음과 같은 코드를 이용하여 수신과 관련된 포트 번호를 설정할 수 있다.

<cacheManagerPeerListenerFactory
    class="net.sf.ehcache.distribution.RMICacheManagerPeerListenerFactory"
    properties="port=12345, socketTimeoutMillis=120000" />

위 코드에서 properties 속성의 값에 사용된 프로퍼티는 다음과 같다.

port 메시지를 수신할 때 사용되는 포트
socketTimeoutMillis 이 노드에 메시지를 보냈을 때 메시지 전송을 기다리는 시간. 기본값은 2000ms.

캐시별 CacheReplicator 설정

분산 환경에 적용되어야 하는 캐시는 캐시의 내용이 변경되었을 때 다른 노드에 있는 캐시에 변경 내역을 알려주어야 한다. <cacheEventListenerFactory> 태그를 사용하면, 언제 어떻게 캐시의 변경 내역을 통지할지의 여부를 지정할 수 있다. 아래 코드는 설정의 예이다.

<cache name="simpleBean"
      maxElementsInMemory="100"
      eternal="false"
      overflowToDisk="false"
      timeToIdleSeconds="300"
      timeToLiveSeconds="600"
      memoryStoreEvictionPolicy="LRU">
       <cacheEventListenerFactory 
           class="net.sf.ehcache.distribution.RMICacheReplicatorFactory" 
           properties="replicateUpdatesViaCopy=true,replicateUpdates=true" />
</cache>

위 코드와 같이 <cacheEventListenerFactory>의 구현 클래스로 RMICacheReplicatorFactory를 지정하면 캐시에 변경이 생길 때 마다 해당 변경 내역을 클러스터에 참여하고 있는 노드의 캐시에 통지하게 된다. properties 속성에 프로퍼티를 지정하면, 캐시 요소의 추가, 변경, 삭제 등에 대해 통지 방식을 적용할 수 있다. 설정할 수 있는 프로퍼티는 다음과 같다.

replicatePuts 캐시에 새로운 요소가 추가됐을 때 다른 노드에 복사할지의 여부
replicateUpdates 캐시 요소의 값이 변경되었을 때 다른 노드에 값을 복사할지의 여부
replicateRemovals 캐시 요소가 삭제되었을 때 다른 노드에 반영할지의 여부
replicateAsynchronously 비동기로 값을 복사할지의 여부
replicateUpdatesViaCopy 새로운 요소를 다른 노드에 복사할 지 아니면 삭제 메시지를 보낼지의 여부
asynchronousReplicationIntervalMillis 비동기 방식을 사용할 때 변경 내역을 다른 노드에 통지하는 주기. 기본값은 1000.

위 속성의 기본값은 모두 true이다. 따라서, 기본 설정값을 사용하려면 다음과 같이 properties 속성을 사용하지 않아도 된다.

<cache name="simpleBean" ...
      memoryStoreEvictionPolicy="LRU">
       <cacheEventListenerFactory 
           class="net.sf.ehcache.distribution.RMICacheReplicatorFactory" />
</cache>

어플리케이션 구동시 캐시 데이터 로딩하기

CacheManager가 초기화 될 때, 클러스터에 있는 다른 캐시로부터 데이터를 로딩할 수 있다. 이는 초기 구동이 완료된 후 곧 바로 서비스를 제공할 수 있음을 의미한다. 초기 구동시 다른 노드로부터 캐시 데이터를 로딩하려면 다음과 같이 <bootstrapCacheLoaderFactory> 태그의 구현 클래스를 RMIBootstrapCacheLoaderFactory로 지정해주면 된다.

<cache name="simpleBean" ...
      memoryStoreEvictionPolicy="LRU">
       <bootstrapCacheLoaderFactory
           class="net.sf.ehcache.distribution.RMIBootstrapCacheLoaderFactory"
           properties="bootstrapAsynchronously=true,
                       maximumChunkSizeBytes=5000000" />

       <cacheEventListenerFactory 
           class="net.sf.ehcache.distribution.RMICacheReplicatorFactory" />
</cache>

RMIBootstrapCacheLoaderFactory에 전달 가능한 프로퍼티 목록은 다음과 같다.

bootstrapAsynchronously 비동기적으로 수행할지의 여부를 지정
maximumChunkSizeBytes 클러스터의 다른 노드로부터 로딩 가능한 데이터의 최대 크기

RMIBoostrapCacheLoaderFactory를 설정하면 캐시를 초기화 할 때, 원격지 노드의 캐시에 저장된 데이터를 로딩하여 로컬 캐시에 저장한다.

분산 캐시 고려사항

분산 캐시를 사용할 때에는 다음과 같은 내용을 고려해야 한다.

  • 노드 증가에 따라 네트워크 트래픽 증가:
    많은 양의 네트워크 트래픽이 발생할 수 있다. 특히 동기 모드인 경우 성능에 영향을 받을 수 있다. 비동기 모드인 경우 버퍼에 변경 내역을 저장하였다가 일정한 주기로 버퍼에 쌓인 내역을 다른 노드에 통지하기 때문에 이 문제를 다소 완하시킬 수 있다.
  • 데이터 불일치 발생 가능성:
    두 노드에서 동시에 동일한 캐시의 동일한 데이터에 대한 변경을 수행할 경우, 두 노드 사이에 데이터 불일치가 발생할 수 있다. 캐시 데이터의 불일치가 매우 심각한 문제가 될 경우, 동기 모드(replicateAsynchronously=false)와 복사 메시지 대신 삭제 메시지를 전송(replicateUpdatesViaCopy=false)함으로써 이 문제를 해결할 수 있다.
관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 레온이 2009.06.14 12:19 신고  댓글주소  수정/삭제  댓글쓰기

    항상 좋은글 잘 읽고갑니다 ^ ^

  2. 2010.07.07 13:26  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

  3. 나뿐남자 2011.03.24 10:15 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 감사합니다.
    제블로그에 퍼갈께요.

  4. 허니몬 2013.08.27 14:01 신고  댓글주소  수정/삭제  댓글쓰기

    마침 EHCache에 대한 설명글을 찾고 있었는데,
    깔끔히 정리된 글 잘보고갑니다. ^^

  5. dial 2014.02.20 11:57 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 감사하고,
    제 블로그에 펌좀 해가겠습니다 ㅎㅎ
    문제시 삭제하겠습니다.

  6. 한발 2015.04.09 11:19 신고  댓글주소  수정/삭제  댓글쓰기

    우연히 검색해서 들어왔는데...학생시절 봤던 ajax, jsp 서적의 저자시군요~
    좋은글 감사합니다. 저도 블로그에 펌좀 해가겠습니다. 문제될 경우 삭제하겠습니다.

  7. 해외감자 2017.07.20 13:41 신고  댓글주소  수정/삭제  댓글쓰기

    정리가 정말 잘 되어 있어서 많은 도움이 되었습니다
    정말 감사합니다 ^-^

  8. 자바맨 2018.10.12 23:17 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 잘 읽고 갑니다

SiteMesh를 이용하여 웹 페이지의 레이아웃을 처리하는 방법을 살펴본다.

SiteMesh의 동작 방식과 설치

웹 어플리케이션을 구성하고 있는 웹 페이지들은 대부분은 페이지 레이아웃이 동일하게 구성되어 있다. 예를 들어, 미디어 다음의 스포츠 게시판을 보면, 각 페이지는 아래 그림과 동일한 형태로 구성되어 있는 것을 확인할 수 있다.


위 그림에서 내용 부분을 제외한 나머지 헤더, 푸터, 좌측 메뉴, 그리고 우측 주요기사는 모든 페이지에서 동일한 위치에 나타낸다. 즉, 게시글 목록 페이지와 게시글 쓰기 페이지는 모두 위 그림과 동일한 레이아웃을 갖는 것이다.

이렇게 동일한 레이아웃을 여러 페이지에 적용해야 할 때, 가장 쉽게 사용할 수 있는 방법이 <jsp:include>나 <%@ include %>를 사용하는 것이다. 하지만, 이는 중복된 코드를 발생시킬 가능성이 높기 때문에, Tiles나 Velocity가 제공하는 레이아웃 기능을 사용하여 구현하게 된다. 추가적으로 SiteMesh를 사용하여 레이아웃을 여러 페이지에 적용할 수 있다.

Tiles나 Velocity 또는 <jsp:include>를 사용하는 방식이 전체 페이지 중 내용 부분에 해당하는 코드만을 생성하는 방식이라면, SiteMesh는 완전한 HTML 페이지를 생성한 뒤 Decorator 패턴을 사용하여 HTML 페이지에 레이아웃을 입히는 방식이다. 본 글에서는 SiteMesh의 동작방식에 대해서 살펴보고, SiteMesh를 사용하여 여러 웹 페이지에 레이아웃을 동일하게 적용하는 방법을 살펴볼 것이다.

SiteMesh의 동작 방식

SiteMesh는 Tiles와 같은 프레임워크와 달리 완전한 HTML 코드로부터 레이아웃이 적용된 새로운 HTML 코드를 생성해낸다. 아래 그림은 SiteMesh의 동작방식을 설명한 것이다.


위 그림에서 데코레이터에 전달되는 HTML 페이지는 <html>, <head>, <body> 등을 포함한 완전한 HTML 페이지이다. 이때 데코레이터에 전달되는 HTML 페이지는 레이아웃과 관련된 내용은 포함되지 않는다. 데코레이터는 레이아웃 정보를 담고 있는 JSP 페이지로서, 앞서 생성한 HTML 페이지에 저장된 (<title> 등의) 메타 정보와 <body> 태그에 포함된 내용을 추출한 뒤, 레이아웃의 알맞은 위치에 추출한 내용을 삽입하여 최종 결과를 생성하게 된다.

예를 들어, 앞서 그림에서 welcome.jsp의 경우를 살펴보자. welcome.jsp는 레이아웃과 관련된 코드를 생성하지 않고 단지 메타 정보와 내용 부분에 들어가는 정보만을 생성하게 된다. welcome.jsp가 생성한 HTML 페이지는 데코레이터에 전달된다. 데코레이터는 welcome.jsp가 생성한 내용으로부터 메타 정보와 BODY 부분을 추출한 뒤 데코레이터의 알맞은 위치에 삽입하여 최종 결과를 생성한다.

SiteMesh 설치

SiteMesh는 서블릿 환경에서 동작하며, http://www.opensymphony.com/sitemesh/download.action 사이트에서 최신 버전을 다운로드 받을 수 있다. 이 글을 쓰는 시점에서 최신 버전은 2.3 버전으로서 sitemesh-2.3.jar 파일을 다운로드 받은 뒤, 웹 어플리케이션 콘텍스트의 WEB-INF/lib 디렉토리에 복사하면 설치가 완료된다.

SiteMesh를 이용한 레이아웃 적용

SiteMesh를 사용하여 웹 페이지에 레이아웃을 적용하기 위해서는 다음의 두 가지를 필요로 한다.

  • SiteMesh 설정 파일
  • 데코레이터
SiteMesh 설정 1, web.xml

SiteMesh를 설정하기 위해서는 먼저 SiteMesh가 제공하는 PageFilter(서블릿 필터)를 설정해주어야 한다. 아래 코는 설정 예이다.

<?xml version="1.0" encoding="UTF-8"?>
<web-app id="WebApp_ID" version="2.4"
    xmlns="http://java.sun.com/xml/ns/j2ee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee 
                      http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd">
    
    <filter>
        <filter-name>sitemesh</filter-name>
        <filter-class>
            com.opensymphony.module.sitemesh.filter.PageFilter
        </filter-class>
    </filter>

    <filter-mapping>
        <filter-name>sitemesh</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

</web-app>

위 코드는 / 로 들어오는 모든 요청에 대해서 PageFilter를 적용한다고 설정하였다. PageFilter는 요청 URL과 매칭되는 데코레이터를 검색한 뒤, 데코레이터가 발견될 경우 결과 HTML에 매칭되는 데코레이터를 적용한다. 따라서, 데코레이터가 적용되어야 하는 URL의 경우 반드시 PageFilter에 매핑시켜주어야 한다.

SiteMesh 설정 2, decorators.xml 작성

web.xml 파일에 PageFilter 매핑을 설정한 다음에는, 실제 데코레이터에 대한 정보를 담고 있는 decorators.xml 파일을 작성해주어야 한다. decorators.xml 파일은 데코레이터에 대한 설정 정보를 담게 된다.

decorators.xml 파일은 다음과 같은 형태로 데코레이터 목록을 기술한다.

<decorators defaultdir="/decorators">
    <decorator name="submenu" page="submenu_decorator.jsp">
        <pattern>/sub/*</pattern>
    </decorator>

    <decorator name="main" page="main_decorator.jsp">
        <pattern>/*</pattern>
    </decorator>

</decorators>

위 코드에서 <decorators> 태그의 defaultdir 속성은 데코레이터 JSP가 위치할 경로를 의미한다. 이 경로는 웹 어플리케이션 콘텍스트 내에서의 경로를 의미한다.

<decorator> 태그는 한 개의 데코레이터를 설정한다. <decorator> 태그의 두 속성은 다음과 같다.

  • name - 데코레이터의 이름
  • page - 데코레이터로 사용될 JSP 페이지
<pattern> 태그는 데코레이터를 적용할 패턴을 의미한다. 이 패턴은 서블릿 매핑에서의 패턴과 비슷하다. 예를 들어, /sub/submain1.jsp 나 /sub/menu/submenu1.jsp 로 요청이 들어올 경우 'submenu' 데코레이터가 적용되며, 그 외 /main.jsp나 /another/another1.jsp와 같이 /sub/* 에 포함되지 않는 요청의 경우는 'main' 데코레이터가 적용된다.

만약 정확하게 일치하는 <pattern> 값이 존재할 경우 해당 데코레이터를 사용한다. 예를 들어, 아래의 설정을 보자.

    <decorator name="submenu" page="submenu_decorator.jsp">
        <pattern>/sub/*</pattern>
    </decorator>
    
    <decorator name="submain" page="submain_decorator.jsp">
        <pattern>/sub/submain1.jsp</pattern>
    </decorator>

이 경우 /sub/submain1.jsp는 submenu 데코레이터와 submain 데코레이터에 모두 매핑되지만, 좀더 정확하게 일치하는 submain 데코레이터가 사용된다.

한 개의 <decorator> 태그는 0개 이상의 <pattern> 태그를 포함할 수 있다.

서블릿 매핑 시 주의 사항

서블릿 매핑을 사용할 경우 <pattern> 값은 서블릿의 경로를 따른다. 예를 들어, 다음과 같이 서블릿 매핑을 설정했다고 하자.

<servlet-mapping>
    <servlet-name>content</servlet-name>
    <url-pattern>/catalog/*</url-pattern>
</servlet-mapping>

이 경우, 지정한 서블릿 매핑에 해당되는 요청에 데코레이터를 적용하고자 한다면, 다음과 같이decorators.xml의 <pattern> 태그의 값으로 서블릿 경로명을 지정해주어야 한다.

    <decorator name="catalog" page="catalog_decorator.jsp">
        <pattern>/catalog</pattern>
    </decorator>

만약 서블릿 경로명이 아닌 /catalog/* 를 <pattern> 태그의 값으로 지정할 경우 해당 데코레이터가 적용되지 않는다.

데코레이터 작성

SiteMesh의 데코레이터는 JSP 페이지로서, SiteMesh가 제공하는 커스텀 태그를 사용하여 결과 HTML 페이지를 데코레이션하게 된다. 아래 코드는 간단하게 작성해본 SiteMesh의 데코레이터 코드이다.

<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="decorator" uri="http://www.opensymphony.com/sitemesh/decorator" %>
<html>
    <head>
        <title><decorator:title default="테크리포트" /></title>
        <decorator:head />
    </head>
    <body>
    <div>헤더</div>
    <hr/>
    
    <decorator:body />
    
    <hr/>
    <div>푸터</div>
    </body>
</html>

위 코드에서 눈여겨 볼 부분은 decorator로 시작하는 커스텀 태그이다. 사용자의 요청을 처리한 결과 페이지는 데코레이터에 전달되는데, 이때 커스텀 태그를 사용하여 전달된 페이지의 내용을 사용할 수 있게 된다. 예를 들어, <decorator:title> 커스텀 태그는 전달된 페이지의 <title> 태그의 값을 구하게 된다. 사용가능한 커스텀 태그는 다음과 같다.

<decorator:head />

HTML의 <head> 태그의 내용을 삽입한다.

<decorator:body />

<body> 태그의 내용을 삽입한다.

<body> 태그에 명시된 프로퍼티의 값을 데코레이터 JSP에 삽입하고 싶다면 다음과 같이 <decorator:getProperty> 커스텀 태그를 사용하면 된다.

<body onload="<decorator:getProperty property="body.onload" />">

   <decorator:body />

</body>

<decorator:title [ default="..." ] />

<title> 태그에 명시된 타이틀을 삽입한다. 만약 <title> 태그의 값이 발견되지 않을 경우 default 속성에 명시한 값을 삽입한다.

<decorator:getProperty property="." [default="."] [writeEntireProperty="." ]/>

원본 HTML 페이지의 프로퍼티를 삽입한다. 이때 사용가능한 프로퍼티는 다음과 같이 생성된다.

  • HTML Tag:
    <html> 태그의 모든 속성이 프로퍼티로 추가된다.
  • TITLE Tag:
    <title> 태그의 내용이 'title' 프로퍼티로 추가된다.
  • META Tags:
    이름과 내용을 갖는 모든 <meta> 태그는 'meta.이름' 프로퍼티로 추가된다.
  • BODY Tag:
    모든 <body> 태그의 속성이 'body.속성이름' 프로퍼티로 추가된다.
<decorator:getProperty> 커스텀 태그에서 사용가능한 속성은 다음과 같다.

  • property (필수) - 삽입할 프로퍼티의 이름(키)
  • default (선택) - 프로퍼티가 존재하지 않을 경우 삽입할 값
  • writeEntireProperty (선택) - 프로퍼티의 이름 및 이름 앞의 공백을 함께 삽입할 지의 여부를 지정한다. 허용되는 값은 'true', 'yes', 또는 '1' 이다.
예를 들어, 원본 페이지에서 다음과 같이 <body> 태그를 작성했다고 하자.

<body onload="document.someform.somefield.focus();">

그리고 데코레이터 JSP에서 다음과 같이 <decorator:getProperty> 커스텀 태그를 사용했다고 하자.

<body bgcolor="White" <decorator:getProperty property="body.onload" 
           writeEntireProperty="true" />>

이 경우 최종적으로 생성되는 코드는 다음과 같다.

<body bgcolor="White" onload="document.someform.somefield.focus();"> 

테스트 코드

데코레이션 될 JSP 코드

간단하게 SiteMesh의 데코레이터를 통해 레이아웃이 적용될 HTML 페이지를 생성하는 JSP 페이지를 다음과 같이 작성해보자. 이 JSP의 경로는 /sub/submain1.jsp 라고 하자.

<%@ page contentType="text/html; charset=UTF-8" %>
<html>
<head>
    <title>서브 메인 1</title>
    <script type="text/javascript">
    window.onload = function() {
    }
    </script>
</head>
<body>
    서브 메인 1
</body>
</html>

데코레이터 JSP

데코레이터 JSP인 /decorators/submenu_decorator.jsp를 아래와 같이 작성해보았다.

<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="decorator" uri="http://www.opensymphony.com/sitemesh/decorator" %>
<html>
    <head>
        <title><decorator:title default="테크리포트" /></title>
        <decorator:head />
    </head>
    <body>
    <div>공통 헤더</div>
    <hr/>
    <decorator:body />
    <hr/>
    <div>공통 푸터</div>
    </body>
</html>

데코레이터 JSP를 작성했으면 decorators.xml 파일에 등록해주어야 한다. 아래 코드는 등록 예이다. 앞서 원본 JSP의 경로를 /sub/submain1.jsp로 지정하였으므로, 아래 코드에서 <pattern>의 값을 '/sub/*'로 지정하였다.

<decorators defaultdir="/decorators">
    <decorator name="submenu" page="submenu_decorator.jsp">
        <pattern>/sub/*</pattern>
    </decorator>
</decorators>

테스트 결과

이제 웹 브라우저에서 실제로 출력 결과를 확인해보자. 웹 브라우저에서 http://…/[contextPath]/sub/submain1.jsp를 입력한 뒤, 출력된 결과의 소스 코드는 다음과 같을 것이다.

<html>
    <head>
        <title>서브 메인 1</title>
        
    
    <script type="text/javascript">
    window.onload = function() {
    }
    </script>

    </head>
    <body>
    <div>공통 헤더</div>
    <hr/>
    
    서브 메인 1

    <hr/>
    <div>공통 푸터</div>
    </body>
</html>

위 코드를 보면 원본 JSP가 출력한 결과가 데코레이터를 통해 알맞은 위치에 삽입된 것을 확인할 수 있다.

관련링크:


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 2009.05.28 16:27  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

  2. 김효선 2010.03.03 14:21 신고  댓글주소  수정/삭제  댓글쓰기

    담아갈께요~~~ 감사합니다~ ^^

  3. sun 2010.05.17 10:49 신고  댓글주소  수정/삭제  댓글쓰기

    자세한 설명 넘 감사드려요~~
    완전 초보라 다 이해하지는 못했지만 그래도 도움이 많이 됐어요 ^ㅅ^

  4. 2010.09.01 15:31 신고  댓글주소  수정/삭제  댓글쓰기

    굉장히 좋은글이네요.. 왜 써야하는지 어떻게쓰이는지 잘 정리한 내용같네요 ^^;;
    많은 도움 되었습니다.

  5. 주사위 2012.06.26 15:10 신고  댓글주소  수정/삭제  댓글쓰기

    http://javacan.tistory.com/entry/UsingCustomDecoratorMapperForSiteMeshURLPatternMatching

    이 글도 함께 링크걸어주셨으면 합니다. 여기 정리된 커스텀 데코레이터가 아니었다면 도저히 사이트메시를 못쓸뻔했습니다. 덕분에 잘 해결했습니다. 감사합니다.

  6. luck 2012.09.11 14:18 신고  댓글주소  수정/삭제  댓글쓰기

    사이트메시에 대한 궁금증을 풀어주셔서 감사합니다 ^^
    잘 읽고 갑니다~

JOTM을 이용하여 트랜잭션을 처리하는 방법과 Spring 2에서 JOTM을 연동하는 방법을 살펴본다.

Tomcat 5.5와 JOTM 연동

JOTM(Java Open Transaction Manager)은 ObjectWeb에서 제공하는 트랜잭션 관리자로서 JTA, JTS 등의 API를 지원하고 있다. JOTM을 사용하면 기존의 JDBC 기반의 코드에 JTA를 이용한 트랜잭션 처리 코드를 손쉽게 적용할 수 있다. 본 글에서는 DAUM 내에서 많이 사용되고 있는 Tomcat 5.5와 JOTM을 연동하여 트랜잭션을 처리하는 방법과 앞서 살펴봤던 Spring 2.0과 JOTM을 연동하는 방법에 대해서 살펴볼 것이다.

먼저, Tomcat 5.5 버전과 JOTM을 연동하는 방법에 대해서 살펴보도록 하자. Tomcat 5.5와 JOTM을 연동하려면 다음과 같은 순서에 따라 작업을 진행하면 된다.

  1. JOTM 다운로드 및 jar 파일 설치
  2. JOTM 설정 파일 작성
  3. Tomcat 설정: DataSource 및 UserTransaction 설정
  4. 웹 어플리케이션의 web.xml 파일에 DataSource 참고하도록 설정
  5. 코드 작성
JOTM 다운로드 및 jar 파일 설치

JOTM은 http://jotm.objectweb.org/download/index.html 사이트에서 다운로드 받을 수 있다. JOTM을 다운로드 받은 뒤 압축을 풀고, [JOTM]/lib 디레렉토리에 있는 모든 jar 파일을 [Tomcat]/common/lib 디렉토리에 복사한다. 또한, 사용할 DBMS에 알맞은 JDBC 드라이버를 [Tomcat]/common/lib 디렉토리에 복사한다.

JOTM 설정 파일 작성

[Tomcat]/common/classes 디렉토리에 carol.properties 파일을 생성하고 다음과 같이 내용을 작성한다.

carol.protocols=jrmp
carol.start.jndi=false
carol.start.ns=false
carol.start.rmi=false
# carol을 사용하지 않고 Tomcat의 JNDI를 사용하도록 설정
carol.jndi.java.naming.factory.url.pkgs=org.apache.naming

Tomcat 설정: DataSource 및 UserTransaction 설정

JOTM을 설치한 다음에 할 작업은 JOTM이 제공할 DataSource와 UserTransaction을 설정하는 것이다. 다음과 같이 콘텍스트를 위한 <Context> 태그에 DataSource와 UserTransaction을 위한 설정을 추가한다.

<Context path="/jotmtest" debug="0"
  reloadable="true" crossContext="true">
    <!-- Resource configuration for JDBC datasource use XAPool -->
    <Resource name="jdbc/myDB" auth="Container" type="javax.sql.DataSource"
        factory="org.objectweb.jndi.DataSourceFactory"
        driverClassName="com.mysql.jdbc.Driver"
        username="root" password="root" url="jdbc:mysql://localhost:3307/test"/>
    
    <!-- Resource configuration for UserTransaction use JOTM -->
    <Transaction factory="org.objectweb.jotm.UserTransactionFactory"
       jotm.timeout="60"/>
</Context>

org.objectweb.jndi.DataSourceFactory는 ObjectWeb에서 제공되는 XAPool을 사용하여 DataSource를 생성하는 ResourceFactory 이다.

웹 어플리케이션: web.xml 설정

web.xml 파일에 앞서 정의한 DataSource를 참고하도록 다음과 같은 코드를 추가한다.

<?xml version="1.0" encoding="ISO-8859-1"?>

<web-app xmlns="http://java.sun.com/xml/ns/j2ee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee 
    http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd"
    version="2.4">

  <resource-env-ref>
    <description>DB Connection</description>
    <resource-env-ref-name>jdbc/myDB</resource-env-ref-name>
    <resource-env-ref-type>javax.sql.DataSource</resource-env-ref-type>
  </resource-env-ref>

</web-app>

코드

이제 남은 작업은 JOTM이 제공하는 DataSource와 UserTransaction을 사용하여 트랜잭션 처리를 수행하는 것이다. 아래 코드는 DataSource와 UserTransaction을 사용하는 간단한 JSP 코드이다.

<%@ page contentType="text/html; charset=euc-kr" %>
<%@ page import = "javax.naming.Context" %>
<%@ page import = "javax.naming.InitialContext" %>
<%@ page import = "javax.sql.DataSource" %>
<%@ page import = "javax.transaction.UserTransaction" %>
<%@ page import = "java.sql.Connection" %>
<%@ page import = "java.sql.PreparedStatement" %>
<%
    Context ctx = new InitialContext();
    
    UserTransaction tx = (UserTransaction)ctx.lookup("java:comp/UserTransaction");
    
    DataSource ds = (DataSource)ctx.lookup("java:comp/env/jdbc/myDB");
    
    Connection conn = null;
    PreparedStatement pstmt = null;
    try {
        tx.begin(); // 트랜잭션 시작
        conn = ds.getConnection();
        pstmt = conn.prepareStatement("update user set name = '2222' where id = ?");
        pstmt.setInt(1, 25);
        pstmt.executeUpdate();
        tx.commit(); // 트랜잭션 커밋
        out.println("success");
    } catch(Throwable e) {
        tx.rollback(); // 트랜잭션 롤백
        throw e;
    } finally {
        if (conn != null) conn.close();
    }
%>

JOTM이 분산 트랜잭션을 지원하는 JTA를 구현하고 있기 때문에, 다음과 같이 다수의 DataSource에 대한 트랜잭션 처리도 가능하다.

    Context ctx = new InitialContext();
    
    UserTransaction tx = (UserTransaction)ctx.lookup("java:comp/UserTransaction");
    
    DataSource ds1 = (DataSource)ctx.lookup("java:comp/env/jdbc/myDB1");
    DataSource ds2 = (DataSource)ctx.lookup("java:comp/env/jdbc/myDB2");

    Connection conn1 = null;
    Connection conn2 = null;
    try {
        tx.begin(); // 트랜잭션 시작
        conn1 = ds1.getConnection();
        conn2 = ds2.getConnection();
        ...
        tx.commit(); // 트랜잭션 커밋 (2-phase commit)
    } catch(Throwable e) {
        tx.rollback(); // 트랜잭션 롤백
        throw e;
    } finally {
        if (conn1 != null) conn.close();
        if (conn2 != null) conn.close();
    }

JOTM과 Spring의 연동

Spring은 JOTM과의 연동을 위한 별도의 클래스를 제공하고 있기 때문에, Spring의 설정 파일만 변경하면 JOTM이 제공하는 트랜잭션 관리 기능을 사용할 수 있다. 아래 코드는 Spring에서 JOTM을 사용하기 위한 설정의 예이다.

<?xml version="1.0" encoding="UTF-8"?>

<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:aop="http://www.springframework.org/schema/aop"
    xmlns:tx="http://www.springframework.org/schema/tx"
    xsi:schemaLocation="http://www.springframework.org/schema/beans   
       http://www.springframework.org/schema/beans/spring-beans-2.0.xsd
       http://www.springframework.org/schema/tx 
       http://www.springframework.org/schema/tx/spring-tx-2.0.xsd
       http://www.springframework.org/schema/aop
       http://www.springframework.org/schema/aop/spring-aop-2.0.xsd">

    <bean id="jotm"
        class="org.springframework.transaction.jta.JotmFactoryBean" />

    <bean id="txManager"
        class="org.springframework.transaction.jta.JtaTransactionManager">
        <property name="userTransaction" ref="jotm" />
    </bean>
    <!-- XAPool로 생성한 데이터소스 -->
    <bean id="dataSource"
        class="org.enhydra.jdbc.pool.StandardXAPoolDataSource"
        destroy-method="shutdown">
        <property name="dataSource">
            <bean class="org.enhydra.jdbc.standard.StandardXADataSource"
                destroy-method="shutdown">
                <property name="transactionManager" ref="jotm" />
                <property name="driverName" value="com.mysql.jdbc.Driver" />
                <property name="url"
                          value="jdbc:mysql://localhost:3307/test" />
            </bean>
        </property>
        <property name="user" value="root" />
        <property name="password" value="root" />
    </bean>

    <!-- 트랜잭션을 위한 Advisor 정의 -->
    <tx:advice id="txAdvice" transaction-manager="txManager">
        <tx:attributes>
            <tx:method name="get*" read-only="true" />
            <tx:method name="*" />
        </tx:attributes>
    </tx:advice>

    <aop:config>
        <aop:pointcut id="requiredTx"
            expression="execution(* net.daum.cto.ts.techReport.report0.BookingService.*(..))" />
        <!-- txAdvice를 특정 pointcut에 적용 -->
        <aop:advisor advice-ref="txAdvice" pointcut-ref="requiredTx" />
    </aop:config>
    ...

만약 iBatis를 사용한다면 XAPool로 생성한 DataSource를 iBatis의 DataSource로 지정해주면 된다. 아래는 iBatis를 사용할 때의 Spring 설정 예이다.

<pre class="code">

    <bean id="sqlMapClient"
        class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
        <property name="configLocation"
            value="WEB-INF/sqlmap-config.xml" />
        <property name="dataSource" ref="dataSource" />
    </bean>

    <bean id="bookingService"
        class="net.daum.cto.ts.techReport.report0.BookingServiceImpl">
        <property name="bookingDao" ref="bookingDao" />
    </bean>

    <bean id="bookingDao"
        class="net.daum.cto.ts.techReport.report0.BookingDaoImpl">
        <property name="sqlMapClient" ref="sqlMapClient" />
    </bean>

</beans>

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 클라인STR 2010.06.23 16:19 신고  댓글주소  수정/삭제  댓글쓰기

    마침 찾아던 자료인데 잘보구 갑니다.

TPTP가 제공하는 Agent Server를 사용하여 원격지의 JVM을 프로파일링 하는 방법을 살펴본다.

Agent Server 설치

TPTP는 원격지 JVM에 대해서 프로파일 할 수 있는 기능을 제공하는데, 이 기능을 활용하기 위해서는 원격지 호스트에 Agent Server를 설치해야 한다. TPTP는 다양한 OS를 지원하는 Agent Server를 제공하고 있다. 본 글에서는 윈도우 및 리눅스에서 Agent Server를 설치하는 방법에 대해서 살펴보고, 이클립스에 설치한 TPTP에서 원격지 JVM에 접속해서 원격지 자바 어플리케이션을 프로파일링 하는 방법에 대해서 살펴볼 것이다.

윈도우즈 설치

윈도우즈에 Agent Server를 설치하는 과정은 다음과 같다.

  1. AgentServer 설치
    1. 윈도우즈 Agent Server 다운로드: www.eclipse.org/tptp/home/downloads/downloads.php
    2. 임의의 폴더에 압축 해제 (예, c:\win_ia32 )
  2. AgentServer 환경 설정
    1. c:\win_ia32\bin\SetConfig.bat 실행
    2. 사용할 java.exe 실행 파일의 전체 경로 입력
    3. 네트워크 Access mode 입력 (원격지에서 접속하므로 ALL 입력)
    4. Security enabled 입력 (기본값 사용)
    5. 나머지 옵션은 기본값 사용
리눅스 설치

  1. AgentServer 설치
    1. 리눅스 Agent Server 다운로드: www.eclipse.org/tptp/home/downloads/downloads.php
    2. 임의의 폴더에 압축 해제 (예, /usr/local/linux_ia32)
    3. chmod +x /usr/local/linux_ia32/lib/*
    4. chmod +x /usr/local/linux_ia32/bin/*
    5. export PATH=/usr/local/jdk1.5/jre/bin:$PATH
  2. AgentServer 환경 설정
    1. bin/SetConfig.sh 실행
    2. 사용할 $JAVA_HOME/jre/bin/java 실행 파일의 전체 경로 입력
    3. 네트워크 Access mode 입력 (원격지에서 접속하므로 ALL 입력)
    4. Security enabled 입력 (기본값 사용)
    5. 나머지 옵션은 기본값 사용
  3. AgentServer 테스트
    1. bin/RAStart.sh
    2. bin/RAStop.sh
위 과정을 수행하는 도중에 ibxerces-c.so와 관련된 에러가 발생할 경우 아래와 같은 조취를 취한다.

    $ cd /usr/local/linux_ia32/lib
    $ rm libxerces-c.so libxerces-c.so.26
    $ ln -s libxerces-c.so.26.0 libxerces-c.so
    $ ln -s libxerces-c.so.26.0 libxerces-c.so.26

TPTP를 사용한 원격지 JVM 프로파일링

외부에서 현재 호스트의 JVM을 프로파일링 할 수 있도록 하려면 아래의 순서에 따라 자바 어플리케이션을 실행하면 된다.

  1. Agent Server 실행
  2. Agent Server를 사용하여 프로파일링 할 자바 어플리케이션 실행
  3. 이클립스 TPTP에서 Agent Server가 실행중인 원격 호스트에 접속
  4. 프로파일링 시작
Agent Server 및 자바 어플리케이션 실행

원격지에 있는 이클립스 TPTP에서 로컬 호스트에서 실행중인 JVM을 프로파일링 하기 위해서는 먼저 Agent Server를 실행해주어야 한다. Agent Server가 실행되면 그 다음으로 할 일은 Agent Server를 기반으로 자바 어플리케이션을 실행하는 것이다. 이렇게 하면 Agent Server는 해당 자바 어플리케이션과 관련된 JVM을 프로파일링 할 수 있게 되며, 원격지의 이클립스 TPTP는 Agent Server에 접속해서 해당 JVM의 프로파일링 정보를 읽어올 수 있게 된다.

윈도우즈에서 Agent Server 및 자바 어플리케이션을 실행하는 과정은 다음과 같다.

  1. AgentServer 실행
    1. c:\win_ia32\bin\ACServer.exe
  2. 자바 어플리케이션 실행
    1. set PATH=c:\win_ia32\bin;%PATH%
    2. set LD_LIBRARY_PATH=c:\win_ia32\lib;%LD_LIBRARY_PATH%
    3. java -XrunpiAgent:server=enabled test.memoryleak.human.HumanTest
리눅스의 경우는 아래와 같다.

  1. AgentServer 실행
    1. /usr/local/linux_ia32/bin/RAStart.sh
  2. 자바 어플리케이션 실행
    1. export LD_LIBRARY_PATH=/usr/local/linux_ia32/lib:$LD_LIBRARY_PATH
    2. java -XrunpiAgent:server=enabled test.memoryleak.human.HumanTest
윈도우즈/리눅스에서 자바 어플리케이션을 실행할 때 -XrunpiAgent:server=enabled 옵션을 설정하였는데, 이 옵션을 지정해줌으로써 자바 어플리케이션이 Agent Server를 기반으로 실행된다. 따라서 Agent Server는 해당 자바 어플리케이션의 프로파일링 정보를 수집할 수 있게 된다.

이클립스 TPTP에서 프로파일링 시작하기

이클립스 TPTP에서 원격지에서 실행중인 자바 어플리케이션을 프로파일링 하기 위해서는 먼저 원격지에서 실행중인 Agent Server에 접속해야 한다. [Run] - [Profile As ...]를 실행한 뒤, Profile 대화창에서 [Attach - Java Process]를 더블 클릭하면 아래 그림과 같은 새로운 연결을 설정할 수 있게 된다.


Host 탭을 선택한 뒤, 우측의 [Add ...] 버튼을 클릭하면 다음과 같이 새로운 호스트를 추가할 수 있는 입력 대화창이 출력된다.


위 그림에서 Host 에는 Agent Server가 실행중인 호스트의 주소를 입력해주면 된다. [Ok] 버튼을 눌러 새로운 호스트를 추가한 다음에는 Agents 탭을 선택한다. 그럼, 아래와 같이 현재 원격지 호스트에서 실행중인 Agent Server 프로세스가 출력된다. (처음에는 Retrieving list ... 라는 메시지가 출력되며, 잠시 후, 아래 그림과 같이 목록이 출력된다.)


여기서 프로파일링할 프로세스를 클릭한 뒤, [>] 버튼을 클릭하면 (또는 프로세스를 더블 클릭하면) 아래와 같이 프로파일링 할 프로세스가 선택된다. 프로세스를 선택한 다음에 할 작업은 'Monitor' 탭을 선택하여 어떤 정보를 프로파일링 할지 선택하는 것이다. 이 부분은 앞서 1부와 2부에서 살펴봤던 내용과 동일하므로 관련 글을 참고하기 바란다.

모든 설정이 완료되면 [Profile] 버튼을 클릭한다. 그러면 원격지 호스트에서 설치된 Agent Server에 연결하게 되고, 잠시 뒤 아래 그림과 같이 TPTP가 Agent Server에 연결된 상태 정보가 출력될 것이다.


위 그림을 보면 <attached> 라고 되어 있는데, 이는 TPTP가 원격지 Agent Server에 연결은 됐지만, 아직 프로파일링을 시작하지는 않았다는 뜻이다. 프로파일링을 시작하기 위해서는 아래 그림과 같이 'Start Monitoring' 메뉴를 통해서 모니터링을 시작해야 한다.


모니터링이 시작되면 원격지 JVM에서 실행중인 메모리, 메소드 실행 등의 정보를 읽어올 수 있게 되며, 앞서 1부와 2부에서 살펴봤던 내용을 바탕으로 원격지 JVM에서 실행중인 어플리케이션의 메모리 누수와 병목 지점을 찾아낼 수 있게 된다.

결론

지금까지 살펴본 TPTP 내용은 자바 객체 및 메모리 변화, 자바 메소드의 실행 정보 등과 같은 자바 어플리케이션의 내부 실행 상황을 모니터링하고 프로파일링하는 것에 대한 내용이었다. 이런 정보를 바탕으로 메모리 누수 및 병목 현상을 발생시키는 코드를 찾아내는 방법을 살펴보았고, 또한 원격지 JVM에 대해서 프로파일링 하는 방법을 살펴보았다. 이를 통해 여러분은 성능에 문제를 발생시키는 코드를 찾아내어 문제를 줄일 수 있는 방법 한 가지를 터득했으리라 생각하고, 앞으로 성능 문제를 줄여 최대한 안정적인 어플리케이션을 만들 수 있는 힘을 얻었기를 바란다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

TPTP를 사용하여 어플리케이션의 병목 지점을 찾아내는 방법에 대해서 살펴본다.

TPTP를 활용한 병목 지점 찾기

20/80 법칙에 대해서 들어본 적이 있을 것이다. 이를 소포트웨어의 실행 시간에 맞춰서 표현하면, 전체 코드의 20%가 전체 실행 시간의 80%를 차지한다로 설명할 수 있다. 소프트웨어란 대부분 반복적으로 실행되는 코드를 갖고 있으며 for, while 등의 코드가 전체 실행 시간의 80% 이상을 차지하는 경우가 다반사다. 예를 들어, 웹 서버의 경우는 다음과 같은 코드를 갖고 있을 것이다.

    while(isAlive) {
        // 클라이언트 연결 대기
        ...
        // 클라이언트 요청 처리
        ...
    }

웹 서버에서 while 문 안에서 실행되는 코드가 전체 실행 시간의 90% 이상을 차지한다는 것은 누구라도 쉽게 예측할 수 있다. (실제로 웹 서버를 초기화와 종료를 제외한 나머지는 위와 같은 루프를 통해서 실행된다.)

20/80 법칙으로부터 생각해볼 수 있는 것은 전체 실행 시간의 80%를 차지하는 코드에서 병목 현상이 발생한다면 이는 곧 전체 어플리케이션 실행 시간에 영향력을 미친다는 것이다. 즉, 20%의 코드에서 병목이 발생하면 전체 실행 시간의 80%가 병목을 일으킨다는 뜻이 된다. 따라서, 성능이 중요한 어플리케이션의 경우 주요 코드의 병목 부분을 찾아서 해결하게 되면 전체 어플리케이션의 실행 속도를 향상시킬 수 있게 되며, 이는 곧 어플리케이션의 전체 처리량(throughput)을 증가시켜준다.

본 글에서는 TPTP를 활용하여 어떤 코드에서 병목이 발생하는 지 찾아내는 방법을 살펴볼 것이며, 이를 통해 여러분이 만든 어플리케이션의 성능을 향상시킬 수 있는 토대를 만들 수 있을 것이다.

예제 프로그램

먼저 병목 부분을 갖고 있는 예제 어플리케이션을 하나 작성해보자. 참고로 이 예제는 이클립스 사이트의 TPTP 설명 문서인 'Java Application Profiling using TPTP'에 있는 예제를 그대로 사용하였다.

본 예제는 XML 문서로부터 정보를 읽어와 그 내용을 출력해주는 코드인데, test.bottleneck.util.ProductCatalog 클래스 XML 문서를 파싱해서 상품 정보를 읽어오는 기능을 수행한다. ProductCatalog 클래스에서 SAXParser를 구해서 파싱을 시작하는 코드는 다음 부분과 같다. (완전한 소스 코드는 첨부한 파일에 포함되어 있으니 참고하기 바란다.)

    protected void parseContent(File file)
    {
        try {
            SAXParser parser = createParser();            
            InputStream is = new FileInputStream(file);

            if (is == null) { 
                return;
            }
            parser.parse(is, this);
        } 
        catch (Exception e) {
            e.printStackTrace();
        }
        return;
    }

    protected SAXParser createParser() throws ParserConfigurationException, SAXException {
        SAXParserFactory f = SAXParserFactory.newInstance();        
        f.setValidating(false);
        return f.newSAXParser();
    }

이 부분이 본 글에서 살펴볼 병목 부분인데, TPTP를 사용해서 병목을 찾아낸 뒤 어떻게 해결해 나가는 지 차례대로 살펴보도록 하겠다.

실행 시간 분석 도표 활용한 병목 찾기

'Run' > 'Profile As' 메뉴를 사용하여 Product 클래스를 실행해보자. Product 클래스는 제품 정보를 담고 있는 XML 파일이 위치한 디렉토리의 경로를 명령행 인자로 입력받으므로, 프로파일을 실행할 때 'Arguments' 탭에 알맞게 경로를 입력해야 한다. (예를 들면, c:\eclipse3.2\workspace\TestBottleNeck\product 와 같은 경로를 입력)

프로파일 모드로 어플리케이션을 실행한 뒤, 프로파일 좌측 메뉴에서 'Execution Time Analysis'를 더블 클릭해서 실행 시간 분석 결과를 살펴보도록 하자. 그럼, 아래 그림과 비슷한 결과가 출력될 것이다. 아래 그림은 메소드 단위로 출력한 것으로서 패키지나 클래스 단위로도 결과를 볼 수 있다. (본 글에서는 test.bottleneck.* 에 해당하는 클래스를 제외한 나머지는 분석 결과에서 제외하도록 설정하였다.)


위 결과에서 먼저 간단하게 각 컬럼에 대해 설명을 하자면 아래와 같다.

  • Base Time: 메소드 전체 실행 시간 중에서, 메소드 내에서 다른 메소드를 호출할 때 소요된 시간을 제외한 나머지 시간
  • Average Base Time: Base Time을 호출 회수로 나눈 것
  • Cumulative Time: 메소드의 전체 실행 시간
따라서, Base Time의 값이 가장 높은 메소드가 전체 실행 시간 중에서 가장 많은 비중을 차지한다는 것을 알 수 있다. 위 그림의 경우 % 단위를 사용해서 해당 메소드를 실행하는 데 소요된 시간이 전체 어플리케이션 실행 시간의 몇 %에 해당하는 지를 보여주고 있는데, ProductCatalog.createParser() 메소드가 16.30%로 가장 높은 비중을 차지하는 것을 알 수 있다.

따라서 createParser() 메소드는 병목 현상을 일으키는 후보로 생각할 수 있다. 통계표에서 createParser() 부분을 더블 클릭하면 메소드 호출에 대한 상세 정보가 호출되는데, 그 화면은 다음과 같다.


위 그림에서 각 영역은 다음과 같다.

  • Selected method: 선택한 메소드
  • Selected method invoked by: 선택한 메소드를 내부적으로 호출하는 메소드
  • Selected method invokes: 선택한 메소드가 내부적으로 호출하는 메소드
즉, 위 그림의 경우 createParser() 메소드에 대한 정보를 보여주고 있는데, 이 중에서 ProductCatalog.parseContent() 메소드는 내부적으로 createParser() 메소드를 호출하고, createParser() 메소드가 내부적으로 호출하는 메소드는 없다는 것을 의미한다. (실제적으로는 다른 클래스의 메소드를 호출하고 있으나, 본 예제에서는 프로파일링 정보를 test.bottlenck.* 에 대해서만 수집하였기 때문에, 없는 것으로 나왔다).

이 결과에서 중요한 건, parseContent() 메소드의 전체 실행 시간 28.16% 중에서 createParser() 메소드 호출과 관련된 비중이 17% 가깝다는 점이다. 따라서 createParser() 메소드가 병목 지점일 가능성을 다시 한번 확인할 수 있게 되었다. 다시 한번 createParser() 메소드를 살펴보면 다음과 같이 실행 될 때 마다 매번 파서 객체를 생성하는 것을 알 수 있다.

    protected SAXParser createParser() throws ParserConfigurationException, SAXException {
        SAXParserFactory f = SAXParserFactory.newInstance();        
        f.setValidating(false);
        return f.newSAXParser();
    }

이 예제의 경우 SAXParser를 매번 생성하지 않더라도 상관없기 때문에 다음과 같이 처음만 SAXParser를 생성하고 이후에는 이미 생성한 객체를 리턴하도록 코드를 수정할 수 있을 것이다.

    private SAXParser parser = null;
    protected SAXParser createParser() throws ParserConfigurationException, SAXException {
        if (parser == null) {
            SAXParserFactory f = SAXParserFactory.newInstance();        
            f.setValidating(false);
            parser = f.newSAXParser();
        }
        return parser;
    }

위와 같이 변경한 다음에 다시 한번 프로파일링을 해보자. 메소드 실행 정보를 보면 아래와 같이 createParser()가 차지하는 실행 시간 비중이 줄어든 것을 알 수 있으며, 이에 전체적인 실행 시간이 향상된 것을 확인할 수 있다. (퍼센트 단위가 아닌 시간 단위로 결과를 보면 알 수 있다.)



결론

본 글에서는 어플리케이션에서 병목을 일으키는 부분을 TPTP를 사용하여 발견하는 방법에 대해서 살펴보았다. TPTP가 제공하는 'Execution Time Analysis' 기능을 사용해서 실행 시간 중 많은 부분을 차지하는 메소드를 찾는 방법을 알게 되었으며, 이를 통해 병목이 예상되는 메소드를 추려낼 수 있게 되었다. 병목 부분은 전체 어플리케이션의 성능에 영향을 미치기 때문에 반드시 찾아서 없애는 것이 좋으며, TPTP가 이 작업을 도와줄 것이다. 다음 글에서는 마지막으로 에이전트 서버를 사용하여 원격지 JVM에서 실행되는 자바 어플리케이션을 프로파일링 하는 방법에 대해서 살펴보도록 하겠다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

TPTP를 이클립스에 설치하는 방법과 TPTP를 이용하여 메모리 누수 코드를 찾는 방법에 대해서 살펴본다.

TPTP 설치하기

메모리 누수를 찾는 것은 쉽지 않은데 그 이유는 메모리가 부족한 현상이 어플리케이션을 실행하자 마자 곧 바로 나타나는 것이 아니라 짧게는 1-2시간, 길게는 1주일 후에나 발생하기 때문이다. 예를 들어, 웹 어플리케이션을 구동하면 대략 48시간 정도 후에 시스템에서 OutOfMemoryError 와 같은 치명적 에러가 발생하면서 어플리케이션이 죽는 경우가 있을 것이다. 또한, 메모리가 부족하여 시스템이 죽는 걸 알게 되어도 어떤 코드 때문에 메모리 누수 현상이 발생하는 지 찾는 것 또한 쉽지 않다.

메모리 누수 현상이 발생할 경우 이를 해결하는 가장 좋은 방법은 어플리케이션을 프로파일링 하여 메모리 누수가 될 만한 코드를 찾는 것이다. 프로파일링을 통해서 메모리 누수가 발생하는 코드를 찾을 수 있도록 도와주는 다양한 툴이 존재하는데, 본 글에서는 TPTP가 제공하는 프로파일링 기능을 사용하여 메모리 누수가 예상되는 코드를 찾아내는 방법에 대해서 살펴볼 것이다.

TPTP 설치하기

TPTP는 이클립스 Test & Performance Tool Platform 프로젝트의 약자로서, 이클립스에서 오픈 소스로 진행중인 테스트 및 성능 관련 툴을 위한 플랫폼이다. TPTP는 모니터링, 테스트 자동화, 프로파일링 등 어플리케이션의 문제점을 찾고 해결하는 데 도움이 되는 기능을 제공하고 있다. TPTP는 이클립스 플러그인으로 제공되며, 설치 과정은 아래와 같다.

  1. 이클립스 3.2 설치: http://www.eclipse.org/downloads/
  2. EMF 2.2.1 SDK 버전(EMF, SDO, XSD가 함께 포함된 배포판)을 다운로드 하여 이클립스 폴더에 설치: http://www.eclipse.org/emf/downloads/
  3. UML2 2.0.1 SDK 버전을 다운로드하여 이클립스에 설치: http://download.eclipse.org/tools/uml2/scripts/downloads.php
  4. TPTP 4.2.0.x All Runtime 버전을 다운로드 하여 이클립스에 설치: http://www.eclipse.org/tptp/
EMF, UML2, TPTP 등은 모두 플러그인 형태로 제공되기 때문에, 압축을 풀면 plugins 폴더와 features 폴더가 생성된다. 이 두 폴더를 이클립스 디렉토리에 그대로 복사한 뒤 이클립스를 재실행하기만 하면 모든 설치가 완료된다. 이클립스를 실행한뒤, 'Run' 메뉴를 클릭하면, 아래 그림과 같이 프로파일과 관련된 메뉴가 추가된 것을 확인할 수 있다.

 
위에서 설명한 EMF, SDO, XSD, UML2의 버전은 이클립스 3.2에서 올바르게 동작하는 것으로 이클립스 3.1을 사용할 경우 그에 알맞은 버전을 설치해야 TPTP를 실행할 수 있으니 주의하기 바란다.

메모리 누수를 찾는 과정

자바는 개발자가 별도로 메모리 관리를 하지 않아도 가비지 콜렉터를 통해서 사용되지 않는 객체를 메모리에서 제거해준다. 가비지 콜렉터가 메모리 관리를 수행하는 알고리즘이 단순하진 않지만, 기본적으로 더 이상 참조되지 않는 객체를 가비지로 인식하여 해당 객체를 메모리에서 제거하게 된다. 예를 들어, 아래의 코드를 살펴보자.

    Human h = new Hunam(); // 새로운 Human 객체 생성
    ....
    h = null; // 더 이상 Human 객체를 참조하지 않음

위 코드에서 새로 생성한 Human 객체를 h 가 참조하도록 했는데, 어떤 처리를 수행한 후 h 에 null을 할당하였다. 이렇게 되면 생성된 Human 객체는 더 이상 참조되지 않으며, 가비지 콜렉터의 대상이 된다. 객체가 더 이상 참조되지 않는다고 해서 곧바로 메모리에서 제거되는 것은 아니며, 메모리가 부족하거나 인위적으로 가비지 콜렉터를 실행했을 때 비로서 참조되지 않는 객체가 메모리에서 제거된다.

하지만, 개발자의 실수로 더 이상 사용되지 않는 객체를 어디선가 참조하게 되면, 그 객체는 가비지 콜렉터의 대상이 되지 않으며 따라서 지속해서 메모리에 남아 있게 된다. 이런 객체가 많아질수록 메모리는 불필요한 객체로 공간을 낭비하게 되며, 결국 새로운 객체를 생성해서 저장할 메모리 공간이 부족한 상태가 발생하게 된다.

메모리 누수를 발견하는 과정

메모리 누수가 발생하는 이유는 가비지 콜렉터에 의해 메모리에서 제거되어야 할 객체가 계속해서 누군가에 의해 참조되고 있기 때문일 것이다. 따라서, 어떤 객체가 메모리 누수를 일으키는 지 알아내기 위해서는 특정 시점에 메모리에 저장된 객체들의 상태를 알아내는 것이 중요하다. 즉, 현재 어떤 클래스의 객체가 몇개 만들어졌고, 그 중에 몇개가 가비지 콜렉터에 의해 제거되었고, 또, 객체들 사이의 참조 관계는 어떻다라는 것을 알아야 하는 것이다.

대부분의 프로파일링 툴은 특정 시점의 메모리 상태를 알아낼 수 있는 기능을 제공하고 있으며, TPTP가 제공하는 프로파일링 툴 역시 이 기능을 제공하고 있다. 이 기능을 사용하면 특정 시점의 메모리 스냅샵을 생성할 수 있는데, 툴 마다 다르지만 TPTP의 경우는 다음과 같은 메모리 스냅샵을 제공한다.


위 그림을 보면 현재까지 생성된 객체의 개수와 살아있는(active) 객체, 그리고 제거된(collected) 객체의 수 뿐만 아니라 바이트 단위로 현재 사용중인 메모리 사용량까지 표시되어 있다. TPTP 이외의 프로파일링 툴 역시 이와 비슷한 정보를 제공하며, 아래와 같은 절차에 따라 메모리 누수가 발생하는 객체를 찾을 수 있게 된다.


메모리 누수를 발생시킬거라고 의심되는 코드를 실행하기에 앞서 메모리 스냅샵을 구한다. 그리고 나서, 해당 코드를 실행하고 그 다음에 다시 메모리 스냅샷을 구한다. 만약 GC 되어야 할 객체가 잘못된 코드에 의해 GC 되지 않는다면 스냅샷1에서는 존재하지 않았던 (또는 개수가 매우 적었던) 객체의 수가 스냅샷2에서는 증가되어 있을 것이다. 메모리 스냅샷을 구하기에 앞서 GC를 실행하는 이유는 더 이상 참조되지 않는 객체를 메모리에서 제거함으로써 좀더 정확하게 의심 객체를 찾기 위함이다.

TPTP를 사용한 메모리 누수 찾기

TPTP를 사용할 때에도 앞서 설명한 메모리 스냅샷을 사용해서 메모리 누수를 발생시키는 객체를 찾을 수 있으며, TPTP가 제공하는 추가적인 기능을 사용해서 메모리 누수를 발생시키는 메소드까지도 발견해낼 수 있다. 본 절에서는 TPTP를 사용하여 메모리 누수 코드를 발견하는 과정을 살펴보도록 하겠다.

메모리 누수가 발생하는 예제 어플리케이션

일단 메모리 누수를 일으키는 코드부터 작성해보도록 하자.

    package test.memoryleak.human;
    
    import java.io.BufferedReader;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.util.HashMap;
    import java.util.Map;
    
    public class HumanTest {
        
        private Map<Integer, Human> tempMap;
        
        public  HumanTest() {
            tempMap = new HashMap<Integer, Human>();
        }
        
        public void testHuman(int i) {
            Human human = new Human(i);
            tempMap.put(human.getId(), human);
            
            if (i > 0 && i % 3 != 0) { // 의도적으로 i가 3의 배수일 때 tempMap에서 제거되지 않도록 함
                tempMap.remove(human.getId());
            }
        }
        
        public static void main(String[] args) throws IOException {
            HumanTest test = new HumanTest();
            
            System.out.println("press enter ....");
            BufferedReader br = null;
            br = new BufferedReader(new InputStreamReader(System.in));
            br.readLine();
            
            try {
                for (int i = 1 ; i <= 10000 ; i++) {
                    test.testHuman(i);
                    if (i == 10) {
                        System.out.println(i +" humans processed");
                        System.out.println("press enter ....");
                        br.readLine();
                    }
                    if (i > 0 && i % 5000 == 0) {
                        try {
                            Thread.sleep(2000);
                        } catch(Throwable ex) {}
                        System.out.println(i+" humans processed");
                        br.readLine();
                    }
                }
                System.out.println("completed.");
            } catch(OutOfMemoryError ex) {
                System.out.println("error: "+ex.getMessage());
            }
            System.out.println("press any key....");
            br.readLine();
        }
    }

위 코드에서 testHuman() 메소드는 Human 객체를 생성한 뒤 임시로 tempMap에 저장한다. 그리고 Human 객체가 사용된 뒤에 마지막에 임시로 저장한 tempMap에서 Human 객체를 제거한다. 하지만, 메모리 누수 현상을 설명하기 위해 의도적으로 i의 값이 3의 배수인 경우에는 tempMap에서 Human 객체를 제거하지 않았다. 따라서, tempMap에 저장된 Human 객체는 tempMap이 Human 객체를 참조하고 있으므로, GC의 대상이 되지 않게 되며, 곧 메모리 누수가 발생하게 된다.

프로파일 시작하기

메모리 스냅샷을 구하기 위해서는 자바 어플리케이션을 프로파일이 가능한 상태로 실행해야 한다. 프로파일이 가능하도록 어플리케이션을 수행하는 과정은 다음과 같다.

  1. 'Run' > 'Profile As' > 'Java Application' 을 실행
  2. Monitor 탭을 선택해서 프로파일 할 옵션을 선택

     
    • 'Java Profiling' 옵션을 선택한 뒤, 'Edit Options'을 클릭한 뒤, 아래와 같이 조사할 클래스의 범위 선택


      [Next] 버튼을 클릭한 다음에 아래 그림과 같이 어플리케이션이 구동될 때 자동적으로 프로파일을 시작하도록 설정

    • 'Basic Memory Analysis' 옵션을 선택
    • 'Execution Time Analysis' 옵션 선택한 뒤, 'Edit Options'를 클릭하여 'Show execution flow graphical details'를 선택
조사할 클래스의 범위를 지정해주지 않으면 모든 클래스에 대해서 프로파일링 하게 되는데, 이 경우 프로파일링에 따른 부하 때문에 어플리케이션을 실행하는 데 방해를 줄 수도 있다. 따라서, 분석해야만 하는 클래스에 대해서만 프로파일링 하도록 클래스 범위를 지정해주는 것이 좋다.

설정이 완료되었다면 'Ok' 버튼을 눌러서 프로파일을 시작해보자. 그럼, 아래와 같은 대화창이 뜰 것이다.


이 대화창은 'Profiling and Logging' 퍼스펙티브로 전환할지 묻는 것으로 'Yes' 버튼을 클릭하면 아래 그림과 같이 프로파일링을 위한 퍼스펙티브로 전환된다.


위 화면에서 좌측 영역은 프로파일과 관련된 기능을 제공하며, 우측 영역은 프로파일링 정보를 보여준다. 이제 어플리케이션에서 메모리가 누수되는 지점을 찾을 준비가 완료되었다.

메모리 스냅샵으로부터 메모리 누수되는 객체 추측

메모리 누수가 발생하는 지점을 찾기 위해서는 먼저 어떤 객체가 메모리 누수의 범인인지 찾아내야 한다. 이를 위해서는 앞서 설명했듯이 메모리 스냅샷 정보가 필요하다. 예제 어플리케이션은 testHuman() 메소드를 10,000번 실행하는 데, 처음 10번째 까지 실행한 뒤 엔터키 입력을 기다린다. 콘솔 화면에서 엔터키를 눌러보면 아래 그림과 같이 10번 실행된 뒤 키 입력을 기다릴 것이다.


이 상태에서 메모리 스냅샷을 살펴보자. 메모리 스냅샵을 구하기 전에 GC를 먼저 실행해야 하는데, GC는  아이콘을 클릭하거나 팝업 메뉴를 통해서 실행할 수 있다. GC를 실행한 뒤, 'Basic Memory Analysis'를 더블 클릭해서 메모리 스냅샷을 구해보자. 아래와 비슷한 메모리 스냅샷 결과가 출력될 것이다.


위 그림을 보면 Human 객체가 총 10개 생성되었고 그 중에 4개가 여전히 메모리에 존재하고, 6개는 GC에 의해 메모리에서 제거되었음을 알 수 있다. 4개의 Human 객체가 메모리 존재한다는 사실을 통해서 Human 객체가 메모리 누수를 일으킬 수 있는 가능성을 확인할 수 있다. 이제 콘솔에서 다시 한번 엔터키를 눌러보자. 그럼, 아래 그림과 같이 메모리 부족 현상이 발생함을 알 수 있다.


메모리 스냅샷을 확인하기 위해 GC를 실행하고, 'Memory Statistics' 화면을 'Refresh' 해보자. 그럼, 아래와 같이 메모리 상태에 변화가 생겼을 것이다.


위 그림을 보면 Human 객체가 904개가 생겼는데 이 중에 302개나 메모리에 존재한다는 것을 알 수 있으며, 이를 통해 Human 객체가 누수되고 있다는 사실을 확신할 수 있게 되었다.

객체 참조 그래프로부터 GC 방해 요소 추측

Human 객체가 메모리 누수의 범인이라는 점을 밝혀냈으므로, 이제 어떤 객체가 Human 객체를 참조하고 있는지를 알아내야 한다. 가비지 콜렉터는 기본적으로 어떤 객체에 의해서도 참조되지 않는 객체에 대해서 메모리 반환을 수행하기 때문에, Human 객체가 메모리에 남아 있다는 것은 곧 Human 객체를 누군가가 참조하고 있다는 뜻이된다.

객체 참조 상태를 확인하기 위해서는 먼저 객체 참조 정보를 수집해야 한다. 아래 그림과 같이 팝업 메뉴 또는 해당 아이콘을 클릭함으로써 현재 상태의 객체 참조 그래프를 구할 수 있다.


객체 참조 정보를 구했다면, 이제 아래와 같이 실행하여 객체 참조 정보를 확인하도록 하자.


객체 참조 정보에서 Human 객체의 참조 정보를 확인하면 아래 그림과 같은 그래프를 확인할 수 있다. 아래 그래프를 보면 Human 객체를 HashMap 객체가 참조하고 있고, 이 HashMap 객체를 HumanTest 가 참조하고 있다는 것을 알 수 있다. 즉, HumanTest 객체의 (HashMap 타입의) tempMap 필드가 Human 객체를 저장하고 있다는 것을 확인할 수 있다.



클래스 인터액션 그래프로부터 누수 예상 메소드 추측

HumanTest 클래스의 tempMap 필드가 Human 객체를 참조하기 때문에 Human 객체가 메모리에서 제거되지 않고 메모리 누수를 일이키고 있다는 것을 알게 되었다. 이쯤 되면 대충 어떤 부분에서 잘못된 코드를 작성했는 지 유추해 낼 수 있다. 하지만, 많은 클래스가 존재하고 참조 문제를 일으키는 범인 클래스가 다양한 곳에서 사용되는 경우가 있다. 이런 경우에는 클래스들 간의 인터액션을 살펴봄으로써 어떤 메소드에서 문제가 있는 지를 찾아낼 수 있다. 아래 그림과 같이 'UML2 Class Interaction' 메뉴를 실행해보자.


그럼, 클래스 사이의 메소드 호출 관계 및 실행 순서를 시퀀스 다이어그램을 통해서 확인할 수 있다. 이 그래프를 살펴보면 아래와 같은 부분을 발견할 수 있을 것이다.


위 그림에서 파란색 박스 부분은 올바르게 실행된 testHuman() 메소드의 경우이다. 파란색 부분은 HashMap의 put과 remove가 호출된 것을 확인할 수 있다. 즉, Human 객체를 생성한 뒤 HashMap에 저장했다가 메소드를 종료하기 전에 HashMap에 제거했다는 것을 유추할 수 있다. 반면에 빨간색 박스 부분은 메소드를 리턴하기 전에 remove 메소드가 호출되지 않는 것을 볼 수 있는데, 이는 HashMap에 저장된 Human 객체가 메소드가 testHuman() 메소드가 리턴되기 전에 HashMap에서 제거되지 않는다는 것을 유추할 수 있다. 즉, TestHuman 클래스의 testHuman() 메소드가 메모리 누수를 발생시키는 코드를 포함하고 있다는 것을 알아낸 것이다.

이제 남은 작업은 메모리 누수를 발생시키는 코드를 찾아서 문제를 제거하는 것만 남았다!!

결론

본 글에서는 TPTP가 제공하는 프로파일링 기능을 사용하여 누수되고 있는 객체를 찾아내고, 해당 객체를 누수 시키는 코드를 찾아나가는 과정을 살펴보았다. 본 글에서 소개한 방식을 통해서 여러분은 메모리 누수를 일으키는 문제의 코드를 비교적 빠르게 찾아낼 수 있게 되었을 것이다. 다음 글에서는 TPTP를 사용하여 어플리케이션의 병목(bottleneck) 부분을 찾는 방법에 대해서 살펴볼 것이며, 이를 통해 어플리케이션의 실행 속도를 향상시키는 방법을 배우게 될 것이다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 알려주세요 2013.01.30 16:57 신고  댓글주소  수정/삭제  댓글쓰기

    톰켓외에도 jboss도 가능하나요? 좋은거 설명해주셔서 감사합니다.
    이해가 부족하여, 여쭤봅니다.

    1. jboss도 가능한가요?
    2. EMP, TPTP 이 두개만 이클립스 설치경로 plugin 에 압축풀어서 jar 파일넣으면..
    메모리 스냅샷 확인가능하나요?

자바에서 이미지의 크기를 변환할 때 품질을 유지하는 방법을 살펴본다.

이미지 크기 변환시 품질 유지 방법

필자가 쓴 'JSP 2.0 프로그래밍' 책에서 자바 1.4부터 추가된 ImageIO 클래스를 사용해서 썸네일 이미지를 작성하는 방법을 소개한 바 있다. 이 때 소개한 코드는 다음과 같다.

    public static void resize(File src, File dest, 
                              int width, int height) throws IOException {
        BufferedImage srcImg = ImageIO.read(src);
        
        int srcWidth = srcImg.getWidth();
        int srcHeight = srcImg.getHeight();
        ...
        BufferedImage destImg = new BufferedImage(
             destWidth, destHeight, BufferedImage.TYPE_3BYTE_BGR);
        Graphics2D g = destImg.createGraphics();
        g.drawImage(srcImg, 0, 0, destWidth, destHeight, null);
        
        ImageIO.write(destImg, "jpg", dest);
    }

필자는 최근에 이미지 관련 서비스를 개발하고 있는데, 썸네일 이미지를 비롯하여 원본 이미지보다 작은 크기의 이미지를 몇 개 생성할 필요가 있어서 위와 같은 코드를 사용하였다. 위 코드는 비교적 빠른 속도로 이미지 크기 변환을 처리했지만, 새롭게 생성된 이미지의 품질이 떨어진다는 문제점을 발견하게 되었다.

이미지 품질을 높이기 위해 찾아낸 해결책은 Image.getScaledInstance(int width, int height, int hints) 메소드를 사용하는 것이었다. 로딩한 이미지를 getScaledInstance() 함수를 통해서 다음과 같이 크기 변환을 하면 변환된 이미지의 품질이 떨어지지 않게 되었다.

    BufferedImage srcImg = ImageIO.read(src);
    Image imgTarget = srcImg.getScaledInstance(destWidth, destHeight, Image.SCALE_SMOOTH);
    int pixels[] = new int[destWidth * destHeight]; 
    PixelGrabber pg = new PixelGrabber(imgTarget, 0, 0, destWidth, destHeight, pixels, 0, destWidth); 
    try {
        pg.grabPixels(); // JEPG 포맷의 경우 오랜 시간이 걸린다.
    } catch (InterruptedException e) {
        throw new IOException(e.getMessage());
    } 
    BufferedImage destImg = new BufferedImage(destWidth, destHeight, BufferedImage.TYPE_INT_RGB); 
    destImg.setRGB(0, 0, destWidth, destHeight, pixels, 0, destWidth); 
    

위 코드와 같이 getScaledInstance() 메소드의 세번째 파라미터로 Image.SCALE_SMOOTH를 사용하면 새롭게 생성된 이미지의 품질이 떨어지지 않게 된다. getScaledInstance() 메소드가 생성한 Image 객체로부터 픽셀 정보를 읽어온 뒤, 새롭게 생성한 BufferedImage에 채워 넣어주면 이미지 크기 변환이 마무리 된다.

그런데, 이미지 변환 과정에서 한가지 문제가 발생하였다. 그것은 바로 PNG, GIF, BMP와 달리 JPEG 포맷을 변환할 때는 PixelGrabber.granPixels() 함수를 실행할 때 시간이 오래 걸린다는 것이었다. (이미지 크기가 3000*2000인 경우 3분 이상 소요되기도 했다.) 그래서 몇가지 테스트 끝에 다음과 같이 ImageIcon 클래스를 사용해서 JPEG 이미지를 로딩할 경우 오랜 처리 시간 문제를 해결할 수 있다는 걸 알게 되었다. (왜 JPEG을 사용할 때 시간이 오래 걸리는 지의 문제는 아직 정확하게 파악하지 못했으나, BufferedImage가 JEPG 이미지를 저장할 때 사용되는 방식 때문인 것으로 판단된다.)

    Image srcImg = new ImageIcon(src.toURL()).getImage(); // JPEG 포맷인 경우

그런데, ImageIcon의 경우는 GIF와 JPEG 포맷의 이미지만 사용할 수 있기 때문에, BMP나 PNG 같은 파일은 ImageIcon으로 읽어올 수가 없다. 따라서, 다음과 같이 이미지 포맷에 따라서 서로 다른 방식으로 이미지를 로딩하도록 하였다.

    Image srcImg = null;
    String suffix = src.getName().substring(src.getName().lastIndexOf('.')+1).toLowerCase();
    if (suffix.equals("bmp") || suffix.equals("png") || suffix.equals("gif")) {
        srcImg = ImageIO.read(src);
    } else {
        // JPEG 포맷
        srcImg = new ImageIcon(src.toURL()).getImage();
    }

위와 같은 코드를 사용하면, JPEG인 경우에는 ImageIcon을 사용해서 Image를 생성하고 그 외에 경우에는 ImageIO.read()를 사용해서 Image를 읽어오게 된다. 이제 JPEG 포맷에 대해서 PixelGrabber.granPixels() 메소드를 사용하더라도 빠르게 이미지 크기 변환을 수행할 수 있게 되었다.

이미지 변환과 관련된 완전한 코드는 다음과 같다.

    public class ImageUtil {
        public static final int RATIO = 0;
        public static final int SAME = -1;
        
        public static void resize(File src, File dest, int width, int height) throws IOException {
            Image srcImg = null;
            String suffix = src.getName().substring(src.getName().lastIndexOf('.')+1).toLowerCase();
            if (suffix.equals("bmp") || suffix.equals("png") || suffix.equals("gif")) {
                srcImg = ImageIO.read(src);
            } else {
                // BMP가 아닌 경우 ImageIcon을 활용해서 Image 생성
                // 이렇게 하는 이유는 getScaledInstance를 통해 구한 이미지를
                // PixelGrabber.grabPixels로 리사이즈 할때
                // 빠르게 처리하기 위함이다.
                srcImg = new ImageIcon(src.toURL()).getImage();
            }
            
            int srcWidth = srcImg.getWidth(null);
            int srcHeight = srcImg.getHeight(null);
            
            int destWidth = -1, destHeight = -1;
            
            if (width == SAME) {
                destWidth = srcWidth;
            } else if (width > 0) {
                destWidth = width;
            }
            
            if (height == SAME) {
                destHeight = srcHeight;
            } else if (height > 0) {
                destHeight = height;
            }
            
            if (width == RATIO && height == RATIO) {
                destWidth = srcWidth;
                destHeight = srcHeight;
            } else if (width == RATIO) {
                double ratio = ((double)destHeight) / ((double)srcHeight);
                destWidth = (int)((double)srcWidth * ratio);
            } else if (height == RATIO) {
                double ratio = ((double)destWidth) / ((double)srcWidth);
                destHeight = (int)((double)srcHeight * ratio);
            }
            
            Image imgTarget = srcImg.getScaledInstance(destWidth, destHeight, Image.SCALE_SMOOTH); 
            int pixels[] = new int[destWidth * destHeight]; 
            PixelGrabber pg = new PixelGrabber(imgTarget, 0, 0, destWidth, destHeight, pixels, 0, destWidth); 
            try {
                pg.grabPixels();
            } catch (InterruptedException e) {
                throw new IOException(e.getMessage());
            } 
            BufferedImage destImg = new BufferedImage(destWidth, destHeight, BufferedImage.TYPE_INT_RGB); 
            destImg.setRGB(0, 0, destWidth, destHeight, pixels, 0, destWidth); 
            
            ImageIO.write(destImg, "jpg", dest);
        }
    }

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. lae 2016.05.10 14:05 신고  댓글주소  수정/삭제  댓글쓰기

    이 클래스를 사용할 경우에
    java.lang.ArrayIndexOutOfBoundsException: 700
    at java.awt.image.BufferedImage.setRGB(Unknown Source)
    이러한 에러가 뜨는데 어떻게 사용을 해야할지 답변좀 부탁드릴게요!!

    • 최범균 madvirus 2016.05.10 23:54 신고  댓글주소  수정/삭제

      혹시 실행할 때 사용하신 코드와 이미지를 madvirus@madvirus.net으로 보내주실 수 있을까요? 간단한 이미지 파일로 확인해보니 동작은 되는듯 한데, 실제 에러 나는 파일을 갖고 해 봐야 좀더 구체적으로 알 수 있을 것 같아요.
      그리고, 이게 10년 전 코드라 더 좋은 방법이 있을 것도 같습니다.

Commons Net이 제공하는 FTPClient를 사용하여 파일을 송수신하는 방법을 살펴본다.

Jakarta Commons Net API

  • FTP, TFTP
  • SMTP, POP3
  • telnet
  • Finger, Whois, Time, Echo 등
Commons Net 라이브러리는 다양한 프로토콜에 대한 지원을 할 수 있는 구조를 갖고 있으며, 위 목록과 같이 이미 기존에 많이 사용되는 몇몇 프로토콜에 대한 클라이언트 모듈을 제공하고 있다. 또한, 개발자가 직접 프로토콜 처리를 하지 않아도 되기 때문에 관련 모듈을 빠르게 개발할 수 있는 장점도 있다.

Commons Net 라이브러리 설치하기

Commons Net 라이브러리는 http://jakarta.apache.org/commons/net 에서 다운로드 받을 수 있다. 현재 버전은 1.4.1 버전으로서, 다운로드 받은 파일의 압축을 풀면 commons-net-1.4.1.jar 파일이 생성되는데, 이 파일을 클래스패스에 추가해주면 된다.

Commons Net은 Jakarta ORO API를 필요로 한다. Jakarta ORO 관련 모듈은 http://jakarta.apache.org/oro/index.html 사이트에서 다운로드 받을 수 있다. 현재 Jakarta ORO의 버전은 2.0.8로서, 배포판의 압축을 풀면 jakarta-oro-2.0.8.jar 파일이 생성되는데 이 파일을 클래스패스에 추가해면 설치가 완료된다.

Commons Net API로 FTP 파일 송수신하기

Commons Net API는 FTP와 관련해서 다음의 두 클래스를 제공하고 있다.

  • org.apache.commons.net.ftp.FTPClient
    FTP 프로토콜 처리를 위한 API를 제공한다. 서버 연결, 로그인, 파일 목록, 송수신 등의 기능을 제공한다.
  • org.apache.commons.net.ftp.FTPFile
    FTP 서버의 파일 표현할 때 사용되는 클래스. 파일명, 파일크기, 위치 등에 대한 정보를 제공한다.
이 두 클래스만 사용하면 원격지의 FTP 서버로부터 원하는 파일을 송수신 할 수 있다. FTPClient API는 FTP 프로토콜과 관련해서 로그인, 파일 목록, 경로변경 등과 관련된 기능을 제공하는 데, 이들 기능과 관련된 메소드는 다음과 같다.

conect(Strint server, int port)
connect(String server)
FTP 서버에 접속한다. 포트 번호를 지정할 경우 해당 포트로 접속하며, 포트 번호를 지정하지 않으면 기본 포트인 21번 포트로 접속한다.
login(String user, String pass) 지정한 사용자와 암호를 사용하여 로그인한다.
changeWorkingDirectory(String path) FTP 서버에서 현재 작업 디렉토리의 경로를 변경한다.
String printWorkingDirectory() 현재 작업 디렉토리를 구한다.
FTPFile[] listFiles()
FTPFile[] listFiles(String path)
현재 작업 디렉토리에 있는 파일 목록을 구한다.
경로를 지정할 경우 해당 경로의 파일 목록을 구한다.
String[] listNames()
FTPFile[] listFiles(String path)
현재 작업 디렉토리에 있는 파일 이름을 구한다.
경로를 지정할 경우 해당 경로의 파일 이름을 구한다.
makeDirectory(String path) 새로운 디렉토리를 생성한다.
rename(String from, String to) from에 해당하는 경로를 to로 변경한다.
retriveFile(String remoteName, OutputStream local) FTP 서버의 remoteName 파일을 local에 지정된 출력 스트림에 다운로드 한다. 성공적으로 완료되면 true를 리턴한다.
InputStream retrieveFileStream(String remoteName) FTP 서버의 remoteName 파일로부터 데이터를 읽어오는 입력 스트림을 리턴한다.
storeFile(String remoteName, InputStream local) local 입력 스트림으로부터 데이터를 읽어와 FTP 서버에 remoteName 경로로 업로드한다. 성공적으로 파일을 업로드 하면 true를 리턴한다.
logout() FTP 서버에서 로그아웃한다.
disconnect() 서버와의 연결을 종료한다.

이 외에 몇가지 메소드가 더 존재하지만, 일단 위 메소드 정도만 사용하면 충분히 FTP 프로토콜을 사용해서 파일을 송수신 할 수 있게 된다.

파일 다운로드

파일을 다운로드 하기 위해서는 다음과 같은 순서로 코드를 작성하면 된다.

  1. FTPClient.connect(): FTP 서버에 접속한다.
  2. FTPClient.login(): 로그인 한다.
  3. FTPClient.changeWorkingDirectory(): 파일이 있는 폴더로 이동한다.
  4. FTPClient.retrieveFile(): 파일을 다운로드 한다.
  5. FTPClient.logout(): 로그아웃한다.
  6. FTPClient.disconnect(): 연결을 종료한다.
이를 실제 코드로 작성하면 다음과 같다.

    FTPClient ftp = null;
    try {
        ftp = new FTPClient();
        ftp.setControlEncoding("UTF-8");
        
        ftp.connect("ftp.somehost");
        ftp.login("user", "pass");
        ftp.changeWorkingDirectory("/dbdump");

        File f = new File("d:\\dbdump", "oradump1_200605.tmp");
        FileOutputStream fos = null;
        try {
            fos = new FileOutputStream(f);
            boolean isSuccess = ftp.retrieveFile("oradump1.tmp", fos);
            if (isSuccess) {
                // 다운로드 성공
            } else {
                // 다운로드 실패
            }
        } catch(IOException ex) {
            System.out.println(ex.getMessage());
        } finally {
            if (fos != null) try { fos.close(); } catch(IOException ex) {}
        }
        ftp.logout();
    } catch (SocketException e) {
        System.out.println("Socket:"+e.getMessage());
    } catch (IOException e) {
        System.out.println("IO:"+e.getMessage());
    } finally {
        if (ftp != null && ftp.isConnected()) {
            try { ftp.disconnect(); } catch (IOException e) {}
        }
    }

파일 다운로드

파일 업로드는 파일을 다운로드 하는 것과 크게 다르지 않다. 차이점이 있다면 retrieveFile() 메소드 대신 storeFile() 메소드를 사용한다는 것 뿐이다. 파일 업로드 하는 예제 코드는 다음과 같다.

    FTPClient ftp = null;
    try {
        ftp = new FTPClient();
        ftp.setControlEncoding("UTF-8");
        
        ftp.connect("camp.cvnet.co.kr");
        ftp.login("uniasset", "unisise");
        ftp.changeWorkingDirectory("/dbdump");

        File uploadFile = new File("d:\\today_log.txt");
        FileInputStream fis = null;
        try {
            fis = new FileInputStream(uploadFile);
            boolean isSuccess = ftp.storeFile(uploadFile.getName(), fis);
            if (isSuccess) {
                System.out.println("업로드 성공");
            }
        } catch(IOException ex) {
            System.out.println(ex.getMessage());
        } finally {
            if (fis != null) try { fis.close(); } catch(IOException ex) {}
        }
        ftp.logout();
    } catch (SocketException e) {
        System.out.println("Socket:"+e.getMessage());
    } catch (IOException e) {
        System.out.println("IO:"+e.getMessage());
    } finally {
        if (ftp != null && ftp.isConnected()) {
            try { ftp.disconnect(); } catch (IOException e) {}
        }
    }

FTPFile 클래스

FTPClient.listFiles() 메소드를 사용하면 현재 디렉토리의 파일 및 폴더 목록을 읽어올 수 있는데, 이 때 각 파일과 폴더는 FTPFile 객체로 표현된다. FTPFile 클래스는 파일 및 폴더에 대한 정보를 제공하는데, 이와 관련된 메소드는 다음과 같다.

String getName() 파일의 이름을 구한다.
String getSize() 파일의 크기를 구한다.
boolean isDirectory() 폴더인 경우 true를 리턴한다.
boolean isFile() 파일인 경우 true를 리턴한다.
boolean isSymbolicLink() 심볼 링크인 경우 true를 리턴한다.
String getLink() 심볼 링크인 경우 링크가 가리키는 파일의 이름을 리턴한다.
Calendar getTimestamp() 생성날짜를 구한다.

이 외에도 FTPFile 클래스는 파일의 소유자 및 그룹, 권한 관련 정보를 구할 수 있는 메소드를 제공하고 있다.

결론

본 글에서는 Jakarta Commons Net API를 사용하여 FTP 파일 업로드/다운로드 기능을 구현하는 방법을 살펴봤다. Commons Net API의 FTPClient 클래스는 개발자 대신 FTP 프로토콜을 처리해줌으로써 개발자가 빠르게 FTP 관련 기능을 구현할 수 있게 해준다는 것을 알 수 있었을 것이다. FTP 뿐만 아니라 SMTP/POP3, Telnet 등 다양한 프로토콜을 처리할 수 있는 클라이언트 모듈을 제공하고 있으므로, Commons Net을 사용하면 이들 프로토콜과 관련된 클라이언트 어플리케이션을 빠르게 개발할 수 있을 것이다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 2018.06.21 17:49  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

정규 표현식을 사용해서 문자열의 HTML 태그를 제거하는 방법을 살펴본다.

정규 표현식을 사용하여 HTML 태그 제거하기

웹 프로그래밍을 하다보면 DB에 저장된 내용중 일부분을 보여주어야 하는 경우가 있다. 예를 들어, 게시판의 내용중 일부를 보여준다거나 제목의 일부만 보여주어야 하는 경우가 그렇다. 이렇게 내용의 일부만 보여주어야 할 때 주의해야 할 점은 내용에 포함된 HTML 태그를 제거해주어야 한다는 점이다. 자바 1.4부터 정규 표현식 기능이 추가되었는데, 이를 사용하면 매우 간단하게 String에 포함된 HTML 태그를 제거할 수 있다. 본 팁에서는 이 정규 표현식을 사용해서 HTML 태그를 제거하는 방법을 설명하겠다.

먼저 HTML 태그는 < 로 시작해서 > 로 끝나고, 종료 태그의 경우는 </ 로 시작하거나 또는 />로 끝난다. 따라서, 다음과 같은 정규 표현식은 HTML 태그를 모두 포함하게 된다.

    <(/)?([a-zA-Z]*)(\\s[a-zA-Z]*=[^>]*)?(\\s)*(/)?>

자바 1.4부터 String.repalceAll(String regex, String replacemenet) 메소드를 제공하는데, 이 메소드는 문자열에서 regex의 정규 표현식에 일치하는 부분을 replacement로 취환해주는 기능을 제공한다. 따라서, 특정 문자열에 HTML 태그를 제거하려면 위의 정규 표현식을 다음과 같이 적용하면 된다.

    String text = "......";
    String textWithoutTag = text.replaceAll("<(/)?([a-zA-Z]*)(\\s[a-zA-Z]*=[^>]*)?(\\s)*(/)?>", "");

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. hyshin 2012.04.03 11:20 신고  댓글주소  수정/삭제  댓글쓰기

    큰 도움 됐습니다~ 좋은 정보 감사합니다~

  2. 감사합니다 2012.05.05 16:49 신고  댓글주소  수정/삭제  댓글쓰기

    태그 정규 표현식 감사히 얻어갑니다! ^^

  3. eizt 2013.03.21 15:48 신고  댓글주소  수정/삭제  댓글쓰기

    정규식 감사합니다!!!!!!!

  4. 나돌라 2014.02.13 13:57 신고  댓글주소  수정/삭제  댓글쓰기

    잘 쓰고 갑니다~

  5. 우와!! 2014.04.16 16:48 신고  댓글주소  수정/삭제  댓글쓰기

    당신은 진정한 지식인 감사합니다!!!

IE와 모질라 계열 브라우저에서 로컬 파일의 크기를 알아내는 방법을 살펴본다.

모질라 계열 브라우저에서 로컬 파일 크기 알아내기

파이어폭스와 같은 모질라 계열 브라우저에서는 XUL(XML User Interface Language)이 제공하는 로컬 파일 인터페이스를 사용해서 파일의 크기를 알아낼 수가 있다. 하지만, 파이어폭스가 보안상의 이유로 로컬 파일에 접근할 수 없도록 막아놓고 있기 때문에, 이 부분의 보안을 허용해주어야만 로컬 파일에 접근할 수 있게 된다.

먼저 로컬 파일에 접근할 수 있도록 하기 위해 "signed.applets.codebase_principal_support" 설정값을 true로 지정해주어야 한다. "signed.applets.codebase_principal_support"의 설정은 다음과 같은 방법으로 변경할 수 있다.

  1. 주소창에 about:config 를 입력하면, 설정 목록이 출력된다.
  2. 필터 입력란에 signed 를 입력하면 "signed.applets.codebase_principal_support" 설정명만 목록에 출력된다.
  3. 설정명에서 더블클릭하면 값이 true로 바뀐다.
signed.applets.codebase_principal_support 설정을 true로 변경해주면, 자바스크립트에서 로컬 파일에 접근하고자 할때, 사용자가 접근 권한 여부를 지정해줄 수 있게 된다. signed.applets.codebase_principal_support 설정을 true로 지정한 상태에서 다음과 같은 자바스크립트 코드를 실행하면,

    netscape.security.PrivilegeManager.enablePrivilege('UniversalXPConnect');

다음과 같이 'UniversalXPConnect' 권한을 현재 페이지에 부여할지의 여부를 확인하는 경고창이 뜬다.


위 화면에서 [허가] 버튼을 클릭하면 'UniversalXPConnect' 권한이 현재 페이지에 부여되어, 자바스크립트에서 로컬 파일에 접근할 수 있게 된다.

'UniversalXPConnect' 권한을 얻은 후에는 다음의 코드를 통해서 로컬 파일의 크기를 알아낼 수 있다.

    try {
        netscape.security.PrivilegeManager.enablePrivilege('UniversalXPConnect');
        var file = Components.classes["@mozilla.org/file/local;1"]
                             .createInstance(Components.interfaces.nsILocalFile);
        file.initWithPath(filePath);
        
        len = file.fileSize;
    } catch(e) {
        alert("signed.applets.codebase_principal_support를 설정해주세요!\n"+e);
    }

XUL에서 제공하는 nslLocalFile 인터페이스를 사용하면 로컬 파일에 접근할 수 있는데, nslLocalFile 인터페이스의 fileSize 프로퍼티값을 통해서 파일의 크기를 알아낼 수가 있다. 접근하고자 하는 파일은 initWithPath() 함수를 통해서 지정하게 된다.

IE에서 업로드한 파일 크기 알아내기

IE는 Scripting.FileSystemObject ActiveX 객체를 사용해서 로컬 파일에 접근할 수 있지만, 보안상의 문제 때문에 원격에서 로딩한 페이지에서는 로컬 파일에 접근할 수가 없다. IE에서는 인증된 애플릿이나 ActiveX 객체를 사용하지 않고 로컬 파일의 크기를 알아낼 수 있는 편법이 있는데, 간단하게 Image 객체를 사용하면 된다.

IE에서 Image 객체는 dynSrc 프로퍼티를 사용해서 로컬 파일을 명시하더라도 에러가 발생하지 않는다. 또한, IE의 Image 객체는 fileSize 프로퍼티를 제공한다. 따라서, Image 객체의 dynSrc 프로퍼티와 fileSize 프로퍼티를 사용하면, 다음과 같이 매우 간단하게 로컬 파일의 크기를 알아낼 수가 있다.

    var img = new Image();
    img.dynsrc = filePath;
    len = img.fileSize;

완전한 코드

IE와 모질라 계열의 브라우저에 따라 알맞게 자바 스크립트 코드를 사용하면 브라우저에 상관없이 로컬 파일의 크기를 알아낼 수가 있을 것이다. 아래 코드는 브라우저에 상관없이 동작하도록 만들어본 getFileSize() 자바스크립트 함수이다.

    function getFileSize(filePath)
    {
        var len = 0;
        
        if ( navigator.appName.indexOf("Netscape") != -1) {
            try {
                netscape.security.PrivilegeManager.enablePrivilege('UniversalXPConnect');
            } catch(e) {
                alert("signed.applets.codebase_principal_support를 설정해주세요!\n"+e);
                return -1;
            }
            try {
                var file = Components.classes["@mozilla.org/file/local;1"]
                                     .createInstance(Components.interfaces.nsILocalFile);
                file.initWithPath ( filePath );
                
                len = file.fileSize;
            } catch(e) {
                alert("에러 발생:"+e);
            }
        } else if (navigator.appName.indexOf('Microsoft') != -1) {
            var img = new Image();
            img.dynsrc = filePath;
            len = img.fileSize;
        }
        return len;
    }

이제 다음과 같이 getFileSize() 함수만 사용하면 로컬 파일의 크기를 구할 수 있게 된다.

    <script ..>
    ...
    var len = getFileSize(document.test.file.value);
    if (len > 1024 * 1024) {
        alert("파일 크기는 1메가보다 작아야 합니다.");
    }
    ...
    </script>
    
    <form name="test">
    <input type="file" name="file">
    </form>

Posted by 최범균 madvirus

댓글을 달아 주세요

자바스크립트에서 자바의 패키지와 같은 이름공간을 정의하는 방법을 살펴본다.

자바스크립트에서 패키지 지정 방법

자바는 클래스를 패키지 단위로 분류하고 있다. java.lang이나 java.util 과 같이 패키지를 사용해서 클래스를 분류함으로써 클래스의 이름을 정할 때의 불편함으로 줄이고 있다. (만약 패키지가 없었다면 java.awt.List 클래스와 java.util.List 클래스의 이름은 AwtList와 UtilList 처럼 되었을지도 모른다.)

자바스크립트에서도 약간의 트릭으로 자바의 클래스와 같은 효과를 낼 수 있다. 이를 위해서는 다음과 같이 Object()나 JSON 표기법인 '{}'를 사용하면 된다.

    var tle = new Object();    

    // 또는
    
    var tle = {};

위와 같이 패키지 이름으로 사용할 객체를 생성한 뒤에 다음과 같이 패키지를 나타내는 객체에 함수나 클래스 등을 추가해주면 된다.

    var tle = {};
    tle.getX = function(event) {
        // 함수
    }

위에서 선언한 getX 함수를 사용할 때에는 다음과 같이 패키지 이름을 함께 명시하면 된다.

    x = tle.getX(e);

패키지를 중첩해서 정의하고 싶을 때가 있을 것이다. 그런 경우에는 다음과 같이 각 단계별로 매번 객체를 생성해주면 된다.

    var tle = new Object(); // tle 패키지
    tle.dnd = new Object(); // tle.dnd 패키지
    tle.util = new Object(); // tle.util 패키지
    
    tle.dnd.getX = function() {...}
    tle.util.getX = function() { ...}

자바에 익숙한 사람은 무심코 다음과 같이 패키지 구조를 한번에 정의할 수도 있는데,

    var tle.dnd = new Object(); // 에러!!!!

위 코드는 에러를 발생시키므로 반드시 상위 패키지부터 단계적으로 정의해주어야 한다.

패키지를 정의할 때 또 다른 주의할 점은 패키지를 정의한 이후에, 패키지 이름에 다른 객체를 할당해서는 안 된다는 점이다. 예를 들어, 아래의 코드를 보자.

    var tle = new Object(); // tle 패키지
    tle.dnd = new Object(); // tle.dnd 패키지
    tle.dnd.getX = function() {...}
    
    ...
    
    tle = new Array(); // 앞에서 패키지로 지정한 tle에 다른 객체 할당
    
    tle.dnd.getX(a); // 에러!!!! tle은 더 이상 패키지로 사용되는 객체가 아니다!
    

위 코드에서처럼 패키지로 사용하는 객체에 다른 객체를 할당하면 더 이상 패키지가 유지되지 않는다. 따라서, 패키지로 사용되는 객체에 다른 객체를 할당해서는 안 된다!

본 글에서는 아주 간단하게 자바스크립트에서 패키지를 정의하는 방법을 살펴보았다. 이 방법은 자바스크립트로 제공되는 대부분의 라이브러리에서 사용되는 방법이다. 여러분이 만든 자바스크립트 라이브러리에 패키지 개념을 추가함으로써 다른 자바스크립트 라이브러리와의 이름 충돌 문제를 해결할 수 있을 것이다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요