이런 경험, 한 번쯤 있지 않으셨나요?#
오후 3시. 어김없이 전화가 걸려옵니다.
“홈페이지 공지사항 올렸는데요, 글자가 이상하게 나와요. 물음표가 막 떠요.”
담당자분은 분명히 “별다른거 없이 멀쩡하게 글을 썼어요.” 라고 합니다. 하지만 경험치가 있는 전산 담당자는 알죠. 몇마디 나눠보면 한글 문서(HWP)에서 내용을 그대로 복사해서 붙여넣었다고 합니다. 미리보기에서도 멀쩡했고요. 그런데 저장하고 나서 보니 이런 화면이 펼쳐집니다.
교육과정 안내 ? 신청 방법
수업료 ?15만 원 (분할납부 가능)
캡처이미지: codeslog
분명히 중앙점 특수문자가 들어가 있었는데, 어딘가에서 ?로 바뀌어버렸습니다.
DB를 열어보면…
SELECT content FROM notices WHERE id = 123456;
-- 결과: 교육과정 안내 ? 신청 방법역시 DB에 이미 ?로 들어가 있었습니다. 문제는 저장 시점이었어요.
HWP 파일은 결백합니다. 네트워크도 결백합니다. 브라우저도 결백합니다.
용의자는 처음부터 VARCHAR 컬럼이었습니다.
이 글에서는 이 문제가 왜 생기는지, 그리고 어떻게 해결하는지 차근차근 설명해보겠습니다.
어떤 문자가 문제였나?#
HWP에서 자주 사용하는 중앙점·따옴표·쌍따옴표 계열 문자들을 실제로 테스트해봤습니다. 컬럼 타입이 VARCHAR인 경우, 아래처럼 결과가 달라집니다.
중앙점 계열#
| 문자 | 유니코드 | 이름 | VARCHAR 저장 결과 |
|---|---|---|---|
・ | U+30FB | 가타카나 중점 | ❌ ? |
• | U+2022 | 불릿 | ❌ ? |
· | U+00B7 | 중간점 (Middle Dot) | ✅ 정상 |
˙ | U+02D9 | 위 점 (Dot Above) | ✅ 정상 |
홑따옴표 계열#
| 문자 | 유니코드 | 이름 | VARCHAR 저장 결과 |
|---|---|---|---|
' | U+2018 | 왼쪽 작은따옴표 | ✅ 정상 |
' | U+2019 | 오른쪽 작은따옴표 | ✅ 정상 |
′ | U+2032 | 프라임 | ✅ 정상 |
´ | U+00B4 | 악센트 (Acute Accent) | ✅ 정상 |
쌍따옴표 계열#
| 문자 | 유니코드 | 이름 | VARCHAR 저장 결과 |
|---|---|---|---|
" | U+201C | 왼쪽 큰따옴표 | ✅ 정상 |
" | U+201D | 오른쪽 큰따옴표 | ✅ 정상 |
″ | U+2033 | 더블 프라임 | ✅ 정상 |
˝ | U+02DD | 더블 악센트 | ✅ 정상 |
・(가타카나 중점)과 •(불릿)만 ? 처리된 것이죠.
이 두 문자의 공통점은 뭘까요? 서버의 코드 페이지에 포함되지 않은 문자라는 점입니다.
문제의 핵심: 코드 페이지와 유니코드#
코드 페이지(Code Page)란?#
컴퓨터가 문자를 저장할 때는 결국 숫자(바이트)로 변환해야 합니다. 이 “어떤 문자를 어떤 숫자로 바꾼다"는 약속표가 바로 코드 페이지입니다.
한국어 환경에서 많이 쓰이는 코드 페이지는 CP949 (EUC-KR 확장)입니다. 가 → 0xB0A1, 나 → 0xB3AA 이런 식으로 매핑됩니다.
문제는, 이 약속표에 등록되지 않은 문자는 저장할 수가 없다는 점입니다. SQL Server는 그냥 ?로 대체해버립니다.
・(U+30FB, 가타카나 중점) → CP949에 없음 →?•(U+2022, 불릿) → CP949에 없음 →?·(U+00B7, 중간점) → KS X 1001 문자 집합에 포함 → 정상 저장
유니코드(Unicode)란?#
유니코드는 세상의 거의 모든 문자를 하나의 표준 번호 체계로 정리한 것입니다. 가 = U+AC00, A = U+0041, ・ = U+30FB… 이런 식으로요.
MSSQL의 NVARCHAR는 이 유니코드를 UTF-16LE 방식으로 저장합니다. 코드 페이지에 의존하지 않기 때문에, HWP에서 복사한 어떤 특수문자든 그대로 저장할 수 있습니다.

이미지: Nanobanana AI로 생성
VARCHAR vs NVARCHAR, 무엇이 다른가?#
| 구분 | VARCHAR | NVARCHAR |
|---|---|---|
| 저장 방식 | 서버 코드 페이지 (CP949 등) | 유니코드 (UTF-16LE) |
| 한 글자 용량 | 영문 1바이트, 한글 2바이트 | 모든 문자 2바이트 (서로게이트 4바이트) |
| 저장 용량 예시 | VARCHAR(100) = 최대 100바이트 | NVARCHAR(100) = 최대 200바이트 |
| 코드 페이지 밖 문자 | ?로 손상 | 정상 저장 |
| 권장 상황 | ASCII 위주 데이터 (영문 로그 등) | 한글·특수문자·다국어 포함 데이터 |
용량에 대한 오해#
NVARCHAR는 글자당 2바이트를 사용하므로, 같은 길이라면 저장 공간이 2배라고 볼 수 있습니다. 그래서 레거시 시스템에서는 “용량 낭비"를 이유로 VARCHAR를 고집하는 경우가 있어요.
하지만 현대의 저장 비용을 생각하면, 사용자 입력 데이터에서 이 차이는 거의 의미가 없습니다.
VARCHAR로 아낀 바이트 vs NVARCHAR로 막은 야근 — 어느 쪽이 더 중요할까요.

이미지: Nanobanana AI로 생성
해결 방법#
1단계: 컬럼 타입을 NVARCHAR로 변경#
ALTER TABLE notices
ALTER COLUMN content NVARCHAR(4000);주의: 이미
?로 저장된 데이터는 컬럼을 바꿔도 복구되지 않습니다. 원본 데이터를 다시 입력해야 합니다.
2단계: 쿼리에 N 접두사 추가#
이 부분을 빠뜨리는 경우가 많습니다. 컬럼이 NVARCHAR여도, 리터럴 문자열에 N을 붙이지 않으면 여전히 코드 페이지로 처리됩니다.
-- ❌ 여전히 ? 가 될 수 있음
INSERT INTO notices (content) VALUES ('교육과정 안내 ・ 신청 방법');
-- ✅ 올바른 방법
INSERT INTO notices (content) VALUES (N'교육과정 안내 ・ 신청 방법');
-- UPDATE도 마찬가지
UPDATE notices SET content = N'수업료 ・15만 원' WHERE id = 42;N'...' 표기는 SQL Server에게 “이 문자열을 유니코드로 처리하세요"라고 명시적으로 알려주는 것입니다.
애플리케이션 레이어에서의 처리#
직접 SQL을 작성하는 경우 외에도, ADO.NET 등을 사용할 때 파라미터 타입을 확인해야 합니다.
// ❌ SqlDbType.VarChar 로 바인딩하면 손상 발생 가능
cmd.Parameters.Add("@content", SqlDbType.VarChar).Value = content;
// ✅ NVarChar 로 명시
cmd.Parameters.Add("@content", SqlDbType.NVarChar).Value = content;대부분의 현대 ORM(Entity Framework 등)은 string 타입을 자동으로 NVARCHAR로 매핑하지만, 레거시 코드나 직접 ADO.NET을 사용하는 경우에는 반드시 확인이 필요합니다.
DB를 바꾸기 어렵다면: 서버사이드 문자 치환#
컬럼 타입을 NVARCHAR로 바꾸는 것이 가장 확실한 해결책이지만, 언제나 그게 가능한 건 아닙니다. 레거시 시스템이거나, 써드파티 솔루션을 사용 중이거나, 배포 일정상 DB 변경이 어렵다면 저장 전에 서버사이드에서 문제 문자를 안전한 대체 문자로 바꾸는 방법이 현실적인 대안이 될 수 있습니다.
일종의 통역사를 두는 방식입니다. HWP에서 ・를 들고 오면, DB에 전달하기 전에 먼저 ·로 바꿔서 건네주는 거죠.
치환 원칙#
- 가능하면 시각적으로 유사한 문자로 대체 (예:
・→·) - 완전히 같은 문자가 없으면 의미에 가장 가까운 문자로 교체 (예:
—→-)
C# 예제#
private static readonly Dictionary<char, string> HwpCharReplacements = new()
{
{ '\u30FB', "·" }, // 가타카나 중점 → 중간점(U+00B7)
{ '\u2022', "·" }, // 불릿 → 중간점
{ '\u2027', "·" }, // Hyphenation Point → 중간점
{ '\u2014', "-" }, // Em 대시 → 하이픈
{ '\u2013', "-" }, // En 대시 → 하이픈
{ '\u2010', "-" }, // Hyphen (U+2010) → 하이픈
{ '\u2015', "-" }, // Horizontal Bar → 하이픈
{ '\u2192', "->" }, // 오른쪽 화살표
{ '\u21D2', "=>" }, // 이중 화살표
{ '\u3000', " " }, // 전각 공백 → 반각 공백
{ '\uFF01', "!" }, // 전각 느낌표
{ '\uFF08', "(" }, // 전각 왼쪽 괄호
{ '\uFF09', ")" }, // 전각 오른쪽 괄호
{ '\uFF1A', ":" }, // 전각 콜론
};
public static string ReplaceUnsafeChars(string input)
{
if (string.IsNullOrEmpty(input)) return input;
var sb = new StringBuilder(input.Length);
foreach (char c in input)
{
if (HwpCharReplacements.TryGetValue(c, out var replacement))
sb.Append(replacement);
else
sb.Append(c);
}
return sb.ToString();
}저장 전에 호출하면 됩니다:
string sanitized = ReplaceUnsafeChars(userInput);
cmd.Parameters.Add("@content", SqlDbType.VarChar).Value = sanitized;CP949에서 처리 불가능한 문자가 있는지 사전에 확인하고 싶다면 이런 방법도 있습니다.
public static bool IsVarCharSafe(string input)
{
var cp949 = Encoding.GetEncoding(949);
byte[] bytes = cp949.GetBytes(input);
return cp949.GetString(bytes) == input;
}인코딩·디코딩을 거친 문자열이 원본과 다르면, 중간에 ?로 손상되는 문자가 있다는 뜻입니다.
Python 예제#
Python에서 pyodbc나 pymssql로 MSSQL을 사용하는 경우:
CHAR_REPLACEMENTS = {
'\u30FB': '·', # 가타카나 중점
'\u2022': '·', # 불릿
'\u2027': '·', # Hyphenation Point
'\u2014': '-', # Em 대시
'\u2013': '-', # En 대시
'\u2010': '-', # Hyphen
'\u2015': '-', # Horizontal Bar
'\u2192': '->',
'\u21D2': '=>',
'\u3000': ' ', # 전각 공백
'\uFF01': '!',
'\uFF08': '(',
'\uFF09': ')',
'\uFF1A': ':',
}
def replace_unsafe_chars(text: str) -> str:
return ''.join(CHAR_REPLACEMENTS.get(c, c) for c in text)한계: 치환 목록에 없는 새로운 특수문자가 유입되면 여전히
?손상이 발생할 수 있습니다. 완전한 해결책은 아니에요. DB 마이그레이션이 가능해지는 시점에NVARCHAR로 전환하는 것을 권장합니다.
다른 데이터베이스는 어떤가요?#
MSSQL만 이런 문제가 있는 건 아닙니다. 각 DB마다 접근 방식이 다릅니다.
| DB | VARCHAR 동작 | 권장 설정 |
|---|---|---|
| MSSQL | 서버 코드 페이지 기준 저장. 코드 페이지 외 문자는 ? 손상 | NVARCHAR + N 접두사 |
| MySQL | 컬럼/테이블/DB별 문자셋 지정 가능. 기본값 latin1이면 동일 문제 발생 | CHARACTER SET utf8mb4 설정 |
| PostgreSQL | DB 생성 시 인코딩 UTF8 설정 시 VARCHAR도 유니코드 저장. 기본값이 UTF-8이라 실무에서 거의 문제 없음 | ENCODING 'UTF8' (대부분 기본값) |
| Oracle | VARCHAR2는 DB 캐릭터셋 기준. NVARCHAR2는 유니코드 전용 | NVARCHAR2 또는 DB를 AL32UTF8로 설정 |
| SQLite | 내부적으로 모든 텍스트를 UTF-8로 저장. TEXT 타입이면 유니코드 문제 거의 없음 | 기본값으로 충분 |
PostgreSQL이나 SQLite처럼 UTF-8을 기본으로 사용하는 DB였다면 처음부터 이 문제를 겪지 않았을 수도 있습니다. MSSQL은 오래된 코드 페이지 기반 설계의 영향이 지금도 남아 있어, 특히 한국어 환경에서는 주의가 필요합니다.

사진: Unsplash의 Kevin Ache
실무에서 체크할 것들#
기존 데이터베이스 점검#
-- VARCHAR 컬럼 중 ? 가 포함된 데이터 확인
SELECT id, content
FROM notices
WHERE content LIKE '%?%';
-- 사용자 입력 컬럼이 VARCHAR로 잡혀있는지 일괄 확인
SELECT
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH
FROM INFORMATION_SCHEMA.COLUMNS
WHERE DATA_TYPE IN ('varchar', 'char', 'text')
AND TABLE_SCHEMA = 'dbo'
ORDER BY TABLE_NAME, COLUMN_NAME;신규 테이블 설계 원칙#
한국어 데이터를 다루는 MSSQL 환경이라면, 사용자 입력이 들어가는 컬럼은 처음부터 NVARCHAR로 설계하는 것을 권장합니다.
CREATE TABLE notices (
id INT IDENTITY(1,1) PRIMARY KEY,
title NVARCHAR(500) NOT NULL,
content NVARCHAR(MAX) NOT NULL, -- MAX = 약 2GB
reg_date DATETIME DEFAULT GETDATE()
);NVARCHAR(MAX)는 최대 약 2GB까지 저장할 수 있으며, 게시판 본문처럼 길이 제한이 불확실한 경우에 적합합니다.

사진: Unsplash의 Sasun Bughdaryan
정리#
HWP에서 복사·붙여넣기한 텍스트가 MSSQL에서 ?로 깨지는 이유, 이제 명확해지셨나요?
VARCHAR는 서버 코드 페이지(CP949 등) 기준으로 저장됩니다.- HWP의 일부 특수문자는 해당 코드 페이지에 포함되지 않아
?로 손상됩니다. NVARCHAR로 컬럼 타입을 변경하고, 쿼리 리터럴에N접두사를 붙이면 해결됩니다.- PostgreSQL, SQLite 등은 기본이 UTF-8이라 이 문제가 드물지만, MSSQL은 특히 주의가 필요합니다.
신규 시스템을 설계한다면 한글이 들어가는 모든 컬럼을 NVARCHAR로 하는 것을 기본값으로 삼는 것이 좋습니다. 나중에 데이터가 깨진 뒤 복구하는 것보다, 처음부터 제대로 설정하는 편이 훨씬 낫거든요.
데이터 손상은 조용히 일어납니다. 발견했을 때는 이미 늦은 경우가 많아요.
참고 자료#
- KS X 1001 — Wikipedia: KS X 1001 표준에 포함된 문자 목록.
′(U+2032),˙(U+02D9) 등이 CP949에 포함되는 근거를 확인할 수 있습니다. - CP949 to Unicode Mapping — Unicode Consortium: Windows CP949(코드 페이지 949) 공식 유니코드 매핑 테이블
