Java String length() 주의점

mongBrown·2026년 5월 28일

Java String, length()만 믿으면 안 되는 이유

"😀".length()를 호출하면 얼마가 나올까?
1이 아니라 2가 나온다.
이유는 Java String이 내부적으로 UTF-16을 사용하기 때문이다.


Java String은 UTF-16을 쓴다

Java String은 내부적으로 char[]를 가지고 있고, 각 char는 UTF-16 코드 유닛 하나를 나타낸다.

UTF-16에서 대부분의 문자는 코드 유닛 하나(2바이트)로 표현된다. 알파벳, 한글, 일반적인 한자가 여기에 해당한다. 이 범위를 BMP(Basic Multilingual Plane) 라고 한다.

문제는 BMP 밖에 있는 문자들이다. 이모지, 일부 고대 문자, 특수 기호 중 BMP를 벗어나는 문자들은 코드 유닛 2개가 필요하다. 이 두 코드 유닛을 서로게이트 페어(Surrogate Pair) 라고 한다.

String emoji = "😀";
// 내부적으로 char 2개 (상위 서로게이트 + 하위 서로게이트)로 저장됨

length()가 세는 것

length()는 char 개수를 반환한다. 유니코드 문자(코드 포인트) 수가 아니다.

String emoji = "😀";
emoji.length();  // 2 (char 2개)

이모지 하나가 서로게이트 페어로 저장되기 때문에 length()는 2를 반환한다. 실제 문자는 하나인데.

정확한 문자 수가 필요하다면 코드 포인트 단위로 세야 한다.

emoji.codePointCount(0, emoji.length());  // 1
emoji.codePoints().count();               // 1 (Java 8+)

charAt()의 함정

charAt()도 char 단위로 동작한다. 서로게이트 페어로 저장된 문자에 charAt(0)을 호출하면 상위 서로게이트만 반환해서 깨진 문자가 나온다.

String emoji = "😀";
char c = emoji.charAt(0);       // 깨진 문자 (상위 서로게이트)
int cp = emoji.codePointAt(0);  // 128512 (실제 코드 포인트)

순회할 때도 마찬가지다. charAt()으로 순회하면 서로게이트 페어를 두 번 만나게 된다.

// 잘못된 방법
for (int i = 0; i < s.length(); i++) {
    char c = s.charAt(i);  // 이모지를 깨진 문자 2개로 처리
}

// 올바른 방법
s.codePoints().forEach(cp -> {
    // 코드 포인트 단위로 처리
});

getBytes()의 함정

getBytes()를 인코딩 지정 없이 호출하면 JVM 기본 인코딩을 사용한다. 기본 인코딩은 file.encoding 시스템 환경변수에서 온다.

// 실제 JDK 소스
public static Charset defaultCharset() {
    String csn = GetPropertyAction.privilegedGetProperty("file.encoding");
    Charset cs = lookup(csn);
    if (cs != null) defaultCharset = cs;
    else defaultCharset = sun.nio.cs.UTF_8.INSTANCE;
}

file.encoding이 있으면 그 값을, 없으면 UTF-8을 사용한다.

문제는 OS마다 file.encoding 기본값이 다르다는 것이다. Windows는 MS949(EUC-KR 계열)인 경우가 많고, Linux/Mac은 보통 UTF-8이다. 로컬에서는 멀쩡하게 동작하는 코드가 서버에서 깨지는 대표적인 원인이다.

// 위험 — 환경마다 다른 결과
byte[] b = str.getBytes();

// 안전 — 항상 동일한 결과
byte[] b = str.getBytes(StandardCharsets.UTF_8);

비밀번호는 String으로 저장하면 안 되는 이유

String은 불변이다. 한 번 생성되면 내용을 직접 바꿀 수 없다. 비밀번호를 사용하고 나서 메모리에서 즉시 지우고 싶어도 방법이 없다.

String password = "secret123";
password = null;  // 참조만 끊음, 실제 문자열은 GC 전까지 힙에 남아있음

GC가 수거하기 전까지 힙에 남아있고, 그 사이에 힙 덤프가 뜨면 비밀번호가 평문으로 노출된다.

char[]는 사용 후 직접 덮어쓸 수 있다.

char[] password = {'s', 'e', 'c', 'r', 'e', 't'};

// 인증 처리...

Arrays.fill(password, '\0');  // 즉시 메모리에서 제거

그렇다면 Spring Security는 char[]를 쓸까? HTTP 요청 자체가 텍스트로 오기 때문에 Spring Security도 내부적으로는 String을 사용한다. 대신 인증 완료 후 eraseCredentials()로 credential 필드를 null로 세팅해서 참조를 끊는다.

char[]가 실제로 의미 있는 곳은 JDBC 드라이버 비밀번호 설정이나 Swing의 JPasswordField처럼 애플리케이션 레벨에서 직접 제어할 수 있는 경우다. 웹 레이어에서는 HTTPS + 짧은 세션 수명 + 힙 덤프 접근 제한으로 보완하는 게 현실적인 접근이다.

profile
화이팅!

0개의 댓글