Proactor Pattern
지난 포스팅에서 Selector와 Reactor 패턴에 대해 다뤘습니다.
또한, Reactor 패턴을 활용하여 I/O Multiplexing이 가능한 EventLoop를 구현해 보았습니다. 하지만 Reactor 패턴에는 몇 가지 한계점이 존재합니다. 이번 글에서는 이러한 한계점과 그것을 극복한 Proactor 패턴에 대해 살펴보겠습니다.
Reactor vs Proactor
Reactor 패턴은 단일 쓰레드로 동기적으로 이벤트가 발생할 때마다 Selector가 이를 처리해야 합니다. 이로 인해, 트래픽이 과도하거나 이벤트 처리에 시간이 오래 걸리는 경우, 전체 시스템에 영향을 줄 수 있는 문제가 발생할 수 있습니다.
반면에, Proactor 패턴은 비동기적으로 I/O 작업을 처리합니다. I/O 이벤트를 시스템(Kernel)에 등록한 후, 이벤트가 완료되면 시스템이 자동으로 이를 애플리케이션에 전달합니다. 이후, 시스템은 등록된 **콜백(callback)**을 실행하여 I/O 이벤트를 처리합니다. 이 방식은 Reactor 패턴처럼 특정 부분에서 발생할 수 있는 병목 현상을 피할 수 있으며, I/O 이벤트 완료를 전달하는 주체가 Selector(Reactor)에서 시스템(Kernel)로 바뀌기 때문에, 시스템의 효율성이 크게 향상됩니다.
따라서, Reactor 패턴에서는 이벤트를 처리하는 주체가 Selector이고, Proactor 패턴에서는 시스템(Kernel)이 직접 I/O 이벤트 완료를 처리해 주므로, 후속 작업 처리에서 병목 현상이 발생하지 않게 됩니다.
I/O 작업을 비동기적으로 처리하는 방법을 이해하려면, Java AIO(Asynchronous I/O)에 대해 살펴봐야 합니다.
Java AIO 란?
Java 1.4에서 추가된 Java NIO는 기본적인 I/O 작업을 처리할 수 있게 했습니다. 이후, Java 1.7부터는 NIO 2가 도입되면서 비동기적인 I/O 작업을 처리할 수 있는 기능이 추가되었습니다. 특히, NIO 2에서는 AsynchronousChannel 인터페이스를 지원하며, 이를 통해 callback과 Future를 사용하여 비동기 I/O 작업을 효율적으로 처리할 수 있습니다.
Java NIO 2는 비동기 I/O를 지원하므로 Java AIO라고 불리기도 합니다.
Java AIO는 callback 방식으로 구현했을 때에만 Non-Blocking하게 동작합니다.
CompletionHandler
CompletionHandler는 Java AIO에서 제공하는 비동기 I/O 작업을 처리하는 데 사용되는 callback 인터페이스입니다.
이 인터페이스는 작업이 성공적으로 완료 후 호출되는 completed() 메서드와 작업이 실패했을 때 호출되는 failed() 메서드를 제공합니다. V 인자는 비동기 작업이 성공적으로 완료된 결과를 의미하며, A 인자는 추가적으로 첨부된 사용자 정의 객체를 의미합니다.
테스트 코드
@SneakyThrows
public static void main(String[] args) {
new Proactor(8080).run();
Thread.sleep(Long.MAX_VALUE);
}
public class Proactor implements Runnable {
private final AsynchronousServerSocketChannel serverChannel;
@SneakyThrows
public Proactor(int port) {
this.serverChannel = AsynchronousServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress("localhost", port));
}
@Override
public void run() {
serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
@Override
public void completed(AsynchronousSocketChannel clientChannel, Void attachment) {
var requestBuffer = ByteBuffer.allocateDirect(1024);
clientChannel.read(requestBuffer, null, new CompletionHandler<Integer, Void>() {
@Override
@SneakyThrows
public void completed(Integer result, Void attachment) {
requestBuffer.flip();
var request = StandardCharsets.UTF_8.decode(requestBuffer);
log.info("request: {}", request);
var response = "This is server.";
var responseBuffer = ByteBuffer.wrap(response.getBytes());
clientChannel.write(responseBuffer);
clientChannel.close();
log.info("close clientChannel");
}
@Override
public void failed(Throwable exc, Void attachment) { }
});
}
@Override
public void failed(Throwable exc, Void attachment) { }
});
}
}
위와 같은 방식으로 Proactor 패턴을 간단하게 구현할 수 있습니다.
AsynchronousServerSocketChannel의 accept() 메서드를 호출하여 클라이언트 연결을 비동기적으로 수락하고, 연결 수락 후 수행할 Acceptor 역할을 하는 CompletionHandler를 익명 클래스로 구현하여 인자로 전달합니다.
그 후, accept()의 콜백 함수에서 반환된 AsynchronousSocketChannel을 completed() 메서드의 인자로 받아 read() 메서드를 호출합니다. 읽은 데이터를 처리할 Handler 역할을 하는 또 다른 CompletionHandler를 익명 클래스로 구현하여 인자로 전달합니다.
read()의 콜백 함수에서 반환된 읽은 byte 수를 completed() 메서드의 인자로 받아서, 응답을 생성한 후, AsynchronousSocketChannel에 write()를 호출하여 데이터를 클라이언트에 전송하도록 구현합니다.
마치며
이번 글에서는 Reactor 패턴의 단점을 보완한 Proactor 패턴에 대해 살펴보았습니다.
하지만 모든 아키텍처가 그렇듯, Proactor 패턴 역시 단점이 존재합니다. I/O 작업의 완료를 운영 체제에 의존하여 처리하기 때문에 플랫폼 간 호환성 문제가 발생할 수 있습니다. 또한, 콜백 방식으로 처리되기 때문에 여러 단계의 비동기 작업을 진행할 때 콜백 지옥에 빠질 위험이 있을 수 있습니다.
이러한 점들을 잘 고려하고, 상황에 맞게 적절한 패턴을 선택하는 것이 중요한 것 같습니다.
'Java' 카테고리의 다른 글
[Java] Multiplexer가 뭔가요? (0) | 2025.02.11 |
---|---|
[Java] CompletableFuture 란? (0) | 2025.02.11 |
[Java] 동기와 블로킹, 뭐가 다른가요? (0) | 2025.02.10 |