유랑하는 나그네의 갱생 기록

だけど素敵な明日を願っている -HANABI, Mr.children-

Study/Java

[Java] charAt과 substring에 long 타입 인덱스를 사용할 수 없는 이유에 관하여

Madirony 2024. 11. 10. 23:51
728x90

String, 왜 why?

 최근 Java로 문자열을 처리하던 도중에 골머리를 앓았습니다. 대체 왜 자바에서는 charAt과 substring 메서드에 long 타입 인덱스를 사용할 수 없는가? 물론 문자열을 여기까지 끌고 온 코드 설계의 문제 이긴 하지만요.

 

 

charAt 그리고 substring의 구조

charAt의 구조

일단 charAt 메서드의 구조는 이런 식으로 짜여있습니다. 매개변수 자체가 int형이죠. 범위 역시 0부터 Integer.MAX_VALUE 값입니다. 


substring

substring 메서드 역시 인덱스의 타입을 int 타입으로 받고 있습니다. 아니 그냥 int 타입을 long으로 바꿔서 메서드를 구현하면 안 되냐고 생각할 수도 있겠습니다만. 자바는 굳이 배열이나 문자열과 같은 데이터 구조에서 인덱스를 int 타입으로 제한합니다. 왜why? 자바 언어 사양과 JVM 메모리 모델이 int 범위 내에서 안전하게 동작하도록 최적화되었기 때문입니다. 자바에서 배열의 인덱스나 문자열의 인덱스는 항상 int 범위 (-2,147,483,648 ~ 2,147,483,647)에 맞춰야 합니다.

 

이렇게 int로 제한된 이유는 자바의 메모리와 성능을 고려한 설계 때문입니다. int는 상대적으로 작은 4바이트 크기의 정수로 인덱스를 처리할 때 메모리를 효율적으로 사용할 수 있습니다. 또한 자바가 동작하는 대부분의 플랫폼은 int를 효율적으로 처리할 수 있는 하드웨어 구조를 가지고 있기 때문에 int 범위를 넘어서 메모리를 할당할 경우 성능에 불리한 영향을 줄 수 있습니다.

 


만약에 만약에 정말 만약에 long 타입을 지원할 경우 발생할 수 있는 문제

 

long 타입은 8바이트 크기의 정수로 int보다 훨씬 큰 범위를 표현할 수 있습니다.

(-9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807)

 

만약 자바가 charAt이나 substring 메서드에서 long 인덱스를 허용하도록 수정된다면 메모리 초과 같은 문제가 생길 수도 있습니다. 문자열을 포함한 배열의 길이가 int 범위를 초과하면 이를 저장하기 위해 엄청난 메모리가 필요합니다. 예를 들어보자면, 문자열 길이가 Integer.MAX_VALUE를 초과한다면 약 2GB 이상의 메모리를 필요로 하게 됩니다. 이는 대부분의 시스템에서 효율적이지 않고 성능 저하나 OutOfMemoryError 같은 메모리 부족 문제를 유발할 수 있어요.

 


 

... 그래도 혹시나 해서 StringBuilder라면 어떨까 싶어서 시도해보려고 했으나, StringBuilder 역시 내부적으로 char[] 배열을 사용하여 문자열을 조작하고 있었습니다. 하긴 JVM이 그렇게 하라고 멱살 잡고 흔드니 StringBuilder가 어쩔 수 있겠습니까. 까라면 까야죠..

 


다른 방법은 없는가?

??? : 안되면 어쩔 건데 w 네가 뭘 할 수 있는데 w

 

 일반적인 상황에서 문자열 길이가 Integer.MAX_VALUE (약 21억)를 초과하는 경우는 발생하지 않겠습니다만. (메모리도 생각해야죠.) 매우 큰 문자열을 처리해야 한다면 다 방법이 있습니다. 방법이. 큰 숫자를 다루는 BigInteger 같은 클래스가 존재하는 건 아니지만요.

 

예로 몇 가지를 보여드리겠습니다.

 

List<StringBuilder>에 저장

 

List<StringBuilder> largeString = new ArrayList<>();
// 각각의 StringBuilder가 약 1GB 이하의 문자열을 저장하도록 설정

극단적으로 100GB의 데이터를 문자열로 저장해야 한다면 각각 1GB 크기의 작은 문자열 조각으로 나눠 List<StringBuilder>에 저장하는 방법입니다. 필요한 부분에 접근할 때는 각 조각에 대한 인덱스를 따로 계산해 접근할 수 있습니다. 근데 이러면 메모리 부족 오류가 뜨겠죠??

 

파일이나 외부 저장소를 이용한 접근

// 예시: RandomAccessFile을 사용해 특정 위치의 문자열만 읽어오기
RandomAccessFile file = new RandomAccessFile("large_text_file.txt", "r");
file.seek(largeOffset);  // 특정 위치로 이동
byte[] buffer = new byte[bufferSize];
file.read(buffer);       // 버퍼 크기만큼 읽어옴
file.close();

매우 긴 문자열 데이터를 파일로 저장하고 필요한 부분만 읽어오는 방식입니다. 이 방법을 쓰면 메모리를 효율적으로 사용하면서도 큰 데이터를 처리할 수 있습니다.

 

메모리 매핑

import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;

public class LargeFileReader {
    public static String readSegment(String filePath, long start, int length) throws Exception {
        try (FileChannel channel = new RandomAccessFile(filePath, "r").getChannel()) {
            MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, start, length);
            byte[] bytes = new byte[length];
            buffer.get(bytes);
            return new String(bytes, StandardCharsets.UTF_8);
        }
    }
}

java.nio의 메모리 매핑 파일을 사용하면 특정 파일의 내용을 메모리처럼 다룰 수 있습니다. 전체 파일을 메모리에 로드하지 않고 필요한 부분만 매핑하므로 메모리 사용량을 크게 줄일 수 있습니다.

 

스트리밍 방식으로 처리

import java.io.BufferedReader;
import java.io.FileReader;

public class StreamedFileProcessor {
    public static void processFile(String filePath) throws Exception {
        try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
            String line;
            while ((line = reader.readLine()) != null) {
                // 필요한 처리 수행
            }
        }
    }
}

파일을 한 번에 읽는 대신 스트리밍 방식으로 데이터를 조금씩 처리하는 방법도 있습니다. BufferedReaderInputStream을 사용해 필요한 만큼씩 데이터를 읽고 처리하면 메모리 사용을 절약할 수 있습니다.

 

etc.

뭐, 가장 좋은 건 데이터베이스를 활용하는 게 좋겠죠? 데이터베이스에 데이터를 저장하고 필요할 때마다 쿼리를 통해 부분 접근하는 것이 효율적이겠습니다. 그런데 만약에 알고리즘을 푸는 상황에서 이와 같은 문제에 봉착했다? 알고리즘을 최적화하는 방법을 터득하는 수밖에 없겠습니다. 알고리즘 풀이용 웹 IDE에서 DB를 쓸 순 없으니까요.

 

 


* 2024.11.15)

 

jdk14u/src/java.base/share/classes/jdk/internal/util/ArraysSupport.java at 84917a040a81af2863fddc6eace3dda3e31bf4b5 · openjdk/j

https://openjdk.org/projects/jdk-updates last released 2020-07-14 - openjdk/jdk14u

github.com

    /**
     * The maximum length of array to allocate (unless necessary).
     * Some VMs reserve some header words in an array.
     * Attempts to allocate larger arrays may result in
     * {@code OutOfMemoryError: Requested array size exceeds VM limit}
     */
    public static final int MAX_ARRAY_LENGTH = Integer.MAX_VALUE - 8;

허나.. jdk14의 코드를 열어보면 MAX_ARRAY_LENGTH 값이 Integer.MAX_VALUE에서 8을 뺀 값이 있음을 확인할 수 있었습니다. 배열을 저장할 때 메타 데이터와 관련된 메모리 공간도 있어야 하기 때문입니다.

 

자바에서 배열 객체의 구조와 형태는 일반 자바 객체와 유사합니다. 하지만 배열 객체에는 배열의 크기를 나타내는 추가적인 메타데이터가 포함된다는 점이 주요 차이점입니다. 배열 객체의 메타데이터는 다음 요소들로 구성됩니다.

 

클래스 정보 (Class)
배열 객체는 클래스 정보를 가리키는 포인터를 가지고 있습니다. 또한 이 포인터는 객체 타입을 설명하며, int[] 배열의 경우 int[] 클래스 정보를 가리키게 됩니다.

 

플래그 (Flags)
플래그는 객체의 상태를 설명하는 다양한 정보들을 포함합니다. 여기에는 객체의 해시코드(객체가 해시코드를 가지고 있는 경우)와 객체의 형태(예: 객체가 배열인지 여부)를 나타내는 정보가 포함됩니다.

 

동기화 정보 (Lock)

동기화 정보는 객체가 현재 동기화 상태에 있는지 여부를 나타냅니다. 이는 동기화된 메서드나 블록에서 객체를 사용할 때 관리되는 데이터입니다.

 

크기 (Size)

배열 객체에는 배열의 크기를 나타내는 정보가 포함됩니다. 배열의 크기는 JVM이 배열의 경계를 확인하고 메모리를 효율적으로 관리하기 위해 사용됩니다.

이러한 메타데이터 덕분에 JVM은 배열 객체를 안전하고 효율적으로 관리할 수 있습니다. 배열 객체는 일반 객체와 마찬가지로 GC(가비지 컬렉션)의 대상이 되며, JVM은 메타데이터를 기반으로 배열의 동작과 메모리를 제어합니다.

cf) 배열 객체는 일반 객체보다 약간 더 많은 메모리를 소비합니다. 이는 위와 같은 메타데이터를 저장하기 위한 추가 공간이 필요하기 때문입니다.



** 2024.11.16)

추가로 알아본 것은 String의 내부구조였습니다. 자바에서 String은 가장 널리 사용되는 클래스 중 하나로, 효율적인 메모리 관리와 성능 최적화가 매우 중요합니다. 자바 버전이 발전하면서 String의 내부 구현 방식도 변화해 왔는데, 특히 Java 9에서 도입된 Compact Strings는 큰 전환점으로 평가받고 있습니다. 이번 글에서는 String의 내부 구조가 어떻게 변화했는지, 그리고 Compact Strings가 무엇인지 알아보겠습니다.

 

Java 8 이전: char[] 기반의 String

Java 8과 그 이전 버전에서 String은 내부적으로 char[] 배열을 사용하여 데이터를 저장했습니다. char[]UTF-16 형식을 기반으로, 모든 문자를 2바이트로 저장합니다. 장점으로는 UTF-16은 다국어를 지원하는 표준 인코딩 방식으로, 다양한 언어의 문자를 표현하는 데 적합했고 단순한 구조로 구현이 쉬웠습니다.

 

하지만, 무슨 문제점이 있었느냐? 메모리 비효율성의 문제가 있었습니다. 대부분의 문자열은 ASCII 문자(영문 알파벳과 숫자 등)로 구성되며, 이 경우 실제로는 1바이트로도 충분하지만, UTF-16 형식 때문에 2배의 메모리를 소모하게 됩니다. 메모리 낭비는 특히 대규모 문자열 데이터를 처리하는 애플리케이션에서 큰 문제가 되었습니다.

 

Java 9: Compact Strings의 도입

Java 9부터 String의 내부 구조는 char[] 대신 byte[] 배열을 사용하도록 변경되었습니다. 이와 함께 Compact Strings라는 최적화가 도입되었습니다. Compact Strings의 핵심은 문자열의 실제 데이터가 Latin-1(ASCII)로 표현 가능한지 여부에 따라 저장 방식을 다르게 한다는 점입니다. 문자열 데이터를 저장하는 byte[]에서 Latin-1 문자열은 각 문자를 1바이트로 저장하고, UTF-16 문자열은 각 문자를 2바이트로 저장합니다. 문자열의 인코딩 방식을 나타내는 추가 필드인 coder 필드도 존재합니다. coder == 0: Latin-1 (1바이트), coder == 1: UTF-16 (2바이트).

 

Compact Strings는 문자열이 Latin-1(ASCII) 범위의 문자만 포함하는 경우, 데이터를 1바이트로 압축하여 저장합니다. 그리고 Latin-1 범위를 벗어나는 문자가 포함되면 UTF-16 형식을 사용합니다.

 

이렇게 압축을 한다면? Latin-1 문자열은 기존 UTF-16보다 메모리를 절반으로 줄일 수 있습니다. 실제로 많은 애플리케이션에서 사용하는 문자열은 ASCII 범위에 포함되므로, 메모리 절감 효과가 큽니다. 또한 Latin-1 문자열은 데이터가 더 작으므로, 메모리 대역폭을 덜 사용하며 캐시 효율이 높아집니다. 추가로 Compact Strings는 개발자가 명시적으로 관리할 필요 없이 자동으로 적용됩니다. 문자열의 압축 여부는 JVM이 런타임에 처리하므로, 기존 코드와의 호환성도 유지됩니다.

 

Java 6의 Compressed Strings

Compact Strings 이전에도 비슷한 아이디어가 Java 6에서 Compressed Strings라는 이름으로 도입된 적이 있습니다. 당시에는 String 내부 데이터를 ASCII일 경우 byte[]로 압축하여 저장했으나, Java 7에서 이 기능이 제거되었습니다. 왜냐면 Compressed Strings는 성능과 메모리 사용량 간의 균형을 맞추지 못했습니다. JVM 내부에서 문자열 압축 및 해제를 반복하며 성능 저하를 초래했습니다.

 

그래도 기록 말살형에 처해진 게 아니라, 이런 식으로 추후에 개선된 방식으로 만들어내는 걸 보니까 자바가 끊임없이 발전하는 언어라는 점을 다시 한번 느낄 수 있었습니다. Virtual Thread 같은 건 언제 다뤄보지.. 학습할 게 많네요!

 

728x90