본문 바로가기
빌드노트 검색

빌드노트

Java toLowerCase()로 만든 조회 키가 서버마다 달라질 때

같은 ASCII 식별자도 Java의 기본 로캘에 따라 소문자 키가 달라진다. 영어·터키어·ROOT의 문자열과 코드 포인트, Map 조회를 실제 비교하고, 언어 중립 키에 ROOT를 적용할 위치와 기존 저장 키의 한계를 설명한다.

Java에서 같은 ASCII 식별자를 toLowerCase()로 바꿨는데 특정 서버에서만 키 조회가 실패한다면, 변환에 쓰인 기본 Locale부터 확인한다. 인자 없는 메서드는 그 로캘의 언어 규칙을 따른다. 터키어에서는 대문자 I가 점 없는 소문자 ı로 바뀐다. 언어와 무관한 식별자를 소문자로 맞추는 규약이라면 저장과 조회 양쪽에 toLowerCase(Locale.ROOT)를 명시한다. String.toLowerCase() 공식 설명 (새 탭에서 열림)

변환하는 JVM에서 실제 값을 확인한다

기본 로캘은 JVM 시작 때 호스트 환경을 바탕으로 정해지며, 실행 중 Locale.setDefault()로 바뀔 수도 있다. 서버의 국가나 OS 설정만 보고 현재 값을 확정할 수는 없다. Locale.getDefault() 공식 설명 (새 탭에서 열림)

다음 코드는 변환된 키와 각 문자의 유니코드 번호인 코드 포인트를 확인한다. 문제가 발생하는 애플리케이션의 변환 지점에 넣고, raw에는 실제 조회 입력을 대입한다. 여기서는 합성 식별자 TITLE_ID를 쓴다.

import java.util.Locale;
String raw = "TITLE_ID";
String key = raw.toLowerCase();
System.out.println("locale=" + Locale.getDefault().toLanguageTag());
System.out.println("key=" + key);
System.out.println(key.codePoints().mapToObj(cp -> String.format(Locale.ROOT, "U+%04X", cp)).toList());

별도 JShell에서 기본 로캘을 출력하면 그 실습 JVM의 값을 읽게 된다. 기존 서버의 문제를 진단할 때는 실제로 키를 만드는 JVM에서 위 값을 확인해야 한다.

터키어 기본 로캘로 시작한 실험용 JVM에서는 다음 값이 나왔다. 운영 장애 로그가 아니라 별도 실행한 결과이며, JShell의 프롬프트와 입력 표시는 생략했다.

locale=tr-TR
key=tıtle_ıd
[U+0074, U+0131, U+0074, U+006C, U+0065, U+005F, U+0131, U+0064]

TITLE_ID에 있던 두 I는 U+0049다. 영어 소문자 i는 U+0069인데, 위 결과에는 두 곳 모두 U+0131이 들어갔다. 입력이 ASCII여도 로캘에 따른 소문자 변환 결과까지 ASCII인 것은 아니다. 저장된 키에도 같은 코드 포인트 출력을 적용해 비교하면 모양이 비슷한 문자를 구별할 수 있다.

같은 저장 키를 영어·터키어·ROOT로 조회했다

Windows 11, OpenJDK 17.0.2에서 새 Java 프로세스 세 개를 실행했다. 각 자식 JVM의 시작 옵션으로 기본 로캘을 영어 en-US, 터키어 tr-TR, ROOT로 지정하고, 모두 동일한 TITLE_ID를 인자 없는 toLowerCase()로 변환했다. 저장소는 title_id에 문자열 found를 넣은 합성 Map 하나로 고정했다.

  • 영어 기본 로캘: title_id로 변환되어 조회 결과가 found였다.
  • 터키어 기본 로캘: tıtle_ıd로 변환되어 조회 결과가 null이었다.
  • ROOT 기본 로캘: title_id로 변환되어 조회 결과가 found였다.

같은 입력과 저장 키를 유지한 채 변환 규칙에 따라 조회 결과가 달라졌다. 이어서 각 JVM 안에서 로캘을 인자로 명시해도 비교했다. Locale.ROOT를 명시한 변환은 세 JVM 모두 title_id를 만들었다.

직접 비교하려면 JDK 17 이상에서 jshell을 실행한 뒤 아래 입력을 붙여 넣는다. 기본 로캘을 바꾸지 않고, 메서드 인자로 영어·터키어·ROOT를 선택해 같은 키를 조회하는 코드다.

import java.util.*;
String raw = "TITLE_ID";
var stored = Map.of("title_id", "found");
for (Locale locale : List.of(Locale.ENGLISH, Locale.forLanguageTag("tr-TR"), Locale.ROOT)) {
    String key = raw.toLowerCase(locale);
    System.out.println(locale.toLanguageTag() + " | " + key + " | " + stored.get(key));
    System.out.println(key.codePoints().mapToObj(cp -> String.format(Locale.ROOT, "U+%04X", cp)).toList());
}

각 줄은 로캘 태그·변환된 키·조회 결과 순서다. 그다음 줄은 키 전체의 코드 포인트다. 실제 출력은 다음과 같았다.

en | title_id | found
[U+0074, U+0069, U+0074, U+006C, U+0065, U+005F, U+0069, U+0064]
tr-TR | tıtle_ıd | null
[U+0074, U+0131, U+0074, U+006C, U+0065, U+005F, U+0131, U+0064]
und | title_id | found
[U+0074, U+0069, U+0074, U+006C, U+0065, U+005F, U+0069, U+0064]

여기서 und는 ROOT를 toLanguageTag()로 출력한 값이다. 글꼴이나 터미널 인코딩 때문에 점 없는 ı가 다르게 보이면 코드 포인트의 U+0131을 확인한다. 실제 애플리케이션에서 저장 키와 조회 키의 코드 포인트가 모두 같다면, 이 실험의 로캘 차이만으로 조회 실패를 설명할 수 없다. 그때는 실제 저장·조회 경로를 더 확인해야 한다.

사람의 언어인가, 시스템 식별자인가

터키어 문장을 터키어 규칙에 따라 소문자로 표시하려는 경우에는 해당 언어의 로캘을 명시한다. 반면 여기처럼 언어와 무관한 식별자에 소문자 키 규약을 정했다면 Locale.ROOT가 그 의도를 드러낸다. ROOT는 언어와 국가에 중립적인 로캘이다. Locale.ROOT 공식 설명 (새 탭에서 열림)

다음은 저장과 조회에 같은 ROOT 규칙을 적용하는 작은 예다. 새 JShell에서 실행하면 found가 출력되며, 터키어 기본 로캘에서도 확인했다.

import java.util.*;
String raw = "TITLE_ID";
var stored = new HashMap<String, String>();
stored.put(raw.toLowerCase(Locale.ROOT), "found");
System.out.println(stored.get(raw.toLowerCase(Locale.ROOT)));

애플리케이션에서는 이 변환을 공통 키 생성 경로에 두어 저장과 조회가 같은 규칙을 쓰게 한다. 기본 로캘 전체를 영어로 바꾸는 방식은 같은 JVM의 다른 로캘 의존 기능에도 영향을 줄 수 있다. Locale.setDefault()의 영향 범위 (새 탭에서 열림)

이미 tıtle_ıd로 저장된 키는 조회 코드만 ROOT로 바꿔도 찾지 못한다. 별도 Map에 그 키를 저장한 뒤 ROOT로 만든 title_id를 조회했을 때도 일치하지 않았다. 기존 데이터가 있다면 원래 식별자와 대조해 키 재생성 범위와 충돌 가능성을 확인해야 한다.

이번 실행은 합성 ASCII 식별자 하나와 영어·터키어·ROOT의 변환 및 Java Map 조회를 확인했다. 부모 프로세스나 OS의 로캘은 바꾸지 않았다. Java 21 공식 문서에도 같은 기본 로캘 의존 동작과 ROOT 사용 안내가 있지만, Java 21 런타임과 운영 DB 조회는 실행하지 않았다. Java 21 String 설명 (새 탭에서 열림)

Locale.ROOT 소문자 변환은 언어 중립적인 키 규칙을 정하는 한 단계다. 모든 유니코드 문자열의 동등성을 판정하거나 유니코드 정규화·보안 검증을 대신하는 규칙으로 확대해서는 안 된다. 임의의 유니코드 입력을 받는 식별자는 허용 문자와 동등성 정책을 별도로 정해야 한다.

← 전체 글 보기