본문 바로가기

C#

C# 유니코드 문자열 처리와 인코딩 변환

.NET의 문자열은 유니코드(UTF-16) 기반입니다. 이 글에서는 올바른 비교와 검색을 위한 정규화, 실제 글자 단위 처리, 안전한 인코딩 변환, 파일/스트림과 콘솔 인코딩 설정까지 실무 중심으로 정리합니다.

1. 문자열과 유니코드 핵심

.NET의 string은 UTF-16 코드 유닛(char 16비트)의 시퀀스입니다. 하나의 문자(그래프림)는 하나 이상의 코드포인트로, 또 각 코드포인트는 하나 또는 두 개의 UTF-16 코드 유닛(서로게이트 페어)로 표현될 수 있습니다. 즉 char 개수와 사람이 인식하는 글자 수가 다를 수 있습니다.

문화권에 따라 동등한 문자도 이진 비교로는 다를 수 있습니다. 예: "é"(U+00E9) vs "é"(e + 결합 악센트, U+0301). 이런 경우 정규화 후 Ordinal 비교가 안전합니다.

using System;
using System.Globalization;
using System.Text;

string a = "é";          // U+00E9 (단일 코드포인트)
string b = "e\u0301";    // 'e' + COMBINING ACUTE ACCENT (표기 목적)

Console.WriteLine(a == b); // False (바이너리 비교)

// 정규화 후 Ordinal 비교
var an = a.Normalize(NormalizationForm.FormC);
var bn = b.Normalize(NormalizationForm.FormC);
Console.WriteLine(string.Equals(an, bn, StringComparison.Ordinal)); // True

Console.WriteLine($"a.Length={a.Length}, b.Length={b.Length}");

// 서로게이트 예시: 😀 (U+1F600) 는 UTF-16에서 char 2개
string emoji = "😀";
Console.WriteLine($"emoji.Length={emoji.Length}"); // 2

2. 올바른 비교/검색을 위한 정규화

저장 전/키 생성 전 정규화(FormC 권장)로 표현을 표준화하십시오. 비교는 문화 민감도가 필요하면 CurrentCulture, 정확한 바이트 등가성이 필요하면 Ordinal을 사용합니다. 파일 경로, 키, 토큰 비교는 Ordinal이 안전합니다.

using System;
using System.Globalization;

string userInput = "Cafe\u0301"; // 'Café'의 분해형 입력 가정
string canonical = userInput.Normalize(NormalizationForm.FormC);

// 사전 저장/색인 전 정규화
SaveToDb(canonical);

// 검색 시에도 동일하게 정규화 후 Ordinal 비교
bool EqualsNormalized(string x, string y)
{
    return string.Equals(
        x.Normalize(NormalizationForm.FormC),
        y.Normalize(NormalizationForm.FormC),
        StringComparison.Ordinal);
}

3. 글자 수 세기와 그래프림 처리(Rune, StringInfo)

사용자에게 보이는 글자 단위 처리는 StringInfo 또는 Rune을 사용합니다. StringInfo는 텍스트 요소(그래프림) 단위 열거를, Rune은 코드포인트 단위 처리를 제공합니다.

using System;
using System.Globalization;
using System.Text;

string s = "한🇰🇷e\u0301😀"; // 한글, 국기(조합 플래그), 결합 악센트, 이모지

// 그래프림(텍스트 요소) 단위 순회
var enumerator = StringInfo.GetTextElementEnumerator(s);
int graphemeCount = 0;
while (enumerator.MoveNext())
{
    graphemeCount++;
    Console.WriteLine($"[{graphemeCount}] '{enumerator.GetTextElement()}'");
}
Console.WriteLine($"Text elements: {graphemeCount}");

// 코드포인트(Rune) 단위 순회
foreach (var rune in s.EnumerateRunes())
{
    Console.WriteLine($"Rune U+{rune.Value:X4} '{rune}'");
}

4. 인코딩 변환 기본 패턴(UTF-8 권장)

전송/저장은 UTF-8을 우선하세요. 레거시 코드페이지로 변환 시 CodePagesEncodingProvider 등록이 필요합니다. 데이터 손실 가능성(매핑 불가 문자)을 Fallback으로 제어할 수 있습니다.

using System;
using System.Text;

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

string text = "안녕하세요 😀";

// UTF-8 바이트로
byte[] utf8 = Encoding.UTF8.GetBytes(text);
Console.WriteLine(BitConverter.ToString(utf8));

// EUC-KR로 변환 (이모지는 매핑 불가)
var euckr = Encoding.GetEncoding(
    "euc-kr",
    EncoderFallback.ReplacementFallback,    // 매핑 불가 문자는 '?' 등으로 대체
    DecoderFallback.ReplacementFallback);

byte[] euckrBytes = Encoding.Convert(Encoding.UTF8, euckr, utf8);
Console.WriteLine(BitConverter.ToString(euckrBytes));
Console.WriteLine(euckr.GetString(euckrBytes)); // 이모지 대체 표시

// 손실 금지: 예외 fallback
var euckrStrict = Encoding.GetEncoding(
    "euc-kr",
    EncoderFallback.ExceptionFallback,
    DecoderFallback.ExceptionFallback);
try
{
    _ = euckrStrict.GetBytes(text); // 매핑 불가 시 EncoderFallbackException 발생
}
catch (EncoderFallbackException ex)
{
    Console.WriteLine("Cannot encode: " + ex.Message);
}

5. 파일/스트림 인코딩과 BOM

UTF-8은 BOM 유무를 선택할 수 있습니다. 상호운용 대상이 BOM을 싫어하면 UTF8Encoding(false)를 사용하세요. StreamReader/Writer에 명시적으로 인코딩을 지정하면 예측 가능성이 높습니다.

using System;
using System.IO;
using System.Text;

string pathBom = "with-bom.txt";
string pathNoBom = "no-bom.txt";
string content = "파일 저장 테스트: 한글, emoji 😀";

var utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);

// 쓰기
using (var sw = new StreamWriter(pathBom, append: false, encoding: utf8Bom))
    sw.Write(content);

using (var sw = new StreamWriter(pathNoBom, append: false, encoding: utf8NoBom))
    sw.Write(content);

// 읽기: BOM 자동 감지 켜고, 없으면 지정 인코딩 사용
using (var sr = new StreamReader(pathNoBom, encoding: utf8NoBom, detectEncodingFromByteOrderMarks: true))
{
    string read = sr.ReadToEnd();
    Console.WriteLine(read);
}

6. 콘솔 입출력 깨짐 방지(Windows)

Windows 콘솔은 기본적으로 지역 코드페이지를 사용합니다. 한글/이모지 깨짐을 방지하려면 UTF-8로 변경하세요.

using System;
using System.Text;

Console.OutputEncoding = new UTF8Encoding(false);
Console.InputEncoding = Encoding.UTF8;

Console.WriteLine("콘솔 UTF-8 출력: 안녕하세요 😀");

7. 간단 BOM 감지기

외부 파일 인코딩을 모를 때 BOM을 간단히 스니핑하고, 없으면 UTF-8을 가정하거나 사용자 선택을 받는 전략이 일반적입니다.

using System;
using System.IO;
using System.Text;

static Encoding? DetectBom(string file)
{
    using var fs = File.OpenRead(file);
    Span bom = stackalloc byte[4];
    int n = fs.Read(bom);

    if (n >= 4)
    {
        if (bom[0] == 0xFF && bom[1] == 0xFE && bom[2] == 0x00 && bom[3] == 0x00) return new UTF32Encoding(bigEndian: false, byteOrderMark: true);
        if (bom[0] == 0x00 && bom[1] == 0x00 && bom[2] == 0xFE && bom[3] == 0xFF) return new UTF32Encoding(bigEndian: true, byteOrderMark: true);
    }
    if (n >= 3 && bom[0] == 0xEF && bom[1] == 0xBB && bom[2] == 0xBF) return new UTF8Encoding(true);
    if (n >= 2)
    {
        if (bom[0] == 0xFF && bom[1] == 0xFE) return new UnicodeEncoding(bigEndian: false, byteOrderMark: true);
        if (bom[0] == 0xFE && bom[1] == 0xFF) return new UnicodeEncoding(bigEndian: true, byteOrderMark: true);
    }
    return null; // BOM 없음
}

var enc = DetectBom("no-bom.txt") ?? Encoding.UTF8; // 합리적 기본값
using var sr = new StreamReader("no-bom.txt", enc);
Console.WriteLine(enc.WebName);
Console.WriteLine(sr.ReadToEnd());

8. 실무 체크리스트

1) 비교/키/색인 전에 FormC 정규화합니다. 2) 기본 인코딩은 UTF-8을 사용하고, 상호운용 대상 요구에 맞춰 BOM 유무를 결정합니다. 3) 레거시 코드페이지는 Fallback 정책을 명확히 하고, 손실 금지 시 예외 Fallback을 사용합니다. 4) Console/Input/Output 인코딩을 명시합니다. 5) Encoding.Default 사용을 피하고, 항상 명시적으로 Encoding을 전달합니다. 6) 사용자 단위 글자 처리는 StringInfo 또는 Rune로 구현합니다.

요약: 내부는 UTF-16(string) + 정규화, 외부는 UTF-8 우선 + 명시적 Encoding으로 관리하면 대부분의 유니코드/인코딩 문제를 예방할 수 있습니다.