티스토리 툴바


저작권 안내 (펌 하실 때)
  • 저작권자표시 Yes, 상업적이용 No , 컨텐츠변경 No

최근 수행중인 프로젝트에서 JPA의 구현체로 하이버네이트를 사용하고 있다. 매핑 설정에 따라 다르겠지만, 이런 JPA 구현체들은 Lazy 로딩을 구현하기 위해 프록시 객체를 사용하고 있는데, 이와 관련해서 한 가지 주의해야 할 것이 있다. 그것은 바로 this를 리턴하는 메서드와 관련된 것이다.


현재 수행중인 프로젝트에는 상속을 매핑한 코드가 있다. 예를 들면 다음과 같은 구조이다.


public class Content { ... 최상위 }

public class VideoContent extends Content { .... }

public class MovieContent extends VideoContent { ... }

public class BookContent extends Content { ... }


그리고, 특정 클래스과 다음과 같이 Content를 *-to-One의 형식으로 레퍼런스하고 있다.


public class Order {

    private Content content;

    public Content getContent() {

        return content;

    }

}


Order 객체를 구한 뒤에 Content 객체에 접근해야 하는 경우가 있으며, 특히 Content 타입이 아닌 실제 타입에 접근해야 할 때가 있다. 이런 경우에 아주 단순하게 생각하면 다음과 같이 Content에 자기 자신을 리턴하는 코드를 넣는 방법을 생각해 볼 수 있을 것이다.


public Content {

    public Content self() {

        return this; // 자기 자신을 리턴

    }

}


Order o = getOrder(xxx);

Content proxy = o.getContent(); // Content는 프록시

Content real = proxy.self(); // self() 메서드는 자기 자신을 리턴하므로 프록시가 아닌 실제 대상 객체???

if (real instanceof MovieContent) { // 하지만, real은 프록시 객체이므로 항상 false

    ...

}


하지만, 위와 같은 방법은 통하지 않는다. 그 이유는 프록시 객체는 대상 객체가 대상 객체 자신을 리턴하는 경우 프록시 객체를 리턴하도록 만들어지기 때문이다. 

  • proxy.self() 메서드는 대상 객체의 self() 메서드를 호출한다.
  • 대상 객체의 self() 메서드는 자기 자신을 리턴한다.
  • proxy.self() 메서드는 대상 객체가 리턴한 객체가 대상 객체와 동일한지 확인한다.
  • 동일하다면 프록시 객체 자신을 리턴한다.
그렇다면, 왜 프록시 객체가 대상 객체를 리턴하지 않고 자기 자신을 리턴하는 것일까? 그 이유는 간단하다. 대상 객체에 직접 접근하게 될 경우 ORM이 제공하는 Dirty Checking이나 Lazy Loading 등이 적용되지 않기 때문이다.

이런 이유로 실제 대상 객체에 접근해서 뭔가를 하고 싶다면, 대상 객체가 자기 자신을 리턴하도록 하지 말고, Double dispatch를 사용해야 한다. Double dispatch란 다음과 같은 것이다.
  • A 객체의 a() 메서드는 B 인터페이스를 파라미터로 갖는다.
  • B 인터페이스의 b() 메서드는 A를 파라미터로 갖는다.
  • A 객체의 a() 메서드는 파라미터로 전달받은 B 인스턴스의 b() 메서드를 호출할 때 자기 자신을 전달한다.
위 내용을 코드로 다시 살펴보면 다음과 같다.
public interface B {
    public void b(A a);
}

public class A {
    public void a(B b) {
        b.b(this);
    }
}

A aobj = new A();
B bobj = new BImpl();
aobj.a(bobj); // 내부적으로 bobj.b(a)가 호출됨

위 코드를 보면 aobj.a() 메서드를 호출하면, a() 메서드는 내부적으로 다시 파라미터로 전달받은 bobj의 b() 메서드를 호출한다. 이렇게 두 객체간의 호출이 두 번 이루어지기 때문에 이를 Double Dispatch라고 표현한다.

그럼, 요놈을 앞서 Content에 적용해 보자. 다음과 같이 변경해 주면 된다.

public interface Accessor {
    public void access(Content c);
}

public class Content {
    public void access(Accessor access) {
        accessor.access(this);
    }
}

위 코드에서 Accessor의 구현체는 Content의 프록시 객체가 아닌 실제 객체에 접근할 수 있게 된다.

Order o = getOrder(xxx);

Content proxy = o.getContent(); // Content는 프록시

proxy.access(new Accessor() {
    public void access(Content c) {
        // c는 proxy의 대상 객체인 실제 객체
        if (c instanceof MovieContent) {
            ...
        }
    }
});

Content 및 Content의 자식 클래스들은 상속 관계에 있으므로 사용하므로 Visitor 패턴을 사용하면 instanceof 연산자를 사용하지 않고 실제 타입으로 바로 접근할 수도 있어 더 보기 좋은 코드를 만들어 낼 수 있을 것이다.



저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 권남 2012/08/29 23:10  댓글주소  수정/삭제  댓글쓰기

    저도 비슷한 상황에서 Visitor 패턴으로 해결한 적이 있습니다.
    하지만 이런일 자체가 안일어나게 만드는게 제일 좋을 것 같습니다.
    예전에 Visitor 패턴으로 베베꽈가면서 만든 기능을 이번 프로젝트에서는 각 엔티티가 특정 interface를 구현하게 해서 간단히 해결했던게 생각나네요.
    즉, 예제에서 보여주신 Accessor가 할 일을 각 엔티티가 인터페이스를 구현해서 알아서 하게 만든거죠.
    상속구조니까 Accessor가 할일을 인터페이스 메소드로 빼고 @Override를 계속해가면서 어쩌면 쉽게 해결 될수도 있어보입니다. 하지만 프로javascript:;젝트 실제 요구사항이 어떤지를 모르니 속단은 금물이겠죠.

    • madvirus 2012/08/30 09:22  댓글주소  수정/삭제

      상속 구조에서 하위 타입이 특정 인터페이스를 상속받는다 하더라도, 최상위 타입에 대한 프록시 객체를 사용하기 때문에, 해당 인터페이스를 사용할 수 없는 상황이었습니다.
      그렇다고 해당 인터페이스를 최상위 타입이 상속받도록 할 수도 없었습니다. 왜냐면, 최상위 타입에는 해당 인터페이스에 대한 역할을 필요가 없었기 때문이다.
      또한, 각 엔티티의 역할이 아니면서 엔티티의 타입에 따라서 다르게 동작해야 하는 것들이 있었습니다.
      그래서 위와 같이 double dispatch되는 방식의 코드를 사용하게 되었습니다.

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

java에서 동영상의 스틸컷을 추출하기 위해 ffmpeg을 Runtime.exec()로 실행하는데, ffmpeg이 실행이 종료되지 않고 뭄추는 현상이 발생했다. 확인해 본 결과 ffmpeg이 쏫아내는 에러 출력 메시지 때문이었다. Runtime.exec()로 ffmpeg Processor를 생성한 뒤에 아래 코드와 같이 에러 출력 스트림으로부터 데이터를 읽어오기만 하면 블록킹 없이 ffmpeg이 실행된다.


public File extractImage(File videoFile, int position,

File creatingImageFile) {

try {

int seconds = position % 60;

int minutes = (position - seconds) / 60;

int hours = (position - minutes * 60 - seconds) / 60 / 60;


String videoFilePath = videoFile.getAbsolutePath();

String imageFilePath = creatingImageFile.getAbsolutePath();


String[] commands = { "ffmpeg", "-ss",

String.format("%02d:%02d:%02d", hours, minutes, seconds),

"-i", videoFilePath, "-an", "-vframes", "1", "-y",

imageFilePath };


Process processor = Runtime.getRuntime().exec(commands);


String line1 = null;

BufferedReader error = new BufferedReader(new InputStreamReader(

processor.getErrorStream()));

while ((line1 = error.readLine()) != null) {

logger.debug(line1);

}

processor.waitFor();

int exitValue = processor.exitValue();

if (exitValue != 0) {

throw new RuntimeException("exit code is not 0 [" + exitValue

+ "]");

}

return creatingImageFile;

} catch (IOException e) {

throw new RuntimeException(e);

} catch (InterruptedException e) {

throw new RuntimeException(e);

}

}


참고로, 위 코드는 동영상으로부터 특정 시점의 썸네일 이미지를 추출하는 코드이다.


저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 2013/04/24 02:46  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

JPA를 이용해서 구현하는 프로젝트에서 두 개의 프로퍼티를 묶어서 식별자로 사용해야 하는 경우가 생겼다. 그래서, @EmbeddedId를 이용해서 ID 필드를 지정하였다. 이 프로젝트는 Spring Data JPA를 이용해서 Repository를 자동생성하고 있는데, @EmbeddedId 부분에서 문제가 발생했다. 아래 코드는 문제를 발생시킨 리포지토리의 인터페이스이다.


public interface FollowRepository extends Repository<Follow, FollowId> {


    Page<Follow> findAll(Specification<Follow> spec, Pageable pageable);

...

}


Follow의 식별자의 타입은 FollowId 클래스이고, FollowId 클래스는 두 개의 프로퍼티를 갖고 있다. 그런데, 위 코드에서 findAll()을 실행하는 순간에 다음과 같은 쿼리가 실행되면서 문제가 발생했다.


select count((follow0_.FOLLOWING_ID, follow0_.USER_ID)) as col_0_0_ 

from FOLLOW follow0_ 

where ....


DBMS로 오라클을 사용하고 있는데, 위 count() 부분에서 쿼리 오류가 발생한 것이다. 처음엔 Spring Data JPA 문제일까 해서 커스텀 구현을 넣어 보았다.


public class FollowRepositoryImpl implements FollowRepositoryCustom {


    @PersistenceContext

    private EntityManager entityManager;


    @Override

    public Page<Follow> findAll(Specification<Follow> spec, Pageable pageable) {

        List<Follow> result = getResultList(spec, pageable);

        long total = count(spec);

        return new PageImpl<Follow>(result, pageable, total);

    }


    private List<Follow> getResultList(Specification<Follow> spec,

            Pageable pageable) {

        ...

        return query.getResultList();

    }


    private long count(Specification<Follow> spec) {

        CriteriaBuilder cb = entityManager.getCriteriaBuilder();

        CriteriaQuery<Long> c = cb.createQuery(Long.class);

        Root<Follow> root = c.from(Follow.class);

        c.select(cb.count(root));

        Predicate predicate = spec.toPredicate(root, c, cb);

        c.where(predicate);


        TypedQuery<Long> query = entityManager.createQuery(c);

        return query.getSingleResult();

    }


}


하지만, 결과는 동일했다. JPA API를 직접 사용해도 변화가 없는 걸 봐서는 Spring Data JPA의 문제는 아니였다. Spring Data JPA 1.1 버전을 사용하고 있었는데, 이 버전은 하이버네이트 3.6.9를 기준으로 하고 있어서 해당 버전의 하이버네이트를 사용했었다.


하이버네이트의 버전 문제가 아닐까해서 범위를 좀 더 좁혀서 구글링을 해 봤더니 하이버네이트에서 이런 문제가 발생하고 있는 듯 했다. (관련 문의가 포럼에 있었다.) 좀 더 파 볼까 하다가, 하이버네이트 버전을 올려보기로 결심했다. 하이버네이트 버전을 3.6.9에서 4.1.4로 올렸다.


결과는? 야호! 테스트케이스의 녹색바! 에러가 없어졌다. 처음부터 하이버네이트 버전 업부터 할 걸, 괜히 코드 작성했다. 오픈소스에서 뭔가 이상한 문제가 있으면 먼저 버전을 올려보라는 걸 다시 한 번 확인했다.


저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 권남 2012/07/12 17:37  댓글주소  수정/삭제  댓글쓰기

    @IdClass를 사용할때도 마찬가지 현상이 발생합니다.
    @IdClass/MySQL

  2. PKH갈휘 2012/07/16 22:22  댓글주소  수정/삭제  댓글쓰기

    버전업후에 말끔해졌네요, 감사합니다.

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

ORM!! 오늘 한번 더 ORM을 추종할 수 밖에 없는 일이 벌어졌다. 경험한 내용이 흥분도 되고 ORM의 좋음을 공유하고자 이렇게 글을 남긴다.


최초의 설계: 별도 클래스, 별도 테이블


현재 진행하는 프로젝트에서 컨텐츠에 대한 모델을 설계할 때 컨텐츠 종류마다 별도 테이블과 별도 클래스로 구성하도록 설계를 진행했었다. 각 컨텐츠들이 비슷한 데이터를 갖고 있었지만, 절반은 다른 데이터를 갖고 있었다. 또한, 컨텐츠 타입은 두 개였고 컨텐츠마다 사용되는 영역이 달랐기 때문에, 상위 클래스에 공통 정보를 두지 않고 서로 계층 관계에 묶이지 않는 별도 클래스로 구성하였다. 물론, 테이블도 별도로 구성하였다.




변화의 압박: 서로 다른 컨텐츠 종류에 공통으로 적용되는 기능들


최초 개발하는 동안에는 크게 문제될 것이 없었다. 그런데, PoC 프로젝트를 거의 마무리하는 과정에서 1.0 버전의 기획으로 다음의 기능들이 추가되었다.

  • 모든 컨텐츠에 대해 댓글 달기
  • 모든 컨텐츠에 대해 좋아요 하기
  • 모든 컨텐츠에 대해 즐겨찾기 하기
  • 모든 컨텐츠에 연관 정보 넣기
  • 새로운 종류의 컨텐츠 타입 추가 및 새로운 타입 컨텐츠에도 댓글/좋아요/연관 정보 넣기
고민에 휩싸이기 시작했다. 예를 들어, 댓글 구현은 다음과 같이 두 가지 중 하나로 할 수 있다.
  • 각 컨텐츠 타입마다 댓글을 위한 테이블을 구분해서 만들고, 각 컨텐츠 타입별로 댓글 관련 인터페이스 묶음 구현하기.
  • 댓글 테이블에 컨텐츠 타입 보관 위한 컬럼을 추가하고, 한 묶음의 인터페이스로 각 컨텐츠 타입을 위한 댓글 기능 구현하기.

구현이야 할 수 있겠지만, 둘 다 딱히 마음에 안 들었는다. 첫 번째 방법은 완전히 동일한 데이터 구조, 구현 코드, 테이블이 중복된다는 점이 불만이었다.



두 번째 방법은 하나의 신규 테이블에서 서로 다른 테이블에 대한 참조를 가져야 할 수도 있다는 것이 거슬렸다. 예를 들어, 두 컨텐츠에 동일하게 추가되는 정보인 경우, 두 컨텐츠에 대한 외부키를 각각 갖고 있어야 했다.



또한, 두 방법 모두 2.0 버전에 새로운 컨텐츠 타입이 추가되거나 각 컨텐츠 타입에 공통으로 적용되는 기능이 추가되면 같은 짓을 반복해야 하는 동일한 문제점을 갖고 있었다.


그래서, 뭐가 문제일까 하고 잠시 고민해 본 결과, 모델링을 잘못 했다는 결론에 다다랐다. PoC 시점에서는 그리 잘못 되지 않았지만, 몇 달 후에 신규 기능들이 추가되면서  최초에 만들었던 모델로는 깔끔하게 처리할 수 없게 된 것이다. 이 모든 문제를 깔끔하게 해결하는 방법은 모델을 다시 정리하는 것 뿐이었다.


상속으로 풀기로 결심!


이 모든 문제의 근원은 추상화의 변경에서 비롯되었다. 최초에는 두 개의 컨텐츠를 논리적인 하나의 컨텐츠로 바라보지 않았다. 그런데, 댓글, 연관 정보, 좋아요 기능이 생기면서 개별 컨텐츠 타입이 아닌 '컨텐츠'라는 개념이 도출된 것이다. 따라서, 컨텐츠라는 새로운 상위 개념을 도출했고, 이 상위 개념에 대해서 '좋아요', '댓글' 등의 기능을 적용하기로 하였다. 새로운 모델은 다음과 같이 변경되었다.



컨텐츠라는 단일 개념을 표현하기 위해 Content를 출현시켰고, 댓글과 좋아요 등의 기능은 새롭게 추가한 Content에 대고 구현을 하도록 했다. 각 개별 타입들의 변이는 Content의 하위 타입으로 처리하였다. 그리고, 각 컨텐츠 타입마다 별도의 테이블을 가졌던 것에서 모든 컨텐츠 타입을 한 개의 테이블에 저장하기로 결정했다.


변경의 여파는?


모델을 새롭게 정의했으니, 이제 기존 코드에 변경할 차례이다. 시간은 대략 얼마나 걸렸을까? 주요 기능의 정상적인 동작을 확인하는 데 까지 40분 정도 (밖에 안) 걸렸다. 물론, 개발 과정이기 때문에 기존 데이터의 마이그레이션이나 변경 등의 이슈는 없었고, 단지, 테스트를 위해 마련해 둔 데이터를 변경하는 정도의 데이터 수정 작업이 있었다. 하지만, 그렇다고 하더라도 모델을 변경하고 테이블을 합친 것에 비하면 이는 정말 짧은 시간이다.


그렇다면 어떻게 이것이 가능했을까? 이는 ORM을 사용했기 때문이다. 실제로 모델을 변경하기 위해 한 작업은 다음과 같다.

  • 상위 Content 클래스를 추가하고 각 하위 타입에 대해 공통인 필드와 관련 메서드를 추가
  • 각 하위 Content 타입 클래스에서 상위 클래스로 옮겨간 필드 및 관련 메서드 제거
  • Content 및 하위 클래스에 대한 ORM 설정
  • 테스트 데이터 구성
  • 기능 테스트 (약 40% 진행)
위에서 가장 시간이 오래 걸린 작업은 테스트 데이터 구성과 기능을 테스트 한 시간이다. Content 및 하위 클래스 구성, 그리고 ORM 관련 설정에는 불과 10분 정도 밖에 소요되지 않았다. 테스트 데이터를 구성하는데 10분 정도가 소요되었고, 기능 테스트에 20분 정도가 소요되었다. 물론, 전체 기능 중에서 조회 위주로만 기능을 테스트 했지만, 놀라운 점은 조회 기능 테스트하는 과정에서 오류가 발생하지 않았다는 점이다. 게다가 위의 작업 내역을 보면 DB 연동을 처리하는 리포지토리는 변경도 하지 않았다. 오!! 놀랍지 않은가?

ORM이 아니였다면?


어떻게 이런 과감한 변경을 하면서도 짧은 시간에 변경이 가능했을까? 정답은 바로 ORM 때문이다.

만약 ORM이 아니였다면 관련된 쿼리들을 쫓아다니면서 테이블을 변경해주고, 쿼리를 변경해주는 등의 쌩노가나를 해야만 했을 것이다. 물론, 이 과정에서 짜증 유발과 함께 오타도 발생했을 거고, 누가 오타를 덜 내면서 쿼리를 변경해 내느냐가 그 사람의 (노가다) 능력으로 인정받았을 것이다.

하지만, 이 프로젝트에서는 ORM을 사용했기에, 매우 적은 스트레스를 받으며, 약간의 노가다 만으로, 그야말로 스마트하게 변경 작업을 진행할 수 있었다. 프로젝트를 진행하다보면, SQL로 했으면 거부감이 확 들었을 이런 종류의 변경에서부터 작게는 사소한 컬럼 추가까지 다양한 변경이 발생하게 되는데, 이만하면 ORM을 도입하기 위한 약간의 학습 비용은 프로젝트 전체로 봤을 때 그리고 향후 유지보수를 생각해 봤을 때 절대로 큰 비용이 아닐 것이다. 아니 오히려 매우 작은 비용일 것이다.

저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

서블릿 3.0에 몇 가지 새로운 것들이 추가되었는데, 그 중 하나가 비동기 서블릿이다. 그 동안 서블릿은 한 개의 요청에 대해 한 개의 쓰레드를 사용하는 모델을 사용했었다. 일반적인 경우 이 방식은 알맞게 동작하지만, 서버에서 연결을 유지한 채 지속적으로 데이터를 받는 기능을 구현하기에는 적합하지 않은 모델이었다. 예를 들어, 채팅 어플리케이션을 개발하려면 클라이언트가 서버와 연결을 유지한채로 서버로부터 채팅 메시지를 받아와야 하는데, HTTP의 연결 유지 기능을 사용하면 서버의 쓰레드 풀의 쓰레드가 모두 사용되어서 더 이상 다른 클라이언트에 서비스를 제공할 수 없는 문제가 발생할 수 있다. 반대로 주기적으로 서버로부터 데이터를 읽어오면 불필요한 네트워크 트래픽이 발생하는 단점이 발생하게 된다.


이런 문제나 단점이 발생하는 이유는 서블릿 모델이 한 쓰레드가 클라이언트의 요청-응답 과정을 처리하기 때문문이다. 서블릿 3.0은 클라이언트의 요청을 받아들이는 쓰레드와 실제 클라이언트에게 응답을 제공하는 쓰레드를 분리할 수 있도록 함으로써, 즉 클라이언트에 대한 응답을 비동기로 처리할 수 있도록 함으로써 앞서 언급한 문제들을 해소할 수 있도록 하였다. 


서블릿 3.0의 비동기 처리


서블릿 3은 응답을 비동기로 처리하기 위한 기능이 추가되었다. 새로 추가된 비동기 기능을 설명하기에 앞서 먼저 기존 방식의 서블릿의 동작 방식을 간단하게 살펴보자.


public class HelloServlet extends HttpServlet {


    @Override

    protected void doGet(HttpServletRequest req, HttpServletResponse response)

            throws ServletException, IOException {

        response.setContentType("text/plain");

        response.setCharacterEncoding("UTF-8");


        PrintWriter writer = response.getWriter();

        writer.println("Hello");


        // 서블릿 실행이 종료되면 클라이언트에 응답 전송 및 스트림 종료

    }


}



기존 서블릿의 경우 클라이언트의 요청을 처리하는 쓰레드에서 클라이언트에 전송할 응답을 생성한다. 모든 실행이 끝나면 서블릿 컨테이너는 응답 전송을 완료하고 클라이언트와의 연결을 종료한다. 따라서, 연결이 유지되는 방식으로 Comet 구현시, 한 클라이언트가 한 쓰레드를 점유하게 되어 클라이언트의 개수가 증가할 경우 쓰레드가 부족해지는 상황이 발생하게 된다.


서블릿 3에 추가된 비동기 기능은 응답을 별도 쓰레드로 처리할 수 있도록 하였다. 아래 코드는 비동기 기능을 사용하여 응답을 생성하는 아주 간단한 비동기 지원 서블릿의 예이다.


@WebServlet(urlPatterns = "/hello", asyncSupported = true)

public class AsyncHelloWorldServlet extends HttpServlet {


    private Logger logger = Logger.getLogger(getClass());

    

    @Override

    protected void doGet(HttpServletRequest req, HttpServletResponse res)

            throws ServletException, IOException {

        final AsyncContext asyncContext = req.startAsync();

        

        new Thread(new Runnable() {

            

            @Override

            public void run() {

                try {

                    Thread.sleep(5000);

                } catch (InterruptedException e) {

                }

                HttpServletResponse response = (HttpServletResponse) asyncContext.getResponse();

                response.setContentType("text/plain");

                response.setCharacterEncoding("UTF-8");

                

                try {

                    response.getWriter().println("HELLO");

                } catch (IOException e) {

                    e.printStackTrace();

                }

                logger.info("complete response");

                asyncContext.complete();

            }

        }).start();

        

        logger.info("doGet return");

    }


}


위 코드에서 AsyncHelloWorldServlet은 @WebServlet 애노테이션의 asyncSupported 속성의 값을 true로 지정함으로써 비동기 방식을 지원한다고 설정하였다. (비동기 방식 지원은 web.xml을 통해서도 할 수 있다.)


비동기 지원 서블릿은 ServletRequest의 startAsync() 메서드를 이용해서 비동기로 요청을 처리하기 위한 AsyncContext 객체를 생성할 수 있다. AsyncContext 객체를 생성하면 서블릿의 메서드 실행이 종료되더라도 클라이언트와의 연결이 종료되지 않고 유지된다. 물론, 해당 서블릿을 실행하던 쓰레드는 컨테이너가 관리하는 쓰레드 풀로 반환되어 다른 클라이언트 요청을 처리할 수 있게 된다.


AsyncContext의 getResponse() 메서드를 사용하면 클라이언트에 데이터를 전송할 수 있는 HttpServletResponse를 구할 수 있다. 위 코드의 경우 별도 쓰레드에서 5초간 실행을 중지한 뒤에 AsyncContext를 이용해서 응답을 생성하고 있다. 클라이언트에 대한 응답이 완료되면, AsyncContext의 complete() 메서드를 호출해서 클라이언트와의 연결을 종료하게 된다.


웹 브라우저에서 위 서블릿에 연결하면, 전체 실행 흐름은 다음과 같이 흘러가게 된다.

  1. 클라이언트의 요청을 수신하는 쓰레드(T1)가 AsyncHelloWorldServlet의 doGet() 메서드를 실행한다.
  2. T1은 req.startAsync() 메서드를 이용해서 비동기 처리를 위한 AsyncContext 객체를 구한다.
  3. T1은 비동기로 응답을 처리할 쓰레드 T2를 생성하고 실행한다.
  4. T2는 5초간 실행을 중지한다.
  5. T1은 doGet() 메서드가 종료되고, 컨테이너의 쓰레드 풀에 반환된다.
  6. T2는 AsyncContext를 이용해서 클라이언트에 응답을 전송한다.
  7. T2는 complete()을 통해 클라이언트와의 연결을 종료한다.
  8. T2의 실행이 종료된다.
위 실행 흐름을 보면 서블릿의 실행이 종료된 이후 별도 쓰레드를 통해서 클라이언트에 응답이 전송됨을 알 수 있다. 실제로 웹 브라우저에서 http://localhost:8080/hello를 실행해보면 약 5초 후에 응답이 오는 것을 확인할 수 있다.

비동기 기능을 이용한 채팅 구현: 서버 측 코드

서블릿 비동기 기능을 활용하면 iframe 기반의 Comet을 통해서 쉽게 채팅 기능을 구현할 수 있다. 구현하는 방법은 다음과 같이 간단하다.
  • 클라이언트가 연결하면, 클라이언트에 대한 AsyncContext를 생성한 뒤 목록에 저장한다.
  • 클라이언트의 채팅 메시지를 수신하면 각 AsyncContext에 메시지를 전송한다.
실제 샘플 구현에 사용된 클래스는 다음과 같다.


  • ChatRoom : 채팅 방을 관리한다. 클라이언트 목록(AsyncContext)을 관리하고, AsyncContext를 이용해서 클라이언트에 메시지를 전송하는 역할을 수행한다.
  • ChatRoomLifeCycleManager: 컨테이너 시작시 ChatRoom을 초기화하고, 컨테이너 종료시 ChatRoom을 종료한다.
  • EnterServlet: 클라이언트 채팅방 입장 기능을 처리한다.
  • SendMessageServlet: 클라이언트의 채팅 메시지 전송 요청을 처리한다. 클라이언트 채팅 메시지를 전송하면, ChatRoom을 통해 각 클라이언트에 메시지를 푸쉬(push)한다.

먼저, EnterServlet을 살펴보자.


@WebServlet(urlPatterns = "/enter", asyncSupported = true)

public class EnterServlet extends HttpServlet {


    private Logger logger = Logger.getLogger(getClass());


    @Override

    protected void doGet(HttpServletRequest req, HttpServletResponse resp)

            throws ServletException, IOException {

        processConnectionRequest(req, resp);

    }


    @Override

    protected void doPost(HttpServletRequest req, HttpServletResponse resp)

            throws ServletException, IOException {

        processConnectionRequest(req, resp);

    }


    private void processConnectionRequest(HttpServletRequest req,

            HttpServletResponse res) throws IOException {

        logger.info("Receive ENTER request");


        res.setContentType("text/html; charset=UTF-8");

        res.setHeader("Cache-Control", "private");

        res.setHeader("Pragma", "no-cache");

        res.setCharacterEncoding("UTF-8");


        PrintWriter writer = res.getWriter();

        // for IE

        writer.println("<!-- start chatting -->\n");

        writer.flush();


        AsyncContext asyncCtx = req.startAsync();

        addToChatRoom(asyncCtx);

    }


    private void addToChatRoom(AsyncContext asyncCtx) {

        asyncCtx.setTimeout(0);

        ChatRoom.getInstance().enter(asyncCtx);

        logger.info("New Client enter Room");

    }


}


EnterServlet은 클라이언트의 채팅방 입장 요청이 오면 비동기 모드를 시작한 뒤 AsyncContext를 ChatRoom.enter() 메서드를 이용해서 채팅에 클라이언트를 참여시킨다. 이후 ChatRoom은 AsyncContext 객체를 이용해서 클라이언트에 채팅 메시지를 전송한다.

@WebServlet(urlPatterns = "/sendMessage")
public class SendMessageServlet extends HttpServlet {

    private Logger logger = Logger.getLogger(getClass());
    
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse res)
            throws ServletException, IOException {
        logger.info("Receive SEND request");
        
        res.setContentType("text/plain");
        res.setHeader("Cache-Control", "private");
        res.setHeader("Pragma", "no-cache");
        req.setCharacterEncoding("UTF-8");

        ChatRoom.getInstance().sendMessageToAll(req.getParameter("message"));

        res.getWriter().print("OK");
    }

}

SendMessageServlet은 클라이언트가 전송한 채팅 메시지를 ChatRoom.sendMessageToAll()에 전달한다. ChatRoom은 전달받은 메시지를 내부적으로 관리하는 모든 AsyncContext에 전송하게 된다.


여기서 알 수 있는 사실은, 채팅 메시지를 서버에 전송하는 커넥션과 채팅 메시지를 클라이언트에 뿌려주는 커넥션이 다르다는 사실이다. 앞서 EnterServlet에 연결한 클라이언트 커넥션은 AsyncContext를 이용해서 종료되지 않은 채로 ChatRoom에 전달된다. 반면, 채팅 메시지를 전송하기 위해 SendMessageServlet에 연결한 클라이언트 커넥션은 새로운 커넥션으로서 메시지를 전달하고서는 바로 커넥션을 종료하게 된다. 서버에서 클라이언트로의 메시지 전달은 ChatRoom에 보관된 AsyncContext를 통해서 이루어진다.


클라이언트에 서버 푸쉬 방식으로 메시지를 전달하는 ChatRoom 클래스는 다음과 같이 구현된다.


public class ChatRoom {


    private static ChatRoom INSTANCE = new ChatRoom();

    public static ChatRoom getInstance() {

        return INSTANCE;

    }


    private Logger logger = Logger.getLogger(getClass());

    private List<AsyncContext> clients = new LinkedList<AsyncContext>();

    private BlockingQueue<String> messageQueue = new LinkedBlockingQueue<String>();


    private Thread messageHandlerThread;

    private boolean running;


    private ChatRoom() {

    }


    public void init() {

        running = true;

        Runnable handler = new Runnable() {

            @Override

            public void run() {

                logger.info("Started Message Handler.");

                while (running) {

                    try {

                        String message = messageQueue.take();

                        logger.info("Take message [" + message + "] from messageQueue");

                        sendMessageToAllInternal(message);

                    } catch (InterruptedException ex) {

                        break;

                    }

                }

            }

        };

        messageHandlerThread = new Thread(handler);

        messageHandlerThread.start();

    }


    public void enter(final AsyncContext asyncCtx) {

        asyncCtx.addListener(new AsyncListener() {

            @Override

            public void onTimeout(AsyncEvent event) throws IOException {

                logger.info("onTimeout");

                clients.remove(asyncCtx);

            }

            @Override

            public void onError(AsyncEvent event) throws IOException {

                logger.info("onError");

                clients.remove(asyncCtx);

            }

            @Override

            public void onStartAsync(AsyncEvent event) throws IOException {}

            @Override

            public void onComplete(AsyncEvent event) throws IOException {}

        });

        try {

            sendMessageTo(asyncCtx, "Welcome!");

            clients.add(asyncCtx);

        } catch (IOException e) {

        }

    }


    public void sendMessageToAll(String message) {

        try {

            messageQueue.put(message);

        } catch (InterruptedException e) {

            e.printStackTrace();

        }

        logger.info("Add message [" + message + "] to messageQueue");

    }


    private void sendMessageToAllInternal(String message) {

        for (AsyncContext ac : clients) {

            try {

                sendMessageTo(ac, message);

            } catch (IOException e) {

                clients.remove(ac);

            }

        }

        logger.info("Send message [" + message + "] to all clients");

    }


    private void sendMessageTo(AsyncContext ac, String message)

            throws IOException {

        PrintWriter acWriter = ac.getResponse().getWriter();

        acWriter.println(toJSAppendCommand(message));

        acWriter.flush();

    }


    private String toJSAppendCommand(String message) {

        return "<script type='text/javascript'>\n"

                + "window.parent.chatapp.append({ message: \""

                + EscapeUtil.escape(message) + "\" });\n" + "</script>\n";

    }


    public void close() {

        running = false;

        messageHandlerThread.interrupt();

        logger.info("Stopped Message Handler.");


        for (AsyncContext ac : clients) {

            ac.complete();

        }

        logger.info("Complete All Client AsyncContext.");

    }

}


ChatRoom 클래스는 AsyncContext의 목록을 관리하기 위해 List를 사용하였다. 그리고, 클라이언트에 푸시할 채팅 메시지를 큐에 보관하고, 별도 쓰레드를 이용해서 큐에 보관된 메시지를 클라이언트에 전송하도록 구현하였다. 이렇게 구현한 이유는 ChatRoom에 채팅 메시지를 전송해 달라고 요청하는 쓰레드(즉, SendMessageServlet을 실행하는 쓰레드)와 실제로 채팅 메시지를 클라이언트에 푸시하는 쓰레드를 비동기로 실행하기 위함이다.


init() 메서드가 실행되면, messageQueue로부터 메시지를 읽어와 sendMessageToAllInternal() 메서드를 실행하는 쓰레드가 시작된다. 이 쓰레드는 running 필드가 false가 되거나 messageQueue로부터 데이터를 읽어오는 쓰레드에 인터럽트가 걸릴 때 까지 계속된다.


enter() 메서드는 AsyncContext 객체를 clients 리스트에 추가한다. 추가하기 전에 AsyncListener를 AsyncContext 객체에 등록한다. AsyncListener는 연결 타임아웃이 발생하거나 연결 에러가 발생하면 clients 리스트에서 해당 AsyncContext를 제거하는 기능을 수행해서 ChatRoom이 정상적인 클라이언트의 목록을 유지할 수 있도록 한다.


sendMessageToAll() 메서드는 messageQueue에 메시지를 등록한다. 앞서 말했듯이 SendMessageServlet은 ChatRoom의 sendMessageToAll() 메서드를 이용해서 채팅방에 참여한 모든 클라이언트에 채팅 메시지를 전송할 것은 요청하는데, sendMessageToAll() 메서드는 messageQueue에 보관만 하고 바로 리턴한다. 이렇게 함으로써 채팅 메시지를 전송한 클라이언트는 모든 클라이언트에 채팅 메시지가 전달될 때까지 기다리지 않고 연결을 종료할 수 있다.


messageQueue에 저장된 메시지는 앞서 init() 메서드에서 생성한 핸들러 쓰레드를 통해서 전체 클라이언트에 푸시된다.


각 클라이언트에 메시지를 전송하는 기능은 sendMessageTo() 메서드를 이용하여 구현하였다. 이 메서드를 보면 PrintWriter의 printlnl() 메서드를 이용해서 클라이언트에 메시지를 뿌린 뒤에 flush() 메서드를 실행하는데, flush() 메서드를 호출해야 클라이언트에 내용이 전달된다.


sendMessageTo()가 클라이언트에 전송하는 메시지는 다음과 같은 형식을 띈다.


<script type='text/javascript'>

window.parent.chatapp.append({ message: "채팅 메시지" });

</script>


클라이언트는 서버로부터 위 메시지를 받을 때 마다 자바 스크립트 코드를 실행하게 되며, 따라서 채팅 메시지가 수신될 때마다 자바 스크립트를 이용해서 채팅 메시지를 화면에 추가할 수 있게 된다.


비동기 기능을 이용한 채팅 구현: 클라이언트 측 코드


클라이언트 코드는 비교적 간단하다. 몇 가지 이벤트를 처리하기 위해 jQuery를 사용하였다.


<html>

<head>

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

<title>Chat</title>

<script src="/jquery-1.7.1.js" type="text/javascript"></script>

<script type="text/javascript">

var chatapp = {

append: function(msg) {

$("#chatmessage").append("<div>"+msg.message+"</div>");

}

};

$(function() {

$("#sendBtn").click(function() {

var msg = $("#message").val();

$.ajax({

type: "POST",

url: '/sendMessage',

data: {message: msg},

success: function(data) {}

});

$("#message").val("");

});

document.getElementById("comet-frame").src = "/enter";

});

</script>

</head>

<body>

<div id="chatmessage"></div>

<input type="text" name="message" id="message" />

<input type="button" name="sendBtn" id="sendBtn" value="보내기" />

<iframe id="comet-frame" style="display: none;"></iframe>

</body>

</html>


위 HTML에서 눈여겨 볼 부분은 chatapp과 숨겨진 iframe이다. comet-frame은 숨겨진 iframe인데, 웹 페이지 로딩이 완료되면 iframe의 주소가 /enter가 된다. 이는, iframe이 EnterServlet에 연결하게 되며, EnterServlet이 생성하는 AsyncContext를 통해서 채팅 메시지를 수신받게 된다. 앞서 ChatRoom은 자바 스크립트 코드를 채팅 메시지로 전송했었는데, 이 채팅 메시지가 iframe에 지속적으로 전달되는 것이다. 앞서 자바 스크립트 코드는 다음과 같았다.


<script type='text/javascript'>

window.parent.chatapp.append({ message: "채팅 메시지" });

</script>


위 코드에서 window.parent.chatapp은 앞서 HTML 코드에서 생성한 chatapp 객체가 된다. 따라서, iframe이 위 코드를 실행하면 chatapp.append() 메서드가 실행되어 chatmessage 영역에 채팅 메시지를 추가하게 된다.


sendBtn 버튼을 클릭하면 /sendMessage에 채팅 메시지를 전달한다. 즉, 채팅 메시지 전송 요청을 SendMessageServlet이 받게 되고, SendMessageServlet은 ChatRoom의 AsyncContext를 통해서 채팅 메시지를 클라이언트에 위 코드 형태로 푸시하게 된다. 각각의 웹 브라우저는 숨겨진 iframe을 통해서 위 코드를 받게 되고, 위 자바스크립트 코드를 실행함으로써 메시지를 화면에 뿌리게 된다.


아래는 두 개의 서로 다른 브라우저에서 채팅 메시지를 실행한 결과 화면을 보여주고 있다.



소스 코드 사용법


소스 코드는 Maven 프로젝트로 작성되었다. 다운로드 받은 뒤 압축을 풀고 다음의 명령을 실행하면 바로 예제를 테스트 해 볼 수 있다.


$ mvn jetty:run


소스 코드는 아래 링크에서 다운로드 받을 수 있다.


servlet-async.zip





저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 권남 2012/06/18 11:27  댓글주소  수정/삭제  댓글쓰기

    혹시 Servlet 3.0 기준의 Servlet/JSP 책도 출간 예정이신가요?
    기다리고 있습니다. ^^

    • madvirus 2012/06/18 11:49  댓글주소  수정/삭제

      서블릿을 많이 다루는 게 요즘같은 프레임워크 시대에는 다소 의미가 약해서 쓴다 해도 JSP 2.1->2.2로의 개정판 정도를 준비하게 될 것 같습니다.

  2. lahuman 2012/07/23 14:33  댓글주소  수정/삭제  댓글쓰기

    좋은글 잘 읽었습니다.

    감사합니다.

  3. 2013/01/21 10:59  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

    • madvirus 2013/01/21 11:56  댓글주소  수정/삭제

      이런 작업은 비동기 쓰레드를 사용하는 것 보단, 클라이언트에서 주기적으로 확인하는 방식을 사용하는 것이 좋을 것 같습니다.
      HTML5가 가능하다면 웹소켓을 사용하는 것도 좋을 것 같습니다.

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

Scala 언어가 재미있지만 실제 프로젝트에 적용하려면 콘솔에서 컴파일하거나 실행해 볼 수는 없다. 실제 프로젝트에 Scala를 적용하려면 먼저 편리한 개발 환경을 구축해 주어야 한다. 필자의 경우 개발시 주로 이클립스와 메이븐을 사용하기 때문에, Scala를 이 환경에서 개발하기 위한 내용을 찾아보았으며, 각 내용들은 한 곳에 모아 보았다. (나중에 또 돌아다니면서 찾는 건 귀찮기에 이곳에 정리한다.)


이클립스와 메이븐(m2e 플러그인) 환경을 이용해서 Scala를 적용하려면 다음과 같은 준비를 하면 된다.

  • Scala IDE 설치
  • M2Eclipse Scala 플러그인 설치
  • Maven 프로젝트에 pom.xml 파일에 maven-scala-plugin 설정

Scala IDE 설치하기


먼저 Scala IDE를 설치한다. 이클립스 업데이트 사이트는 아래와 같다. 참고로 아래 주소는 Scala 2.9 버전 기준이다. (참고 사이트는 http://scala-ide.org/index.html 이다.)

  • http://download.scala-ide.org/releases-29/stable/site

M2Eclipse Scala 플러그인 설치


그 다음 할 작업은 Maven 프로젝트와 Scala IDE를 연결해 줄 플러그인을 설치해주는 것이다. 업데이트 주소는 아래와 같다. (참고 사이트는 https://github.com/sonatype/m2eclipse-scala 이다.)


  • http://alchim31.free.fr/m2e-scala/update-site/

pom.xml 파일에 maven-scala-plugin 설정하기


이제 남은 작업은 이클립스에서 실행할 메이븐 프로젝트의 pom.xml 파일에 maven-scala-plugin 설정을 하는 것이다. pom.xml 파일의 설정 예는 https://github.com/sonatype/m2eclipse-scala 사이트에서 찾아볼 수 있다. pom.xml 파일을 알맞게 작성한 뒤에 Scala 소스 코드를 작성하면, 알맞게 컴파일되고, 실행해 볼 수 있고,  자바 코드에서 Scala가 생성한 클래스를 사용하는 등의 작업을 할 수 있다.


참고로, 그럼 내가 왜 Scala를 사용해보려고 하느냐? 여러 이유가 있겠지만 다음이 주요 이유다.

  • 언어 자체의 재미: 언어가 다소 복잡하지만 재미는 있다.
  • Combinator Parser: 뭔가 규칙에 기반해서 텍스트를 파싱해야 할 때, 언어 차원에서 Parser를 지원하기 때문에, ANTLR 등을 사용하지 않아도 쉽게 파싱할 수 있다.
  • DSL(Domain Specific Language): 위의 내용과 더불어 DSL을 만들기에 Ruby나 Groovy 등의 언어들 만큼 좋다.
  • 자바와의 호환성: JVM 기반으로 동작하며 자바와의 호환이 당연히 잘 된다.
  • 함수형 언어이고 Clousure 등을 지원하기에 테스트 코드에 적용하면 테스트 코드에서 발생하는 중복을 많이 줄일 수 있으므로, 그것만으로도 가치가 있다.



저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

요즘 저녁에 집에서 짬이 생길 때마다 공부겸 취미겸 간단한 웹 기반 어플리케이션을 만들고 있는데, 만들던 중 아래와 같은 기능이 필요하게 되었다.

  • WAR로 배포하고, 데이터 디렉토리를 외부에서 변경할 수 있어야 함
  • JNDI나 시스템 프로퍼티 값을 이용해서 디렉토리 경로를 지정할 수 있어야 함
위 기능을 직접 구현할까 하다가 누군가도 위와 같은 기능을 필요로 할 것 같아서 검색을 해 보았다. 아니나 다를까, 딱 들어맞는 기능을 제공하는 모듈이 있어 간단하게 기능을 정리해보았다. 이 모듈의 이름은 Data directory locator tool, 줄여서 datadirlocator (http://simplericity.org/datadirlocator)로서 사용법도 매우 간단하다.

모듈 다운로드

홈페이지에서 다운로드 받거나 Maven을 사용하는 경우 다음과 같이 의존을 추가해주면 된다.

<dependency>
    <groupId>org.simplericity.datadirlocator</groupId>
    <artifactId>datadirlocator</artifactId>
    <version>1.10</version>
</dependency>

지원하는 설정 방식

datadirlocator는 설정 파일이 위치하는 디렉토리나 어플리케이션의 홈 디렉토리와 같이 디렉토리 경로를 구하는 기능을 제공하며, 다음과 같이 4가지 방식으로 설정 경로를 구할 수 있도록 지원하고 있다.
  • JNDI 설정 이용 (기본 JNDI 명: java:com/env/dataDirectory)
  • 서블릿 컨텍스트 파라미터 이용 (기본 컨텍스트 파라미터 명: dataDirectory)
  • 시스템 프로퍼티 이용 (기본 시스템 프로퍼티  명: dataDirectory)
  • 환경 변수 이용 (기본 환경 변수 명: DATADIRECTORY)
JNDI부터 순서대로 값이 존재하는지 검색하고 값이 존재하면 그 값을 사용하고 존재하지 않으면 그 다음 방식의 값이 존재하는 검사한다. 위의 네 가지 경우에 대해 모두 값이 존재하지 않으면 기본 디렉토리로 $HOME/datadirectory를 사용한다.

사용법1, 직접 모듈 사용하기

가장 간단한 사용방법은 다음과 같다.
  • ServletContextListener를 추가한다.
  • ServletContextListener에서 DefaultDataDirectoryLocator를 사용해서 경로 값을 구한다.
예를 들어, 아래와 같은 코드를 구현해서 JNDI나 시스템 프로퍼티에 지정된 경로값을 구해서 시스템을 초기화하는데 사용할 수 있다.

public class ConfigInitializerServletContextListener implements ServletContextListener {

@Override
public void contextInitialized(ServletContextEvent sce) {
DefaultDataDirectoryLocator locator = new DefaultDataDirectoryLocator();
locator.setServletContext(sce.getServletContext());
locator.setJndiName("java:comp/env/rr4s/home");
locator.setSystemProperty("rr4s.home");
locator.setContextParamName("rr4shome");
locator.setEnvVarName("RR4SHOME");
locator.setDefaultDir("$HOME/rr4s.home");
File homeDirectory = locator.locateDataDirectory();
// homeDirectory를 이용한 설정 초기화
}
....
}

사용법2, 스프링 빈으로 사용하기

또 다른 방법은 스프링 빈으로 사용하는 것이다. DefaultDataDirectoryLocator를 스프링 빈 객체로 설정해서 사용할 수 있고, 만약 서블릿 컨텍스트 파라미터에 접근해야 한다면, ServletContextAware 인터페이스를 구현한 ServletContextAwareDataDirectoryLocator를 사용하면 된다. 다음은 설정 예이다.

<bean id="dataDirectoryLocator"
class="org.simplericity.datadirlocator.spring.ServletContextAwareDataDirectoryLocator">
<property name="jndiName" value="java:comp/env/rr4s/home" />
<property name="systemProperty" value="rr4s.home" />
</bean>

<bean id="contextReloader" class="org.chimi.rr4s.setup.ContextReloader">
<property name="dataDirectoryLocator" ref="dataDirectoryLocator" />
</bean>

위 코드에서 ContextReloader 클래스는 인젝션을 통해서 전달받은 dataDirectoryLocator를 이용해서 설정에 필요한 디렉토리 경로를 받아올 것이다. 

public class ContextReloader implements ApplicationContextAware,
ApplicationListener<ContextRefreshedEvent> {

private DataDirectoryLocator dataDirectoryLocator;
...
private File locateHomeDirectory() {
return dataDirectoryLocator.locateDataDirectory();
}
...
}

 




저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

요즘 유행하는 빅데이터류의 기술을 사용하고 있지는 않지만, 빅이 아닌 나머지 분야에서의 대부분 자바 개발자들은 아마 웹 관련 프로젝트에 주로 참여하고 있을 거라 생각되어 최근에 진행중인 프로젝트에서 사용한 오픈 소스들에 대한 초간단 리뷰를 한번 해 보고자 한다. 이들 목록은 아래와 같다.

  • Spring Data JPA
  • Apache Shiro
  • Sitemesh
  • Bootstrap
  • Solr
  • Easyrec
Spring Data JPA

필자는 ORM 매니아이다. 아니 매니아를 넘어 ORM 신봉자에 가깝고 심지어 SQL은 (물론 필요할 땐 사용하지만) 쳐다보기도 싫을 정도이다. 이런 필자에게 Spring Data JPA는 하이버네이트에서 JPA로 넘어가는 계기를 만들어줬다. Spring Data를 사용하면 다음의 편리함들이 있다.
  • (거의 모든 리포지토리에 대해) 리포지토리 인터페이스만 정의하면 Spring Data가 런타임에 구현객체를 만들어 준다. 그래서 잡다하고 지겨운 코드 작성을 줄일 수 있다.
  • DDD의 Specification을 지원해서 검색 조건을 도메인 용어로 잘 표현할 수 있게 된다.
    • 덤으로 이들 스펙의 조합도 쉽게 할 수 있다.
  • 페이징, 정렬 등의 표준화된 인터페이스 제공

DB 연동과 관련된 지겨운 코드 타이핑을 덜 하게 해 주고 이는 더 중요한 부분에 시간을 더 많이 쏟을 수 있다는 걸 의미한다. 물론, DB 연동 관련 코딩 시간이 주니까 전반적인 개발 시간도 줄어드는 효과가 있다.


Apache Shiro


Apache Shiro는 인증과 권한을 위한 프레임워크로서 웹 URL 기반의 접근 제어나 코드에서 직접 권한 검사를 하기 위한 기능을 제공한다. 단, Shiro를 알맞게 커스터마이징해서 사용하려면 Shiro의 구조와 동작 방식에 대한 이해가 필요하다. 이와 관련해서는 예전에 필자가 정리한 http://javacan.tistory.com/entry/Apache-Shiro-Core-Diagram 글을 참고하기 바란다. 필자의 프로젝트의 경우는 권한 검사 부분을 커스터마이징 해서 사용했다. 예를 들어, DB로부터 역할과 기능 정보를 로딩하도록 커스텀 클래스를 구현했고, 쿠키를 이용해서 인증을 수행하도록 구현했다.


Sitemesh


예전부터 Tiles보다 Sitemesh가 좋았다. Sitemesh가 좋은 이유는 데코레이터를 적용하지 않아도 결과물이 완전한 HTML이 된다는 점이다. 예를 들어, Tiles를 사용하는 경우에는 내가 만드는 JSP가 Tiles 템플릿의 일부 영역을 만드는 것이기 때문에 완전한 HTML이 아니며, 따라서 필요한 자바 스크립트가 <head> 안에 들어가는 것이 아니라 <body> 태그 어딘가에 들어가게 된다. <head>에 넣으려면 별도의 JSP 파일에 넣어야 하는 불편함이 따른다. 반면에 Sitemesh를 사용하면 내가 만드는 코드가 완전한 HTML을 생성하게 된다. 즉, 데코레이터 적용 여부에 상관없이 완전한 하나의 결과물을 만들어내기 때문에, UI 관련 코드가 불필요하게 이 파일 저 파일에 쪼개지는 현상을 줄일 수 있다.


Bootstrap


프로토타입을 만들더라도 UI나 UX나 너무 개발자스러우면(^^;) 뭔가 만든 것 같지 않은 느낌이 들기 마련이다. 필자도 이걸로 고민을 좀 했는데, 아는 지인의 소개로 Bootstrap이란 걸 알게 되었다. Twitter에서 오픈한 CSS 소스인데, Bootstrap의 사용법을 조금만 익히면 최소한 개발자스러운 껍데기를 벗어날 수 있게 된다. 게다가 약간의 이미지만 곁들이면 있어 보이기까지 한다. 필자처럼 UI에 대한 감이 없는 개발자들이 디자인의 도움없이 뭔가 껍데기를 입혀야 한다면 적극 추천한다.


Apache Solr


Solr는 그 유명한 Lucene을 이용한 검색 서비스이다. 웹 서비스로 제공되기 때문에 플랫폼에 상관없이 쉽게 연동할 수 있다. 설치도 쉽고, 검색을 위한 스키마 설계만 간단하게 해주면 거의 바로 사용할 수 있다. 게다가 (필자처럼) 검색에 대한 지식이 약해도 빠르게 적용해 볼 수 있다는 장점이 있다. 한글 검색을 제대로 하려면 별도의 분석기가 필요하고 사전도 필요하겠지만, 단순 키워드 매칭 수준의 검색 용도르는 충분하다. 물론, 유사단어, 검색어 오류 수정 등의 기능을 제공하고 싶지만 많은 노력이 필요할 것이다.


Easyrec


이번에 PoC 성격의 프로젝트를 진행하면서 뭔가 개인화 추천 기능을 넣고 싶었다. CI(Collective Intelligence) 관련 내용은 이전부터 틈틈히 봤지만 그렇다고 이걸 직접 구현하고 싶진 않았다. 게다가 Mahout 같은 걸 삽질해 가면서 사용하고 싶진 않았다. 그런 와중에 지인(좋은 지인 열 개발자 안 부럽다인가요..)의 소개로 Easyrec라는 걸 알게 됐다. 정말이지 딱 필요한 기능만 제공하고 있어 이거다 싶을 정도였다. 내부 DB로는 MySQL을 사용하고 있고 자바 기반의 웹 어플리케이션으로 만들어졌기 때문에, 어지간한 환경에서 다 사용할 수 있다. 웹기반으로 동작하기 때문에 자바가 아닌 다른 언어에서도 쉽게 연동할 수 있다. 이쪽 분야의 전문가가 아니기에 품질이 어느 정도인지 아직 확인은 안 되지만, '빅'이 아닌 사이트에서 작게 사용하기에는 충분할 거라 생각된다.



저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.

Confluence Wiki를 설치해 본 사람은 설치 과정이 참 편하다는 것을 알 수 있다. 홈디렉토리 경로만 지정해주면 주요 설정은 웹 어플리케이션을 처음 구동할 때 설정하게 된다. 예를 들어, JDBC 연결 정보를 웹 브라우저를 이용해서 입력하고, DB를 생성하는 작업을 설치 마법사와 같은 방식으로 진행하게 된다.


많은 웹 어플리케이션들이 스프링을 이용해서 기능을 구현하는 경우가 많은데, 스프링 기반의 웹 어플리케이션을 사용하는 경우 어떻게 설치 위자드를 제공할 수 있을까? 요즘 이런 고민을 해 봤는데 답은 아주 간단하다. 스프링의 리로드 기능을 사용하는 것이다.


예를 들기 위해 다음과 같은 아주 간단한 웹 어플리케이션을 하나 만들어 보자.

  • WAR 파일로 배포되는 웹 어플리케이션
  • 최초 실행 시 설정 과정
    • 설치 과정1: 설치 안내
    • 설치 과정2: DB 정보 입력
    • 설치 과정3: 설정 완료
      • 지정 디렉토리에 설정 파일 저장
    • 설정 완료 후, 서비스 제공
  • 설정 완료 후, 이후 WAS를 재시작할 경우 설정 없이 서비스 제공
    • 설정 여부는 설정 파일 존재 여부로 확인
간단함을 위해 다음과 같이 코드를 구성했다.
  • 설정 과정 위한 파일
    • SetupController 클래스: 설정 과정을 처리하는 컨트롤러
    • ContextReloader 클래스: 설정 완료된 경우 또는 이미 설정된 경우 서비스 제공하도록 스프링 컨테이너 리로딩
    • setup-config.xml: 설정 과정에서 사용되는 스프링 설정 파일
  • 실 서비스 위한 파일
    • HomeController: 서비스의 첫 화면을 위한 컨트롤러
    • application-config.xml: 실제 서비스 용 스프링 설정 파일
web.xml 설정은 설정 위한 설정으로

web.xml 파일은 설정 과정을 위한 정보를 담는다.

<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>
classpath:/setup-config.xml
</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>

setup-config.xml 파일은 다음과 같이 사용자의 설정 과정을 처리하기 위한 SetupController와 설정이 완료되면 스프링 컨테이너를 리로딩해주는 ContextReloader로 구성된다.


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

<beans ...>

<bean id="contextReloader" class="org.chimi.rr4s.setup.ContextReloader">

</bean>


<bean class="org.chimi.rr4s.setup.SetupController">

<property name="contextReloader" ref="contextReloader" />

</bean>

         .... 스프링 MVC 관련 나머지 설정 들

</beans>


SetupController는 설정 과정을 처리하고 설정이 완료되면 설정 정보를 저장한 뒤에 ContextReloader를 이용해서 컨테이너를 리로딩한다. 아래는 SetupController의 예시 코드이다.


public class SetupController {


private ContextReloader contextReloader;


@RequestMapping("/home")

public String home() {

                // 설정 과정에서의 첫 화면은 설정 관련 안내 페이지

return "setup/home";

}


@RequestMapping("/setup/step1")

public String step1() {

                // 설정 폼을 보여주기 위한 처리

return "setup/step1";

}


@RequestMapping("/setup/save")

public String step2(...) {

saveConfig(); // 설정 저장

contextReloader.reloadContext(); // 리로딩

return "setup/completed";

}


private void saveConfig() {

FileOutputStream fos = new FileOutputStream(new File("./target/conf.properties"));

// 설정 파일을 지정한 경로에 저장

}


public void setContextReloader(ContextReloader contextReolader) {

this.contextReloader = contextReolader;

}

}


위 코드에서 핵심은 설정을 입력하면 설정 정보를 지정한 위치의 파일에 저장하고, ContextReloader.reloadContext()를 호출한다는 점이다. (실제로는 필요한 DB를 생성하는 과정 등이 포함되겠지만 여기서는 설명에 필요한 것만 보여주었다.)


ContextReloader는 다음과 같다.


public class ContextReloader implements ApplicationContextAware,

ApplicationListener<ContextRefreshedEvent> {


private ApplicationContext applicationContext;


public void reloadContext() {

if (applicationContext instanceof ConfigurableWebApplicationContext) {

ConfigurableWebApplicationContext configurableCtx = 

(ConfigurableWebApplicationContext) applicationContext;

String[] configLocations = { "classpath:/application-config.xml" };

configurableCtx.setConfigLocations(configLocations);

configurableCtx.refresh();

}

}


@Override

public void onApplicationEvent(ContextRefreshedEvent event) {

// 설정 확인

if (new File("./target/conf.properties").exists()) {

// 설정 존재하면 reloadContext();

reloadContext();

}

}


@Override

public void setApplicationContext(ApplicationContext context)

throws BeansException {

this.applicationContext = context;

}


}


ContextReloader는 스프링 컨테이너를 리로딩해야 하기 때문에 ApplicationContextAware를 implement해서 ApplicationContext를 전달받을 수 있도록 했다. 또한, 컨테이너가 초기화가 완료되는 이벤트를 받을 수 있도록 ApplicationListener를 implement하였다. 이 이벤트를 받는 이유는 뒤에서 다시 설명한다.


reloadContext() 메서드는 현재 스프링 컨테이너가 사용할 설정 파일을 application-config.xml로 변경한 뒤에 refresh()를 수행한다. 즉, reloadContext()를 호출하면 application-config.xml에 설정된 내용을 이용해서 스프링 컨테이너가 초기화된다. 따라서, 앞서 SetupController 클래스에서 설정을 완료한 뒤에 reloadContext()를 호출하게 되면, 실제 서비스를 위한 스프링 설정이 사용되는 것이다.


onApplicationEvent() 메서드는 ContextRefreshedEvent를 처리하는데, 이 이벤트를 처리하는 이유는 최초에 한 번만 설정 과정을 거치고 이후에는 거치지 않기 위함이다. 예를 들어, WAS를 재구동해야 하는 경우 기존에 이미 설정을 완료했다면 설정 화면이 아닌 서비스 화면이 나와야 할 것이다. 그런데, web.xml 파일은 설정 과정을 위한 setup-config.xml 파일을 사용하므로, 다시 설정 화면이 나올 것이다. 이를 방지하기 위해 컨테이너 초기화가 완료될 때 기존 설정 정보가 존재하는 지 확인하는 과정을 구현할 필요가 있는데, 컨테이너 초기화 시에 발생하는 이벤트가 ContextRefreshedEvent 이다. 따라서, ContextRefreshedEvent 이벤트를 수신하는 onApplicationEvent() 메서드에서 설정 파일이 존재하는 지 확인하고, 만약 존재한다면 서비스용 스프링 설정 파일로 컨테이너를 리로딩함으로써 서비스를 제공할 수 있게 된다.


저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.
JPA를 사용할 때 입력 값에 따라 동적으로 검색 조건을 변경하려면 Criteria API를 사용하면 되지만, 이게 참 이쁘지가 않고 코드를 만지다 보면 짜증이 나기도 한다. 최근에 하는 프로젝트에서 Spring Data JPA를 선택했는데, Spring Data JPA 덕분에 검색 조건을 조합할 때 발생하는 짜증을 많이 감소시킬 수 있게 되었다. Spring Data JPA가 Repository 또는 Dao의 인터페이스만 정의하면 알아서 구현체를 만들어준다는 장점이 정말 좋은데, 그에 못지 않게 검색 조건을 추상화한 Specification을 지원한다는 것 역시 아주 마음에 든다.

Specification을 사용하는 방법은 아주 간단한데, 그 방법은 다음과 같다.
  1. Specification을 입력 받도록 Repository 인터페이스를 정의하기
  2. 검색 조건을 모아 놓은 클래스 만들기
  3. 검색 조건을 조합한 Specification 인스턴스를 이용해서 검색하기
하나씩 만들어보자.

1. Specification을 입력 받는 Repository 메서드 정의
Spring Data에서 Specification은 검색 조건을 추상화하기 위해 사용되는데, 이 추상화된 검색 조건을 이용해서 데이터를 검색하도록 구현하려면 다음과 같이 Specification을 검색 메서드의 파라미터로 추가해주기만 하면 된다.

public interface ContentRepository extends Repository<Content, Long> {

Page<Content> findAll(Specification<Content> spec, Pageable pageable);

}


2. 검색 조건 모아 놓은 클래스 만들기
다음에 할 일은 검색 조건을 모아 놓은 클래스를 하나 만드는 것이다. 이 클래스를 구현할 때 JPA의 Criteria API를 사용하게 된다. 아래는 구현 예이다.

public class ContentSpecs {


    public static Specification<Content> titleLike(final String keyword) {

        return new Specification<Content>() {

            @Override

            public Predicate toPredicate(Root<Content> root,

                    CriteriaQuery<?> query, CriteriaBuilder cb) {

                return cb.like(root.get(Content_.title), "%" + keyword + "%");

            }

        };

    }


    public static Specification<Content> category(final Category category) {

        return new Specification<Content>() {

            @Override

            public Predicate toPredicate(Root<Content> root,

                    CriteriaQuery<?> query, CriteriaBuilder cb) {

                return cb.equal(root.get(Content_.category), category);

            }

        };

    }

    ... // 기타 검색 조건 생성 클래스

}


위에서 눈여겨 볼 점은 Criteria를 이용해서 검색 조건을 지정하는 코드가 category(), titleLike()와 같은 메서드로 추상화된다는 점이다.

3. 검색 조건 조합해서 검색하기
이제 할 일은 앞서 만든 검색 조건을 모아 놓은 클래스를 이용해서 검색 조건을 조합하고 Repository를 이용해서 결과를 가져오는 것이다. 아래는 예시 코드이다.

Specifications<Content> spec = Specifications.where(ContentSpecs.titleLike(query));
if (category != null) {
    spec.and(ContentSpecs.category(category));
}
Page<Content> p = contentRepository.findAll(spec); // david hong 님의 지적으로 수정합니다.


Spring Data JPA가 제공하는 Specifications를 사용하면 두 개 이상의 Specification을 AND나 OR 등으로 조합할 수 있다.


왜 좋지?
이 방식이 마음에 드는 이유는 검색 조건을 생성할 때 도메인의 용어를 사용할 수 있게 된다는 점이다. 앞서 만들었던 ContentSpecs.titleLike()만 보더라도 이 검색 조건이 제목을 LIKE 검색하기 위한 조건이라는 것을 알 수 있다.

마음에 드는 또 다른 이유는 검색 조건을 조합하는 코드에서 Criteria와 같은 실제 구현 기술의 타입을 사용하지 않는다는 점이다. 사용자는 ContentSpecs.category() 메서드를 사용해서 검색 조건을 추상화한 Specification 객체를 구하기만 하면 된다. 내부적으로 JPA의 Criteria를 사용하는지의 여부는 전혀 알 필요가 없다.

마지막으로 이 방식은 누구나 쉽게 만들 수 있을 만큼 쉽다. 처음 이 방식을 접하는 경력자 뿐만 아니라 이제 3개월 된 신입들도 별다른 어려움 없이 위 방식에 적응했다.

불필요한 코드 작성을 줄이고 그러면서도 이전보다 쉽게 DB 연동 부분을 처리하고 싶은 개발자분들에게 JPA + Spring Data JPA/Specification의 조합을 아주 강력하게 권장한다. 


저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. david hong 2013/03/15 16:53  댓글주소  수정/삭제  댓글쓰기

    spec 설정과 repository 의 findAll 호출간에 relation 이 없는데 findAll(spec,pageRequest) 와 같은 형태로 전달해야 하지 않나요?

    • madvirus 2013/03/17 20:38  댓글주소  수정/삭제

      글에 오류가 있었네요. findAll(pageRequest)가 아니라 findAll(spec) 이었습니다. 지적하신 부분 수정해 놓았습니다. 잘못된 부분 알려주셔거 고맙습니다.

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.
사용자 인증 정보를 보관하는 가장 손쉬운 방법은 세션을 사용하는 것인데, 가용성 향상이나 부하 증가 대처를 위해 웹 서버를 옆으로 늘릴 경우 세셔 클러스터링을 해 주어야 한다. 하지만, 세션 클러스터링을 하려면 별도의 장비 구성이 필요하거나 WAS에 의존적일 수 있기 때문에, 세션 대신에 쿠키에 인증 정보를 암호화해서 저장하고(인증 쿠키라고 부르자) 웹 서버는 인증 쿠키를 파싱해서 사용자를 인증하는 방식으로 아키텍처를 단순화시키곤 한다. 이 경우, 로그인과 로그아웃을 처리하는 서버는 인증 쿠키를 생성하거나 삭제하는 역할을 수행하게 되고, 나머지 웹 어플리케이션들은 인증 쿠키를 이용해서 사용자의 인증 여부 및 아이디 등의 정보를 조회하게 된다.

Apache Shiro는 기본적으로 세션에 사용자 정보를 저장하기 때문에, 인증 쿠키를 이용해서 인증하도록 처리하려면 몇 가지 커스텀 구현을 제공해 주어야 한다. 최근에 필자가 시작한 프로젝트에서 인증 쿠키와 Shiro를 엮을 필요가 있었으며, 이를 위해 시도한 방법을 이 글을 통해 정리해보고자 한다.

쿠키를 이용한 인증을 처리하기 위해 다음과 같은 작업을 하였다.
  • 인증 쿠키가 존재할 경우 인증 쿠키 값을 이용해서 사용자 인증을 수행하는 Filter 구현
  • 인증 쿠키의 값을 이용해서 사용자를 인증해주는 Authenticator 커스텀 구현
  • SecurityManager 설정
    • 세션에 Subject를 보관하지 않도록 설정
    • 커스텀 Authenticator를 사용하도록 설정
  • Shiro 필터를 이용해서 알맞은 필터 체인 형성
한 가지씩 차례대로 살펴보도록 하자.

인증 쿠키가 존재할 경우, 인증 쿠키 값을 이용해서 인증을 수행하는 Filter
인증 쿠키가 존재할 경우 해당 인증 쿠키로부터 인증을 수행하도록 처리하는 코드는 간단한다. 아래는 구현 코드 예이다.

public class AuthenticationByUserAuthCookieFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        try {
            Cookie authCookie = getAuthCookie((HttpServletRequest) request);
            if (authCookie != null) {
                try {
                    authenticate(authCookie);
                } catch (AuthenticationException ex) {
                    // 인증 쿠키가 잘못되었으므로, 쿠키를 제거
                    removeInvalidAuthCookie((HttpServletResponse) response);
                }
            }
            chain.doFilter(request, response);
        } finally {
        }
    }

    private void removeInvalidAuthCookie(HttpServletResponse response) {
        // 쿠키 삭제 처리 코드 위치
    }

    private Cookie getAuthCookie(HttpServletRequest request) {
        // 인증 쿠키 구하는 코드 위치
    }

    private void authenticate(Cookie authCookie) {
        try {
            SecurityUtils.getSubject().login(
                    new UserAuthValueAuthenticationToken(
                            URLDecoder.decode(authCookie.getValue(), "UTF-8")));
        } catch (UnsupportedEncodingException e) {
            // TODO 잘못된 쿠키 값이므로 쿠키 삭제 필요
        }
    }
    ... // init(), destroy() 메서드
}


위 코드는 인증 쿠키가 존재할 경우, 인증 쿠키의 값을 이용해서 UserAuthValueAuthenticationToken 객체를 생성하고, 그 객체를 이용해서 로그인 요청을 수행한다. 이 필터를 적용하게 되면, 사용자의 웹 요청이 발생할 때 마다 매번 인증 처리를 수행하게 된다.

인증 쿠키 값을 이용하여 인증을 처리해주는 Authenticator 구현
인증 요청을 수행할 때 사용한 인증 토큰이 UserAuthValueAuthenticationToken 이므로, 이 토큰을 이용해서 인증을 처리해주는 Authenticator를 만들어주어야 한다. 아래 코드는 구현 예이다.

public class AuthValueAuthenticator extends AbstractAuthenticator {

    @Override
    protected AuthenticationInfo doAuthenticate(AuthenticationToken token)
            throws AuthenticationException {
        if (token instanceof UserAuthValueAuthenticationToken) {
            return getUserAuthenticationInfo(token.getPrincipal().toString());
        }
        return null;
    }

    private AuthenticationInfo getUserAuthenticationInfo(String authToken) {
        String[] authInfo = AuthValueCryptor.decrypt(authToken);
        return new SimpleAuthenticationInfo(new UserPrincipal(authInfo[0],
                authInfo[1]), "", "AuthValueAuthenticator");
    }

}

토큰 타입이 UserAuthValueAuthenticationToken 이면, 해당 토큰으로부터 인증 값을 가져오고, 그 인증값으로 사용자 인증 정보(getUserAuthenticationInfo() 메서드)를 생성한다. 위 코드는 인증 쿠키 값에 사용자 ID 정보가 암호화되어 들어가 있는 경우의 구현 코드를 보여준 것이며, 인증 쿠키 값이 DB에 보관된 사용자 정보를 조회하기 위한 고유키 값이라면 그 키 값을 이용해서 정보를 조회한 뒤 AuthenticationInfo를 생성하도록 구현하면 될 것이다.

참고로, 위 코드에서 UserPrincipal은 커스텀 구현 Principal 클래스로서 사용자 ID와 이름을 보관하기 위해 만들었다.

스프링 설정을 이용한 SecurityManager 설정
Authenticator 구현 클래스를 작성했으므로 SecurityManager가 해당 Authenticator를 사용하도록 설정해 주어야 한다. 아래 코드는 스프링 설정의 예를 보여주고 있다.

<bean id="authValueAuthenticator"
    class="com.scgs.racon.infra.shiro.AuthValueAuthenticator">
</bean>

<!-- Shiro Security Manager -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <property name="subjectDAO.sessionStorageEvaluator.sessionStorageEnabled"
        value="false" />
    <property name="realm" ref="realm" />
    <property name="authenticator" ref="authValueAuthenticator" />
</bean>

<bean id="realm" class="com.scgs.racon.infra.shiro.OpUserJdbcRealm">
    <property name="dataSource" ref="dataSource" />
    <property name="userRolesQuery"
        value="select ROLE_CODE from OP_USER_ROLE where OP_USER_ID = ?" />
    <property name="permissionsQuery"
        value="select PERMISSION_CODE from OP_USER_ROLE_PERMISSIONS where ROLE_CODE = ?" />
    <property name="permissionsLookupEnabled" value="true" />
</bean>

위 코드에서는 두 가지를 하고 있다.
  • Subject 정보가 세션에 보관되지 않도록 설정 (subjectDAO.sessionStorageEvaluator.sessionStorageEnabled 프로퍼티를 false로 설정)
  • 앞서 구현한 Authenticator를 사용하도록 설정

(참고로, realm의 경우에도 커스텀 Realm 구현 클래스이다.)


ShiroFilter 설정을 이용한 필터 설정
이제 남은 작업은 ShiroFilter를 이용해서 앞서 작성한 필터를 적용하는 것이다.

먼저 스프링 설정 파일에 아래와 같이 앞서 작성했던 필터를 생성하고, ShiroFilter에서 해당 필터를 사용하도록 설정한다.

<bean id="userAuthFilter"
    class="com.scgs.racon.infra.auth.AuthenticationByUserAuthCookieFilter">
</bean>

<bean id="userAuthCheckFilter" class="com.scgs.racon.infra.auth.AuthCheckFilter">
    <property name="loginUrl" value="/login" />
</bean>

<!-- Shiro Filter를 이용한 필터 체인 처리 -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager" />
    <property name="filterChainDefinitions">
        <value>
            /home=userAuthFilter
            /login=userAuthFilter
            /my/**=userAuthFilter,userAuthCheckFilter
        </value>
    </property>
</bean>

ShiroFilter의 필터 체인 정의를 사용해서 필터가 적용될 URL의 가장 첫 번째에 인증 필터를 적용한다. 그리고, 로그인을 안 한 경우 로그인 페이지로 이동시키고 싶다면, 해당 기능을 제공하는 필터를 만들어 추가해주면 된다. 예를 들어, 위 코드의 경우 /my/** 로 오는 모든 요청에 대해 먼저 userAuthFilter를 적용하고, 그 뒤에 userAuthCheckFilter가 적용되도록 했다. userAuthCheckFilter는 사용자가 인증되지 않은 경우 로그인 페이지로 이동시키는 기능을 제공한다고 할 경우, 위 설정은 /my/**로 요청이 들어올 경우 먼저 userAuthFilter로 사용자 인증을 처리하고(쿠키가 있는 경우에 한해 인증 처리), userAuthCheckFilter를 이용해서 인증을 하지 않은 사용자인 경우 로그인 페이지로 리다이렉트 시킨다.

참고로, userAuthCheckFilter는 아래와 같이 Subject.isAuthenticated()를 이용해서 인증 여부를 확인한다.

public class AuthCheckFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        if (!SecurityUtils.getSubject().isAuthenticated()) {
            sendRedirectLoginPage((HttpServletRequest) request,
                    (HttpServletResponse) response);
            return;
        }
        chain.doFilter(request, response);
    }
    ...
}

web.xml에서 ShiroFilter를 사용하도록 설정
이제 남은 작업은 ShiroFilter가 적용되록 web.xml에 설정하는 것이다. ShiroFilter가 서블릿 필터로 사용되록 하기 위해 DelegatingFilterProxy를 사용하면 된다. 아래는 설정 예이다.

<filter>
    <filter-name>shiroFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    <init-param>
        <param-name>targetFilterLifecycle</param-name>
        <param-value>true</param-value>
    </init-param>
    <init-param>
        <param-name>contextAttribute</param-name>
        <param-value>org.springframework.web.servlet.FrameworkServlet.CONTEXT.dispatcher</param-value>
    </init-param>
</filter>

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


저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.
Apache Shiro는 사용하는 입장에서 보면 매우 단순한 API를 제공하고 있다. 예를 들어, 아래 코드는 Shiro가 제공하는 Subject를 이용한 로그인/권한검사/로그아웃 코드 예를 보여주고 있다.

Subject subject = SecurityUtils.getSubject();

subject.login(new UsernamePasswordToken(id, password);

// ROLE 체크
subject.checkRole("MEMBER");
subject.checkRole("TEAMLEADER");

// 권한 체크
subject.checkPermission("project:approve");
subject.checkPermission("team:summary:regist");

subject.logout();

위 코드만 보면 '아~ 정말 쉽다'라고 하면서 도전해 보고 싶은 마음이 생기게 된다. 하지만, Shiro의 '정말 쉽다'는 어디까지나 자체적으로 사용하는 회원 DB와 역할/퍼미션 매핑 DB가 없다는 가정하에서 '정말 쉽다'이다. 회원 DB와 역할/퍼미션 DB가 자체적으로 존재하고, 이 DB에 맞게 Shiro를 커스터마이징하려면 전체 클래스 구성을 이해하는 것이 중요하다. 제대로 된 이해 없이 쉬울 것 같아 덤볐다간 뭘 해야할지 몰라 멍 때리며 시간만 보내게 될 것이다.

최근에 Apache Shiro를 기존 어플리케이션에 적용할 일이 있었는데, 몇 가지 커스터마이징을 해야 했다. 커스터 구현체를 Shiro 프레임워크에 꽂아주기 위해 Shiro의 전체 구성과 동작 방식을 분석했으며, 이 글을 통해 전체 구조와 커스터마이징 포인트를 찍어 보고자 한다.

Apache Shiro의 SecurityManager

Apache Shiro를 보안 프레임워크로 사용하려면 Apache Shiro의 핵심인 SecurityManager에 대한 이해가 필요하다. 이걸 이해해야 비로서 어느 부분에 커스텀 구현체를 꽂아 넣을 지 알 수 있기 때문이다.

SecurityManager 및 구현 클래스, 그리고 SecurityManager가 동작하는 데 사용되는 협업 클래스의 구성은 아래  아래 그림과 같다.



SecurityManager 인터페이스는 인증/권한 검사/세션 관리와 관련된 모든 기능을 정의하고 있다. 예를 들어, 로그인 처리를 위한 login() 메서드, 권한 검사를 위한 hasRole(), checkPermission() 등의 메서드를 정의하고 있다.

SecurityManager의 하위 클래스들은 각각 특정 역할을 수행하며, 실제로 우리가 사용하는 구현 클래스는 DefaultSecurityManager와 DefaultWebSecurityManager이다. 계층에서 각 클래스의 역할은 아래와 같다.
  • RealmSecurityManager: Realm 목록을 관리해준다.
  • AuthenticatingSecurityManager: 인증 처리를 Authenticator에 위임하는 기능을 제공한다.
  • AuthorizingSecurityManager: 권한 검사 처리를 Authorizer에 위임하는 기능을 제공한다.
  • SessionSecurityManager: 세션 관리 기능을 제공한다.
  • DefaultSecurityManager: 기본 구현 클래스이다. '기억하기(Remember Me)' 기능을 추가로 제공한다.
  • DefaultWebSecurityManager: 웹 어플리케이션에서 사용되는 구현 클래스이다.
DefaultSecurityManager 클래스를 사용하면 인증 처리와 권한 검사 처리는 각각 Authenticator와 Authorizer에 위임하므로, 자신의 어플리케이션에 맞게 인증/권한 검사 처리를 수행하려면 Authenticator와 Authorizer 구현 클래스를 제공하면 된다.

Authenticator와 Authorizer를 따로 지정하지 않으면 각각 ModularRealmAuthenticator와 ModularRealmAuthorizer를 구현 클래스로 사용하며, 이 두 ModularRealm 객체는 다시 Realm에게 인증 처리나 권한 처리를 위임한다. 예를 들어, 기본 구현 클래스를 사용할 경우 Subject.login()을 실행하면 다음의 흐름에 따라 인증 처리를 수행하게 된다.


그렇다면 ModularRealmAuthenticator가 위임하게 되는 Realm은 어디서부터 구할까? 이 목록은 RealmSecurityManager가 관리하는 Realm 목록을 사용하게 된다. DefaultSecurityManager가 RealmSecurityManager를 상속받고 있으므로, RealmSecurityManager가 내부적으로 관리하고 있는 Realm 객체들에 위임을 하게 된다. ModularRealmAuthorizer도 동일한 과정을 거쳐 Realm에 역할/퍼미션 검사를 위임한다.

지금까지의 설명을 바탕으로 필자는 다음의 두 가지 정도의 확장 포인트가 유용할 거라 생각했다.
  • Authenticator
    • 만들려는 어플리케이션의 환경에 들어 맞는 인증 수단을 제공하는 Realm이 없다면, Authenticator를 직접 구현한다.
    • 적용 가능한 Realm이 있다면, 그 Realm을 사용하거나 또는 Realm을 확장해서 구현한다.
  • Realm
    • 인증/권한 검사에 적합한 Realm 구현체가 존재하지 않으면 Realm을 직접 구현한다.
    • 또는, 기존의 Realm 구현 클래스를 확장해서 구현한다.
필자의 경우는 인증 처리를 위해 Authenticator를 새로 구현하였고, 역할/권한 구현을 위해 기존에 존재하던 Realm을 확장해서 구현했다.

Apache Shiro의 Realm

앞서 살펴봤듯이 DefaultSecurityManager는 (ModularRealmAuthenticator와 ModularRealmAuthorizer를 사용한다는 가정하에) 최종적으로 Realm을 이용해서 인증과 권한 검사를 수행하게 된다. 그렇다면 Realm은 뭘까? Realm은 인증과 관련된 유저 정보와 역할/퍼미션과 관련된 정보를 담고 있는 DB라고 생각하면 된다.

Shiro가 제공하는 Realm 구현 클래스는 아래 그림과 같다.


각 Realm 구현 클래스의 역할은 다음과 같다.
  • AuthenticatingRealm: 인증 토큰(AuthenticationToken-예를 들어, 아이디/암호)에 일치하는 인증 정보(AuthenticationInfo)를 제공한다. 인증 토큰이 인증 정보와 일치할 경우 인증된다.
    • CredentialsMatcher: 인증 토큰과 인증 정보가 일치하는지 검사한다.
  • AuthorizingRealm: 역할/권한 검사를 위한 기반 기능을 제공한다. 역할/권한 검사 기능을 제공할 Realm은 이 클래스를 상속 받아 구현하면 좀 더 쉽게 구현할 수 있다.
  • SimpleAccountRealm: 메모리 상에 사용자 정보/역할 정보/퍼미션 정보를 관리하는 경우에 사용한다.
    • IniRealm: INI 형식의 파일로 사용자/역할/퍼미션 정보를 설정할 수 있도록 해 주는 Realm. 즉, INI 파일을 사용자/역할/퍼미션에 대한 DB로 사용한다. Shiro를 테스트 해 보거나 사용자 계정에 변경이 거의 없는 단순한 어플리케이션에 한해서 사용하는 것이 좋다.
  • JdbcRealm: DB로부터 사용자 정보, 역할 정보, 퍼미션 정보를 가져오는 Realm 구현 클래스이다.
Apache Shiro를 이용해서 인증/권한 검사를 수행하려면 결국 알맞은 Realm을 선택해서 사용하거나 새로운 Realm을 구현해 주어야 한다. 필자의 경우 DB에 역할과 퍼미션 등의 정보를 넣었기 때문에 JdbcRealm을 상속받아 커스텀 Realm을 구현해 주었다.

Shiro 커스텀 구현을 위해 더 알아야 할 내용들

인증을 수행하는 Authenticator는 인증 처리를 위해 다음의 메서드를 제공하고 있다.

public AuthenticationInfo authenticate(AuthenticationToken authenticationToken)

앞서 커뮤니케이션 다이어그램에서 봤듯이, Subject의 login() 메서드를 호출하면 실제로 Authenticator의 authenticate() 메서드가 호출된다. 이 때, Authenticator가 리턴하는 타입은 AuthenticationInfo 이다. 이 AuthenticationInfo는 다음과 같이 정의되어 있다.

public interface AuthenticationInfo extends Serializable {
    // 내가 누구인지를 알려주는 정보
    PrincipalCollection getPrincipals();

    // 날 증명하는 정보
    Object getCredentials();
}

PrincipalCollection에는 ID와 같은 정보가 기록되며, Authenticator가 생성한 PrincipalCollection 정보는 Subject에 전달된다. 실제로 Shiro에서 '사용자'를 표현할 때 사용되는 Subject는 다음의 메서드를 이용해서 그 사용자가 누구인지를 알 수 있도록 하고 있다.

// Subject에 정의된 메서드

Object getPrincipal(); // PrincipalCollection에서 주요 Principal 객체를 리턴
PrincipalCollection getPrincipals();

Subject가 checkPermission(String permission)와 같은 메서드를 실행하게 되면, Subject는 내부적으로 SecurityManager에 그 처리를 위임하는데 그 때 호출하는 메서드는 아래와 같다. 즉, 권한 검사를 수항핼 때 마다 Authenticator가 생성한 PrincipalCollection 객체가 SecurityManager에 전달된다.

// SecurityManager에 정의된 메서드

void checkPermission(PrincipalCollection subjectPrincipal, String permission)

(DefaultSecurityManager라는 가정하에) SecurityManager는 다시 Authorizer에 권한 검사를 위임하게 되고, Authorizer가 ModularRealmAuthorizer인 경우 Authorizer는 다시 Realm에 권한 검사를 위임하게 된다. 이 얘기는 Subject의 checkPermission() 메서드를 호출하면 최종적으로 Subject가 전달한 PrincipalCollection 객체가 Realm까지 전달된다는 것을 의미한다.

이는 PrincipalCollection이 내부적으로 갖고 있는 '사용자 정보'의 타입 문제가 발생할 수 있음을 말한다. 예를 들어, Authenticator가 Employee 타입을 갖는 객체를 PrincipalCollection에 넣었는데 Realm은 String 타입을 필요로 한다고 해 보자. 이 경우 타입 불일치로 인해 권한 검사가 정상적으로 동작하지 않을 것이다.

Shiro의 기본 컴포넌트를 사용하면 하나의 Realm이 인증도 하고 권한 검사도 같이 하기 때문에, PrincipalCollection 타입을 자신에 맞게 맞추게 된다. 하지만, 인증을 수행하는 Realm과 권한을 검사하는 Realm이 다를 경우 또는 인증은 커스텀 Authenticator로 수행하고 권한 검사는 제공되는 Realm을 사용할 경우에는 PrincipalCollection에 저장될 '사용자 정보' 타입을 맞춰주어야 한다. 실제로 JdbcRealm의 경우 PrincipalCollection에 보관된 '사용자 정보' 객체를 무조건 String으로 타입 변환해주는 코드가 있었는데 필자가 작성한 Authenticator는 String이 아닌 다른 타입의 객체를 PrincipalCollection에 저장해서 타입 변환 오류가 발생했었다. 이 문제를 해소하기 위해 부득이 JdbcRealm을 상속받은 커스텀 Realm을 만들게 되었다.

정리

이 글에서 Shiro에 대해 자세하게 살펴본 것은 아니지만, 이 정도 내용이면 Shiro를 사용할 때 커스터마이징 지점을 찾는데에는 도움이 될 거라 생각한다. Shiro 프레임워크를 사용해서 보안 기능을 적용하고자 하는 개발자들에게 이 글이 도움이 되길 바라며, 글을 마친다.




저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.
이 글은 Jetty 6 버전 기준이긴 하지만, 플러그인 버전이나 의존 라이브러리에서 차이가 날 뿐 Jetty 7 버전에서도 크게 다르지 않다.

Jetty 서버를 임베딩하기 위한 pom.xml 설정

Maven 프로젝트에서 Jetty 서버를 임베딩하려면 먼저 Jetty 관련 클래스들을 사용할 수 있어야 하므로, 아래와 같이 의존 라이브러리에 Jetty 관련 artifact를 추가해 준다.

<dependencies>
    <dependency>
        <groupId>org.mortbay.jetty</groupId>
        <artifactId>jsp-api-2.1</artifactId>
        <version>6.1.14</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.mortbay.jetty</groupId>
        <artifactId>servlet-api-2.5</artifactId>
        <version>6.1.14</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.mortbay.jetty</groupId>
        <artifactId>jetty</artifactId>
        <version>6.1.14</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mortbay.jetty</groupId>
        <artifactId>jsp-2.1</artifactId>
        <version>6.1.14</version>
        <scope>test</scope>
    </dependency>
    ...

위 설정에서 눈여겨 볼 점은 서블릿/JSP API를 위한 artifact는 provided 범위를 갖는 반면에 Jetty 자체를 임베딩해서 실행하기 위한 artifact는 test 범위를 갖는다는 것이다. test 범위를 갖도록 한 이유는 Jetty 임베딩을 ATDD를 위한 용도로만 사용하기 때문이다.

Jetty 서버 임베딩으로 실행하기

Maven 프로젝트 구조에서 Jetty 서버를 임베딩으로 실행하고 종료하는데 사용된 코드는 다음과 같다.

public class JettyServer {

    private static Server server;
   
    public static void start() throws Exception {
        server = new Server(9090);

        WebAppContext context = new WebAppContext();
        context.setResourceBase("src/main/webapp");
        context.setContextPath("/goodjob");
        context.setParentLoaderPriority(true);

        server.setHandler(context);
       
        server.start();
    }

    public static void stop() throws Exception {
        server.stop();
    }
}

ATDD를 수행하는 코드에서는 다음과 같이 픽스처 구성시 JettyServer.start() 메서드를 이용해서 Jetty 서버를 임베딩해서 실행하고, 테스트 완료 후 JettyServer.stop() 메서드를 이용해서 Jetty 서버를 중지시켜주면 된다.

public class EndToEndTestBase {

    @BeforeClass
    public static void init() throws Exception {
        JettyServer.start();
    }

    @AfterClass
    public static void close() throws Exception {
        JettyServer.stop();
    }

}

일단, Jetty 서버를 임베딩으로 실행하면 http://localhost:9090/goodjob/authentication/login 과 같은 URL을 이용해서 실제 기능 테스트를 수행할 수 있다. 예를 들어, 웹 기능 테스트를 위한 코드는 아래와 같은 구성을 갖게 된다.

public class AllTeamsSummaryReportEndToEndTest extends EndToEndTestBase {

    @BeforeClass
    public static void initData() throws Exception {
        // 테스트 목적을 위한 데이터 초기화
        ...
    }

    @Test
    public void queryAllTeamsWeekSummaryReport() throws Throwable {
        // HtmlUnit이나 HttpClient와 같은 라이브러리를 이용해서
        // http://localhost:9090/goodjob/testurl 등 테스트 URL에 연결하고
        // 그 결과를 검증하는 코드 삽입
    }

}




저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.
자바7 출시가 점점 앞으로 다가오는데, 마음에 드는 자바7의 몇 가지 특징들은 다음과 같다.
  • try-with-resources
  • 멀티캐치(multicatch)
  • switch-case에 문자열 지원
  • Fork/Join
  • ECC 암호화 기능 제공
try-with-resources 이용 자원 해제 자동 처리

사실 이거 진작에 필요했던거다. 뭔가 자원을 생성하고 사용하고 해제하는 코드는 항상 다음과 같이 구조가 중복되는 코드를 작성해야 했다. finally 블록의 자원 해제 코드 정말 하는 거 없이 여러 줄 차지한다.

SomeResource resource = null;
try {
    resource = getResource();
    use(resource);
} catch(...) {
    ...
} finally {
    if (resource != null) {
        try { resource.close(); } catch(...) { /* 아무것도 안 함 */ }
    }
}

이게 코딩할 때 참 귀찮게 만드는 건지 알았는지 자바7에서 try-with-resources라는 특징이 추가되었다. 이건 문법 차원에서 추가된 건데, try에 자원 객체를 전달하면 finally 블록으로 종료 처리를 하지 않아도 try 코드 블록이 끝나면 자동으로 자원을 종료해주는 기능이다. 모습은 아래와 같다.

try (SomeResource resource = getResource()) {
    use(resource);
} catch(...) {
    ...
}

뭔가 코드 줄 수가 많이 줄어드는 것을 알 수 있다. finally 블록에 출현했던 자원 해제 코드를 작성하지 않아도 되기 때문에 코딩도 편하다. try 블록에서 사용하는 자원의 개수가 늘어나면 try-with-resources의 위력은 배가 된다. 아래는 예이다.

try (InputStream in = new FileInputStream(inFile);
     OutputStream out = new FileOutputStream(outFile)) {
    ...
} catch(IOException ex) {
    ...
}
// in과 out 모두 자동으로 종료됨


멀티캐치(multicatch)

예외 처리 블록도 참 코드를 길게 만들어주는 것 중의 하나였다. 어떤 메서드를 실행하면 예외가 AException, BException, CException이 발생하는데, 이 세 예외에 대해 AException과 BException은 동일한 코드를 실행하고 CException은 다른 코드를 실행한다고 하자. 만약 AException과 BException이 공통의 부모 예외 클래스가 없다면 다음과 같이 중복되는 코드를 작성해 주어야 했다.

try {
    throwABCmethod();
} catch(AException ex) {
    any(ex); // 코드 중복
} catch(BException ex) {
   any(ex); // 코드 중복
} catch(CException ex) {
    some(ex);
}

항상 개발 서적에서 나오는 말 중의 하나는 코드 중복을 없애라는 것이다. 그런데, 애초에 언어가 저 따구로 밖에 코드를 만들 수 없도록 했기 때문에 부득이 예외 처리 부분에서 중복된 코드가 발생했었다. 그런데, 자바7 버전에서 다음과 같이 하나의 catch 블록에서 동시에 여러 예외를 묶어서 처리할 수 있도록 했다.

try {
    throwABCmethod();
} catch(final AException | BException ex) {
    any(ex);
} catch(CException ex) {
    some(ex);
}

catch 블록에서 한번에 여러 개의 예외를 잡을 수 있다는 의미로 위 방식을 멀티캐치(multicatch)라고 하는데, 멀티캐치 덕에 주저리 주저리 길게 나열되면서 중복되는 catch 블록을 작성하지 않아도 된다.

switch-case의 문자열 지원

이것도 좀 진작에 지원해 주었으면 좋으련만, 어쨋든 switch-case에 이제 문자열을 사용할 수 있게 되었다.

String value = ...;
switch(value) {
case "city":
    ...
case "country":
    ...
}


fork/join을 이용한 작업 분할 실행

자바에서 분할-정복(divide-and-conquer) 방식으로 작업을 분해하고 실행하려면 직접 관련 코드를 만들어주어야 했다. 그런데, 필자 같은 경우는 멀티코어가 정착되면서 뭔가 작업을 분할해서 다중 쓰레드를 이용해서 병렬로 정복하고 싶은 싶은 욕구가 생긴다. 필자 외에도 많은 개발자들이 멀티코어를 활용하고 싶은 욕구가 있을텐데, 자바7에 분할-정복 방식을 위한 ForkJoinPool 클래스가 추가되었다. 이 클래스는 큰 작업을 작은 작업으로 나누고 각각의 분할된 작업을 병렬로 처리해주는 기능을 제공하기 때문에 멀티코어 환경에서 처리 시간을 단축할 수 있도록 하고 있다.

중요한 건 프레임워크이기 때문에 내가 밑바닥부터 만들 필요가 없다는 점이다. 그냥 제공하는 API에 맞게 코드를 만들어주면 땡이다.

ECC 암호화 기본 내장

이것도 진작 넣었더라면 더 좋았을 것을. 암호화 처리를 위해 ECC를 제공하지 않아 외부 라이브러리를 사용하는 곳이 많았는데, 이제 그럴 필요가 없어졌다. 드디어 자바가 ECC를 지원하기 시작했다.

기타

이 외에 NIO 강화, 네트워크 관련 기능 강화 등이 있다.

저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.
Akka는 액터에 메시지를 전달하고 응답을 받을 때 자바 인터페이스를 사용할 수 있는 기능을 제공하고 있다. 즉, ActorRef의 sendOneWay()나 sendRequestReply()와 같은 메서드가 아닌 자바 인터페이스에 정의된 메서드를 이용해서 액터에 메시지를 전달하고 응답을 받을 수 있도록 하고 있다. 본 글에서는 Akka가 제공하는 TypedActor를 이용해서 자바 인터페이스를 액터와의 통신 인터페이스로 사용하는 방법을 설명한다.

TypedActor를 이용한 자바 인터페이스 기반 액터 생성

TypedActor 클래스를 사용하면 인터페이스를 구현한 자바 클래스를 액터로 사용할 수 있다. TypedActor를 사용하려면 다음과 같이 인터페이스와 그 인터페이스를 구현한 클래스를 필요로 한다. 이때 인터페이스를 구현한 클래스는 TypedActor 클래스를 상속 받아야 한다.

public interface DataMigrator {
    public void run();
    public int restCount();
}

public class DataMigratorImpl extends TypedActor implements DataMigrator {

    private int count = 0;

    @Override
    public void run() {
        System.out.println("DataMigratorImpl: 작업 시작");
        // 뭔가 작업을 비동기로 처리
    }

    @Override
    public int restCount() {
        return 100 - count;
    }
}

액터와 통신할 때 사용할 인터페이스를 구현하고 TypedActor를 상속받은 클래스를 구현했다면, 다음의 코드를 이용해서 액터를 생성하고 사용할 수 있다.

DataMigrator migrator =
            TypedActor.newInstance(DataMigrator.class, DataMigratorImpl.class);

// migrator는 액터와 통신을 위한 프록시
migrator.run();
int rest = migrator.restCount();
do {
    Thread.sleep(10);
    rest = migrator.restCount();
} while(rest > 0);

TypedActor.stop(migrator); // TypedActor 종료
// Actors.registry().shutdownAll(); 코드도 TypedActor 종료

TypedActor.newInstance()의 첫 번째 파라미터는 액터와 통신할 때 사용할 인터페이스 타입을 지정하며, 두 번째 파라미터는 실제 TypedActor로 사용될 클래스를 지정한다. TypedActor.newInstance() 메서드가 생성한 객체는 액터와 통신을 수행해주는 프록시 객체가 된다. 위 코드에서는 migrator가 프록시 객체가 되는데, 이 프록시 객체의 메서드를 호출하면, 내부적으로 TypedActor 객체에 메시지를 전송하게 되고 TypedActor 객체는 일치하는 메서드를 호출하게 된다.

[참고]
Akka는 TypedActor에 대한 프록시를 객체를 생성하기 위해 AspectWerkz(http://aspectwerkz.codehaus.org/ 참고)를 사용한다.


Fire-And-Forget

메서드의 리턴 타입이 void 이면, 해당 메서드에 대한 메시지는 sendOneWay()와 동일하게 Fire-And-Forget 방식으로 전송된다. 따라서, 메서드를 호출하면 액터가 메시지를 처리 여부에 상관없이 즉시 리턴한다. DataMigrator 인터페이스의 run() 메서드가 이에 해당한다.

DataMigrator migrator = TypedActor.newInstance(DataMigrator.class, DataMigratorImpl.class);
migrator.run(); // 리턴 타입이 void 이므로 Fire-And-Forget 방식


Send-And-Receive-Eventually

메서드가 리턴 타입을 가지면, sendRequestReply()와 동일하게 Send-And-Receive-Eventually 방식으로 메시지가 전송된다. 따라서, 액터로부터 응답이 도착할 때 까지 블럭킹 된다.

DataMigrator migrator = TypedActor.newInstance(DataMigrator.class, DataMigratorImpl.class);
int rest = migrator.restCount(); // Send-and-receive-Eventually 방식

Send-And-Receive-Future

메서드의 리턴 타입이 akka.dispatch.Future이면, Send-And-Receive-Future 방식으로 메서드를 호출한다.

리모트 TypedActor 생성하기

UntypedActor와 마찬가지로 TypedActor도 간단하게 리모트 액터로 제공할 수 있다.

리모트 서버에서 TypedActor 생성하기

리모트 서버에서, 액터를 리모트 액터로 등록하려면 registerTypedActor()를 사용하면 된다.

Actors.remote().start("0.0.0.0", 2553);
DataMigrator migrator = TypedActor.newInstance(DataMigrator.class, DataMigratorImpl.class);
Actors.remote().registerTypedActor("data-migrator", migrator);

클라이언트 코드에서는 Actors.remote().typedActorFor() 메서드를 이용해서 리모트 액터에 대한 프록시 객체를 구한 뒤 알맞은 메서드를 호출하면 된다.

DataMigrator migrator = Actors.remote()
        .typedActorFor(DataMigrator.class, "data-migrator", "172.20.1.2", 2553);

migrator.run();
int rest = migrator.restCount();
do {
    Thread.sleep(1000);
    rest = migrator.restCount();
} while(rest > 0);

Actors.remote().shutdown();

[주의]
클라이언트에서 리모트 액터를 생성할 수도 있으나, 현재 버전에서는 기능이 예상하는 대로 동작하지 않아 본 글에서는 소개하지 않는다.

참고자료
  • http://doc.akka.io/typed-actors-java

저작자 표시 비영리 변경 금지
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 11 2011/06/25 13:14  댓글주소  수정/삭제  댓글쓰기

    감사합니다.
    정말 좋은 자료네요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.