가끔 웹 애플리케이션에서 IO 처리를 하는 부분이 있는데, 이 부분도 잘못 사용하면 시스템의 응답 속도에 많은 영향을 준다. 게다가 서버의 DISK I/O도 발생시키므로 별로 득될 것이 없다. 여기서는 IO와 NIO 파일 처리 시 성능상의 비교 결과를 보면서 얼마나 개선할 수 있는지 확인해 보자.
자바에서 입력과 출력은 스트림(stream)을 통해 이루어진다. 일반적으로 IO라고 하면 파일 IO만을 생각할 수 있는데, 어떤 디바이스를 통해 이뤄지는 작업을 모두 IO라고 한다. 네트워크를 통해서 다른 서버로 데이터를 전송하거나, 다른 서버로부터 데이터를 전송받는 것도 IO에 포함된다. 콘솔에 출력하는 것도 스트림을 통해서 출력하는 것이다.
System.out.println("hahaha");
여기서 out은 PrintStream을 System 클래스에 static으로 정의해 놓은 변수이다. 그러므로 알게 모르게 자바에서 IO를 사용해왔다.
참고로 IO는 성능에 영향을 가장 많이 미친다. IO에서 발생하는 시간은 CPU를 사용하는 시간과 대기 시간 중 대기 시간에 속하기 때문이다. 개발된 애플리케이션에서 IO를 사용했을 때, IO와 관련된 디바이스가 느리면 느릴수록 애플리케이션의 속도는 느려진다.
자바에서 파일을 읽고 처리하는 방법은 굉장히 많다. 스트림을 읽는데 관련된 주요 클래스들은 다음과 같다.
바이트 기반의 스트림 입력을 처리하기 위해서는 이 클래스들의 하위 클래스를 사용한다. 여기에 명시된 모든 입력과 관련된 스트림들은 java.io.InputStream 클래스로부터 상속받았다(스트림을 쓰는데 관련된 클래스들은 Input을 Output으로 바꾸어 사용하면 된다).
문자열 기반의 스트림을 읽기 위해서 사용하는 클래스는 이와 다르게 java.io.Reader의 하위 클래스들이다. 주요 클래스는 다음과 같다.
바이트 단위로 읽거나, 문자열 단위로 읽을 때 중요한 것은 한 번 연(open한) 스트림은 반드시 닫아주어야 한다는 것이다. 스트림을 닫지 않으면 나중에 리소스가 부족해질 수도 있다. 예를 들어 파일을 열지 못하는 경우가 발생하면, 관련된 파일을 관리하는 스트림의 상태 변경이 불가능해지기 때문이다.
텍스트 기반으로 된 파일을 FileReader 클래스를 이용하여 읽는 기능을 구현해 보자.
package com.perf.nio;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
public class BasicIOReadUtil {
public static ArrayList readCharStream(String fileName) throws Exception {
ArrayList<StringBuffer> list = new ArrayList<StringBuffer>();
FileReader fr = null;
try {
fr = new FileReader(fileName); // 객체 생성
int data = 0;
// 한줄씩 데이터를 담을 StringBuffer 생성
StringBuffer sb = new StringBuffer();
while((data=fr.read()) != -1) {
if(data=='\n' || data=='\r') {
list.add(sb);
sb = new StringBuffer();
} else {
sb.append((char)data);
}
}
} catch (IOException e) {
System.err.println(e.getMessage());
throw e;
} catch (Exception e) {
System.err.println(e.getMessage());
throw e;
} finally {
if(fr != null) fr.close();
}
return list;
}
public static void main(String[] args) throws Exception {
String fileName = "C:\\10MBFile";
StopWatch sw = new StopWatch();
sw.start();
ArrayList list1 = BasicIOReadUtil.readCharStream(fileName);
System.out.println(sw);
System.out.prinln(list1.size());
}
}
readCharStream 메서드는 지정된 파일을 받으면 해당 파일을 읽는다. 읽은 내용을 일단 StringBuffer에 담고, 줄이 바뀔 경우 ArrayList에 담아서 리턴하도록 되어 있다. 파일을 처리할 때는 되도록 IOException을 따로 구분하여 처리하는 것이 좋다. main 메서드에서는 10MB 정도의 파일을 읽는 속도가 얼마나 되는지 확인한다.
실행 결과, 윈도 기반이고 디스크를 사용하는 시스템에서 10MB 파일을 처리할 경우 약 2,480초 정도의 시간이 소요된다. 이렇게 응답 속도가 느린 이유는 문자열을 하나씩 읽도록 되어 있기 때문이다. 이러한 단점을 해결하기 위한 메서드는 다음과 같다.
public static String readCharStreamWithBuffer(String fileName) throws Exception {
StringBuffer retSB = new StringBuffer();
FileReader fr = null;
try {
fr = new FileReader(fileName);
int bufferSize = 1024 * 1024;
char readBuffer[] = new char[bufferSize];
int resultSize = 0;
while ((resultSize = fr.read(readBuffer)) != -1) {
if (resultSize == bufferSize) {
retSB.append(readBuffer);
} else {
for (int loop = 0; loop < resultSize; loop++) {
retSB.append(readBuffer[loop]);
}
}
}
}
// 이하 예외 처리 생략
return retSB.toString();
}
특정 배열에 읽은 데이터를 저장한 후 그 데이터를 사용하면, 더 빠르게 처리할 수 있다. 앞에서 사용한 매개변수 없는 read() 메서드가 읽은 문자열을 리턴하는 것과는 달리, 매개변수가 있는 read() 메서드에서는 파일에서 읽은 char 배열의 개수가 리턴된다. 이 메서드를 수행하면 약 400ms로 양호한 응답 속도가 나온다.
참고로 char의 배열을 버퍼로 사용했기 때문에, 해당 배열을 초기화하지 않으면 리턴되는 문자열에 필요하지 않은 값들이 포함된다. 그 문제를 해결하기 위해서, 마지막에 읽은 데이터는 리턴된 배열의 크기만큼만 문자열에 포함하도록 처리하였다.
간단하게 FileReader를 사용하여 파일을 읽었는데, 이러한 방식은 별로 사용되지 않는다. 문자열 단위로 읽는 것은 굉장히 비효율적이기 때문이다. 그리고 만약 파일의 크기가 크면 OutofMemoryError가 발생하게 된다. 따라서 파일의 크기가 크거나 반복 횟수가 많을 경우에 대응하는 로직을 포함해야 한다. 문자열 단위로 읽는 방식에 대한 해결 방안에는 BufferedReader 클래스가 있다.
public static ArrayList<String> readBufferedReader(String fileName) throws Exception {
ArrayList<String> list = new ArrayList<String>();
BufferedReader br = null;
try {
br = new BufferedReader(new FileReader(fileName));
String data;
while ((data = br.readLine()) != null) {
list.add(data);
}
} catch (Exception e) {
System.err.println(e.getMessage());
throw e;
} finally {
if (br != null) br.close();
}
return list;
}
BufferedReader 클래스는 다른 FileReader 클래스와 마찬가지로 문자열 단위나 문자열 배열 단위로 읽을 수 있는 기능을 제공하지만, 추가로 라인 단위로 읽는 readLine() 메서드를 제공한다. 소스를 보면 알겠지만 문자열을 읽는 소스가 굉장히 간단해졌다. 실제 응답 속도도 약 350ms로 약간 빨라진다. 이 속도는 파일의 크기와 비례한다. 각 응답 속도를 정리하면 다음과 같다.
버퍼 없이 FileReader | 버퍼 포함한 FileReader | BufferedReader | |
응답 속도 | 2,480ms | 400ms | 350ms |
사용자의 요청이 발생할 때마다 매번 파일을 읽어오도록 되어 있는 시스템도 있다. 다음 소스를 한번 보자.
String configUrl;
public Vector getRoute(String type) {
if(configUrl == null) {
configUrl = this.getClass().getResource("/xxx/config.xml");
}
obj = new DaoUtility(configURl, "1");
...
}
이 소스는 어떤 경로를 확인하는 시스템의 일부이다. 경로 하나를 가져오기 위해 매번 configUrl을 DaoUtility에 넘겨 준다. DaoUtility에서는 요청이 올 때마다 config.xml 파일을 읽고 파싱하여 관련 DB 쿼리 데이터를 읽는다. 이 애플리케이션이 실제 운영된다면, 모든 요청이 올 때마다 파일에 있는 DB 쿼리를 읽어야 한다. 서버에는 엄청난 IO가 발생할 것이며, 응답 시간이 좋지 않을 것이다.
많은 프로젝트의 웹 애플리케이션이 생각보다 많은 IO 작업이 수행된다. 최근에는 특히 DB 쿼리나 여러 종류의 설정을 파일에 저장하고 사용하는 경우가 많다. DB 쿼리 관련 설정 파일이 변경되었다면 설정 파일을 다시 읽도록 되어 있는데, 설정 파일을 다시 점검하는지 여부를 점검하는데도 시간이 오래 걸린다.
이런 부분을 해결하기 위해서 가장 좋은 방법은 데몬(Daemon) 스레드를 생성하여 5분이나 10분에 한 번씩 확인하도록 수정하는 것이다. 그렇게 하면 매번 요청할 때마다 수정 여부를 확인하지 않아도 된다.
그러면 JDK 1.4부터 새롭게 추가된 NIO가 어떤 것인지 알아보자. NIO가 무엇인지 자세하게 알기 위해서는 근본적으로 IO 작업이 운영체제에서 어떻게 수행되었는지를 알아야 한다. 만약 자바를 사용하여 하드 디스크에 있는 데이터를 읽는다면 어떤 프로세스로 진행이 될까?
1) 파일을 읽으라는 메서드를 자바에 전달한다.
2) 파일명을 전달받은 메서드가 운영체제의 커널에게 파일을 읽어달라고 요청한다.
3) 커널이 하드 디스크로부터 파일을 읽어서 자신의 커널에 있는 버퍼에 복사하는 작업을 수행한다. DMA에서 이 작업을 하게 된다.
4) 자바에서는 마음대로 커널의 버퍼를 사용하지 못하므로, JVM으로 그 데이터를 전달한다.
5) JVM에서 메서드에 있는 스트림 관련 클래스를 사용하여 데이터를 처리한다.
자바에서는 3번 복사 작업을 할 때에나 4번 전달 작업을 수행할 때 대기하는 시간이 발생할 수 밖에 없다. 이러한 단점을 보완하기 위해서 NIO가 탄생했다. 3번 작업을 자바에서 직접 통제하여 시간을 더 단축할 수 있게 한 것이다. NIO를 사용한다고 IO에서 발생하는 모든 병목 현상이 해결되는 것은 아니지만, IO를 위한 여러 가지 새로운 개념이 도입되었다. 추가된 개념을 간단하게 정리하면 다음과 같다.
NIO를 사용할 때 ByteBuffer를 사용하는 경우가 있다. ByteBuffer는 네트워크나 파일에 있는 데이터를 읽어 들일 때 사용한다. ByteBuffer 객체를 생성하는 메서드에는 wrap(), allocate(), allocateDirect()가 있다. 이 중에서 allocateDirect() 메서드는 데이터를 자바 JVM에 올려서 사용하는 것이 아니라, OS 메모리에 할당된 메모리를 Native한 JNI로 처리한 DirectByteBuffer 객체를 생성한다. 그런데, 이 DirectByteBuffer 객체는 필요할 때 계속 생성해서는 안 된다.
다음의 간단한 코드를 보자.
package com.perf.io;
import java.nio.ByteBuffer;
public class DirectByteBufferCheck {
public static void main(String[] args) {
DirectByteBufferCheck 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;
buffer = ByteBuffer.allocateDirect(65536);
return buffer;
}
}
getDirectByteBuffer() 메서드를 지속적으로 호출하는 간단한 코드다. 그리고, getDirectByteBuffer() 메서드에서는 ByteBuffer 클래스의 allocateDirect() 메서드를 호출함으로써 DirectByteBuffer 객체를 생성한 후 리턴해 준다. 그냥 보기에는 큰 문제가 없어 보인다.
이 예제를 실행하고 나서 GC 상황을 모니터링 하기 위해 jstat 명령을 사용하여 확인해 보면 다음과 같은 결과가 출력된다.
$ jstat -gcutil pid 수행 간격
수행 결과를 보면 O라고 되어 있는 Old 영역의 메모리는 증가하지 않는다. 왜 이러한 문제가 발생했을까?
그 이유는 DirectByteBuffer의 생성자 때문이다. 이 생성자는 java.nio에 아무런 접근 제어자 없이 선언된 (package private인) Bits라는 클래스의 reserveMemory()를 호출한다. 이 reserveMemory() 메서드에서는 JVM에 할당되어 있는 메모리보다 더 많은 메모리를 요구할 경우 System.gc() 메서드를 호출하도록 되어 있다.
JVM에 있는 코드에 System.gc() 메서드가 있기 때문에 해당 생성자가 무차별적으로 생성될 경우 GC가 자주 발생하고 성능에 영향을 줄 수 밖에 없다. 따라서, 이 DirectByteBuffer 객체를 생성할 때는 매우 신중하게 접근해야만 하며, 가능하다면 singleton 패턴을 사용하여 해당 JVM에는 하나의 객체만 생성하도록 하는 것을 권장한다.
JDK 6까지 자바에서 파일이 변경되었는지를 확인해서 File 클래스에 있는 lastModified()라는 메서드를 사용해왔다. 이 메서드를 사용하면 최종 수정된 시간을 밀리초 단위로 제공한다. 이 시간은 System.currentTimeMillis() 메서드에서 리턴되는 시간 단위와 동일하다. 그런데 이 lastModified() 메서드는 처리되는 절차가 조금 복잡하다.
1) System.getSecurityManager() 메서드를 호출하여 SecurityManager 객체를 얻어옴
2) 만약 null이 아니면 SecurityManager 객체의 checkRead() 메서드 수행
3) File 클래스 내부에 있는 FileSystem이라는 클래스의 객체에서 getLastModified() 메서드를 수행하여 결과 리턴
그냥 보기에는 3단계이지만, 각각의 호출되는 메서드에서 호출되는 메서드들이 매우 많으며 이는 OS마다 상이하다. 이 작업이 실제 얼마나 소요되는지 JMH를 통해서 확인해보자.
package com.perf.io;
import java.io.File;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.GenerateMicroBenchmark;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
@State(Scope.Thread)
@BenchmarkMode({ Mode.AverageTime })
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public class IOPerformance {
long lastModifiedTime;
@GenerateMicroBenchmark
public void lastModified() {
File file=new File("C:\\Temp\\setting.properties");
lastModifiedTime=file.lastModified();
}
}
아무런 내용도 없는 setting.properties라는 파일을 만들고, 그 파일의 최종 수정 시간을 얻는 작업을 반복하였다. 결과는 58마이크로초, 즉 0.000058초 정도 소요된다. 어떻게 되면 큰 문제는 안 된다. 하지만 이 작업을 반복하는 형태의 서비스를 제공한다면 이야기는 달라진다. 이 작업은 IO 작업을 수반하기 때문에 OS의 IO 영향을 많이 받을 수 밖에 없다.
그런데 JDK 7 부터는 새로운 개념의 IO 처리를 한다. 여기서는 성능에 영향을 주는 WatcherService에 대해서 알아보자.
package com.perf.io;
import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.Date;
import java.util.List;
public class WatcherThread extends Thread{
String dirName;
public WatcherThread(String dirName) {
this.dirName=dirName;
}
public void run() {
System.out.println("Watcher is started");
fileWatcher();
System.out.println("Watcher is ended");
}
public void fileWatcher() {
try {
Path dir = Paths.get(dirName);
WatchService watcher = FileSystems.getDefault().newWatchService();
dir.register(watcher, ENTRY_CREATE, ENTRY_DELETE,
ENTRY_MODIFY);
WatchKey key;
for(int loop=0;loop<4;loop++) {
key = watcher.take();
String watchedTime=new Date().toString();
List<WatchEvent<?>> eventList = key.pollEvents();
for (WatchEvent<?> event : eventList) {
Path name = (Path) event.context();
if (event.kind() == ENTRY_CREATE) {
// Do something when created
System.out.format("%s created at %s%n", name, watchedTime);
} else if (event.kind() == ENTRY_DELETE) {
// Do something when deleted
System.out.format("%s deleted at %s%n", name, watchedTime);
} else if (event.kind() == ENTRY_MODIFY) {
// Do something when modified
System.out.format("%s modified at %s%n", name, watchedTime);
}
}
key.reset();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
Path 클래스와, Watch로 시작하는 클래스가 추가되었다. fileWatcher()라는 메소드를 보자.
1) Path 객체를 생성해서 모니터링할 디렉터리를 지정한다.
2) WatchService의 watcher라는 객체를 생성한다.
3) dir이라는 Path 객체의 register라는 메서드를 활용하여 파일이 생성, 수정, 삭제되는 이벤트를 처리하도록 지정하였다.
4) watcher 객체의 take() 메서드를 호출하면 해당 디렉터리에 변경이 있을 때까지 기다리다가, 작업이 발견되면 key라는 WatchKey클래스의 객체가 생성된다. 마치 Socket 관련 객체에 accpet() 메서드처럼 어떤 이벤트가 생길 때까지 낚시 줄을 던져 놓고 기다리고 있는 상황이라고 생각하면 된다.
5) 파일에 변화가 생겼다면 이벤트의 목록을 가져온다.
6) 이벤트를 처리한 다음에 key 객체를 리턴한다.
이 스레드를 실행하는 코드를 작성하자.
package com.perf.io;
public class WatcherSample {
public static void main(String[] args) {
WatcherThread thread=new WatcherThread("C:\\Temp");
thread.start();
}
}
이와 같이 JDK 7을 사용하는 환경에서는 해당 파일이 변경되었는지 주기적으로 확인할 필요가 없어졌다. Watch 관련 클래스만 잘 활용해도 파일을 쉽게 모니터링 할 수 있기 때문이다.
참고