자바 성능 튜닝 - IO 에서 발생하는 병목 현상 By starseat 2024-05-14 16:08:56 java/spring Post Tags [자바 성능 튜닝 이야기](https://product.kyobobook.co.kr/detail/S000001032977) 책을 읽고 정리한 내용 입니다. --- IO 부분을 잘못 사용하면 시스템의 응답 속도에 많은 영향을 준다. 또한 서버의 DISK I/O 도 발생시키기므로 득될 것이 없다. # 기본적인 IO 처리 자바에서 입력과 출력은 스트림(stream)을 통해서이루어진다. 파일 IO뿐만아니라 어떤 디바이스를 통해 이뤄지는 작업을 모두 IO 라고 한다. (네트워크로 데이터 전송/수신 도 포함) 흔히 사용하는 `System.out.println('blablabla');` 도 IO 에 속한다. `out`은 `PrintStream`을 `System` 클래스에 `static` 으로 정의해 놓은 변수이다. 참고로 IO는 성능에 영향을 가장 많이 미친다. IO 에서 발생하는 시간은 CPU를 사용하는 시간과 대기 시간 중 대기 시간에 속하기 때문에 애플리케이션에서 IO를 사용했을 때, IO와 관련된 디바이스가 느리면 느릴수록 애플리케이션의 속도는 느려진다. ## IO 작업이 운영체제에서 수행되는 방법 1) 파일을 읽으라는 메서드를 자바에 전달 (FileReader 등) 2) 파일명을 전달받은 메서드가 운영체제의 커널에게 파일을 읽어달라고 요청 3) 커널이 하드 디스크로부터 파일을 읽어서 자신의 커널에 있는 버퍼에 복사. (DMA 에서 이 작업 수행) 4) JVM 으로 데이터 전달 5) JVM 에서 메서드에 있는 스트림 관리 클래스를 사용하여 데이터 처리 ## 스트림 처리 주요 클래스 쓰는 것과 관련된 스트림들은 Input 을 Output 스트림으로 변경하면 된다. 스트림 처리를 할때는 반드시 open 한 스트림은 close 해주어야 한다. 스트림을 닫지 않으면 나중에 리소스가 부족해 질 수 있고, 관련된 파일의 상태 변경이 불가능해질 수 있다. ### 바이트 기반 스트림 아래에 명시된 모든 입력과 관련된 스트림들은 `java.io.InputStream` 클래스로부터 상속받았다. - **ByteArrayInputStream**: 바이트로 구성된 배열을 읽어서 입력 스트림 생성 - **FileInputStream**: 이미지와 같은 바이너리 기반의 파일 스트림 생성 - **FilterInputStream**: 여러 종류의 유용한 입력 스트림의 추상 클래스 - **ObjectInputStream**: **ObjectOutputStream**을 통해서 저장해 놓은 객체를 읽기 위한 스트림 생성 - **PipedInputStream**: **PipedOutputStream**으 통해서 출력된 스트림을 읽어서 처리하기 위한 스트림 생성 - **SequenceInputStream**: 별개인 두 개의 스트림을 하나의 스트림으로 합침 ### 문자열 기반 스트림 `java.io.Reader` 클래스의 하위 클래스들이다. - **BufferedReader**: - 문자열 입력 스트림을 버터에 담아 처리 - 일반적으로 문자열 기반의 파일을 읽을 때 가장 많이 사용됨. - **CharArrayReader**: char 의 배열로 된 문자 배열 처리 - **FilterReader**: 문자열 기반의 스트림을 처리하기 위한 추상 클래스 - **FileReader**: 문자열 기반 파일을 읽기 위한 클래스 - **InputStreamReader**: 바이트 기반의 스트림을 문자열 기반의 스트림으로 연결하는 역할 수행 - **PipedReader**: 파이프 스트림을 읽음 - **StringReader**: 문자열 기반 소스 읽음 ## FileReader 예제 ### 일반 예제 책에서는 실행 결과 약 2,480ms 정도의 시간이 소요 되었다고 한다. 이렇게 속도가 느린 이유는 문자열을 하나씩 읽도록 되어 있기 때문이다. ```java import java.util.*; import java.io.*; public class BasicIOReadUtil { public static ArrayList readCharStream(String fileName) throws Exception { List list = new ArrayList<>(); FileReader fr = null; try { fr = new FileReader(fileName); // FileReader 객체 생성 // 한 줄씩 데이터를 담을 StringBuffer 생성 StringBuffer sb = new StringBuffer(); int data = 0; while( (data = fr.read()) != -1) { if(data == '\n' || data == '\r') { // 개행 처리 list.addElement(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:\\temp\\10MBFile"; StopWatch sw = new StopWatch(); sw.start(); List list = BasicIOReaderUtil.readCharStream(fileName); System.out.println(sw); System.out.println("size: " + list.size()); } } ``` ## 처리 개선 예제 특정 배열에 읽은 데이터를 저장한 후 그 데이터를 사용하면 조금 더 빠르게 처리할 수 있다. 매개변수가 있는 **read()** 메서드를 사용하여 처리하였다. 이렇게 하니 책에서는 400ms 가 소요되었다고 한다. ```java import java.util.*; import java.io.*; public class BasicIOReadUtil { public static ArrayList readCharStream(String fileName) throws Exception { StringBuffer retSb = new StringBuffer(); FileReader fr = null; try { fr = new FileReader(fileName); // FileReader 객체 생성 int bufferSize = 1024*1024; char[] readBuffer = new char[bufferSize]; int resultSize = 0; while((resultSize = fr.read(readBuffer)) != -1) { // 여기서의 read() 처리 변경 if(resultSize == bufferSize) { retSb.append(readBuffer); } else { for(int i=0; i readBufferedReader(String fileName) throws Exception { List list = new ArrayList<>(); BufferReader br = null; try { br = new BufferedReader(new FileReader(fileName)); // FileReader 로 BufferedReader 생성 String data; while((data = br.readLine()) != null) { list.addElement(data); } } catch (Exception e) { System.err.println(e.getMessage()); throw e; } finally { if(br != null) { br.close(); } } return list; } } ``` ### 처리 결과 | | 버퍼 없는 FileReader(일반 예제) | 버퍼 포함 FileReader(처리 개선 예제) | BufferedReader(처리 개선 예제 2) | | -- | -- | -- | -- | | 응답 속도 | 2,480 ms | 400 ms | 350 ms | # IO 병목 발생 사례 사용자의 요청이 발생할 때마다 매번 파일을 읽는 시스템으로 예를 들어보자. ```java String configUrl; public Vector getRoute(String type) { if(configUrl == null) { configUrl = this.getClass().getResource("/xxx/config.yaml"); } obj = new TempUtility(configUrl, "1"); } ``` 위 소스는 경로 하나를 가져오기 위해 매번 configUrl 을 TempUtility 에 넘겨 준다. TempUtility 에서는 요청이 올 때마다 config.yaml 파일을 읽고 파싱하여 관련 DB 처리를 한다. 이 소스가 실제로 운영된다면, 서버에서는 엄청난 IO 가 발생하며 응답 시간이 좋지 않을 것이다. 다음은 새로운 예제에 대한 시스템 보고서를 프로파일링 한 결과이다. | 수행 횟수 | CPU 시간(ms) | 전체 시간 (ms) | 대기 시간 (ms) | 소스 | | -- | -- | -- | -- | -- | | | | | | long lastModified; | | 3,445 | 20.79 | 22.57 | 1.79 | public void init() throws Exception { | | 3,445 | 759.98 | 1,036.32 | 276.34 | __File configFile = new File(test.config"); | | | | | | __if(!configFile.canRead()) { throw new Exception("..."); } | | 3,445 | 568.61 | 1,145.90 | 577.29 | __if(lastModified != configFile.lastModified()) { | | | | | | ____// 파일 읽은 후 데이터 세팅 | init() 메서드에서 설정 파일이 수정되었는지 확인하고, 수정이 되었으면 설정을 다시 읽도록 되어 있다. 설정 파일을 다시 읽을지에 대한 시간이 얼마 안되지만 이게 쌓이면 절대 적은 시간이 아니다. # NIO 원리 JDK 1.4 부터 새롭게 추가되었다. NIO 가 Non-blocking IO 가 아니라 **New IO** 이다. (이건 나도 잘못알고 있었다.) 기존 IO 에서 커널 버퍼에 복사하거나 JVM 으로 데이터를 전달할 때의 단점을 보완하기 위해 NIO가 탕생하였다. IO 에 대한 모든 병목현상이 해결되는 것은 아니지만 IO를 위핸 여러 가지 새로운 개념이 도입되었다. - 버퍼의 도입 - 채널의 도입 - 문자열의 encoder 와 decoder 제공 - Perl 스타일의 정규 표현식에 기초한 패턴 매칭 방법 제공 - 파일을 잠그거나 메모리 매핑이 가능한 파일 인터페이스 제공 - 서버를 위한 복합적인 Non-blocking IO 제공 NIO 에 대한 자세한 내용은 [oracle NIO 메뉴얼](https://docs.oracle.com/javase/6/docs/api/java/nio/package-summary.html) 을 참고하길 바란다. ## DirectByteBuffer 잘못 사용 사례 NIO 를 사용할 때 ByteBuffer를 사용하는 경우가 있다. ByteBuffer 는 네트워크나 파일에 있는 데이터를 읽어 들일 때 사용한다. ByteBuffer 객체를 생성하는 메서드는 wrap(), allocate(), allocateDirect() 가 있다. 이 중에서 allocateDirect() 메서드는 데이터를 JVM 에 올려서 사용하는 것이 아니라 **OS 메모리에 할당된 메모리를 native 한 JNI로 처리하는 DirectByteBuffer 객체를 생성**한다. 다음 예제 코드를 보자. ```java import java.nio.ByteBuffer; public class DirectByteBufferCheck { public static void main(String[] args) { DirectByteBufferCheck check = new DirectByteBufferCheck(); for(int i=0; i<(1024*1000); i++) { check.getDirectByteBuffer(); if( i % 100 == 0) { System.out.println(i); } } } public ByteBuffer getDirectByteBuffer() { return ByteBuffer.allocateDirect(65536); } } ``` getDirectByteBuffer() 메서드를 지속적으로 호출하는 간단한 코드다. 그리고 getDirectByteBuffer() 메서드 내에서 ByteBuffer 클래스의 `allocateDirect()` 메서드를 호출함으로써 DirectByteBuffer 객체를 생성 후 리턴한다. 그냥 보기에는 큰 문제가 없어 보이지만 5~10초에 한번씩 Full GC가 발생하게 된다. Old 영역의 메모리는 증가하지 않았다. (이 코드를 실행하고 GC 상황을 모니터링하기 위해 `jstat` 명령을 사용하였다고 한다.) DirectByteBuffer 생성자로 이런 상황이 발생하게 되었다. DirectByteBuffer 생성자는 java.nio 에 아무런 접근제어자가 없이 선언된 (package priavte 인) Bits 라는 클래스의 reserveMemory() 메서드를 호출한다. 이 reserveMemory() 메서드에서는 JVM 에할당되어 있는 메모리보다 더 많은 메모리를 요구할 경우 System.gc() 메서드를 호출하도록 되어 있다. JVM 에 있는 코드에 System.gc() 메서드가 있기 때문에 해당 생성자가 무차별적으로 생성될 경우 GC 가 자주 발생하게 되고 이는 **성능에 영향을 줄 수 밖에 없다.** 따라서, DirectByteBuffer 객체를 생성할 때는 신중하게 접근해야 하며 가능하다면 singleton 패턴을 사용하여 하나의 객체를 사용하는 것이 좋다. ## lastModified() 메서드 성능 저하 JDK 6 까지는 파일이 변경되었는지 확인할 때 `File` 클래스에 있는 lastModified() 라는 메서드를 사용해왔다. 이 메서드를 사용하면 최종 수정된 시간을 밀리초 단위로 제공한다. - lastModified() 메서드 처리 절차 - 1) System.getSecurityManager() 메서드를 호출하여 SecurityManager 객체 얻어옴 - 2) null 이 아니면 SecurityManager 객체의 checkRead() 메서드 수행 - 3) File 클래스 내부에 있는 FileSystem 이라는 클래스의 객체에서 getLastModifiedTime() 메서드를 수행하여 결과 리턴 크게 보면 3가지 이지만 OS 별로 내부적으로 후출되는 메서드들이 다르다. JDK 7 에서 **Path** 와 **Watch** 로 시작하는 새로운 내용들이 추가 되었다. 다음은 샘플 코드 이다. - WatcherThread ```java 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.*; import.java.util.*; 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); // Path 객체를 생성해서 모니터링할 디렉터리 지정 WatchserService watcher = FileSystems.getDefault().newWatchService(); // register 라는 메서드를 활용하여 파일의 생성, 수정, 삭제되는 이벤트를 처리하도록 지정 dir.register(watcher, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY); WatchKey key; for(int i=0; i<4; i++) { // 해당 디렉터리에 변경이 있을떄가지 기다리다가 작업이 발견되면 WatchKey 클래스의 객체 생성 // (Socket 의 accpet() 와 비슷) key = watcher.take(); String watchedTime = new Date().toString(); List> eventList = key.pollEvents(); // 파일에 변화가 생겼다면 이벤트 목록 가져옴 for(WatchEvent> event : eventList) { Patn 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_MODIFIED) { // Do something when modified System.out.format("%s modified at %s\n", name, watchedTime); } } key.reset(); } } catch (Exception e) { e.printStackTrace(); } } } ``` - watcherSample (WatcherThread 실행) ```java public class WatcherSample { public static void main(String[] args) { WatcherThread thread = new WatcherThread("C:\\Temp"); thread.start(); } } ``` # 정리 요즘 클라우드 환경(AWS) 에 Spring 을 쓰면서 IO 에 대한 부분을 많이 사용하지 않고 있었는데 이런 내용도 있다는 것을 인지했음 한다. 쪼금은 도움이 되겠지... JDK 7 이상이라면 NIO2 에 추가된 기능들도 있느니 살펴보면 좋을 듯 하다. Previous Post 자바 성능 튜닝 - Thread, synchronized Next Post [spring] build.gradle 로컬 lib 추가