마지막 글 조회
마지막 글 조회 최적화
기존 파일의 마지막 글을 조회할 때 while문을 없애면 시간이 더 줄어질 수 있을 것 같아 코드를 수정 했다.
기존 코드는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) {
long fileLength = file.length();
if (fileLength > 0) {
randomAccessFile.seek(fileLength);
long pointer = fileLength - 2;
while (pointer > 0) {
randomAccessFile.seek(pointer);
char c = (char) randomAccessFile.read();
if (c == '\n') {
break;
}
pointer--;
}
randomAccessFile.seek(pointer+1);
String line = randomAccessFile.readLine();
if (line == null || line.trim().isEmpty()) {
return null;
}
if (line.startsWith(",")) {
line = line.substring(1);
}
// 이하 동일
파일 저장 형식
{"roomId":"6d0a2b57","type":"JOINED","sender":"hello","message":"환영합니다.","adminChat":0,"userChat":0,"day":"9/21","time":"01:04"}
,{"roomId":"6d0a2b57","type":"TALK","sender":"hello","message":"123","adminChat":1,"userChat":0,"day":"9/21","time":"01:04"}
첫번째 줄만 쉼표(,)가 없고 마지막 줄은 항상 공백(줄바꿈)으로 끝나게 저장된다.
1차 수정
ReversedLinesFileReader를 적용했다.
implementation 'commons-io:commons-io:2.11.0'
1
2
3
4
5
6
7
8
9
10
11
try (ReversedLinesFileReader r = new ReversedLinesFileReader(file,StandardCharsets.UTF_8)) {
long fileLength = file.length();
if (fileLength > 0) {
String line = r.readLine();
if (line == null || line.trim().isEmpty()) {
return null;
}
if (line.startsWith(",")) {
line = line.substring(1);
}
// 이하 동일
이 글을 보고 거꾸로 읽는 것이 오히려 시간을 더 잡아먹을 수 있다는 걸 알게 되었다.
기존 코드와 차이가 얼마나 나는지 출력시켜봤다.(System.nanoTime())
- 기존 코드 : 7,457,800ns
- ReversedLinesFileReader 적용 코드 : 15,646,500ns
기존 코드가 오히려 더 빠르게 동작했다.
RandomAccessFile와 ReversedLinesFileReader
RandomAccessFile
RandomAccessFile은 seek()를 통해 파일 포인터를 특정 위치로 바로 이동시키고 필요한 부분만 읽는다.
char 단위로 읽으면서 줄바꿈 문자(\n)를 찾아 byte 단위로 탐색한다.
ReversedLinesFileReader
일정한 크기로 나눈 블록을 메모리에 로드한 후 그 안에서 줄바꿈 문자(\n)를 찾는다.
한 번에 블록 단위로 읽기 때문에 블록 내에 줄바꿈 문자가 없을 경우 추가 블록을 읽어 다시 검색하는 과정을 반복한다.
여러 줄을 한꺼번에 읽는 경우에는 효율적이지만, 한 줄만 읽는 경우 오버헤드가 발생한다.
나는 파일의 끝에서 2번째 줄만 읽으려고 했기 때문에 RandomAccessFile가 더 빠르게 동작했던 것 같다.
*오버헤드란?
추가적인 작업이나 불필요한 처리가 개입되어 원래 소요 시간보다 더 많은 시간이 걸리는 것을 의미한다.
예를 들어 원래 5초면 끝날 작업이 추가적인 처리로 인해 7초가 걸린다면 2초가 오버헤드가 된다.
2차 수정
첫 줄만 있을 경우와 두 줄 이상의 경우 차이에 따라서도 동일하게 적용하기 위해서
줄바꿈(\n)이 아닌 { 문자를 기준으로 줄을 찾고 {부터 읽도록 수정했다.
위와 같이 적용하면서 아래 코드는 필요없어졌다.
1
2
3
if (line.startsWith(",")) {
line = line.substring(1);
}
또, 테스트를 해보다가 문제점을 발견했는데
첫번째 줄만 있는 경우 줄바꿈을 만나 종료하는게 아니라 {에서 종료를 하게 된다.
그러면서 pointer+1을 하다보니 {가 아닌 rommId의 r부터 해당 줄을 읽게 되어버렸다.
따라서 \n가 아닌 {를 만나면 즉시 종료한 후 {부터 줄을 읽게 했다.
처음에는 사용자에 따라 변하는 값 외의 고정된 값(byte)을 계산해서
파일 끝에서 145byte 앞으로 건너뛴 뒤 {를 찾게 했었다.
그런데 마지막 줄의 길이는 메시지 내용에 따라 달라지기 때문에
145라는 고정값을 기준으로 삼는 게 의미가 없어서 걷어냈다.
지금은 파일 끝에서부터 {를 찾는다.
최종 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
if (fileLength > 0) {
// 파일 끝에서부터 역방향으로 마지막 JSON 객체의 시작('{')을 찾는다.
long pointer = fileLength - 1;
while (pointer > 0) {
randomAccessFile.seek(pointer);
char c = (char) randomAccessFile.read();
if (c == '{') {
break;
}
pointer--;
}
randomAccessFile.seek(pointer);
String line = randomAccessFile.readLine();
JMH로 측정한 값
위 숫자들은 System.nanoTime()으로 한 번씩 잰 값이라 참고용이다.
한 번씩만 재면 첫 호출의 JIT 워밍업이나 파일 시스템 캐시 상태가 그대로 섞여 들어가서
실행할 때마다 값이 크게 흔들린다.
마지막 글 조회 속도는 JMH로 따로 측정했다.
testOriginal: 한 건이 여러 줄로 저장되던 시절, 파일 끝에서 개행을 7개 세면서 역방향으로 읽는 방식testImproved: 한 건을 한 줄로 저장하도록 바꾼 뒤, 개행 하나만 찾아 마지막 한 줄을 읽는 방식
측정 조건은 fork = 2, warmup 10회 × 2초, 측정 10회 × 3초다.
| Benchmark | Score | 오차 비율 |
|---|---|---|
testOriginal | 6.062 ± 0.218 ms/op | 3.6% |
testImproved | 2.184 ± 0.059 ms/op | 2.7% |
평균 기준 약 2.8배 빨라졌다.
기존 방식은 마지막 줄이 길수록 더 많이 읽어야 해서 실행마다 시간이 달라지고
개선한 방식은 읽는 양이 거의 고정이라 값이 일정하게 나온다.
처음에는 iteration을 1초로 두고 쟀는데 그때는 testOriginal이 7.227 ± 4.740 ms/op로 나왔다.
오차가 점수의 66%라 실제 값이 2.5ms일 수도 12ms일 수도 있다는 뜻이었다.
이 평균으로 배수를 계산하는 건 성립하지 않는다.
원인은 iteration 길이였다. JMH는 iteration 한 번 동안 메서드를 계속 호출하고
총 시간 ÷ 호출 횟수를 그 iteration의 점수로 쓴다.
6ms짜리 작업을 1초로 재면 호출이 150회뿐이라 OS가 CPU를 잠깐 넘기거나 디스크가 한 번 튀면 그게 150회 평균에 그대로 찍힌다.
3초로 늘리면 호출이 약 500회가 되어 희석된다.
바꾸고 나니 오차가 3.6%로 떨어졌고 배수도 4.4배에서 2.8배로 정정됐다.
Score만 보지 말고 Error가 Score의 몇 %인지를 같이 봐야 한다는 걸 이번에 알았다.
이 글에서 다룬 { 기준 탐색 방식은 벤치마크에 따로 넣지 않아서 위 표에는 포함되어 있지 않다.
관련 글 : Jmh