Home Line vs Buffer 언제, 어떤 파일 읽기 전략을 선택해야 할까?
Post
Cancel

Line vs Buffer 언제, 어떤 파일 읽기 전략을 선택해야 할까?


데이터가 많은 엑셀 파일을 읽으면서 라인 단위 처리버퍼 단위 처리 에 대해 알게 되었고, 이를 정리하기 위해 글을 작성하게 되었습니다.




1. 왜 고민하게 되었을까?


용량이 꽤 있는 파일을 읽는 과정에서 아래와 같은 오류가 발생했습니다.

1
2
3
4
5
6
7
Java heap space
	at java.base/java.util.Arrays.copyOf(Arrays.java:3537)
	at java.base/java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:228)
	at java.base/java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:740)
	at java.base/java.lang.StringBuilder.append(StringBuilder.java:233)
	at java.base/java.io.BufferedReader.readLine(BufferedReader.java:376)
	at java.base/java.io.BufferedReader.readLine(BufferedReader.java:396)




원인은 readLine( )이 개행 문자를 만날 때까지 데이터를 StringBuilder에 계속 누적하기 때문이었는데요. 파일에 개행이 없거나 한 줄이 매우 긴 경우, 내부적으로 문자열이 계속 추가되면서 힙 메모리를 초과하게 된 것이죠. 파일을 읽을 때, 큰 고민 없이 라인 단위로만 처리했었는데 이게 화근이었습니다. 이 문제를 마주치면서 파일 읽기 방식도 종류가 있다는 것을 알게 되었죠.

1
2
3
4
5
6
7
8
9
10
11
12
@Service
class FileService {

    fun readLineBased(inputStream: InputStream) {
        BufferedReader(InputStreamReader(inputStream)).use { reader ->
            var line: String?
            while (reader.readLine().also { line = it } != null) {
                process(line!!)
            }
        }
    }
}





2. 파일 읽기 방식


파일을 처리할 때는 라인 단위, 버퍼 단위 두 가지가 있는데요. 용량이 큰 파일을 다룰 경우 읽기 방식에 따라 메모리 사용량과 안정성이 달라지므로, 데이터 구조에 맞는 전략을 잘 선택해야 합니다. 각 읽기 방식에 대해 간단하게 살펴보겠습니다.

1
2
3
4
InputStream  →  Read Strategy 선택  →  parse  →  transform  →  ......
                      │
                      ├─ LINE    → readLine( )
                      └─ BUFFER  → read(byte[])




2-1. 라인 단위

라인 단위 읽기는 데이터가 텍스트이고 줄 단위로 의미가 구분될 때 적합합니다. 로그 파일, CSV, 설정 파일처럼 한 줄이 하나의 레코드나 명령을 구성하는 경우 사용할 수 있으며, 개행 기준 분리를 직접 구현할 필요가 없어 코드가 단순해집니다. 다만 내부적으로 개행 문자를 탐색하고 줄이 끝날 때까지 데이터를 누적하므로, 순수 처리 속도는 버퍼 단위 읽기보다 낮을 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
class FileService {

    ......

    fun processLineBased(inputStream: InputStream) {
        inputStream.bufferedReader().use { reader ->
            var line: String?
            while (reader.readLine().also { line = it } != null) {
                process(line!!)
            }
        }
    }
}




2-2. 버퍼 단위

버퍼 단위 읽기는 고정 크기의 byte 배열을 사용해 데이터를 일정 크기씩 처리하는 방식입니다. 이미지나 바이너리 파일처럼 내용을 해석하지 않고 그대로 전달하는 작업에 적합하며, 파일 복사나 스트리밍 전송에도 사용됩니다. 또한 수 GB 이상의 대용량 파일이나 개행이 없거나 한 줄이 매우 긴 파일을 다룰 때 안정적입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
class FileService {
    
    ......

    fun processBufferBased(inputStream: InputStream) {
        val buffer = ByteArray(8192)

        inputStream.use { stream ->
            var bytesRead: Int
            while (stream.read(buffer).also { bytesRead = it } != -1) {
                process(buffer, bytesRead)
            }
        }
    }
}





3. 주의할 점


물론 각 방식을 사용할 때, 주의할 점도 있는데요. 이도 같이 살펴보겠습니다.



3-1. 라인 단위

라인 단위에서는 메모리 관리데이터 형식을 잘 이해 하는 것이 중요합니다.

  • 메모리 누수
  • 데이터 형식 이해



3-1-1. 메모리 누수

라인 단위 방식은 개행을 만날 때까지 데이터를 누적하므로, 개행 없거나 너무 큰 하나의 라인을 readLine( )으로 처리하면 메모리 부족이 발생할 수 있습니다.




readLine( )은 한 줄을 읽을 때 내부 char[] 버퍼의 데이터를 StringBuilder에 누적하고, 이 과정에서 공간이 부족하면 Arrays.copyOf( )를 호출해 배열을 확장합니다. 줄이 길수록 이 확장과 복사가 반복됩니다. 이후 toString( )이 호출되면서 내부 배열이 다시 한 번 복사되어 최종 String 객체가 생성되고, 이 객체는 힙에 올라갑니다.

1
2
3
reader.forEachLine { line ->
    process(line)
}




따라서 라인 단위 처리는 단순한 읽기가 아니라, 배열 재할당과 복사를 거쳐 매 줄마다 새로운 String 객체를 생성하는 구조입니다. 줄 수가 많거나 한 줄이 매우 길 경우, 힙 사용량과 GC 부담이 증가할 수 있습니다.

at java.base/java.util.Arrays.copyOf(Arrays.java:3537)




3-1-2. 데이터 형식 이해

라인 단위는 개행을 기준으로 동작합니다. Windows는 \r\n, Unix는 \n을 사용합니다. 보통 BufferedReader가 이를 처리하지만, 파일이 다른 시스템에서 생성되었거나 개행이 일정하지 않다면 의도와 다르게 동작할 수 있습니다. BufferedReader는 내부적으로 \r, \n, \r\n을 처리하지만, 직접 파싱하거나 split(“\n”) 같은 단순 로직을 사용할 경우 플랫폼 차이를 반드시 고려해야 합니다.

1
2
3
4
val text = "a\r\nb\nc\r\nd"
val lines = text.split("\n")

lines.forEach { println("[$it]") }
1
2
3
4
5
# \n만 기준으로 split하면 Windows 개행(\r\n)에서 \r이 남습니다.
[a\r]
[b]
[c\r]
[d]




또한 JSON처럼 “줄에 의미가 없는 형식”에서는 라인 단위가 논리 단위와 맞지 않을 수 있습니다.

1
2
3
4
{
  "id": 1,
  "name": "apple"
}




3-2. 버퍼 단위

버퍼 단위에서는 메모리 관리, 데이터 단위, 인코딩, 버퍼 크기 관리 가 중요합니다.

  • 메모리 관리
  • 데이터 단위
  • 인코딩
  • 버퍼 크기 관리



3-2-1. 메모리 관리

버퍼는 고정 크기 배열을 재사용하므로 읽기 자체는 안정적입니다. 그러나 읽은 데이터를 StringBuilder나 리스트에 계속 쌓아두면 결국 전체 파일을 메모리에 올리는 것과 동일해집니다. 버퍼 방식의 핵심은 읽고 즉시 소비하는 것이지, 안전하게 모아두는 것이 아닙니다.

1
2
3
4
5
6
7
8
9
10
11
12
fun readAll(inputStream: InputStream): String {
    val buffer = ByteArray(8192)
    val sb = StringBuilder()

    inputStream.use { stream ->
        var bytesRead: Int
        while (stream.read(buffer).also { bytesRead = it } != -1) {
            sb.append(String(buffer, 0, bytesRead))
        }
    }
    return sb.toString() // 결국 전체 파일이 메모리에 올라감
}




3-2-2. 데이터 단위

버퍼는 고정 크기만큼 잘라서 읽기 때문에, 우리가 파싱하려는 논리 단위(예: CSV 한 줄, JSON 한 객체)가 한 번에 들어오지 않을 수 있습니다. 버퍼는 단순히 byte를 나눠 읽을 뿐, 줄이나 객체 경계를 고려하지 않습니다. 따라서 이전 버퍼의 끝과 다음 버퍼의 시작을 이어 붙이는 처리를 하지 않으면 데이터가 중간에서 잘려 파싱 오류가 발생할 수 있습니다. 예를 들어 다음과 같은 CSV가 있다고 가정해보겠습니다.

1
2
3
1,apple
2,banana
3,orange



버퍼 크기가 10byte라면 이렇게 읽힐 수 있습니다.

1
2
1,apple
2,



2차, 3차 때는 다음과 같이요. 2,banana 한 줄이 두 버퍼에 나뉘어 있습니다. 버퍼는 물리적 단위로 자르고, CSV/JSON은 논리적 단위로 해석합니다. 그래서 버퍼 경계에서 데이터가 끊길 수 있고, 텍스트를 직접 파싱할 경우 이전 버퍼의 끝을 이어 붙이는 처리가 반드시 필요합니다.

1
2
banana
3,
1
orange



텍스트를 직접 파싱할 경우 이 경계 처리를 반드시 고려해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
fun brokenLineParse(inputStream: InputStream) {
    val buffer = ByteArray(8)

    inputStream.use { stream ->
        var bytesRead: Int
        while (stream.read(buffer).also { bytesRead = it } != -1) {
            val chunk = String(buffer, 0, bytesRead)
            val lines = chunk.split("\n") // 위험
            lines.forEach { println(it) }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun safeLineParse(inputStream: InputStream) {
    val buffer = ByteArray(8)
    val leftover = StringBuilder()

    inputStream.use { stream ->
        var bytesRead: Int
        while (stream.read(buffer).also { bytesRead = it } != -1) {
            leftover.append(String(buffer, 0, bytesRead))

            var index: Int
            while (leftover.indexOf("\n").also { index = it } >= 0) {
                val line = leftover.substring(0, index)
                println(line)
                leftover.delete(0, index + 1)
            }
        }
    }
}




3-2-3. 인코딩

byte 단위로 읽을 경우 UTF-8 같은 멀티byte 문자가 버퍼 경계에서 잘릴 수 있습니다. 이 상태에서 바로 String으로 변환하면 문자 깨짐이 발생합니다. UTF-8은 한 글자가 1byte가 아니라 여러 byte(예: 한글은 3byte)로 구성됩니다. 그런데 버퍼는 byte 단위로 끊어서 읽기 때문에, 글자가 완성되기 전에 중간에서 잘릴 수 있습니다. 이 상태에서 바로 String으로 변환하면 깨진 문자가 발생합니다. 그래서 텍스트를 처리할 때는 InputStreamReader처럼 문자 단위로 디코딩을 보장해주는 계층을 사용하는 것이 안전합니다.

1
echo -n "가" | xxd



“가”는 ea b0 80 세 byte입니다. 만약 2byte만 읽는다고 가정하면: 이건 완전한 글자가 아닙니다. 이 상태를 문자열로 해석하면 깨집니다. 버퍼는 안전하지만, 텍스트는 byte가 아니라 문자 기준으로 처리해야 합니다.

1
ea b0



텍스트 처리라면 InputStreamReader나 CharsetDecoder를 사용해 디코딩 단위까지 안전하게 처리해야 합니다.

1
2
3
4
5
6
7
8
9
10
fun safeTextRead(inputStream: InputStream) {
    InputStreamReader(inputStream, Charsets.UTF_8).use { reader ->
        val charBuffer = CharArray(8192)
        var charsRead: Int

        while (reader.read(charBuffer).also { charsRead = it } != -1) {
            processChars(charBuffer, charsRead)
        }
    }
}




3-2-4. 버퍼 크기 관리

8192 byte는 일반적인 기본값이지만 환경에 따라 적절하지 않을 수 있습니다. 너무 작으면 I/O 호출이 증가하고, 너무 크면 불필요한 메모리 점유가 생깁니다. 대부분의 경우 8KB~64KB 범위면 충분하지만, 네트워크 스트리밍이나 대용량 파일 처리에서는 상황에 맞는 조정이 필요합니다.

1
2
3
4
5
6
7
8
9
10
11
12
fun copy(input: InputStream, output: OutputStream) {
    val buffer = ByteArray(16 * 1024)

    input.use { ins ->
        output.use { outs ->
            var bytesRead: Int
            while (ins.read(buffer).also { bytesRead = it } != -1) {
                outs.write(buffer, 0, bytesRead)
            }
        }
    }
}





4. 정리


성능이 중요한 경우에는 전략을 변경할 수 있겠지만, 기본적인 판단 기준은 데이터의 논리적 단위가 줄인지 여부입니다. 줄 단위 의미가 있는 텍스트는 라인 기반으로 읽고, 그렇지 않거나 한 줄이 지나치게 긴 경우에는 버퍼 기반으로 처리하는 것이 적절합니다.


This post is licensed under CC BY 4.0 by the author.

OepnSearch에서 중복 데이터 저장은 어떻게 방지할 수 있을까?

Excel 다운로드 과정에서 발생한 이슈