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

한빛출판네트워크

회원가입이벤트 :: 한빛비즈 스페셜 멤버로 초대합니다! (이메일 수신동의, '한빛비즈' 관심브랜드 체크시 최대 5,000점 마일리지 적립)

IT/모바일

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

한빛미디어

|

2003-01-13

|

by HANBIT

10,649

저자: 전재성

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

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

네트워크 프로그래밍의 개요

1. UNIX & C 환경

네트워크 프로그래밍의 이해를 돕기 위해, 먼저 TCP/IP Socket의 가장 일반적인 UNIX&C 환경에서 네트워크 프로그래밍의 개괄적인 내용을 살펴보도록 하자. 본 기사에서는 JAVA를 중심으로 설명할 것이므로 자세한 부분은 UNIX Network Programming관련 서적을 참조하기 바란다.

UNIX 상의 C언어를 사용한 네트워크 프로그래밍 전형적인 모습은 아래와 같다.

① Server Side

socket() : Connection point를 만들고 생성된 소켓에 해당하는 File Descriptor를 Return함
bind() : 소켓 Address 및 Port를 결합
listen() : 클라이언트 연결을 기다린다
accept() : 클라이언트가 connect()로 연결을 하면, 클라이언트와 통신할 새로운 소켓을 생성하고, 생성된 socket에 File Descriptor를 할당 후 Return함
read() : 클라이언트 소켓으로부터 데이터를 읽음
write() : 클라이언트 소켓에 데이트를 보냄
close() : File Descriptor를 닫음


② Client Side

socket() : 서버와 동일
connect() : 특정 호스트, 포트로 접속을 시도함
read(), write() : 서버와 동일
IT EXPERT, 유닉스 시스템 프로그래밍


③ 프로세스 흐름 ([그림1] 참조)

서버는 socket()을 통해 하나의 서버 소켓을 생성하고, bind()함수를 통해 특정 포트를 결합한 다음, listen()을 호출하여 클라이언트 연결을 받아들일 수 있는 준비단계를 마치게 된다. 이후로 서버는 무한루프 내에서 accept()를 통해 새롭게 연결된 클라이언트 소켓을 생성하고 fork(), exec()를 통해서 자식 프로세스 혹은 스레드를 생성시키고 생성된 자식 프로세스가 클라이언트 요청을 처리하게 된다. 이때 프로세스간의 데이터 공유 및 Exclusive access를 위해 IPC(Inter Process Communication) 메커니즘을 사용한다. IPC에 대해서는 이 글의 범위에 벗어나므로 UNIX System Programming관련 서적을 참조하기 바란다.


[그림1] UNIX&C 환경의 네트워크 프로그램 구조

2. JAVA 기반

다음으로 JAVA언어에서 네트워크 프로그래밍의 개괄적인 내용에 대해 알아보도록 하겠다. 여기서는 JDK1.3.x를 기준으로 설명한다. I/O Stream, Network관련 패키지 및 클래스에 대한 이해가 필수적이지만 서두에서 언급한 것처럼 이 글에서는 설명하지 않겠다. 클래스의 자세한 설명은 JDK Reference를 참조하기 바란다.

UNIX&C환경과 비슷하지만 OOP의 장점을 살려 많은 부분이 객체를 통해 Encapsulation 되었다. 전형적인 모습은 아래와 같다.

① Server Side

서버 소켓 생성 - ServerSocket serverSocket = new ServerSocket( 8000 )
: 로컬 호스트의 8000번 포트에 서버 소켓을 생성하고 8000번 포트에 binding한 다음, 클라이언트의 연결을 listening한다.

Socket clientSocket = serverSocket.accept()
: 클라이언트의 연결을 받아들이고 Connection Point에 해당하는 클라이언트 소켓을 Return한다. 클라이언트의 연결이 있을 때까지 이 메소드를 호출한 Thread는 Blocking된다.

InputStream in = clientSocket.getInputStream()
OutputStream out = clientSocket.getOutputStream()
: 연결된 클라이언트와 Read/Write을 위한 스트림을 가져온다.

클라이언트 요청 처리 - in.read(), out.write() 부류
: 데이터 Read/Write 처리. read()경우, 클라이언트로부터 데이터의 입력이 있을 때까지 read()를 호출한 Thread는 Blocking된다.

② Client Side

클라이언트 소켓 생성 - Socket socket = new Socket( "211.47.65.13", 8000 )
: "211.47.65.13"의 8000번 포트와 연결할 소켓 생성하고 서버에 연결한다.

InputStream in = socket.getInputStream()
OutputStream out = socket.getOutputStream()
: 연결된 서버와 Read/Write를 위한 스트림을 가져온다

서버로 요청 보내기 및 처리결과 수신 - in.read(), out.write() 부류
: Read/Write 처리. read()경우, 클라이언트로부터 데이터의 입력이 있을 때까지 read()를 호출한 Thread는 Blocking된다.

③ 프로세스 흐름 ([그림2] 참조)


[그림 2] 자바 네트워크 프로그램 구조

서버는 Listening할 포트번호를 생성자로 ServerSocket 객체를 만든다. 객체의 생성자 내에서 C Socket의 bind(), listen()과 같은 기능을 차례대로 수행한다. 다음으로 서버는 무한루프 내에서 ServerSocket객체의 accept() 메소드를 호출하여 클라이언트 연결을 받아 들인다. 이때 클라이언트의 연결이 있을 때까지 accept() 메소드를 호출한 Thread는 blocking된다. 클라이언트의 연결을 받아들이면 accept() 메소드는 클라이언트와 통신할 Socket객체를 return하고 이 Socket 객체를 가지고 Worker Thread를 생성하여 연결된 클라이언트의 요청을 처리하고 처리된 결과를 클라이언트에게 송신한다. 이 과정에서 몇 가지 전형적인 패턴이 있다. 이러한 패턴들에 대해서는 2.2절을 참조하기 바란다.

다음으로 클라이언트에 대해서 알아보겠다. 클라이언트는 연결할 remote host와 포트 번호를 생성자로 Socket객체를 만든다. 이 객체의 생성자에서는 C Socket의 connect()와 같은 기능을 수행하여 서버로 접속을 시도한다. 서버에 접속이 연결되면 생성자가 return되어 Socket객체가 만들어지고 Socket객체로부터 input stream, output stream을 얻고 특정 조건동안 루프를 반복하며 서버에 요청을 보내고 처리된 결과를 수신한다.

2.1 Blocking I/0란?

Blocking I/O는 특정 Device( Disk, Keyboard, Network ... )에서 Read/Write수행시, data가 이용 가능할 때까지 Read/Write를 수행한 Process or Thread는 Blocking 즉, 아무것도 하지 않는 것을 말한다. 예를 들어 소켓으로부터 데이터를 읽어 들이기 위해 BufferedReader.readLine()을 호출할 경우, 개행문자를 읽을 때까지 readLine()을 호출한 Thread는 Blocking될 것이다. 마찬 가지로 디스크에 대량의 데이터를 기록하려고 할 때, 데이터 기록이 완료될 때까지 해당 메소드를 호출한 Thread는 Blocking될 것이다.

JDK1.3.x 까지는 이러한 Blocking I/O를 해결할 수 있는 API가 제공되지 않았다. 이러한 API에 대한 요구가 많이 있었지만 JDK 1.4버전에서 들어와서야 Non-blocking I/O의 개념이 도입되면서 해결이 되었다. JDK1.4의 Non-blocking I/O에 대해서는 다음에 알아보도록 하겠다. 서버 프로그래밍의 경우, 먼저 클라이언트의 요청을 읽고 각 요청에 맞는 처리를 한 다음 그 결과를 클라이언트에 보내주는 구조이므로, 물론 서버에서 특정 Event시(예, 채팅방 입장 시) Push를 해주는 경우도 있지만 연결된 모든 클라이언트에 대한 읽기 요청시 Non-blocking I/O의 구현은 필수적인 사항이다.

이러한 Blocking I/O를 해결하기 위한 몇 가지 방법들이 있는데 다음에서 알아보기로 한다.

2.2 Non-blocking Server 만들기

① Socket객체의 setSoTimeout() 메소드 사용

Socket객체는 setSoTimeout( int timeout )메소드를 제공하는데 이 메소드를 사용하면 클라이언트 요청을 읽을 때 클라이언트로부터 아무런 입력이 없이 timeout ( millisecond ) 경과 시 InterruptedIOException Exception을 발생시킨다.

ServerSocket#accept()에서 만들어진 Socket 객체를 List류의 객체( Vector, ArrayList,..)에 담고 첫 Element부터 마지막 Element까지 loop를 돌면서 read()류의 메소드를 사용한다. 이 때 주의할 점은 List에 담을 클라이언트 개수를 적절하게 제한하는 것이다. 이유는 클라이언트들의 Request를 읽는데 각 소켓의 setSoTimeout()에서 지정된 millisecond x 클라이언트 개수 만큼의 시간을 소비하기 때문이다.

이 경우, 먼저 서버는 클라이언트 Request를 처리할 적절한 수의 Thread를 만든다. 해당 Thread는 연결된 클라이언트들에 대한 Socket객체를 담을 List를 가지고 있다. ServerSocket.accept()를 통해 연결된 Socket객체를 Thread 내의 List에 담고 이 Thread는 적절한 루프 내에서 연결된 클라이언트로부터 요청을 읽고 처리한 다음 결과를 다시 클라이언트에 Write한다.

아래의 Skeleton Code 을 참조하기 바란다. 아래 코드는 사용 예만 보여주기 위한 코드이므로 실제 컴파일 되어 실행되지는 않는다.
class Server
{
    int CLIENT_PER_THREAD = 10;
    long clientCount = 0; 
    ProcessThread pThread[] = new ProcessThread[CLIENT_PER_THREAD]; 
ServerSocket serverSocket;
    ...
    public Server()
    {
        serverSocket = new ServerSocket( 8000 );
        // pThread[] 초기화
    }

    public void acceptClient()
    {
while(true)
{
   Socket clientSocket = serverSocket.accept()
   try{
       pThread[++clientCount % CLIENT_PER_THREAD].addClient( clientSocket );
   }catch( TooManyClientException e ){
       // 스레드 수를 늘린다..
   }
}
}
}

class ProcessThread extends Thread
{
    int MAX_CLIENT = 100;
    int cnClients = 0;
    
Hashtable clientLists = new ArrayList();
ArrayList requestLists = new ArrayList();

Public ProcessThread()
{
   new WriterThread().start();
}

    public addClient( Socket clientSocket ) throws TooManyClientException
    {
        if( cnClient > MAX_CLIENT )
            throw new TooManyClientException( "Too many client exception" );

        synchronized( clientLists )
        {
            cnClient++;
            clientLists.put( clientSocket.getInetAddress().getHostAddress(), clientSocket );
        }
    }

    public void run()
    {
while( true )
        {
if( clientList.size() == 0 )
{
    sleep( 1000 );
    continue;
}

synchronized( clientLists )
{
Enumeration enum = ((Hashtable)clientLists.clone()).keys();
while( enum.hasMoreElements() )
{
Socket clientSocket = (Socket)enum.nextElement();
   BufferedReader reader=new BufferedReader(clientSocket.getInputStream());
   Request req;
    try{ 
        // 클라이언트 Request는 개행문자로 구분되어 있다고 가정..
req = RequestParser.parse( reader.readLine() ); 
if( req != null )
requestLists.add( req );
else
 clientLists.remove( clientSocket.getInetAddress().getHostAddress() );
}
    catch( IOException ioe )
{
 clientLists.remove( clientSocket.getInetAddress().getHostAddress() );
}
}

synchronized( requestLists ){ try{requestList.notify();}catch(Exception e){} }
}
        }
    }

    // Inner class for writing thread
    class WriterThread extend Thread
    {
        public void run()
        {
            Request req = null;
            Result res = "";

            while( true )
            {
               if( requestLists.size() == 0 )
               {
                  synchronized( requestLists ) { try{requestLists.wait();}catch(Exception e){} }
}
else
   req = requestLists.removeElementAt(0);

if( req != null )
   res = RequestProcessor.process( req );

Socket target = (Socket)ruquestLists.get( res.who() );
new PrintWriter(target.getInputStream()).writerln( res.getResult() );
req = null;
            }
        }
    }
}
문제점

연결된 클라이언트의 요청을 읽는데 많은 시간이 걸린다. 따라서 스레드 당 연결할 클라이언트 개수를 가능한 적게 가져가는 것이 바람직하다. 전체 스레드 수를 줄여 많은 동시사용자의 접속을 수용할 수 있다. 하나의 InputStream, OuputStream, 및 Buffer, String 객체가 연결된 모든 클라이언트들의 Read/Write를 처리하므로 Garbage collection이 빈번하게 일어나 성능저하로 이어질 것이다. 또한 연결된 클라이언트의 요청처리와 처리결과 송신을 분리해야 하므로 프로그램 개발의 난이도가 증가할 것이다.

Multithreaded서버와 함께 제일 많이 사용되는 기법이다.

② InputStream의 available() 메소드 사용 클라이언트의 요청을 읽기 전에 InputStream객체의 available() 메소드를 사용하여 읽을 데이터가 있는지 먼저 체크한다. 이 경우 미리 정한 byte 수만큼 읽어 들여야 하기 때문에 Buffer처리를 프로그램내에서 해주어야 한다. 거의 사용하지 않는 방법이다.

코드는 생략한다.

③ JNI를 사용하여 native select() method 사용

버클리 소켓에서는 select() 함수를 제공하는데, 이 함수는 Read/Write가 가능한 socket에 대해 자바의 Socket, ServerSocket과 같은 File Descriptor를 return해 준다. 이 함수는 현재 연결된 클라이언트들 중에서 즉시 읽어 들일 데이터가 있는 클라이언트들을 Return해 줌으로써 가능한 클라이언트에 대해서만 Read/Write를 수행하여 프로세스는 Blocking되지 않는다.

JNI를 사용해야 하고 구현이 어렵기 때문에 이 방법 역시 자바에서는 거의 사용하지 않는 방법이다. 대신에 JDK1.4.x 에서는 select()와 같은 API를 제공하기 때문에 이를 사용하면 된다. 이 방법은 다음 기사에서 언급된다.

④ Multithreaded 서버

만약 Multi-thread를 적용하지 않고 Blocking I/O를 해결하기 위해서는 ,과 같은 방법들이 있으나 구현 자체가 어렵고 이를 구현했을 경우, 많은 기능상의 제약들로 인해 거의 사용하지 않는 방법들이다. Multi-thread의 적용 없이 InputStream객체의 read()류의 메소드를 호출했다면 어떻게 되겠는가? read()류의 메소드를 호출한 순간 해당 Thread는 읽을 데이터가 있을 때까지 Blocking될 것이다. 서버 프로그래밍에서 Multi-thread의 적용은 필수적이다.

의 방법이 스레드 개수를 제한시켜 하나의 스레드에서 여러 클라이언트들의 요청을 읽고 처리하여 다수의 동시사용자 접속을 처리할 수 있는 방법이라면, Multithread서버의 경우는 하나의 클라이언트 당 하나의 스레드를 두어 연결된 클라이언트의 모든 요청을 전담 스레드에서 처리하게 함으로써 프로그램 로직의 구현이 쉽고 적정규모의 동시사용자 환경에서 Object Pooling기법 적용과 함께 최상의 성능을 낼 수 있다. Object Pooling 기법에 대해서는 다음에 자세하게 설명하겠다.

문제점

이 경우는 연결된 클라이언트당 하나의 스레드를 둠으로 인해 무한정 스레드가 늘어나게 된다. 스레드는 자체적으로 Stack을 가지고 있고 CPU 자원을 소모하기 때문에 해당 시스템에서 스케쥴링할 수 있는 Active Thread 개수는 물론 시스템의 성능에 따라 다르겠지만 많아야 수백 정도일 것이다(실제 테스트를 해보지 않아서 확신할 수는 없음).

관련된 코드는 쉽게 찾을 수 있기 때문에 생략한다.
TAG :
댓글 입력
자료실