인코딩 디버깅 — CP949, UTF-8

강정우·2026년 6월 9일

CS

목록 보기
2/2
post-thumbnail

인코딩 디버깅 — CP949, UTF-8

production DB 백업(.sql.gz)을 로컬 MySQL에 복원하는 PowerShell 스크립트가
ERROR 1062 Duplicate entry '' for key 'linkserver_linkserver.name' 로 죽었다.
dump 파일도 멀쩡하고 스크립트 로직도 멀쩡한데, 왜?
범인은 콘솔 인코딩이었고, 잡는 데 한참 헤맨 이유는 "내 환경에선 재현이 안 돼서" 였다.


TL;DR

  • 텍스트는 시스템 경계를 넘을 때마다 bytes ↔ text 변환(인코딩)이 일어난다. 경계마다 인코딩이 다르면 깨진다.
  • PowerShell이 외부 프로세스(mysql)의 stdin으로 보낼 때, 그 인코딩은 콘솔의 [Console]::OutputEncoding 을 상속한다. 한글 Windows 콘솔은 기본 CP949다.
  • CP949로 인코딩된 한글 바이트를 mysql이 utf8mb4로 읽으니 한글이 통째로 깨져 빈 문자열 '' 이 됐고, UNIQUE 제약에서 '' 중복 → 1062.
  • "내 환경에선 됨"은 결론이 아니라 단서다. 환경 의존 버그는 실패하는 환경의 조건을 재현해야 잡힌다.

1. 증상

기존 DB 초기화: DROP & CREATE DATABASE `smartsilvercenter` ... 초기화 완료
ERROR 1062 (23000) at line 1753: Duplicate entry '' for key 'linkserver_linkserver.name'

DB 초기화는 성공했는데, 데이터를 부어 넣는 도중 죽었다. 키워드 두 개:

  • Duplicate entry '' — 중복된 값이 NULL이 아니라 빈 문자열이다.
  • key 'linkserver_linkserver.name'name 컬럼에 UNIQUE 제약이 걸려 있다.

💡 MySQL의 UNIQUE 인덱스는 NULL은 여러 개 허용하지만 빈 문자열 '' 은 하나만 허용한다.
즉 여러 행의 name 이 전부 '' 로 들어가려 해서 충돌한 것.

2. dump 파일은 멀쩡했다

.sql.gz 를 풀어 해당 테이블의 INSERT를 직접 확인:

INSERT INTO `linkserver_linkserver` VALUES
 (1,'인천시',...),(2,'강화군',...),(4,'미추홀',...);

이름이 셋 다 다르고, 빈 값도 없다. 그러니 이 파일을 그대로 복원하면 1062가 날 이유가 없다.
빈 문자열은 "복원 과정 어딘가"에서 생겨났다는 뜻.

3. 데이터 흐름을 경계로 쪼개기

복원 스크립트는 dump를 한 줄씩 읽어 mysql의 stdin 파이프로 흘려보낸다. 경계를 그려보면:

dump(.sql.gz, UTF-8 bytes)
  └─[StreamReader: UTF-8 decode]→ 메모리의 올바른 "인천시"
       └─[StreamWriter: ??? encode]→ mysql stdin (bytes)
            └─[mysql: utf8mb4 decode]→ DB

StreamWriter 가 무슨 인코딩으로 내보내는가? 이게 핵심 경계였다.

4. 헤맨 구간 — 정직한 기록

여기서 한참 돌았다. 가설은 처음부터 맞았다("stdin 인코딩이 깨진다"). 그런데:

  • 내 개발 환경에서 똑같은 로직을 돌리면 항상 성공했다.
  • 그래서 "인코딩이 원인이 아니다"라고 잘못 결론 내리고 수정을 되돌리길 반복했다.

문제는 검증을 내 환경에서만 했다는 것. 알고 보니:

환경$proc.StandardInput 인코딩
내 개발 환경(콘솔이 UTF-8)CP65001 (UTF-8) → 안 깨짐
사용자 콘솔(한글 Windows)CP949 → 깨짐

결정타는 사용자 환경에서 딱 한 줄 측정한 값:

$p=[Diagnostics.Process]::Start((New-Object Diagnostics.ProcessStartInfo -Property @{
  FileName='cmd';RedirectStandardInput=$true;UseShellExecute=$false}))
"stdin=$($p.StandardInput.Encoding.WebName) CP$($p.StandardInput.Encoding.CodePage)"
# → stdin=ks_c_5601-1987 CP949

CP949. 범인 확정.

5. 재현 → 수정 → 재검증

콘솔을 CP949로 강제(chcp 949)하니 그제서야 원래 코드가 1062로 죽었다. 비로소 진짜 재현.

수정: 콘솔 설정에 의존하지 말고 stdin을 UTF-8로 직접 고정한다.

$proc = [System.Diagnostics.Process]::Start($psi)
# .NET Framework/PS5.1 엔 ProcessStartInfo.StandardInputEncoding 속성이 없으므로
# BaseStream 위에 UTF-8(BOM 없음) StreamWriter 를 직접 씌운다.
$stdin = New-Object System.IO.StreamWriter(
    $proc.StandardInput.BaseStream,
    (New-Object System.Text.UTF8Encoding($false)))

CP949 콘솔에서 검증:

버전writer CP결과
원래 코드949exit 1, ERROR 1062 (재현됨)
수정 코드65001exit 0, 한글 정상 저장

오늘 배운 것 (인코딩 너머의 일반 원칙)

① 인코딩은 "저장"이 아니라 "경계를 건널 때" 일어나는 변환이다

"텍스트가 깨졌다" = 어떤 경계의 encode 인코딩 ≠ 다음 경계의 decode 인코딩.
깨진 결과를 보지 말고 경계의 목록을 그려라: 파일 읽기 / 파이프 / 네트워크 / DB 연결 / 터미널 출력.

② "내 환경에선 되는데요"는 버그 리포트의 절반이다

재현 불가는 결론이 아니라 단서다. "A 성공 + B 실패" = 두 환경의 차이에 범인이 있다.
→ 추측한 원인을 내 환경에서 검증하지 말고, 실패하는 환경의 조건을 재현하라.

③ 에러의 "값"은 지문, "숫자"는 좌표계를 의심하라

  • ''(빈 문자열) ← 값이 통째로 사라짐 = 인코딩 절단의 지문.
  • at line 1753 ← dump 파일 줄이 아니라 mysql 입력 스트림 줄. 숫자는 항상 "어느 좌표계?"를 물어라.

④ 추측으로 고치지 말고: 가설 → 최소검증 → 확정 → 수정

같은 곳을 3번 두드리면 멈추고 "내가 검증하는 방법이 틀린 건 아닐까"를 의심하라.


한글 Windows + PowerShell 5.1 실전 함정 모음

함정증상해결
경로의 대괄호 [ ]Test-Path/Resolve-Path/cd 가 와일드카드로 오해 → 파일 못 찾음-LiteralPath
외부 프로세스 stdin 인코딩콘솔 CP949 상속 → 한글 깨짐BaseStream 위 UTF-8 StreamWriter
.ps1 파일 BOM 없음한글 .ps1을 ANSI로 오독 → 줄 깨짐UTF-8 with BOM 저장
2>&1 로 native exe stderrexit 0인데 ErrorRecord로 감싸져 throw리다이렉트 말고 $LASTEXITCODE 로 판단
PS 5.1 = .NET Framework.NET Core 전용 API 없음API 도입 버전 확인 후 우회

근본 해법: 시스템/터미널을 UTF-8로 통일하거나 PowerShell 7+로 이주.
방어적으로는 스크립트 상단에
$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false).


보너스 ① — 유니코드 정규화 (Normalization)

같은 글자가 여러 바이트 표현을 가질 수 있다. 한글 :

  • NFC(완성형): = 코드포인트 1개 (U+D55C)
  • NFD(조합형): ㅎ+ㅏ+ㄴ = 자모 3개 (U+1112 U+1161 U+11AB)

눈엔 똑같지만 "한"(NFC) == "한"(NFD)false, 길이도 1 vs 3.

⚠️ macOS 함정: macOS 파일시스템은 한글 파일명을 NFD로 저장한다.
"Mac에서 만든 한글 파일명을 git에 올렸더니 Windows에서 깨지거나 중복으로 보인다"가 여기서 온다.

형식의미용도
NFC합쳐서 하나웹/DB 저장 표준
NFD분해macOS 내부
NFKC/NFKD호환 분해(1, (주))검색 인덱싱

원칙: 외부 입력(사용자 입력/업로드 파일명/API)은 저장 전 NFC로 정규화.

import unicodedata
clean = unicodedata.normalize("NFC", user_input)

보너스 ② — DB charset / collation

  • charset(문자셋): 글자를 어떤 바이트로 저장 → 데이터의 표현
  • collation(정렬규칙): 글자를 어떻게 비교·정렬 → 데이터의 동작 (UNIQUE / WHERE / ORDER BY / GROUP BY)

오늘의 '' 중복은 바로 collation의 영역 — 두 값을 "같다"고 판정해 UNIQUE를 위반시켰다.

MySQL 필수 상식:

  1. utf8 은 가짜다. 글자당 최대 3바이트 → 이모지·일부 한자 저장 불가. 항상 utf8mb4.

  2. collation 접미사가 동작을 결정한다:

    collation특징
    utf8mb4_general_ci_ci=대소문자 무시, 빠르지만 부정확(구식)
    utf8mb4_unicode_ci유니코드 정렬, 더 정확
    utf8mb4_0900_ai_ciMySQL 8.0+ 기본·권장 ✅ (_ai=악센트 무시)
    ..._bin바이트 그대로, 전부 구분
  3. 악명 높은 사고 — collation 불일치 JOIN:

    SELECT * FROM A JOIN B ON A.name = B.name;
    -- ERROR 1267: Illegal mix of collations

    서버/DB/테이블/컬럼 + 클라이언트 연결까지 전부 동일 charset·collation으로 통일해야 한다.
    (오늘 버그도 DB는 utf8mb4였지만 연결로 보낸 바이트가 어긋나 깨졌다 — "DB만 맞아선 부족하다"는 산 증거.)


두 보너스를 잇는 한 줄

정규화(NFC)는 "같은 글자를 같은 바이트로" 만드는 입구 작업이고,
collation은 "그 바이트들을 어떻게 같다고 볼지" 정하는 DB 규칙이다.
둘 다 통일하지 않으면, 분명 같아 보이는 값이 검색·UNIQUE·JOIN에서 제멋대로 갈라진다.

그리고 오늘 가장 크게 남은 한 줄:

데이터가 경계를 넘을 때마다 인코딩 변환이 일어나고,
"내 환경에선 됨"은 환경 차이라는 단서이며, 버그는 추측이 아니라 재현으로 잡는다.

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글