semtax의 개발 일지

디자인패턴 공부내용 정리 : 리액터 패턴 본문

개발/Java

디자인패턴 공부내용 정리 : 리액터 패턴

semtax 2020. 5. 18. 23:55
반응형

개요

이번에는, 이벤트 핸들링을 하는 디자인 패턴 중 하나인 리액터 패턴에 대해서 알아보도록 하겠습니다.

왜 나오게 됬는가?

시스템에서 여러 종류의 이벤트를 동시에 동기적으로 처리하게 될때 어떻게 해야하는지 고민을 하다 나오게 된 패턴입니다.

문제 상황

먼저 아래와 같은 상황을 가정해보도록 하겠습니다.

당신이 만약 IoT 센서 데이터를 처리하는 서버를 만든다고 가정을 해봅시다.

이때 센서 데이터들은 언제 들어올지 불확실 하므로 이벤트 드리븐 기반의 서버를 작성해야 한다고 판단하였습니다.

일단 당신은, 단순하게 무한 루프를 만들고 센서 데이터에 관한 이벤트가 왔나 안왔나 폴링(이벤트가 왔나 안왔나 계속 체크하면서 이벤트가 오면 이벤트에 관한 코드를 실행하게 하기)하는 방식으로 구현하였습니다.

하지만, 센서의 종류가 너무나도 늘어나면서 각 센서 마다 처리해야 하는 코드를 추가하는게 너무 고역스러운 상황에 놓이게 되었습니다.

이럴때 당신은 어떠한 선택을 하실건가요?

다음으로 아래와 같은 상황도 가정 할 수 있습니다.

만약 우리가 GUI로 작동하는 테트리스 게임을 만든다고 가정을 해봅시다.

이때 사용자의 키보드나 마우스 입력을 받기 위해서는 키보드 혹은 마우스 이벤트를 받아야 하는 상황이 필연적으로 발생하게 됩니다.

일단 당신은, 단순하게 무한 루프를 만들고 이벤트를 폴링(이벤트가 왔나 안왔나 계속 체크하면서 이벤트가 오면 이벤트에 관한 코드를 실행하게 하기)하는 방식으로 구현하였습니다.

하지만, 게임이 복잡해지면서 추가해야될 이벤트의 개수가 너무 많아지게 되고 코드를 추가하는게 너무 고역스러운 상황에 놓이게 되었습니다.

이때 당신은 어떤 선택을 하실건가요?

또한 위의 있는 문제들을 효율적으로 해결하기 위해서는 아래 4가지 상황 역시 지켜야 합니다.

주의해야 될 사항은 아래와 같습니다.

  1. 이벤트 핸들러의 작업이 Blocking인 작업이면 절대로 안됩니다.(속도가 엄청나게 느려짐)
  2. 불필요한 동기화 로직이나 Context switching, 발생하는 경우 처리율이 엄청 떨어지므로 이 2가지 사항을 가급적 피해야 합니다.
  3. 새로운 이벤트 핸들러를 쉽게 추가할 수 있어야 합니다.
  4. 복잡한 멀티스레딩이나, 동기화 코드에 영향을 최대한 안받게 구현해야 합니다.

이제 그러면 위의 문제들을 어떻게 해결해야 하는지 알아봅시다.

해결법

해결방법은, 중간에 Dispatcher 라는 객체를 하나 둬서, 이벤트 요청을 미리 매핑된 이벤트 핸들러에 던져버리고, 실제 이벤트에 대한 처리는 이벤트 핸들러가 하도록 외주(?)를 줘버리면 됩니다.

이떄, 이러한 이벤트 핸들러 매핑 작업과 디스패치 객체를 호출하는 수행하는 객체를 리액터(Reactor) 객체라고 합니다.

좀더 구체적으로 설명하면, 아래와 같은 방식으로 처리가 이루어 집니다.

  1. 먼저 리액터가 시작되기 전에, 사용자가 이벤트 핸들러를 리액터에 등록합니다.
  2. 리액터에서, 사용자의 연결(또는 요청)을 받아서 연결을 demultiplexing 시켜주고 해당 연결을 그에 맞는 핸들러와 매핑(연결)시켜 줍니다.
  3. 이제 실제로 Dispatch 객체에서 dispatch 함수가 실행되면서 사용자의 요청을 받아 이벤트 핸들러가 실행되게 됩니다.

그림으로 나타내면 아래와 같습니다.

이제 실제로 Reactor 패턴을 구현해보도록 하겠습니다.

구현

먼저, 구현에 앞서 Reactor 패턴의 대략적인 구조 및 요소들을 알아봅시다.

먼저 Reactor 패턴은 아래 객체 들로 구성 되어 있습니다.

  1. Reactor : 실제 이벤트를 등록/삭제하고 사용자의 요청을 Dispatcher로 넘겨주는 역할을 하는 객체
  2. Dispatcher : 실제 사용자의 요청을 받아서 이벤트 핸들러에 넘겨주는 해주는 객체
  3. Event Handler : 실제 사용자에게 요청을 받아서 처리하는 객체
  4. Event Handler Map : 이벤트에 대응하는 이벤트 핸들러를 가지고 있는 컨테이너 객체

그러면 실제로 코드로 구현을 해보도록 합시다.

먼저 아래와 같이 Reactor 객체를 만들어 줍시다.

package main.java.reactorexample;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.UnknownHostException;

public class Reactor {

    private ServerSocket serverSocket;
    private HandleMap handleMap;

    public Reactor(int port) {
        handleMap = new HandleMap();
        try {
            serverSocket = new ServerSocket(port);
        } catch(IOException e) {
            e.printStackTrace();
        }
    }

    public void startServer(){
        Dispatcher dispatcher = new Dispatcher();

        while(true) {
            dispatcher.dispatch(serverSocket, handleMap);
        }
    }

    public void registerHandler(EventHandler handler) {
        handleMap.put(handler.getHandler(),handler);
    }

    public void removeHandler(EventHandler handler) {
        handleMap.remove(handler.getHandler(), handler);
    }
}

위에서 설명했다시피, 리액터 객체는 이벤트 핸들러를 등록/삭제 하는 역할 및 사용자에게 받은 요청을 Dispatcher로 넘겨주는 역할을 수행합니다.

다음으로, Dispatcher 객체를 만들어 줍시다.

package main.java.reactorexample;


import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class Dispatcher {

    private final int HEADER_SIZE = 6;

    public void dispatch(ServerSocket serverSocket, HandleMap handleMap) {
        try{
            Socket socket = serverSocket.accept();
            demultiplex(socket, handleMap);
        }catch (IOException e) {
            e.printStackTrace();
        }
    }

    public void demultiplex(Socket socket, HandleMap handleMap) {
        try {
            InputStream inputStream = socket.getInputStream();

            byte[] buffer = new byte[HEADER_SIZE];
            inputStream.read(buffer);
            String header = new String(buffer);

            handleMap.get(header).handleEvent(inputStream);

        } catch(IOException e) {
            e.printStackTrace();
        }
    }
}

Dispatcher 객체는, 사용자(소켓)으로 부터 요청을 받아서, 사용자의 요청(= 이벤트 종류)에 대응되는 핸들러를 호출하는 역할을 합니다.

해당 예제에서는, demultiplex 함수에서, 사용자의 이벤트 요청에 대응되는 핸들러를 호출하는 역할을 수행합니다.

다음으로, Handler Map 객체를 보도록 하겠습니다.

package main.java.reactorexample;

import java.util.HashMap;

public class HandleMap extends HashMap<String, EventHandler> {


}

간단하게, 문자열(이벤트 코드)를 키로 하고, 거기에 대응되는 이벤트 핸들러를 꺼낼 수 있게 HashMap으로 구현하였습니다.

다음으로, EventHandler 인터페이스 입니다.

package main.java.reactorexample;

import java.io.InputStream;

public interface EventHandler {
    public String getHandler();
    public void handleEvent(InputStream inputStream);
}

이벤트 핸들러는, 실제로 이벤트를 처리하는 handleEvent 메서드와, 해당 핸들러가 어떠한 이벤트 코드에 해당하는지를 알려주는 getHandler() 메소드로 구성되어 있습니다.

다음으로, 실제 EventHandler를 구현한 실제 구현체들을 짜보도록 하겠습니다.

package main.java.reactorexample;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.StringTokenizer;

public class LGDeviceEventHandler implements EventHandler {

    private static final int DATA_SIZE = 1024;
    private static final int TOKEN_NUM = 5;

    @Override
    public String getHandler() {
        return "0x6001";
    }

    public void handleEvent(InputStream inputStream) {
        try {
            byte[] buffer = new byte[DATA_SIZE];

            inputStream.read(buffer);
            String data = new String(buffer, StandardCharsets.UTF_8);

            String[] params = new String[TOKEN_NUM];
            StringTokenizer token = new StringTokenizer(data, "|");

            int i = 0;
            while(token.hasMoreTokens()) {
                params[i] = token.nextToken();
                ++i;
            }
            processDeviceInfo(params);
        }catch(IOException e){
            e.printStackTrace();
        }
    }

    private void processDeviceInfo(String[] params) {
        System.out.println("LG Device : " + params[0]
            + "\n Device No : " + params[1]
            + "\n Device Protocol " + params[2]
            + "\n Device Name " + params[3]
            + "\n Device Network " + params[4]);
    }
}

package main.java.reactorexample;

import java.io.IOException;
import java.io.InputStream;
import java.util.StringTokenizer;

public class SamsungDeviceEventHandler implements EventHandler {

    private static final int DATA_SIZE = 512;
    private static final int TOKEN_NUM = 2;

    @Override
    public String getHandler() {
        return "0x5001";
    }

    public void handleEvent(InputStream inputStream) {

        try {
            byte[] buffer = new byte[DATA_SIZE];
            inputStream.read(buffer);
            String data = new String(buffer);
            String[] params = new String[TOKEN_NUM];
            StringTokenizer token = new StringTokenizer(data, "|");

            int i = 0;
            while(token.hasMoreTokens()) {
                params[i] = token.nextToken();
                ++i;
            }

            processDeviceInfo(params);
        }catch(IOException e){
            e.printStackTrace();
        }

    }

    protected void processDeviceInfo(String[] params) {
        System.out.println("Samsung Device : " + params[0] + "\nDevice No : " + params[1]);
    }
}

마지막으로 Main 함수 코드입니다.

package main.java;

import main.java.reactorexample.Dispatcher;
import main.java.reactorexample.LGDeviceEventHandler;
import main.java.reactorexample.Reactor;
import main.java.reactorexample.SamsungDeviceEventHandler;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.UnknownHostException;

public class ReactorExample {

    public static void main(String[] args) {
        int port = 5000;

        Reactor reactor = new Reactor(port);

        reactor.registerHandler(new SamsungDeviceEventHandler());
        reactor.registerHandler(new LGDeviceEventHandler());

        reactor.startServer();
    }
}

위와 같이 구현 함으로써, 이전의 브로커 패턴에 비해 더욱 깔끔한 구조로 바뀌었습니다.

특히, switch 문에 이벤트 핸들러를 덕지덕지 추가해야되서 의존성이 너무 높은 구조였지만, 해당 방식으로 구현 함으로써 더욱 깔끔하게 구현이 가능해졌습니다.

예시

해당 패턴을 적용한 가장 대표적인 프레임워크가 있습니다. 바로 Netty가 그 대표적인 예시입니다.

Netty는 자바진영에서 가장 많이 쓰는 대표적인 비동기 네트워크 프레임워크입니다. 특히 트위터나 다른 유수의 대기업에서도 많이 사용하고 있으며, 스프링 진영에서도 많이 사용하고 있습니다.

보통 아래와 같은 방식으로 코드가 작성됩니다.

public final class DiscardServer {
    public static void main(String[] args) throws Exception {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .handler(new LoggingHandler(LogLevel.INFO))
                .childHandler(new DiscardServerHandler());
            ChannelFuture f = b.bind(8010).sync();
            System.err.println("Ready for 0.0.0.0:8010");
            f.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}

class DiscardServerHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(C..H..Context ctx, Object msg) throws Exception {
        ByteBuf buf = (ByteBuf) msg;
        try {
            // discard
        } finally {
            buf.release(); // 이 부분은 두번째 시간에 설명합니다.
        }
    }
    @Override
    public void exceptionCaught(C..H..Context ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

Netty도 위의 구현 예제와 유사하게 실제 요청을 처리하는 "Handler", Dispatcher에 해당하는 이벤트 루프, Reactor에 해당하는 "ServerBootstrap" 으로 구성 되어있습니다.

(사실, Netty와 같은 경우 Reactor 패턴 이외에 다른 패턴도 같이 섞어서 사용하고 있습니다. 해당 패턴은 다음 포스팅에서 다룰 예정입니다.)

Netty 이외에도 많은 비동기 프레임워크(node.js) 들이 해당 패턴을 알게 모르게 쓰고 있습니다.

위의 구현 에서의 문제점

사실, 문제 상황에서 언급한 3번문제(Event Handler를 추가하기 어려운 문제)는 회피 하였습니다.

하지만, 해당 패턴을 사용한다고 해서 1번에 대한 문제점을 완벽하게 해결한 것은 아닙니다.

이를 위해서는 다른 방법이 필요합니다. 바로 Proactor라는 패턴을 사용해서 해결이 가능합니다.

다음 시간에는 Proactor 패턴에 대해서 알아보도록 하겠습니다.

출처

  1. Pattern Oriented Software Architecture Vol 1.
  2. SW마에스트로 10기 아키텍처 수업 교육자료
  3. https://www.slideshare.net/ConversionMeetup/dejan-pekter-nordeus-reactor-design-pattern
반응형
Comments