1. 깨진 한글과의 첫 만남
개발을 하다 보면 영어는 멀쩡한데, 한글만 ��처럼 깨져 보이는 경험을 하게 된다.
“같은 요청인데 왜 영어는 괜찮고, 한글만 깨질까?”
그 해답은 바로 문자셋(Character Set)과 인코딩(Encoding)에 숨어 있다.
2. 문자셋과 인코딩의 차이
- 문자셋(Character Set): 문자에 번호를 붙인 목록 (예: 가 = U+AC00)
(사전 같은 개념이라고 생각해보자) - 인코딩(Encoding): 그 번호를 실제 바이트로 바꾸는 규칙
예시 – "가" (U+AC00):
- UTF-8 → EA B0 80 (3바이트)
- UTF-16LE → AC 00 (2바이트)
- EUC-KR → B0 A1 (2바이트)
즉, 같은 문자라도 인코딩 방식에 따라 바이트가 달라진다.
영어는 ASCII 영역(0~127) 덕분에 어느 인코딩에서나 값이 같아 깨지지 않는 반면, 한글은 인코딩 차이로 쉽게 깨진다.
3. 실제 바이트 비교 – “가나다”
아래 표는 "가나다"라는 문자열을 다양한 인코딩으로 표현했을 때의 바이트 값이다.
| 문자 | 유니코드 코드 포인트 | UTF-8(hex) | UTF-16LE(hex) | EUC-KR(hex) |
| 가 | U+AC00 | EA B0 80 | AC 00 | B0 A1 |
| 나 | U+B098 | EB 82 98 | 98 B0 | B3 AA |
| 다 | U+B2E4 | EB 8B A4 | E4 B2 | B4 D9 |
👉 같은 "가나다"라도 UTF-8은 9바이트, UTF-16LE는 6바이트, EUC-KR은 6바이트를 차지한다.
이 차이 때문에 시스템마다 다르게 해석되면 “깨짐” 현상이 발생하는 거죠.
4. 문자셋의 역사적 배경
- ASCII: 7비트, 영어/숫자/기호만 표현 가능
- ISO-8859 계열: 유럽 언어 확장 (라틴-1, 키릴, 아랍 등)
- EUC-KR, CP949: 한국어 문자 집합 (완성형 한글 지원)
- Shift_JIS: 일본어 문자 집합
- GB2312, GBK, GB18030: 중국어 문자 집합
→ 결국 언어별로 각자 표준을 만들어 쓰다 보니, 다국어 환경에서 충돌이 생기고 “문자 깨짐”이 일상이 되고 있다.
5. 유니코드와 UTF 시리즈
이 문제를 해결하기 위해 등장한 게 유니코드(Unicode)다
- 유니코드: 전 세계 모든 문자를 하나의 코드 포인트 체계로 통합 (0x0000 ~ 0x10FFFF)
유니코드를 바이트로 변환하는 인코딩 방식이 UTF (Unicode Transformation Format)이다.
- UTF-8: 가변 길이 1~4바이트, ASCII 호환 → 웹/네트워크 표준
- UTF-16: 대부분 2바이트, 동아시아 문자 효율 ↑ → Windows, Java 내부 표현
- UTF-32: 고정 4바이트, 단순하지만 메모리 낭비 심함
💡 Unicode의 다른 이름들
UCS (Universal Coded Character Set): ISO/IEC 10646 국제 표준에서 쓰는 이름. 사실상 Unicode와 동일한 개념.
코드 페이지(Code Page): 유니코드 이전 시대, Windows/IBM에서 문자셋을 부르던 이름 (예: CP949, CP1252).
BMP (Basic Multilingual Plane): 유니코드 첫 평면 (U+0000 ~ U+FFFF). 예전에는 “16비트면 충분”하다고 했던 배경.
6. 실무에서 만난 문제와 해결
- 기존 서버: 인코딩을 느슨하게 처리 → 어떤 charset이 와도 잘 받아줌
- 차세대 서버: UTF-8 전제 → 다른 인코딩 오면 깨짐
- 라우트 서버: 요청을 한 번 디코딩 후 다시 UTF-8로 인코딩 → 기존 서버가 받으면 이미 깨진 상태
👉 해결책:
- Content-Type 헤더에서 charset 확인
- UTF-8이면 차세대 서버로 전달 대상
- 아니면 레거시 서버로 라우팅
- 전달할 때는 반드시 raw bytes 그대로 넘겨야 깨지지 않음
7. 보안과 시스템 설계에서의 중요성
문자셋과 인코딩은 단순히 “깨짐 방지” 이상의 의미가 있다.
- 보안: 인코딩 불일치를 이용한 공격 → XSS, SQL Injection 우회 (%uXXXX 등)
- 시스템 설계: 글로벌 서비스는 기본적으로 UTF-8 통일
- 소프트웨어 공학: 국제화(i18n), 지역화(l10n)에서 필수 지식
- CS 기본기: 데이터 표현(문자 → 코드포인트 → 바이트)의 이해
보안 & 국제화 & 지역화에 대해서 더 정리하자면
( 보안 )
무슨 문제인가? 입력이 여러 번 디코딩/재인코딩되거나, 서버, 프록시, 앱이 서로 다른 문자셋을 가정하면 필터/검증을 우회할 수 있음.
- 문자열 = 바이트의 해석 결과
- 네트워크/스토리지 레벨은 바이트. 사람이 읽는 문자열은 바이트를 어떤 인코딩으로 해석해서 얻은 결과.
- 중간 컴포넌트가 임의로 디코딩/재인코딩하면 의미가 바뀜
예) 프록시가 URL 디코딩을 한 번 하고, 웹 앱은 또 디코딩을 하면(또는 다른 charset으로 디코딩하면) 원래 숨겨진 문자가 드러남. - 검증기는 보통 ‘지금 보이는 문자열’만 검사
- 검증 시점에선 무해해 보여도, 나중에 또 디코딩되면 악성 코드가 됨 → 우회 성공.
예시 A) 이중(중첩) 인코딩으로 XSS 우회
- 공격자 전송(이중 인코딩):(이건 "<" → "%3C" → "%253C" 처럼 한 번 더 인코딩한 것)
- %253Cscript%253Ealert(1)%253C/script%253E
- 흐름:
- 프록시(또는 웹서버)가 URL 디코딩을 한 번만 수행 → 결과: %3Cscript%3Ealert(1)%3C/script%3E
- → 이 문자열은 아직 <로 변하지 않아서 필터(“금지 문자열 <script> 포함 여부”)를 통과할 수 있음.
- 앱 레이어에서 추가로 디코딩(또는 템플릿이 무심코 디코드) → 결과: <script>alert(1)</script> → 브라우저에서 실행 → XSS 성공.
한 번만 검사하면 안 된다. 여러 계층에서의 디코딩 타이밍을 고려해야 함.
예시 B) %uXXXX (옛 JS 이스케이프) 우회
- 구형 코드/라이브러리에서 unescape('%u003C') → < 로 해석되는 경우가 있음.
- 공격자가 %u003Cscript%u003E처럼 보내면 일부 필터에서 걸리지 않다가, 브라우저/JS에서 해석되면 XSS 발생.
예시 C) 인코딩 불일치로 인한 SQL 우회(개념적)
- 공격자가 의도적으로 바이트열을 구성해서,
- 방화벽/프록시가 UTF-8로 디코딩하면 안전한 문자로 보이고,
- 백엔드가 CP949/EUC-KR로 디코딩하면 '(따옴표)나 ; 같은 문자가 나와 SQL 쿼리를 끊는 경우.
(구체적 바이트 값은 인코딩별 매핑에 따라 달라지므로, 원리는 “다른 인코딩에서는 다른 문자로 보일 수 있다”는 점.)
검증필터가 어떤 인코딩/타이밍에서 작동하는지 반드시 명확해야 한다.
우선순위 실무 체크리스트 방어 방법
- 입력 정규화(입력 지점에서 canonicalize)
- 수신한 원본 바이트(raw bytes)를 한 번만 정해진 방식(예: UTF-8)으로 디코딩 → 정규화(Unicode NFC) → 그 결과로만 검증/필터 적용.
- 즉, “정규화 → 검증 → 처리” 순서를 강제.
- (중요) 중간 컴포넌트가 임의로 디코딩하지 않게 아키텍처 설계.
- 전 구간 인코딩 통일(또는 명시적 계약)
- 클라이언트 → 프록시 → 앱 → DB → 로그까지 UTF-8 강제(또는 명확히 문서화된 인코딩).
- Content-Type에 charset=utf-8을 명시하고, 다른 charset은 거부하거나 엄격히 로깅/검사.
- 디코딩/디코드 연산을 최소화
- 프록시나 게이트웨이가 쿼리스트링과 본문을 임의로 디코딩하지 않도록 설정.
- 가능한 raw bytes를 그대로 전달하고, 애플리케이션 단에서 한 번만 처리.
- 검증을 바이트 레벨이 아니라 ‘정규화된 유니코드 문자열’ 레벨에서 수행
- unicodedata.normalize('NFC', text) 같은 정규화를 통해 동등성 문제(NFKC/NFC)를 제거한 뒤 필터 적용.
- 출력 인코딩(escaping) 철저
- HTML context → HTML escape (e.g., <, >)
- JS context → JS escape
- SQL → 파라미터화(PreparedStatement) 등.
- 이건 입력 검증이 뚫려도 최종 공격 실행을 막는 마지막 방패.
- 파라미터화된 쿼리 사용
- 문자열 연결로 쿼리를 만들지 마라. SQL Injection 근본 차단.
- 의심스러운 인코딩/이상 패턴 차단 로깅
- %uXXXX, 중첩 인코딩(%25 sequences), 이상한 대량 non-UTF-8 바이트 등은 탐지를 거부하거나 심층 검사.
- 테스트 케이스로 ‘이중 디코딩’ 시나리오 포함
- CI 유닛/통합 테스트에 double-encoding, unicode-escape, legacy-encoding 사례 포함.
입력이 여러 번 디코딩되거나(또는 서로 다른 컴포넌트가 서로 다른 인코딩 전제로 디코딩하면),
한 컴포넌트에서는 “안전한 값”으로 보이지만 최종적으로는
악성 문자열(<script>, ' OR 1=1 -- 등)이 되어서 필터를 우회하여 침투할 수 있다.
( 국제화 & 지역화 )
- i18n (internationalization): 소프트웨어를 다국어 대응 가능하게 만드는 과정.
- i10n (localization): 특정 언어/문화권에 맞게 현지화하는 과정.
❓무엇을 조심할까 (i18n에서의 위험 요소들)
- 문자 길이 문제
- 예: "가"는 UTF-8에서 3바이트, UTF-16에서 2바이트 → “문자 수 제한”과 “바이트 크기 제한”이 달라질 수 있음.
- 특히 DB 칼럼 정의(VARCHAR(10) vs VARCHAR(30 BYTE))에서 혼동.
- 정렬(Collation)
- "가나다"와 "가 나다"를 사전순으로 정렬할 때, 한국어 사전 규칙과 단순 유니코드 코드포인트 정렬이 달라질 수 있음.
- MySQL, PostgreSQL도 collation을 설정하지 않으면 기대와 다른 정렬 결과가 나옴.
- 대소문자 규칙
- 영어: User == user
- 터키어: "I".lower() → "ı" (점 없는 i)
- 단순 toLowerCase()로는 언어별 규칙을 반영 못함.
- 날짜/숫자 포맷
- 한국: 2025-09-28, 미국: 09/28/2025, 독일: 28.09.2025
- 통화: 1,234.56 (미국) vs 1.234,56 (독일)
- 지역화를 고려하지 않으면 잘못된 값 파싱 가능.
- 방향성 (RTL, Right-to-Left)
- 아랍어, 히브리어 등은 오른쪽→왼쪽으로 작성.
- UI 레이아웃, 입력 필드, 문자열 붙임 위치(예: 따옴표·괄호)도 영향을 받음.
‼️ 실무 원칙
- 저장/전송은 UTF-8 통일
- DB, API, 메시지 큐, 로그 → 전부 UTF-8로 강제.
- 이유: 호환성, ASCII fallback, 네트워크 효율.
- 입력은: 바이트 → 정규화(NFC) → 유효성 검사 → 저장
- é → 단일 문자(U+00E9) / e+́ 조합(U+0065 + U+0301) → 시각적 동일
- 정규화를 안 하면 “동일하게 보이는 문자열이 DB에서는 다르다”는 문제 발생.
- 저장은 NFC, 보안 검사 시는 NFKC + casefold 활용.
- 문자 길이 vs 바이트 길이 분리
- UI 제한(예: 닉네임 10글자): 문자 단위(grapheme cluster)
- DB 저장/네트워크 패킷 제한: 바이트 단위
- “이모지 하나 = 2~4 code units” 문제를 꼭 고려해야 함.
- 로케일 기반 처리
- 문자열 비교, 정렬은 반드시 collation을 지정해야 함.
- 자바: Collator, Python: locale.strxfrm, DB: COLLATE 절.
- 이모지·보조 평면 지원
- 이모지는 UTF-8에서 4바이트, UTF-16에서 surrogate pair(2 code units).
- Java String.length()는 code unit 수 → "😀".length() == 2 (혼동 주의).
- UI는 grapheme cluster 단위로 길이 계산해야 올바름. (Python regex\PLG, ICU 라이브러리 사용)
- RTL 언어 테스트
- UI에서 텍스트 정렬·플로우가 깨지지 않게 해야 함.
- HTML: <bdo dir="rtl">, CSS: direction: rtl
- 숫자·기호·이모지 섞이면 혼란 생기므로 반드시 QA 필요.
✅ 배포 전 체크리스트
- Content-Type: text/html; charset=UTF-8 헤더 강제.
- DB 문자셋 utf8mb4(MySQL) / UTF8(Postgres) 확인.
- 로그/백업도 UTF-8로 통일 (혼합 인코딩 방지).
- 외부 연동(API, 메일, SFTP 등) 시 인코딩 계약서/명세 확인.
- 이모지/보조 평면 저장 가능한지 확인 (MySQL의 utf8 vs utf8mb4 차이).
- UI/UX 테스트에 RTL 언어 포함(아랍어/히브리어 샘플 텍스트).
- 다국어 정렬, 검색 동작 확인 (예: “김영희” vs “김 영희”).
인코딩이 틀어지면 보안 필터가 무력화될 수 있고, 국제화, 지역화는 단순 인코딩 통일을 넘어 문자 단위의 처리, 정렬, 표시까지 고려해야 안전하고 올바르게 동작한다.
결국, 우리가 맞닥뜨린 한글 깨짐은 단순한 버그가 아니라,
컴퓨터가 문자를 이해하는 방식 자체를 탐험하는 여정이었던 셈이다.
한글이 꺠지는 순간, 나는 비로소 유니코드와 UTF-8의 위대함을 깨달았다.
'IT 지식' 카테고리의 다른 글
| macOS에서 계정명(short name) 변경하기 - Sequoia 15.1 경험 정리 (0) | 2025.09.21 |
|---|---|
| [맥북] 한영키 변환 속도 개선 (0) | 2025.06.17 |
| [PC/문제해결] Windows 10 AMD Radeon HD 4000 모니터 화면 버그 (0) | 2022.04.02 |
| [IT 지식] 소프트웨어 아키텍트 역할군 ( AA, TA, DA, BA ??? ) (0) | 2022.01.20 |
| [USB Boot] How to make USB BOOT / USB 부팅 만들기 (0) | 2020.02.20 |