
이 카테고리들은 누구의 것인가? LLM의 일회성 라벨을 실질적인 데이터로 만들기
요약
LLM이 생성하는 일회성 카테고리 라벨을 안정적인 데이터베이스 모델로 승격시키는 설계 방법을 다룹니다. 사용자가 편집 가능한 표시용 이름과 불변의 키 값을 분리하여 데이터 일관성을 유지하는 전략을 제안합니다.
핵심 포인트
- LLM의 라벨을 단순 문자열이 아닌 일급 객체로 모델링
- 불변의 key와 편집 가능한 name/emoji 분리 설계
- 데이터 유실을 방지하기 위한 자동 카테고리 추가(auto-add) 방식
- 사용자 정의 정렬을 위한 JSON 기반 프로필 저장 전략
요청은 사소해 보였습니다: 내가 매장을 걷는 방식에 맞춰 통로(aisles) 순서를 재정렬하고, 하는 김에 "농산물(Produce)"를 "과일 및 채소(Fruit & Veg)"로 이름을 바꿔주세요. 재정렬과 이름 변경. 설정 화면에서나 할 법한 일들이었죠.
작은 문제: 통로(aisles)가 존재하지 않습니다. Claude가 "우유와 달걀 한 판"을 파싱할 때, 항목에 category: "Dairy"라는 스탬프를 찍고 다음으로 넘어갑니다. 카테고리 테이블도 없고, 통로 엔티티도 없으며, 재정렬할 수 있는 것도 아무것도 없습니다. 그저 항목들에 흩어져 우연히 반복되는 문자열(strings)뿐입니다. 이 포스트는 LLM이 버려버리는 라벨로부터 사용자가 소유할 수 있는 것을 만드는 방법에 관한 것입니다.
요약 (TL;DR) — 단순히 편집 가능하게 만들기 위해 LLM이 생성한 라벨을 데이터베이스 모델로 승격시키지 마세요. 각 통로를 클라이언트 측에서
{ key, name, emoji }형태로 모델링하세요: key는 항목에 저장되는 불변의 문자열(Claude의 어휘)이며, name과 emoji는 편집 가능한 표시용입니다. 사용자의 정렬된 목록은 프로필의a.json()필드 하나에 저장하세요. 표준 통로들로 초기 데이터를 구성(Seed)하고, LLM이 만들어내는 모든 카테고리를 **자동으로 추가(auto-add)**하여 어떤 것도 정렬 불가능한 상태가 되지 않도록 하세요. 이름 변경은 표시용 편집이 되며, 항목을 다시 쓰거나 마이그레이션(migration)할 필요가 없습니다.
(제가 공개적으로 개발 중인 음성 우선 쇼핑 앱인 Building CannyCart의 9부입니다. 5부의 맥락: Claude가 음성을 구조화된 항목으로 변환하며, 각 항목은 자유 형식의 category를 가집니다. 그 외에는 독립적인 내용입니다.)
세 가지 옵션, 솔직한 검토
옵션 A — 항목으로부터 통로(aisle)를 도출하기. 저장 공간이 전혀 필요 없습니다. 통로 목록은 현재 목록에 있는 고유한 카테고리일 뿐입니다. 스키마가 필요 없고 항상 정확하지만, 불안정합니다(unstable). 마지막 유제품(Dairy) 항목을 체크 해제하면 유제품 통로가 사라지며, 정성스럽게 배치한 순서 속의 위치도 함께 사라집니다. 내일 항목을 하나 추가하면 다시 나타나지만... 맨 아래에 나타납니다. 존재했다가 사라졌다를 반복하는 것에 사용자의 선호도를 매달아 둘 수는 없습니다.
옵션 B — 고정된 세트를 하드코딩하기. 파싱용 Lambda의 프롬프트에는 이미 표준 식료품 통로(Produce, Bakery, Dairy, Meat, Frozen, Pantry, Drinks, Household)가 명시되어 있으므로, 이를 편집 가능한 목록으로 만들기만 하면 됩니다. 안정적이고 간단합니다 — Claude가 LLM 특유의 행동을 하기 전까지는 말이죠. 만약 "새 노트북과 고양이 사료"라고 입력하면 항목들이 Electronics와 Pet에 배치될 것입니다. 이는 고정된 목록이 들어본 적 없는 카테고리이며, 이제 영원히 정렬되지 못한 채 맨 아래에 갇히게 됩니다.
옵션 C — 일급 객체인 ShoppingCategory 모델.
// UserProfile
aisleOrder: a.json(), // 정렬된 [{key,name,emoji}] — 사용자별 통로(aisle) 표시 순서
Lambda의 프롬프트가 사용하는 것과 동일한 어휘(vocabulary)로부터 시드(Seeded)되었으므로, Claude의 출력은 통로에 바로 매핑됩니다: 🥬 Produce · 🥖 Bakery · 🥛 Dairy · 🥩 Meat · 🧊 Frozen · 🥫 Pantry · 🥤 Drinks · 🧽 Household · 🛒 Other.
그리고 여기서 key/name 분리가 제값을 하는 이유가 있습니다. 사용자가 Produce를 "Fruit & Veg"로 이름을 변경하더라도, 아이템들은 여전히 category: "Produce"라고 표시되며, Claude는 계속해서 "Produce"를 출력하고, 섹션 헤더는 _name_을 렌더링합니다. 그 외의 어떤 것도 움직이지 않습니다 — 아이템 재작성도, LLM에 대한 재프롬프팅(re-prompting)도, 마이그레이션(migration)도 필요 없습니다. 정체성(Identity)과 표시(display)가 분리되었기 때문에, 표시를 수정하는 데 비용이 전혀 들지 않습니다.
아이콘이 아닌 이모지를 사용하는 이유
글리프(glyph) 선택은 미적인 요소처럼 보이지만, 빌드 타임(build-time) 제약 조건에 의해 강제되었습니다. 이 앱의 아이콘 라이브러리(react-native-iconify v1 — Part 3에서 버전 고정(version pin)에 대해 설명함)는 Babel 플러그인을 통해 작동하며, JSX를 스캔하여 정적 리터럴(static literal) 아이콘 이름을 찾아 빌드 타임에 각 SVG를 인라인(inline)화합니다. 통로별로 사용자가 선택한 아이콘은 정의상 변수(variable)이며, 해당 파이프라인이 해결할 수 없는 유일한 요소입니다.
이모지는 단순한 텍스트입니다. 완전히 동적이며, 레지스트리(registry)가 필요 없고, 기본적으로 다채로우며, 섹션 헤더와 설정 행에서 동일하게 렌더링됩니다. 개방형 이름→이모지 조회(lookup)를 통해 자동으로 추가된 카테고리에 합리적인 글리프를 부여할 수 있으며, 폴백(fallback)도 제공합니다:
const EMOJI_MAP: Record<string, string> = {
produce: "🥬", bakery: "🥖", dairy: "🥛", meat: "🥩", frozen: "🧊",
snacks: "🍿", coffee: "☕", pet: "🐾", electronics: "💻", // … 약 40개 항목
...
자동 추가(Auto-add): 무엇이든 정렬 불가능한 것은 없다
디자인의 오픈 월드(open-world) 측면입니다. 아이템이 사용자의 목록에 아직 없는 카테고리를 가지고 있을 때마다, 해당 카테고리는 추가됩니다. 이름은 기본적으로 key를 따르며, 이모지는 조회(lookup)를 통해 가져옵니다:
export function withUnlistedAisles(saved: Aisle[], seenKeys: string[]): Aisle[] {
const have = new Set(saved.map((a) => a.key.toLowerCase()));
const extra = dedupe(seenKeys)
...
목록 화면의 작은 이펙트(effect)가 이를 감시하고 병합된 목록을 저장합니다. 실무적으로 중요한 가드(guard)가 하나 있었는데, 이미 시도된 키들을 세션 범위의 ref(session-scoped ref)로 관리하여 저장에 실패하더라도 이펙트가 루프(loop)를 돌며 재발생하지 않도록 하는 것이었습니다:
// Claude가 생성했지만 아직 aisle 목록에 없는 모든 카테고리를 자동으로 추가하여,
// 영구적으로 마지막에 고정되는 대신 순서 변경이나 이름 변경이 가능하도록 합니다.
// (저장된 목록에 해당 카테고리가 포함되면서 수렴하게 됩니다.) 세션 ref는
// 저장 실패 시 루프가 반복되는 것을 방지합니다.
...
따라서 Claude가 Electronics를 만들어내면, 그것은 영원히 이류(second-class) 취급을 받는 문자열로 남는 대신, aisle 에디터의 끝에 💻 이모지와 함께 나타나며 순서를 바꾸거나 이름을 변경할 수 있게 됩니다.
현재 상태 (How it's holding up)
- 에디터는 Part 8의 탭하여 순서 변경(tap-to-reorder) 메뉴를 재사용합니다 — 드래그 라이브러리 없이 맨 위로/위로/아래로/맨 아래로 이동 기능을 제공합니다. 이 지루한 패턴은 배포 후 일주일 만에 재사용되었는데, 이는 어떤 패턴이 받을 수 있는 최고의 찬사입니다.
- 저장은 낙관적(optimistic)으로 이루어집니다 (React Query의
onMutate/rollback 사용). 따라서 프로필 업데이트를 위해 왕복(round-trip) 과정이 필요함에도 불구하고 순서 변경이 즉각적으로 느껴집니다. - AWSJSON이 또 한 번 문제를 일으켰습니다: 저장된 JSON은 레이어(layer)에 따라 단일 인코딩 또는 이중 인코딩되어 돌아올 수 있습니다. 따라서 읽기 단계에서 Part 5에서 사용했던 것과 동일한
while (typeof v === "string") v = JSON.parse(v)루프를 사용하여 언랩(unwrap)합니다. 또한, 이 필드는 신뢰할 수 있는 API 응답이 아니라 사용자 접점 데이터(user-adjacent data)이므로 각 항목마다 형태 검사(shape check)를 수행합니다.

언제 실제 모델로 승격시켜야 하는가
JSON 선호도(JSON-preference) 방식은 카테고리가 **사용자별로 존재하며, 규모가 작고, 표시 전용(display-only)**이라고 가정합니다. 만약 다음 중 하나라도 해당된다면 일급 모델(first-class model)로 승격시키십시오. 사용자 간 또는 리스트 간에 공유되는 카테고리, 점차 늘어나는 카테고리별 메타데이터(통로별 예산, 매장 위치 등), 또는 카테고리 수준의 소프트 삭제(soft-delete)/감사(audit) 요구사항이 발생하는 경우입니다. 이것들은 엔티티(entity) 문제이며 엔티티로서의 취급을 받을 가치가 있습니다. 수십 개의 표시 선호도는 엔티티가 아닙니다. 또한 단일 JSON 필드는 해당 기능을 삭제할 때 데이터 마이그레이션 없이 컬럼 하나만 삭제하면 된다는 장점이 있습니다.
다음 단계
이제 그 카테고리들이 제 역할을 할 차례입니다. 다음 기능은 사용자의 쇼핑 내역을 분석하여 가장 많이 구매하는 품목을 추출하는 것인데, 이는 DynamoDB의 가장 유명한 결여된 기능과 정면으로 충돌합니다. 바로 GROUP BY를 사용할 수 없다는 점입니다. 대신에 수행해야 할 작업은 Post 10에 나와 있습니다.
"이것은 실제 테이블로 만들 가치가 있다"라고 판단하는 당신만의 기준은 무엇인가요? 저는 모델이 되려던 것들의 절반이 사실은 트렌치코트를 입고 있는 선호도(preferences)일 뿐이라는 사실을 계속 발견하곤 합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기