본문 바로가기
C.W.K.
Lesson 03 of 06 · published

같은 요리를 두 번 먹어도 클래스는 하나

~13 min · matching, unicode, normalization, deduplication

Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

중복이라는 문제

모델은 철자가 들쭉날쭉해. 한 달 동안 접시가 쌓이면 피파는 같은 요리를 "Gambas al ajillo"라고도, "gambas al ajillo!"라고도, "Gambas-al-Ajillo"라고도 쓸 수 있어. 철자마다 새 클래스가 생기면 한 요리의 접시들이 여러 행으로 흩어져. 배지가 문제인 건 아니야. 집에서 만든 접시 하나면 클래스는 바로 "직접 요리"로 읽히거든. 문제는 접시가 어디 떨어지느냐야. 행마다 페이지엔 자기 몫의 접시만 보이고, 피파가 전에 제안한 형제는 아빠가 그 요리를 먹은 뒤에도 피파의 추정으로 남을 수 있어. 아빠 접시가 쌍둥이 행으로 갔으니까. 트리는 쪼개진 채로 거짓말을 하게 돼.

Sizzle엔 방어선이 두 겹 있고, 순서대로 돌아.

첫째, 모델한테 id를 쥐여 줘

읽기 입력엔 매번 store.catalog()가 만든 아빠의 트리가 데이터로 들어가. 원리마다 id, 이름, 요약, 상태가 있고, 요리나 변형마다 id, 종류, 이름, gloss, 요리권, 상태가 있어. 루브릭은 맞는 게 있으면 ref나 dish_ref에 그 id를 넣고 원래 이름을 지키라고 해.

엔진은 그 id를 그냥 믿지 않아. _resolve_principle은 ref가 정말 있는 클래스고 종류가 principle일 때만 받아. _resolve_dish는 dish_ref가 요리나 변형일 때만 받고. 피파가 잘못 기억한 id나, 원리 자리에 들어간 요리 id는 무시되고 이름 맞추기로 넘어가.

둘째, 이름을 정규화해

쓸 만한 id가 없으면 엔진은 norm으로 이름을 비교해. backend/store.py에 있는 6줄짜리 함수야. 단계는 이래.

  1. NFKD가 악센트 붙은 글자를 기본 글자랑 결합 부호로 쪼개.
  2. 결합 부호를 전부 버려. 악센트가 여기서 빠져.
  3. NFKC가 남은 걸 다시 붙이고, 전각 글자 같은 호환 문자를 보통 글자로 바꿔.
  4. casefold()는 lower()보다 세게 소문자로 만들어. 그래서 "Straße"랑 "strasse"가 같아져.
  5. 글자나 숫자가 아닌 문자가 이어진 곳은 밑줄까지 포함해서 공백 하나가 되고, 양 끝은 잘려.

origin/main 코드로 이름 몇 개를 직접 돌려 봤어. 전부 같은 키 하나로 모이는 것들이야.

쓴 이름맞추기 키
Gambas-al-Ajillogambas al ajillo
gambas_al_ajillogambas al ajillo
Crème brûléecreme brulee
paella (valenciana)paella valenciana
KIMCHI (전각)kimchi
김치-찌개김치 찌개

따로 남는 것도 있어. 김치찌개랑 김치 찌개는 안 맞아. norm은 구분자를 공백 하나로 바꾸기만 하지, 지우진 않거든. Spaghetti aglio e olio랑 Aglio e olio도 달라. 비슷한 말 찾기나 부분 일치는 아예 없어. 문장 부호만으로 된 이름은 빈 키가 되고, find_class는 빈 키엔 아무것도 안 돌려줘. 이름 없는 걸 몽땅 같다고 묶지 않는 거지.

한글은 멀쩡히 통과해. NFKD가 음절을 자모로 쪼개긴 하는데, 자모는 결합 부호가 아니라서 버려지는 게 없어. 그리고 NFKC가 음절을 다시 붙여 놔.

이름이 둘이면 기회도 둘

요리엔 이름이 둘 있고, 찾을 때 둘 다 써. find_dish는 먼저 읽기의 원어 이름으로 찾고, 안 되면 한국어 이름으로 찾아. find_class는 후보마다 그 키를 모든 요리와 변형의 name 칸, gloss 칸 양쪽과 비교해. 그래서 읽기가 원어 이름 자리에 한국어 이름을 써도, gloss가 그거랑 맞는 클래스를 찾아. 변형도 같이 뒤지니까, 읽기가 아빠가 붙인 이름 그대로 부르면 아빠 버전의 접시는 그 변형에 바로 이어져. 원리는 find_principle로 이름만 맞춰.

이 레슨의 엔진 테스트는 짧아. 같은 요리 두 접시를 읽히고 나면, 그 요리 페이지엔 클래스 하나에 딱 그 두 접시가 걸려 있어.

키는 셋, 하는 일도 셋

Sizzle엔 이름 키가 셋 있고, 셋이 모든 걸 똑같이 보진 않아.

  • 엔진의 norm은 트리를 맞추는 데 써. 악센트랑 문장 부호까지 정리해.
  • integrate 안의 _key는 형제가 댄 원리 이름을 같은 읽기에서 앞서 나온 원리에 맞추는 데 써. 띄어쓰기랑 대소문자만 통일해.
  • 웹의 nameKey랑 폰의 TreeIndex.key는 읽기 안의 이름을 클래스 페이지 링크로 바꾸는 데 써. NFKC, 소문자, 공백 정리까지만 하고 악센트는 남겨.

그래서 눈에 보이는 이음매가 하나 생겨. 트리에 "Crème brûlée"가 있고 읽기가 "creme brulee"라고 쓰면, 엔진은 접시를 맞는 클래스에 이어. 웹 접시 화면의 In the tree 목록, 곧 트리에 있는 요리 목록에도 떠. 그런데 읽기 안의 요리 제목은 한국어 이름이 우연히 맞지 않는 한 그냥 글자로 남아. 기록은 맞아. 편의용 링크 하나가 빠진 것뿐이야.

원칙: "같은 이름"이 뭔지는 한 번만 정해서 함수로 써. 그리고 꼭 맞아야 하는 쌍이랑 절대 맞으면 안 되는 쌍으로 테스트해.

Code

cwkSizzle, backend/store.py: norm, 모든 클래스 이름의 맞추기 키 (원문 그대로, 128-133행)·python
def norm(text: str | None) -> str:
    """Matching key for names: case, accents, punctuation and spacing folded."""
    decomposed = unicodedata.normalize("NFKD", text or "")
    stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
    folded = unicodedata.normalize("NFKC", stripped).casefold()
    return re.sub(r"[\W_]+", " ", folded).strip()
cwkSizzle, backend/store.py: find_class는 키 하나를 name과 gloss 양쪽에 댄다 (원문 그대로, 428-437행)·python
    def find_class(self, kind: str | tuple[str, ...], name: str) -> dict[str, Any] | None:
        kinds = (kind,) if isinstance(kind, str) else kind
        key = norm(name)
        if not key:
            return None
        marks = ",".join("?" * len(kinds))
        for row in self._all(f"SELECT id, name, gloss FROM classes WHERE kind IN ({marks})", kinds):
            if norm(row["name"]) == key or (row["gloss"] and norm(row["gloss"]) == key):
                return self.klass(row["id"])
        return None
cwkSizzle, backend/domain.py: find_dish는 이름 둘을 다 시도하고, find_principle은 원리만 맞춘다 (원문 그대로, 321-330행)·python
    def find_dish(self, name: str | None, gloss: str | None = None) -> dict[str, Any] | None:
        for candidate in (name, gloss):
            if candidate:
                found = self.store.find_class(("dish", "variant"), candidate)
                if found:
                    return found
        return None

    def find_principle(self, name: str | None) -> dict[str, Any] | None:
        return self.store.find_class("principle", name or "") if norm(name) else None

External links

Exercise

네 시스템의 엔티티 이름을 정규화하는 함수를 써 봐. 태그든, 상품 이름이든, 사람이나 장소든 좋아. 그리고 표 기반 테스트를 써. 목록은 둘이야. 같은 키가 나와야 하는 쌍, 그리고 절대 같으면 안 되는 쌍. 악센트, 전각 문자, 문장 부호, 띄어쓰기 차이를 넣고, 사용자는 같다고 부르지만 일부러 갈라 두기로 한 쌍도 하나 넣어. 배포하기 전에 그 함수를 실제 데이터에 돌려서 기존 행이 몇 개나 부딪히는지 세어 봐.
Hint
부딪힌 것마다 원래 이름 옆에 키를 찍어 봐. 읽을 수 있는 충돌 목록이 있어야 정규화가 너무 세다는 걸 알아챌 수 있어. 그리고 norm이 빈 문자열을 내면 find_class가 None을 돌려주는 것처럼, 빈 키는 아무것과도 안 맞게 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.