이런 경험, 한 번쯤 있지 않으셨나요?

오후 3시. 어김없이 전화가 걸려옵니다.

“홈페이지 공지사항 올렸는데요, 글자가 이상하게 나와요. 물음표가 막 떠요.”

담당자분은 분명히 “별다른거 없이 멀쩡하게 글을 썼어요.” 라고 합니다. 하지만 경험치가 있는 전산 담당자는 알죠. 몇마디 나눠보면 한글 문서(HWP)에서 내용을 그대로 복사해서 붙여넣었다고 합니다. 미리보기에서도 멀쩡했고요. 그런데 저장하고 나서 보니 이런 화면이 펼쳐집니다.

교육과정 안내 ? 신청 방법
수업료 ?15만 원 (분할납부 가능)
웹페이지에서 특수문자가 ?로 표시되는 실제 화면 — VARCHAR 컬럼에 HWP 특수문자를 저장했을 때 나타나는 문제
웹페이지에서 특수문자가 ?로 표시되는 실제 화면 — VARCHAR 컬럼에 HWP 특수문자를 저장했을 때 나타나는 문제
캡처이미지: codeslog

분명히 중앙점 특수문자가 들어가 있었는데, 어딘가에서 ?로 바뀌어버렸습니다.

DB를 열어보면…

sql
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에서 복사한 어떤 특수문자든 그대로 저장할 수 있습니다.

코드 페이지(CP949)와 유니코드 비교 개념도 — CP949에 없는 문자(• ・)는 ?로 대체되고, NVARCHAR의 유니코드는 모든 문자를 저장합니다
코드 페이지(CP949)와 유니코드 비교 개념도 — CP949에 없는 문자(• ・)는 ?로 대체되고, NVARCHAR의 유니코드는 모든 문자를 저장합니다
이미지: Nanobanana AI로 생성

VARCHAR vs NVARCHAR, 무엇이 다른가?

데이터 표
구분VARCHARNVARCHAR
저장 방식서버 코드 페이지 (CP949 등)유니코드 (UTF-16LE)
한 글자 용량영문 1바이트, 한글 2바이트모든 문자 2바이트 (서로게이트 4바이트)
저장 용량 예시VARCHAR(100) = 최대 100바이트NVARCHAR(100) = 최대 200바이트
코드 페이지 밖 문자?로 손상정상 저장
권장 상황ASCII 위주 데이터 (영문 로그 등)한글·특수문자·다국어 포함 데이터

용량에 대한 오해

NVARCHAR는 글자당 2바이트를 사용하므로, 같은 길이라면 저장 공간이 2배라고 볼 수 있습니다. 그래서 레거시 시스템에서는 “용량 낭비"를 이유로 VARCHAR를 고집하는 경우가 있어요.

하지만 현대의 저장 비용을 생각하면, 사용자 입력 데이터에서 이 차이는 거의 의미가 없습니다.

VARCHAR로 아낀 바이트 vs NVARCHAR로 막은 야근 — 어느 쪽이 더 중요할까요.

VARCHAR와 NVARCHAR 비교 — VARCHAR는 코드 페이지 기반 저장으로 특수문자 손상 위험, NVARCHAR는 유니코드로 모든 문자 안전 저장
VARCHAR와 NVARCHAR 비교 — VARCHAR는 코드 페이지 기반 저장으로 특수문자 손상 위험, NVARCHAR는 유니코드로 모든 문자 안전 저장
이미지: Nanobanana AI로 생성

해결 방법

1단계: 컬럼 타입을 NVARCHAR로 변경

sql
ALTER TABLE notices
ALTER COLUMN content NVARCHAR(4000);

주의: 이미 ?로 저장된 데이터는 컬럼을 바꿔도 복구되지 않습니다. 원본 데이터를 다시 입력해야 합니다.

2단계: 쿼리에 N 접두사 추가

이 부분을 빠뜨리는 경우가 많습니다. 컬럼이 NVARCHAR여도, 리터럴 문자열에 N을 붙이지 않으면 여전히 코드 페이지로 처리됩니다.

sql
-- ❌ 여전히 ? 가 될 수 있음
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 등을 사용할 때 파라미터 타입을 확인해야 합니다.

csharp
// ❌ 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# 예제

csharp
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();
}

저장 전에 호출하면 됩니다:

csharp
string sanitized = ReplaceUnsafeChars(userInput);
cmd.Parameters.Add("@content", SqlDbType.VarChar).Value = sanitized;

CP949에서 처리 불가능한 문자가 있는지 사전에 확인하고 싶다면 이런 방법도 있습니다.

csharp
public static bool IsVarCharSafe(string input)
{
    var cp949 = Encoding.GetEncoding(949);
    byte[] bytes = cp949.GetBytes(input);
    return cp949.GetString(bytes) == input;
}

인코딩·디코딩을 거친 문자열이 원본과 다르면, 중간에 ?로 손상되는 문자가 있다는 뜻입니다.

Python 예제

Python에서 pyodbcpymssql로 MSSQL을 사용하는 경우:

python
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마다 접근 방식이 다릅니다.

데이터 표
DBVARCHAR 동작권장 설정
MSSQL서버 코드 페이지 기준 저장. 코드 페이지 외 문자는 ? 손상NVARCHAR + N 접두사
MySQL컬럼/테이블/DB별 문자셋 지정 가능. 기본값 latin1이면 동일 문제 발생CHARACTER SET utf8mb4 설정
PostgreSQLDB 생성 시 인코딩 UTF8 설정 시 VARCHAR도 유니코드 저장. 기본값이 UTF-8이라 실무에서 거의 문제 없음ENCODING 'UTF8' (대부분 기본값)
OracleVARCHAR2는 DB 캐릭터셋 기준. NVARCHAR2는 유니코드 전용NVARCHAR2 또는 DB를 AL32UTF8로 설정
SQLite내부적으로 모든 텍스트를 UTF-8로 저장. TEXT 타입이면 유니코드 문제 거의 없음기본값으로 충분

PostgreSQL이나 SQLite처럼 UTF-8을 기본으로 사용하는 DB였다면 처음부터 이 문제를 겪지 않았을 수도 있습니다. MSSQL은 오래된 코드 페이지 기반 설계의 영향이 지금도 남아 있어, 특히 한국어 환경에서는 주의가 필요합니다.

DB는 저마다 문자 인코딩 접근 방식이 다릅니다
DB는 저마다 문자 인코딩 접근 방식이 다릅니다
사진: UnsplashKevin Ache

실무에서 체크할 것들

기존 데이터베이스 점검

sql
-- 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로 설계하는 것을 권장합니다.

sql
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까지 저장할 수 있으며, 게시판 본문처럼 길이 제한이 불확실한 경우에 적합합니다.

데이터 손상은 조용히 일어납니다. 자물쇠로 자산을 미리 보호하듯 처음부터 제대로 설계하는 것이 데이터를 지키는 최선입니다.
데이터 손상은 조용히 일어납니다. 자물쇠로 자산을 미리 보호하듯 처음부터 제대로 설계하는 것이 데이터를 지키는 최선입니다.
사진: UnsplashSasun Bughdaryan

정리

HWP에서 복사·붙여넣기한 텍스트가 MSSQL에서 ?로 깨지는 이유, 이제 명확해지셨나요?

  1. VARCHAR서버 코드 페이지(CP949 등) 기준으로 저장됩니다.
  2. HWP의 일부 특수문자는 해당 코드 페이지에 포함되지 않아 ?로 손상됩니다.
  3. NVARCHAR로 컬럼 타입을 변경하고, 쿼리 리터럴에 N 접두사를 붙이면 해결됩니다.
  4. PostgreSQL, SQLite 등은 기본이 UTF-8이라 이 문제가 드물지만, MSSQL은 특히 주의가 필요합니다.

신규 시스템을 설계한다면 한글이 들어가는 모든 컬럼을 NVARCHAR로 하는 것을 기본값으로 삼는 것이 좋습니다. 나중에 데이터가 깨진 뒤 복구하는 것보다, 처음부터 제대로 설정하는 편이 훨씬 낫거든요.

데이터 손상은 조용히 일어납니다. 발견했을 때는 이미 늦은 경우가 많아요.


참고 자료