01. 셀렉터 개요
NIO에서 비블록킹 서버 구현의 밑바탕이 되는 것은 Reactor 패턴이다. 이벤트 중심의 애플리케이션이 하나 이상의 클라이언트로부터 하나의 애플리케이션으로 동시에 전달되는 서비스 요청들을 나눠 각 요청에 상응하는 서비스 제공자에게 구별해서 보내준다. 좀더 자세하게 설명하면 클라이언트들의 모든 요청을 우선 앞단의 큐에 저장하고 큐를 모니터링 하는 스레드에 이벤트를 보낸다. 그러면 큐를 모니터링 하는 스레드는 큐에 저장된 요청의 방향을 분석해서 적절한 프로세스 로직으로 보내줘 해당 요청이 처리되게 해주는 것이 바로 Reactor 패턴이다.
Selector는 바로 Reactor의 역할을 한다. 즉, 여러 SelectableChannel을 자신에게 등록하게 하고 등록된 SelectableChannel의 이벤트 요청들을 나눠서 적절한 서비스 제공자에게 보내 처리하는 것이다. 즉 멀티플렉스 IO를 가능하게 한다.
멀티플렉스 IO는 단 하나의 스레드로 동시에 많은 IO 채널들을 효율적으로 관리할 수 있게 해주어 기존의 멀티스레드를 이용한 네트워크 프로그램에 비해 많은 부분에서 훨씬 유리하다. 즉, 좀더 적은 CPU와 자원을 소모하게 됨으로써 기존의 서버보다 좀더 빠르고 많은 동시 접속자를 수용할 수 있는 확장성 있는 서버를 만들 수 있게 되는 것이다.
( 확장성이란 두 가지 의미를 내포한 단어다. 첫 번째는 어떤 애플리케이션에 기능을 추가하는 것 등이 손쉽게 이루어질 수 있는 것을 말한다. 두 번째, 제한된 CPU, 메모리 등의 자원을 이용해서 최대한의 성능을 내는 것을 말한다. 만약 동일한 컴퓨터에 서버 A는 1000명의 동시 접속자를 수용할 수 있고 서버 B는 5000명의 동시 접속자를 수용할 수 있다면 서버 A 보다 서버 B가 더 확장성 있는 서버라고 할 수 있다.)
유닉스 환경에서 C/C++로 네트워크 프로그래밍을 해본 독자들이라면 Selector를 이용한 멀티플렉스 모델을 쉽게 이해할 수 있을 것이다. 즉, 자바에서 Selector를 사용하는 것과 같이 select(), poll() 시스템 콜을 이용해서 멀티플렉스 모델의 서버를 만들어 봤을 것이기 때문이다.
JDK 1.4 이전에는 자바의 IO가 블록킹 모델이었다. 따라서 네트워크 프로그램을 개발할떄, 많은 구조적 문제점이 있었다. 따라서 우선 기존 IO 모델을 이용한 네트워크 프로그램의 문제점이 무엇인지를 알아보고 이 문제를 해결하기 위해 어떤 방법들이 이용되고 있는지를 살펴보겠다.
02. 기존의 네트워크 프로그래밍 모델
기존의 IO는 블록킹 IO이다. 블록킹 IO란 특정 디바이스에서 읽기, 쓰기(read/write) 작업을 할 때 데이터를 이용할 수 있을떄까지 해당 IO 작업을 수행하려던 스레드가 아무것도 하지않고 대기하는 것을 말한다.
예를 들어, 소켓으로부터 BufferedReader 객체를 얻어 이 스트림으로부터 데이터를 읽어오기 위해 readLine() 메소드를 호출했다고 가정해보자. readLine() 메소드를 호출했을 때 해당 스트림으로부터 개행문자(\n)를 읽을 떄까지 이 스레드는 블록킹 될 것이다.
블록킹을 피할 수 있는 두가지 방법 중 하나는
ServerSocket ss = new ServerSocket(4567);
while(true) {
Socket s = ss.accept();
...
// 동시에 여러 클라이언트들의 요청을 수행하기 위해 별도의 스레드를 만들어서 처리한다.
Service service = new Service(s);
service.start();
...
}
이처럼 멀티 스레드 기반으로 구현하는 것이다. 하지만 이처럼 멀티 스레드 모델은 많은 문제점이 있는데, 우선 해당 서버로 접속하는 클라이언트 수가 많아지면 스레드 개수가 접속된 클라이언트의 수만큼 증가한다는 것이다. 앞서 코드 템플릿을 통해 살펴봤지만 이 구조에서 클라이언트당 하나의 service 스레드를 만들어야만 한다. 따라서 다음과 같은 문제점이 발생한다.
많은 스레드 생성에 따른 스레드 컨텍스트 스위치 부하 서버의 성능과 애플리케이션의 성격에 따라 다르기는 하지만 보통 경량 서버의 경유 약 천여개 정도의 스레드가 생성되면 스레드 컨텍스트 스위치 부하로 인해 급격한 성능 저하가 발생된다.
스레드 자체가 CPU와 고유 스택을 갖는 데 따른 컴퓨터 리소스 부하 모든 스레드는 자신만의 스택 영역을 갖고 스레드는 별도의 실행흐름이므로 CPU를 사용한다.
클라이언트의 빈번한 접속과 종료에 따라 많은 가비지가 생성되는 문제점 서버를 통해 주고받는 데이터는 대부분 일회용이다. 따라서 클라이언트와 데이터를 주고받는 과정에서, 앞서 NIO의 도입부에서 그림을 통해 설명한 것과 같이 JVM 힙 영역으로 데이터의 복사가 빈번하게 이뤄지므로 많은 가비지가 생성된다. 또한 서버에 클라이언트의 연결이 빈번하다면 스레드의 잦은 생성과 소멸에 따른 가비지도 많을 것이다. 이미 알고 있을지 모르겠지만 자바에서 가비지 컬렉터를 실행하는 것은 상당히 느린 작업이다.
클라이언트가 접속할 때마다 매번 스레드를 새로 생성하는 부담 자체 스택을 갖고 있는 스레드를 생성하는 것은 그리 빠른 작업이 아니다. 그런데 그 스레드가 클라이언트와 통신하는 로직까지 들고 있도록 무겁게 설계된 경우에는 더욱더 그럴 것이다.
서버의 메모리가 부족해서 OutOfMemoryException 눈에 보이는 위험성을 가지고 있는 애플리케이션은 결코 좋은 애플리케이션이 아니다. 보통 이런 애플리케이션은 신뢰성이 없다. 예를 들어 보자. 만약, 채팅 서버에 접속해서 친구와 열심히 대화를 하고 있는데 종종 아무런 공지없이 갑자기 서버가 종료된다면 이 채팅서버를 신뢰할 수 없을 것이다. 아마 채팅이 가능한 다른 곳을 알아 볼 것이다.
이처럼 멀티스레드를 이용한 서버는 확장성(scalable)이 없어서 동시 접속자가 많은 대규모의 서버로 사용하기에는 부적합하다. 또한 C/C++로 만든 서버에 비해 상당히 느리다. 보통 상용 애플리케이션에서는 위의 문제점 중 일부를 해결하기 위해서 스레드풀(ThreadPool)을 사용한다.. 보통 스레드 생성시간을 단축시키고 스레드를 재사용 하기 위해서 스레드풀을 이용한다고 생각하지만 가장 중요한 이유는 첫번째와 다섯번째에 있다.
상용 애플리케이션은 신뢰성이 중요하기 떄문에 스레드풀을 이용해서 서버가 수용할 수 있는 제한된 개수만큼 스레드 생성을 제한해서 최악의 상황(OutOfMemoryException)을 예방하는 것이다. 또한 많은 테스트와 튜닝으로 스레드 컨텍스트 스위치로 인해 급격한 성능 저하가 발생하는 지점을 찾아 그 이전 개수만큼으로 스레드 개수를 제한해서 서버가 항상 최적의 상태로 동작하는 것을 보장하게 만드는 것이다. 하지만 스레드풀을 사용한다고 하더라도 확장성(scalable) 문제는 어쩔 수 없다.
그렇지만 많은 전문가들은 다른 방법보다 멀티스레드를 이용하는 것이 개발기간 개발효율 등을 고려했을때 가장 나은 선택이라고 판단해서 서버 프로그래밍을 할때 ㅐ부분 멀티스레드를 이용하는 방법을 사용했다.
그러나 멀티스레드를 포함한 기존 블록킹 IO의 문제를 해결하는 방법 중에서 가장 효율성을 인정받는 방법은 바로 JNI를 이용해서 기존의 C/C++ 개발자들 처럼 Select(), poll() 시스템 콜을 사용하는 것이다. 하지만 이 방법은 개발 난이도가 상당히 높아 쉽게 구현할 수 없다는 점이 문제다. 따라서 많이 사용되지 않는다.
03. 비블록킹 모델
멀티플렉스 모델의 서버를 만들기 위해 핵심적인 역할을 하는 것이 바로 Selector, SelectableChannel, SelectionKey 클래스다. 이 클래스 세 개가 각각 어떤 기능을 수행하고 또 서로 간에 어떻게 협력하는지를 이해하면 멀티플렉스 모델의 서버를 만들기 위한 핵심을 이해하는 것이다.
블록킹 모드의 경우 일반적으로 다음과 같은 코드 템플릿을 이용한다.
try{
byte buffer[] = new byte[4096];
while(true) {
int r = in.read(buffer);
String message = new String(buffer, 0, r);
System.out.println(message);;
}
}catch(IOException ie){}
int r = in.read(buffer)를 실행하는 순간에 읽어들일 데이터가 없다면 이 스레드는 블록킹된다. 따라서 기존에 멀티스레드로 이처럼 블록킹 될 수 있는 부분은 별도의 부분에서 처리하도록 스레드를 이용해 분리하는 방법을 사용했다.
이제 NIO에서 멀티스레드를 사용하지 않고 비블록킹을 사용해서 스레드 단 하나만으로도 멀티스레드 서버와 같은 동작을 할 수 있는지, 그 워크 플로우를 설명한다.
- 이미 생성된 바운드 되어 있는 채널(SelectableChannel)들을 Selector에 자신이 발생시키고 싶은 이벤트(OP_ACCEPT, OP_READ 등)와 함께 등록한다. 이렇게 채널을 Selector에 등록하면 이 등록에 관련된 채널과 Selector 사이의 관계를 캡슐화한 SelectionKey가 Selector에 저장되고 또한 등록하는 메소드의 리턴 값으로 이렇게 생성된 SelectionKey가 리턴된다.
- SelectionKey는 어떤 채널이 어떤 Selector에 등록되었는지, 또한 이 채널이 Selector에 등록할 때 어떤 모드로 등록했는지, 이 채널이 등록한 모드에 대한 동작할 준비가 되었는지 등의 정보를 가지고 있게 된다. 따라서 어떤 채널이 자신이 등록한 모드에 대해 동작할 준비가 되면 SelectionKey는 그 준비상태를 내부적으로 저장하고 있게 된다. 예를 들어 서버 소켓 채널의 경우에는 접속한 클라이언트를 Accept할 준비가 된 상태, 소켓 채널의 경우에는 클라이언트가 보낸 데이터를 읽을 준비가 된 상태를 말한다. 그 후 Selector가 select() 메소드를 호출해서 자신에게 등록된 모든 SelectionKey들을 검사하는데, 바로 동작할 준비가 되어있는지 아닌지를 검사하는 것이다. 이 검사를 통해 동작할 준비가 된 SelectionKey의 집합(Set)을 얻어서 이것들을 하나씩 순서대로 꺼내서 요청한 이벤트에 대해 적절히 처리하는 것이다.
04. SelectableChannel
SelectableChannel은 모든 소켓 채널들의 수퍼클래스이다. SelectableChannel에는 두 가지 기능이 있는데그 한 가지가 채널을 블록킹이나 비블록킹 모드로 설정하는 것이고 다른 하나는 Selector에 등록하는 것이다. 이를 15장에서 자세히 알아본다고 말하고 넘어갔다. 이제 그 남은 기능인 채널을 Selector에 등록하는 방법에 대해 알아볼 것이다.
public abstract class SelectableChannel extends AbstractChannel implements Channel {
// 블록킹-비블록킹 모드 설정에 관련된 메소드들
public abstract void configureBlocking(boolean block) throws IOException;
public abstract boolean isBlocking();
public abstract Object blockingLock();
// 채널을 Selector에 등록하는 것과 관련된 메소드들
public abstract SelectionKey register(Selector sel, int ops) throws ClosedChannelException;
public abstract SelectionKey register(Selector sel, int ops, Object att) throws ClosedChannelException;
public abstract SelectionKey keyFor(Selector sel);
public abstract int validOps();
}
출처 : 김성박 송지훈 공저, 『 자바 IO & NIO 네트워크 프로그래밍』, 한빛미디어(2004.9.30), 15장 인용