Java NIO Direct Buffer를 이용해서 대용량 파일 행 기준으로 쪼개기

기가 단위의 파일을 외부에 전송할 일이 생겼다.

한 방에 보내기엔 너무 커서 파일을 쪼개서(split) 보내려고 하는데, 그마저도 쉽지 않다. 쪼개기 위해 대용량 파일을 읽을 때 이미 수십분 동안 CPU를 너무 잡아 먹어서 이 쪼개는 배치 작업을 스케줄링하는 스케줄러(Quartz)에 문제를 일으킨다.

DMA(Direct Memory Access)를 사용하는 것이 좋겠다.

DMA

Imgur

(그림 출처: https://howtodoinjava.com/java/io/how-java-io-works-internally-at-lower-level/)

디스크 컨트롤러는 DMA를 통해 CPU를 건드리지 않고 직접 운영체제 메모리에 접근할 수 있고, 응용 프로그램은 Direct Buffer를 활용해서 JVM 메모리가 아닌 운영체제 메모리에 직접 접근할 수 있다.

장점은 다음과 같다.

  1. 디스크에 있는 파일을 운영체제 메모리로 읽어들일 때 CPU를 건드리지 않는다.
  2. 운영체제 메모리에 있는 파일 내용을 JVM 내 메모리로 다시 복사할 필요가 없다.
  3. JVM 내 힙 메모리를 쓰지 않으므로 GC를 유발하지 않는다.(물론 일정 크기를 가진 버퍼가 운영체제 메모리에 생성되는 것이고, 이 버퍼에 대한 참조 자체는 JVM 메모리 내에 생성된다)

단점은 다음과 같다.

  1. DMA에 사용할 버퍼 생성 시 시간이 더 소요될 수 있다.
  2. 바이트 단위로 데이터를 취급하므로, 데이터를 행 단위로 취급하기 불편하다.

Aㅏ.. 파일 쪼개기 할 때 행 기준으로 쪼개야 하는데.. 일단 불편한 것일 뿐 아예 안 되는 것은 아니므로 시도해보자.

종합해보면 파일의 크기가 대단히 클 때 DMA의 장점을 누릴 수 있고 단점을 피할 수 있다.

FileChannel

Java 1.4 부터 도입된 NIO에 FileChannel이 포함되어 있는데, ByteBuffer를 통해 File I/O를 수행할 수 있다.

대략 다음과 같은 방식으로 사용할 수 있다.

파일 읽기 용 FileChannel

1
FileChannel srcFileChannel = Files.open(Paths.get("/home/homo-efficio/tmp/LargeFile"), StandardOpenOption.READ);

파일 쓰기 용 FileChannel

1
FileChannel destFileChannel = Files.open(Paths.get("/home/homo-efficio/tmp/LargeFile"), StandardOpenOption.WRITE);

Path 말고 RandomAccessFile을 활용하는 방법도 있고, 파일 열기 모드에도 TRUNCATE_EXIST, CREATE, CREATE_NEW 등 여러가지가 있고 혼합해서 사용할 수 있다.

ByteBuffer

실제 데이터를 담는 바이트 버퍼 클래스로서 JVM 힙을 사용하는 일반(Non-Direct) 버퍼와 운영체제 메모리에 직접 접근할 수 있는 Direct Buffer를 생성할 수 있다. Direct Buffer는 다음과 같이 간단하게 생성할 수 있다.

1
ByteBuffer buffer = ByteBuffer.allocateDirect(100 * 1024 * 1024);

버퍼가 Direct 인지 아닌지 buffer.isDirect()를 통해 판별할 수도 있다.

위치 관련 속성

position

버퍼 내에서 값을 읽거나 쓸 수 있는 시작 위치를 나타낸다. 버퍼 내로 1 바이트의 데이터가 추가될 때마다 position 값도 1 증가한다.

buffer.position(), buffer.position(int)

limit

버퍼 내에서 값을 읽거나 쓸 수 있는 끝 위치를 나타낸다. limit - 1 위치까지의 데이터가 읽거나 써진다.

buffer.limit(), buffer.limit(int)

mark

현재 position 위치에 표시를 해두고, 나중에 reset()을 호출하면 표시해둔 위치로 position이 이동한다.

buffer.mark()

capacity

버퍼의 용량(담을 수 있는 데이터의 크기)를 나타내며, 버퍼 생성 시 ByteBuffer.allocate(capacity)ByteBuffer.allocateDirect(capacity) 로 지정할 수 있다.

buffer.capacity()

위치 관련 메서드

flip()

position을 0으로, limit을 읽어들인 데이터의 마지막 바이트+1 위치로 세팅한다.

버퍼에 있는 데이터를 처음부터 모두 읽을 때 사용한다.

rewind()

position을 0으로, limit은 현재 그대로 유지한다.

버퍼에 있는 데이터를 처음부터 현재 limit 위치 바로 앞까지 읽을 때 사용한다.

reset()

positionmark 위치로 세팅한다.

compact()

현재 position부터 limit - 1 까지의 데이터를 버퍼의 맨 앞으로 복사한 후에, position을 복사한 데이터 바로 다음 위치로 이동시키고, limitcapacity로 이동한다. 행 기준으로 데이터를 다룰 때 핵심 역할을 담당한다.

읽고 쓰는 값 관련 메서드

get()

position 에 있는 값을 읽어서 반환한다.

put(byte)

byteposition 위치에 쓴다.

코드

Direct Buffer를 생성하는 것 자체는 그저 전용 메서드 하나 호출하는 것밖에 없어 단순하다. 다만, 줄 바꿈 처리에 좀 신경을 써야 한다.

대용량 파일 대상이므로 행의 크기 보다 버퍼 크기가 크다고 가정했다. 버퍼가 작을 때도 적용되도록 구현할 수는 있겠지만 불필요하게 로직만 복잡해지므로 이 때는 그냥 예외를 던지게 했다.

쉽게 요약하면,

  1. 파일에서 버퍼만큼 읽어들인 후,
  2. 버퍼에서 LF가 나올 때까지 1 바이트씩 뒤로 이동,
  3. LF가 나오면(0 ~ position 내의 마지막 LF) 버퍼의 시작부터 position 위치 내의 마지막 LF까지를 target 파일에 쓰고,
  4. 마지막 LF 뒤에서 버퍼 끝까지의 내용을 compact() 메서드를 이용해서 버퍼의 시작 위치로 복사하고,
  5. 그 이후의 내용은 다시 1번으로 돌아가서 버퍼에 읽어들이면서 반복
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public static void splitFileIntoDir(String srcFilePath,
String destDirPath,
String splittedFileNameFormat,
String header,
ByteBuffer buffer) throws IOException {

final byte LINE_FEED = 0x0A;
final byte CARRIAGE_RETURN = 0x0D;

int fileCounter = 0;
long totalReadBytes = 0L;
long totalWriteBytes = 0L;
long readBytes;

final Path path = Paths.get(srcFilePath);

try (final FileChannel srcFileChannel = FileChannel.open(path, StandardOpenOption.READ)) {
while ((readBytes = srcFileChannel.read(buffer)) >= 0) {
totalReadBytes += readBytes;

final int contentLength = buffer.position();
int newLinePosition = buffer.position();

try (final FileChannel splittedFileChannel =
FileChannel.open(Paths.get(destDirPath, String.format(splittedFileNameFormat, ++fileCounter)),
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE)) {
writeHeader(header, readBytes, splittedFileChannel);

boolean hasLineFeed = false;
boolean needCompact = true;
while (newLinePosition > 0) {
if (buffer.get(--newLinePosition) == LINE_FEED) { // 1 byte 씩 뒤로 가면서 줄바꿈 탐색
if (newLinePosition + 1 == buffer.capacity()) { // 버퍼 끝에 줄바꿈이 있으면 compact 불필요
needCompact = false;
}
buffer.position(0); // buffer의 처음부터
buffer.limit(++newLinePosition); // LINE_FEED 까지 포함해서 write 되도록 limit 조정
// 버퍼의 [0, limit)의 내용을 splittedFileChannel이 바인딩된 파일에 write
totalWriteBytes += splittedFileChannel.write(buffer);
splittedFileChannel.close();
hasLineFeed = true;
break;
}
}

if (!hasLineFeed) {
throw new IllegalArgumentException("버퍼 안에 줄바꿈이 없습니다. 버퍼 크기는 한 행의 길이보다 커야 합니다.");
}

if (needCompact) {
// compact()를 위해 원래 읽었던 내용의 마지막 바이트 위치+1(==contentLength) 로 limit 설정
buffer.limit(contentLength);

// 버퍼의 [position, limit) 의 내용을 [0, limit - position) 으로 복사
buffer.compact();
// 복사 후 position은 limit에 위치하며 다음에 파일에서 읽어오는 내용이 position 부터 이어짐
// limit는 capacity로 이동
} else {
// compact()가 필요없다면 파일을 읽어서 버퍼의 처음 위치부터 저장하도록 초기화
buffer.clear();
}
}
}
} catch (Exception e) {
throw new RuntimeException("File Split 도중 예외 발생", e);
}

System.out.println("Total Read Bytes: " + totalReadBytes);
System.out.println("Total Write Bytes: " + totalWriteBytes);
}


private static void writeHeader(String header, long readBytes, FileChannel splittedFileChannel) throws IOException {
if (readBytes > 0 && !StringUtils.isEmpty(header)) {
byte[] headerBytes = (header + System.lineSeparator()).getBytes(StandardCharsets.UTF_8);
splittedFileChannel.write(ByteBuffer.wrap(headerBytes));
}
}

친절한 그림

아무래도 그림으로 안 남겨놓으면 까먹은 후에 다시 봐도 이해가 안 될까봐..

Imgur

Imgur

Imgur

Imgur

Imgur

Imgur

Imgur

Imgur

Imgur

Imgur

Imgur

Imgur

Benchmark

한 행 당 70바이트 이내, 4,096,000행, 263MB 짜리 csv 파일을 1M, 10M, 50M, 100M 버퍼 크기 단위로 쪼개서 비교해 본 결과는 다음과 같다.

수행 시간 비교

버퍼 크기를 1M, 10M, 50M, 100M 를 한 사이클로 해서 2 사이클을 실행해서 수행 시간(초)을 비교해 본 결과,
전반적으로 Direct Buffer는 일반 Buffer보다 약 10% 정도 빠르고,
Direct Buffer는 readLine() 방식보다 적게는 약 2배, 많게는 약 5배 정도 빠르다.

버퍼 크기 회차 Direct Buffer Buffer readLine
1M 1 1.62 1.87 3.85
1M 2 1.39 1.52 3.47
10M 1 0.46 0.52 2.41
10M 2 0.33 0.47 2.37
50M 1 1.23 2.65 2.33
50M 2 1.12 1.34 2.32
100M 1 1.37 1.51 2.42
100M 2 1.40 1.46 2.32

FileChannel과 버퍼를 사용하는 방식은 버퍼 크기가 10M 일 때 다른 크기일 때에 비해 약 3 ~ 4배 정도 빨라지는 것이 특이하다.

CPU, 메모리 사용량 비교

Direct Buffer, Buffer, readLine() 방식을 순서대로 실행해서 VisualVM으로 측정했다. 그래프에 표시된 것도 맨 왼쪽부터 Direct Buffer, Buffer, readLine() 방식이다.

예상대로 Direct Buffer는 JVM Heap 메모리를 사용하지 않고 운영체제 메모리에 직접 접근하므로 JVM의 힙 메모리 사용량은 거의 없다. 물론 JVM 관점에서나 힙을 쓰지 않기 때문에 사용량이 적어 보이는 것이고, 운영체제 관점에서는 여전히 버퍼 크기 만큼의 메모리를 더 사용한다.
일반 Buffer는 버퍼 크기에 따라 JVM 힙 메모리 사용량도 비례해서 커지며,
readLine()은 1M, 10M, 50M 단위로 파일을 쪼개더라도 JVM 힙 메모리 사용량은 100M를 넘나든다.

CPU는 Direct Buffer와 Buffer는 아주 큰 차이는 없이 모두 10% 이내다. readLine()에 비해 약 1/3 정도에 불과할 정도로 절감 효과가 크지만, readLine() 방식도 30%에 미치지 않은 걸 보면 역시 263MB 짜리 파일은 Direct Buffer 효과가 두드러질 정도로 큰 파일은 아니라고 볼 수 있다.

1M 버퍼, 1M 단위로 자르기

Imgur

Imgur

10M 버퍼, 10M 단위로 자르기

Imgur

Imgur

50M 버퍼, 50M 단위로 자르기

Imgur

Imgur

100M 버퍼, 100M 단위로 자르기

Imgur

Imgur

그런데 메모리 해제는?

Direct Buffer는 JVM 힙이 아니라 운영체제 메모리를 사용하므로 GC를 유발하지 않는다고 했는데, GC를 유발하지 않는다고 마냥 좋아할 것이 아니다. GC가 안 되면 메모리 해제는 어떻게 이루어지는지 걱정해보는 센스가 필요하다.

아쉽게도 Java API 문서에는 Direct Buffer 로 사용한 메모리 해제 관련한 내용이 기술되어 있지 않다. 검색을 해보니 그나마 가장 있어보이는 질의응답은 https://stackoverflow.com/questions/36077641/java-when-does-direct-buffer-released 여기에 있다. 공식 문서 내용은 아니므로 이게 정말 답인지는 알 수 없다.

요는 운영체제 메모리에 생성된 Direct Buffer 를 가리키는 참조는 JVM 메모리 내에 생성되는데, 이 참조가 GC가 되면 일반적인 GC 스레드가 아닌 다른 특정 스레드에 의해 버퍼 메모리가 해제된다고 한다.

DirectByteBuffer.Deallocator에 대한 얘기가 나오는데 Java API 문서에는 없는 클래스지만, IntelliJ에서 클래스 검색을 해보면 나오긴 한다.

정리

읽을 소스 파일이 기가 단위로 매우 크면 BufferedReader.readLine()은 CPU와 메모리를 엄청 잡아먹는다.

이 때 Java NIO의 FileChannel과 Direct Buffer를 활용하면 자원 사용량을 줄일 수 있다.

Buffer를 사용하면 자원 사용량과 속도 관점에서는 좋지만 행 기준으로 데이터를 처리하는데 불편함이 있다.

Buffer가 제공하는 메서드(position, mark, limit, flip, reset, rewind, compact)를 잘 활용하면 행 기준 처리도 가능하다.

Direct Buffer 를 가리키는 참조가 GC되면 운영체제 메모리에 생성된 Direct Buffer도 해제된다고 한다.(공식 API에 있는 내용은 아님)

읽을거리

기타

Direct Buffer를 사용하더라도 channel의 read/write 메서드가 아니라 buffer의 get/put을 쓰면 DMA가 작동하지 않는다는 글을 봤는데 확인해보진 않았다.

추가

Native Memory 사용 현황을 모니터링 할 수 있는 방법을 알아내서 별도의 글로 정리했다. 어떤 과정을 통해 메모리가 회수 되는지는 여전히 미지수지만 Direct Buffer로 사용됐던 Native Memory는 비교적 빠른 시간 안에 회수됨을 확인할 수 있었다.


크리에이티브 커먼즈 라이선스HomoEfficio가 작성한 이 저작물은(는) 크리에이티브 커먼즈 저작자표시-비영리-동일조건변경허락 4.0 국제 라이선스에 따라 이용할 수 있습니다.