Skip to main content Link Menu Expand (external link) Document Search Copy Copied

본문은 아래의 Reference 들을 번역하고 정리한 글입니다.

Reference

1. NIO(New Input/Output)

Java NIO 는 크게 3가지 컴포넌트로 이루어져 있습니다.

  • Channels
  • Buffers
  • Selectors

추가적으로 Pipe, FileLock 과 같은 컴포넌트또한 존재하지만, 대표적인 컴포넌트는 위의 3가지라고 생각하시면 될 것 같습니다.

1.1 Channels and Buffers

img

NIO 는 채널로부터 시작하게 됩니다. 채널은 Stream 으로써, 해당 채널을 통해 Buffer에 값을 저장할 수 있는 것이죠. 반대로 Buffer에서 채널로 값을 쓰는것 또한 가능합니다.

1.1.1 Channels

이러한 채널은 여러 형식이 존재합니다.

  • FileChannel : 파일에 데이터 쓰기/읽기
  • DatagramChannel : UDP I/O 관리
  • SocketChannel : TCP I/O 관리
  • ServerSocketChannel : 대표적으로 서버에서 사용하는 것이죠. TCP 연결요청이 오면 이 채널에서 듣고, SocketChannel 을 만들어줍니다

위로 미루어보아 1) UDP/TCP 네트워크 IO와 2) file IO 를 바로 NIO 가 도와주게 되죠.

1.1.2 Buffers

버퍼또한 여러 형식이 존재합니다.

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

1.2 Selectors

img

Selector 은 여러 채널들을 싱글 스레드로 관리할 수 있도록 도와줍니다.

예를 들어 채팅 서버를 가정해볼까요? 채팅서버는 많은 수의 socket을 필요로 합니다. 실시간으로 채팅을 주고받아야 하기 때문이죠. 그렇다면 많은 채널(SocketChannel)이 열린 상태일 것이고, 해당 채널마다 각각의 스레드가 할당되면 매우매우 많은 스레드가 동시에 돌아가야겠죠? 이는 굉장히 비효율적이고 자원소모적인 과정입니다. 이를 해결하기 위해서 NIO는 Selector을 만들어 싱글 스레드로 여러 채널들을 관리할 수 있도록 하였습니다!

자, 종합하면 NIO 는 채널과 버퍼, 셀렉터를 통해 네트워크와 스토리지 입출력을 관리하도록 도와주는 Java 객체로 이해하였습니다.

2. Reactor Pattern

Reactor Pattern 은 하나 이상의 클라이언트로 부터의 I/O 요청을 동시처리하기 위해서 사용하는 패턴입니다. 해당 패턴을 알기에 앞서, 이전에 우리가 사용하던 패턴을 알아보겠습니다.

2.1 Tranditional Way of Handling Connection

img

보통 우리가 옛날 Tomcat을 사용하면 주로 이방식을 사용하죠. 이러한 처리방식의 문제점은 클라이언트가 늘어날 수록, 스레드가 부족하다는 점입니다. handler 마다 하나의 스레드를 잡아먹기 때문이죠. 즉, 하나의 클라이언트 별 하나의 스레드가 배정되었기 때문에 스레드 제한이 걸릴 것이고, bottleneck 이 생길 수 밖에 없습니다. 스레드 풀로 이를 완화시키긴 했지만, 여전히 스레드 제한에 걸리는 것은 피할 수 없게 됩니다. 그래서 이걸 어떻게 하면 해결할 수 있을까를 고민하게 되고 아래와 같이 Reactor 모델 이라는 것을 만들어서 아키텍처가 나왔습니다.

Reactor 모델 은 이벤트 드리븐 모델입니다. 서버가 여러개의 inbound 요청을 수신하고 이를 처리하는 핸들러와 스레드에 매핑시켜주는 모델이죠. Reactor 모델은 다른말로 Dispatcher mode 라고도 불리는데요. 이유는 I/O 멀티플렉싱을 지원하기 때문입니다. Reactor 모델의 메인 컴포넌트는 Reactor/Handler 입니다.

1) Reactor: 여러 스레드에서 동작하며, 이벤트들을 딱 맞는 핸들러에 보내주는 역할을 수행합니다. 쉽게 말하면 고객 상담센터 연결원 이라고 볼 수 있겠네요. 소비자에게 전화를 받아서 공급자에게 전달할 지 정하는 중간 매개자 역할이죠.

2) Handler: 클라이언트 요청(이벤트)을 읽고/비즈니스 로직을 수행하는 컴포넌트입니다. 쉽게 말하면, 위에서 말한 예시의 공급자 역할을 수행한다고 볼 수 있겠죠?

Reactor 모델은 리엑터 개수와 스레드 개수에 따라 3가지로 설명할 수 있습니다.

  1. Single-thread model (one reactor and one thread)
  2. Multi-thread model (one reactor and multiple threads)
  3. Master-slave Reactor model (multiple reactors and threads)

2.2 Single-Thread Model (one reactor and one thread)

img

위의 컴포넌트들을 설명해볼까요?

  • Dispatcher: Reactor 내부에서 multiplexer/selector를 관리하는 모듈입니다. 이벤트가 수신되면 Acceptor가 등록한 핸들러에 매핑시켜주는 역할을 수행합니다.
  • Acceptor: 클라이언트가 연결 이벤트를 전송하면 Acceptor이 수신해서 여러 타입의 핸들러를 생성하고 Dispatcher에 등록합니다.
  • Handler: 실직적으로 로직을 수행하는 컴포넌트로, read-write-ready 이벤트를 처리합니다. 얘는 read, decode, process, encode, send, etc. 들을 수행하게 됩니다.

하지만 Handler 가 block 되면 모든 handler와 acceptor이 중지되는 취약점이 존재합니다. 그래서 보통 위의 모델을 사용하려면 빠르게 I/O를 하기위해 Redis로 인메모리 관리해야되죠.

2.3 Multi-Thread Model (one reactor and multiple threads)

img

메인 스레드에서 하나의 Reactor 는 하나의 Selector 을 통해 이벤트를 수신받고 Handler 에 이벤트를 넘겨줍니다. Acceptor 은 이벤트를 수신받을 때, Handler 를 생성하게 됩니다. 이 Handler는 오직 데이터를 읽고 쓰기만 하고, thread pool의 NIO worker thread 는 실제 비즈니스 로직을 수행하게 되죠. worker thread 는 로직 수행 이후 메인 스레드(Reactor-Handler)에 결과를 보내주고, 메인스레드의 Handler 는 클라이언트에게 결과를 Output 하게 됩니다.

종합하면 아래와 같은 순서를 거치겠군요.

  1. 클라이언트 요청
  2. Acceptor는 요청에 맞는 Handler 생성 후, Reactor에 저장
  3. Reactor은 클라이언트 요청을 Acceptor이 보내준 Handler에 매핑
  4. Handler는 클라이언트 요청을 read 하고, Thread Pool 에서 하나 꺼내서 NIO Worker Thread로써 실제 요청 비즈니스 로직 처리
  5. Worker Thread는 반환된 결과를 자신을 부른 Reactor의 Handler에게 전송
  6. Handler는 클라이언트에게 write

하지만 worker thread가 처리하는 비즈니스 로직이 너무 긴 시간이 걸린다면 어떤 문제가 발생할까요?

Reactor의 Handler를 클라이언트 요청에 따라 만들어주는 Acceptor에 bottleneck이 걸릴 거에요. Acceptor는 메인 스레드로만 동작하기 때문이죠! 뿐만 아니라 메인 스레드의 Handler들 또한 단일 스레드로만 동작하기 때문에 네트워크 입출력에서 병목이 발생합니다. 즉 비즈니스 로직을 처리하는 부분은 빠를 지 몰라도, 여전히 네트워크 연결 관리 측면에서 병목이 발생할 수 밖에 없다는 것입니다.

2.4 Master-slave Reactor model (multiple reactors and threads)

img

마스터-슬레이브 Reactor 모델은 위의 Multi-Thread Model 와 매우 유사합니다. 단! Acceptor과 Reactor 이 Multi-Thread인 점과 Reactor가 main과 sub로 나뉘어진것 빼곤 말이죠.

다수의 Sub-Reactor 은 각자 자신만의 selector, thread pool, dispatcher을 가지고 있습니다.

selector 는 다시한번더 정리하자면, 하나의 스레드가 여러개의 채널을 관리하도록 도와주는 녀석입니다. dispatcher 은 클라이언트 요청과 handler 를 매핑해주는 역할을 수행하구요.

Acceptor 은 Handler를 생성해서 Sub-Reactor 에다가 등록해줍니다. 그리고 하나의 Main-Reactor 은 모든 연결들을 관리해주는 역할입니다.

그럼 이제 클라이언트 요청시 내부 로직 순서를 정리해보겠습니다.

  1. 클라이언트 요청
  2. Acceptor는 요청에 맞는 Handler 생성 후, Sub-Reactor에 저장 (multi-thread)
  3. Main-Reactor은 클라이언트 요청을 Sub-Reactor의 Handler에 link
  4. Sub-Reactor의 Handler는 클라이언트 요청을 read 하고, Thread Pool 에서 하나 꺼내서 NIO Worker Thread로써 실제 요청 비즈니스 로직 처리
  5. Worker Thread는 반환된 결과를 자신을 부른 Sub-Reactor의 Handler에게 전송
  6. Handler는 클라이언트에게 write

자! 이렇게 되면, 하나의 클라이언트의 요청은 하나의 Sub-Reactor에 자연스럽게 묶이며(Main-Reactor의 link로 인해), Sub-Reactor은 단일 스레드로 네트워크 I/O를 진행하게 됩니다. 따라서 하나의 요청에 대한 state나 context와 같은 부가 정보들이 보장될 수 있게 되죠. 이전 Multi-Thread Model 에서 관찰했던 네트워크 I/O 병목현상은 여러 Sub-Reactor 과 Acceptor을 통해 해소되었습니다.

이제 Reactor I/O 모델이 끝났어요! 다음 포스팅은 이러한 리엑터 I/O 패턴을 활용한 Netty를 알아보겠습니다.