본문 바로가기
JAVA

[NIO] 3부, 블럭킹 IO와 논블럭킹 IO #2

by windrises 2007. 3. 29.
논블럭킹 IO를 이용한 웹 서버 구축

마지막으로 논블럭킹 IO를 이용하여 간단한 웹 서버를 구현해보도록 하겠다. 여기서 구현할 웹 서버는 다음과 같이 4개의 클래스로 구성되어 있다.

  • ClientAcceptor - 클라이언트의 연결을 대기하는 쓰레드. 클라이언트로부터 연결 요청이 들어올 경우 관련 SocketChannel을 SocketChannelQueue에 저장한다.
  • ClientProcessor - 클라이언트의 요청을 처리하는 쓰레드. SocketChannelQueue로부터 처리할 SocketChannel을 읽어오며 각 SocketChannel로부터 데이터를 읽어와 알맞은 작업을 수행한다.
  • SocketChannelQueue - ChannelSocket을 연결된 순서대로 임시적으로 저장하는 큐
  • NIOWebServer - 구동 프로그램.

먼저 ClientAcceptor를 살펴보자. ClientAcceptor는 비교적 소스 코드가 짧으므로 전체 코드를 보여주도록 하겠다.

   package madvirus.nioexam;
   
   import java.io.IOException;
   import java.net.InetSocketAddress;
   import java.nio.channels.SelectionKey;
   import java.nio.channels.Selector;
   import java.nio.channels.ServerSocketChannel;
   import java.nio.channels.SocketChannel;
   import java.util.Iterator;
   
   /**
    * 클라이언트의 연결을 대기한다.
    * @author 최범균
    */
   public class ClientAcceptor extends Thread {
      private int port;
      private SocketChannelQueue channelQueue;
      
      private ServerSocketChannel ssc;
      private Selector acceptSelector;
      
      public ClientAcceptor(SocketChannelQueue channelQueue, int port)
      throws IOException {
         this.port = port;
         this.channelQueue = channelQueue;
         
         acceptSelector = Selector.open();
         ssc = ServerSocketChannel.open();
         ssc.configureBlocking(false);
            
         // 지정한 포트에 서버소켓 바인딩
         InetSocketAddress address = new InetSocketAddress(port);
         ssc.socket().bind(address);
         
         ssc.register(acceptSelector, SelectionKey.OP_ACCEPT);
      }
      
      public void run() {
         try {
            while(true) {
               int numKeys = acceptSelector.select();
               if (numKeys > 0) {
                  Iterator iter = acceptSelector.selectedKeys().iterator();
                  
                  while(iter.hasNext()) {
                     SelectionKey key = (SelectionKey)iter.next();
                     iter.remove();
                     
                     ServerSocketChannel readyChannel = 
                          (ServerSocketChannel)key.channel();
                     SocketChannel incomingChannel = readyChannel.accept();
                     
                     System.out.println("ClientAcceptor - 클라이언트 연결됨!");
                     
                     channelQueue.addLast(incomingChannel);
                  }
               }
            }
         } catch (IOException e) {
            e.printStackTrace();
         } finally {
            try { ssc.close(); } catch (IOException e1) { }
         }
      }
   }

ClientAcceptor는 쓰레드로서 생성자에서 클라이언트와 연결된 SocketChannel을 저장할 ChannelQueue와 클라이언트의 연결을 대기할 포트 번호를 전달받는다. 생성자에서는 클라이언트의 연결을 대기할 ServerSocketChannel을 생성하고 지정한 지정한 포트에 바인딩한다. 또한 생성자에서는 ServerSocketChannel을 논블럭킹 모드로 전환하고 ServerSocketChannel에 Selector를 등록한다.

run() 메소드에서는 Selector.readyOps()를 사용하여 클라이언트의 연결이 들어올 경우 ServerSocketChannel의 accept() 메소드를 사용해서 클라이언트와 연결된 SocketChannel을 구한 후 큐에 저장한다.

ClientAcceptor가 클라이언트의 연결 요청을 받아서 관련된 SocketChannel을 큐에 저장하면, ClientProcessor는 그 큐로부터 SocketChannel을 읽어와 클라이언트의 요청을 처리한다. ClientProcessor 클래스의 소스 코드는 다음과 같다.

   package madvirus.nioexam;
   
   import java.io.File;
   import java.io.FileInputStream;
   import java.io.FileNotFoundException;
   import java.io.IOException;
   import java.nio.ByteBuffer;
   import java.nio.CharBuffer;
   import java.nio.channels.FileChannel;
   import java.nio.channels.SelectionKey;
   import java.nio.channels.Selector;
   import java.nio.channels.SocketChannel;
   import java.nio.charset.CharacterCodingException;
   import java.nio.charset.Charset;
   import java.nio.charset.CharsetDecoder;
   import java.nio.charset.CharsetEncoder;
   import java.util.Iterator;
   import java.util.StringTokenizer;
   
   /**
    * 클라이언트의 요청을 처리한다.
    * @author 최범균
    */
   public class ClientProcessor extends Thread {
      private SocketChannelQueue channelQueue;
      private File rootDirectory;
      private Selector readSelector;
      private ByteBuffer readBuffer;
      private Charset iso8859;
      private CharsetDecoder iso8859decoder;
      private Charset euckr;
      private CharsetEncoder euckrEncoder;
      
      private ByteBuffer headerBuffer;
      
      public ClientProcessor(SocketChannelQueue channelQueue, File rootDirectory)
      throws IOException {
         this.channelQueue = channelQueue;
         this.rootDirectory = rootDirectory;
         readSelector = Selector.open();
         
         this.channelQueue.setReadSelector(readSelector);
         
         readBuffer = ByteBuffer.allocate(1024);
         
         // 캐릭터셋 관련 객체 초기화
         iso8859 = Charset.forName("iso-8859-1");
         iso8859decoder = iso8859.newDecoder();
         euckr = Charset.forName("euc-kr");
         euckrEncoder = euckr.newEncoder();
         
         initializeHeaderBuffer();
      }
      
      private void initializeHeaderBuffer() 
      throws CharacterCodingException {
         CharBuffer chars = CharBuffer.allocate(88);
         chars.put("HTTP/1.1 200 OK\n");
         chars.put("Connection: close\n");
         chars.put("Server: 자바 NIO 예제 서버\n");
         chars.put("Content-Type: text/html\n");
         chars.put("\n");
         chars.flip();
         headerBuffer = euckrEncoder.encode(chars);
      }
      
      public void run() {
         while(true) {
            try {
               processSocketChannelQueue();
               
               int numKeys = readSelector.select();
               if (numKeys > 0) {
                  processRequest();
               }
            } catch (IOException e) {
               //   
            }
         }
      }
      
      private void processSocketChannelQueue() throws IOException {
         SocketChannel socketChannel = null;
         while ( (socketChannel = channelQueue.getFirst()) != null) {
            socketChannel.configureBlocking(false);
            socketChannel.register(
                                                     readSelector,
                                                     SelectionKey.OP_READ, new StringBuffer());
         }
      }
      
      private void processRequest() {
         Iterator iter = readSelector.selectedKeys().iterator();
         while( iter.hasNext() ) {
            SelectionKey key = (SelectionKey)iter.next();
            iter.remove();
            
            SocketChannel socketChannel = (SocketChannel)key.channel();
            
            try {
               socketChannel.read(readBuffer);
               readBuffer.flip();
               String result = iso8859decoder.decode(readBuffer).toString();
               StringBuffer requestString = (StringBuffer)key.attachment();
               requestString.append(result);
                  
               readBuffer.clear();
               
               if(result.endsWith("\n\n") || result.endsWith("\r\n\r\n")) {
                  completeRequest(requestString.toString(), socketChannel);
               }
            } catch (IOException e) {
               // 에러 발생
            }
         }
      }
      
      private void completeRequest(String requestData, SocketChannel socketChannel) 
      throws IOException {
         StringTokenizer st = new StringTokenizer(requestData);
         st.nextToken();
         String requestURI = st.nextToken();
         System.out.println(requestURI);
         
         try {
            File file = new File(rootDirectory, requestURI);
            FileInputStream fis = new FileInputStream(file);
            FileChannel fc = fis.getChannel();
            
            int fileSize = (int)fc.size();
            ByteBuffer fileBuffer = fc.map(FileChannel.MapMode.READ_ONLY, 0, fileSize);
            
            headerBuffer.rewind();
            
            socketChannel.write(headerBuffer);
            socketChannel.write(fileBuffer);
         } catch(FileNotFoundException fnfe) {
            sendError("404", socketChannel);
         } catch (IOException e) {
            sendError("500", socketChannel);
         }
         socketChannel.close();
      }
      
      private void sendError(String errorCode, SocketChannel channel) 
      throws IOException {
         System.out.println("ClientProcessor - 클라이언트에 에러 코드 전송:"+errorCode);
         
         CharBuffer chars = CharBuffer.allocate(64);
         chars.put("HTTP/1.0 ").put(errorCode).put(" OK\n");
         chars.put("Connection: close\n");
         chars.put("Server: 자바 NIO 예제 서버\n");
         chars.put("\n");
         chars.flip();
         
         ByteBuffer buffer = euckrEncoder.encode(chars);
         channel.write(buffer);
      }
   }

ClientProcessor의 소스 코드는 지금까지 설명했던 것들을 코드로 표현한 것에 불과하므로 자세한 설명은 덧붙이지 않겠다. 참고로 각 메소드를 간단하게 설명하자면 다음과 같다.

  • ClientProcessor() : 생성자. 데이터를 읽고 쓸때 사용할 인코더/디코더를 생성하고 Selector를 초기화.
  • initializeHeaderBuffer() : 클라이언트에 응답을 보낼 때 사용될 헤더 정보를 초기화.
  • processSocketChannelQueue() : ClientAcceptor가 클라이언트의 연결이 들어올 때 생성된 큐에 저장한 SocketChannel을 읽어와 논블럭킹 처리 및 Selector를 SocketChannel에 등록.
  • run() : Selector를 사용하여 SocketChannel의 이벤트 대기.
  • processRequest() : Selector로부터 사용가능한 SelectionKey 목록을 읽어와 알맞은 작업을 수행.
  • completeRequest() : 클라이언트가 요청한 문서 데이터를 전송.
  • sendError() : 에러가 발생한 경우 헤러 정보를 클라이언트에 전송.

ClientProcessor 클래스에서 주의해서 봐야할 소스 코드는 run() 메소드와 processSocketChannelQueue() 메소드이다. run() 메소드를 보면 다음과 같이 processSocketChannelQueue() 메소드를 호출하는 것을 알 수 있다.

      public void run() {
         while(true) {
            try {
               processSocketChannelQueue();
               
               int numKeys = readSelector.select();
               if (numKeys > 0) {
                  processRequest();
               }
            } catch (IOException e) {
               //   
            }
         }
      }
      
      private void processSocketChannelQueue() throws IOException {
         SocketChannel socketChannel = null;
         while ( (socketChannel = channelQueue.getFirst()) != null) {
            socketChannel.configureBlocking(false);
            socketChannel.register(
                                  readSelector, 
                                  SelectionKey.OP_READ, new StringBuffer());
         }
      }

위 코드에서 문제가 발생할 수 있는데 그것은 바로 readSelector.select() 메소드는 클라이언트로부터 데이터가 들어올 때에 비로서 값을 리턴한다는 점이다. 좀더 구체적으로 설명하기 위해 다음과 같은 실행 순서를 생각해보자.

     ClientAcceptor의 run()     ClientProcessor의 run()
     -----------------------------------------------------
1                                processSocketChannelQueue();
2 >>> acceptSelector.select()    readSelector.select()
3     ...                        [블럭킹상태]
4     channelQueue.addLast(..)   [블럭킹상태] --> 2에서 생성된 채널소켓은
5                                [블럭킹상태]       readSelector 등록불가
6     ...                        [블럭킹상태]
7 >>> acceptSelector.select()    [블럭킹상태]
8     channelQueue.addLast(..)   [블럭킹상태]  --> 7에서 생성된 채널소켓
9     ...                        [블럭킹상태]      readSelector 등록불가

위에서 '>>>' 표시는 클라이언트로부터 연결 요청이 들어왔음을 의미하는데, 4의 과정에서 새로들어온 SocketChannel을 channelQueue에 등록한다. channelQueue에 등록된 SocketChannel은 ClientProcessor.processSocketChannelQueue() 메소드를 통해서 논블럭킹 모드로 지정되고 readSelector를 등록하게 된다. 여기서 실행 순서상 과정 4나 과정 8에서 channelQueue에 새로운 SocketChannel을 등록하더라도 이들 SocketChannel은 readSelector를 등록되지 않은 상태이기 때문에 이들 채널에 데이터가 들어오더라도 readSelector.select() 메소드는 계속해서 블럭킹된다.

이처럼 계속해서 논리적으로 계속해서 블럭킹될 수 밖에 없는데 왜 필자가 이렇게 구현했을까? 그것은 바로 Selector가 제공하는 wakeup()이라는 메소드를 사용하면 위와 같은 문제를 해결할 수 있기 때문이다. 실제로 ChannelSocketQueue의 addLast() 메소드는 다음과 같이 구현되어 있다.

   public void addLast(SocketChannel channel) {
      list.addLast(channel);
      readSelector.wakeup();
   }

위 코드에서 readSelector는 ClientProcessor의 readSelector와 동일한 객체를 가리키는데, Selector의 wakeup() 메소드를 호출하게 되면 블럭킹되어 있던 select() 메소드는 곧바로 리턴된다. 즉, 앞에서 살펴봤던 블럭킹 문제가 발생하지 않게 되는 것이다.

마지막으로 NIOWebServer 클래스는 앞에서 ClientAcceptor 쓰레드와 ClientProcessor 쓰레드를 구동한다. 소스 코드는 복잡하지 않으므로 첨부한 소스 코드를 참고하기 바란다. 실행은 다음과 같이 하면 된다. (소스 코드에 컴파일한 .class 파일도 포함되어 있으니 그대로 사용할 수 있을 것이다.)

  c:\>java madvirus.nioexam.NIOWebServer Y:\docuemtRoot 80

위와 같이 실행한 후 웹브라우저를 띄워서 http://localhost/index.html 과 같은 알맞은 URL을 입력해서 실제로 웹 서버가 제대로 동작하는 지 확인해보기 바란다.

결론

본 글에서는 NIO API의 논블럭킹 IO에 대해서 살펴보았다. 앞에서 작성한 웹 서버의 경우 단 두 개의 쓰레드로(ClientAcceptor와 ClientProcessor) 모든 클라이언트의 요청을 처리하고 있다. 물론 상황에 따라서 ClientProcessor 쓰레드의 개수를 증가시킬 수 있겠지만 기본적으로 다수의 클라이언트를 하나의 쓰레드에서 성능저하현상없이 처리할 수 있게 해주는 원천이 바로 논블록킹 IO의 막강한 장점이라 할 수 있다.

-출처 [http://javacan.madvirus.net/]

'JAVA' 카테고리의 다른 글

NIO SAMPLE SOURCE  (0) 2007.03.29
[NIO] 1부, 버퍼와 채널 #2  (0) 2007.03.29
[NIO] 1부, 버퍼와 채널 #1  (0) 2007.03.29
[NIO] 2부, Charset을 이용한 인코딩/디코딩처리  (0) 2007.03.29
[NIO] 3부, 블럭킹 IO와 논블럭킹 IO #1  (0) 2007.03.29