9. IO에서 발생하는 병목 현상

웹 애플리케이션에서 IO 처리를 하는 부분은 시스템의 응답 속도에 많은 영향을 준다.

기본적인 IO는 이렇게 처리한다

자바에서 입력과 출력은 스트림(stream)을 통해서 이루어진다. 파일을 포함해 디바이스를 통해 이뤄지는 작업을 모두 IO라고 한다. 네트워크를 통해서 다른 서버로 데이터를 전송하거나, 다른 서버로부터 데이터를 전송 받는 것도 IO에 포함된다.

System.out.println("Jeremy");

여기서 out은 PrintStream을 System 클래스에 static으로 정의해 놓은 변수이다(이 또한 역시 IO). IO에서 발생하는 시간은 CPU를 사용하는 시간과 대기 시간 중 대기 시간에 속하기 때문에 성능에 영향을 가장 많이 미친다.

자바에서 파일을 읽고 처리하는 방법은 굉장히 많다. 스트림을 읽는 데 관련된 주요 클래스는 다음과 같다. 여기에 명시된 모든 입력과 관련된 스트림들은 java.io.InputStream 클래스로부터 상속받았다.

문자열 기반의 스트림을 읽기 위해서 사용하는 클래스는 java.io.Reader 클래스의 자식 클래스들이다.

BufferedReader 클래스는 다른 FileReader 클래스와 마찬가지로 문자열 단위나 문자열 배열 단위로 읽을 수 있는 기능을 제공하지만, 추가로 라인 단위로 읽을 수 있는 readLine() 함수를 제공한다. 실제 응답 속도도 약 350ms로, 약간 빨라진다. 이 속도는 파일의 크기와 비례한다.

  버퍼 없이 FileReader 버퍼 포함한 FileReader BufferedReader 사용시
응답 속도 2,480ms 400ms 350ms

NIO의 원리

JDK 1.4부터 새롭게 추가된 NIO가 어떤 것인지 알아보자. 자바를 사용하여 하드 디스크에 있는 데이터를 읽는다면 어떤 프로세스로 진행될까?

  1. 파일을 읽으라는 함수를 자바에 전달한다.
  2. 파일명을 전달받은 함수가 운영체제의 커널에게 파일을 읽어 달라고 요청한다.
  3. 커널이 하드 디스크로부터 파일을 읽어서 자신의 커널에 있는 버퍼에 복사하는 작업을 수행한다. DMA에서 이 작업을 하게 된다.
  4. 자바에서는 마음대로 커널의 버퍼를 사용하지 못하므로, JVM으로 그 데이터를 전달한다.
  5. JVM에서는 함수에 있는 스트림 관리 클래스를 사용하여 데이터를 처리한다.

자바에서는 3번 복사 작업과 4번 전달 작업을 수행할 때 대기하는 시간이 발생할 수 밖에 없다. 이러한 단점을 보완하기 위해 NIO가 탄생했다. NIO를 사용한다고 IO에서 발생하는 모든 병목 현상이 해결되는 것은 아니지만, IO를 위한 여러 가지 새로운 개념이 도입되었다.

DirectByteBuffer를 잘못 사용하여 문제가 발생한 사례

NIO를 사용할 때 ByteBuffer를 사용하는 경우가 있다. ByteBuffer는 네트워크나 파일에 있는 데이터를 읽어 들일때 사용한다. ByteBuffer 객체를 생성하는 함수에는 wrap(), allocate(), allocateDirect()가 있다. 이 중에서 allocateDirect 함수는 데이터를 JVM에 올려서 사용하는 것이 아니라, OS 메모리에 할당된 메모리를 Native한 JNI로 처리하는 DirectByteBuffer 객체를 생성한다. 그런데 이 DirectByteBuffer 객체는 필요할 때 계속 생성해서는 안 된다.

...
psvm() {
  DirectByteBuffer check = new DirectByteBufferCheck();
  for (int loop = 1; loop < 1024000; loop++) {
    check.getDirectByteBuffer();
    if (loop % 100 == 0) {
      System.out.println(loop);
    }
  }
}

public ByteBuffer getDirectByteBuffer() {
  ByteBuffer buffer = ByteBuffer.allocateDirect(65536);
  return buffer;
}
...

getDirectByteBuffer 함수를 지속적으로 호출하는 간단한 코드다. getDirectByteBuffer 함수에서는 ByteBuffer 클래스의 allocateDirect 함수를 호출함으로써 DirectByteBuffer 객체를 생성한 후 리턴해준다.

이 예제를 실행하고 나서 GC 상황을 모니터링하기 위해 jstat 명령을 사용하여 확인해보면 거의 5~10초에 한 번씩 Full GC가 발생하는 것을 볼 수 있다. 그런데 Old 영역의 메모리는 증가하지 않는다. 왜 이러한 문제가 발생했을까?

그 이유는 DirectByteBuffer의 생성자 때문이다. 이 생성자는 java.nio 에 아무런 접근 제어자가 없이 선언된(package private) Bits라는 클래스의 reserveMemory() 함수를 호출한다. 이 reserveMemory 함수에서는 JVM에 할당되어 있는 메모리보다 더 많으 메모리를 요구할 경우 System.gc() 함수를 호출하도록 되어 있다.

JVM에 있는 코드에 System.gc() 함수가 있기 때문에 해당 생성자가 무차별적으로 생성될 경우 GC가 자주 발생하고 성능에 영향을 줄 수 밖에 없다. 따라서, 이 DirectByteBuffer 객체를 생성할 때는 매우 신중하게 접근해야만 하며, 가능하다면 singleton 패턴을 사용하여 해당 JVM에는 하나의 객체만 생성하도록 하는 것을 권장한다.