문제 발견
- 위니버시티의 관리자 페이지에서 강의 목록을 이름순으로 정렬하던 중이었다. 평소처럼 정렬 버튼을 눌렀는데, 순서가 이상했다.
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 기본 콜레이션의 한글 정렬 문제는
- 한글 순서 깨짐: 영어 기준 가중치 테이블에서 한글이 부차적으로 처리되어, 길이가 다르거나 공백·숫자가 섞이면 ㄱㄴㄷ순이 무너진다.
- 숫자 자연 정렬 미지원: 숫자를 문자 단위로 비교하여 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 지정을 빠뜨릴 위험이 생긴다.


