1. 소개
컴퓨터가 아무리 똑똑해도 문자 "A"나 기호 "⌘"가 뭔지 스스로 이해하진 못해. 컴퓨터는 0과 1만 알지. 사람이 이해하는 문자로 바꾸려면 번역기, 즉 인코딩이 필요해.
인코딩의 역사는 타협과 진화의 역사야. 처음엔 단순했는데 점점 복잡해졌고, 결국 어느 정도 범용적인 표준이 나오게 됐지. 시간 순으로 한 번 훑어보자.
시작
기원으로 가볼게. 지난 강의에서 텍스트 인코딩의 조상인 ASCII를 언급했지(발음은 "아스키"). 요약하면 이건 American Standard Code for Information Interchange의 약자야. 이름에서 알 수 있듯 미국 중심으로 만들어졌어.
ASCII는 1960년대에 만들어져서 처음 널리 쓰인 문자 인코딩 표준이야. 128개의 문자를 정의하고 있어:
- 라틴 알파벳(대문자/소문자): A-Z, a-z
- 숫자: 0-9
- 구두점: .,!?"' 등
- 몇몇 제어 문자: 개행, 탭 등
이 128개 문자는 각각 1바이트로(사실 8비트 중 7비트 사용) 인코딩됐어(최상위 비트는 보통 비워두거나 오류 검사용으로 쓰였지). 영어에는 아주 효율적이고 컴팩트했어.
예시:
문자 'A' 는 ASCII에서 바이트 0x41로 인코딩됨 (이진: 01000001)
문자 '!' 는 ASCII에서 바이트 0x21로 인코딩됨 (이진: 00100001)
제약:
가장 큰 한계는 명확해: ASCII는 영어 중심이야. 러시아어("Привет"), 독일어("Grüße"), 중국어 같은 문자를 담을 수 없어. 그래서 여러 코드 페이지(Code Pages)가 나왔고, 128개를 256개로 확장하려고 8번째 비트를 썼어. 예를 들어 러시아어용으로 CP1251(Windows Cyrillic), KOI8-R 등이 있었지. 문제는 서로 호환이 안 된다는 것: 같은 바이트가 다른 코드 페이지에선 전혀 다른 문자를 의미할 수 있어 — 진짜 바벨탑 상황이었어.
오늘날 실제 사용:
순수한 형태의 ASCII는 요즘 일반 텍스트 파일에서 잘 안 쓰여, 특정 용도나 오래된 시스템 정도에서나 보이는 정도야. 다만 많은 현대 인코딩이 ASCII와 역호환되는 건 그 유산 덕분이야.
한번 ASCII로 무언가를 쓰고 읽어보고, 거기에 러시아어를 넣으면 어떻게 되는지 볼게.
JetBrains Rider에서 콘솔 프로젝트를 만들고 이름을 예로 FileEncodingExplorer라고 해보자.
using System;
using System.IO;
using System.Text;
string file = "ascii.txt";
string asciiText = "Hello, world!";
string cyrillicText = "Привет, мир!";
// ASCII로 쓰기
using var writer = new StreamWriter(file, false, Encoding.ASCII);
writer.WriteLine(asciiText);
writer.WriteLine(cyrillicText);
// ASCII로 읽기
using var reader = new StreamReader(file, Encoding.ASCII);
string content = reader.ReadToEnd();
Console.WriteLine("파일 내용:");
Console.WriteLine(content);
Console.WriteLine("\n키릴 문자는 '?'로 바뀔 거야, ASCII가 키릴 문자를 지원하지 않거든!");
이 코드를 실행하면 영어 부분은 제대로 나오지만 러시아어는 물음표(?)나 다른 '알 수 없는' 문자로 변할 거야. 이유는 Encoding.ASCII가 키릴 문자를 바이트로 변환할 줄 모르기 때문에 안전한 대체 문자(보통 ?)로 바꾸거나, 어떤 다른 인코딩의 바이트를 ASCII로 잘못 해석해서 이상한 문자가 나오는 거야. 이건 StreamWriter가 지원하지 않는 문자를 기록할 때 자동으로 대체하는 동작을 보여줘. 그래서 올바른 인코딩을 사용하는 게 중요해!
2. UTF-8: 인터넷의 킹, 유연함의 대명사
이제 오늘날 가장 중요하고 아마도 가장 널리 쓰이는 인코딩인 UTF-8으로 넘어가자. 인터넷의 대부분, 리눅스 시스템, 현대 앱들이 이걸 사용해.
뭐야 이건?
UTF-8 (Unicode Transformation Format - 8-bit)는 UTF-16의 영어 텍스트 비효율 문제를 해결한 유니코드 인코딩이야. UTF-8은 가변 길이 인코딩인데 영리하게 설계되어 있어:
- 보통의 ASCII 문자(코드 0부터 127)는 1바이트로 인코딩돼. 이 바이트들은 ASCII 표현과 완전히 동일해서 UTF-8이 ASCII와 역호환이 된다는 뜻이야.
- 나머지 문자는 2~4바이트로 인코딩돼:
- 키릴 문자는 보통 2바이트.
- 유럽의 악센트 문자, 아랍어, 히브리어, 그리스어 등은 2바이트.
- 중국어/일본어/한국어 한자 등은 보통 3바이트.
- 희귀 문자나 일부 이모지는 4바이트.
UTF-8의 바이트 표현 예:
- 문자 'A'(ASCII): 01000001 (1바이트)
- 문자 'я'(러시아어): 11010001 10111111 (2바이트)
- 문자 '€'(유로): 11100010 10000010 10101100 (3바이트)
- 문자 '😂'(이모지): 11110000 10011111 10011000 10000010 (4바이트)
왜 UTF-8이 강자인가?
- 효율성: ASCII가 많은 텍스트(영어, 코드 등)에 대해 매우 컴팩트해.
- ASCII와의 역호환: UTF-8 파일에 ASCII 문자만 들어있다면 ASCII로 읽어도 문제 없어!
- BOM이 보통 없음: UTF-16과 달리 UTF-8은 보통 BOM을 쓰지 않아. 가끔 EF BB BF 같은 BOM이 붙기도 하는데, 이건 선택적이고 때론 문제를 일으키기도 해(예: 일부 포맷 파싱이나 리눅스 스크립트).
단점:
- 가변 길이라서 어떤 연산(예: N번째 문자로 바로 점프)이 번거로울 수 있어. 하지만 C#에서는 string이 파일 인코딩과 무관하게 유니코드 문자를 다루니까 크게 문제되지 않아.
실무 적용:
- 웹 페이지(HTML, CSS, JavaScript),
- API(JSON, XML),
- 설정 파일,
- 대부분 프로그래밍 언어의 소스 코드,
- Linux/Unix 계열 OS.
UTF-8로 파일을 쓰고 읽어보자.
string file = "utf8.txt";
string text = "Hello, 세계! 😀 €";
// UTF-8로 쓰기 (기본적으로 BOM 없음)
File.WriteAllText(file, text, Encoding.UTF8);
// UTF-8로 읽기
string readText = File.ReadAllText(file, Encoding.UTF8);
Console.WriteLine(readText); // 제대로 읽혀!
이 코드를 실행하고 파일 크기를 비교해보면, 혼합 텍스트의 경우 utf8.txt가 보통 UTF-16보다 작을 거야. 영어만 있다면 ASCII와 크기가 비슷해.
3. UTF-16: 대부분의 문자를 위한 유니코드
여러 코드 페이지의 혼란은 전 세계로 서비스를 확장하는 개발자들에게 큰 골칫거리였어. 그래서 범용 솔루션이 필요했고, 그게 바로 Unicode야. Unicode는 인코딩 자체가 아니라 각 문자에 고유한 숫자 코드 포인트를 부여한 거지.
뭐야 이건?
UTF-16 (Unicode Transformation Format - 16-bit)은 초기 설계상 모든 유니코드 문자를 2바이트(16비트)로 표현하려 했던 인코딩이야.
- 대부분 문자(BMP, 65535 이하)는 2바이트로 인코딩돼.
- BMP 밖의 문자들은 서러게이트 쌍(surrogate pairs)을 써서 4바이트가 돼. 즉 UTF-16도 사실상 가변 길이지만 보통은 문자당 2바이트로 생각해.
바이트 순서(Endianness)와 BOM:
- Big-Endian (BE): 상위 바이트가 먼저 온다.
- Little-Endian (LE): 하위 바이트가 먼저 온다.
- 파일을 읽는 쪽에서 올바른 순서를 알게 하려고 파일 시작에 BOM(Byte Order Mark)을 넣는 경우가 많아:
- UTF-16 LE의 BOM: FF FE
- UTF-16 BE의 BOM: FE FF
장점:
- 세계 대부분의 문자를 지원해.
- BMP 범위 내에서는 문자 단위 작업이 간단해(고정 길이 2바이트처럼 느껴짐).
단점:
- 영어 텍스트에는 비효율적: ASCII 문자 하나당 2바이트를 쓰니까.
- BOM이 있을 경우, 이를 예상하지 못하는 리더에서 문제를 일으킬 수 있어.
실무 적용:
UTF-16은 Windows 내부와 예를 들어 Java 같은 환경에서 문자열 내부 표현으로 널리 쓰여. 윈도우 메모장으로 저장한 키릴 텍스트 파일은 종종 UTF-16 LE + BOM 형태야.
string file = "utf16.txt";
string text = "Hello, 세계! 👋";
// UTF-16으로 쓰기 (기본적으로 Little-Endian, BOM 포함)
File.WriteAllText(file, text, Encoding.Unicode);
// UTF-16으로 읽기
string readText = File.ReadAllText(file, Encoding.Unicode);
Console.WriteLine(readText); // 제대로 읽혀!
Console.WriteLine($"파일 크기: {new FileInfo(file).Length} 바이트");
실행해보면 문자들이 잘 표시되는 걸 볼 수 있어. 영어만 있는 파일은 UTF-16에서 대략 두 배 정도 더 큰 용량을 차지할 거야(ASCII 범위 기준).
4. 인코딩 비교 요약표
기본적인 특성을 정리해서 한 표로 만들자.
| 인코딩 | 문자당 최소 바이트 수 | 문자당 최대 바이트 수 | ASCII와의 직접 호환성 | .NET에서 기본적으로 BOM 사용 여부 | 사용 예 |
|---|---|---|---|---|---|
| ASCII | 1 | 1 | 완전 호환 | 아니오 | 오래된 시스템, 아주 단순한 텍스트, 내부 프로토콜 |
| UTF-16 | 2 | 4 | 아니오 | 예 (Encoding.Unicode) | Windows 내부 문자열 표현, Java 내부 표현; 윈도우 텍스트 파일 |
| UTF-8 | 1 | 4 | 완전 호환 | 아니오 (Encoding.UTF8 in .NET 5+); 예 (Encoding.UTF8 in .NET Framework) | 웹(HTML, JSON), 설정 파일, 소스 코드, Linux/Unix |
Encoding.UTF8에 대한 작은 메모:
역사적으로 .NET Framework의 Encoding.UTF8는 기본적으로 BOM을 추가했어. 최신 .NET(.NET Core/5+)에서는 기본 동작이 바뀌어서 BOM을 기본으로 추가하지 않아. BOM이 필요하면 new UTF8Encoding(true)를 사용하면 돼.
5. C#에서 인코딩 지정하는 방법
예제에서 봤듯이 StreamReader나 StreamWriter에 어떤 '사전'을 쓸지 알려주려면 System.Text.Encoding에서 하나의 객체를 전달하면 돼.
System.Text.Encoding에는 미리 정의된 옵션들이 있어:
- Encoding.ASCII: ASCII용.
- Encoding.Unicode: UTF-16 LE (BOM 포함).
- Encoding.UTF8: UTF-8 (최신 .NET에서는 기본적으로 BOM 없음).
다른 인코딩은 Encoding.GetEncoding로 가져올 수 있어(예: "windows-1251", "koi8-r"). 하지만 지금은 Unicode 중심으로 보면 돼.
// UTF-8로 쓰기
using var writer = new StreamWriter("my_file.txt", false, Encoding.UTF8);
writer.WriteLine("어떤 텍스트.");
// UTF-16으로 읽기
using var reader = new StreamReader("another_file.txt", Encoding.Unicode);
string content = reader.ReadToEnd();
Console.WriteLine(content);
이게 전부야! StreamReader와 StreamWriter가 선택한 인코딩 규칙에 따라 문자↔바이트 변환을 알아서 처리해줘.
6. 인코딩 문제: "깨진 문자"
앞에서 ASCII에 러시아어를 썼을 때 '깨진 문자'를 본 적 있지. 그런데 더 흔한 실수는 파일을 한 인코딩으로 저장하고 다른 인코딩으로 읽는 거야. 그때 진짜 골 때리는 일이 벌어져.
예를 들어 러시아어로 쓴 파일을 UTF-8로 저장했는데, 읽는 쪽에서 CP1251로 읽으면 바이트 시퀀스가 잘못 해석돼서 "Привет, мир!" 대신에 깨진 문자(mojibake)가 나와. 원인은 단 하나: 쓰기와 읽기 인코딩 불일치야. 항상 같은 인코딩을 쓰는 게 원칙이야. 의도적으로 재인코딩하는 경우가 아니라면.
string file = "mismatch.txt";
string russianText = "Привет, мир!";
// UTF-8로 올바르게 쓰기
File.WriteAllText(file, russianText, Encoding.UTF8);
// 잘못 읽기: UTF-8 파일을 ASCII로 읽으려고 함
string readAsAscii = File.ReadAllText(file, Encoding.ASCII);
Console.WriteLine($"원본: {russianText}");
Console.WriteLine($"ASCII로 읽기: {readAsAscii}"); // 여기서 깨진 문자가 나올 거야!
프로그램을 돌려보면 키릴 문자가 물음표나 이상한 문자로 변하는 걸 볼 수 있어. 다음 강의에서는 인코딩을 더 유연하게 다루는 방법, 파일의 인코딩을 어떻게 확인하는지, 그리고 이런 문제를 피하는 실전 팁을 다룰게.
GO TO FULL VERSION