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

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의

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와 같은 것들을 이용해서 채팅을 구현을 하는 것이 커넥션 관리에 더 유리하다 생각됩니다.

  5. 자바개발자 2015.03.18 12:11 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요?
    서버를 이중화 했을 경우에 1번 서버에 접속한 유저가 있을 것이고
    2번 서버에 접속한 유저가 있을 것인데
    이런 경우 1번 서버에 접속한 유저가 2번 서버에 접속한 유저에게
    채팅 메시지를 전달하려면 어떻게 해야 할까요?
    지금 생각난 것은 세션공유를 통해서 가능할 수도 있겠구나 하는 것 뿐입니다.

    늘 행복하세요..^^

  6. 서버관심자 2015.06.19 11:27 신고  댓글주소  수정/삭제  댓글쓰기

    궁금해서 그러는데요..
    ChatRoom 클래스의 colse() 함수는 언제 누가 호출하게 되는건가요?

    그리고 두명의 채팅 사용자중 한명이 정상적인 로그아웃이 아닌 그냥 웹페이지를 닫는경우엔 저 위 코드에서 어떤 이벤트를 받게 되는건가요?
    asyncContext 를 해당 유저가 종료되는(페이지를 닫거나)시점에 리스트에서 제거를 해줘야 할거 같은데요..즉, 언제, 어떻게 리스트에 추가된 해당 asyncContext 를 제거할수 있나요?

    • 최범균 madvirus 2015.06.19 13:32 신고  댓글주소  수정/삭제

      우선 ChatRoom의 close는 톰캣을 종료할 때 호출합니다. (예제 코드에서는 ServletContextListener를 사용했어요.)

      그리고, 웹 브라우저에서 다른 페이지로 이동하거나 할 때 연결이 끊기는 걸 알아내기는 어렵습니다. 그래서, 타임아웃을 10초로 주고 10초 마다 클라이언트가 다시 연결하게 하거나, 페이지를 이동하거나 웹 브라우저를 종료할 때 자바스크립트로 서버에 알려주거나 하는 방법을 혼용해서 사용합니다.

  7. autuln 2015.12.04 12:05 신고  댓글주소  수정/삭제  댓글쓰기

    잘 봤습니다. 만약 웹서버 앞단에 L4 장비 같은게 있어서 로드 벨런스을 했을 경우에
    처음 enter 세션은 1번 웹서버랑 맺어지고 sendMessag 세션은 2번 웹서버랑 맵어지면 제대로 동작을 안할텐데 이런 경우는 어떻게 처리 하는지 궁금합니다. L4 장비나 다른 설정에 의해서 첨 접속된 웹서버로 계속 연결 하게 설정 할수 있는건가요?

    • 최범균 madvirus 2015.12.07 10:40 신고  댓글주소  수정/삭제

      L4가 있으면 사실상, 서버 간에 데이터를 주고 받는 방법이 필요하죠. 이 예제는 비동기 처리 방법을 소개하는 예제이므로 실제 서비스 수준의 채팅을 구현하려면 다양한 기법을 함께 적용해야 할 것 같습니다.
      처음 접속된 서버로 계속 연결하게 하는 방식보다는 어느 서버에 접속하더라도 메시지를 수신할 수 있는 방식을 고민해야할 것 같네요. Vert.x의 이벤트버스 같은 걸 사용하거나 트래픽이 많다면 알람을 주기적으로 pull 하는 방식을 혼합해서 사용하면 좋을 것 같습니다.

  8. 나그네 2016.02.15 12:11 신고  댓글주소  수정/삭제  댓글쓰기

    제가 찾던 방식이네요
    예전에 접속할경우 스레드 동기화된 컬렉션에 response를담고 메시지를 받으면 컬렉션에서 response를 뽑아서 쏴주는식으로 하고 응답이 끝난후 서블릿 스레드가 죽어버리기 때문에 대기 태우는게 안되서 polling으로 체크해서 response에 있는 메시지 가져오게했었는데 매번 request랑 response 객체값이 바뀌기 때문에 제대로 동작하지 않아서 로근인할때 컬렉션에 세션박아넣고 메시지가 오면 세션에 메시지를 박아놓고 polling으로 세션 체크해서 값 있으면 뽑아서 뿌리는식으로 했었는데..

    서블릿 3.0의 비동기 지원 서블릿은 저런방식으로 가능하나 보네요.. polling 없이 동일 request 뽑아서 데이터 넣어주는식으로...

    물론, 웹소켓 방식도 사용해보긴했습니다. 저 방식을 알기 전까지 일반 프로그램에서 쓰는 방식처럼 동작하게 하는것은 웹소켓이 진리다 하고 알고있었죠..

    궁금한게 있는데
    1) 일반 서블릿으로는 안되나요? - 수신부 루프를 스레드 없이 doGet이나 doPost에 넣으면 일반서블릿도 되는거였네요..
    2) 큐에 넣고 빼는거 말고 메시지를 받으면 바로 뿌리는건 안되나요?
    3) 접속을 iframe말고 ajax로는 할 수 없나요? ajax로 할려니 잘 안되네요 -> 쌩 Ajax(XMLHttpRequest)로는 되는거 같은데 Jquery ajax로는 연결이 유지되어있는 상태에서 오는 데이터(readyStatus 3)를 받을 방법이 없는거 같네요..

  9. 나그네 2016.02.24 13:30 신고  댓글주소  수정/삭제  댓글쓰기

    블로킹 큐 안쓰는 방법을 찾았습니다.
    노드 JS가 응답을 끝내주지 않으면 접속한 소켓이 닫히지 않고 대기한다는 점에 착안하여
    접속하는 부분에서 response를 List에 넣은후 wait 시키고(동기화 필수네요) 메시지를 보내는 부분에서 List에 들어있는 response를 하나씩 꺼내서 PrintWrite를 뽑아서 메시지를 전송하고 flush 시키면 되네요.
    스프링에서 했지만 일반 서블릿도 될것이며 무한 루프를 안돌려도 되네요..

    그런데 ajax를 이용한 코넷 스트리밍은 제이쿼리 ajax로는 안되고 HTML5에 추가된 것인지 익스는 10버전 이상부터 되네요..

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

  • 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

댓글을 달아 주세요

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

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

  2. monggu 2015.02.23 22:41 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 자료 감사합니다.
    잘보고 가요

특정 노드에 갑자기 장애가 발생했다. 이럴 때 가장 먼저 개발자들이 선택하는 장애 대처 방법은? 아마도 관련 프로세스를 재시작하는 방법이 가장 많이 사용될 것이다. 웹서버를 재시작하거나 로그 수신기를 재시작하는 등 뭔가 문제가 발생한 영역의 프로세스나 쓰레드를 재시작함으로써 서비스 다운타임을 줄이는 것이 장애 발생 시 첫 번째로 수행하는 작업이다. 일단, 재시작해서 서비스가 살아나도록 만든 뒤, 그 다음에 원인 분석을 하게 된다.

Akka도 이와 비슷한 방법으로 액터의 장애에 대응할 수 있는 방법을 제공하고 있다. 액터는 자신을 관리하는 Supervisor를 가질 수 있으며, Supervisor는 액터가 다운될 경우 재시작함으로써 다운타임을 최소화하도록 해 준다. 이 방식은 얼랭(erlang)으로부터 빌려온 방식으로서 Akka는 Supervisor를 통해 무정지 서비스를 구현할 수 있도록 하고 있다.

액터의 두 가지 라이프 사이클: permanent, temporary

액터는 다음의 두 가지 라이프 사이클을 가진다.
  • permanent: 메시지 처리 과정에서 예외가 발생해도 액터가 살아 있음.
  • temporary: 메시지 처리 과정에서 예외가 발생하면 액터가 죽음.
액터를 permanent로 설정할 지 temporary로 설정할 지의 여부는 다음과 같이 설정할 수 있다.

import akka.config.Supervision;

public class WorkerActor extends UntypedActor {
    public WorkerActor() {
        getContext().setLifeCycle(Supervision.temporary());
        ...
    }


Akka의 액터 예외 대응 방식: Let it Crash

다중 쓰레드를 이용해서 병행 처리 코드를 작성할 경우, 병행 처리 코드에서 예외가 발생했을 때 이를 알 수 있는 방법은 예외 추적 메시지를 확인하는 방법 뿐이다. (또는 try-catch로 모든 예외를 잡아서 알림해 주는 기능을 넣는 방법 뿐이다.) 예외가 발생해서 병행 처리 쓰레드가 종료된 경우 이를 복구하는 방법은 재시작해주는 것 외에 특별한 방법이 없다.

Akka는 액터가 더 이상 정상적으로 동작할 수 없는 상태가 되어 메시지 처리 과정 중 예외를 발생시키면, 해당 액터를 재시작하는 방법으로 장애에 대응한다. 복구할 수 없는 예외 상황 발생시 액터가 뭔가 하지 않고 그냥 죽도록 놔두고 안정된 상태로 초기화하고 재시작하기 때문에, 이 방식을 "Let it Crash"라고 부른다.

수버바이저를 이용한 액터 관리

Akka는 수퍼바이저를 이용해서 액터를 관리한다. 수퍼바이저는 다른 액터를 모니터링하는 액터로서, 수퍼바이저 액터에서 다른 액터를 연결(link)함으로써 수퍼바이저가 다른 액터를 관리하게 된다.

수퍼바이저는 연결된 액터가 죽었을 때 다음의 두 가지 방식을 이용해서 연결된 액터를 재시작한다. 참고로 permanent 모드의 액터만 재시작되며, temporary 액터는 재시작되지 않는다.
  • One-For-One
  • All-For-One
One-For-One은 수퍼바이저와 연결된 액터가 죽으면, 죽은 액터만 재시작하고 나머지 연결된 액터는 그대로 유지한다. (아래 그림 참고)


[발췌: http://doc.akka.io/fault-tolerance-java]

반면 All-For-One은 수퍼바이저와 연결된 액터 중 하나가 죽으면, 연결된 모든 액터를 재시작한다. (아래 그림 참고) 이는 수퍼바이저에 의해 관리되는 액터 중 하나로도 비정상적으로 동작하면 나머지 액터들도 영향을 받아서 비정상적으로 동작하게 될 때에 사용된다.


[발췌: http://doc.akka.io/fault-tolerance-java]

수퍼바이저(Supervisor) 액터 만들기

수퍼바이저 액터는 일반 액터와 동일한 액터로서, 다음의 두 가지 방법을 이용해서 만들 수 있다.
  • link() 메서드를 이용
  • Supervisor 클래스를 이용해서 생성

link()를 이용한 액터 연결 및 관리

액터는 다른 액터를 연결함으로써 수퍼바이저 액터가 될 수 있으며, 연결할 때에는 link() 메서드를 사용한다. link()를 이용해서 액터를 관리할 경우 다음과 같은 방법으로 개발을 진행하면 된다.
  1. 수퍼바이저 액터로 동작할 클래스의 생성자에 FaultHandler를 지정한다. FaultHandler는 관리되는 액터가 죽었을 때, 그 액터만 재시작할 지 아니면 관리되는 모든 액터를 재시작할 지의 여부를 지정한다.
  2. 수퍼바이저 액터를 생성한 뒤, 관리할 액터를 link()로 연결한다.

1번, 수퍼바이저 액터를 직접 구현할 경우 다음과 같이 수퍼바이저 액터 생성자에서 재시작 전략을 지정해 주어야 한다.

import akka.actor.UntypedActor;
import akka.config.Supervision.OneForOneStrategy;

public class MasterActor extends UntypedActor {

    public MasterActor() {
        getContext().setFaultHandler(
                new OneForOneStrategy(
                        new Class[] { RuntimeException.class }, 3, 1000));
    }

    @Override
    public void onReceive(Object message) throws Exception {
        System.out.println("Master가 받은 메시지: " + message);
    }

}

위 코드에서 MasterActor는 관리하는 액터가 죽으면 해당 액터만 재시작하도록 설정하였다. OneForOneStrategy 객체를 생성할 때 첫 번째 파라미터는 액터를 재시작할 예외 타입을 지정한다. 위 코드는 모니터링 대상 액터의 onReceive() 메서드에서 RuntimeException이 발생하면 액터를 재시작한다는 것을 의미한다. 뒤의 두 숫자에 대해서는 뒤에서 다시 설명하겠다.

관리되는 액터가 죽을 때 관리되는 다른 액터들도 함께 재시작하고 싶은 경우에는 AllForOneStrategy 클래스를 사용하면 된다. 생성자에 전달되는 파라미터 목록은 OneForOneStrategy 클래스와 동일하다.

2번, 수퍼바이저 액터를 알맞게 구현했다면 그 다음으로 할 작업은 link() 메서드를 이용해서 수퍼바이저에 관리할 액터를 연결해 주는 것이다. 아래 코드는 예를 보여주고 있다.

ActorRef master = Actors.actorOf(MasterActor.class);
master.start();

ActorRef worker1 = Actors.actorOf(WorkerActor.class);
worker1.start();

master.link(worker1);

테스트를 위해 WorkerActor가 "die"라는 메시지를 받으면 RuntimeException을 발생시키도록 구현해 보았다.

@SuppressWarnings("unchecked")
public class WorkerActor extends UntypedActor {
    private static int num = 1;
   
    private int id;
    public WorkerActor() {
        id = num++;
        System.out.println("액터 생성됨: " + id);
    }
   
    @Override
    public void onReceive(Object message) throws Exception {
        if (message.equals("die")) {
            throw new RuntimeException("고의적 DIE");
        }
        System.out.println("Worker " + id + ": " + message);
    }
   
    @Override
    public void preRestart(Throwable cause) {
        System.out.println("Worker " + id + ": 재시작 전처리");
    }
   
    @Override
    public void postRestart(Throwable cause) {
        System.out.println("Worker " + id + ": 재시작 후처리");
    }
   
}

WorkerActor는 preRestart() 메서드와 postRestart() 메서드를 구현하고 있는데, 이 두 메서드는 각각 액터가 재시작하기 전/후에 호출된다. WorkerActor가 생성될 때 마다 1씩 증가된 id 값을 할당하는데 id 값을 새로 부여한 이유는 액터가 재시작할 때 액터 객체를 새로 생성하는 지의 여부를 확인하기 위해서다.

ActorRef master = Actors.actorOf(MasterActor.class);
master.start();

ActorRef worker1 = Actors.actorOf(WorkerActor.class);
worker1.start();
ActorRef worker2 = Actors.actorOf(WorkerActor.class);
worker2.start();

master.link(worker1); // master에 worker1 액터 연결
master.link(worker2); // master에 worker2 액터 연결

worker1.sendOneWay("메시지1-1");
worker2.sendOneWay("메시지2-1");
worker1.sendOneWay("메시지1-2");
worker2.sendOneWay("메시지2-2");

worker1.sendOneWay("die"); // worker1 액터 죽임!
worker1.sendOneWay("메시지1-3"); // worker1 액터에 메시지 전달
worker2.sendOneWay("메시지2-3");

위 코드는 중간에 worker1에 "die" 메시지를 보냄으로써 worker1을 죽인다. worker1 액터는 "die" 메시지를 받으면 RuntimeException을 발생시키는데, MasterWorker는 RuntimeException이 발생할 경우 해당 액터를 재시작하라고 설정하고 있다. 따라서, worker1 액터는 "die" 메시지를 받는 순간 RuntimeException을 발생시키며 죽지만 곧이어 재시작하게 되고, 따라서 죽은 이후에 받은 "메시지1-3" 메시지를 재시작한 액터가 처리하게 된다.

실제 위 코드의 실행 결과는 다음과 같다. (Akka가 출력하는 로그 메시지 중 중요한 것만 남기고 나머지는 생략하였다.)

액터 생성됨: 1
액터 생성됨: 2
16:43:40.843 [main] DEBUG akka.actor.Actor$ - Linking actor [Actor[tuto3.WorkerActor:46f67fb0-506a-11e0-a0e5-001d92ad4c1a]] to actor [Actor[tuto3.MasterActor:46f1c4c0-506a-11e0-a0e5-001d92ad4c1a]]
16:43:40.843 [main] DEBUG akka.actor.Actor$ - Linking actor [Actor[tuto3.WorkerActor:46f67fb1-506a-11e0-a0e5-001d92ad4c1a]] to actor [Actor[tuto3.MasterActor:46f1c4c0-506a-11e0-a0e5-001d92ad4c1a]]
Worker 1: 메시지1-1
Worker 1: 메시지1-2
16:43:40.875 [akka:event-driven:dispatcher:global-1] ERROR akka.actor.Actor$ - Exception when invoking
    actor [Actor[tuto3.WorkerActor:46f67fb0-506a-11e0-a0e5-001d92ad4c1a]]
    with message [die]
16:43:40.875 [akka:event-driven:dispatcher:global-1] ERROR akka.actor.Actor$ - Problem
java.lang.RuntimeException: 고의적 DIE
    at tuto3.WorkerActor.onReceive(WorkerActor.java:18) ~[classes/:na]
    ...
Worker 2: 메시지2-1
Worker 2: 메시지2-2
Worker 2: 메시지2-3
16:43:40.968 [akka:event-driven:dispatcher:global-3] INFO  akka.actor.Actor$ - Restarting actor [tuto3.WorkerActor] configured as PERMANENT.
16:43:40.968 [akka:event-driven:dispatcher:global-3] DEBUG akka.actor.Actor$ - Invoking 'preRestart' for failed actor instance [tuto3.WorkerActor].
Worker 1: 재시작 전처리
액터 생성됨: 3
16:43:40.968 [akka:event-driven:dispatcher:global-3] DEBUG akka.actor.Actor$ - Invoking 'postRestart' for new actor instance [tuto3.WorkerActor].
Worker 3: 재시작 후처리
16:43:40.968 [akka:event-driven:dispatcher:global-3] DEBUG akka.actor.Actor$ - Restart: true for [tuto3.WorkerActor].
16:43:40.968 [akka:event-driven:dispatcher:global-3] DEBUG a.d.Dispatchers$globalExecutorBasedEventDrivenDispatcher$ - Resuming 46f67fb0-506a-11e0-a0e5-001d92ad4c1a
16:43:40.984 [akka:event-driven:dispatcher:global-4] DEBUG akka.dispatch.MonitorableThread - Created thread akka:event-driven:dispatcher:global-4
Worker 3: 메시지1-3

위 실행 결과를 보면 다음의 사실을 확인할 수 있다.
  • 재시작 전처리는 1번 Worker가 수행한다.
  • 전처리 후, worker1에 해당하는 새로운 액터 객체를 생성한다. (액터 생성된: 3)
  • 재시작 후처리는 3번 Worker가 수행한다.
  • 이후 worker1은 3번 Worker와 연결되며, "메시지1-3" 메시지는 3번 Worker가 수행하게 된다.
즉, 액터가 죽으면 그 액터 객체를 재사용하는 것이 아니라 새로운 액터 객체를 생성하는 방법으로 재시작하는 것을 알 수 있다.

Supervisor 클래스를 이용한 수퍼바이저 액터 생성

수퍼바이저 액터에서 직접 관리할 액터를 생성하는 경우가 아니면 수퍼바이저 액터를 별도로 구현하기 보다는 Akka가 제공하는 Supervisor 클래스를 이용하는 것이 편리하다.


import akka.actor.Supervisor;
import akka.actor.SupervisorFactory;
import akka.config.Supervision;
import akka.config.Supervision.OneForOneStrategy;
import akka.config.Supervision.Supervise;
import akka.config.Supervision.SupervisorConfig;

...

ActorRef worker1 = Actors.actorOf(WorkerActor.class);
ActorRef worker2 = Actors.actorOf(WorkerActor.class);

Supervise[] supervises = new Supervise[2];
supervises[0] = new Supervise(worker1, Supervision.permanent());
supervises[1] = new Supervise(worker2, Supervision.permanent());

OneForOneStrategy strategy = new OneForOneStrategy(
        new Class[] {RuntimeException.class}, 3, 3000);
SupervisorConfig config = new SupervisorConfig(strategy, supervises);

Supervisor supervisor = new SupervisorFactory(config).newInstance();
// supervisor 생성 시점에서 내부적으로 생성한 SupervisorActor와 worker1가 worker2가 시작됨

worker1.sendOneWay("메시지");

SupervisorFactory를 통해서 Supervisor를 생성하면, 내부적으로 SupervisorActor 타입의 액터를 생성하고, 그 액터에 SupervisorConfig에 지정된 모든 액터를 연결(link)하고, 각 액터를 시작(start) 한다.

내부적으로 생성한 SupervisorActor에 접근하고 싶다면, 다음과 같이 supervisor() 메서드를 사용하면 된다.

Supervisor supervisor = ...;
ActorRef supervisorActor = supervisor.supervisor();


재시작 횟수 제한

OneForOneStrategy나 AllForOneStrategy를 생성할 때 두 번째/세 번째 파라미터는 각각 제한된 시간 내의 최대 재시작 시도 회수와 제한을 시간 의미한다.  예를 들어, 아래 코드는 1초 이내에 최대 3번의 재시작 시도를 시도한다는 것을 의미한다. (1초 안에 재시작을 3번까지 허용한다는 의미가 아니다.)

new OneForOneStrategy(new Class[] { RuntimeException.class }, 3, 1000);

1초 안에 액터 재시작을 3번 실패하면 (예를 들어, postRestart() 메서드에서 런타임 예외가 발생해서 실패), 해당 액터에 대해 재시작 시도를 하지 않으며 더 이상 액터를 사용할 수 없게 된다.

액터 생성/연결 한번에 하기

ActorRef는 액터를 생성하고 관리하기 위한 메서드를 제공하고 있으며, 이들 메서드는 다음과 같다.
  • ActorRef spawn(Class clazz): 액터를 생성하고 시작한다.
  • ActorRef spawnLink(Class clazz): 액터를 생성하고 시작하고, 연결한다.
  • ActorRef spawnRemote(Class clazz, String host, int port, long timeout): 리모트 액터를 생성하고 시작한다.
  • void startLink(ActorRef actor): 액터를 시작하고 연결한다.
위 메서드를 이용하면 다음과 같이 코드를 조금 더 간결하게 작성할 수 있다.

ActorRef master = Actors.actorOf(MasterActor.class);
master.start();
ActorRef worker1 = master.spawnLink(WorkerActor.class); // worker1 액터 시작/연결 됨

ActorRef worker2 = Actors.actorOf(WorkerActor.class);
master.startLink(worker2); // worker2 시작/연결 됨


참고자료


Posted by 최범균 madvirus

댓글을 달아 주세요

Akka가 관심을 끄는 이유는 사실 액터 모델 자체보다는 리모트 노드에 위치한 액터를 마치 로컬에 위치한 액터처럼 사용할 수 있다는 것이었다. Scala 언어가 자체적으로 액터를 제공하고 있지만, Akka의 액터는 이 액터 모델을 리모트까지 확장했기 때문에, Akka를 사용하면 한 노드에서의 병행 처리 뿐만 아니라 다수 노드에서의 병행 처리까지 쉽게 구현할 수 있다.

리모트 액터를 사용하기 위한 과정

리모트 액터를 사용하려면 다음의 과정을 거치면 된다.
  1. 리모트 서버를 만든다. 리모트 서버는 리모트로 제공될 액터를 관리하며, 클라이언트는 리모트 서버에 연결해서 리모트로 제공되는 액터를 사용하게 된다.
  2. 리모트 서버에 액터 등록하기 (클라이언트에서 액터 등록하기, 서버에서 액터 등록하기)

[주의]
클라이언트와 서버는 모두 액터에서 사용되는 클래스를 갖고 있어야 한다. 이후 버전에서는 클라이언트와 서버간의 코드 제공 기능이 포함될 거라고 한다.


단계1, 리모드 액터를 실행할 리모트 서버 만들기

액터를 외부 노드에 제공하고 싶다면, 먼저 클라이언트와의 연결을 처리할 서버를 생성해 주어야 한다. 서버는 다음의 코드를 이용해서 리모트 액터를 실행할 서버를 생성할 수 있다.

Actors.remote().start("0.0.0.0", 2552); // 모든 호스트에 대해 2552 포트로 들어오는 요청 처리


Actors.remote().start("localhost", 2553); // 로컬 호스트의 2553 포트로 들어오는 요청 처리


Actors.remote().start(); // 설정 파일에 있는 기본 값 사용 (설정 파일 없을 시 기본값은 "localhost", 2552)


start() 메서드에서 호스트 값으로 "localhost"를 지정하면, 로컬호스트로 들어오는 요청에 대해서만 처리할 수 있기 때문에, 실 환경에서는 의미가 없다. 실 환경에서는 "0.0.0.0"이나 "192.168.0.1"과 같이 전체 허용 또는 특정 호스트를 지정해 주는 것이 좋다.

단계2, 리모트 서버에 액터 생성하기

다음의 두 가지 방법을 이용해서 리모트 서버에서 액터를 실행할 수 있다.3
  • 클라이언트에서 생성/관리: 리모트 노드에 있는 액터를 클라이언트에서 관리해야 할 때 사용 (액터 모니터링, 액터 수퍼바이징 등)
  • 서버에서 생성/관리: 클라이언트에 액터 서비스만 제공하고 서버에서 액터에 대한 관리를 할 때 주로 사용한다.

클라이언트에서 원격지 서버에 액터 생성하기

다음의 코드를 사용하면 클라이언트에서 리모트 서버에 액터를 생성하고 관리할 수 있다.

ActorRef actor1 = Actors.remote().actorOf(MyActor.class, "172.20.1.11", 2552);
actor1.start();
actor1.sendOneWay("hello");
actor1.stop();

Actors.remote().actorOf() 메서드는 리모트 서버에 MyActor 타입의 액터를 생성한다. 클라이언트에서 액터를 생성한 경우 로컬 액터를 사용하듯이 start() 메서드를 이용해서 액터를 시작하고 stop() 메서드를 이용해서 액터를 종료할 수 있다.

클라이언트에서 리모트 서버에 액터를 생성할 때 주의할 점은 호스트와 포트가 "localhost"와 2552 이면, 리모트 액터가 아닌 로컬 액터로 생성해서 실행된다는 점이다.

서버에서 액터 생성해서 등록하기

서버에서 액터를 생성해서 클라이언트에 제공할 수도 있다. 서버에서 액터를 등록할 때에는 다음과 같이 register() 메서드를 사용하면 된다.

Actors.remote().start("0.0.0.0", 2552);
// MyActor를 리모트 액터로 등록, 식별값은 "hello-service"
Actors.remote().register("hello-service", Actors.actorOf(MyActor.class));

register() 메서드를 사용하면 액터는 자동으로 시작된다.

클라이언트는 액터의 식별값을 이용해서 리모트 액터에 대한 레퍼런스를 구할 수 있으며, 이 레퍼런스를 이용해서 리모트 액터에 메시지를 전달할 수 있다. actorFor() 메서드를 사용하면 리모트 노드에서 생성한 액터에 접근할 수 있다.

// 192.168.1.11:2553 포트로 실행중인 리모트 서버에 등록된 "hello-service" 액터 접근
ActorRef actor = Actors.remote().actorFor("hello-service", "192.168.1.11", 2553);
actor.sendOneWay("테스트!!!"); // 리모트 액터에 메시지 전달


리모트 액터에서 클라이언트에 응답하기

리모트 액터에서 클라이언트에 응답하는 방법은 앞서 'Akka 첫 번째, Akka를 이용한 Concurrent 프로그래밍 시작하기' 에서 살펴봤던 것과 동일하다.

로컬 액터에서 리모트 액터로 메시지를 전송하면, 리모트 액터는 getContext().getSender()를 이용해서 로컬 액터에 메시지를 전달할 수 있다. (즉, 리모트 액터 입장에서는 로컬 액터가 리모트 액터가 되는 것이다.) 리모트 액터에서 로컬 액터에 메시지를 전송할 때에도 결국 네트워크를 통해서 보내기 때문에, 클라이언트도 리모트 서버를 실행해야 리모트 액터에서 로컬 액터에 메시지를 전송할 수 있게 된다.

---- 클라이언트 코드

// 리모트 액터에서 로컬 액터에 메시지 보낼 때 사용할 서버 실행
Actors.remote().start("192.168.4.4", 2552);

ActorRef localActor = Actors.actorOf(LocalActor.class);
localActor.start();

ActorRef actor = Actors.remote().actorFor("hello-service", "192.168.4.3", 2552);
actor.sendOneWay("테스트!!!", localActor); // 로컬 액터를 sender로 지정

---- 리모트 액터 코드
public class MyActor extends UntypedActor {
   
    @Override
    public void onReceive(Object msg) throws Exception {
        if (getContext().getSender().isDefined()) {
            ActorRef sender = getContext().getSender().get(); // 클라이언트의 LocalActor가 sender
            sender.sendOneWay(msg); // 192.168.4.4:2552 로 메시지 전송
        }
    }
}

클라이언트 코드에서 리모트 서버를 실행하지 않으면, 리모트 액터가 로컬 액터에 메시지를 전달할 수 없게 된다. 즉, 서로 다른 노드에 있는 액터들 간에 메시지를 주고 받기 위해서는 각 노드마다 리모트 서버를 실행시켜 주어야 한다.

비신뢰 모드(UntrustedMode)로 리모트 서버 실행하기

리모트 서버를 비신뢰 모드로 실행하게 되면, 클라이언트에서 액터를 생성할 수 없게 된다. 리모트 서버를 비신뢰 모드로 실행하려면 설정 파일에 다음과 같이 untrusted-mode 값을 on으로 설정해 주면 된다.

akka {
    remote {
        server {
            untrusted-mode = on  # 기본 값은 off
        }
    }
}

비신뢰 모드로 실행하면 클라이언트에서 리모트 액터에 대해 다음의 메서드에 대한 호출이 제한된다.
  • start(), stop(), link(), unlink(), spawnLink() 등

리모트 서버와 클라이언트 종료 처리

아래의 클라이언트 코드를 실행하면 JVM이 종료되지 않고 실행된 채로 남아 있는다. 이유는 Akka가 내부적으로 리모트 서버와의 연결 처리를 위해 사용하는 쓰레드가 죽지 않기 때문이다.

public class Client {

    public static void main(String[] args) {
        ActorRef actor = Actors.remote().actorFor("hello-service", "172.20.4.64", 2553);
        actor.sendOneWay("테스트!!!");
        // JVM 종료되지 않음
    }
}

shutdown() 메서드를 이용해서 리모트 서버와의 연결을 종료시키고 관련된 모든 쓰레드를 함께 종료시켜주므로, 클라이언트 코드에서 리모트 액터에 대한 사용이 끝나면 shutdown() 메서드를 호출해서 JVM을 종료처리할 수 있다.

ActorRef actor = Actors.remote().actorFor("hello-service", "172.20.4.64", 2553);
// actor 사용
Actors.remote().shutdown(); // 프로그램 종료시 반드시 실행해 주어야 함

리모트 서버에서도 마찬가지로, 어플리케이션을 종료처리할 때 shutdown() 메서드를 호출해 주어야 관련된 쓰레드가 모두 정리되어 JVM이 종료하게 된다.

Actors.remote().start("0.0.0.0", 2553);
...
Actors.remote().shutdown(); // 프로그램 종료시 반드시 실행해 주어야 함


이벤트 처리

클라이언트와 서버는 액터를 이용해서 클라이언트의 연결/해제 등의 이벤트를 수시할 수 있다. 리모트 기능과 관련된 이벤트를 처리하고 싶다면 다음과 같이 이벤트를 수신할 액터를 Actors.remote().addListener() 메서드를 이용해서 이벤트 리스너로 등록해주면 된다.

ActoreRef listener = Actors.actorOf(ListenerActor.class);
listener.start();

Actors.remote().addListener(listener);

리스너로 사용되는 액터에는 리모트 기능과 관련된 이벤트 객체가 메시지로 전달되며, 액터는 이벤트 타입에 따라서 알맞은 작업을 수행하면 된다.

public class ListenerActor extends UntypedActor {

    @Override
    public void onReceive(Object message) throws Exception {
        if (message instanceof RemoteServerStarted) {
            ...
        }
    }

}

주요 이벤트 클래스는 다음과 같다.
  • 서버측 이벤트
    • RemoteServerStarted
    • RemoteServerShutdown
    • RemoteServerClientConnected
    • RemoteServerClientDisconnected
    • RemoteServerClientClosed
    • RemoteServerWriteFailed
  • 클라이언트측 이벤트
    • RemoteClientConnected
    • RemoteClientDisconnected
    • RemoteClientStarted
    • RemoteClientShutdown
    • RemoteClientError
    • RemoteClientWriteFailed

참고자료

Posted by 최범균 madvirus

댓글을 달아 주세요

개인적으로 관심을 가지고 지켜보던 Akka 프로젝트가 1.0 버전이 되었다. 평소에 병행 처리와 분산 처리에 관심이 많았는데, Akka는 이를 보다 쉽게 구현할 수 있도록 도와주는 프로젝트이다. 본 글에서는 Akka가 무엇인지 간단하게 설명하고 실제 Akka를 이용해서 액터를 생성하고 실행하는 방법을 살펴볼 것이다.

Akka란?

Akka는 병행(concurrent) 및 분산 처리를 위한 오픈 소스 프로젝트로서 액터(Actor) 모델을 이용하고 있다. 필자가 액터 모델 자체에 대한 이해가 완전하지 않지만, 액터 모델을 간단하게 설명하면 다음의 특징을 갖는다.

  • 액터들은 상태를 공유하지 않는다.
  • 액터들 간의 통신은 메시지 전달을 통해서 이루어진다. (이벤트 기반 모델)
  • 액터간의 통신은 비동기로 이루어진다.
  • 각 액터는 전달받은 메시지를 큐에 보관하며, 메시지를 순차적으로 처리한다.
  • 액터는 일종의 경량 프로세서다.
위와 같은 특징은 병행 처리 코드를 보다 쉽게 구현할 수 있도록 도와준다. 실제로 다중 쓰레드 프로그래밍을 해 본 개발자 중에서는 올바르지 못한 동기화 처리로 쓰레드 블럭킹 되는 등의 문제로 고생한 경험이 한 두 번씩은 존재할 것이다. 액터는 애초에 데이터를 서로 공유하지 않는 것을 원칙으로 하기 때문에, 데드락이나 락에 대한 고민을 줄여주고 병행 처리 그 자체에 집중할 수 있도록 도와준다.

Akka는 액터 모델을 통해서 병행 처리를 쉽게 할 수 있도록 도와줄 뿐만 아니라, 리모트 노드에 존재하는 액터를 마치 로컬에 존재하는 액터처럼 사용할 수 있도록 해 주고 있다. 개발자는 통신 프로토콜에 대해 고민할 필요 없이 리모트 액터를 사용할 수 있기 때문에 분산 처리 코드를 손쉽게 작성할 수 있게 된다.

Akka는 스카라(Scala)와 자바(Java)의 두 언어에 대한 API를 제공하고 있는데, 필자가 스카라 언어 자체에 대해서는 아직 잘 모르고 국내에서도 스카라 언어에 대한 관심이 적은 관계로 자바 API를 기준으로 Akka 사용법을 살펴볼 것이다.

Akka 코어 사용

Akka를 사용하려면 http://akka.io 사이트에서 필요한 파일을 다운로드 받으면 되는데, Maven을 사용하고 있다면 pom.xml 파일에 다음과 같이 리포지토리 설정과 의존 설정을 추가해주면 Akka가 제공하는 액터 기능을 사용할 수 있다.

<repositories>
    <repository>
        <id>Akka</id>
        <name>Akka Maven2 Repository</name>
        <url>http://akka.io/repository/</url>
    </repository>

    <repository>
        <id>Multiverse</id>
        <name>Multiverse Maven2 Repository</name>
        <url>http://multiverse.googlecode.com/svn/maven-repository/releases/</url>
    </repository>

    <repository>
        <id>GuiceyFruit</id>
        <name>GuiceyFruit Maven2 Repository</name>
        <url>http://guiceyfruit.googlecode.com/svn/repo/releases/</url>
    </repository>

    <repository>
        <id>JBoss</id>
        <name>JBoss Maven2 Repository</name>
        <url>http://repository.jboss.org/nexus/content/groups/public/</url>
    </repository>
</repositories>

<dependencies>
    <dependency>
        <groupId>se.scalablesolutions.akka</groupId>
        <artifactId>akka-actor</artifactId>
        <version>1.0</version>
    </dependency>
</dependencies>

[주의]
Akka는 Scala로 만들어졌기 때문에, 이클립스에서 Akka 모듈을 사용할 때 코드 어시스트가 제대로 동작하려면 Scala-IDE 등을 이용해서 개발환경을 구축해 주어야 한다. 그렇지 않으면, 코드 어시스트가 안 되서 짜증나는 시간을 보내게 될 것이다.

액터 클래스 및 사용

액터를 생성하고 사용하려면, 먼저 Akka가 제공하는 기반 클래스를 이용해서 액터 역할을 수행할 클래스를 구현해 주어야 한다. Akka는 akka.actor.UntypedActor 클래스를 제공하고 있으며, 이 클래스를 상속받아서 액터를 구현할 수 있다.

import akka.actor.UntypedActor;

public class PrintActor extends UntypedActor {

    @Override
    public void onReceive(Object message) throws Exception {
        Thread.sleep(500); // 테스트를 위해 0.5초 sleep
        System.out.println(message);
    }

}

UntypedActor를 상속받은 클래스는 onReceive() 메서드를 구현해 주어야 하는데, onReceive() 메서드는 액터에 전달된 메시지를 처리하게 된다.

액터 클래스를 만들었다면, 액터를 생성한 뒤에 액터에 메시지를 전달할 수 있다. 액터를 생성할 때에는 akka.actor.Actors 클래스의 static 메서드인 actorOf() 메서드를 사용한다. Actors.actorOf() 메서드는 액터를 구현한 클래스의 Class를 전달받으며, 액터를 생성한다.

ActorRef actor = Actors.actorOf(PrintActor.class);
actor.start();
// actor를 이용해서 액터에 메시지를 전달

ActorRef의 start() 메서드는 액터를 시작하며, 액터가 시작된 이후부터 액터에 메시지를 전달할 수 있게 된다.

액터에 메시지 전달하기

Actors.actorOf()를 이용해서 액터를 생성했다면, 이후 ActorRef가 제공하는 메서드를 이용해서 액터에 메시지를 전달할 수 있다. 다음의 세 가지 방법으로 액터에 메시지를 전달할 수 있다.
  • Fire-And-Forget: 메시지를 전달하고 메시지에 대한 응답을 기다리지 않는다. 병행 및 확장에 적합한 메시지 전달 방식이다.
  • Send-And-Receive-Eventually: 메시지를 전달하고 응답을 받는다. 응답을 받을 때 까지 블록킹된다.
  • Send-And-Receive-Future: 메시지를 전달하고 응답을 받기 위한 Future를 리턴한다.
sendOneWay() 메서드를 이용한 Fire-And-Forget 방식 메시지 전달

ActorRef.sendOneWay() 메서드는 메시지를 액터에 전달할 때 사용된다. sendOneWay()라는 이름에서 알 수 있듯이 이 메서드는 액터로부터 어떤 값도 받지 않으며, 액터로부터 응답을 기다리지 않고 곧 바로 리턴한다.

ActorRef actor = Actors.actorOf(PrintActor.class);
actor.start();
actor.sendOneWay("받아라"); // actor에 "받아라" 메시지를 전달하고 바로 리턴.
actor.sendOneWay("받아라2");
actor.sendOneWay("받아라3");
System.out.println("비동기로 실행");

메시지를 받은 액터는 내부적으로 사용하는 큐에 메시지를 보관한 뒤, 차례대로 액터의 onReceive(Object message) 메서드에 메시지를 전달한다. PrintActor의 onReceive() 메서드는 0.5초후에 전달받은 메시지를 출력하고 sendOneWay()메서드는 응답 대기 없이 바로 리턴하므로, 위 코드가 실행되면 콘솔에는 다음과 같은 순서로 문자열이 출력된다.

비동기로 실행
받아라
받아라2
받아라3

sendOneWay() 메서드는 다음의 두 가지를 제공된다.
  • sendOneWay(Object message)
  • sendOneWay(Object message, ActorRef sender) : 메시지를 전송하면서 메시지를 보낸 액터로 sender를 지정한다.

sendRequestReply() 메서드를 이용한 Send-And-Receive-Eventually 방식 메시지 전달

ActorRef.sendRequestReply() 메서드는 액터에 메시지를 전달하고, 그 메시지에 대한 응답이 올 때 까지 대기하고 싶을 때 사용된다. 액터 구현 클래스는 getContext().replyUnsafe() 메서드를 이용해서 메시지에 대해 응답할 수 있는데, ActorRef.sendRequestReply() 메서드는 이 응답을 리턴하게 된다. 예를 들어, 다음과 같이 메시지에 대해 응답하는 액터가 있다고 하자.

public class PingActor extends UntypedActor {

    @Override
    public void onReceive(Object message) throws Exception {
        getContext().replyUnsafe("응답: "+ message); // 메시지 sender에 응답
    }

}

이 경우 다음과 같이 sendRequestReply() 메서드를 이용함으로써 액터에 전달한 메시지에 대한 응답이 도착할 때 까지 대기할 수 있다.

ActorRef actor = Actors.actorOf(PingActor.class);
actor.start();
Object res = actor.sendRequestReply("헬로우"); // 액터로부터 응답이 도착할 때 까지 대기

sendRequestReply() 메서드는 일정 시간 동안 액터로부터 응답이 없을 경우 akka.actor.ActorTimeoutException 예외를 발생시킨다. 별도 설정을 하지 않은 경우 기본 타입 시간은 5초이며, sendRequestReply() 메서드를 호출할 때 타임아웃을 지정할 수도 있다.

Object res = actor.sendRequestReply("헬로우", 1000, null); // 1초간 응답 대기

sendRequestReply() 메서드는 다음의 세 가지가 존재한다.
  • sendRequestReply(Object message)
  • sendRequestReply(Object message, ActorRef sender): 메시지를 보낸 액터로 sender를 지정한다.
  • sendRequestReply(Object message, long timeout, ActorRef sender)

sendRequestReplyFuture() 메서드를 이용한 Send-And-Receive-Future 방식 메시지 전달

sendRequestReplyFuture() 메서드는 메시지를 전달한 뒤 응답을 받기 위한 Future를 리턴한다. Future는 자바가 제공하는 Future가 아닌 Akka가 제공하는 akka.dispatch.Future 타입이다. Future는 주로 다음과 같은 형식으로 주로 사용된다.

Future future = actor.sendRequestReplyFuture("하이");
future.await(); // 응답을 대기. 대기 시간을 초과하면 예외 발생
if (future.isCompleted()) { // 완료되었다면
    Option resultOption = future.result(); // 응답 구함
    if (resultOption.isDefined()) { // 응답 데이터가 있다면,
        Object result = resultOption.get(); // 응답 데이터 구함
        System.out.println(result);
    }
}

sendRequestReplyFuture()가 리턴한 Future의 await() 메서드는 시간이 초과될 때 까지 대기한다. 시간이 초과되기 전에 응답이 도착하면 다음으로 넘어가고, 시간이 초과되면 ActorTimeoutException 예외를 발생시킨다.

sendRequestReplyFuture() 메서드는 다음의 세 가지가 존재한다.
  • sendRequestReplyFuture(Object message)
  • sendRequestReplyFuture(Object message, ActorRef sender) : 메시지를 보낸 액터로 sender를 지정한다.
  • sendRequestReplyFuture(Object message, long timeout, ActorRef sender)
두 개 이상의 액터에 메시지를 전달한 후 액터로부터의 응답이 모두 도착할 때 까지 대기해야 한다면, Futures.awaitAll(Future[] futures) 메서드를 사용하면 된다.

// actor들에 메시지 전달(작업 전달)
for (ActorRef actor : actors) {
    futureList.add(actor.sendRequestReplyFuture(someWork);
}
Future[] futures =  futureList.toArray();
Future.awaitAll(futures); // 모든 액터로부터 응답이 (작업 결과가) 올 때 까지 대기.
// futures로부터 응답 구해서 처리


액터에서 메시지 받아 처리하기

ActorRef의 send*() 메서드를 통해서 전달된 메시지는 UntypedActor 클랫를 상속받은 액터 구현 클래스의 onReceive(Object message)에 차례대로 전달된다. (TypedActor를 사용하면 인터페이스를 이용해서 메시지를 전달받을 메서드를 정의할 수도 있는데, 이에 대한 내용은 다음에 살펴볼 것이다.)

onReceive() 메서드는 다음과 같이 메시지의 타입을 확인한 뒤 메시지 타입에 맞는 동작을 수행하도록 구현하는 것이 보통이다.

public class ActorImpl extends UntypedActor {
    public void onReceive(Object message) throws Exception {
        if (message instanceof String) {
            // 메시지 처리
        }
        ...
    }
}


메시지에 응답하기

replyUnsafe()/replySafe()를 이용한 응답

액터 구현 클래스는 getContext()를 이용해서 해당 액터에 대한 ActorRef를 구할 수 있는데, ActorRef의 replyUnsafe() 또는 replySafe() 메서드를 이용해서 메시지에 대한 응답을 전달할 수 있다. replyUnsafe() 메서드는 응답 실패시 예외를 발생시키는 반면에 replySafe() 메서드는 응답에 실패할 경우 false를 리턴한다.

public class PingActor extends UntypedActor {

    public void onReceive(Object message) throws Exception {
        if (message.equals("ping")) {
            if (! getContext.replySafe("pong")) {
                // 실패에 대한 처리
            }
        }
    }
}

액터에 메시지를 전달할 때 sendRequestReply() 메서드나 sendRequestReplyFuture() 메서드를 사용한 경우, replyUnsafe()와 replySafe()를 이용해서 응답한 데이터를 리턴 값으로 받게 된다.
 
메시지를 전달한 액터에 메시지로 응답하기

액터가 다른 액터에게 메시지를 전달하기도 한다. 이때 sendRequestReply*() 메서드에 대한 응답이 아니라 메시지를 전달한 액터에 메시지를 전달하는 방법으로 응답할 수도 있을 것이다. 이렇게 메시지를 전달한 액터에 응답으로 메시지를 전송하고 싶다면, getContext().getSender() 메서드를 이용해서 메시지를 보낸 액터에 대한 ActorRef를 구한 뒤, 그 ActorRef의 send*() 메서드를 이용해서 응답을 전달하면 된다.

public class PongActor extends UntypedActor {

    @Override
    public void onReceive(Object message) throws Exception {
        if (message.equals("ping")) {
            if (getContext().getSender().isDefined()) {
                ActorRef sender = getContext().getSender().get();
                sender.sendOneWay("pong", getContext());
            } else {
                getContext().replyUnsafe("pong");
            }
        }
    }
}


액터의 라이프사이클

액터의 라이프 사이클은 다음과 같다.
  • NEW: 액터가 만들어졌을 때. 메시지를 수신하지 못한다.
  • STARTED: start()가 호출되었을 때. 메시지를 수신할 수 있다.
  • SHUTDOWN: exit()나 stop()이 호출되었을 때. 어떤 것도 하지 못한다.
ActorRef는 start(), stop() 메서드를 제공하고 있으며, 이들 메서드를 이용해서 액터를 시작하고, 중지할 수 있다. 아래 코드는 전형적인 액터의 사용방법을 보여주고 있다.

actor.start(); // 액터를 시작
// 필요한 만큼 액터에 메시지 전달
actor.sendOneWay(msg);
...
actor.stop(); // 액터 종료

start() 메서드는 액터와 메시지 큐를 시작하고, stop() 메서드는 액터의 디스패처와 메시지 큐를 포함한 액터를 종료시킨다.

모든 액터를 종료시키고 싶다면, 다음과 같은 코드를 사용하면 된다.

Actors.registry().shtudownAll();


UntypedActor 클래스의 라이프 사이클 관련 콜백 메서드

UntypedActor 클래스는 라이프 사이클과 관련해서 시작/중지 이벤트를 처리할 수 있는 콜백 메서드를 제공하고 있다.
  • preStart(): 액터 시작 전에 호출된다.
  • postStop(): 액터 종료 후에 호출된다.
  • preRestart(Throwable reason): 액터 재시작 전에 호출된다. (무정지 액터 기능과 관련됨)
  • postRestart(Throwable reason): 액터 재시작 후에 호출된다. (무정지 액터 기능과 관련됨)

설정 파일 지정 및 기본 값

Akka는 다음의 세 가지 방법 중 한가지를 이용해서 설정 파일을 찾는다.
  • akka.config 시스템 프로퍼티로 지정한 파일 (java -Dakka.config=... )
  • 클래스패스에 위치한 akka.config 파일
  • AKKA_HOME 환경변수 존재 시, '$AKKA_HOME/config 디렉터리의 설정 파일 사용. (또는 akka.home 시스템 프로퍼티를 AKKA_HOME 환경 변수 대신 사용)
각 설정 정보 및 기본 값은 http://doc.akka.io/configuration 참고하기 바란다.

참고자료

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 고광현 2016.10.25 17:41 신고  댓글주소  수정/삭제  댓글쓰기

    메시지에 응답하기
    replyUnsafe()/replySafe()를 이용한 응답 예제에서 실패에 대한처리 부분에 질문이 있는데요
    if (! getContext.replySafe("pong")) {

    실패시 처리인데 아직 해당 actor가 작업을 마치지 않았늗네 결과를 알수가 있나요?

    • 최범균 madvirus 2016.10.27 08:13 신고  댓글주소  수정/삭제

      제가 akka 내부를 잘 모르지만, reply라는 게 메시지를 보내는 용도니 메시지 보내는데 실패했다를 의미합니다.
      메시지를 수신한 것과, 수신한 메시지를 처리하는 건 구별된다고 알고 있습니다.

개인적으로 SiteMesh의 동장 방식이 Tiles보다 좋기 때문에 SiteMesh를 선호하는데, SiteMesh를 사용하면서 언제나 아쉬운 점은 <servlet-mapping>의 <url-pattern>의 값을 /catalog/*와 같은 경로 기반으로 설정한 경우 SiteMesh의 데코레이터 설정 파일에서 경로 패턴에 기반한 매칭을 사용할 수 없다는 점이다. (관련글: SiteMesh를 이용한 웹 페이지 데코레이션, http://javacan.tistory.com/entry/131) 특히 요즘처럼 (REST 방식의 유행으로) 확장자 없는 URL을 제공하는 게 멋처럼 느껴질 때에는 더더욱 SiteMesh의 지원이 아쉬웠다.

이런 아쉬움을 해소하기 위해 <servlet-mapping>의 <url-pattern>의 값에 상관없이 URL 경로를 이용해서 패턴 매칭을 하는 DecoratorMapper 클래스를 작성해보았다.

WemadeConfigDecoratorMapper 클래스 구현 코드

아래 코드는 실제로 구현해서 사용하고 있는 코드이다.

package com.madvirus.sitemesh;

import java.util.Properties;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;

import com.opensymphony.module.sitemesh.Config;
import com.opensymphony.module.sitemesh.Decorator;
import com.opensymphony.module.sitemesh.DecoratorMapper;
import com.opensymphony.module.sitemesh.Page;
import com.opensymphony.module.sitemesh.mapper.AbstractDecoratorMapper;
import com.opensymphony.module.sitemesh.mapper.ConfigLoader;

/**
 * 서블릿 경로가 아닌 컨텍스트 경로를 제외한 요청 URI를 이용해서 패턴 매칭을 수행한다.
 * 
 * Sitemesh 2.4.1 버전의 ConfigDecoratorMapper로부터 코드를 가져와서 작성하였음.
 * 
 * @author 최범균
 * @version 2010. 3. 5.
 */
public class WemadeConfigDecoratorMapper extends AbstractDecoratorMapper {

    private ConfigLoader configLoader = null;

    /** Create new ConfigLoader using '/WEB-INF/decorators.xml' file. */
    public void init(Config config, Properties properties,
            DecoratorMapper parent) throws InstantiationException {
        super.init(config, properties, parent);
        try {
            String fileName = properties.getProperty("config",
                    "/WEB-INF/decorators.xml");
            configLoader = new ConfigLoader(fileName, config);
        } catch (Exception e) {
            throw new InstantiationException(e.toString());
        }
    }

    /**
     * Retrieve {@link com.opensymphony.module.sitemesh.Decorator} based on
     * 'pattern' tag.
     */
    public Decorator getDecorator(HttpServletRequest request, Page page) {
        String thisPath = request.getRequestURI();
        String contextPath = request.getContextPath();
        if (thisPath.startsWith(contextPath)) {
            thisPath = thisPath.substring(contextPath.length());
        }
        String name = null;
        try {
            name = configLoader.getMappedName(thisPath);
        } catch (ServletException e) {
            e.printStackTrace();
        }
        Decorator result = getNamedDecorator(request, name);
        return result == null ? super.getDecorator(request, page) : result;
    }

    /**
     * Retrieve Decorator named in 'name' attribute. Checks the role if
     * specified.
     */
    public Decorator getNamedDecorator(HttpServletRequest request, String name) {
        Decorator result = null;
        try {
            result = configLoader.getDecoratorByName(name);
        } catch (ServletException e) {
            e.printStackTrace();
        }

        if (result == null
                || (result.getRole() != null && !request.isUserInRole(result
                        .getRole()))) {
            // if the result is null or the user is not in the role
            return super.getNamedDecorator(request, name);
        } else {
            return result;
        }
    }

}

sitemesh.xml 파일 설정에서 커스텀 DecoratorMapper 사용

sitemesh.xml 파일에서는 아래 코드와 같이 앞서 구현한 DecoratorMapper를 사용하도록 설정한다.

<sitemesh>
    <property name="decorators-file" value="/WEB-INF/decorators.xml"/>
    <excludes file="${decorators-file}"/>

    <page-parsers>
        <parser content-type="text/html" class="com.opensymphony.module.sitemesh.parser.HTMLPageParser" />
    </page-parsers>
    <decorator-mappers>
        <mapper class="com.opensymphony.module.sitemesh.mapper.PageDecoratorMapper">
            <param name="property.1" value="meta.decorator" />
            <param name="property.2" value="decorator" />
        </mapper>
        <mapper class="com.opensymphony.module.sitemesh.mapper.FrameSetDecoratorMapper"/>
        <mapper class="com.opensymphony.module.sitemesh.mapper.PrintableDecoratorMapper">
            <param name="decorator" value="printable" />
            <param name="parameter.name" value="printable" />
            <param name="parameter.value" value="true" />
        </mapper>
        <mapper class="com.opensymphony.module.sitemesh.mapper.FileDecoratorMapper"/>
        <mapper class="com.madvirus.sitemesh.WemadeConfigDecoratorMapper">
            <param name="config" value="${decorators-file}" />
        </mapper>
    </decorator-mappers>
</sitemesh>

web.xml 파일 및 decorator.xml 파일 설정

web.xml 파일에서 다음과 같이 경로 기반의 패턴을 사용하고 있다고 해 보자.

<web-app ...>
    ...
    <servlet-mapping>
        <servlet-name>ControllerServlet</servlet-name>
        <url-pattern>/my/*</url-pattern>
        <url-pattern>/data/*</url-pattern>
    </servlet-mapping>

    <filter>
        <filter-name>sitemesh</filter-name>
        <filter-class>com.opensymphony.module.sitemesh.filter.PageFilter</filter-class>
    </filter>

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

</web-app>

위 코드에서 ControllerServlet은 경로 기반의 <url-pattern>을 사용하고 있는데, 앞서 구현한 커스텀 DecoratorMapper를 사용함으로써 다음과 같이 데코레이터 파일에서 URL 패턴을 이용해서 매칭을 할 수 있게 된다.

<decorators defaultdir="/decorators">

    <decorator name="my" page="/WEB-INF/view/common/decorator/my.jsp">
        <url-pattern>/my/*</url-pattern>
    </decorator>

    <decorator name="data" page="/WEB-INF/view/common/decorator/data.jsp">
        <url-pattern>/data/*</url-pattern>
    </decorator>

</decorators>

그 동안 경로 기반의 서블릿 매핑을 사용할 때 SiteMesh 때문에 섭섭함이 있었던 개발자에게 본 글이 조금이나마 도움이 되길 바란다.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 삽지리 2010.07.13 10:45 신고  댓글주소  수정/삭제  댓글쓰기

    도움이 되었습니다. 감사합니다.

DWR을 이용해서 웹 어플리케이션을 개발하다 보면 성능 상의 이유로 DWR이 객체를 변환해서 생성하는 JSON 코드를 서버 코드에서 직접 생성하고 싶을 때가 있다. 이 경우 다음과 같은 절차에 따라 작업을 진행하면 된다.

1. web.xml 파일의 DwrServlet에 publishContainerAs 초기화 파라미터 값 설정

<servlet>
  <servlet-name>dwr-invoker</servlet-name>
  <servlet-class>org.directwebremoting.servlet.DwrServlet</servlet-class>
  <init-param>
     <param-name>publishContainerAs</param-name>
     <param-value>dwrContainer</param-value>
  </init-param>
</servlet>

DwrServlet은 publishContainerAs 초기화 파라미터에 설정된 값이 있으면 ServletContext.setAttribute() 메서드를 이용해서 DWR Container를 ServletContext에 보관한다. 이때 publishContainerAs 초기화 파라미터의 값을 속성 이름으로 사용한다.

2. DWR이 제공하는 클래스를 이용해서 객체를 JSON으로 변환한다.

Container dwrContainer = (Container)getServletContext().getAttribute("dwrContainer");
ConverterManager converterManager = (ConverterManager)dwrContainer.getBean(
                                                                    ConverterManager.class.getName());
ScriptBuffer sb = new ScriptBuffer("dwrObject=");
SomeObject object = ...; // JSON으로 변환할 객체
sb.appendData(object); //
String json = null;
try {
 json = ScriptBufferUtil.createOutput(sb, converterManager);
 json = StringEscapeUtils.escapeJavaScript(json);
} catch (MarshallException e) {
 throw new RuntimeException(e);
}
request.setAttribute("JSON_OBJECT", json);

위 코드에서 StringEscapeUtils 클래스는 Jakarta Commons Lang 프로젝트에 포함된 클래스이다.

ScriptBufferUtil.createOutput() 메서드는 ScriptBuffer에 설정한 객체로부터 자바 스크립트 코드를 생성해 주는데, 이때 생성된 자바 스크립트 코드는 서버 측의 객체를 자바 스크립트의 JSON 형식으로 변환해주는 코드가 된다.

3. 자바 스크립트의 eval()을 이용해서 자바 스크립트 객체로 변환

이제 남은 작업은 eval()을 이용해서 DWR 모듈이 생성한 자바 스크립트 코드를 실행해서 그 결과를 객체로 저장하면 된다.

var someObj = (function () { return eval("${JSON_OBJECT}"); })();

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. agapeuni 2013.01.14 12:54 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 출처를 표시하고 블로그에 담아갑니다. ^^

자바 1.2 버전부터 제공되고 있지만 아직 다수의 개발자들이 잘 몰라서 활용을 잘 못하는 기능이 하나 있는데, 그 기능이 바로 쓰레드 단위로 로컬 변수를 할당하는 기능이다. 이 기능은 ThreadLocal 클래스를 통해서 제공되는데, 본 글에서는 ThreadLocal 클래스의 기본적인 사용방법과 활용 방법을 살펴보도록 하겠다.

ThreadLocal이란?

일반 변수의 수명은 특정 코드 블록(예, 메서드 범위, for 블록 범위 등) 범위 내에서만 유효하다.

{
    int a = 10;
    ...
   // 블록 내에서 a 변수 사용 가능
}
// 변수 a는 위 코드 블록이 끝나면 더 이상 유효하지 않다. (즉, 수명을 다한다.)

반면에 ThreadLocal을 이용하면 쓰레드 영역에 변수를 설정할 수 있기 때문에, 특정 쓰레드가 실행하는 모든 코드에서 그 쓰레드에 설정된 변수 값을 사용할 수 있게 된다. 아래 그림은 쓰레드 로컬 변수가 어떻게 동작하는 지를 간단하게 보여주고 있다.


위 그림에서 주목할 점은 동일한 코드를 실행하는 데, 쓰레드1에서 실행할 경우 관련 값이 쓰레드1에 저장되고 쓰레드2에서 실행할 경우 쓰레드2에 저장된다는 점이다.


ThreadLocal의 기본 사용법

ThreadLocal의 사용방법은 너무 쉽다. 단지 다음의 네 가지만 해 주면 된다.
  1. ThreadLocal 객체를 생성한다.
  2. ThreadLocal.set() 메서드를 이용해서 현재 쓰레드의 로컬 변수에 값을 저장한다.
  3. ThreadLocal.get() 메서드를 이용해서 현재 쓰레드의 로컬 변수 값을 읽어온다.
  4. ThreadLocal.remove() 메서드를 이용해서 현재 쓰레드의 로컬 변수 값을 삭제한다.
아래 코드는 ThreadLocal의 기본적인 사용방법을 보여주고 있다.

// 현재 쓰레드와 관련된 로컬 변수를 하나 생성한다.
ThreadLocal<UserInfo> local = new ThreadLocal<UserInfo>();

// 로컬 변수에 값 할당
local.set(currentUser);

// 이후 실행되는 코드는 쓰레드 로컬 변수 값을 사용
UserInfo userInfo = local.get();

위 코드만으로는 ThreadLocal이 어떻게 동작하는 지 잘 이해가 되지 않을테니, 구체적인 예제를 이용해서 ThreadLocal의 동작 방식을 살펴보도록 하겠다. 먼저 ThreadLocal 타입의 static 필드를 갖는 클래스를 하나 작성해보자.

public class Context {
    public static ThreadLocal<Date> local = new ThreadLocal<Date>();
}

이제 Context 클래스를 사용해서 쓰레드 로컬 변수를 설정하고 사용하는 코드를 작성할 차례이다. 아래는 코드의 예이다.

class A {
    public void a() {
        Context.local.set(new Date());
       
        B b = new B();
        b.b();

        Context.local.remove();
    }
}

class B {
    public void b() {
        Date date = Context.local.get();

        C c = new C();
        c.c();
    }
}

class C {
    public void c() {
        Date date = Context.local.get();
    }
}

위 코드를 보면 A, B, C 세 개의 클래스가 존재하는데, A.a() 메서드를 호출하면 다음 그림과 같은 순서로 메서드가 실행된다.


위 그림에서 1~10은 모두 하나의 쓰레드에서 실행된다. ThreadLocal과 관련된 부분을 정리하면 다음과 같다.
  • 2 - A.a() 메서드에서 현재 쓰레드의 로컬 변수에 Date 객체를 저장한다.
  • 4 - B.b() 메서드에서 현재 쓰레드의 로컬 변수에 저장된 Date 객체를 읽어와 사용한다.
  • 6 - C.c() 메서드에서 현재 쓰레드의 로컬 변수에 저장된 Date 객체를 읽어와 사용한다.
  • 9 - A.a() 메서드에서 현재 쓰레드의 로컬 변수를 삭제한다.
위 코드에서 중요한 건 A.a()에서 생성한 Date 객체를 B.b() 메서드나 C.c() 메서드에 파라미터로 전달하지 않는다는 것이다. 즉, 파라미터로 객체를 전달하지 않아도 한 쓰레드로 실행되는 코드가 동일한 객체를 참조할 수 있게 된다.

ThreadLocal의 활용

ThreadLocal은 한 쓰레드에서 실행되는 코드가 동일한 객체를 사용할 수 있도록 해 주기 때문에 쓰레드와 관련된 코드에서 파라미터를 사용하지 않고 객체를 전파하기 위한 용도로 주로 사용되며, 주요 용도는 다음과 같다.

  • 사용자 인증정보 전파 - Spring Security에서는 ThreadLocal을 이용해서 사용자 인증 정보를 전파한다.
  • 트랜잭션 컨텍스트 전파 - 트랜잭션 매니저는 트랜잭션 컨텍스트를 전파하는 데 ThreadLocal을 사용한다.
  • 쓰레드에 안전해야 하는 데이터 보관

이 외에도 쓰레드 기준으로 동작해야 하는 기능을 구현할 때 ThreadLocal을 유용하게 사용할 수 있다.

ThreadLocal 사용시 주의 사항

쓰레드 풀 환경에서 ThreadLocal을 사용하는 경우 ThreadLocal 변수에 보관된 데이터의 사용이 끝나면 반드시 해당 데이터를 삭제해 주어야 한다. 그렇지 않을 경우 재사용되는 쓰레드가 올바르지 않은 데이터를 참조할 수 있다.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 협객 2009.05.24 22:37 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요 ^^
    너무 좋은 글이라 제 카페에 펌질을 하였습니다. ^^;
    출처는 밝혔습니다만 혹시 문제되시면 말씀해주세요.
    감사합니다.

  2. 제주소년 2009.09.01 14:05 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 감사합니다 ^^

  3. Hugh 2010.02.04 18:41 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 글 감사합니다. 이게 정확하게 어떻게 쓰는지 몰라서 고민하고 있던 찰라에 명확한 해법이네요.

  4. JG 2010.10.08 16:02 신고  댓글주소  수정/삭제  댓글쓰기

    깔끔한 해석과 정리. 감사합니다. 많은 도움이 되었습니다.

  5. nnoonn 2011.07.14 10:37 신고  댓글주소  수정/삭제  댓글쓰기

    글이 너무 좋아서 제 블로그에 퍼갈게요~~
    제 블로그 주소 : http://blog.naver.com/iukim21c
    혹 안된다면 멜이나 댓글남겨주시면 바로 처리 할게요~~

  6. 파랑보석 2011.11.08 11:33 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 정보 주셔서 감사합니다.
    출처와 이름 밝히고 펌해 갑니다.^^

  7. 서향 2012.05.03 10:08 신고  댓글주소  수정/삭제  댓글쓰기

    좋은정보 잘 보고 갑니다.

  8. 주한길 2013.01.28 13:03 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 출처를 표시하고 블로그에 담아갑니다. ^^

  9. me 2013.02.08 15:10 신고  댓글주소  수정/삭제  댓글쓰기

    이해가 잘되는 글이네요! 블로그에 퍼갑니다. 출처와 이름 남길게요~ 감사합니다

  10. bluepoet 2013.05.16 11:43 신고  댓글주소  수정/삭제  댓글쓰기

    저희도 ThreadLocal을 쓰고 있는데 왜 쓰는지도 모르고 쓰고 있었네요.

    좋은 글 감사합니다^^

  11. 방글라 2013.09.23 14:07 신고  댓글주소  수정/삭제  댓글쓰기

    담아갑니다.^^

  12. nklee 2014.04.11 15:36 신고  댓글주소  수정/삭제  댓글쓰기

    한 가지 질문이 있어서 이렇게 글 남깁니다.
    "쓰레드 풀 환경에서 ThreadLocal을 사용하는 경우 ThreadLocal 변수에 보관된 데이터의 사용이 끝나면 반드시 해당 데이터를 삭제해 주어야 한다" <== 현재 tomcat을 기반으로 웹 애플리케이션을 기도하게 되면 tomcat에서 제공해주고 있는 쓰레드 풀을 사용하게 되는데요.

    혹, 이런 경우 remove를 명시적으로 하지 않았을 때 문제가 발생되는 건가요?

    • 최범균 madvirus 2014.04.14 12:37 신고  댓글주소  수정/삭제

      예를 들어, ThreadLocal에 현재 로그인 한 사용자 정보 user1 객체를 보관했다고 해 보죠. 만약 remove를 하지 않는다면, 이후 같은 쓰레드를 사용해서 처리하는 웹브라우저의 요청은 마치 user1이 요청한 것처럼 처리될 수 있습니다.

  13. 천기자 2018.03.07 13:38 신고  댓글주소  수정/삭제  댓글쓰기

    위 예제를 실제로 코딩하려면 어떻게 해야하나요?
    main() 안에 그대로 적으면 되요?

  14. tester 2018.04.17 16:51 신고  댓글주소  수정/삭제  댓글쓰기

    와우.. 정말 잘 정리되었네요

    감사합니다 : )

  15. 먹튀 검증 2018.08.02 14:04 신고  댓글주소  수정/삭제  댓글쓰기

    잘보고갑니다~

최근 자바캔의 컨텐츠를 티스토리로 옮기는 준비를 할 때, 컨텐츠를 옮기는 대신 티스토리 블로그의 RSS 피드를 읽어와 자바캔 메인 화면에 보여줌으로써 컨텐츠를 쉽게 접근할 수 있게 하자는 계획을 세웠다. RSS 피드를 읽어와 사용하기 쉬운 형태로 변경해주는 라이브러리를 찾다가 'ROME'라는 오픈 소스 솔루션을 발견하게 되었다. RSS와 Atom의 다양한 버전을 지원할 뿐만 아니라 사용법도 매우 간단해서 간단하게 원하는 기능을 구현할 수 있었다.

먼저 ROME(RSS and atOM utilitiEs for Java)는 https://rome.dev.java.net/ 사이트에서 다운로드 받을 수 있다. 아직 정식 1.0 버전이 출시된 건 아니면, 이 글을 쓰는 시점에서 현재 버전은 1.0-RC1이다. rome-1.0RC1.zip 파일을 다운로드 받은 뒤 압축을 풀면 rome-1.0RC1.jar 파일이 생성되는 데 이 파일을 클래스패스에 추가해주면 된다. 또한, JDOM 1.0 버전을 사용하고 있기 때문에 JDOM 관련 라이브러리도 추가적으로 등록해 주어야 한다.

ROME를 이용한 피드 읽기

ROME가 제공하는 API를 이용해서 피드를 읽어오는 코드는 아래와 같다.

String rssUrl = "http://javacan.tistory.com/rss";
URL feedUrl = new URL(rssUrl);
try {
    SyndFeedInput input = new SyndFeedInput();
    SyndFeed feed = input.build(new XmlReader(feedUrl));

    System.out.println("RSS title: " + feed.getTitle());
    System.out.println("RSS author: " + feed.getAuthor());

    List entries = feed.getEntries();
    for (int i = 0; i < entries.size(); i++) {
        SyndEntry entry = (SyndEntry) entries.get(i);
        System.out.println("--- Entry " + i);
        System.out.println(entry.getTitle());
        System.out.println(entry.getAuthor());
        System.out.println(entry.getDescription().getValue());
        System.out.println(entry.getLink());
    }
} catch (IllegalArgumentException e) {
    // ...
} catch (FeedException e) {
    // ...
} catch (IOException e) {
    // ...
}

SyndFeedInput은 RSS 피드로부터 XML 데이터를 읽어와 피드 내용을 추상화한 SyndFeed 인스턴스를 리턴한다. SyndFeed는 내부적으로 개별 로그 정보를 담고 있는 SyndEntry 목록을 저장하고 있어서, 개별 로그의 제목이나 내용에 접근할 수 있다. 이 두 클래스가 제공하는 정보는 API 문서(https://rome.dev.java.net/apidocs/1_0/overview-summary.html)를 참고하기 바란다.

ROME가 지원하는 피드 버전

  • RSS 0.90, RSS 0.91 Netscape, RSS 0.91 Userland
  • RSS 0.92, RSS 0.93, RSS 0.94
  • RSS 1.0
  • RSS 2.0
  • Atom 0.3
  • Atom 1.0


관련 링크:

 

Posted by 최범균 madvirus

댓글을 달아 주세요

  1. soulfree 2009.02.23 02:38 신고  댓글주소  수정/삭제  댓글쓰기

    비슷한 기술이 많이 연구되는지 찾다가 오게되었습니다.
    졸업논문 관련으로 소개해주신 라이브러리를 쓸거같아서 제 블로그에 링크해두었는데 혹시 실례가 되는게 아닌지 모르겠네요^^;;