저작권 안내 (펌 하실 때)
  • 저작권자표시 Yes, 상업적이용 No , 컨텐츠변경 No
블로그 운영자가 쓴 책 들입니다:

스프링 4

객체 지향과
디자인 패턴

JSP 2.2

스프링 MockMv의 단독 모드를 사용해서 한 개 컨트롤러에 대한 MockMvc를 생성하고, 테스트 코드를 작성했다.


mockMvc = MockMvcBuilders.standaloneSetup(SomeController).build();


여기서 문제는 익셉션 상황을 테스트하는 코드에서 발생했다. 이 프로젝트에서는 컨트롤러에서 발생한 익셉션의 처리를 표준화하기 위해 @ControllerAdvice를 사용했는데, 위와 같이 컨트롤러 단독 모드로 생성한 MockMvc의 경우 @ControllerAdvice를 이용한 익셉션 처리가 되지 않는 것이었다.


다행히 단독 모드로 생성한 StandaloneMockMvcBuilder는 다음의 메서드를 제공하고 있었다.


setHandlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers)


그래서, @ControllerAdvice를 지정한 클래스를 익셉션 핸들러로 사용하는 ExceptionHandlerExceptionResolver의 구현 객체를 만들어서 사용하기로 했다. 그 구현 객체를 생성하는 코드는 다음과 같다.


public static ExceptionHandlerExceptionResolver createExceptionResolver() {

    ExceptionHandlerExceptionResolver exceptionResolver = new ExceptionHandlerExceptionResolver() {

        @Override

        protected ServletInvocableHandlerMethod getExceptionHandlerMethod(

                HandlerMethod handlerMethod, Exception exception) {

            // 익셉션을 CommonExceptionHandler가 처리하도록 설정

            Method method = new ExceptionHandlerMethodResolver(

                    CommonExceptionHandler.class).resolveMethod(exception);

            return new ServletInvocableHandlerMethod(new CommonExceptionHandler(), method);

        }

    };

    exceptionResolver.getMessageConverters().add(new MappingJackson2HttpMessageConverter());

    exceptionResolver.afterPropertiesSet();

    return exceptionResolver;

}


위 코드에서 눈여겨 볼 점은 생성한 exceptionResolver에 알맞은 MessageConverter를 등록했다는 점이다. 새로 등록한 ExceptionResolver는 독립 모드로 생성한 MokcMvc가 사용하는 MessageConverter를 사용하지 않기 때문에, 위 코드처럼 필요한 MessageConverter를 ExceptionResolver에 직접 등록해 주었다.


@ControllerAdvice 클래스를 이용해서 익셉션을 처리하는 ExceptionResolver를 생성하는 기능을 만들었으니 이제 이 기능을 사용해서 MockMvc를 생성하면 된다.


mockMvc = MockMvcBuilders.standaloneSetup(someController)

        .setHandlerExceptionResolvers(createExceptionResolver()).build();


이제 someController에서 익셉션이 발생하면, @ControllerAdvice를 적용한 클래스를 이용해서 익셉션을 처리할 수 있게 된다.

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

댓글을 달아 주세요

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

자바8 스트림 API 소개 자료입니다.


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

댓글을 달아 주세요

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

자바8의 람다식(lambda expression) 소개 자료입니다.



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

댓글을 달아 주세요

  1. ㅎㅎㅎ 2014/09/18 17:55  댓글주소  수정/삭제  댓글쓰기

    와 엄청바꼈네요 잘보고갑니다!

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

Mockito, Spring MVC Test 등 테스트 코드를 작성하다보면 static import를 사용해서 메서드 이름만 사용하는 경우가 자주 발생한다. 이 때 이클립스에서 메서드 이름만 입력한 뒤에 컨트롤+스페이스 또는 컨트롤+1 을 사용해서 static import를 자동으로 처리하고 싶지만, 아직 여기까진 지원해주지 않고 있다.


그렇다고 static import와 관련된 지원을 아주 받지 못하는 것은 아니다. 다음의 방법으로 지원 받을 수 있다.

  • Window --> Preferences 메뉴 실행
  • Java/Editor/Content Assist/Favorites 메뉴 실행
  • Net Type 또는 New Member 버튼을 클릭한 뒤, static import 코드 지원 대상이 될 타입이나 멤버 추가
내 경우는 아래 그림처럼 테스트 코드에서 주로 사용하는 클래스들을 등록해서 사용하고 있다.




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

댓글을 달아 주세요

  1. 나그네 2014/07/01 08:47  댓글주소  수정/삭제  댓글쓰기

    진짜 이런걸 보면 인텔리J가 왜 좋은지 알것 같네요
    딱 개발에만 집중할 수 있도록 잘 되어있는거 같아요^^

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

요즘 밤에 틈이 나면 수련을 위한 개인 프로젝트를 진행중인데, 구현하는 과정 중에 클라이언트를 식별하기 위해 세션ID를 생성하는 기능이 필요해졌다. 이 기능을 직접 구현하기가 너무 너무 귀찮아서 자바에서 기본으로 제공하는 UUID 클래스를 사용하기로 마음 먹었다.


UUID의 사용법은 간단한데, 조금 마음에 안 들었던 건 UUID를 문자열로 변환할 때 그 길이가 다소 길다는 것이었다. 아래 코드처럼 UUID를 문자열로 변환하면 32자 ('-' 포함하면 36자)이다.


UUID uuid = UUID.randomUUID();

System.out.println(uuid.toString()); // --> "bf6f75a4-a1e9-4c0b-9b17-e9b6b5c0e5ed"


세션ID로 쓰기에 너무 길어서 이걸 조금이라도 줄여보고 싶은 마음에 16진수가 아닌 알파벳 대소문자와 특수문자 몇 개를 더 보태 64진수로 표현하는 코드를 만들어 보았다. 사실 만들었다기 보다는 자바에 있는 변환 코드를 복사해서 약간 보탰다.


    public static String toUnsignedString(long i, int shift) {

        char[] buf = new char[64];

        int charPos = 64;

        int radix = 1 << shift;

        long mask = radix - 1;

        long number = i;

        do {

            buf[--charPos] = digits[(int) (number & mask)];

            number >>>= shift;

        } while (number != 0);

        return new String(buf, charPos, (64 - charPos));

    }


    final static char[] digits = {

            '0', '1', '2', '3', '4', '5', '6', '7',

            '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',

            'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',

            'o', 'p', 'q', 'r', 's', 't', 'u', 'v',

            'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D',

            'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',

            'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T',

            'U', 'V', 'W', 'X', 'Y', 'Z', '_', '*' // '.', '-'

    };


toUnsignedString() 메서드에서 i는 변환할 문자열을, shift는 변환 대상 진수를 위한 값을 나타낸다. 예를 들어, shift가 1이면 2진수를, shift가 3이면 8진수를, shift가 6이면 64진수를 의미한다. 여기서 코드를 보면서 눈치를 챈 사람도 있겠지만, 숫자 1을 왼쪽으로 shfit 만큼 비트 이동했을 때 나오는 숫자가 변환 대상 진수가 된다.


실제 toUnsignedString()을 이용해서 UUID를 문자열로 변환하면 아래와 같이 10글자를 줄일 수 있는 것을 알 수 있다.


UUID uuid = UUID.randomUUID();

System.out.println(uuid.toString());

// eda788c4-2187-4e3c-9a53-affe69dd4fb5 : 32자 ('-' 포함 36자)


System.out.println(toUnsignedString(uuid.getMostSignificantBits(), 6) + 

                          toUnsignedString(uuid.getLeastSignificantBits(), 6));

// eSDycgxxQUY9FjH*VFTk_R (22자)



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

댓글을 달아 주세요

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

ip-filter 구현 이야기 발표 자료:



관련 글:





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

댓글을 달아 주세요

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

ip-filter-core 모듈은 아래와 같이 Config 객체를 이용해서 차단/허용 목록을 설정한다. (ip-filter의 사용법은 https://github.com/madvirus/ip-filter/wiki/HOME_kr 문서를 참고하기 바란다.)


Config config = new Config();

config.setAllowFirst(true);

config.setDefaultAllow(false);

config.allow("1.2.3.4"); // 허용 IP 추가

config.allow("10.20.30.40");

config.deny("101.102.103.104"); // 차단 IP 추가


IpFilter ipFilter = IpFilters.create(config);


ipFilter.accept("1.2.3.4"); // true

ipFilter.accept("101.102.103.104"); // false


코드로 설정하는 것이 필요할 때가 있지만, 아래와 같은 문자열을 이용할 수 있다면 파일이나 운영툴 등에서 쉽게 설정할 수 있을 것이다.


# 주석

order allow,deny

default true

allow from 1.2.3.4

allow from 1.2.5.* # 뒤에 주석

allow from 201.202.203.10/64

deny from all


위 문자열을 읽어와서 Config 객체를 생성하려면 문자열을 알맞게 파싱해 주어야 하는데, 파싱을 어떻게 할까 고민하다가 최근에 학습하고 있는 스카라(Scala)의 콤비네이터 파서(Conbinator parser)를 사용해 보기로 했다. 스카라는 자체적으로 Context-free grammer 에 따른 파싱 기능을 제공하고 있기 때문에, ANTLR과 같은 별도의 파서 생성기를 사용하지 않아도 된다.


스카라를 이용한 문자열 파서


음,, 먼저 스카라에 익숙하지 않은 분들은 아래 코드가 잘 이해되지 않을 것이다. 그래도 그냥 읽어보기 바란다. 설정 문자열을 파싱하기 위해 작성한 스카라 코드는 아래와 같다. (아래 코드는 order나 default를 여러 줄 입력해도 에러를 발생시키진 않는데, order나 default를 여러번 설정할 경우 오류를 발생시킬지의 여부는 고민 중에 있다.)


class Conf extends JavaTokenParsers {

  override val whiteSpace = """[ \t]+""".r


  def conf: Parser[Config] = repsep(confPart, eol) ^^ (

    x => {

      val config = new Config

      x.foreach(part =>

        part match {

          case ("order", firstAllow: Boolean) => config.setAllowFirst(firstAllow)

          case ("default", defaultAllow: Boolean) => config.setDefaultAllow(defaultAllow)

          case ("allow", ip: String) => config.allow(ip)

          case ("deny", ip: String) => config.deny(ip)

          case _ =>

        })

      config

    }

    )


  def confPart: Parser[Any] = commentPart | orderPart | defaultPart | allowOrDenyPart | emptyLine


  def commentPart: Parser[String] = """#(.*)""".r ^^ (x => x)


  def orderPart: Parser[Tuple2[String, Boolean]] =

    "order" ~ orderValue ~ opt(commentPart) ^^ (x => ("order", x._1._2))


  def orderValue: Parser[Boolean] = {

    "allow" ~ "," ~ "deny" ^^ (x => true) |

      "deny" ~ "," ~ "allow" ^^ (x => false)

  }


  def defaultPart: Parser[Tuple2[String, Boolean]] = 

      "default" ~ booleanValue ^^ (x => ("default", x._2))


  def booleanValue: Parser[Boolean] = "true" ^^ (x => true) | "false" ^^ (x => false)


  def allowOrDenyPart: Parser[Tuple2[String, String]] =

    allow ^^ (x => ("allow", x)) | deny ^^ (x => ("deny", x))


  def allow: Parser[String] = "allow" ~ "from" ~ ipPattern ~ opt(commentPart) ^^ (x => x._1._2)


  def deny: Parser[String] = "deny" ~ "from" ~ ipPattern ~ opt(commentPart) ^^ (x => x._1._2)


  def ipPattern: Parser[String] =

    "all" ^^ (x => "*") |

      """(\d+\.){1,3}(\*)""".r ^^ (x => x) |

      """(\d+\.\d+\.\d+\.\d+\/\d+)""".r ^^ (x => x) |

      """(\d+\.\d+\.\d+\.\d+)""".r ^^ (x => x)


  def emptyLine: Parser[String] = ""


  def eol: Parser[String] = """(\r?\n)+""".r

}


class ConfParser extends Conf {

  def parse(confText: String): Config = {

    val result = parseAll(conf, confText)

    if (result.successful)

      result.get

    else

      throw new ConfParserException(result.toString)

  }

}


Conf 클래스는 문자열을 파싱해서 Config 객체를 생성해주는 기능을 제공하며, ConfParser 클래스는 외부에 파싱 기능을 제공하는 parse() 메서드를 제공한다.


자바 코드에서는 ConfParser 클래스를 이용해서 문자열로부터 Config 객체를 생성할 수 있다.


public class UsingConfParserTestInJava {

    @Test

    public void useConfParser() {

        String confValue =

                "order deny,allow\n" +

                        "allow from 1.2.3.4\n" +

                        "deny from 10.20.30.40\n" +

                        "allow from 101.102.103.*\n" +

                        "allow from 201.202.203.10/64";


        Config config = new ConfParser().parse(confValue);

        assertFalse(config.isAllowFirst());

        assertEquals(config.getAllowList().size(), 3);

    }


응용


문자열로부터 Config 객체를 생성하는 파서를 만들었으니, 이제 파일이나 다른 곳에서 설정 데이터를 읽어와 Config 객체를 만들 수 있게 되었다. 실제 응용은 웹 어플리케이션에서 특정 IP 차단을 위해 작성한 ip-filter-web-simple 에서 사용하였다. (관련 코드는 https://github.com/madvirus/ip-filter/tree/master/ip-filter-web-simple 에서 확인 가능)


package org.chimi.ipfilter.web.impl;


import org.chimi.ipfilter.Config;

import org.chimi.ipfilter.parser.ConfParser;


import java.io.FileReader;

import java.io.IOException;


public class FileConfigFactory extends ConfigFactory {


    @Override

    public Config create(String value) {

        return new ConfParser().parse(readFromFile(value));

    }


    private String readFromFile(String fileName) {

        try {

            return IOUtil.read(new FileReader(fileName));

        } catch (IOException e) {

            throw new ConfigFactoryException(e);

        }

    }


    @Override

    public boolean isReloadSupported() {

        return true;

    }


}



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

댓글을 달아 주세요

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

지난 글 "IP 필터 만들기 1, 아이디어"에서 트리 구조로 허용/차단 IP 목록을 표시하는 방법을 정리해봤는데, 이번 글에서는 실제 구현을 살펴보도록 하겠다. 참고로, 본 글에서 설명하는 모든 코드는 https://github.com/madvirus/ip-filter 에 공개되어 있으니 참고하기 바란다.


IP 트리 구성을 위한 클래스: IpTree, NumberNode


ip-filter 모듈에서 사용하는 IP 목록은 아래와 같은 방식으로 표현한다.


1.2.3.4

1.2.3.64/26

5.6.7.*

10.20.*

30.*


이를 트리로 표현하기 위해 두 개의 클래스를 작성하였다.

  • IpTree: 트리의 관리 및 특정 IP가 트리로 표현되는지 확인해주는 기능
  • NumberNode: 트리의 각 노드를 표현


NumberNode 구현

NumberNode는 트리의 루트 노드부터 말단 노드까지를 표현한다. 노드는 위 그림에서 보듯 세 가지 종류의 값을 갖는다.
  • 숫자
  • 별표(*)
  • 네트워크주소표현 (예, 128/25)
이를 표현하기 위해 NumberNode 객체의 생성 부분을 아래와 같이 구현하였다.

public class NumberNode {

    private final String number;

    private boolean isSimpleNumber;
    private int filterNumber;
    private int lastValueOfNetworkNumber;
    private boolean allAccept;

    public NumberNode(String number) {
        this.number = number;
        processPattern();
    }

    private static int[] filterNumbers = {
            0x00, // 24
            0x80, // 25
            0xC0, // 26
            0xE0, // 27
            0xF0, // 28
            0xF8, // 29
            0xFC // 30
    };

    private void processPattern() {
        if (number.equals("*")) {
            isSimpleNumber = false;
            allAccept = true;
            return;
        }
        int slashIdx = number.indexOf("/");
        if (slashIdx == -1) {
            isSimpleNumber = true;
            return;
        }
        // 64/26과 같은 네트워크 주소 형식 처리 위한 코드
        this.lastValueOfNetworkNumber = Integer.parseInt(number.substring(0, slashIdx));
        int bitsOfNetworkNumber = Integer.parseInt(number.substring(slashIdx + 1));

        this.filterNumber = filterNumbers[bitsOfNetworkNumber - 24];
        this.isSimpleNumber = false;
    }

위 코드에서 각 필드는 다음과 같다.
  • isSimpleNumber: 값이 숫자면 true, 별표나 네트워크주소 표현이면 false
  • allAccept: 값이 별표면 true, 아니면 false
  • 네트워크 주소 관련 필드 (값 형식이 A/B 인 경우)
    • lastValueOfNetworkNumber: A/B 형식에서 A 값. 예, 128/25 에서 128
    • filterNumber: 데이터 비교시에 사용할 숫자 (뒤에서 설명)
값 일치 여부 확인

NumberNode를 생성한 뒤에, 해당 노드가 특정 숫자와 매칭되는지 여부를 확인하는 기능이 필요하다. 이를 위해 isMatch() 메서드를 아래와 같이 구현하였다.

public class NumberNode {

    private final String number;

    private boolean isSimpleNumber;
    private int filterNumber;
    private int lastValueOfNetworkNumber;
    private boolean allAccept;

    public boolean isMatch(String number) {
        if (allAccept) return true; // this.number가 "*" 인 경우
        if (isSimpleNumber) return this.number.equals(number); // this.number가 숫자인 경우

        // this.number가 A/B 형식인 경우
        int filtered = filterNumber & Integer.parseInt(number);
        return filtered == lastValueOfNetworkNumber;
    }

위 코드는 아래와 같이 작동한다.

NumberNode n1 = new NumberNode("128");
n1.isMatch("10"); // false
n1.isMatch("128"); // true

NumberNode n2 = new NumberNode("*");
n2.isMatch("0"); // true
n2.isMatch("123"); // true

NumberNode n3 = new NumberNode("128/25");
n3.isMatch("128"); // true
n3.isMatch("200"); // true
n3.isMatch("255"); // true
n3.isMatch("127"); // false
n3.isMatch("100"); // false

[박스] 네트워크 주소
"64/26"에 대해 약간의 설명이 필요할 것 같다. 흔히 네트워크 주소라 하면, 아래와 같이 표시한다.

2.4.8.64/26

여기서 숫자들은 각각 한 바이트를 차지하며, 여기서 마지막 64/26은 네트워크 범위를 지정하는 숫자가 된다. 이 예의 경우 64는 네트워크 범위 기준점이 되는 네 번째 바이트의 값이며, 26은 전체 주소에서 네트워크 주소로 사용될 비트 길이가 된다. 위 숫자에서 2.4.8.64는 아래와 같이 비트로 표현할 수 있는데,

00000010 00000100 00001000 01000000 (2 4 8 64)

여기서 앞에서 26개 비트, 즉 아래 값이 2.4.8.64/26 네트워크에서 사용할 네트워크 주소가 된다.

00000010 00000100 00001000 01 (앞에서부터 26비트)

이 네트워크 주소에서 사용할 수 있는 주소 범위는 아래와 같다.

00000010 00000100 00001000 01000000 ~ 
00000010 00000100 00001000 01111111

즉, 마지막 주소로 64 부터 127 까지가 64/26에 해당하는 숫자 범위이다. 따라서, 어떤 주소가 이 네트워크 주소 범위에 포함되는 확인하려면 다음과 같이 64/26에 해당하는 비교 값과 실제 주소 값을 AND 연산해서 네트워크 주소값이 나오는지 확인해보면 된다.

11111111 11111111 11111111 11000000 (필터 위한 비교 값)
00000010 00000100 00001000 01001100 (실제 주소)
---------------------------------- (AND 연산)
00000010 00000100 00001000 01000000 -> 네트워크 주소 값과 일치하므로, 이 주소는 네트워크 범위에 포함

앞서 NumberNode는 이 방식을 사용해서 특정 숫자가 범위에 포함되는 지 확인한다.

자식 노드 생성을 위한 메서드

NumberNode는 자식 노드를 생성/보관/검색하는 기능을 제공함으로써, 뒤에서 설명할 IpTree가 내부적으로 노드를 쉽게 구성할 수 있도록 하였다. 이와 관련된 코드는 아래와 같다.

public class NumberNode {
    ...
    private Map<String, NumberNode> simpleChildNodeMap = new HashMap<String, NumberNode>();
    private List<NumberNode> patternChildNodes = new ArrayList<NumberNode>();

    public NumberNode createOrGetChildNumber(String numberPattern) {
        if (simpleChildNodeMap.containsKey(numberPattern))
            return simpleChildNodeMap.get(numberPattern);

        for (NumberNode patternChild : patternChildNodes)
            if (patternChild.number.equals(numberPattern))
                return patternChild;

        NumberNode childNode = new NumberNode(numberPattern);
        if (childNode.isSimpleNumber)
            simpleChildNodeMap.put(number, childNode);
        else
            patternChildNodes.add(childNode);

        return childNode;
    }

    public NumberNode findMatchingChild(String number) {
        NumberNode simpleChildNode = simpleChildNodeMap.get(number);
        if (simpleChildNode != null) return simpleChildNode;

        for (NumberNode patternChildNode : patternChildNodes)
            if (patternChildNode.isMatch(number))
                return patternChildNode;

        return null;
    }
    ...
}

createOrGetChildNumber() 메서드는 NumberNode는 자식 노드를 두 가지 방식으로 보관한다.
  • 정확한 숫자는 simpleChildNodeMap 에 <숫자값, 자식노드>로 보관 (숫자값이 키)
  • A/B 형식의 패턴 및 "*"은 patternChildNodes 에 추가
createOrGetChildNumber() 메서드는 동일한 숫자를 가진 자식 노드가 존재하면 그 노드를 리턴하고, 존재하지 않으면 새로 생성해서 자식 노드로 추가한다. 이 메서드는 IpTree 클래스가 IP 문자열로부터 트리 노드를 생성할 때 사용된다.

findMatchingChild() 메서드는 입력받은 숫자와 매칭되는 자식 노드를 구해서 리턴해준다. 이 메서드는 IpTree에서 특정 IP 문자열이 트리에 매칭되는 지 확인하기 위해 사용된다.

IpTree 구현

IpTree 클래스는 다음의 두 기능을 제공한다.

  • IP 문자열을 트리로 구성해주는 기능을 제공한다. 
  • 특정 IP 문자열이 트리에 매칭되는지 확인하는 기능을 제공한다.
실제 구현은 아래와 같다.

public class IpTree {
    private NumberNode root = new NumberNode("");

    public void add(String ip) {
        String[] ipNumbers = ip.split("\\.");
        NumberNode node = root;
        for (String number : ipNumbers) {
            node = node.createOrGetChildNumber(number);
        }
    }

    public boolean containsIp(String ip) {
        String[] ipNumbers = ip.split("\\.");
        NumberNode node = root;
        for (String number : ipNumbers) {
            node = node.findChildNumber(number);
            if (node == null)
                return false;
            if (node.isAllAccept())
                return true;
        }
        return true;
    }
}


IpTree 클래스는 아래와 같이 사용한다.


IpTree tree = new IpTree();

tree.add("1.2.3.4");

tree.add("1.2.4.5");

tree.add("1.2.5.*");

tree.add("1.2.6.128/25");


tree.containsIp("1.2.3.4"); // true

tree.containsIp("1.2.3.5"); // false

tree.containsIp("1.2.6.129"); // true

tree.containsIp("1.2.5.4"); // true


실제 IP Filter 기능 구현


IP 필터는 다음과 같이 두 개의 IP 목록을 갖는다.

  • 허용 IP 목록
  • 차단 IP 목록
IP 필터는 이 두 개의 IP 목록을 별도의 IpTree 타입 필드를 이용해서 보관한다. 실제 IpFilter의 구현은 다음과 같다.

public class ConfigIpFilter implements IpFilter {

    private boolean defaultAllow;
    private IpTree allowIpTree;
    private IpTree denyIpTree;
    private boolean allowFirst;

    public ConfigIpFilter(Config config) {
        defaultAllow = config.isDefaultAllow();
        allowFirst = config.isAllowFirst();
        allowIpTree = makeIpTree(config.getAllowList());
        denyIpTree = makeIpTree(config.getDenyList());
    }

    private IpTree makeIpTree(List<String> ipList) {
        IpTree ipTree = new IpTree();
        for (String ip : ipList)
            ipTree.add(ip);
        return ipTree;
    }

    @Override
    public boolean accept(String ip) {
        if (allowFirst) {
            if (allowIpTree.containsIp(ip)) return true;
            if (denyIpTree.containsIp(ip)) return false;
        } else {
            if (denyIpTree.containsIp(ip)) return false;
            if (allowIpTree.containsIp(ip)) return true;
        }
        return defaultAllow;
    }

}

ConfigIpFilter는 Config로부터 허용 IP 목록과 차단 IP 목록을 읽어와 각각을 위한 IpTree를 생성한다. accept() 메서드는 이 IpTree 객체를 이용해서 지정한 IP가 차단 IP인지, 허용 IP인지 여부를 결정한다. 참고로, ConfigIpFilter 클래스의 사용법은 아래와 같다.

    @Test
    public void shouldReturnTrueToAllowedIpAndReturnFalseToDeniedIp() {
        Config config = new Config();
        config.setDefaultAllow(true);
        config.setAllowFirst(true);
        config.allow("1.2.3.4");
        config.allow("1.2.3.5");
        config.allow("1.2.3.64/26"); // // 01xxxxxx 범위 : 64~127
        config.deny("5.6.7.8");
        config.deny("101.102.103.32/27"); // 001xxxxx 범위: 32~63")

        IpFilter ipFilter = new ConfigIpFilter(config);
        assertTrue(ipFilter.accept("1.2.3.4"));
        assertTrue(ipFilter.accept("1.2.3.5"));
        assertTrue(ipFilter.accept("1.2.3.64"));
        assertTrue(ipFilter.accept("1.2.3.65"));
        assertTrue(ipFilter.accept("1.2.3.127"));
    }


성능 확인


ConfigIpFilter의 IP 필터 기능의 성능을 측정해 보았다. 이를 위해 다음의 IP 설정 목록을 차단 목록으로 추가하였다. 이 목록은 37,538 개 이며, 전체 목록은 https://github.com/madvirus/ip-filter/wiki/ip-list-config-for-performance-test 에서 확인할 수 있다.


1.0.1.*

1.0.2.*

1.0.3.*

...

101.253.*  
101.254.*  
103.1.8.*  
...
106.187.34.17
106.187.34.18
106.187.34.19
...
223.223.207.*
223.240.*
223.241.*
...
223.255.252.*
223.255.253.*


이 IP 설정이 포함하는 IP 개수는 약 3억 3천만이다. 


테스트는 아래와 같이 진행하였다.

  • 테스트 과정
    • 위 IP 설정 목록을 이용해서 차단 IP 목록으로 사용하는 IpFilter 객체 생성
    • 테스트 IP 범위 목록 중 랜덤하게 5개의 패턴을 뽑아, 그 패턴에 해당하는 IP 들을 차례대로 테스트 진행
      • 5개의 패턴이 적어 보일 수 있으나, 36.16.0.0~36.37.255.255 범위는 약 144만 개의 IP를 테스트
    • 각 IP를 검사하는 시간을 누적해서 평균 값 구함
  • 이 테스트를 5회 진행
비교 테스트를 위해 두 개의 IpFilter에 대해 테스트를 진행하였다..
  • 트리 기반으로 IP 목록을 유지하는 ConfigIpFilter 클래스
  • List를 이용해서 IP 목록을 유지하는 테스트용 ListIpFilter 클래스 (비교 목적으로 작성)
    • IP 확인 요청이 왔을 때, 허용IP/차단IP 목록을 순차적으로 비교 확인
두 번째 방식의 테스트는 안 하려고 했으나, 비교 자료를 원하시는 분들이 있을 것이기에 넣었다.


성능 비교 결과


테스트는 노트북에 진행했으면 노트북의 사양은 아래와 같다.

  • Intel Core i5-2457M @ 1.6 GHz
  • Windows 7 64bit 
  • JDK 6 (1.6.0_26)

성능 비교 결과 표를 아래와 같이 정리하였다.


[표1] 1개 쓰레드로 실행한 결과

-트리 방식


리스트 방식
회차

실행 회수

실행 시간 합
(밀리초)

실행 시간 평균
(밀리초)

실행 회수

실행 시간 합
(밀리초)

실행 시간 평균
(밀리초)

1

199,680

678

0.003400

50,944

28,631

0.562029

2

1,450,240

3,648

0.002516

1,212,928

181,893

0.152436

3

22,016

196

0.008931

12,800

6,397

0.499768

4

804,352

2,109

0.002622

377,088

152,709

0.404970

5

1,120,256

2,723

0.002431

273,920

14,964

0.054632

  

 평균

0.003980

 

평균 

0.334767


두 방식에서 확연한 속도 차이가 느껴진다. 트리 기반의 IP 필터 기능은 한 개 검사하는 데 0.004 밀리초(즉, 0.000004 초) 걸리는데 반해 리스트 방식은 0.335 밀리초로 (즉, 0.000335 초) 약 80배 이상 차이가 난다. 또한, 트리 방식은 실행 시간 평균이 균일한데 반해, 리스트 방식은 실행 시간 평균 10 배 이상 차이가 나기도 한다.


참고로, 트리 방식과 달리 리스트 방식이 이렇게 들쑥 날쑥한 이유는 리스트 방식이 풀스캔을 하기 때문이다. 트리 방식은 DB에 비교하면 인덱스를 사용해서 검색하는 방식이고, 리스트 방식은 인덱스 없이 전체 데이터를 스캔해서 검색하는 방식이다. 따라서, 리스트 방식에서 비교 대상이 앞쪽에 위치하면 검색 속도가 빠르고, 비교 대상이 뒤쪽에 위치하면 검색 속도가 그 만큼 느려지게 된다.


위와 동일한 테스트를 동시에 10개 쓰레드를 실행해서 테스트 해 보았다. 최대한 동시에 실행되는 효과를 만들기 위해 한 쓰레드가 최대 10만개의 IP만 검사하도록 제한을 걸었다. 테스트 결과는 아래와 같다.


[표2] 10개 쓰레드로 실행한 결과 (1쓰레드 당 최대 10만개까지만 검사)

-

트리 방식


리스트 방식
회차

실행 회수

실행 시간 합
(밀리초)

실행 시간 평균
(밀리초)

실행 회수

실행 시간 합
(밀리초)

실행 시간 평균
(밀리초)

1

695,840

7,592

0.010912

672,033

745,404

1.110667

2

816,640

6,325

0.007746

847,712

837,813

0.988323

3

698,304

6,301

0.009024

720,576

633,170

1.101748

4

901,120

8,216

0.009118

792,000

976,851

1.233398

5

664,096

5,576

0.008397

693,024

1,162,127

1.677894

 

 

 평균

0.009039

 

평균 

1.205352



1개 쓰레드를 이용한 경우와 10개 쓰레드를 이용한 경우를 비교해보면, 트리 방식에 비해 리스트 방식의 평균 실행 시간이 더 크게 증가한 것을 알 수 있다.


비교 목적으로 사용한 ListIpFilter 클래스 코드


트리 구조와 성능 비교 하기 위해 작성한 ListIpFilter의 코드는 아래와 같다.


public class ListIpFilter implements IpFilter {

    private boolean defaultAllow;

    private boolean allowFirst;

    private List<IpPattern> allowIpPatterns = new ArrayList<IpPattern>();

    private List<IpPattern> denyIpPatterns = new ArrayList<IpPattern>();


    public ListIpFilter(Config config) {

        defaultAllow = config.isDefaultAllow();

        allowFirst = config.isAllowFirst();

        for (String ipPattern : config.getAllowList())

            allowIpPatterns.add(new IpPattern(ipPattern));

        for (String ipPattern : config.getDenyList())

            denyIpPatterns.add(new IpPattern(ipPattern));

    }


    @Override

    public boolean accept(String ip) {

        if (allowFirst) {

            if (isAllowIp(ip)) return true;

            if (isDenyIp(ip)) return false;

        } else {

            if (isDenyIp(ip)) return false;

            if (isAllowIp(ip)) return true;

        }

        return defaultAllow;

    }


    private boolean isAllowIp(String ip) {

        for (IpPattern ipPattern : allowIpPatterns) {

            if (ipPattern.isMatch(ip))

                return true;

        }

        return false;

    }


    private boolean isDenyIp(String ip) {

        for (IpPattern ipPattern : denyIpPatterns) {

            if (ipPattern.isMatch(ip))

                return true;

        }

        return false;

    }


    private class IpPattern {


        private String exactMatchingPart;


        private boolean exactMatchingPattern = false;

        private boolean acceptAllPattern = false;

        private boolean rangePattern = false;


        private int fromNumberInRangePattern = 0;

        private int toNumberInRangePattern = 0;


        public IpPattern(String ipPattern) {

            if (ipPattern.endsWith("*")) {

                acceptAllPattern = true;

                exactMatchingPart = ipPattern.substring(0, ipPattern.length() - 1);

            } else {

                int slashIdx = ipPattern.indexOf("/");

                if (slashIdx == -1) {

                    exactMatchingPart = ipPattern;

                    exactMatchingPattern = true;

                } else {

                    int lastDotIdx = ipPattern.lastIndexOf(".");

                    exactMatchingPart = ipPattern.substring(0, lastDotIdx + 1);


                    int rangeNumber = Integer.parseInt(ipPattern.substring(lastDotIdx + 1, slashIdx));

                    int bitLength = Integer.parseInt(ipPattern.substring(slashIdx + 1));


                    rangePattern = true;

                    fromNumberInRangePattern = rangeNumber;

                    switch (bitLength) {

                        case 24:

                            toNumberInRangePattern = fromNumberInRangePattern + 0xFF;

                            break;

                        case 25:

                            toNumberInRangePattern = fromNumberInRangePattern + 0x7F;

                            break;

                        case 26:

                            toNumberInRangePattern = fromNumberInRangePattern + 0x3F;

                            break;

                        case 27:

                            toNumberInRangePattern = fromNumberInRangePattern + 0x1F;

                            break;

                        case 28:

                            toNumberInRangePattern = fromNumberInRangePattern + 0x0F;

                            break;

                        case 29:

                            toNumberInRangePattern = fromNumberInRangePattern + 0x07;

                            break;

                        case 30:

                            toNumberInRangePattern = fromNumberInRangePattern + 0x03;

                            break;

                    }

                }

            }

        }


        public boolean isMatch(String ip) {

            if (exactMatchingPattern)

                return ip.equals(exactMatchingPart);


            if (acceptAllPattern)

                return ip.startsWith(exactMatchingPart);


            if (rangePattern) {

                if (!ip.startsWith(exactMatchingPart)) return false;


                int lastNumberOfIp = Integer.parseInt(ip.substring(exactMatchingPart.length()));

                return lastNumberOfIp >= fromNumberInRangePattern 

                         && lastNumberOfIp <= toNumberInRangePattern;

            }

            return false;

        }

    }

}



참고 자료



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

댓글을 달아 주세요

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

    비밀댓글입니다

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

몇 년 전, 이전 직장에서 웹 게임의 CBT를 진행하는데, 갑자기 외부에서의 연결이 급격하게 느려지는 증상이 발생했다. 얼른 웹 서버와 DB에 들어가 봤으나 CPU/네트워크 관련 수치 모두 문제가 없었다. 내부에서의 연결은 문제 없이 잘 들어가졌다. 유관 팀과 함께 조사를 진행하다가 원인을 찾았다. 원인은 "방화벽의 차단 IP 목록"이었다.


방화벽에 등록된 차단 IP에 중국 IP 및 IP 대역들이 등록되어 있었는데, 그 개수가 많아서 트래픽 증가와 함께 방화벽의 CPU 사용률이 높아졌고 이로 인해 방화벽이 느려지는 문제가 발생했다. 웹 게임 서버로 들어오는 웹 요청을 방화벽이 빠르게 처리하지 못하자, 외부 게이머들의 불만이 폭주하기 시작했다. 일단, 방화벽의 IP 차단 목록의 개수를 줄여서 (정확하게는 화이트 리스트의 순위를 위로 올려서) 상황을 벗어났다. 그 당시 차단 IP 개수가 많아져도 성능이 저하되지 않는 IP 차단 모듈을 만들어봐야지라는 생각을 했고, 거의 3년이 지난 요즈음 IP 차단 기능을 제공하는 ip-filter 모듈 제작을 시작해서 0.1 버전을 만들었다.


아이디어


방화벽의 내부 구현을 알 순 없었지만, 검사할 IP 규칙 목록의 개수가 많았을 때 방화벽의 CPU 점유률이 높아지고 검사할 규칙 목록의 개수가 적었을 때 CPU 점유률이 낮아진 것으로 보아, 클라이언트의 IP에 해당하는 검사 규칙이 나타날 때 까지 순차적으로 각 규칙을 확인하는 것 같았다. 방화벽 설정에는 (중국 IP 대역 목록을 포함한) 매우 많은 차단 규칙이 앞쪽에 위치해 있었기 때문에, 클라이언트의 연결 요청이 들어오면 이 많은 차단 규칙을 다 확인한 뒤에 방화벽을 통과할 수 있는 것으로 추정되었다. IP가 규칙에 맞는지 여부를 확인하는 것은 순전히 CPU를 잡아 먹는 연산이기 때문에 동접자수가 증가할수록 CPU 점유률이 높아졌을 거라 생각된다.


그래서, 생각해본 것이 규칙 목록들을 트리 구조로 구성하는 것이었다. 예를 들어, 아래의 규칙에 해당하는 IP를 차단하고 싶다고 하자.

  • 1.2.3.4 (정확한 일치 IP)
  • 1.2.3.128/25 (1.2.3.128 ~ 1.2.3.255 대역)
  • 1.12.13.14 (정확한 일치 IP)
  • 10.20.* (10.20.0.0 ~ 10.20.255.255 대역)
  • 10.30.40.51 (정확한 일치 IP)
  • 10.30.40.52 (정확한 일치 IP)

이 검사 대상 IP 목록을 아래 그림과 같은 트리 형식으로 보관하는 것이다.



이렇게 트리 형식으로 검사할 규칙의 개수에 상관없이 항상 5 레벨 이하의 트리 탐색으로 특정 IP가 검사 규칙에 일치하는지 여부를 알아낼 수 있다. 예를 들어, IP 주소가 "1.12.13.5"라고 하자. 이 경우, 트리 상의 루트->1->12->13 까지는 일치하나 마지막 5레벨에서 IP 주소의 마지막 "5"와 일치하는 노드가 존재하지 않는다. 따라서, "1.12.13.5"는 매칭되는 규칙이 존재하지 않는다. 반면에 IP 주소가 "10.30.40.51"인 경우, 트리 상의 루트->10->30->40->51 에 정확하게 일치하므로, "10.30.40.51"은 매칭되는 규칙이 존재한다.


검사해야 할 규칙 목록이 백 개이든, 천 개이든, 만 개이든 상관없이 항상 5 레벨 이하의 트리 탐색으로 특정 IP가 검사 규칙에 걸리는지 확인할 수 있다. 따라서, 규칙 개수가 증가하더라도 앞서 순차적 검사와 달리 더 적은 CPU 시간을 사용할 거라 예상된다. 예를 들어, 규칙이 만 개이고 클라이언트 IP가 어떤 규칙에도 일치하지 않는다면, 만 번의 루프를 돈 뒤에야 비로서 클라이언트 IP가 규칙에 적용되지 않음을 알게 된다. 반면 위 트리 구조는 최대 5 레벨의 트리 탐색으로 클라이언트 IP가 규칙에 적용되는지 여부를 알 수 있다.


결과물


어떤 식으로 구현했는지 공유하기에 앞서 실제 만들어 본 0.1 버전을 github에 올려 보았다. 아래 github 사이트에서 ip-filter의 사용법과 소스를 확인해 볼 수 있다.

다음에는


다음에 정리해 볼 내용은 아래와 같은 것들이 있다.



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

댓글을 달아 주세요

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

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  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

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

서블릿 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가 가능하다면 웹소켓을 사용하는 것도 좋을 것 같습니다.

  4. 박재학 2013/06/09 09:53  댓글주소  수정/삭제  댓글쓰기

    만약에 웹브라우저 및 시스템이 갑자기 종료되면 클라이언트에게
    메세지를 보내줘야 하는데.. 어떤 방법으로 클라이언트가 종료됬다는
    것을 알려줄수 있을지가 좀 의문스럽습니다.

    • 최범균 madvirus 2013/06/10 17:20  댓글주소  수정/삭제

      이 방식으로는 갑자기 종료되거나 하면 알려줄 수 없죠.
      그래서 실제로는 이를 사용하기 보다는, HTML의 웹소켓이나 Socket.IO와 같은 것들을 이용해서 채팅을 구현을 하는 것이 커넥션 관리에 더 유리하다 생각됩니다.

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

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

  • 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

댓글을 달아 주세요

페이스북 친구들과 댓글을 공유하고 싶다면 아래를 이용해주세요.
사용자 인증 정보를 보관하는 가장 손쉬운 방법은 세션을 사용하는 것인데, 가용성 향상이나 부하 증가 대처를 위해 웹 서버를 옆으로 늘릴 경우 세셔 클러스터링을 해 주어야 한다. 하지만, 세션 클러스터링을 하려면 별도의 장비 구성이 필요하거나 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

댓글을 달아 주세요

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

티스토리 툴바