저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의
논블럭킹 IO에 대해서 살펴보고, Selector를 이용한 논블럭킹 IO의 사용법을 알아본다.

블럭킹 IO와 논블럭킹 IO

자바 1.5가 조만간 정식 버전이 출시될 것으로 기대되고 있기는 하지만, 필자가 앞서 썻던 자바 1.4의 새로운 입출력 API인 NIO와 관련된 글을 끝맺음하고자 NIO API의 마지막 주제인 'Selector'와 '논블럭킹 IO'에 대해서 살펴보고자 한다. 먼저 1.4 이전의 입출력 코드에 대해서 살펴보도록 하자. 1.4 이전의 입출력 프로그램에서의 기본적인 입출력 메소드는 모두 블럭킹되었다. 예를 들어, ServerSocket의 accept() 메소드의 경우 클라이언트로부터의 연결 요청이 들어올 때 까지 블럭킹된다.

   Socket socket = serverSocket.accept(); // 블럭킹됨!!

뿐만 아니라 클라이언트가 전송한 데이터를 입력받을 때에도 다음과 같이 데이터를 읽어오는 부분에서 프로그램의 흐름이 블럭킹된다.

   in = socket.getInputStream();
   ...
   len = in.read(buff); // 블럭킹됨.

이렇게 블럭킹되는 코드 때문에 자바로 서버 프로그래밍을 하는 개발자들은 성능 문제를 겪어야만 했다. 예를 들어, 동시에 10명의 접속자를 처리하기 위해서는 기본적으로 다음과 같이 각각의 클라이언트마다 하나의 쓰레드를 생성해주어야 했다.

   
   public class ClientAcceptor {
   
      public void run() {
         ...
          Socket socket = serverSocket.accept();
          ClientProcessor processor = new ClientProcessor(socket);
          processor.start(); // 기본구조는 클라이언트 하나당 쓰레드 하나
          ...
      }    
    }
    
    
    public class ClientProcessor extends Thread {
       
       ...
       
       public void run() {
          ...
          in = socket.getInputStreacm();
          // in으로부터 데이터 읽어오기
          ...
       }
    }

그 동안 서버 프로그래밍의 기본 구조는 위 코드와 같이 클라이언트의 요청이 들어올 경우 그 클라이언트의 요청을 처리하는 하나의 쓰레드를 생성하는 것이었다. 하지만 이렇게 <클라이언트-쓰레드> 쌍을 사용하는 경우 클라이언트가 숫자에 비례해서 쓰레드의 숫자가 많아지게 되기 때문에 서버에 심각한 성능 문제를 유발하게 되며 이런 점을 극복하기 위해서 자바 개발자들은 폴링(polling)기법과 쓰레드 풀을 함께 사용해 왔었다. 폴링 기법을 간단하게 설명하자면 다음과 같은 형태의 코드로 정리된다. (아래 코드에서 SocketList는 Socket 목록을 저장하는 List라고 가정하자.)

   
   public class ClientAcceptor {
   
      public void run() {
         ...
          Socket socket = serverSocket.accept();
          socketList.addSocket(socket); // 소켓을 처리 목록에 추가
          ...
      }    
    }
   
   public class ClientProcessor {
   
      public void run() {
          while(true) {
             Thread.sleep(100); // 0.1초간 대기
             
             for (int i = 0 ; i < socketList.size() ; i++) {
                // 클라이언트의 요청을 차례대로 처리
                Socket socket = socketList.getSocket(i);
                in = socket.getInputStream();
                // 클라이언트와의 입출력 처리
                in.read(..); // 블럭킹되므로 시간 대기 문제 발생
                ...
             }
          }
      }
    }

구현하는 방법에 따라서 차이는 있겠지만 폴링기법의 기본 구현 형태는 위 코드와 같다. 즉, 처리해야 할 클라이언트를 차례대로 처리하는 것이다. 폴링 기법과 쓰레드 풀 기법을 함께 사용하면 성능을 좀더 높일 수 있긴 하지만, 대기 시간을 낭비하게 되고, (채팅 서비스와 같이) 동시처리가 요구되는 경우 폴링기법으로는 한계가 있다.

자바에서 성능상의 문제가 발생함에도 불구하고 멀티 쓰레드를 사용하여 클라이언트 당 하나의 쓰레드를 생성해주거나 폴링 기법을 사용하여 클라이언트 요청을 처리하는 근본적인 이유는 InputStream의 read() 메소드가 블럭킹되기 때문이다. 멀티 쓰레드를 사용하는 경우에는 각각의 클라이언트를 별도로 쓰레드로 처리함으로써 read() 메소드의 블럭킹 때문에 발생하는 문제를 해소하고 있으며, 폴링 기법의 경우는 시스템 자원 소모를 최소화하기 위해서 클라이언트의 요청을 순차적으로 반복해서 처리하고 있다. 하지만, 멀티 쓰레드 기법은 클라이언트의 증가에 비례해서 자원을 많이 소모하게 되며, 폴링 기법은 여전히 블럭킹에 따른 문제점을 안고 있다.

이런 문제를 해결하기 위해서 자바 1.4부터는 입출력의 논블럭킹 기능이 추가되었다. (논블럭킹 IO의 경우는 이미 운영체제 차원에서 지원되고 있고 C/C++ 라이브러리들 역시 각 운영체제에 맞게 논블럭킹 IO를 사용할 수 있도록 도와주고 있는 걸 생각해보면 자바에서는 늦게 이 기능이 추가되었다고 생각된다.) 논블럭킹 IO의 핵심은 클라이언트의 연결을 기다리거나 채널로부터 데이터를 읽어올 때 다음과 같이 처리되는 것이다.

  • 논블럭킹 모드인 경우
    • 클라이언트의 연결 요청이 없을 경우 ServerSocketChannel.accept() 메소드는 곧바로 null을 리턴한다.
    • 채널로부터 읽어올 데이터가 없는 경우 SocketChannel.read() 메소드는 곧바로 리턴되며, 인자로 전달한 ByteBuffer에는 어떤 내용도 입력되지 않는다.
즉, 이름 그대로 NIO의 논블럭킹 모드를 사용하면 대기 상태라는 개념이 없어지는 것이다. 이 말은 앞에서 InputStream.read() 메소드의 블럭킹 때문에 발생했던 클라이언트-쓰레드 구조의 다중 쓰레드 구현 기법 및 폴링 기법 구현시 발생하는 문제점을 해결할 수 있다는 것을 의미한다. 실제로 논블러깅 IO를 어떻게 사용하는지에 대해서는 다음 절에서 살펴보도록 하겠다.

논블럭킹 모드 사용하기

논블럭킹 모드를 사용할 수 있는 채널은 다음과 같은 메소드를 제공한다.

  public SelectableChannel configureBlocking(boolean block)  throws IOException

configureBlocking() 메소드의 파라미터의 값을 false로 전달하면 손쉽게 채널을 논블럭킹 모드로 변환할 수 있다. 예를 들어, 소켓스트림으로부터 데이터를 주고 받는데 사용되는 채널인 SocketChannel을 논블럭킹 모드로 지정하고 싶다면 다음과 같은 코드를 사용하면 된다.

   public class ClientAcceptor {
   
      public void run() {
         ServerSocketChannel ssc = null;
         try {
            ssc = ServerSocketChannel.open();
            InetSocketAddress address = new InetSocketAddress(port);
            ssc.socket().bind(address);
            
            while(true) {
                SocketChannel socketChannel = serverChannel.accept();
                 // 소켓채널 논블럭킹 모드 지정
                socketChannel.configureBlocking(false)
                socketList.addSocket(socketChannel);
                ...
                ...
             }
         } catch(IOException ex) {
            ...
         } finally {
            ...
         }
      }
   }
   
   public class ClientProcessor {
      
      public void run() {
         ByteBuffer buffer = ....;
         ...
         
          while(true) {
             Thread.sleep(100); // 0.1초간 대기
             
             for (int i = 0 ; i < socketList.size() ; i++) {
                // 클라이언트의 요청을 차례대로 처리
                SocketChannel socket = socketList.getSocket(i);
                
                buffer.clear();
                socket.read(buffer); // 블럭킹 되지 않음
                if (buffer.position() > 0) {
                   ... // 소켓에서 읽어온 데이터 처리
                }
             }
          }
      }
    }

위 코드와 같이 configureBlocking(false) 메소드를 호출한 이후에 SocketChannel.read() 메소드는 앞에서 설명했듯이 블럭킹 되지 않고 곧바로 리턴된다.

SocketChannel을 비롯해서 논블럭킹 모드를 지원하는 채널은 다음과 같다.

  • ServerSocketChannel
  • SocketChannel
  • DatagramChannel
  • Pipe.SinkChannel
  • Pipe.SourceChannel
채널의 read() 메소드나 ServerSocketChannel.accept() 메소드가 블럭킹되지 않고 곧바로 리턴된다는 점은 분명히 장점이긴 하지만 위 코드와 같이 무한루프를 사용하는 경우에는 문제가 발생할 수 있다. 예를 들어, 위 코드의 ClientAcceptor 클래스의 run() 메소드에서 ServerSocketChannel을 논블럭킹 모드로 지정한다고 가정해보자. 그러면 코드에는 다음과 같이 configureBlocking() 메소드를 호출하는 부분이 추가될 것이다.

   public class ClientAcceptor {
   
      public void run() {
         ServerSocketChannel ssc = null;
         try {
            ssc = ServerSocketChannel.open();
            InetSocketAddress address = new InetSocketAddress(port);
            ssc.configureBlocking(false);
            ssc.socket().bind(address);
            
            while(true) {
                // 연결 요청없을 경우 곧바로 리턴
                SocketChannel socketChannel = serverChannel.accept();
                if (socketChannel != null) {
                   // 소켓채널 논블럭킹 모드 지정
                   socketChannel.configureBlocking(false); 
                   socketList.addSocket(socketChannel);
                   ...
                   ...
                }
             }
         } catch(IOException ex) {
            ...
         } finally {
            ...
         }
      }
   }

위 코드의 문제점은 무한루프를 도는 데 모든 코드가 쉴새 없이 실행되기 때문에 CPU의 실행시간을 상당부분 소모하게 된다는 것이다. 예를 들어, 10초간 클라이언트로부터의 연결 요청이 없을 경우, serverChannel.accept() 메소드는 10초가 계속해서 null을 리턴하게 되며, 결과적으로 10초 동안 while() 루프는 (실제적으로 아무런 기능도 수행하지 않은채로) 반복해서 실행되며 그 만큼 CPU 시간을 낭비하게 되는 것이다. 앞에서 소켓으로부터 데이터를 읽어와 처리하는 부분의 경우에도 Thread.sleep() 메소드를 지정해주지 않으면 상당량의 CPU 시간을 낭비하게 된다.

무한 루프 형태의 반복문에서 논블럭킹 메소드를 수행하는 경우에는 이처럼 CPU 시간이 낭비되는 데, 자바 1.4는 이를 방지할 수 있는 기능을 제공하고 있다. 그것은 바로 Selector라는 것인데, 이 Selector를 사용하게 되면 연결요청이 들어왔거나 또는 데이터를 읽어올 수 있는 경우에만 코드를 수행하도록 지정할 수 있다.

Selector를 통해서 논블럭킹 채널 사용하기

Selector 클래스는 일종의 이벤트 리스너이다. 즉, 논블럭킹 모드를 지원하는 채널에 Selector를 등록해놓으면 논블럭킹 채널은 연결요청이 들어오거나 데이터가 도착한 경우에 그 사실을 Selector에 알리게 된다. 그럼, Selector는 어떤 기능을 사용할 수 있는 지를 리턴하게 되며, 그 리턴값을 통해서 연결요청을 처리할지 데이터 읽기를 처리할지 결정할 수 있게 된다.

논블럭킹 모드를 지원하는 채널들은 다음과 같이 Selector를 등록할 수 있는 메소드를 제공하고 있다.

   public SelectionKey register(Selector sel, int ops, Object att)
   throws ClosedChannelException

sel 인자는 해당 채널에 등록할 Selector이고, ops는 Selector가 전달받은 이벤트의 종류를 명시한다. att 객체는 리턴디는 SelectionKey에서 사용할 속성을 나타낸다.

Selector를 채널에 등록하기 위해서는 먼저 Selector를 생성해야 하는데, Selector.open() 메소드를 사용하면 새로운 Selector를 생성할 수 있게 된다. (NIO API의 특징은 객체를 생성할 때 open() 메소드를 생성한다는 점이다.) open() 메소드의 사용방법은 다음과 같이 간단한다.

   Selector selector = Selector.open();

Selector를 생성한 후에는 Selector 객체를 알맞은 채널에 등록해주면 된다. Selector를 채널에 등록할 때에는 채널의 어떤 기능과 관련해서 등록할지를 정해야 하는데 이때에는 SelectionKey 클래스에 정의된 상수값을 사용하면 된다. 다음의 SelectionKey 클래스에 정의된 상수값의 목록이다.

  • SelectionKey.OP_READ - 채널로부터 데이터를 읽어올 수 있는 경우. 값은 1
  • SelectionKey.OP_WRITE - 채널에 데이터를 쓸 수 있는 경우. 값은 4
  • SelectionKey.OP_ACCEPT - 소켓 연결이 들어온 경우. 값은 16
  • SelectionKey.OP_CONNECT - 연결 요청이 이뤄진 경우. 값은 8
위의 SelectionKey에 정의된 상수값을 사용하면 Selector가 채널로부터 원하는 이벤트를 전달받을 수 있도록 할 수 있다. 예를 들어, 논블럭킹 모드로 지정된 ServerSocketChannel에 연결요청이 들어오는 경우에 이벤트를 전달받도록 Selector를 채널에 등록하고 싶다면 다음과 같은 코드를 사용하면 된다.

   ServerSocketChannel ssc = null;
   ..
   try {
      ssc = ServerSocketChannel.open();
      ssc.blockingConfigure(true);
      
      Selector selector = Selector.open();
      
      ssc.register(selector, SelectionKey.OP_ACCEPT, null);
      
      ...
   } catch(..) {
      ...
   }

위와 같이 논블럭킹 채널에 Selector를 등록한 이후에는 논블럭킹되는 메소드 대신에 Selector 클래스의 readyOps() 메소드를 사용하여 어떤 기능을 사용할 수 있는 지 검사할 수 있게 된다. 예를 들어, 위 코드에서처럼 ServerSocketChannel에 Selector를 등록했다면 다음과 같이 Selector를 사용하여 연결요청이 들어오는 때에 알맞은 처리를 할 수가 있다.

   ssc = ServerSocketChannel.open();
   ssc.blockingConfigure(true);
   
   Selector selector = Selector.open();
   
   ssc.register(selector, SelectionKey.OP_ACCEPT, null); // selector를 등록
   
   while (true) {
      // selector와 관련된 이벤트가 발생할때까지 블럭킹!
      int numKeys = selector.readyOps();
      if (numKeys > 0) {
         if ((numKeys & SelectionKey.OP_ACCEPT) == 
                    SelectionKey.OP_ACCEPT
) {
            Set selectedKeySet = selector.selectedKeys();
            Iterator iter = selectedKeySet.iterator();
            
            while(iter.hasNext()) {
               SelectionKey key = (SelectionKey)iter.next();
               iter.remove();
               
               SocketChannel incomingChannel = ssc.accept();
               // 또는 다음 코드와 같이 (ServerSocketChannel)key.channel()
               // SocketChannel incomingChannel = 
               //    ((ServerSocketChannel)key.channel()).accept();
            }
         }
      }
   }

위 코드에서 핵심적인 기능을 제공하는 메소드는 Selector의 readyOps() 메소드이다. Selector의 readyOps() 메소드는 Selector가 등록되어 있는 채널로부터 관련된 이벤트가 발생할 때 까지 블럭킹되며, 관련된 이벤트가 발생할 경우 그와 간련된 키값을 리턴한다. 예를 들어, 위 코드에서는 SelectionKey.OP_ACCEPT 이벤트에 관심을 갖도록 Selector를 등록했는데 관련된 ServerSocketChannel에 클라이언트의 연결 요청이 들어올때까지 selector.readyOps() 메소드는 블럭킹되며, 클라이언트의 요청이 들어올 경우 그와 관련된 키 값인 SelectionKey.OP_ACCEPT를 리턴하게 된다.

Selector의 readyOps() 메소드는 등록된 채널들로부터 받은 모든 이벤트 목록을 리턴한다. 예를 들어, 다음과 같이 하나의 Selector를 여러 채널에 등록했다고 가정해보자.

   Selector selector = Selector.open();
   
   ServerSocketChannel ssc = ...;
   
   ssc.register(selector, SelectionKey.OP_ACCEPT, null);
   
   ...
   SocketChannel sc = ssc.accept();
   
   sc.register(selector, SelectionKey.OP_READ, null);
   ...

위와 같이 하나의 Selector를 여러 채널에 등록한 경우 Selector의 readyOps() 메소드는 관련된 채널들 중의 하나라도 이벤트가 발생한 경우 그와 관련된 값을 리턴한다. 만약 동시에 클라이언트로부터 연결 요청이 들어오고 소켓으로부터 읽을 수 있는 데이터가 들어왔다면 selector.readyOps() 메소드는 다음과 같은 값을 리턴할 것이다.

   // 연결 요청과 채널에서 읽어올 수 있는 데이터를 사용가능한 경우 아래 numKeys는
   // SelectionKey.OP_READ | SelectionKey.OP_ACCEPT의 값을 갖는다.
   int numKeys = selector.readyOps();

Selector.readyOps() 메소드가 리턴한 값이 0보다 큰 경우 Selector의 selectedKeys() 메소드를 사용하여 관련된 이벤트 목록을 읽어올 수 있다. selectedKeys() 메소드는 Selector가 등록된 채널과 관련된 SelectionKey의 집합인 Set을 리턴한다. SelectionKey는 관련 채널을 리턴하는 channel() 메소드를 제공하고 있다. 따라서, Selector를 이용하는 경우 전체적인 프로그램 코드는 다음과 같은 구조를 갖게 된다.

   
   // 1. Selector 생성   Selector selector = Selector.open();   
   // 2. Selector를 등록할 수 있는 채널 생성
   ServerSocketChannel channel = ServerSocketChannel.open();
   ...
   // 3. 채널에 Selector 등록
   channel.register(selector, SelectionKey.OP_ACCEPT, null);
   ...
   
   while(true) {
   // 4. selector를 이용하여 채널의 이벤트 대기
      int readyKey = selector.readyOps();
   
   // 5. readyKey가 0 이상이면 이벤트가 발생한 것으로 처리
      if (readyKey > 0) {
   
   // 6. selector로부터 채널에서 발생한 이벤트와 관련된 SelectionKey Set 구함
         Set selectionKeySet = selector.selectedKeys();
   
   // 7. Set에서 각 SelectionKey를 차례대로 읽어와
         Iterator iter = selectionKeySet.iterator();
         while(iter.hasNext()) {
            SelectionKey selectionKey = (SelectionKey)iter.next();
            
   // 8. SelectionKey로부터 채널을 구함
            ServerSocketChannel relatedChannel =
                (ServerSocketChannel)selectionKey.channel();
            
   // 9. 채널을 사용하여 알맞은 작업 수행
            ...
         }
      }
   }

논블럭킹 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의 막강한 장점이라 할 수 있다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. NIO 2013.02.16 13:52 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다 :D
    그런데 첨부파일이 어디있는가요ㅜㅜ

  2. 방문자1 2013.03.24 18:03 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다

  3. 대건 2013.04.23 18:49 신고  댓글주소  수정/삭제  댓글쓰기

    많은 도움이 되었습니다.

  4. 방문자2 2013.07.03 11:26 신고  댓글주소  수정/삭제  댓글쓰기

    많은 도움이 되었습니다.
    제가 찾아본 자료 중에 가장 개념이 제대로 정리된 자료이네요.

  5. 강현수 2014.01.16 15:01 신고  댓글주소  수정/삭제  댓글쓰기

    와 진짜 감사합니다

  6. 방문자3 2014.09.19 15:50 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요 글 너무 잘보았습니다..

    만약 cpu 의 코어가 여러개 일때 하나의 스레드를 사용하면 cpu를 100% 활용할 수 없는 문제는 생기지 않을까요?

  7. 핫바지들아 2015.11.24 15:08 신고  댓글주소  수정/삭제  댓글쓰기

    호랑이 담배피던 시절 기능 사용 ㄴㄴ해
    어차피 이것도 accept()만 넌-블럭킹이지 처리 되는 부분은 단일쓰레드에서 처리하는거라 완벽한 넌-블럭킹이 아님.

    1.7부터 AsynchronousServerSocketChannel이라는 완전한 넌-블럭킹 소켓 클래스가 나옴
    그러니 십년도 더 된 자료 보면서 고맙니 어쨌니 그러지말고 자바API를 참고해라
    들어가면 맨앞에 친절하게 예제가 있으니 이해못하는 니들 잘하는 복붙해도 충분히 쓸수 있다

    https://docs.oracle.com/javase/7/docs/api/java/nio/channels/AsynchronousServerSocketChannel.html

NIO의 Charset 클래스를 이용한 캐릭터셋 처리에 대해서 살펴본다.

Charset 클래스와 캐릭터셋변환

지난 1부에서는 NIO API의 버퍼와 채널에 대해서 살펴보았다. NIO의 채널은 자바 1.3 까지의 입출력스트림(또는 Reader와 Writer)라는 것을 알게 되었을 것이며, 이 채널은 버퍼를 데이터 저장소로 사용한다는 것도 알게 되었을 것이다. 이번 2부에서는 채널을 통해서 읽어온 데이터의 캐릭터셋을 변환할 때 사용되는 Charset과 논블럭킹 데이터 입출력을 위해서 사용되는 Selector에 대해서 살펴보도록 하겠다.

자바는 애초에 나올때부터 유니코드를 지향해왔다. 하지만, 자바가 제 아무리 유니코드를 지향한다해도 모든 곳에서 유니코드가 통용되는 것은 아니다. 예를 들어, 우리가 매일같이 접속하는 인터넷만하더라도 유니코드 보다는 각 페이지에 알맞은 캐릭터셋을 사용한다. 예를 들어, 대부분의 한글 웹사이트에서 사용되는 캐릭터셋은 EUC-KR(또는 KSC5601)이다. 물론, 윈도우즈 2000이나 윈도우즈 XP와 같이 비교적 최근에 나온 운영체제들은 유니코드를 지원하고 있긴 하다. 하지만, 실제로 사용되는 캐릭터셋은 대부분 그 나라에 알맞은 것들이다.

자바 1.3의 경우 한글로 된 텍스트 파일을 읽어올 때는 InputStreamReader를 간접적으로 사용했었다. InputStreamReader는 바이트 배열을 특정 캐릭터셋에 따라서 알맞게 유니코드로 변환해주기 때문이다. 예를 들어, 한글 윈도우의 MS949 캐릭터셋(EUC_KR과 거의 유사하며 윈도우즈 한글 코드를 처리할 때 사용된다)을 사용하여 작성된 문서를 InputStreamReader를 사용하여 읽어올 경우, InputStreamReader는 MS949 캐릭터셋으로 구성된 바이트 배열을 유니코드로 알맞게 변환해준다. 반대의 경우, 즉 OutputStreamWriter의 경우는 반대의 과정인 유니코드를 해당 캐릭터셋에 알맞은 바이트 배열로 변환해주는 처리를 해준다.

NIO API 역시 IO API와 마찬가지로 캐릭터셋의 변환 처리를 할 수 있는 방법을 제공하고 있는데, 그것이 바로 Charset 클래스이다. Charset 클래스를 포함해서 캐릭터셋 변환과 관련된 클래스들은 java.nio.charset 패키지에 정의되어 있으며, 주요 클래스는 다음과 같다.

  • Charset - 캐릭터셋을 나타내는 클래스
  • CharsetEncoder - 캐릭터를 바이트로 변환해주는 인코더
  • CharsetDecoder - 바이트 데이터로부터 캐릭터를 생성해주는 디코더
Charset 클래스

Charset 클래스는 캐릭터셋 자체를 나타내며, 이 클래스의 인스턴스가 나타내는 캐릭터셋과 유니코드 사이의 변환을 처리해주는 클래스이다. Charset 클래스의 인스턴스는 생성자를 통해서 생성하지 않고 static으로 제공되는 forName() 메소드를 사용해서 생성한다. 예를 들어, 유니코드와 아스키코드 사이의 인코딩/디코딩을 하는 Charset 인스턴스는 다음과 같이 생성할 수 있다.

    Charset cset = Charset.forName("US-ASCII");

위에서 cset은 유니코드와 아스키코드(US-ASCII) 사이의 인코딩/디코딩을 처리해주는 Charset 인스턴스가 된다. JDK1.4는 다음에 표시한 8개의 캐릭터셋을 기본적으로 지원하고 있다.

    ISO-8859-1, ISO-8859-15, US-ASCII
    UTF-16, UTF-16BE, UTF-16LE, UTF-8
    windows-1252

하지만, 위에 없는 캐릭터셋 이외에 EUC-KR이나 EUC-JP와 같은 캐릭터셋에 해당하는 Charset 인스턴서의 지원여부는 사용하는 자바 가상 머신에 따라 다르다. 현재 썬에서 제공하는 가상 머신은 위의 8가지 캐릭터셋에 대한 Charset 인스턴스만을 제공하고 있다. 이 얘기는 썬에서 제공한 JDK로는 EUC-KR 캐릭터셋을 사용하여 표현한 한글문자열을 Charset 클래스를 사용하여 인코딩하거나 디코딩 할 수 없다는 것을 의미한다. 따라서, 앞의 8가지 기본 캐릭터셋으로 표현할 수 없는 글자들에 대해서는 다른 방법으로 인코딩/디코딩 처리를 해야만 한다. 이에 대해서는 뒤에서 설명하기로 하겠다. (앞으로 출시될 1.4.1 버전에서는 EUC-KR을 거의 모든 캐릭터셋에 대해서 Charset을 지원할 예정이다. 따라서 1.4.1 버전을 사용할 경우에는 EUC-KR 캐릭터셋을 처리하기 위해서 별도의 방법을 사용할 필요가 없다.)

Charset 클래스는 유니코드와 지정된 캐릭터셋 사이에 변환을 할 수 있도록 encode() 메소드와 decode() 메소드를 제공하고 있다. 다음은 encode() 메소드를 사용하여 자바의 문자열을 지정한 캐릭터셋으로 인코딩하여 파일로 저장하는 예제 코드이다.

    import java.nio.charset.*;
    import java.nio.channels.*;
    import java.nio.*;
    import java.io.*;
    
    public class CharsetTest {
       public static void main(String[] args) {
          if (args.length != 2) {
             System.out.println("[사용] java CharsetTest 캐릭터셋 문장");
             System.exit(0);
          }
          
          FileChannel channel = null;
          
          try {
             Charset charset = Charset.forName(args[0]);
             ByteBuffer buff = charset.encode(args[1]);             
             FileOutputStream out = new FileOutputStream("temp.tmp");
             channel = out.getChannel();
             channel.write(buff);
          } catch(IllegalCharsetNameException ex) {
             System.out.println("잘못된 캐릭터셋 이름: " + args[0]);
          } catch(UnsupportedCharsetException ex) {
             System.out.println("지원하지 않는 캐릭터셋: " + args[0]);
          } catch(IOException ex) {
             System.out.println("입출력 예외: " + ex.getMessage());
          } finally {
             if (channel != null) try { channel.close(); } catch(IOException ex) {}
          }
       }
    }

위 프로그램은 매우 간단하지만, 위 프로그램을 통해서 유니코드를 앞에서 언급한 8가지 캐릭터셋에 알맞은 바이트배열로 변경하는 방법을 알 수 있을 것이다. 예를 들어, CharsetTest를 실행해보자.

    d:\test>java CharsetTest UTF-16 한글과Alphabet의조합

이때 생성되는 temp.tmp 파일은 30바이트를 차지하게 된다. 30바이트가 생성되는 이유는 "한글과Alphabet의조합"은 14글자이고 UTF-16(유니코드)에서 한글자는 2바이트를 차지하며, 그리고 추가적으로 2바이트가 어떤 순서로 구성되는지를 나타내기 위해 2바이트가 추가되기 때문이다. 위와 같이 실행한 결과 화면을 도스창의 type 명령어를 사용하여 보면 다음과 같이 출력된다.

    D:\test>type temp.tmp
    ?? ? A l p h a b e t???

이와 같이 출력되는 이유는 도스창의 type 명령어가 유니코드를 지원하지 않기 때문이다. (UTF-16은 한글자가 2바이트를 차지하기 때문에 알파벳도 2바이트를 사용하여 저장되는 것을 알 수 있다.) 유니코드를 지원하는 윈2000이나 윈XP의 메모장에서 temp.tmp 파일을 열어보면 글자가 깨지지 않고 올바르게 보일 것이다.

특정 캐릭터셋의 바이트 배열을 다시 유니코드로 디코딩하는 과정도 인코딩만큼이나 간단하다. 예를 들어, CharsetTest 클래스를 사용하여 인코딩한 바이트 배열을 다시 유니코드로 변환하여 화면에 출력해주는 프로그램은 다음과 같다.

    import java.nio.charset.*;
    import java.nio.channels.*;
    import java.nio.*;
    import java.io.*;
    
    public class CharsetTest2 {
       public static void main(String[] args) {
          if (args.length != 2) {
             System.out.println("[사용] java CharsetTest 캐릭터셋 파일명");
             System.exit(0);
          }
          
          FileChannel channel = null;
          
          try {
             Charset charset = Charset.forName(args[0]);
             ByteBuffer buff = ByteBuffer.allocate(32);
             
             FileInputStream in = new FileInputStream(args[1]);
             channel = in.getChannel();
             channel.read(buff);
             buff.flip();
             CharBuffer charBuffer = charset.decode(buff);
             System.out.println(charBuffer.toString());
          } catch(IllegalCharsetNameException ex) {
             System.out.println("잘못된 캐릭터셋 이름: " + args[0]);
          } catch(UnsupportedCharsetException ex) {
             System.out.println("지원하지 않는 캐릭터셋: " + args[0]);
          } catch(IOException ex) {
             System.out.println("입출력 예외: " + ex.getMessage());
          } finally {
             if (channel != null) try { channel.close(); } catch(IOException ex) {}
          }
       }
    }

CharsetTest2 클래스는 앞에서 작성한 CharsetTest 클래스와는 정반대로 파일로부터 byte 데이터를 읽어와 ByteBuffer에 저장한 후, 그 ByteBuffer에 있는 데이터를 지정한 캐릭터셋으로 디코딩하여 CharBuffer에 저장한다. 디코딩 작업은 위 코드에서 보다시피 Charset 클래스의 decode() 메소드를 사용하여 수행한다. 디코딩할 바이트 데이터가 저장된 ByteBuffer를 파라미터로 전달하면, 알맞게 유니코드로 디코딩된다.

CharsetEncoder와 CharsetDecoder

앞에서 살펴본 Charset 클래스의 encode() 메소드와 decode() 메소드는 내부적으로는 CharsetEncoder와 CharsetDecoder 클래스를 사용한다. CharsetEncoder와 CharsetDecoder는 이름에서 알 수 있듯이 각각 인코딩처리와 디코딩처리를 해 주는데 이 둘은 Charset 클래스의 newEncoder() 메소드와 newDecoder() 메소드를 사용하여 구할 수 있다. 예를 들어, CharsetEncoder를 사용하여 인코딩 처리를 하려면 다음과 같이 하면 된다.

    Charset charset = Charset.forName(charsetName);
    CharsetEncoder encoder = charset.newEncoder();
    ByteBuffer byteBuff = encoder.encode(charBuff);

CharsetEncoder 클래스는 위 코드에서 보다시피 encode() 메소드를 제공한다. 앞서 살펴봤던 Charset.encode() 메소드도 내부적으로 CharsetEncoder의 encode() 메소드를 사용한다. 실제로 Charset.encode() 메소드는 다음과 같다.

    cs.newEncoder()
      .onMalformedInput(CodingErrorAction.REPLACE)
      .onUnmappableCharacter(CodingErrorAction.REPLACE)
      .encode(bb); 

여기서 onMalformedInput() 메소드와 onUnmappableCharacter() 메소드는 모두 CharsetEncoder 자신을 리턴하는데, 이 두 메소드에 대해서는 뒤에서 설명하도록 하겠다.

CharsetDecoder도 CharsetEncoder를 구하는 방법과 비슷하게 Charset 클래스의 newDecoder() 메소드를 사용하여 구할 수 있다. 즉, 다음과 같이 CharsetDecoder를 구하면 된다.

    Charset charset = Charset.forName(charsetName);
    CharsetDecoder decoder = charset.newDecoder();
    CharBuffer charBuff = decoder.decode(byteBuff);

CharsetDecoder 클래스는 decode() 메소드는 사용하는데, Charset.decode() 메소드는 Charset.encode() 메소드와 마찬가지로 CharsetDecoder.decode() 메소드를 내부적으로 사용한다. 실제 Charset.decode() 메소드는 다음과 동일하다.

    cs.newDecoder()
      .onMalformedInput(CodingErrorAction.REPLACE)
      .onUnmappableCharacter(CodingErrorAction.REPLACE)
      .decode(bb); 

캐릭터 변환 처리 방법 지정하기

바이트 데이터에 해당하는 유니코드가 없는 경우에는 예외를 발생시키거나 또는 처리하지 않고 넘어간다던가 하는 등의 별도 처리가 필요할 것이다. 이처럼 예외나 에러 상황이 발생할 때 어떻게 처리할지의 여부를 지정해주는 메소드가 있는데, 그 메소드가 바로 CharsetEncoder 클래스와 CharsetDecoder 클래스는 모두 onMalformedInput() 메소드와 onUnmappableCharacter() 메소드이다.

onMalformedInput() 메소드는 잘못된 데이터를 만났을 때 어떻게 처리할지를 지정한다. 예를 들어, CharsetEncoder.encode() 메소드에 전달한 문자열중의 일부 글자가 유니코드가 아니거나 또는 반대로 CharsetDecoder.decode() 메소드에 전달한 바이트 데이터가 잘못된 경우 onMalformedInput() 메소드에서 지정한 방식에 따라서 에러 처리를 하게 된다. 예를 들어, 잘못된 데이터를 만났을 때 CharacterCodingException(또는 유니코드가 아닌 경우 MalformedInputException)을 발생시키고자 한다면 다음과 같이 onMalformedInput() 메소드를 호출하면 된다.

    csEncoder = cs.newEncoder();
    csEncoder.onMalformedInput(CodingErrorAction.REPORT);

onMalformedInput() 메소드에는 CodingErrorAction 클래스에 정의되어 있는 상수값이 파라미터로 전달되는 데, CodingErrorAction 클래스에 정의되어 있는 상수는 다음과 같이 세 가지가 존재한다.

  • CodingErrorAction.IGNORE : 에러를 발생시킨 글자(또는 바이트)를 무시하고 다음 글자(또는 바이트)를 인코딩(디코딩)한다.
  • CodingErrorAction.REPLACE : 에러를 발생시킨 글자(또는 바이트) 대신에 지정한 데이터를 삽입하고 인코딩(디코딩)작업을 계속 진행한다.
  • CodingErrorAction.REPORT : 인코딩/디코딩 작업을 중단하고 CharacterCodingException(또는 CharacterCodingException을 상속받은 하위 클래스) 예외를 발생시킨다.
onUnmappableCharacter() 메소드도 onMalformedInput() 메소드와 마찬가지로 CodingErrorAction 클래스에 정의된 상수인 IGNORE, REPLACE, REPORT 중 하나의 값을 파라미터로 전달받는다. onUnmappableCharacter() 메소드는 인코딩/디코딩 작업시에 변환할 수 없는 글자를 만났을 때 어떻게 처리할지를 나타낸다. 예를 들어, A 캐릭터셋에는 존재하지만 B 캐릭터셋에는 존재하지 않는 글자를 인코딩하거나 디코딩하려 할 때에는 변환을 할 수 없을 것이며, 이런 경우 onUnmappableCharacter() 메소드를 사용하여 onMalformedInput()과 마찬가지로 그냥 무시할지 아니면 다른 글자로 변환할지 아니면 예외를 발생할지의 여부를 지정할 수 있다.

onMalformedInput() 메소드와 onUnmappableCharacter() 메소드에 CodingErrorAction.REPLACE를 지정하면 인코딩/디코딩 작업을 할 수 없는 글자나 문자에 대해서는 지정한 데이터로 변환을 한다고 했었는데, 이때 변환될 데이터는 replaceWith() 메소드를 사용하여 지정할 수 있다. CharsetEncoder와 CharsetDecoder는 각각 다음과 같이 repalceWith() 메소드를 정의하고 있다.

  • CharsetEncoder: replaceWith(byte[] newReplacement)
  • CharsetDecoder: replaceWith(String newReplacement)
replaceWith() 메소드에 전달할 수 있는 값에는 몇가지 제약사항이 존재한다. CharsetEncoder.replaceWith() 메소드에 전달되는 byte 배열의 경우 길이가 0보다 커야 하고 maxBytesPerChar()가 리턴하는 값보다 길어서는 안 되며, 인코딩할 캐릭터셋에 존재하는 바이트 배열이어야 하고 유니코드로 디코드 할 수 있어야만 한다. CharsetDecoder.replaceWith() 메소드에 전달되는 String은 null이 아니어야 하고 길이가 0보다 길어야 한다.

CoderResult를 사용하여 인코딩/디코딩 결과 처리하기

CharsetEncoder 클래스와 CharsetDecoder 클래스는 앞에서 살펴본 encode()/decode() 메소드 뿐만 아니라, 다음과 같이 인코딩/디코딩을 할 수 있는 메소드를 추가로 제공하고 있다.

  • CharsetEncoder: CoderResult encode(CharBuffer in, ByteBuffer out, boolean endOfInput)
  • CharsetDecoder: CoderResult decode(ByteBuffer in, CharBuffer out, boolean endOfInput)
두 메소드는 모두 CoderResult를 리턴하는데, CoderResult는 인코딩/디코딩 결과를 저장하고 있다. 위의 encode()/decode() 메소드는 앞에서 살펴봤던 encode(CharBuffer in)/decode(ByteBuffer in) 메소드와 달리 캐릭터변환 과정에서 캐릭터 매핑 에러가 있거나 입력이 잘못된 경우 예외를 발생시키지 않는다. 대신 에러가 발생했다는 사실을 리턴하는 CoderResult 객체에 표시한다. CoderResult는 다음과 같은 메소드를 제공하고 있으며, 이들 메소드를 사용하여 처리가 올바르게 되었는지 여부를 알려준다.

메소드 설명
isError() 처리 과정에서 에러가 발생한 경우 true를 리턴한다.
isMalformed() 잘못된 입력(Malformed Input) 데이터가 있을 경우 true를 리턴한다.
isUnmappable() 매핑할 수 없는 데이터를 입력한 경우 true를 리턴한다.
isOverflow() 오버플로우가 발생한 경우 true를 리턴한다.
isUnderflow() 언더플로우가 발생한 경우 true를 리턴한다.
throwException() 인코딩/디코딩 처리 결과에 알맞은 예외를 발생시킨다. 발생하는 예외 종류는 다음과 같다.
  • MalformedInputException
  • UnmappableCharacterException
  • CharacterCodingException
  • BufferOverflowException
  • BufferUnderflowException

위 코드에서 오버플로우와 언더플로우는 에러라기 보다는 계속해서 인코딩/디코딩 작업이 필요하다는 것을 의미한다. CharsetEncoder의 encode(CharBufff, ByteBuffer, boolean) 메소드에서 오버플로우와 언더플로우 그리고 잘못된 입력, 매핑불가능은 다음과 같은 의미를 나타낸다.

  • CoderResult.UNDERFLOW - 입력 버퍼(CharBuff)에서 최대한 많은 양의 데이터를 인코딩했음을 나타낸다. 만약 입력 버퍼에 글자가 남아 있지 않고 입력 데이터가 더 이상 존재하지 않는다면 인코딩 처리가 완료된다. 그렇지 않고 입력 데이터가 불충하다면 입력 데이터를 추가적으로 받아서 인코딩 처리를 해야 한다는 것을 나타내기도 한다.
  • CoderResult.OVERFLOW - 출력 버퍼가 다 찼음을 의미한다. 따라서 다차있지 않은 출력 버퍼를 사용하여 다시 한번 인코딩 처리를 해 주어야 한다.
  • 잘못된 입력(malfored-input) - 잘못된 입력 데이터가 있음을 나타낸다. 버퍼의 현재 위치(position)는 잘못된 글자에 위치한다. 단, onMalformedInput() 메소드에 CodingErrorAction.REPORT를 지정한 경우에만 동작한다.
  • 매핑할 수 없는 글자 - 지정된 캐릭터셋으로 인코딩할 수 없는 글자가 있음을 나타낸다. 버퍼의 현재 위치는 매핑할 수 없는 글자에 위치한다. 단, onUnmappableCharacter() 메소드에 CodingErrorAction.REPORT를 지정한 경우에만 동작한다.
이 중, 오버플로우와 언더플로우는 버퍼를 사용할 때 반드시 필요한 정보중의 하나이다. 이 두 정보를 어떻게 사용할 수 있는 지에 대해서는 뒤에서 살펴볼 것이다.

캐릭터변환시 주의 사항

지금까지 살펴본 Charset, CharsetEncoder 그리고 CharsetDecoder는 그 동안 개발자들이 코드 변환을 위해서 사용했던 방식을 대신 처리해주는 참(!) 좋은 기능을 제공해주는 클래스들이다. 하지만, 이들 클래스를 사용할 때에는 주의해야 할 점이 두가지가 있다. 그것은 바로 다음과 같다.

  • 지원하지 않는 캐릭터셋의 처리
  • 긴 길이를 가진 데이터의 인코딩/디코딩 처리
이글에서는 마지막으로 위의 두가지 문제를 어떤 식으로 처리할 수 있는 지 함께 살펴보도록 하겠다.

위의 두 가지 문제중 첫번째 문제는 NIO API의 치명적인 약점이라 할 수 있다. 당장 한글만 하더라도, 윈도우즈에서 사용되는 캐릭터셋은 MS949(EUC-KR과 호환되는 캐릭터셋이다)인데, NIO의 Charset은 MS949 또는 EUC-KR을 지원하지 않는다. 따라서 개발자가 Charset 클래스를 사용하여 인코딩/디코딩 작업을 처리할 수 없다는 문제가 발생하게 된다. 물론, 한국 사람이 JVM을 새로 만든다면 앞에서 언급했던 기본 8개의 캐릭터셋 이외에 EUC-KR도 지원하도록 구현하겠지만 Sun에서 배포하는 JVM은 지원하지 않고 있다.

따라서, NIO가 지원하지 않는 캐릭터셋에 대해서 버퍼 기능을 활용하기 위해서는 별도의 방법을 생각해내야 한다. 데이터를 저장할 때 사용할 수 있는 방법은 String.getBytes() 메소드를 사용하여 원하는 캐릭터셋의 바이트 배열로 변환한 후 ByteBuffer를 사용하는 것이다. 무식한 방법이라고 생각할 수도 있지만, 기본 캐릭터셋 이외에 다른 캐릭터셋을 사용할 경우 NIO API를 사용하는 가장 좋은 방법이라 할 수 있다. 예를 들면 다음과 같이 사용하면 된다.

    byte[] temp = someString.getBytes("EUC-KR");
    ByteBuffer buff = new ByteBuffer(32);
    buff.put(temp, 0, temp.length);
    
    // 버퍼 사용
    ...
    buff.flip();
    channel.write(buff);
    

조금 무식해 보이긴 하지만 InputStreamReader나 OutputStreamWriter가 사용하는 방식도 궁극적으로 위와 동일하다. 위와 같이 해당 캐릭터셋의 바이트 배열을 구한 후 채널에 출력할 때에는 배열의 길이에 신경을 써 주어야 한다. 예를 들어, ByteBuffer의 크기를 5로 잡아주었는데 getBytes()로 구한 byte[] 배열의 크기가 10일 경우에는 다음과 같이 루프를 사용하여 알맞게 데이터를 출력해주어야 한다.

    byte[] srcData = args[1].getBytes();
    ByteBuffer buff = ByteBuffer.allocate(5);
    
    FileOutputStream out = new FileOutputStream(args[0]);
    channel = out.getChannel();
    
    int count = 0; // srcData를 처리한 바이트 수
    int len = 0;
    while(count < srcData.length) {
        len = buff.remaining();
        if (srcData.length - count < len) {
            len = srcData.length - count;
        }
        buff.put(srcData, count, len);
        buff.flip();
        channel.write(buff);
        buff.clear();
        count += len;
    }

위 코드는 ByteBuffer의 여유 공간에 알맞게 바이트 배열의 데이터를 ByteBuffer에 나누어서 삽입해주고 있다. 이와 같이 나누어서 해 주어야 하는 이유는 버퍼의 여유공간에 알맞게 데이터를 삽입하지 않을 경우 BufferOverflowException이 발생하기 때문이다.

CharsetEncoder를 사용하여 데이터를 처리할 때에도 버퍼의 길이에 신경써야 하기는 마찬가지다. 예를 들어, CharBuffer에 20개의 char가 저장되어 있는데, 이 20개의 캐릭터를 길이가 5인 ByteBuffer를 사용하여 파일에 저장한다고 생각해보자. 이 경우 어떤 캐릭터셋을 사용한다 할지라도 20개의 글자를 모두 인코딩처리하기 위해서는 ByteBuffer에 여러 차례에 걸쳐서 저장해야 할 것이다. 이때의 처리 방식은 byte 배열에 저장된 데이터를 분할해서 출력할 때와 비슷하다. 다음은 실제로 CharBuffer에 저장된 데이터를 인코딩하여 ByteBuffer에 저장한 후 파일에 출력해주는 코드를 보여주고 있다.

    CharBuffer charBuff = CharBuffer.allocate(64);
    charBuff.put(args[1]);
    charBuff.flip();
    
    ByteBuffer buff = ByteBuffer.allocate(5);
    
    FileOutputStream out = new FileOutputStream(args[0]);
    channel = out.getChannel();
    
    CoderResult result = null;
    while(true) {
        result = encoder.encode(charBuff, buff, true);
        buff.flip();
        channel.write(buff);
        buff.clear();
        
        if (result == CoderResult.UNDERFLOW) {
            if (charBuff.position() == charBuff.limit())
                break;
        }
    }

채널을 통해서 데이터를 읽어올 때는 약간 상황이 다르다. Charset이 지원하는 8개의 캐릭터셋에 대해서는 CharsetDecoder를 사용하여 손쉽게 디코딩 작업을 처리할 수 있지만, Charset이 지원하지 않는 캐릭터셋에 대해서는 인코딩할때와 마찬가지로 Charset을 사용하지 않은 별도의 방법을 처리해야 한다. Charset이 지원하지 않는 캐릭터셋을 읽어오는 가장 쉬운 방법은 그냥 속 편하게 java.io.Reader를 사용하는 것이다. 사실, Selector를 사용하여 논블럭킹 I/O 기능을 사용하지 않는 이상 EUC-KR과 같은 캐릭터셋의 데이터를 읽어올 때는 Reader를 사용하는 것이 가장 단순하게 일을 처리할 수 있는 방법이다.

하지만, 논블럭킹 I/O 기능을 사용해야 하는 경우와 같이 반드시 NIO API를 사용해야 하는 경우에는 별도의 방법을 사용해야 한다. 필자가 추천할 만한 방법은 일단 데이터를 ByteBuffer에 저장한 후 필요에 따라 일부 데이터를 알맞게 형변환하라는 것이다. 예를 들어, 웹 서버를 개발한다고 해 보자. 웹브라우저는 웹서버에 다음과 비슷한 데이터를 전송한다.

    POST / HTTP/1.1
    Accept: image/gif, image/x-xbitmap, image/jpeg
    Accept-Language: ko
    Content-Type: application/x-www-form-urlencoded
    Accept-Encoding: gzip, deflate
    User-Agent: Mozilla/4.0
    Host: localhost
    Content-Length: 50
    Connection: Keep-Alive
    Cache-Control: no-cache
    
    param1=%C7%D1%B1%DB%C0%BA¶m2=%B0%FA%BF%AC+abcd

위 데이터에서 굵게 표시한 부분은 사용자가 폼을 통해서 입력한 파라미터 데이터이다. 웹 서버는 모든 데이터에 대해서 캐릭터셋 변환을 할 필요는 없으며, 파라미터에 해당하는 부분만 알맞게 변환해주면 되는 것이다. 따라서 NIO API를 사용하여 바이트 버퍼에 데이터를 읽어오데, 헤더에 해당하는 CharsetDecoder를 사용하여 읽어오고, 파라미터에 해당하는 부분은 별도로 데이터를 처리하는 방식을 채택하면 된다.

CharsetDecoder를 사용하여 데이터를 읽어올 경우 CharsetEncoder에서 버퍼의 길이에 신경을 써 주었던 것과 마찬가지로 버퍼의 길이에 신경을 써 주어야 한다. 앞에서 작성해보았던 CharsetTest2.java는 긴 길이의 데이터를 처리하지 못한다. 왜냐면 버퍼의 크기가 작기 때문이다. 긴 길이의 데이터를 읽어오기 위해서 버퍼의 길이를 무작정 늘리는 건 불필요하게 낭비되는 메모리의 양만 늘릴 수 있으므로, 적당한 크기의 버퍼를 사용하면서 동시에 긴 길이의 데이터를 CharsetDecoder를 사용하여 처리할 수 있어야 한다. 그러기 위해서는 CharsetEncoder와 마찬가지로 CoderResult를 사용해야 한다.

다음 코드는 CoderResult를 사용하여 긴 길이의 데이터를 디코딩하는 방법을 보여주고 있다.

    int len = -1;
    CoderResult result = null;
    boolean endOfInput = false;
    
    while ( true ) {
        len = channel.read(byteBuff);
        byteBuff.flip();
        if (len == -1) {
            endOfInput = true;
        }
        
        result = decoder.decode(byteBuff, charBuff, endOfInput);
        if (result == CoderResult.UNDERFLOW) {
            if (!endOfInput) byteBuff.compact();
        }
        
        if (result == CoderResult.OVERFLOW) {
            System.out.print(charBuff.flip().toString());
            charBuff.clear();
            byteBuff.compact();
        }
        
        if (endOfInput) {
            System.out.print(charBuff.flip().toString());
            break;
        }
    }

위 코드에서 보다시피, CoderResult.UNDERFLOW와 CoderResult.OVERFLOW를 적절히 사용하여 ByteBuffer와 CharBuffer를 알맞게 처리해주어야 한다. (인코딩하는 경우와 비슷한 것을 알 수 있다.) CharsetDecoder의 decode(ByteBuffer, CharBuffer, Boolean) 메소드를 사용할 때 또 하나 주의할 점은 이 메소드의 실행한 후 CharBuffer를 사용하려면 flip() 메소드를 사용하는 것이 좋다는 점이다. 이 점만 주의한다면 큰 문제없이 사용할 수 있을 것이다.

결론

이번 2부에서는 Charset 클래스를 이용하여 인코딩/디코딩을 처리하는 방법과 인코딩/디코딩 작업시 주의해야 할 점에 대해서 살펴보았다. 현재 자바 1.4의 Charset 클래스가 지원하는 캐릭터셋이 기본적으로 8개로 제한되어 있긴 하지만 Charset 클래스는 분명히 유용하게 사용할 수 있는 클래스 중의 하나이다. 그리고 앞으로 출시될 1.4.1 버전에서는 EUC-KR을 비록한 다양한 캐릭터셋에 대한 지원기능이 NIO API에 추가될 것이므로 반드시 알아두어야 하는 클래스 중의 하나이다.

다음 3부에서는 NIO API가 제공하는 기능중에서 '꽃'이라고 불릴만한 Selector에 대해서 살펴볼 것이다. 이 Selector는 이 글의 처음에 언급했던 논블럭킹 I/O를 구현하는 데 필수적인 클래스이다. 실제로 어플리케이션의, 특히 웹서버나 채팅 서버와 같은 서버 애플리케이션의 전체적인 처리량을 높이는 데 있어서 필수적으로 필요한 것이 바로 Selector이므로, 자바 고급 개발자로 거듭나기 위해서는 NIO 패키지의 다른 요소들과 더불어 이 Selector를 마음대로 사용할 수 있어야만 할 것이다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 몽몽이 2012.03.06 11:10 신고  댓글주소  수정/삭제  댓글쓰기

    MSWIN949를 Java를 이용해서 캐릭터셋 변환을 할 수 있을까요? 잘 안되네요

자바2 1.4에 새롭게 추가된 NIO API의 특징 중 버퍼와 채널에 대해서 살펴본다.

New I/O API

자바2 1.4에는 기존해 확장 API로 존재했던 보안 관련 API를 비롯하여 다양한 API가 포함되었으며, 정규표현식이나 로깅, 설정과 같이 어플리케이션을 개발하는 데 필요로 하는 다양한 기능들이 새롭게 추가되었다. NIO(New IO) API는 이렇게 1.4 버전에 새롭게 추가된 API 중의 하나인데, 반드시 알고 있어야 할 매우 중요한 API라 할 수 있다.

NIO API는 자바 1.3 버전까지 사용해왔던 기존 IO API와는 비교가 안될 정도로 성능, 확장성 등에서 뛰어나게 설계되어 있다. 특히 논-블럭킹(Non-blocking) 입출력과 데이터 버퍼링 기능을 사용하여 기존 버전에서는 상상도 할 수 없을 정도의 뛰어난 성능을 지닌 서버 프로그램을 개발할 수 있게 되었다.

본 글에서는 NIO API를 구성하고 있는 네 가지 기본 요소에 대해서 살펴보고, 네 가지 요소 중 버퍼와 채널에 대해서 살펴보도록 하자.

NIO API의 네 가지 핵심 요소

NIO API는 Buffer, Charset, Channel 그리고 Selector의 네가지 핵심 요소로 구성되어 있는데, 이들 네 요소는 각각 다음과 같은 기능을 제공한다.

  • Buffer - 버퍼를 나타낸다. 기본 데이터 타입에 대한 버퍼가 각각 존재하며 입출력 데이터를 임시로 저장할 때 사용된다.
  • Charset - 캐릭터셋을 나타낸다. 바이트 데이터와 문자 데이터를 인코딩/디코딩할때 사용된다.
  • Channel - 데이터가 통과하는 스트림을 나타낸다. 소켓, 파일, 파이프 등 다양한 입출력 스트림에 대한 채널이 존재한다.
  • Selector - 하나의 쓰레드에서 다중의 채널로부터 들어오는 입력 데이터를 처리할 수 있도록 해 주는 멀티플렉서(multiplexer)이다. 논블럭킹 입출력을 위한 핵심 요소이다.
Buffer

Buffer는 byte, char, int 등 기본 데이터 타입을 저장할 수 있는 저장소로서, 배열과 마찬가지로 제한된 크기(capacity)에 순서대로 데이터를 저장한다. Buffer는 데이터를 저장하기 위한 것이지만, 실제로 Buffer가 사용되는 것은 채널을 통해서 데이터를 주고 받을 때이다. 채널을 통해서 소켓, 파일 등에 데이터를 전송할 때나 읽어올 때 버퍼를 사용하게 됨으로써 가비지량을 최소화시킬 수 있게 되며, 이는 가비지 콜렉션 회수를 줄임으로써 서버의 전체 처리량(throughput)을 증가시켜준다.

NIO API는 다음과 같이 모든 버퍼가 상속받아야 할 한개의 추상 클래스와 8개의 Buffer를 제공한다.

java.nio.Buffer 모든 버퍼가 상속받는 추상 클래스
java.nio.ByteBuffer byte 타입의 데이터를 저장하는 버퍼. 다이렉트(direct) 버퍼와 논다이렉트(nondirect) 버퍼가 존재하며, ReadableByteChannel과 WritableByteChannel을 통해서 데이터를 입출력할 수 있다.
java.nio.MappedByteBuffer byte를 저장하는 버퍼로서 항상 다이렉트이다. 파일의 특정 영역을 메모리에 매핑시킬 때 사용된다.
java.nio.CharBuffer char를 저장하며, 다이렉트 또는 논다이렉트 버퍼일 수 있다. 채널에 쓸 수 없다.
java.nio.DoubleBuffer double을 저장하며, 다이렉트 또는 논다이렉트 버퍼일 수 있다. 채널에 쓸 수 없다.
java.nio.FloatBuffer float을 저장하며, 다이렉트 또는 논다이렉트 버퍼일 수 있다.
java.nio.IntBuffer int 데이터를 저장하며, 다이렉트 또는 논다이렉트 버퍼일 수 있다.
java.nio.LongBuffer int 데이터를 저장하며, 다이렉트 또는 논다이렉트 버퍼일 수 있다.
java.nio.ShortBuffer int 데이터를 저장하며, 다이렉트 또는 논다이렉트 버퍼일 수 있다.

위 표를 보면 boolean을 제외한 나머지 기본 데이터 타입에 대한 Buffer가 존재하는 것을 알 수 있으며, MappedByteBuffer를 제외한 나머지 버퍼들은 다이렉트이거나 논다이렉트 버퍼일 수 있다는 것도 확인할 수 있다. 다이렉트/논다이렉트 버퍼에 대해서는 잠시 뒤에 살펴볼 것이며, 여기서는 먼저 버퍼의 속성인 용량(capacity), 제한크기(limit), 위치(position)에 대해서 살펴보도록 하자.

다이렉트냐 논다이렉트냐에 따라서 다르지만, 추상적으로 Buffer는 배열과 비슷한 형태로 데이터를 저장하고 있다고 생각하면 된다. 이때 Buffer는 배열과 마찬가지로 포함할 수 있는 데이터의 총 크기를 갖고 있으며, 이를 용량(capacity)라고 한다. 또한, Buffer에서 다음에 읽거나 쓰는 부분을 위치(position)라고 한다. 대부분의 버퍼는 다이렉트 버퍼를 생성할 수 있는 메소드인 allocateDirect() 메소드를 제공하는데, 만약 다음과 같이 ByteBuffer를 생성했다면 그림1과 같이 byte 버퍼가 생성될 것이다.

  ByteBuffer buf = ByteBuffer.allocateDirect(8);

그림1 - 버퍼 생성시 용량, 제한크기, 위치의 초기값

그림1을 보면 초기 제한크기는 버퍼의 용량과 동일한 것을 알 수 있다. 일단 버퍼를 생성하면 버퍼에 데이터를 삽입할 수 있게 된다. 모든 Buffer 클래스는 데이터를 삽입할 때 사용되는 putXXX() 형태의 메소드를 제공하고 있다. 예를 들어, ByteBuffer의 경우는 putByte(byte b), putChar(char c) 등 다양한 데이터 타입을 바이트로 저장할 수 있는 메소드를 제공하고 있으며, 다른 Buffer 클래스들 역시 각각 알맞은 메소드를 제공하고 있다.

Buffer에 데이터를 저장하면 위치는 저장한 만큼 뒤로 이동하게 된다. 예를 들어, 다음과 같이 3 바이트의 데이터를 저장했다고 해 보자.

  buf.putByte( (byte)0xAB );
  buf.putShort( (short)0xCDEF ); 

이 경우 그림2와 같이 위치값이 3으로 변경된다.

그림2 - 데이터를 삽입한 이후의 위치값 변화

Buffer의 위치값이 제한크기(limit)와 같아지면 더 이상 Buffer에 데이터를 삽입할 수 없게 된다. 제한크기의 값은 limit(int newlimit) 메소드를 사용하여 변경할 수 있으며, 만약 위치가 제한크기까지 도달한 상태에서 데이터를 삽입하려고 하면 java.nio.BufferOverflowException이 발생할 것이다. 비슷하게 제한크기 이후에 위치한 데이터를 읽어오려고 할 경우에는 BufferUnderflowException이 발생하게 된다. 즉, 제한크기는 실제 버퍼의 크기인 용량에 상관없이 사용자가 사용할 수 있는 가상의 버퍼 제한 영역을 표시한다고 생각하면 된다.

원하는 만큼 버퍼에 데이터를 삽입하는 이유는 삽입한 데이터를 사용하기 위해서이다. 각각의 Buffer 클래스는 데이터를 참조할 수 있는 메소드인 getXXX() 형태의 메소드를 제공하고 있다. 하지만, get() 메소드를 사용하거나 채널에 버퍼에 저장된 데이터를 출력하기 위해서는 먼저 위치값을 읽어올 데이터의 인덱스로 변경해야 하고 제한크기를 알맞게 변경해주어야 한다. 예를 들어, 그림2의 경우는 처음부터 데이터를 읽어오기 위해서는 위치값을 0으로 변경해주어야 하며, 또한 저장한만큼의 데이터를 읽어오기 위해서는 제한크기를 3으로 변경해주어야 한다.

위치값과 제한크기는 position(int) 메소드나 limit(int) 메소드를 사용하여 변경할 수 있지만, flip() 메소드를 사용하여 변경할 수도 있다. flip() 메소드는 제한크기를 현재 위치값으로 변경하고 난 후 위치값을 0으로 초기화해준다. 예를 들어, 그림2 상태에서 flip() 메소드를 호출하면 그림3과 같이 위치와 제한크기의 값이 변경된다.

그림3 - flip() 메소드 호출 후 위치값과 제한크기의 변화

flip() 메소드를 실행하면 버퍼의 처음부터 데이터를 읽어올 수 있게 되며, 또한 채널을 통해서 버퍼에 있는 데이터를 모두 출력할 수 있게 된다.

flip() 메소드 뿐만 아니라 Buffer는 rewind() 메소드와 clear() 메소드를 제공해주고 있다. rewind() 메소드는 위치값을 0으로 변경해주어 버퍼의 처음부터 사용할 수 있도록 해 주며, clear() 메소드는 위치값을 0으로 제한크기를 용량으로 변경해주고 버퍼의 내용을 비워주어 버퍼를 초기 상태로 만들어준다.

Direct vs Nondirect

Buffer에는 다이렉트 방식과 논다이렉트 방식이 존재한다. 다이렉트 방식의 Buffer는 연속된 메모리 블럭을 할당하며 원시 접근 메소드를 사용하여 메모리 블럭의 데이터를 읽고 쓴다. 반면에 논다이렉트 방식의 Buffer는 자바의 배열을 데이터 저장소로 사용한다.

ByteBuffer의 경우 allocateDirect() 메소드를 사용하여 다이렉트 버퍼를 생성하는데, 다이렉트 버퍼를 생성할 때에는 일반적인 자바 배열을 사용하는 경우 메모리를 할당하고 해제할 때 논다이렉트 버퍼에 비해 더 많은 시간이 소비되는 반면에 원시 메소드를 사용하기 때문에 입출력 처리 속도는 빠르다. 따라서, 다이렉트 버퍼의 경우는 크고 지속적으로 사용되는 버퍼에 알맞다.

논다이렉트 버퍼의 경우는 wrap() 메소드를 사용하여 생성한다. 예를 들어, ByteBuffer의 경우는 다음과 같은 방법으로 논다이렉트 버퍼를 생성한다.

   byte[] buff = new byte[512];
   ByteBuffer nonDirect = ByteBuffer.wrap(buff);

논다이렉트 버퍼는 자바의 배열을 사용하기 때문에 원시 메소드 호출이 이루어지지 않으며 자바의 기본적인 배열 접근 방식을 통해서 버퍼를 관리하게 된다. 논다이렉트 버퍼의 경우는 다이렉트 버퍼에 비해 메모리를 할당하거나 해제하는 시간이 적게 들지만, 입출력 속도는 원시 메소드를 사용하는 것보다 느리다. 따라서, 논아디렉트 버퍼는 임시적으로 사용하고자 할 때 주로 사용된다.

Channel

이제 버퍼가 실제로 사용되는 채널(channel)에 대해서 살펴볼 차례이다. 간단하게 말해서 채널은 데이터가 통과하는 쌍방향 통로라고 생각하면 되는데, 채널에서 데이터를 주고 받을 때 사용되는 것이 바로 버퍼이다. 채널에는 소켓과 연결된 SocketChannel, 파일과 연결된 FileChannel, 파이프와 연결된 Pipe.SinkChannel과 Pipe.SourceChannel 등이 존재하며, 서버소켓과 연결된 ServerSocketChannel도 존재한다.

채널은 기존에 존재하는 Socket, ServerSocket, FileInputStream, FileOutputStream 등은 그와 관련된 채널을 리턴해주는 getChannel() 메소드를 제공하고 있다. 모든 채널 클래스들은 public 생성자를 제공하고 있지 않으며, 따라서 채널을 생성하기 위해서는 기존의 스트림, 소켓, 서버 소켓 클래스의 getChannel() 메소드를 사용해야한다. SocketChannel이나 ServerSocketChannel의 경우에는 static 메소드인 open() 메소드를 제공하고 있는데, 이 메소드를 사용하여 해당 채널을 구할 수도 있다.

예를 들어, 간단한 파일 복사 프로그램을 FileChannel을 사용해서 작성해보자. 파일 복사 프로그램은 두 개의 FileChannel을 사용하여 작성하면 된다. 한 파일 채널은 파일로부터 데이터를 읽어올 때 사용될 것이며, 또 다른 파일 채널은 파일에 데이터를 출력할 사용된다.

FileChannel을 생성하기 위해서는 해당하는 스트림의 getChannel() 메소드를 사용해야 한다. 즉, 다음과 같이 FileInputStream.getChannel() 메소드와 FileOutputStream.getChannel() 메소드를 사용해서 FileChannel을 구하면 된다.

    FileChannel inputChannel = null;
    FileChannel outputChannel = null;
    try {
        FileIntputStream is = new FileInputStream(source);
        FileOutputStream out = new FileOutputStream(dest);
        inputChannel = is.getChannel();
        outputChannel = out.getChannel();        
        ...
        
    } catch(IOException ex) {
        //
    } finally {
        //
    }

일단 채널을 생성하면 Buffer를 사용하여 데이터를 입출력하면 된다. 다음은 버퍼를 생성한 후 inputChannel로부터 데이터를 읽어와 outputChannel로 데이터를 출력해주는 코드이다.

    ByteBuffer buffer = ByteBuffer.allocateDirect(512);
    int len = -1;
    while ( (len = inputChannel.read(buffer)) != -1) {
        if (len == 512) {
            buffer.position(0);
        } else {
            buffer.flip();
        }
        outputChannel.write(buffer);
        if (len == 512) {
            buffer.rewind();
        } else {
            buffer.clear();
        }
    }

채널의 사용이 끝나면 채널의 close() 메소드를 사용하여 채널을 닫아주어야 한다.

채널의 종류

현재 나와 있는 자바2 1.4 베타3 버전에는 다음과 같은 종류의 채널이 존재한다.

  • FileChannel - 파일에 대한 입출력 채널
  • Pipe.SinkChannel - 파이프에 데이터를 출력하는 채널
  • Pipe.SourceChannel - 파이프로부터 데이터를 입력받는 채널
  • ServerSocketChannel - 클라이언트의 연결 요청을 처리하는 서버 소켓 채널
  • SocketChannel - 소켓과 연결된 채널
  • DatagramChannel - DatagraSocket과 연결된 채널
이들 채널은 입출력이 동시에 이루어지는 것도 있고 또한 그렇지 않은 것도 존재하는데, 채널의 입출력 가능 여부는 그 채널이 어떤 인터페이스를 구현했느냐에 따라서 달라진다. 예를 들어, 입력이 가능한 채널은 ReadableByteChannel 인터페이스를 구현하도록 되어 있으며, 출력이 가능한 채널은 WritableByteChannel 인터페이스를 구현하도록 되어 있다. 따라서, 동시에 입출력이 가능한 SocketChannel의 경우에는 이 두 인터페이스를 모두 구현하고 있으며, 입력만 가능한 Pipe.SourceChannel의 경우에는 ReadableByteChannel 인터페이스만 구현하고 있다.

FileChannel과 MappedByteBuffer

FileChannel과 관련해서 알아두어야 할 것이 있는데, 그것은 바로 파일의 내용을 메모리에 매핑시켜서 버퍼로 사용할 수 있는 기능이다. FileChannel.map() 메소드는 파일의 특정 영역의 데이터가 매핑된 메모리 영역을 버퍼 영역으로 사용하는 MappedByteBuffer를 리턴해준다. MappedByteBuffer는 ByteBuffer를 상속받고 있으며, 물리적 메모리에 직접적으로 접근하는 다이렉트 버퍼의 일종으로서 ByteBuffer와 동일하게 동작한다. 그림4는 MappedByteBuffer의 기본 개념을 보여주고 있다.

그림4 - MappedByteBuffer

그림4에서 볼 수 있듯이 MappedByteBuffer는 파일의 특정 영역의 데이터를 메모리상에 표현하고 있는데, 여기서 중요한 점은 파일의 해당 영역의 데이터가 변경되며 메모리에 있는 데이터도 함께 변경된다는 점이다. (실제로는 하부의 운영체제에 따라서 변경여부가 결정된다.) 또한, MappedByteBuffer.force() 메소드를 사용하여 메모리에서 변경된 내용을 파일의 매핑된 영역에 반영할 수도 있다.

MappedByteBuffer는 FileChannel로부터 만들어지고, FileChannel은 FileInputStream, FileOutputStream 또는 RandomAccessFile로부터 생성된다. 여기서 FileInputStream, FileOutputStream 그리고 RandomAccessFile은 각각 입력, 출력 가능 여부가 다르다. 예를 들어, FileInputStream의 경우는 입력만 가능한 반면에 RandomAcceeFile은 입출력이 동시에 가능하도록 만들어질 수 있다.

이처럼 파일 입출력 스트림은 읽기/쓰기 여부가 스트림의 종류에 따라서 다르기 때문에 MappedByteBuffer도 읽기 전용과 읽기 쓰기 겸용으로 구분된다. 또한, 파일로부터 데이터만 읽어오고 파일 데이터의 변경 여부는 반영하지 않는 MappedByteBuffer도 존재한다. 이처럼 MappedByteBuffer에는 3가지 종류가 존재하기 때문에 FileChannel.map() 메소드는 다음과 같이 어떤 버퍼를 생성할 지의 여부를 파라미터로 전달받도록 되어 있다.

    public MappedByteBuffer map(FileChannel.MapMode mode, long position, int size)

위 메소드 정의에서 FileChannel.MapMode가 어떤 MappedByteBuffer를 생성할지의 여부를 지정하기 위해 사용된다. FileChannel.MapMode 클래스는 다음과 같이 3 개의 상수를 사용하여 생성할 MappedByteBuffer의 종류를 지정할 수 있도록 하고 있다.

  • FileChannel.MapMode.PRIVATE - 파일에 영향을 미치지 않고 받지 않는 버퍼를 생성한다.
  • FileChannel.MapMode.READ_ONLY - 읽기 전용 버퍼를 생성한다.
  • FileChannel.MapMode.READ_WRITE - 읽기/쓰기 버퍼를 생성한다.
FileChannel.map() 메소드의 두번째 세번째 파라미터는 각각 MappedByteBuffer에 저장할 파일의 시작 위치와 시작 위치로부터 읽어올 데이터 길이를 나타낸다.

결론

1부에서는 NIO API를 사용하는 데 있어서 가장 기본이 되는 버퍼와 채널에 대해서 살펴보았다. 버퍼를 사용함으로써 데이터를 입출력할 때 성능향상을 일으킬 수 있다. 또한, NIO API가 제공하는 버퍼는 물리적 메모리에 버퍼를 생성하고 원시 메소드를 통해서 접근하는 다이렉트 버퍼를 제공함으로써 자바 배열로는 제공할 수 없는 뛰어난 성능을 제공하고 있다.

본장에서는 단순히 채널이 데이터가 통과하는 연결통로라는 것에 대해서만 언급했는데, 실제로 NIO API의 주요 기능은 채널과 실렉터를 이용한 논블럭킹 입출력이다. 다음 2부에서는 Charset을 이용한 캐릭터셋의 변환과 Selector를 이용한 논블럭킹 입출력에 대해서 살펴볼 것이다.

관련링크:
Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 함경호 2014.11.08 16:26 신고  댓글주소  수정/삭제  댓글쓰기

    이글이 12년이나 됬네요. 이제 프로그래밍시작하는 입문자가 잘 읽고 많이 배워갑니다.
    감사합니다.

  2. k드래곤 2015.01.16 14:42 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다.
    java 1.2시절에 하다가 오랜만에 보니 이런게 생겨있네요
    너무 잘 정리해 주셔서 감사감사
    이 글 없었으면 겁나 영어공부만 할뻔 했어요... ^^

  3. 남준호 2016.02.17 14:00 신고  댓글주소  수정/삭제  댓글쓰기

    와 감사합니다!!!!!! 밀도있는 이해가 되는군요