티스토리 툴바


개요

 Java에서 Network Programming을 할 때 기본적으로 필요한 사항들을 확인해보고 기본적으로 알고 있어야 하는 사항들을 숙지해보고자 한다.

 

개인적으로 Network Programming을 하는 데 있어서 명확하게 기술의 경계를 구분하는 것이 중요하다고 생각해 다음과 같이 세부분으로 분리해서 보고자 한다.

 

먼저 Network를 통해 데이터를 주고 받기 위해서는 두 지점간의 end to end 통신을 할 수 있도록 지정된 표준 Socket 프로토콜을  구현한 Socket에 대한 부분과 항상 프로그래밍의 화두가 되는 I/O 즉 입력과 출력에 관한 처리 부분, 그리고 Network를 통해 주고 받는 데이터를 효율적으로 다룰 수 있도록 하는 자료구조에 관한 부분으로 나누었다.

 

Socket

 자바의 소켓은 RFC1928(V4, V5)에서 정의된 소켓 프로토콜 구현체를 이용하며, 구현된 자바 코드를 따라가다 보면 PlainSocketImpl에서 native로 선언된 socketCreate 메소드에 닿게 된다. 짐작이긴 하지만 실제 자바 소켓의 구현체는 이 native코드에 의해 자바에 바인딩될 것이라고 생각한다.

 

 구현체를 보면 소켓을 생성하는 것 외에도 bind, listen, accept, available, close, shutdown, setOption, getOption등이 native 코드로 연동하는 부분임을 확인할 수 있었다.

 

 Socket 클래스의 API 문서에서는 SocketImpl class의 인스턴스를 생성하여 Socket과 관련된 처리를 한다라고 명시되어 있다. SocketImpl은 위에서 말한 것과 같이 native 코드에 바인딩된 native socket 구현체를 가지고 있으며, JVM이나 OS에 따라 구현체가 다를 수도 있다.

 

 Socket에는 기본적으로 TCP방식과 UDP방식이 있는데 Java에서는 UDP방식을 이용할 경우 이를 별도의 DatagramSocket을 이용하도록 권고하고, 기존에 Socket의 생성자 정의에서 stream 여부를 조정해서 TCP와 UDP 소켓을 구분하도록 하는 방식을 deprecated 했다.

 

아마도 대부분은 일반적인 Socket class를 이용할  텐데 소켓 통신 처리 자체를 blocking으로 할 것인지 non-blocking으로 할 것인지에 따라 전체적인 구조에 많은 영향을 미칠 수가 있다.

 

blocking과 non-blocking의 차이는 프로그래밍 제어 흐름 상에서 각 실행 명령들이 끝날 때까지 멈추어 서느냐 그렇지 않느냐의 차이일 뿐 결국에는 동일한 Socket에 대한 명령이 실행되는 것이며, 단일 Socket 처리만 놓고 봤을 때는 Blocking이 Non-Blocking에 비해 리소스를 덜 사용할 것이므로 오히려 더 빠르게 처리될 것이다.

 

 Non-Blocking을 Asynchronous와 혼동하는 경우가 간혹 있는데 둘은 분명한 차이가 있다. 

 

Java에서는 Non-Blocking 모드의 소켓 처리를 위해서는 SocketChannel이라는 class를 이용하는데 실제로 SocketChannel의 실제 구현체인 SocketChannelImpl 의 코드를 직접 들여다 본다면 결국 SocketImpl과 거의 동일한  구현임을 알 수 있다. API 설명에도 나와있지만 SocketChannel은 Socket과 Association 관계라는 것이다. 

 

소켓은 기본적으로 두 머신간의 규약에 맞춰 연결을 체결하는 작업을 수행하고, 그 연결을 통해 서로의 데이터를 교환하도록 규정된 프로토콜을 구현한 프로토콜 구현체이다. 그리고 체결 비용은 꽤 높은 편이다.

 

미리 소켓 연결을 체결해 두고, 재사용하기 위해 Object Pooling을 이용하는 방식이 전통적으로 아주 훌륭하게 통용되는 이유이기도 하다. Lazy Connection 등의 전략을 추가하는 것도 고려해 볼만한 사항이지만 전체적으로 왜 필요한지에 대한 이해가 선행되어야 할 것이다.

 

I/O Model

일반적인 프로그래밍에서는 I/O 모델에 대한 고민을 거의 하지 않는데 그 이유로 들 수 있는 것이 결국 효율에 있다고 생각한다. I/O의 속도는 L1 Cache -> L2 Cache -> Memory -> Hard disk -> Network 순으로 느려진다.

 

I/O 모델을 적용하는데 있어서 오해하지 말아야 할 부분은 I/O 모델이 Network Programming에서만 사용할 수 있다라는 믿음이다. 이것은 선택의 문제이고 실제로 파일 I/O에서도 동일한 I/O 모델을 적용할 수 있다. 구현의 난이도에 비해 크게 나은 성능을 나타내지 못하는 것이 문제이고 결국 효율의 문제이다. 

 

Network Programming을 처음 접하게 되면서 I/O Model에 대한 고민을 시작하게 되는 가장 첫번째 이유가 바로 속도 문제일 것이다. Application이 아무리 빠르게 데이터를 처리하더라도 비효율적인 Network 전송으로 인해 전체 성능이 저하되는 경우가 많기 때문이다.

 

Java에서는 jdk1.4 Version 이후 부터 다양한 I/O 모델을 적용하기 위한 노력을 해왔고, jdk 1.7에 와서야 겨우 Asynchronous I/O 모델을 사용할 수 있게 되었다. 

 

I/O 모델은 이론적으로 Blocking I/O, Non-Blocking I/O와 Synchronous I/O, Asynchronous I/O로 나눌 수 있다. 일반적으로 선택하는 select나 poll, epoll과 같은 구현체들은 이러한 I/O 기본 모델을 바탕으로 특징지어지며, 가장 적합한 구조에 맞게 단순하게 뭉뚱그려서 Non-Blocking Asynchronous I/O 모델등으로 불리게 된다. 굳이 이것이 사실 관계를 잘못 알고 있다라고 말하기 보다는 일반적으로 통용되는 용어를 사용하기 때문으로 크게 문제될 만한 부분은 아니라고 보는 시각을 가지고 있다. 그래도 모르는 것보다는 나을 테니 I/O models 를 참조해 보도록 하자.

 

이론적인 베이스는 이렇고 실제로 Java에서는 어떤 I/O 모델을 사용할 수 있는가라는 문제로 접어들면 위에서도 잠깐 언급한 것과 같이 jdk7에서 리눅스/유닉스 계열의 aio와 윈도우즈의 IOCP를 Wrapping한 AsynchronousSocketChannel을 이용한 Non-blocking Asynchronous I/O 모델을 이용할 수 있겠다.

 

아직은 도입이 꺼려지는 입장에서는 어쩔 수 없는 선택으로 jdk1.4에서 도입된 SocketChannel 클래스를 이용해 select 모델을 구현해볼 수 있겠다. 그 이전인 jdk 1.3 버전 대라면 선택의 여지는 없다. 

 

select 모델은 내부적으로 파일을 사용하는데 시스템이 지원하는 File의 Max 개수만큼 처리가 가능하다. non-blocking 모드로 사용하지만 실제로 I/O 이벤트 처리 구간에서는 blocking이 될 수 밖에 없는 구조라 별도의 쓰레드에서 폴링하도록 구현해야 한다.

 

기존의 I/O 모델이 100개의 I/O처리를 위해 100개의 쓰레드를 생성해야 하는 것에 비해서는 상당히 효율적인 방식임에는 분명하다. 일정 개수 이상의 처리를 하게 되면 File Description을 체크하는 select()의 Polling 처리부에서 성능저하가 발생하므로 모든 문제의 해결책은 될 수 없음이 분명하다. 그리고 각 요청당 워킹 쓰레드를 할당해야 하니 결국 반타작이다. select()하는 부분을 멀티쓰레드로 구현하는 경우가 종종 있는데 이느 성능을 떨어뜨리는 결과만 낳을 뿐이며, 각 쓰레드를 동기화하지 않는다면 어떤 문제가 생길지도 모른다.

 

분명한 점은 select는 엄밀히 말해 Non-blocking Synchronous I/O 모델이라는 점이다. 다시 말해 I/O를 실행하는 부분에서는 non-blocking으로 제어흐름 상에서 요청하고 바로 빠지는 구조이지만 실제로 I/O가 가능한 상태 또는 완료 상태를 확인하는 부분은 동기화해서 처리될 수 밖에 없다는 것이다.

 

I/O models에서 Green Thread인 경우에는 Asynchronous I/O가 된다고는 하지만 자바는 jdk1.2 때 부터 Native Thread로 변경되었기 때문에 Synchronous I/O가 될 수 밖에 없다.

 

덧붙이자면 Non-blocking, Synchronous I/O 모델은 과도한 요청이 발생했을 때 (그 요청이 Recv가 되든 Send가 되든) 필연적으로 성능 저하와 지연 시간 증가로 이어지게 된다. 이 문제를 해결하는 방법은 요청 Throttle을 조절하는 방법 정도가 될 것 이다.

 

Data Structure

 일반적인 프로그래밍에서도 데이터를 처리하기 위한 자료구조는 중요하다. 하지만 Network Programming에서 자료구조는 전체 성능을 좌우하는 결정적인 역할을 한다고 해도 과언은 아니다.

 

어차피 Java에서 Socket이나 Thread, I/O 모델들은  Native로 구현된 OS레벨 또는 Native Code 레벨의 성능에 의해 좌우된다. 프로그래밍 구조적인 문제가 없다면 개별 구현에 대한 성능적인 차이가 극히 미미하다고 볼 수 있다.

 

jdk1.4 버전에서 SocketChannel과 함께 업데이트된 중요한 클래스가 또 하나 있다면 바로 ByteBuffer이다.  기존에는 Socket으로 부터 데이터를 퍼올리는 자료구조로 primitive 타입의 byte를 배열로 사용했는데 적당히 구조화 하지 않는 다면 byte 처리만 가지고도 한참을 씨름해야 했다. 무엇보다 ByteBuffer가 byte 배열을 이용하는 것보다 나은 선택이 될 수 있는 이유는 바로 allocateDirect() 에 있을 것 같다. 

 

API 문서의 설명에 ByteBuffer에서의 Direct vs Non-Direct Buffers 항목으로 메모리를 할당하는 부분에 대한 설명이 간략하게 나와 있다. Direct 의 경우 메모리의 할당을  OS의 시스템 메모리로 Direct Allocate 하는 Native 구현체를 JNI(Java Native Interface)를 통해 ByteBuffer의 Implementation 객체로 바인딩하는 방식이다. ByteBuffer는 세가지 메모리 할당 메소드를 제공하는데 wrap, allocate, allocateDirect가 그것이다. 실체는 각각의 ByteBuffer class를 상속받아 구현한 Implementation들에 있고 wrap과 allocate는 Java가 관리하는 Heap에 allocateDirect는 native 코드를 통해 시스템 메모리에 직접 공간을 할당한다. 

 

ByteBuffer의 native 구현체에 바인딩되어 있는 ByteBufferImpl의 내용을 확인했을 때 당연하겠지만 메모리할당 뿐 아니라 access하는 부분도 native로 구현되어 있음을 확인했고 당연히 JNI특성상 빈번하게 호출되는 경우 성능적 저하가 발생할 거란 생각이 들어 테스트 코드를 만들어 실행 했더니 역시나 바이트 단위로 데이터를 바인딩하는 부분에서 성능이 오히려 저하됨을 확인했다. 다른 사람의 벤치마크 사례도 함께 링크한다.

 

윈도우즈에서 테스트한 결과라서 기존 벤치마크 결과들과 상당한 차이를 보이는 것으로 보고 좀더 다양한 방식으로 테스트를 해봐야 할 것 같아 이후로 미루도록 하겠다. 이해를 돕기 위해 실행 결과와 소스코드를 첨부한다.

 

환경:

Intel(R) Core(TM) i5-2500 CPU Duo core 3.3GHz

Memory 4.00GB

Windows7 Enterprise K SP1 64bit

 

결과: JVM 1.6

ByteBuffer allocate 7 ms

ByteBuffer allocateDirect 28 ms

ByteBuffer fill non-direct 1061 ms

ByteBuffer fill direct 5025 ms

ByteBuffer bulk fill non-direct 544 ms

ByteBuffer bulk fill direct 511 ms

 

결과: JVM 1.7

ByteBuffer allocate 7 ms

ByteBuffer allocateDirect 19 ms

ByteBuffer fill non-direct 1072 ms

ByteBuffer fill direct 5233 ms

ByteBuffer bulk fill non-direct 506 ms

ByteBuffer bulk fill direct 514 ms

 

 

 


  1.  

    importjava.nio.ByteBuffer;

     

    publicclass ByteBufferTest {

    intmMax = 10000000;

    intallocateMax = 10000;

    intmemSize = 1024;

    byte[] data = newbyte[memSize];

     

    public ByteBufferTest() {

    for (int i = 0; i < memSize; i++)

    data[i] = (byte)0xFF;

            }

     

    publicvoid allocate(){

              ByteBuffer buffer;

     

              start();

    for(int i = 0 ; i < allocateMax ; i++){

                     buffer = ByteBuffer.allocate(1024);

              }

              end();

              print("ByteBuffer allocate");

     

              start();

    for(int i = 0 ; i < allocateMax ; i++){

                     buffer = ByteBuffer.allocateDirect(1024);

              }

              end();

              print("ByteBuffer allocateDirect");

        }

     

    publicvoid readwrite(){

              ByteBuffer buffer = ByteBuffer.allocate(1024);

              ByteBuffer direct_buffer = ByteBuffer.allocateDirect(1024);

     

              start();

    for(int i = 0 ; i < mMax ; i++){

                     buffer.clear();

    for(int j = 0 ; j < buffer.capacity() ; j++){

                            buffer.put((byte)0x11);

                     }

              }

              end();

              print("ByteBuffer fill non-direct");

     

              start();

    for(int i = 0 ; i < mMax ; i++){

                     direct_buffer.clear();

    for(int j = 0 ; j < direct_buffer.capacity() ; j++){

                            direct_buffer.put((byte)0x11);

                     }

              }

              end();

              print("ByteBuffer fill direct");

        }

     

    publicvoid bulkreadwrite(){

            ByteBuffer buffer = ByteBuffer.allocate(memSize);

            ByteBuffer direct_buffer = ByteBuffer.allocateDirect(memSize);

     

            start();

    for(int i = 0 ; i < mMax ; i++){

                   buffer.rewind();

                   buffer.put(data);

            }

            end();

            print("ByteBuffer bulk fill non-direct");

     

            start();

    for(int i = 0 ; i < mMax ; i++){

                   direct_buffer.rewind();

                   direct_buffer.put(data);

            }

            end();

            print("ByteBuffer bulk fill direct");

      }

     

    publicvoid running(){

              allocate();

              readwrite();

              bulkreadwrite();

        }

     

    staticlongstart;

    staticlongend;

    publicstaticvoid start() {

    start = System.currentTimeMillis();

            }

     

    publicstaticvoid end() {

    end = System.currentTimeMillis();

            }

     

    publicstaticvoid print(String desc) {

                   System.out.println(String.format("%s %d ms", desc, (end-start)));

            }

     

    publicstaticvoid main(String[] args) {

    newByteBufferTest().running();

        }

    }

     

 

자료구조와 관련된 이야기가 나오면 말이 길어질 수 밖에 없는데 자료구조의 구현 형태와 성능, 안정성등을 고려해야 하기 때문이며, 앞서 말한 바와 같이 Application의 전체 성능을 실제로 좌우하게 되는 가장 중요한 역할을 하게 되기 때문이다.

 

Network를 통해 전송 받은 데이터는 필연적으로 이를 이용할 수 있는 자료구조로 바인딩되어야 한다. 그리고 실제로 그 데이터를 처리하는 모듈로 넘기는 동안 데이터의 복사가 일어날 것이다. 그리고 가공된 데이터를 다시 Network를 통해 전송하기 위해 버퍼 공간으로 가공된 데이터의 복사가 일어난다. 이 부분에서는 성능적인 이슈가 그리 크지는 않다. 개인적인 생각으로 byte[] 를 사용하더라도 큰 성능적인 저하는 없을 것이라고 본다. 하지만 직접 Socket I/O에서 데이터를 복사하기 위한 자료구조는 순차적인 데이터를 빠르게 채울 수 있는 자료구조를 사용하는 것만으로도 성능의 향상이 체감될 정도로 빨라질 수 있다.

 

그 외에도 자료구조가 중요한 점은 워커 쓰레드간의 공유자원 접근, Connection Pool 관리, Process Queue 등에서 나타나는데 동기화 처리를 위한 고려는 자료구조가 이상적으로 제공하는 유형을 사용하는 것이 좋다고 본다.

 

JDK1.5에서 java.util.concurrent 패키지가 선보여졌는데 현재 JDK1.7에 이르기 까지 계속 추가되고 있고 발전하는 유용한 패키지이다. 물론 직접 구현하는 것도 하나의 방법이지만 간단하게 자주 사용하던 ConcurrentLinkedQueue의 경우만 가지고도 내부에서 구현되는 알고리즘을 직접 구현하는 비용을 고려했을 때 분석하는 비용보다 구현하는 비용이 훨씬 클것이라고 이야기할 수 있다.

 

어느 언어로 작성하더라도 자료구조는 그 특성을 제대로 파악하는 것이 중요하며, 최근에는 Java의 자료구조의 비효율을 대체할 수 있는 유용한 라이브러리등이 많이 있으니 자료구조로 인한 성능적인 저하에 대응할 수 있을 것이다. 참고로 Java의 nio에 대해 오해하는 경우가 많은데 nio의 정확한 의미는 new i/o이며, 기존의 java.io 패키지가 가지는 문제점등을 보완하고 대체하기 위한 용도로 추가된 패키지이다.

 

정리

 

Java에서 Network Programming에 필요한 사항들에 대한 기본적인 내용들을 정리해 보면서 결국 기술 선택의 폭이 상당히 좁다는 느낌을 버릴 수가 없다.

 

Java로서는 성능이 떨어진다는 오명을 벗기 위해 상당히 많은 부분을 native 코드로 전환하는 모습을 보이고 있는데 일부 OS용 JVM은 메모리 관리의 상당 부분을 Heap이 아닌 OS레벨로 내리는 전략을 채택하는 경우가 있는 것으로 알고 있다.

 

하지만 위의 테스트코드의 결과나 다른 벤치마크 결과들을 확인했을 때 무조건 native API를 사용한다고 해서 성능이 향상되는 것이 아니라는 점은 알았으면 한다. 빈번한 JNI의 호출은 결국 성능을 오히려 떨어뜨리는 결과를 낳는다는 점은 공통적으로 알고 있어야 하는 사실이고, GC의 대상이 아니라는 점이 항상 장점으로만 통하는 것이 아니라는 점을 알아야 할 것이다.

 

Java와 같은 고수준의 언어만을 다루는 개발자의 공통적인 문제는 하부에서 일어나는 일들에 대한 정확한 지식의 부재가 아닐까 한다. 일부 아티클에서 단어 사용의 오류 또는 비약이 심한 벤치마크 결과들이 정확한 사실에 대해 잘못된 지식을 전파하는 요소로 작용하는데 OS와 Network의 기본 지식은 이러한 잘못된 사실을 바로잡는데 도움을 줄것이라고 기대해 볼 수 있다.

 

  기술의 구분을 명확히 못하는 상태에서 예제 코드나 API의 단순 조합으로 만들어진 코드들은 성능의 저하가 발생했을 때 어느 부분에서 문제가 있을 것인지 유추하기 어렵게 만들고 불필요한 코드들을 양산하게 만든다. 그리고 불필요하게 생산되는 코드들은 대부분 상당히 나쁜 코드들이 많다. Java에서 Double Locking을 구현하고 Thread Safe한 자료구조를 동기화 코드로 감싸거나 Selector를 멀티쓰레드로 구현하는 등의 나쁜 코드들이 한 예가 될 것 같다. 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

이 글은 스프링노트에서 작성되었습니다.

Posted by 사내양

트랙백 주소 http://guysheep.tistory.com/trackback/86 관련글 쓰기

댓글을 달아 주세요

이전버튼 1 2 3 4 5 ... 77 이전버튼