메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

IT/모바일

고성능 서버 개발을 위한 애플리케이션 아키텍처 고찰 및 JDK 1.4 New I/O를 적용 II: JDK 1.4의 New I/O에 대하여

한빛미디어

|

2003-02-10

|

by HANBIT

10,996

저자: 전재성

다수의 클라이언트 접속을 처리하는 서버 프로그램의 Application architecture들은 몇 가지 전형적인 패턴을 따른다. 본 기사에서는 이러한 전형적인 Architecture 패턴과 해당 패턴이 가지는 문제점 및 성능 향상을 위한 Tip들에 대해서 알아보고, 아울러 JDK 1.4.x에서 도입된 New I/O의 개념과 이를 사용한 고성능 서버 개발에 대해서도 살펴보도록 하겠다.

본 기사에서는 JAVA언어의 I/O Stream, Network, Multithread와 같은 기본적인 개념을 이해하고 있는 개발자들을 대상으로 하기 때문에 이러한 개념 및 클래스에 대한 구체적인 설명은 생략하도록 하겠다. 아울러 본 기사에서 설명하는 내용의 대부분이 전적으로 필자의 견해이므로 이해대한 잘못된 부분이나 더 좋은 아이디어가 있으신 독자분들께서는 적극적으로 Feedback을 해주시면 좋겠다.


지난기사
고성능 서버 개발을 위한 애플리케이션 아키텍처 고찰 및 JDK 1.4 New I/O를 적용 I: 네트워크 프로그래밍 개요



JDK 1.4의 New I/O에 대하여

JDK1.4에서부터 추가된 New I/O (Buffer, Non-blocking I/O, channel, selector 등)를 통해 고성능 서버 개발이 한결 쉽게 되었다. 이번 기사에서는 JDK1.4에서 추가된 New I/O의 개념적인 내용과 Non-blocking I/O에 적용에 대해서 알아보겠다.

1. java.nio.Buffer 관련 클래스

JDK1.3.x 까지는 클라이언트의 Request를 읽기 위한 Buffer(보통은 byte array) 혹은 String 객체와 클라이언트에 Request 처리 결과를 Return해주기 위한 Buffer 혹은 String 객체를 따로 가져가서 매번 Request를 읽고 처리할 때마다 Buffer에 대한 Garbage collection이 발생하였다. 이는 고성능 서버 개발에 있어서 적지 않은 장애 요소였다. JDK1.4부터는 읽기/쓰기 공용의 Buffer관련 클래스들을 제공함으로써 빈번한 Garbage Collection을 예방할 수 있게 되었다.

java.nio 패키지에 보면 각 Primitive data type에 해당하는 8개의 Buffer 클래스를 제공한다. 자세한 설명은 레퍼런스를 참조하기 바라며 여기서는 간략하게 사용하는데 있어 필수적으로 알아야 할 limit, position, capacity 속성에 대해서만 알아보겠다.

capacity: Buffer할당 시[allocate(int capacity), allocateDirect(int capacity) 호출] Buffer 용량을 명시해야 하는데, 한번 할당된 버퍼는 임으로 용량을 재조정할 수 없다.

position: 읽거나 쓰여질( read or written) 다음 element의 index를 말한다.

limit: 읽거나 쓰여지지 않아야 할 첫 element의 index를 말한다. 현재 position이 limit 보다 크거나 같은 index의 element에 대해 쓰기를 수행할 경우 BufferOverflowException이 발생하고, limit 보다 크거나 같은 index에 대해 읽기 수행할 경우에는 BufferUnderflowException이 발생한다. 음수일 수 없고 capacity보다 클 수도 없다.

flip() 메소드: 버퍼에 쓰기를 마친 후 버퍼를 access하기 위해 반드시 호출되어야 한다. limit를 현재 position으로 설정한 후, position을 0으로 설정한다.

이해를 돕기위해 예를 들어 설명하겠다. ByteBuffer pBuffer = ByteBuffer.allocateDirect( 8 );

pBuffer.put( (byte)0xca );
pBuffer.putShort( (short)0xfeba );
pBuffer.put( (byte)0xbe );

pBuffer.flip();

여기서는 get() 메소드 호출 시 BufferUnderflowException이 발생하기 전까지 4byte가 return될 것이다.

2. Channel에 대하여

기존 InputStream, OutputStream 관련 클래스에서는 앞에서(java.nio.Buffer 관련 클래스) 언급된 Buffer를 위한 Read/Write 메소드를 제공하지 않는다. 대신에 Channel을 사용하여 Buffer에 Read/Write를 수행한다. Channel은 Read 혹은 Write가 가능한 디바이스, 프로그램, 네트워크에 대한 연결 포인트를 제공한다. 자세한 설명은 레퍼런스를 참조하기 바라며, 여기서는 중요한 3가지 클레스에 대해서 알아보도록 하겠다. ServerSocket 및 Socket 객체와 연결하여 비교해보면 쉽게 이해가 될 것이다.

ServerSocketChannel

java.nio.channels.ServerSocketChannel은 기존의 java.net.ServerSocket의 역할을 한다. 즉, listening socket을 만들고 클라이언트 연결을 받아들인다. socket()메소드를 제공하여 기존의 ServerSocket 객체를 리턴한다. 개발자가 직접 객체를 생성해서는 안되며 factory 메소드를 이용하여야 한다(ServerSocketChannel.open() 메소드 사용). accept() 메소드는 java.nio.channels.SocketChannel 객체를 리턴하한다(ServerSocket.accept()가 Socket 객체를 리턴하는 것 처럼) ServerSocketChannel은 Blocking 및 Non-blocking의 2가지 모드가 존재하는데, Blocking mode일 때는 accept()가 클라이언트의 연결이 있기 전까지는 blocking될 것이다. 만약 Non-blocking mode로 사용될 때는 즉시 Return을 하게 되는데 클라이언트의 연결이 있을 경우 SocketChannel를 연결이 없을 경우 null을 리턴한다. 보통은 Selector 객체와 같이 사용된다.

SocketChannel

연결된 클라이언트와 Read/Write를 위한 클래스이며 기존 java.net.Socket 객체 역할 이외에 Non-blocking mode가 추가 되었다. SocketChannel은 2가지 방법에 의해 생성될 수 있다. 첫째, open()에 의해 아직 연결되지 않은 SocketChannel객체가 생성된다. 이는 주로 클라이언트가 서버에 연결하기 위해 사용되어 진다. 둘째, ServerSocketChannel.accept()에 의해 생성되며 이는 서버가 연결된 클라이언트와 통신하기 위해 사용되어 진다. SocketChannel 역시 Non-blocking, Blocking의 2가지 모드가 존재한다. Blocking 모드일 경우, 다른 스레드에 의해 close될 때, Read/Write를 수행한 스레드는 interrupt된다. Non-blocking모드일 경우, Selector와 같이 사용되어 Read/Write에 따른 Thread blocking을 해소할 수 있다.

FileChannel

FileChannel은 ServerSocketChannel, SocketChannel처럼 java.nio.channels.SelectableChannel 객체를 상속하지 않는다. 이는 Non-blocking I/O를 제공하지 않음을 의미한다. 파일 I/O에 관련하여 기존 C프로그래머가 OS 레벨의 API를 사용하여 빠른 Read/Write가 가능했던 것처럼 VM이 최대한 Native API를 사용하여 많은 성능개선이 이루어졌다. 자세한 API는 레퍼런스를 참조하기 바라며, 여기서는 Memory map file에 대해서 알아 보겠다. Memory map file은 파일의 특정 부분을 Memory에 Mapping하는 걸 말하는데, 이는 기존 InputStream객체에 비해서 뛰어난 성능을 보장한다. 특히 전체 파일을 이동하거나 복사할 때 Channel 객체와 같이 사용되어 간단하면서도 빠르게 수행할 수 있다. 아래의 샘플코드를 참조하기 바란다.
public sendFile( String uri, Socket channel )
{
   try
   {
      File f = new File( baseDirectory, uri )
      FileInputStream fis = FileInputStream( f );
      FileChannel fc = fis.getChannel();

      int fileSize = (int)fc.size();
      MappedByteBuffer resBuffer = fc.map( FileChannel.MapMode.READ_ONLY, 0, fileSize );

      resBuffer.rewind();
      channel.write( resBuffer );
   }
   catch( FileNotFoundException fne )
   {
   }
}
3. Selector 및 Non-blocking에 대하여

Non-blocking I/O가 지원되기 전까지는 Stream에서 Read/Write가 가능한 시점까지는 Blocking이 되므로 언제 Read/Write를 언제 수행해야 할지를 알았어야 했다. Selector는 등록된 모든 Channel들의 I/O Event( connect, accept, read, write )를 감지할 수 있어 Read/Write가 가능한 Channel을 return해 준다. 이를 통하여 Multi-thread 적용 없이도 Non-blocking I/O가 가능하게 되었다.
// Selector와 Channel을 사용하여 클라이언트의 연결을 accept하는 스레드
class AcceptThread extends Thread 
{
     private ServerSocketChannel ssc;
     private Selector connectSelector;
     private ConnectionList acceptedConnections;

     public AcceptThread(Selector connectSelector, ConnectionList list,  int port)   
     throws Exception 
     {
         super("Acceptor");

         this.connectSelector = connectSelector;
         this.acceptedConnections = list;

         ssc = ServerSocketChannel.open();
         ssc.configureBlocking(false);

         InetSocketAddress address = new InetSocketAddress(port);
         ssc.socket().bind(address);

       // ACCEPT I/O Event 등록
         ssc.register(this.connectSelector, SelectionKey.OP_ACCEPT);
     }
   
     public void run() 
     {
         while(true) 
         {
             try 
             {
                  connectSelector.select();
                  acceptPendingConnections();
             } 
             catch(Exception ex) 
            {
                  ex.printStackTrace();
            }
         }
     }

     protected void acceptPendingConnections() throws Exception 
     {
          Set readyKeys = connectSelector.selectedKeys();

          for(Iterator i = readyKeys.iterator(); i.hasNext(); ) 
          {
               SelectionKey key = (SelectionKey)i.next();

              // Processing하는 도중에 Channel들의 Ready state 가 변경되면 
              // ConcurrentModificationException이 발생하므로 이를 방지하기 위해 remove한다.
               i.remove();

               ServerSocketChannel readyChannel = (ServerSocketChannel)key.channel();
               SocketChannel incomingChannel = readyChannel.accept();
               acceptedConnections.push(incomingChannel);
          }
     }
}
AcceptThread는 클라이언트 연결을 accept하기 위해 connectSelector를 사용한다. 클라이언트가 연결을 시도하면 connectSelector.select()가 return되고 selectedKeys()를 사용하여 Ready상태인 Channel들을 추출할 수 있게 된다. 다음으로 실제 Read/Write를 수행하는 ReadWriteThread에 대해서 알아보겠다.
class ReadWriteThread extends Thread 
{
     private Selector readSelector;
     private ConnectionList acceptedConnections;
     ...

     public void run() 
     {
          while(true) 
          {
              try {
                  registerNewChannels();

                  // READ가 가능한 Channel return
                  int keysReady = readSelector.select();

                  if(keysReady > 0) 
                  {
                      acceptPendingRequests();
                  }
              } catch(Exception ex) {
                  ex.printStackTrace();
              }
          }
     }

     // accept된 SocketChannel 객체를 Non-blocking mode로 설정하고 Selector에 등록한다.
     protected void registerNewChannels() throws Exception 
     {
          SocketChannel channel;
          
     while(null != (channel = acceptedConnections.removeFirst())) 
     {
          channel.configureBlocking(false);

          // read I/O Event 등록
          channel.register(readSelector, SelectionKey.OP_READ, new StringBuffer());
     }  
}

     // Selector에서 return한 READ 가능한 Channel에서 클라이언트 요청을 처리한다.
     protected void acceptPendingRequests() throws Exception 
     {
          Set readyKeys = readSelector.selectedKeys();

          for(Iterator i = readyKeys.iterator(); i.hasNext(); ) 
          {
              SelectionKey key = (SelectionKey)i.next();
              i.remove();

              SocketChannel incomingChannel = (SocketChannel)key.channel();
              Socket incomingSocket = incomingChannel.socket();

              ...
            
              String path = readRequest(incomingSocket);
              sendFile(path, incomingChannel);

              ...
          }
    }
위 코드처럼 Selector 와 Channel을 사용하면 하나의 Thread로 연결된 모든 클라이언트의 요청을 처리할 수 있다. 다음 기사에서는 성능향상 및 대용량 서버를 위한 팁에 대해 살펴볼 것이다.
TAG :
댓글 입력
자료실