본문 바로가기
"가" 보다 "초"가 먼저다: DB의 한글 정렬 미스터리— PostgreSQL에서 ICU 콜레이션 적용기
백엔드

"가" 보다 "초"가 먼저다: DB의 한글 정렬 미스터리— PostgreSQL에서 ICU 콜레이션 적용기

황병헌
황병헌위니브 백엔드 개발자2026/09/30

문제 발견

  • 위니버시티의 관리자 페이지에서 강의 목록을 이름순으로 정렬하던 중이었다. 평소처럼 정렬 버튼을 눌렀는데, 순서가 이상했다.

1. 강의 번호가 뒤죽박죽

강의 1
강의 10   ← 2보다 먼저?
강의 11
강의 2
강의 3
  • 강의 2가 강의 10 뒤에 있다. 사전 속 단어처럼 한 글자씩 비교하다 보니 컴퓨터 눈에는 1 < 2가 먼저 보여서 강의 10이 앞에 와버린 것이다. 사람이라면 "10이 2보다 크다"고 자연스럽게 생각하지만, 컴퓨터는 그렇지 않았다.

2. 한글 순서가 ㄱㄴㄷ이 아님

초급반
가나다 학습   ← ㄱ이 ㅊ보다 뒤?
알고리즘
  • 가로 시작하는 단어가 초로 시작하는 단어보다 뒤에 와 있다. 국어사전에서는 당연히 ㄱ → ㅊ 순서인데 이것도 깨져 있었다.

3. 영어와 한글이 뒤섞임

알고리즘
ABC 코스   ← 한글이 영어보다 먼저?
Django 입문
  • 한글이 영어 사이사이에 끼어 있다. 영어 먼저, 한글 나중(또는 그 반대)처럼 일관된 기준이 없었다.

전체 정렬 결과

# 기본 콜레이션 (en_US.UTF-8) 정렬 결과
초급반
가나다 학습
알고리즘
강의 1
1단계
강의 10
10단계
강의 11
강의 2
2단계
강의 3                                                                                                                                                                                                                         
3단계
ABC 코스                                                                                                                                                                                                                       
Advanced                                                                                                                                                                                                                     
Beginner
Django 입문
Python 기초
React

기대한 정렬 결과

  • 우리가 원했던 결과는 숫자 → 영어 → 한글 순서에, 각 그룹 안에서는 자연스러운 순서를 따르는 것이었다.

    1단계
    2단계
    3단계
    10단계         ← 숫자가 크기순으로
    ABC 코스
    Advanced
    Beginner       ← 영어는 알파벳순으로
    Django 입문
    Python 기초
    React
    가나다 학습
    강의 1
    강의 2
    강의 10        ← 한글은 ㄱㄴㄷ순, 숫자는 크기순으로
    알고리즘
    초급반
    
  • 왜 이런 단순한 정렬이 제대로 안 됐을까? 원인을 찾다 보니, PostgreSQL이 문자열을 비교하는 방식에 숨은 규칙이 있었다.


원인: en_US.UTF-8은 한글을 위한 locale이 아니다

  • 프로젝트의 DB는 PostgreSQL이었고, 기본 LC_COLLATE는 en_US.UTF-8이다.

  • 순수 한글끼리, 길이가 같다면 ㄱㄴㄷ순으로 정렬된다.

    # 2글자끼리 - 정상
    가나  강의  바나  알고  초급  파이  하하
    
    # 3글자끼리 - 정상
    가나다  강의실  바나나  알고리  초급반  파이썬  하하하
    
  • 하지만 문자열 길이가 다르거나, 공백·숫자가 섞이면 순서가 깨진다.

    # 길이가 다르면 - ㄱㄴㄷ순이 아니다
    강의  가나다  바나나  초급반  파이썬  하하하  알고리즘
    
    # 공백·숫자 포함 - 초급반이 가나다보다 앞으로 정렬
    초급반  가나다 학습  알고리즘  강의 1
    

왜 이런 일이 발생할까?

  • 유니코드/ICU 기반 콜레이션은 문자열을 비교할 때 **다중 가중치(multi-level weight)**를 사용하는데, glibc의 en_US.UTF-8도 유사한 방식인 영어 기반의 가중치 테이블을 사용한다.

    레벨비교 대상예시
    1차기본 문자a vs b
    2차악센트a vs á
    3차대소문자a vs A
    4차특수문자, 공백, 구두점“a b" vs "ab"
  • 중요한 건 공백과 구두점은 1차 비교에서 사실상 무시된다는 점이다. 공백과 구두점은 낮은 레벨(보통 4차)로 미뤄두고, 1차에서는 "진짜 문자"끼리만 비교한다. 영어 기준으로는 합리적인 설계다. "Mr. Kim"과 "MrKim"이 같은 묶음으로 정렬되어야 검색·인덱싱이 자연스럽기 때문이다.

  • 문제는 한글·숫자가 섞일 때 이 규칙이 직관을 깬다는 것이다.

    "초급반" vs "가나다 학습"
    
  • 1차 비교에서 공백이 무시되므로, 실질적으로 다음과 같이 비교하게 된다.

    "초급반" vs "가나다학습"
    
  • 그런데 glibc의 en_US.UTF-8은 한글에 대한 가중치 테이블이 영어 기준으로 설계되어 있어서, 한글 글자 간 상대 순서가 우리가 기대하는 ㄱㄴㄷ순과 정확히 일치하지 않는다. 여기에 공백 무시 동작이 겹치면서, "초급반"이 "가나다 학습"보다 앞에 오는 결과가 나온다.

  • 숫자가 섞인 경우도 같은 원리다. "강의 10"과 "강의 2"를 비교할 때 공백은 1차에서 무시되고, 남은 문자 1, 0과 2를 비교한다. 숫자도 문자 단위로 비교되기 때문에 '1' < '2'에서 비교가 끝나 "강의 10"이 "강의 2"보다 앞에 오는 것이다.

# 숫자를 문자로 비교
강의 1
강의 10    ← '1' < '2'이므로 10이 2보다 앞
강의 11
강의 2
강의 3

정리하자면

  • en_US.UTF-8 기본 콜레이션의 한글 정렬 문제는
    1. 한글 순서 깨짐: 영어 기준 가중치 테이블에서 한글이 부차적으로 처리되어, 길이가 다르거나 공백·숫자가 섞이면 ㄱㄴㄷ순이 무너진다.
    2. 숫자 자연 정렬 미지원: 숫자를 문자 단위로 비교하여 10이 2보다 앞에 온다.

해결 1단계: ICU 콜레이션 도입 (ko-x-icu)

시도

  • PostgreSQL은 3가지 collation provider를 지원한다. libc, icu, builtin(PG 17+). 이 중 ICU는 유니코드 표준 기반의 정렬 규칙을 제공하며, 언어별 세밀한 설정이 가능하다.

  • 처음에는 한국어 기본 ICU 콜레이션인 ko-x-icu를 적용했다.

    CREATE COLLATION "ko-x-icu" (provider = icu, locale = 'ko'); 
    
  • 가장 큰 문제였던 한글 정렬이 해결됐다. 길이가 다르거나 공백이 섞여도 한글이 ㄱㄴㄷ순으로 정렬되었다.

    # ko-x-icu 적용 후
    가나다
    강의
    강의실
    바나나
    알고리즘
    초급반
    파이썬
    

결과

  • 하지만 두 가지 문제가 여전히 남았다.
    • 숫자 자연 정렬 미지원: 여전히 강의 10이 강의 2보다 앞에 왔다. ko locale은 언어 기반 콜레이션일 뿐이고 숫자를 값으로 비교하는 기능을 포함하지 않는다.
    • 영어-한글 순서 미보장: ABC와 가나다 중 어느 쪽이 먼저 올지가 ICU 기본 순서에 맡겨진다. 우리는 서비스에서 영문 → 한글 순서를 명시적으로 보장하고 싶었다.
  • 결국 ko-x-icu는 "한글 정렬" 문제만 풀어주는 기본 옵션이었고, 숫자와 스크립트 순서는 별도의 ICU 옵션으로 추가 지정해야 한다는 것을 알게 됐다.

해결 2단계: 콜레이션 옵션 확장 (ko-u-kr-latn-hang-kn-true-x-icu)

  • ICU 콜레이션은 Unicode BCP 47 형식으로 세부 옵션을 지정할 수 있다. 최종적으로 적용한 콜레이션은 ko-u-kr-latn-hang-kn-true-x-icu이다.

  • 적용 쿼리 및 결과

    CREATE COLLATION IF NOT EXISTS "ko-u-kr-latn-hang-kn-true-x-icu"
    (provider = icu, locale = 'ko-u-kr-latn-hang-kn-true');
    
    # 숫자 자연 정렬
    강의 1
    강의 2
    강의 3
    강의 10
    강의 11
    
    # 영어 → 한글 순서
    ABC
    Django
    PostgreSQL
    가나다
    라마바
    

적용 방식의 변천: 쿼리 레벨 → 컬럼 레벨

쿼리 레벨 적용 (초기)

  • 처음에는 Django ORM의 Collate() 함수를 사용해 정렬이 필요한 곳마다 콜레이션을 지정했다.

    from django.db.models.functions import Collate
    
    # QuerySet
    def korean_order_by(self, order_by: str) -> "CourseQuerySet":
        field = order_by.lstrip("-")
        descending = order_by.startswith("-")
        if field == "title":
            collated = Collate("title", "ko-u-kr-latn-hang-kn-true-x-icu")
            return self.order_by(collated.desc() if descending else collated.asc())
        return self.order_by(order_by)
    
    # Repository
    collated_nickname = Collate("nickname", "ko-u-kr-latn-hang-kn-true-x-icu")
    return queryset.order_by(collated_nickname.asc())
    
  • 문제점

    • 정렬이 필요한 모든 곳에서 Collate()를 호출해야 함
    • 콜레이션 문자열이 코드 곳곳에 하드코딩
    • 새 기능 개발 시 빼먹기 쉬움

컬럼 레벨 적용 (최종)

  • DB 컬럼 자체에 콜레이션을 설정하면, order_by("title")만으로도 한글 정렬이 적용된다. Repository 코드에서 Collate()를 완전히 제거할 수 있었다.

  • 기존 컬럼: 마이그레이션으로 일괄 적용

    KOREAN_COLLATION_TARGET_COLUMNS = [
        ("courses", "title"),
        # ...
    ]
    
    def apply_collation(apps, schema_editor):
        cursor = schema_editor.connection.cursor()
        for table, column in KOREAN_COLLATION_TARGET_COLUMNS:
            col_type = get_column_type(cursor, table, column)
            if col_type is None:
                continue
            cursor.execute(
                f'ALTER TABLE "{table}" ALTER COLUMN "{column}" '
                f'SET DATA TYPE {col_type} COLLATE "{KOREAN_COLLATION}";'
            )
    
  • 새 컬럼: 모델에 db_collation 지정

    from constants import KOREAN_COLLATION
    
    file_name = models.CharField(
        max_length=255,
        null=True,
        blank=True,
        db_collation=KOREAN_COLLATION,
    )
    

이제 Repository에서는 그냥 order_by("title")만 쓰면 된다.


삽질 기록: 테스트 DB에서 콜레이션이 없다

  • 컬럼 레벨 콜레이션을 적용한 후, CI에서 테스트가 깨졌다.

    django.db.utils.ProgrammingError: collation "ko-u-kr-latn-hang-kn-true-x-icu" for encoding "UTF8" does not exist

  • 원인은 테스트 DB 생성 순서에 있었다.

  • 콜레이션을 만드는 마이그레이션이 실행되기 전에, 콜레이션을 참조하는 컬럼이 먼저 생성되려 하면서 에러가 발생한 것이다.

해결: 테스트 DB 생성 과정에 콜레이션 끼워넣기

  • Django의 DatabaseCreation.create_test_db를 monkey-patch해서, 빈 DB 생성 직후 & 마이그레이션 실행 전에 콜레이션을 먼저 생성하도록 했다.
def _patch_create_test_db(creation_instance):
    original_create_test_db = creation_instance.__class__.create_test_db

    def patched_create_test_db(self, verbosity=1, autoclobber=False,
                                serialize=True, keepdb=False):
        original_internal = self._create_test_db

        def _create_test_db_and_collation(verbosity, autoclobber, keepdb=False):
            result = original_internal(verbosity, autoclobber, keepdb)

            # 빈 DB 생성 직후, 마이그레이션 전에 콜레이션 생성
            test_db_name = self._get_test_db_name()
            original_name = self.connection.settings_dict["NAME"]
            self.connection.settings_dict["NAME"] = test_db_name
            self.connection.close()

            with self.connection.cursor() as cursor:
                cursor.execute(
                    f'CREATE COLLATION IF NOT EXISTS "{KOREAN_COLLATION}" '
                    "(provider = icu, locale = 'ko-u-kr-latn-hang-kn-true');"
                )

            self.connection.settings_dict["NAME"] = original_name
            self.connection.close()
            return result

        self._create_test_db = _create_test_db_and_collation
        try:
            return original_create_test_db(self, verbosity, autoclobber,
                                           serialize, keepdb)
        finally:
            self._create_test_db = original_internal

    creation_instance.__class__.create_test_db = patched_create_test_db

  • 테스트 DB 생성 흐름이 이렇게 바뀌었다.

왜 DB 기본 locale을 안 바꾸나요?

  • PostgreSQL DB의 기본 locale provider를 libc에서 icu로 변경하면, 모든 텍스트 컬럼에 자동 적용되어 가장 깔끔하다. 하지만 이 변경은 DB 재생성이 필요하다.
  • 운영 중인 DB의 locale provider를 바꾸려면 DB를 새로 만들고 데이터를 마이그레이션해야 하므로, 컬럼 레벨 적용이 현실적인 선택이었다.

정리

단계콜레이션적용 방식한계점
기본en_US.UTF-8-한글 순서 깨짐, 숫자 자연 정렬 X, 영한 순서 불안정
1차ko-x-icu쿼리 레벨 (Collate())숫자 자연 정렬 X, 영한 순서 미보장, 코드 중복
2차ko-u-kr-latn-hang-kn-true-x-icu쿼리 레벨 (Collate())코드 중복, 누락 위험
최종ko-u-kr-latn-hang-kn-true-x-icu컬럼 레벨 (db_collation)테스트 DB 설정 필요

핵심 교훈

  • en_US.UTF-8 기본 콜레이션에서 한글은 같은 길이의 순수 한글끼리만 ㄱㄴㄷ순으로 정렬된다. 실서비스 데이터처럼 길이가 다르거나 공백·숫자가 섞이면 정렬이 깨지므로, 한글 데이터를 다루는 프로젝트라면 ICU 콜레이션은 필수다.
  • 쿼리마다 Collate()를 붙이는 것보다 컬럼 레벨에서 한 번 설정하는 게 유지보수에 유리하다.
  • 컬럼 레벨 콜레이션 적용 시 테스트 DB 생성 순서를 반드시 고려해야 한다.
  • DB의 기본 콜레이션(LC_COLLATE)은 한번 설정하면 DB 재생성 없이는 변경할 수 없다. 프로젝트 초기에 데이터의 언어와 정렬 요구사항을 고려해 적절한 콜레이션을 설정하는 것이 가장 깔끔하다. 이 프로젝트처럼 운영 중에 문제를 발견하면 컬럼 레벨 콜레이션으로 해결할 수 있지만, 대상 컬럼을 일일이 관리해야 하고 새 필드 추가 시 db_collation 지정을 빠뜨릴 위험이 생긴다.
황병헌
황병헌
위니브 백엔드 개발자

위니버시티 백엔드 개발자