Unicode 대소문자 매핑은 단순한 A↔Z 치환이 아니다
ASCII는 대소문자를 완벽한 양방향 매핑처럼 보이게 만들었습니다. A–Z와 a–z가 정확히 짝을 이루니 대문자화·소문자화가 가역처럼 느껴집니다. Unicode는 이 모델을 의도적으로 깨뜨립니다. 표준의 SpecialCasing.txt는 1:다(one-to-many), 로케일 의존, 문맥 의존 매핑을 정의하며, JavaScript의 toUpperCase()와 toLowerCase()는 이를 충실히 구현합니다 — 그래서 이 놀라움은 프로덕션 코드에서 터집니다.
독일어 에스체트(ß)가 대표적인 1:다 케이스입니다. 'ß'.toUpperCase()는 'SS'를 반환하므로 출력이 입력보다 길어집니다. 대문자 ẞ(U+1E9E)가 2008년 Unicode에 추가되었고 2017년부터 독일어 정서법에서도 공식 허용되었지만, 기본 매핑은 여전히 SS를 내놓습니다. 그 결과 'straße'.toUpperCase().toLowerCase()는 'strasse'로 돌아옵니다 — 왕복 변환이 원래 철자를 파괴합니다.
터키어와 아제르바이잔어는 점 있는 i와 점 없는 ı를 구분합니다. 터키어 로케일에서 'i'.toLocaleUpperCase('tr')는 'İ'(U+0130)를, 'I'.toLocaleLowerCase('tr')는 'ı'(U+0131)를 반환합니다. 한편 기본 로케일에서 'İ'.toLowerCase()는 'i' 뒤에 결합 점(U+0307)이 붙은 문자열을 반환해 .length가 2가 됩니다. 이것이 그 유명한 Turkish-I 버그 계열입니다 — 설정 키나 프로토콜 토큰을 소문자화하는 소프트웨어는 기본 로케일이 터키어인 머신에서 도는 순간 깨집니다.
그리스어는 문맥 의존성을 더합니다. 대문자 Σ는 단어 중간에서는 σ로, 단어 끝에서는 ς로 소문자화됩니다(Unicode의 Final_Sigma 규칙). JavaScript도 이 규칙을 적용합니다. 여기서 나오는 실무 규칙: lower(upper(x)) === x를 가정하지 말 것, 케이스 변환 전후로 길이가 보존된다고 가정하지 말 것, 보안이 걸린 정규화 단계에 단순 케이스 변환을 쓰지 말 것.
'ß'.toUpperCase() // 'SS' — one char in, two out
'straße'.toUpperCase()
.toLowerCase() // 'strasse' — ß never comes back
'i'.toLocaleUpperCase('tr') // 'İ' (U+0130, dotted capital I)
'I'.toLocaleLowerCase('tr') // 'ı' (U+0131, dotless i)
'İ'.toLowerCase().length // 2 — 'i' + combining dot (U+0307)
'ΣΟΦΟΣ'.toLowerCase() // 'σοφος' — medial σ, final ς단어는 어디서 끝나는가 — 경계 탐지와 약어 규칙
모든 케이스 변환기는 다른 무엇보다 먼저 한 가지 질문에 답해야 합니다: 단어가 무엇인가? 언더스코어, 하이픈, 공백으로 나누는 건 쉽습니다. 어려운 건 대문자가 연속되는 camelCase 입력입니다. 대문자마다 새 단어를 시작하는 순진한 규칙은 parseHTMLDocument를 parse / H / T / M / L / Document로 찢어버립니다. 제대로 된 토크나이저는 두 가지 규칙을 씁니다: 소문자→대문자 전환에서 새 단어를 열고, 대문자 연속 구간 안에서는 바로 뒤에 소문자가 오는 마지막 대문자에서 새 단어를 엽니다 — 그래서 parse / HTML / Document가 나옵니다.
숫자는 본질적으로 모호합니다. v2Response는 v2 / Response일까요, v / 2 / Response일까요? base64Encode는 base64 / Encode일까요, base / 64 / Encode일까요? 보편적 정답은 없습니다. 대부분의 변환기는 숫자를 앞 단어에 붙이며, 바로 그래서 user_id_2와 user_id2가 같은 camelCase 출력으로 합쳐지곤 합니다.
기계적 규칙이 서로 충돌하기 때문에 스타일 가이드들은 약어에 대해 명시적 입장을 정했습니다. Google Java Style Guide는 약어를 일반 단어로 취급합니다: XMLHTTPRequest가 아니라 XmlHttpRequest, newCustomerID가 아니라 newCustomerId. Swift API Design Guidelines는 위치에 따라 약어를 균일하게 대문자 또는 소문자로 쓰라고 요구합니다 — 이름 맨 앞이면 utf8Bytes, 중간이면 userSMTPServer. Go는 반대로 갔습니다: golint는 정본 표기를 유지해야 하는 initialism 목록(ID, URL, HTTP, API, JSON 등 약 40개)을 하드코딩했고, 그래서 관용적인 Go 코드는 userID와 ServeHTTP로 씁니다. 결국 같은 문구도 생태계마다 '올바른' camelCase가 다릅니다. 변환기는 하나의 정책을 골라 일관되게 적용할 수 있을 뿐입니다.
케이스 변환은 손실 변환이다 — 왕복해도 원본이 돌아오지 않는다
컨벤션 간 변환은 정보를 파괴합니다. 정확히 어떤 정보가 사라지는지 알아둘 가치가 있습니다. 대부분의 변환기는 입력을 정규 단어 목록(canonical word list) — 소문자 단어들, 구분자는 버림 — 으로 정규화한 뒤 대상 스타일로 다시 조립합니다. 같은 단어 목록을 만들어내는 두 입력은 그 순간부터 영구히 구별 불가능해집니다.
첫 번째 희생자는 단어 내부의 대문자입니다. HTML_parser는 camelCase에서 htmlParser가 되고, 되돌리면 html_parser가 나옵니다 — HTML이 원래 대문자였다는 기록은 어디에도 없습니다. 두 번째는 숫자 경계입니다. user_id_2는 userId2가 되고, 역변환은 user_id2를 내놓습니다. 숫자에는 구분자가 어디 있었는지 표시할 대소문자 정보가 없기 때문입니다. 세 번째는 선행 구분자입니다. Python에서 내부 멤버를 뜻하는 관례인 _private는 camelCase에서 private가 됩니다 — 의미를 담은 접두사가 그냥 사라지고, Python의 name mangling을 유발하는 이중 언더스코어도 마찬가지입니다. 연속 구분자(a__b)는 하나로 붕괴합니다.
엔지니어링 결론: 이름을 재유도하지 마세요. 계층 간 식별자를 매핑하는 코드 생성기 — ORM 컬럼↔필드, protobuf 메시지↔JSON — 는 왕복 변환을 믿는 대신 원본 이름을 파생 이름과 함께 저장합니다. Protocol Buffers가 정확히 그렇게 합니다: 모든 필드는 명시적인 json_name을 갖고 있어, 손실 변환이 API 일부를 조용히 개명하는 일이 없습니다.
user_id_2 → camelCase → userId2
userId2 → snake_case → user_id2 // '_2' boundary lost
HTML_parser → camelCase → htmlParser
htmlParser → snake_case → html_parser // HTML capitals lost
_private → camelCase → private // leading '_' lost
a__b → camelCase → aB // double separator collapsed취향이 아니라 스펙이 케이스를 강제하는 영역들
명명 규칙은 보통 사회적 계약이지만, 몇몇 시스템은 케이스를 강제 규칙으로 만듭니다. HTTP 헤더 필드 이름은 RFC 9110에 따라 대소문자를 구분하지 않으며, HTTP/2와 HTTP/3는 한발 더 나아가 와이어 상에서 필드 이름이 소문자일 것을 요구합니다 — 현대 프록시가 Content-Type이 아니라 content-type을 보여주는 이유입니다. 환경 변수는 POSIX 시스템에서 대소문자를 구분하고(PATH와 path가 공존 가능) 대문자가 관례입니다. 반면 Windows에서는 대소문자를 구분하지 않아 크로스 플랫폼 스크립트의 상호운용 함정이 됩니다.
CSS 커스텀 프로퍼티는 대소문자를 구분합니다(--Main-Color와 --main-color는 다른 변수). HTML 속성명과 태그명은 구분하지 않는데도요. 커스텀 엘리먼트 이름은 소문자여야 하고 하이픈을 포함해야 합니다(my-widget) — 스타일이 아니라 HTML 스펙입니다. npm은 2017년부터 새 패키지 이름에 대문자를 거부합니다. JSONStream 같은 기존 혼합 케이스 패키지는 기득권으로 남아 있습니다.
데이터베이스는 따옴표 없는 식별자를 정반대 방향으로 접습니다(folding): PostgreSQL은 소문자로, Oracle은 대문자로 — 둘 다 SQL 표준을 근거로 듭니다. Postgres에서 따옴표를 써서 "UserAccounts"로 테이블을 만들면 이후 모든 쿼리가 영원히 따옴표를 붙여야 하고, 이 스키마를 Oracle로 옮기면 접힘 방향이 뒤집힙니다.
마지막 함정은 파일시스템입니다. macOS의 APFS와 Windows의 NTFS는 기본적으로 대소문자 비구분(단, 보존은 함)이고, Linux ext4는 구분합니다. button.tsx로 해석되는 import './Button'은 모든 개발자 노트북에서 동작하다가 Linux CI에서만 실패합니다 — "works on my machine"의 가장 확실한 공급원 중 하나입니다.
컨벤션을 가로지르는 리팩터링 실전 워크플로
도메인 개념 하나를 개명한다는 건 모든 컨벤션을 동시에 추적한다는 뜻입니다: TypeScript의 userAccount, SQL과 Python의 user_account, CSS 클래스와 URL의 user-account, 환경 변수의 USER_ACCOUNT, 클래스명의 UserAccount. 한 가지 변형만 검색하면 사용처의 일부만 찾게 됩니다. 신뢰할 수 있는 워크플로는 먼저 모든 변형을 생성하고(이 도구가 붙여넣기 한 번으로 해줍니다), alternation으로 한꺼번에 검색한 뒤 — ripgrep이면 한 번의 패스로 끝납니다 — 무엇이든 치환하기 전에 결과를 검토하는 것입니다.
치환 자체는 IDE 지원으로 안전해집니다. JetBrains의 "Preserve case" 치환 옵션과 VS Code의 preserve-case 토글(치환 상자의 AB 버튼)은 userAccount→memberProfile과 UserAccount→MemberProfile을 한 번의 작업으로 처리하므로, 다섯 번 따로 치환할 필요가 없습니다.
API 경계에서는 아예 손으로 변환하지 마세요. 와이어 포맷은 흔히 snake_case이고(Stripe, 대부분의 Python/Ruby 백엔드) JS 클라이언트는 camelCase를 기대합니다. 직렬화 계층에서 선언된 정책으로 한 번만 변환하세요 — Rust에서는 serde의 rename_all = "camelCase", Java에서는 Jackson의 PropertyNamingStrategies.SNAKE_CASE, Node에서는 camelcase-keys. 한 가지 주의: 깊은 키 변환은 키가 식별자가 아니라 데이터인 맵을 건너뛰어야 합니다 — 외부 ID로 키가 잡힌 딕셔너리를 케이스 변환하면 데이터가 조용히 오염됩니다.
# Find every variant of "user account" in one ripgrep pass
rg -n '(userAccount|UserAccount|user_account|user-account|USER_ACCOUNT)'
# Rust: convert once at the serialization boundary
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UserAccount {
user_id: u64, // wire: "userId"
display_name: String, // wire: "displayName"
}