← 목록으로

2조 토큰을 카테고리 분류에 쓰면서 알게된 것들

2조 토큰을 카테고리 분류에 쓰면서 알게된 것들

요약

카테고리 분류에 2조 토큰을 쓰면서 알게 된 것들

안녕하세요. 당근 Taxonomy 팀 윈터(winter.jung), 지원(jiwon)이에요. 저희 팀은 택소노미(Taxonomy)라고 부르는 카테고리 체계를 만들고, 그 체계를 기반으로 중고거래, 모임 게시글 등 당근에 올라오는 게시글을 자동으로 분류해 실제 서비스가 사용하도록 적재하는 파이프라인을 운영하고 있어요.

이번 글에서는 프로덕션 파이프라인에서 카테고리 분류를 위해 LLM을 어떻게 쓰고 있는지, 그리고 성능, 비용, 운영 측면에서 잘 사용하기 위해 어떤 고민과 시도를 했는지 이야기해보려고 해요.

택소노미가 뭔가요?

택소노미라는 단어가 좀 생소하실 수 있는데, 사실 온라인 마트나 서점, 쇼핑몰의 카테고리를 생각해보시면 바로 이해가 되실 거예요. 택소노미는 트리 구조로 된 카테고리 체계를 뜻해요. 어떤 데이터가 어떤 종류인지를 일관된 규칙으로 표현하기 위한 분류 체계이고, 상황에 따라 카탈로그라고도 불러요. 이 글에서는 용어를 택소노미로 통일할게요.

예를 들어 “아우터 > 패딩/다운 > 롱패딩”처럼 위에서 아래로 내려가며 더 구체적인 의미를 담을 수 있고, 같은 아이템이라도 서비스의 필요에 따라 “겨울 아우터 > 출퇴근용”처럼 다른 위계를 담아볼 수 있어요.

택소노미에는 카테고리뿐만 아니라 속성도 담을 수 있어요. 카테고리가 이 상품이 무슨 종류인지를 표현하는 명사라면, 속성은 이 상품이 어떤 특징을 갖는지를 표현하는 형용사에 가까워요. 예를 들어 같은 “롱패딩”이라도 브랜드=나이키, 색상=블랙, 재질=폴리에스터 같은 속성으로 더 구체적으로 설명할 수 있어요.

당근 내부 중고거래 카테고리 예시

택소노미가 왜 중요한가요?

택소노미가 있으면 같은 종류의 아이템을 통일된 방식으로 다룰 수 있어요. 예를 들어 “스마트폰”을 서비스마다 “휴대폰”, “모바일기기”, “핸드폰” 제각각 취급하는게 아니라 {"id": "electronic_smartphone", "name": "전자기기 > 스마트폰"}처럼 일관된 방식으로 사용할 수 있어요. 이게 생각보다 서비스 전반에 큰 영향을 줘요.

  • 검색은 카테고리를 기준으로 결과를 좁히거나 확장할 수 있어요. 예를 들어 스마트폰을 검색했다면 아이폰, 갤럭시 같은 하위 카테고리를 포함한 검색이 가능해져요.
  • 추천은 비슷한 카테고리의 아이템을 묶어서 학습하거나, 반대로 같은 카테고리만 연속으로 나오지 않게 다양성을 설계할 수 있어요.
  • 광고나 타겟팅은 아이템, 콘텐츠의 카테고리를 기반으로 더 정교한 세그먼트를 만들 수 있어요.
  • 데이터 분석이나 피처 엔지니어링에서도 카테고리와 속성은 매우 재사용성이 높은 공통 피처가 돼요.

이렇게 택소노미는 검색, 추천, 광고, 분석, ML 모델까지 여러 팀이 함께 쓰는 공통 언어 역할을 해요.

당근은 어디서 카테고리를 쓰고있나요?

당근에서 카테고리는 생각보다 다양한 곳에서 쓰여요. 대표적으로는 중고거래 게시글이 있고, 모임을 만들 때 어떤 타입의 모임인지를 표현하는 체계도 있고, 동네지도에 노출되는 업체에도 카테고리가 필요해요.

특히 당근의 핵심인 중고거래 영역에서는 내부적으로 최대 3 depth로 나뉜 약 1,400개 수준의 카테고리를 관리하고 있어요. 다만 실제로 당근 앱을 사용하는 사용자 입장에서는 보통 앱 화면에서 표면적으로 보이는 카테고리나, 추천 칩 같은 형태로만 카테고리를 만나보셨을 거예요.

(좌) 앱에서 보이는 중고거래 카테고리 (우) 홈 피드 상단에서 볼 수 있는 추천 칩

택소노미 관리는 그동안 주로 사람이 해왔어요

전통적으로 택소노미 관리는 휴먼 리소스가 많이 필요한 영역이었어요.

  1. 정의 자체가 도메인 전문가의 영역이에요. 어떤 카테고리를 만들지, 서로 어떻게 구분할지, 어디에 속하게 할지 같은 문제는 정답이 있는 문제라기보다 도메인과 제품의 맥락을 아는 사람이 결정하는 경우가 많아요.
  2. ML로 분류하려면 라벨 데이터가 필요해요. 모델을 학습시키려면 결국 사람이 라벨링한 데이터가 필요하고, 라벨링을 하려면 기준이 필요하고, 이 기준을 유지보수하려면 또 관리 비용이 들어요.
  3. 거버넌스와 모니터링이 필요해요. 택소노미 자체의 품질도 모니터링해야 하고, 분류 결과가 이상해졌을 때 대응하는 프로세스도 필요해요. 택소노미가 바뀌면 번역, 영향 범위 확인, 관계부서 합의, 모델 재학습 같은 연쇄 작업이 생기기도 해요.

당근은 그동안 어떻게 분류해왔나요?

하지만 이 글을 읽으시는 당근 사용자 분들은 “나는 중고거래 게시글 올릴 때 카테고리를 그렇게 많이 고르지 않았는데?”라는 생각이 드실 거예요. 실제로 사용자가 직접 3-depth까지 세밀하게 선택하도록 UI/UX를 구성하기엔 게시글 작성에 허들로 작용하기도 하고, 사용자 입력만으로 신뢰도 높은 분류를 얻기도 어려워요.

그래서 지금까지 당근에서는 게시글 작성 시 발생하는 Kafka 이벤트를 받아 LLM으로 카테고리를 추출해 그 결과를 적재하는 Golang 기반 컨슈머 애플리케이션을 운영하며, 사용자 입력에 전적으로 의존하기보다는 시스템이 추가로 분류해 데이터를 증강하는 방향을 시도해왔어요.

다만 기존 방식엔 제품적, 기술적 아쉬움이 존재했어요.

  • 카테고리 정의를 한 곳에서 관리하는게 아닌 필요에 따라 각자 팀에서 정의하고 추출하는 방식이라 어느 한 곳의 노하우와 개선이 전파되기 어려웠어요.
  • 카테고리만으로 표현력이 부족했어요. 봄 시즌 옷, 따뜻한 재질 같은 속성을 정의하고 활용하고 싶었어요.
  • 이벤트 스트림만 지원하느라 대용량 배치 처리를 제대로 지원하지 못했고, 장애 복구 시 백필도 어려운 구조였어요. 또 앞으로 택소노미 적용 범위를 넓히기엔 그 데이터 소스들이 늘 Kafka 이벤트로 존재하는 건 아니었어요.
  • 품질 관리가 부족했어요. 중요한 피처인데도 현재 뽑은 카테고리가 정말 잘 뽑힌 건지 검증하는 체계가 부족했고, 파이프라인의 불안정성이나 장애 전파 구조도 고민거리였어요.
  • 프롬프트, 모델 변경의 효과와 부작용을 빠르게 검증하기 어려웠어요. 별도 검증 체계가 없었기에 그 변화를 알기 위해서는 오프라인, 온라인 실험까지 가야만 했는데, 그 속도가 더 빨라질 필요가 있었어요.

그래서 Taxonomy Management System을 만들었어요

Taxonomy Management System은 택소노미를 관리하고, 다양한 데이터 소스에 대해 LLM 기반 분류를 안정적으로 실행하고, 카테고리와 속성을 신뢰성 있게 제공하며, 그 품질을 지속적으로 모니터링하는 파이프라인이에요. 구현과 운영의 중심에는 LLM이 있고, 실행 인프라는 Dataflow(Beam)를 선택했어요.

왜 Dataflow(Beam)을 사용했나요?

  • 대용량 데이터를 병렬로 추론할 수 있어야 했어요. LLM 호출 자체가 초 단위의 응답 시간이 걸리기에 높은 throughput이 필요했어요.
  • 하나의 로직으로 스트림 처리와 대규모 배치 처리를 모두 지원하고 싶었어요.
  • Spark나 Flink 같은 다른 선택지도 있었지만, 이미 다른 동일 조합의 파이프라인을 팀 내에서 운영하며 경험치가 있었기에 Dataflow + Beam 구조가 현실적인 선택이었어요.

추론된 결과의 Source of Truth는 BigQuery로 잡았어요

별도의 서빙 서버를 두기보다는 분류 결과를 BigQuery에 쌓아서 누구나 쿼리 가능한 원천 데이터로 제공했어요. 오프라인 활용(분석, 실험, 학습)은 BigQuery로 충분히 커버할 수 있고, 온라인에서 필요한 경우에는 Kafka sink를 통해 사내 피쳐플랫폼에 적재하는 방식으로 연결했어요.

다양한 택소노미, 파이프라인을 대응하기 위한 구조

택소노미는 하나만 있는 게 아니고, 중고거래나 모임 등 서비스마다 형태가 달라질 수 있어요. 그래서 시스템은 특정 케이스에 고정되지 않게 설계했어요.

  • 택소노미 정의는 YAML로 관리해요.
- id: elap
name: Electronics & Appliances
children:
- id: elap-1
name: Laptops
children:
- id: elap-1-1
name: LG Gram
- id: elap-1-2
name: Apple MacBook
attributes:
- color
- brand
- id: elap-2
name: Mobile Phones
children:
- id: elap-2-1
name: iPhones
- id: elap-2-2
name: Galaxy Phones
  • YAML config로 파이프라인 설정을 관리해요.
taxonomy:
content_type: fleamarket
taxonomy_name: karrot_category

dataflow_worker:
min_num_workers: 1
max_num_workers: 10
machine_type: n1-highmem-8

kafka_source:
topic: entities.fleamarket_article

kafka_sink:
topic: entities.taxonomy_result

bigquery_sink:
destination: production-data.taxonomy_management_system.results
  • 여러 LLM 옵션을 YAML 설정으로 표현할 수 있게 해요.
llm:
primary_model: gemini-2.5-flash
eval_models:
- gpt-5-mini
- gemini-3-flash
- claude-sonnet-4-5
categorization: SINGLE_SHOT
attribute_assignment: ALL_AT_ONCE
evaluation: TWO_STAGE
eval_sample_ratio: 0.01
  • 글로벌 다국어 지원이 가능한 구조로 택소노미 체계를 구성했어요. 특히 번역 과정은 규모가 커지면 바로 병목이 돼요. 1만 개가 넘는 카테고리의 다국어 데이터를 마련하기 위해 여기서도 LLM을 사용했는데, 데이터를 청크별로 잘게 쪼개 LLM에게 번역시킨 뒤, 그 번역 결과를 다시 새로운 컨텍스트의 LLM에게 일관성있고 자연스러운 표현인지 검증하는 방식을 사용했어요. 택소노미는 트리 구조라는 특성이 있기에 상위 depth에서 사용한 표현을 하위 depth에서도 그 일관성을 맞추기 위해 DFS 방식으로 진행하며 상위 카테고리의 번역 데이터를 컨텍스트로 첨부해주었어요.

택소노미를 어떻게 새로 만들고 확장했는지

택소노미를 처음부터 사람 손으로 만들기엔 도메인 지식도 필요하고 그 규모가 100개, 1,000개를 넘으면 정의도 어렵고 확장도 어렵기에 여기에도 LLM을 적극적으로 이용했어요.

  • 신규 택소노미를 생성할 땐 기존에 well-known 택소노미가 존재하는지 딥 리서치를 이용해 참고 자료를 마련하고, 이를 참고해 실제 데이터를 기반으로 가상의 카테고리를 생성해가며 택소노미 트리를 구성해나가요.
  • 기존 택소노미를 확장시킬 땐 샘플링된 데이터를 기존 택소노미로 분류를 시도해보고, 부적합 한 경우 신규 카테고리를 제안시켜요. LLM과 유사도로 비슷한 카테고리는 병합시킨 뒤 일정 기준 이상의 카테고리들은 신규 카테고리 후보로 승격시켜요.
  • 승격된 신규 카테고리 후보는 1. 아까 신규 카테고리를 제안한 샘플 데이터들이 실제로 신규 카테고리에 할당되는지 2. regression 평가로 예전 택소노미와 신규 택소노미로 분류가 갈린 데이터를 LLM에게 주고 예전께 더 낫다고 하는 경우가 적은지 두 단계에 걸쳐 심사한 뒤 정식 택소노미로 편입시켜요.

이런 방식으로 기존에 사용하던 3-depth의 1,400개 택소노미 외에도 6-depth 이상의 10,000개 규모의 택소노미도 새롭게 정의할 수 있었어요.

카테고리와 속성은 LLM이 어떻게 추론하나요?

파이프라인 추론 Flow

카테고리는 그 개수가 10,000개 이상 많아질 수 있기에 추론 정확도가 중요하고 그만큼 다양한 프롬프트 시도를 통해 높은 정확도를 이뤄내야 해요. 택소노미 파이프라인을 처음 구축할 땐 직관적으로 떠올릴 수 있는 아래 방식으로 먼저 시도했어요.

  1. 단일 단계 방식 Single Shot: 모든 카테고리를 주고 고르게 하기
  2. 계층적 방식 Hierarchical: 택소노미는 트리구조로 카테고리마다 계층이 있기에, 그 계층(depth)마다 가장 그럴듯한 답을 고르게 하고, 선택된 카테고리의 하위 계층에 대해 다시 고르게 하기.
  3. 토너먼트 방식 Two-stage: 전체 카테고리 리스트를 N개의 청크로 나눠 각 청크마다 후보를 고르게 하고, 선택된 후보군을 다시 토너먼트 형식으로 고르게 해서 최종 1개 고르기

LLM 모델 마다, 택소노미 규모마다 적절한 방식이 따로 있기에 다양한 프롬프트 엔지니어링 옵션을 마련해두고, 손쉽게 다양한 옵션을 시도해볼 수 있도록 아래처럼 파이프라인의 DoFn 컴포넌트 형태로 잘 모듈화 해 Article → Category 추론 → Attribute 추론의 흐름으로 사용하도록 구성했어요. 새로운 방식이 필요한 경우 새로운 Strategy를 구현하여 파이프라인에 갈아 끼워요. 예를 들어 two stage 방식의 카테고리 추출 방식이 필요하다면 이 로직을 담은 새로운 DoFn을 구현하여 (e.g. TwoStageStrategy) categorization stage의 SingleShotStrategy를 대체할 수 있어요.

# Categorization stage
class SingleShotStrategy(DoFn):
def process(self, element: Article) -> Iterator[Tuple[Article, Category]]

# Attribute assignment stage
class AllAtOnceStrategy(DoFn):
def process(
self, element: Tuple[Article, Category]
) -> Iterator[Tuple[Article, Category, List[AttributeValue]]]:

평가는 어떻게 했나요? → LLM as a Judge

분류를 아무리 많이 해도, 결국 그 분류가 적절했는지 정확도를 알 수 있어야 해요. 특히 프로덕션에서 운영하며 모델, 프롬프트, LLM 옵션을 계속해서 튜닝하다 보면 이 변경이 부작용은 없는지, 성능을 더 낫게 만들었는지를 빠르게, 반복적으로 판단할 수 있어야 해요. 그래서 저희는 똑똑한 모델들끼리 합의해 정답을 정하는 LLM as a Judge 방식의 평가를 적극적으로 사용했어요.

  • 일정 비율의 샘플을 서로 다른 N개의 모델로 돌려서 N개의 label을 얻어요.
  • 그 N개의 label로 Ground-Truth label을 형성해요.
  • 이 Ground-Truth를 기준으로 각 모델의 정확도를 계산하고 모니터링해요.
  • LLM 모델, 프롬프트, 파이프라인 구조 등을 변경할 때 마다 이 정확도의 변화를 비교하고 더 나빠지진 않는지, 더 좋아지는지를 판단해요.

Ground Truth는 어떻게 만드나요?

Ground truth를 만드는 방식은 majority voting을 사용했어요. 상황에 따라 plurality voting이나 weighted voting 같은 선택지도 가능하지만, 저희는 직관적이고 가장 구현이 간단한 방식을 선택했어요.

다만 평가 대상이 카테고리인지, 속성인지, 카테고리를 여러 개 뽑는 상황인지에 따라 ground truth를 만드는 방식과 형태가 달라져요.

품질 지표는 어떻게 계산하나요?

Ground truth를 만들었다면 이를 활용해 여러 카테고리, 속성 품질 지표를 계산할 수 있어요.

Category Accuracy

카테고리 정확도는 일종의 precision@1 지표로 Primary model이 추론한 카테고리와 ground truth 카테고리가 일치하는 비율이에요.

Category Accuracy = 1/3 = 33.3%

Attribute Precision / Recall

속성은 한 게시글에 대해 여러 쌍이 정답이 될 수 있는 multi-label 문제예요. 그래서 전체가 맞았는지만 보는 accuracy 대신 precision과 recall을 사용해요.

Precision은 primary model이 추론한 (attribute, value) pair 중 실제로 맞은 비율이에요.

Precision(article) = |Pred ∩ GT| / |Pred|

Recall은 반대로, ground truth pair 중 primary model이 찾아낸 비율이에요.

Recall(article) = |Pred ∩ GT| / |GT|
Attribute Precision Avg = (50 + 0 + 100) / 3 = 50%, Recall Avg = (33.3 + 0 + 50) / 3 = 27.7%

이렇게 개별 게시글마다 precision과 recall을 구한 뒤, 전체 게시글의 평균을 내서 최종 지표로 사용해요. Primary model이 정답 set을 얼마나 정확하게 맞추는지, 얼마나 빠짐없이 포괄하는지를 나눠서 볼 수 있어요.

평가 체계의 검증

LLM as a Judge 방식으로 만든 평가 체계가 진짜로 유효한지에 대한 검증도 중요해요. 모델들끼리의 합의가 곧 정답이라고 확신할 수는 없으니까요. 예를 들어 소규모로 human-labeled goldset 샘플을 만들고, LLM judge와의 상관관계를 확인하는 방식으로 평가 체계를 점검할 수 있어요. 또 top-1 정확도뿐 아니라 depth별, 카테고리별 정확도나 일관성 지표를 같이 보면 어떤 방향으로 틀리는지 더 잘 파악할 수 있어요.

만들고 나면 끝이 아닌 시작, 운영은 어떻게 했나요?

이렇게 Taxonomy Management System을 만들었지만 사실 만들었다 자체보단 그 이후 프로덕션 환경에서 운영해오며 얻은 교훈이 더 많았어요.

카테고리를 얼마나 잘 맞추는지도 중요하지만, 하루 수십만 건씩 올라오는 게시글을 꾸준히 분류해야 하다 보니 합리적인 비용으로 운영하는 것도 그만큼 중요했어요. 결국 합리적인 정확도 안에서 비용을 최소화하는 지점을 찾아야 했고, 그 지점을 찾기 위해 프롬프트, 모델, 분류 전략 조합을 반복해서 실험하면서 비용과 정확도의 sweet spot을 찾아갔어요.

정확도를 올리기 위한 프롬프트 엔지니어링

초기에는 텍스트만으로 분류하던 프롬프트에 이미지를 추가했어요. 중고거래 게시글은 텍스트만으로는 상품 특성이 잘 드러나지 않는 경우가 많아요. 사진을 같이 넣어주면 LLM이 실제로 무엇을 파는지 더 잘 파악할 수 있고, 그게 정확도로 이어졌어요.

다만 프롬프트를 손보면 항상 정확도가 오르는 건 아니었어요. 예를 들어 기존에는 카테고리명만 LLM에게 넘기고 있었는데, 카테고리 경로 정보인 breadcrumb까지 같이 넣어 정확도를 올려보려 했던 적이 있어요. 직관적으로는 상위 문맥이 붙으면 좋아질 것 같았는데, 실제로는 성능 변화가 미미했어요. 그 외에도 Few-shot 프롬프트나, 세부적인 Insturction 튜닝은 그 수고와 비용에 비해 결과 변화가 없거나 오히려 낮아지는 경우도 빈번했어요.

비용을 줄이기 위한 프롬프트 개선도 했어요

정확도를 올리는 시도만큼이나 비용을 줄이기 위한 프롬프트 개선도 중요했어요. LLM API의 프롬프트 캐싱을 활용하려면 프롬프트 구조 자체를 캐시 친화적으로 바꿔야 하거든요.

그래서 게시글 내용처럼 매 요청마다 바뀌는 정보는 프롬프트 뒤쪽에 두고, instruction과 카테고리 목록처럼 상대적으로 고정된 정보는 앞쪽에 배치했어요. 이렇게 하면 정적인 부분의 캐시 히트율이 올라가고 전체 비용을 줄일 수 있었어요.

모델 변경 실험도 병행했어요

프롬프트 개선과 함께 모델 자체를 바꿔보는 실험도 했어요. 더 좋은 모델을 쓸수록 정확도가 올라가는 경향은 항상은 아니어도 어느정도 존재했어요. 하지만 분류량이 많다보니 장기적인 운영 관점에서는 모델 단가를 무시할 수 없었어요. 정확도는 조금 올라가는 반면 비용이 크게 뛰어버리면 그 모델을 프로덕션의 주 모델로 사용할 순 없었어요.

우측 하단일수록 Sweet Spot

여기까지의 결론은 분명했어요

단순 프롬프트 개선과 모델 변경만으로는 정확도를 향상과 낮은 비용 유지 두 목적 모두 달성하기는 점점 어려워졌어요. 어느 순간부터는 프롬프트를 다듬더라도 정확도 향상이 미미해지는 시점이 오고, 더 좋은 모델로 정확도를 올리면 비용이 따라서 오르는 형태였어요.

그래서 시선을 바꿨어요. 지금까지는 어떻게 LLM이 주어진 카테고리 중 최적을 뽑게 할지를 고민했다면, 이제는 애초에 어떤 카테고리를 LLM에게 쥐어줄지를 설계하는 방향으로 접근했어요.

분류 전략 개선

초기 전략들: Single Shot, Hierarchical, Two-Stage

앞서 소개한 세 가지 전략을 다시 운영 관점에서 짚어볼게요.

Single shot은 가장 먼저 시도한 방식이에요. 전체 카테고리를 LLM에게 한 번에 보여주고 고르게 해요. 구현이 단순하고 LLM 호출이 1회로 끝나요. 카테고리 목록이 매번 동일하기 때문에 프롬프트 캐싱에도 유리해요. 하지만 카테고리가 수백 개를 넘어가면 긴 목록 속에서 정확도가 떨어졌어요.

Hierarchical은 택소노미가 트리 구조라는 점을 활용한 방식이에요. 최상위 카테고리부터 시작해 한 레벨씩 내려가며 하나를 골라요. 매 단계에서 해당 레벨의 카테고리만 보여주기 때문에 토큰 사용량은 크게 줄어들어요. 하지만 처음에 잘못된 경로를 선택하면 하위에서 복구할 수 없다는 구조적 한계가 있었어요.

Two-stage는 한 번에 보내면 정확도가 떨어지니 N개로 나누어 보내자는 토너먼트 방식이에요. 예를 들어 1,500개 카테고리를 300개씩 나누면 5개의 청크가 되고, 각각 LLM을 호출해 후보를 선별한 뒤 최종 라운드에서 하나를 고르는 거예요. Single shot에 비해 정확도가 올라갔지만, 전체 카테고리를 다 커버하고 최종 선택까지 해야 하므로 토큰 사용량이 많았어요.

Single Shot, Hierarchical, Two-stage 방식의 추론 Flow

이 초기 전략들의 공통적인 한계는, 결국 전체 카테고리를 LLM이 한 번은 봐야 하거나 매 레벨마다 봐야 한다는 점이었어요. 카테고리 규모가 100개를 넘어 1,000개 이상으로 커질수록 비용과 정확도 문제가 심화되었어요.

1,000개 규모에서 동작하는 전략

그래서 애초에 LLM에게 카테고리를 보여주기 전에 그 후보를 미리 줄이는 방법으로 임베딩을 활용한 전략을 만들었어요.

Embedding Two-stage는 LLM을 호출하기 전에 게시글과 카테고리의 임베딩 유사도를 계산해서 상위 n%의 카테고리만 남긴 뒤 토너먼트를 진행하는 방식이에요. 임베딩 연산은 LLM 호출에 비해 저렴하기 때문에, 카테고리 대부분을 사전에 걸러낸다는 것만으로도 전체 비용을 크게 줄일 수 있었어요.

여기서 한 걸음 더 나아간 게 Embedding Summary 방식이에요. 중고거래 게시글에는 분류와 무관한 표현들이 많이 섞여 있어 raw 게시글을 그대로 임베딩하면 그 성능이 떨어질 수 있어요. 그래서 LLM으로 게시글을 한 줄로 요약한 뒤, 그 요약문으로 임베딩 유사도를 비교하도록 했어요. 예를 들어 {"title": “노스페이스 화이트라벨 미니백", “content": "노스페이스 화이트라벨 심플미니백 블랙 색상입니다. 핸드폰 넣고 가볍고 휴대하기 좋은 사이즈예요. 간단한 소지품 보관에 용이합니다. 거의 새것 상태입니다"} 같은 게시글이 있다면, 이를 "unisex_northface_mini_bag_black" 같은 핵심 표현으로 요약한 뒤 임베딩을 만드는 거예요. LLM 호출이 1회 추가되지만, 노이즈가 제거된 요약문이 더 정확한 후보 필터링을 만들어냈어요. 기존보다 카테고리 후보군을 더 적게 남기더라도 좋은 성능을 보여줬기 때문에, Embedding Two-stage보다도 적은 비용으로 운영할 수 있었어요.

10,000개 카테고리에서의 개선

하지만 이것도 만 개 규모의 택소노미로 확장되니 문제의 성격이 달라졌어요. Hierarchical로는 정확도가 안 나오고, Two-stage로도 카테고리가 너무 많아 비용이 과하게 비쌌어요. Embedding 방식을 적용해 10%만 남겨도 필터링 후에 남는 카테고리 후보군 자체의 절대적인 크기가 크기 때문에 더 적합한 전략이 필요했어요.

이 규모에 맞게 설계한 첫 번째 전략이 DFS Two-stage 전략이에요. 각 레벨에서 하나만 고르는 Hierarchical 방식과 달리, 각 레벨마다 여러개의 그럴듯한 카테고리를 선택하며 Leaf depth까지 탐색해요. 탐색이 끝나면 방문한 후보 중에서 최종 선택을 해요. Two-stage 대비 비용이 절감되고, Hierarchical 대비 정확도가 개선되었어요. 하지만 depth가 깊어질수록 레벨마다 LLM 호출이 발생하고 토큰이 누적되는 문제가 있었어요.

그래서 1,000개 규모에서와 같은 접근으로, 어떻게 하면 LLM에게 전달하는 카테고리 후보를 줄일 지 고민했고 Semantic Search Without Embeddings 블로그 글에서 힌트를 얻어 검색할 때 처럼 BM25 키워드 매칭을 활용해 카테고리를 사전 필터링할 수 있지 않을까 가설을 세웠어요.

게시글에서 키워드와 동의어를 한 번에 추출하고, 이를 검색어로 사용해 카테고리 목록의 BM25 스코어를 구한 뒤 높은 점수의 카테고리 N개를 LLM에게 최종 선택하라고 하는 방식이에요. 예를 들어 “크리스마스 트리 풀세트”라는 게시글이라면 [christmas, tree, decorations, holiday] 같은 키워드와 [xmas, ornaments, baubles, seasonal] 같은 동의어를 추출해서 검색하는 거예요.

하지만 BM25만으로는 Recall 성능이 좋게 나오지 않았어요. 키워드가 정확히 일치하지 않는 경우를 놓치기 쉬웠거든요. 이를 보완하기 위해 앞서 소개한 요약문 기반 임베딩 유사도 검색을 결합한 하이브리드 방식을 도입했어요. BM25는 키워드 매칭에 강하고, 임베딩은 의미적 유사성에 강해요. 둘을 결합하면 서로를 보완할 수 있어요. 구체적으로는 LLM이 게시글에서 요약문과 키워드, 동의어를 한 번에 추출하고, 키워드와 동의어로는 BM25 스코어를, 요약문으로는 임베딩 유사도를 각각 계산한 뒤 Reciprocal Rank Fusion으로 두 랭킹을 융합해 최종 후보를 선정해요.

이렇게 DFS 수준의 성능을 유지하면서 LLM 호출 횟수 자체를 줄이고, 사전 필터링을 통해 토큰 사용량도 줄일 수 있었어요.

시도했으나 채택하지 않은 전략

여기서는 효과적이었던 전략만 소개했지만 사실 그 과정에서 여러 시행착오가 많았어요.

키워드 룰셋 기반 방식은 과거 정답 데이터를 기반으로 키워드와 카테고리 매핑을 만들어서, 명확한 케이스는 LLM 없이 처리하려는 시도였어요. 하지만 실제 데이터는 롱테일 분포를 따르기 때문에 규칙으로 커버할 수 있는 범위가 좁아 들이는 노력 대비 이득이 크지 않았어요. 또 “아이패드 케이스”가 “iPad”가 아니라 “태블릿 케이스”로 분류되어야 하는 것처럼, 그 룰셋이 쉽게 복잡해지고 사람 혹은 LLM을 이용한 고도화가 필요했기에 이 방향으로는 더 투자하지 않았어요.

Agentic grep은 기존 전략들과는 꽤 다른 접근이었어요. 기존 전략들이 시스템이 카테고리를 보여주고 LLM이 고르는 방식이라면, 이 전략은 LLM에게 전체 카테고리를 보여주는 대신 grep 도구를 쥐어주고 스스로 탐색하게 하는 방식이에요. 예를 들어 “아이폰 15 프로”라는 게시글이 들어오면, LLM이 “iPhone”, “Smartphone”, “Electronic device” 등의 키워드로 직접 grep하고, 결과를 확인한 뒤, 후보군을 점점 좁혀가 최종 카테고리를 제출하는 식으로 자율적으로 탐색해요. 하지만 실제로 Evaluation을 수행해봤을 땐 생각보다 정확도가 높지 않았고 비용이 매우 높아서 실제로 도입되진 않았어요.

그 외 운영 개선 작업들

전략만큼 큰 변화는 아니더라도, 꾸준히 효과를 만들어낸 개선들도 많았어요.

먼저 임베딩 품질을 높이는 작업을 병행했어요. Ground-truth 샘플을 이용해 오프라인 실험으로 여러 임베딩 모델을 비교해보며 Recall과 Similarity, Rank 성능이 좋은 모델로 변경하기도 했고, Gemini 임베딩 모델의 task type을 적용해서 게시글과 카테고리 각각에 더 적합한 임베딩을 만들도록 튜닝하기도 했어요.

이미지 최적화도 큰 효과가 있었어요. 기존에는 해상도를 512x512로 맞췄는데, Gemini Flash 2.0 모델 기준으로 이 해상도는 이미지 1장당 4개의 타일이 필요해서 약 1,032 토큰이 소비됐어요. 해상도를 384x384로 낮추면 1개의 타일만 사용하게 되어 이미지당 토큰을 1/4로 줄일 수 있었어요. 그리고 사용자가 올린 이미지를 전부 보내던 방식에서, 이미지 개수를 줄이더라도 정확도 손실이 크지 않다는 걸 확인해서 이미지 개수 자체도 줄였어요.

속성 precision을 올리기 위한 프롬프트 개선도 있었어요. 상품 상태나 거래 방식처럼 주관적인 판단이 필요한 속성을 추가한 이후, 속성 precision이 떨어지는 문제가 있었어요. 경계가 불분명한 값이나 기준이 모호한 값이 있었기 때문이에요. 그래서 각 속성 description에 분류 기준을 더 구체적으로 추가했고, 그 기준을 프롬프트에서 사용할 수 있게 해서 precision을 끌어올렸어요.

name: Item condition
description: |
Indicates the physical condition and functional state of an item.

Classification criteria (Korean keywords):
- new: 새상품, 미개봉, 택달린, 새것, BNIB
- open_box: 단순개봉, 개봉만 함, 박스개봉
- used_like_new: 거의 새것, 1-2회 사용, 착용만 해봄, 시착만
- used_excellent: 상태 좋음, 깨끗함, 하자 없음, A급
- used_good: 사용감 있음, 약간의 사용감, 보통, B급
- used_fair: 하자, 기스, 얼룩, 변색, 수선 필요

Default: used_good (when condition is not explicitly mentioned)

지표 트래킹

위에서 이야기한 변경들의 효과와 리스크를 확인하고, 이상치를 감지하기 위해서는 지속적인 지표 트래킹이 필수예요. 전략이나 프롬프트, 모델 변경이 아니더라도 데이터 트렌드가 바뀌거나 의존하는 LLM 서비스의 상태에 따라 지표가 움직일 수 있기 때문이에요.

저희는 품질 대시보드를 만들어 비용, 정확도, 카테고리 레벨별 정확도를 한눈에 볼 수 있게 했어요.

이 외에도 속성도 그 분포를 추적하고, 특정 카테고리로 분류가 쏠리진 않는지, Primary 모델과 Eval 모델의 판단이 갈렸을 때 어떻게 갈린건지 알 수 있는 오분류 샘플 등도 확인하며 지속적으로 전략이나 프롬프트 개선의 단서를 얻고있어요.

이렇게 만든 데이터, 어디에 쓰이나요?

이런 과정을 거쳐 분류된 카테고리와 속성은 실제로 당근 내 여러 서비스에서 쓰이고 있어요.

앞서 말씀드렸듯 분류 결과는 BigQuery에 쌓아서 다른 팀에서 자유롭게 활용할 수 있도록 했어요. 이 곳에 쌓인 데이터는 분석, 실험, 추천 모델 학습 등 필요한 팀이 가져다 쓸 수 있는 원천 데이터 역할을 해요.

택소노미 체계 자체를 다른 서비스에서 코드로 활용해야 하는 경우도 있어요. 상위 카테고리를 조회해야 한다거나, 검색에서 하위 카테고리를 모두 포함해 검색해야 하는 경우 등이 그래요. 이를 위해 각 팀에서 택소노미 YAML 파일을 직접 파싱하고 트리를 구성하는걸 피하기 위해 택소노미에선 Golang과 Python SDK를 만들어 제공해드리고있어요. 카테고리 조회, 트리 탐색, 속성 조회를 할 수 있고, 택소노미의 정의와 구조를 한 곳에서 관리할 수 있어요.

from taxonomy_manager import TaxonomyManager, Category, Attribute

tm = TaxonomyManager.create_taxonomy_manager(
content_type="fleamarket",
config_name="karrot_category",
)

cat: Category = tm.get_category("aa-1-1")
print(cat.name) # 카테고리 이름
print(cat.breadcrumb) # "Electronics > Phones" 형태
print(cat.child_ids) # 하위 카테고리 ID 목록

if tm.category_id_exists("aa-1-1"):
print("exists")

서비스 사용 케이스

또 추천엔진이나 검색엔진에서 각 게시글의 카테고리와 속성 분류 결과를 모델 피쳐로 활용할 수 있도록 카테고리 분류 결과를 Kafka를 거쳐 피쳐플랫폼에도 적재하고 있어요.

이렇게 쌓인 카테고리 피쳐는 주로 홈피드 중고거래 게시글 추천에서 활용하고 있어요. 비슷한 카테고리의 게시글이 연달아 나오지 않도록 다양성을 확보하는 휴리스틱에 사용되기도 하고, 추천 모델의 피쳐로도 활용되고 있어요. 게시글의 카테고리 피쳐나 카테고리 기반 통계 피쳐 등이 모델에 들어가요.

크로스 도메인 추천도 흥미로운 케이스예요. 중고거래뿐만 아니라 모임의 카테고리 체계도 택소노미 시스템에서 관리하고 있기 때문에, 중고거래와 모임 카테고리 간의 관련성을 파악할 수 있어요. 이를 활용해서 테니스 라켓 중고거래 게시글을 보고 있는 사용자에게 테니스 모임을 추천하는 식의 크로스 도메인 추천을 제공하고 있어요.

테니스 라켓 물품에 보이는 테니스 모임 추천

앞으로 더 뭘 할 건가요?

2025년 하반기에 Taxonomy Management System을 구축한 후 지금까지 운영해오며 많은 확장과 개선이 있었지만 아직도 많은 개선점들이 존재해요.

첫 번째는 LLM에서 자체 모델로의 전환, 혹은 파인튜닝 시도예요. 지금까지 모든 분류는 적어도 1번 이상의 LLM API 호출이 발생하고 이로인해 택소노미 파이프라인 비용의 대부분이 LLM 비용인 상황이에요. 어느정도 체계가 안정되어 변화가 자주 발생하지 않는 택소노미는 6개월 이상 쌓인 분류 결과를 활용해 ML 모델을 학습시키는 시도를 해보려고 해요. 일정 비율은 모델로 처리하고, 나머지는 LLM을 유지해서 새로운 카테고리에도 대응하고 품질을 계속 검토하는 식으로요. 이렇게 하면 품질은 유지하면서 전체 비용이 절감되리라 기대해요. 상황에 따라 LLM 파인튜닝도 옵션이 될 수 있고요.

두 번째는 추론 품질 지표 고도화예요. 지금은 전략, 프롬프트, 모델을 바꿀 때 정확도와 비용을 중심으로 판단하고 있는데, 이것만으로는 그 비교가 충분히 날카롭지 않을 때가 있어요. 어떤 카테고리를 더 잘 맞추고, 어떤 카테고리를 더 못 맞추는지, 그리고 얼마나 심각하게 틀렸는지까지 근거로 삼을 수 있도록 지표를 개선하고 있어요.

세 번째는 택소노미 Evolution을 더 잘하는 거예요. 분류 정확도와는 별개로 택소노미 체계 자체의 품질을 높이는 방향이에요. 택소노미 체계 품질을 높이려면, 이 체계를 평가하는 지표도 있어야 해요. 어떤 것이 좋은 택소노미인지 정량적으로 정의할 수 있는 지표를 설계하고, 카테고리와 속성을 추가하거나 체계 변경이 생길 때 어떤 영향을 미치는지 실험하면서 발전시켜 나가려고 해요.

네 번째는 적은 인력으로도 LLM과 Code Agent를 레버리지 삼아 더 큰 아웃풋을 내는 거예요. 택소노미 정의, 번역, 전략 실험까지 Agent를 적극 활용하면서 운영 부담을 줄이고 있는데, 앞으로는 이런 방식으로 더 잘 레버리지해서 더 많은 개선을 만들어내려 해요.

끝으로

여기까지 작년 하반기부터 지금까지 Taxonomy 팀이 어떻게 택소노미를 정의하고, 추론을 더 잘하기 위한 시스템을 구축해왔고, 어떻게 운영해왔는지 읽어주셔서 감사해요. 당근에서는 현재 AI와 ML을 활용한 다양한 시도를 하고 있어요. 마침 당근에서 ML 엔지니어를 절찬리 채용 중이니 많은 관심 부탁드려요.

같이 읽으면 좋은 글

2조 토큰을 카테고리 분류에 쓰면서 알게된 것들 was originally published in 당근 테크 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.

← 목록으로