태그 기반 추천에서 임베딩 기반 맞춤 추천으로 바꾼 과정

2026. 5. 28. 13:38개발/AI

1. 태그 기반 추천의 한계

기존 추천 방식

study-admin은 블로그 스터디를 운영하기 위한 관리 서비스다. 스터디원들이 작성한 글을 모아 보고, 외부 아티클이나 컨퍼런스 콘텐츠를 큐레이션하는 기능이 있다.

처음 추천 방식은 단순했다. 사용자의 관심사 태그와 콘텐츠에 붙은 태그가 얼마나 겹치는지 보고 정렬했다.

추천 점수 = 사용자 interests ∩ 콘텐츠 tags 개수

작은 서비스에서는 이 방식도 나쁘지 않다. 구현이 쉽고, 결과를 설명하기도 쉽다. 예를 들어 사용자가 React, LLM에 관심이 있고 콘텐츠 태그에도 React가 붙어 있다면, 왜 추천됐는지 비교적 명확하다.

하지만 실제 데이터를 넣고 써보면 한계가 빨리 보였다.

태그가 비어 있으면 추천도 비어버린다

첫 번째 문제는 태그가 비어 있거나 부정확한 콘텐츠가 많다는 점이었다. RSS나 외부 큐레이션 소스에서 들어오는 글은 항상 정리된 태그를 가지고 있지 않다. 제목과 설명만 보면 분명히 LLM, 데이터베이스, 프론트엔드와 관련된 글인데, 태그가 비어 있으면 추천 후보에서 밀릴 수 있었다.

같은 의미를 다른 단어로 표현하는 문제

두 번째 문제는 표기 차이다. pgvector, vector search, embedding, RAG는 서로 가까운 주제지만 문자열로 보면 전부 다른 값이다. 사용자가 LLM에 관심을 등록해두었더라도, 콘텐츠가 vector search나 embeddings라는 태그만 가지고 있으면 단순 overlap 방식에서는 잘 잡히지 않는다.

세 번째 문제는 의미 유사도를 반영하기 어렵다는 점이었다. 사용자가 관심사에 정확히 같은 단어를 입력하지 않아도, 실제로는 관심 있을 만한 글이 있다. 예를 들어 AI, LLM, 검색, 추천 시스템은 서로 맞닿아 있는 주제다. 그런데 태그 기반 추천은 이런 관계를 이해하지 못한다.

추천은 설명 가능해야 한다

마지막으로 추천 결과를 설명하는 방식도 애매했다. 맞춤 추천 탭을 만들더라도 사용자는 결국 "왜 이 글이 나한테 추천됐지?"를 궁금해한다. 추천이 납득되지 않으면 개인화 기능은 오히려 신뢰를 잃는다. 단순히 순서를 바꾸는 것만으로는 부족했고, 추천 결과가 어떤 근거로 나왔는지 함께 보여줄 필요가 있었다.

그래서 추천 방식을 태그 overlap 중심에서 사용자 취향과 콘텐츠의 의미적 유사도를 보는 구조로 바꾸기로 했다. 단순히 벡터 검색을 붙이는 것이 아니라, 추천 이유와 점수 배지까지 함께 설계해서 사용자가 결과를 이해할 수 있는 추천 시스템을 목표로 잡았다.

2. 사용자와 콘텐츠를 임베딩으로 표현하기

비교 가능한 텍스트로 정규화하기

추천을 의미 유사도 기반으로 바꾸려면 먼저 사용자와 콘텐츠를 같은 비교 공간에 올려야 했다. 그래서 study-admin에서는 사용자 취향, 큐레이션 아이템, 스터디 글을 각각 임베딩용 텍스트로 변환한 뒤 벡터로 저장했다.

이 로직은 packages/shared/src/ai/embedding-text.ts에 모아두었다. 추천 API나 봇 서비스 곳곳에서 제각각 문자열을 만들지 않고, 공유 패키지의 함수로 동일한 규칙을 사용하게 했다.

사용자 취향 임베딩 텍스트

사용자 취향은 buildMemberPreferenceText에서 만든다. 사용자의 파트, 관심사, 자기소개를 하나의 짧은 문장으로 합친다.

export function buildMemberPreferenceText(input: {
  part: string;
  bio?: string | null;
  interests?: string[] | null;
}): string {
  const segments = [`${part || '스터디'} 개발자.`];
  if (interests.length > 0) segments.push(`관심사: ${interests.join(', ')}.`);
  if (bio) segments.push(`소개: ${bio}`);

  return segments.join(' ');
}

예를 들면 이런 문장이 된다.

Backend 개발자. 관심사: React, 성능 최적화. 소개: API와 DB 성능을 좋아합니다.

이 텍스트는 사용자가 어떤 파트에 속해 있고, 어떤 주제를 좋아하며, 자기소개에서 어떤 맥락을 드러내는지를 함께 담는다. 단순히 관심사 배열만 임베딩하는 것보다 사용자의 취향을 조금 더 풍부하게 표현할 수 있다.

큐레이션 콘텐츠 임베딩 텍스트

큐레이션 아이템은 buildCurationItemEmbeddingText에서 만든다. 외부 아티클이나 컨퍼런스 콘텐츠는 본문 전체를 가져오지 않는 경우가 많기 때문에, 추천에 사용할 수 있는 메타데이터를 최대한 안정적으로 묶었다.

Title: React Server Components 성능 최적화
Description: Streaming과 cache 전략 정리
Tags: React, Performance
Source: Frontend Weekly

실제 함수는 title을 기본으로 넣고, description, tags, sourceName이 있을 때만 줄을 추가한다. 값이 비어 있으면 억지로 빈 필드를 넣지 않는다. 이렇게 하면 태그가 없더라도 제목과 설명만으로 임베딩을 만들 수 있고, 출처 정보가 있으면 추천 이유를 만들 때도 활용할 수 있다.

스터디 글에는 작성자 맥락을 넣었다

스터디 글은 buildPostEmbeddingText로 만든다. 여기에는 글 자체의 제목과 설명뿐 아니라 작성자 맥락도 들어간다.

Title: React 성능 최적화
Description: memo와 서버 컴포넌트 이야기
Author part: frontend
Author interests: React, Next.js
Round: 4

작성자 정보를 넣은 이유는 스터디 글의 설명이 항상 충분하지 않기 때문이다. 어떤 글은 제목만 있고 설명이 짧을 수 있다. 이때 작성자의 파트, 관심사, 자기소개, 회차 정보를 함께 넣으면 글의 의미를 보강할 수 있다.

해시로 중복 임베딩을 피하기

임베딩 생성은 봇 서버의 EmbeddingService가 담당한다. 예를 들어 큐레이션 아이템은 DB에서 title, description, tags, sourceName을 조회한 뒤 buildCurationItemEmbeddingText로 텍스트를 만들고, 그 텍스트를 임베딩 서버에 보낸다.

const embeddingText = buildCurationItemEmbeddingText(row);
const embeddingTextHash = sha256Text(embeddingText);
const embedding = await this.embed(embeddingText);

사용자 취향과 스터디 글도 같은 흐름을 탄다. 텍스트를 만들고, SHA-256 해시를 만들고, 임베딩을 생성한 뒤 DB에 저장한다.

해시를 저장한 이유는 같은 텍스트를 반복해서 임베딩하지 않기 위해서다. 임베딩 텍스트가 그대로라면 벡터도 다시 만들 필요가 없다. 반대로 사용자가 프로필을 바꾸거나 글 설명이 바뀌면 텍스트 해시가 달라지므로 재생성 대상인지 판단할 수 있다.

텍스트 생성 규칙도 테스트했다

이 부분은 테스트도 추가했다. embedding-text.property.test.ts에서는 큐레이션 텍스트와 사용자 취향 텍스트가 안정적으로 생성되는지, 같은 문자열은 같은 해시를 만들고 다른 문자열은 다른 해시를 만드는지 확인한다.

추천 품질은 모델만으로 결정되지 않는다. 어떤 정보를 어떤 순서와 형식으로 임베딩하느냐가 결과에 큰 영향을 준다. 그래서 이 작업에서는 임베딩 모델을 붙이는 것보다, 사용자와 콘텐츠를 비교 가능한 텍스트로 정규화하는 규칙을 먼저 잡는 것이 중요했다.

3. pgvector로 추천 점수 만들기

벡터 저장 구조

임베딩을 만들었다면 다음 문제는 어디에 어떻게 저장하고 조회할지였다. study-admin에서는 Supabase Postgres에 pgvector를 붙여서 임베딩을 저장했다.

마이그레이션에서는 먼저 extensions 스키마를 만들고, vector 확장을 활성화한다.

CREATE SCHEMA IF NOT EXISTS extensions;
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions;

큐레이션 아이템은 기존 curation_items 테이블에 임베딩 컬럼을 추가했다.

ALTER TABLE public.curation_items
  ADD COLUMN IF NOT EXISTS embedding extensions.vector(768),
  ADD COLUMN IF NOT EXISTS embedding_text_hash varchar(64),
  ADD COLUMN IF NOT EXISTS embedding_model varchar(100),
  ADD COLUMN IF NOT EXISTS embedded_at timestamp with time zone

사용자 취향과 스터디 글은 별도 테이블로 분리했다. 사용자 취향은 member_preference_embeddings, 스터디 글은 post_embeddings에 저장한다.

member_preference_embeddings.embedding vector(768)
post_embeddings.embedding vector(768)
curation_items.embedding vector(768)

큐레이션 아이템은 테이블 자체가 추천 대상이라 컬럼을 직접 추가했고, 포스트는 posts 테이블을 추천 메타데이터로 복잡하게 만들지 않기 위해 별도 테이블로 뺐다.

HNSW 인덱스 붙이기

벡터 검색에는 HNSW 인덱스를 붙였다. 마이그레이션에서는 cosine distance 기준으로 검색할 수 있도록 extensions.vector_cosine_ops를 사용한다.

CREATE INDEX IF NOT EXISTS idx_curation_items_embedding_hnsw
  ON public.curation_items
  USING hnsw (embedding extensions.vector_cosine_ops)
  WHERE embedding IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_member_preference_embeddings_embedding_hnsw
  ON public.member_preference_embeddings
  USING hnsw (embedding extensions.vector_cosine_ops);

CREATE INDEX IF NOT EXISTS idx_post_embeddings_embedding_hnsw
  ON public.post_embeddings
  USING hnsw (embedding extensions.vector_cosine_ops);

추천 쿼리에서는 pgvector의 cosine distance 연산자인 <=>를 사용한다. 거리는 가까울수록 작기 때문에, 추천 점수로 쓰기 위해 1 - distance 형태로 의미 유사도를 만들었다.

const semanticScoreExpr = sql<number>`
  coalesce(1 - (${curationItems.embedding} <=> ${memberPreferenceEmbeddings.embedding}), 0)
`;

포스트 추천에서도 같은 방식으로 post_embeddings.embedding과 member_preference_embeddings.embedding을 비교한다.

큐레이션 추천 점수

큐레이션 추천은 의미 유사도를 중심으로 두되, 최신성과 기존 관련도 점수를 함께 섞었다.

finalScore =
  semanticScore * 0.65
  + freshnessScore * 0.20
  + relevanceScore * 0.15

실제 API에서는 다음처럼 계산한다.

const semanticScoreExpr = sql<number>`
  coalesce(1 - (${curationItems.embedding} <=> ${memberPreferenceEmbeddings.embedding}), 0)
`;
const freshnessScoreExpr = sql<number>`exp(-(${ageDaysExpr}) / 14.0)`;
const normalizedRelevanceExpr = sql<number>`
  least(greatest(coalesce(${curationItems.relevanceScore}, 0), 0), 100) / 100.0
`;
const finalScoreExpr = sql<number>`
  ((${semanticScoreExpr}) * 0.65
  + (${freshnessScoreExpr}) * 0.20
  + (${normalizedRelevanceExpr}) * 0.15)
`;

freshnessScore는 exp(-ageDays / 14.0)로 계산했다. 오늘 올라온 글은 1에 가깝고, 시간이 지날수록 자연스럽게 낮아진다. 단순히 최신순으로만 정렬하지 않고, 오래된 글도 의미적으로 정말 잘 맞으면 추천될 수 있게 하기 위한 보정이다.

큐레이션 정렬은 최종 점수 기준으로 한다.

final_score DESC
publishedAt DESC NULLS LAST
id DESC

무한 스크롤에서 추천 순서가 흔들리지 않도록 cursor에는 rankingAsOf 시각도 포함했다. 최신성 점수가 시간에 따라 계속 바뀌기 때문에, 다음 페이지를 가져올 때도 같은 기준 시각으로 계산해야 랭킹이 안정적으로 유지된다.

finalScore|publishedAt|id|rankingAsOfIso

포스트 추천 점수

스터디 글 추천은 큐레이션과 조금 다르게 잡았다. 스터디원이 올린 글은 조회수, 댓글, 리액션 같은 반응 정보가 있고, 작성자와의 관계도 약간의 의미가 있다고 봤다.

finalScore =
  semanticScore * 0.70
  + freshnessScore * 0.15
  + popularityScore * 0.10
  + authorAffinityScore * 0.05

코드에서는 댓글, 조회, 리액션을 합쳐 인기 점수를 만든다.

const popularScore = sql<number>`
  COALESCE(${posts.commentCount}, 0) * 3
  + (SELECT COUNT(*) FROM post_views pv WHERE pv.post_id = ${posts.id}) * 2
  + (SELECT COUNT(*) FROM post_reactions pr WHERE pr.post_id = ${posts.id})
`;

작성자 친화도는 같은 파트면 1.0, 관심사가 하나라도 겹치면 0.5, 아니면 0.0으로 계산했다.

authorAffinityScoreExpr = sql<number>`case
  when ${members.part} = ${currentMemberPart} then 1.0
  when coalesce(array_length(
    ARRAY(
      SELECT unnest(coalesce(${members.interests}, ARRAY[]::text[]))
      INTERSECT
      SELECT unnest(${interestsArray})
    ), 1), 0) > 0 then 0.5
  else 0.0
end`;

다만 포스트 추천에서는 최종 점수보다 의미 유사도를 더 강하게 우선했다.

semantic_score DESC
final_score DESC
publishedAt DESC

즉 최신성, 인기, 작성자 친화도는 추천을 완전히 뒤집는 요소가 아니라, 의미적으로 비슷한 글들 사이에서 순서를 보정하는 역할에 가깝다. 추천의 중심은 여전히 사용자 취향과 글 임베딩의 의미 유사도다.

4. 추천 이유를 함께 보여주기

추천은 정렬만으로 끝나지 않는다

추천 탭을 만들 때 중요하게 본 것은 "어떤 글을 위에 둘 것인가"만이 아니었다. 사용자가 추천 결과를 봤을 때 왜 이 글이 나왔는지 이해할 수 있어야 했다.

추천 결과가 납득되지 않으면 개인화 기능은 오히려 불편해진다. 사용자는 "내 관심사와 상관없는 글이 왜 추천되지?"라고 느낄 수 있고, 그러면 맞춤 추천 탭 자체를 신뢰하지 않게 된다.

그래서 추천 API는 단순히 정렬된 콘텐츠만 반환하지 않고, 추천 이유도 함께 내려주도록 했다.

LLM 없이 deterministic하게 만들기

처음에는 추천 이유를 LLM으로 생성할 수도 있다고 생각할 수 있다. 하지만 이 기능에서는 그렇게 하지 않았다.

추천 이유는 packages/shared/src/ai/recommendation-reason.ts에서 deterministic하게 만든다. 추천 점수를 계산할 때 이미 사용한 신호를 다시 활용하는 방식이다.

큐레이션 추천 이유의 응답 형태는 다음과 같다.

export interface RecommendationReason {
  summary: string;
  reasons: string[];
  matchedKeywords: string[];
  semanticScore: number | null;
  freshnessScore: number | null;
  relevanceScore: number | null;
}

포스트 추천은 큐레이션과 조금 다르게 popularityScore, authorAffinityScore도 포함한다.

export interface PostRecommendationReason {
  summary: string;
  reasons: string[];
  matchedKeywords: string[];
  semanticScore: number | null;
  freshnessScore: number | null;
  popularityScore: number | null;
  authorAffinityScore: number | null;
}

이렇게 만든 이유는 단순하다. 매 요청마다 LLM을 호출하지 않으면 빠르고, 비용이 들지 않고, 같은 입력에 대해 같은 추천 이유가 나온다. 무엇보다 추천 이유가 실제 점수 계산에 사용한 신호와 연결된다.

추천 이유를 만드는 규칙

큐레이션은 buildRecommendationReason에서 만든다. 사용자 관심사가 제목, 설명, 태그와 직접 매칭되면 관심사 기반 이유를 추가한다.

if (matchedKeywords.length > 0) {
  reasons.push(`관심사 ${matchedKeywords.join(', ')}와 연결돼요.`);
}
if (semanticScore !== null && semanticScore >= 0.6) {
  reasons.push('프로필 관심사와 의미적으로 가까운 글이에요.');
}
if (freshnessScore >= 0.6) {
  reasons.push('최근 올라온 글이에요.');
}
if (relevanceScore >= 0.6) {
  reasons.push('스터디 키워드 관련도가 높은 글이에요.');
}
if (input.sourceName) {
  reasons.push(`${input.sourceName}에서 가져온 글이에요.`);
}

포스트는 buildPostRecommendationReason에서 만든다. 의미 유사도, 최신성뿐 아니라 같은 파트의 글인지, 조회·댓글·리액션 반응이 있는지도 이유에 반영한다.

if (
  authorAffinityScore !== null &&
  authorAffinityScore >= 0.9 &&
  input.authorPart &&
  input.currentMemberPart &&
  input.authorPart === input.currentMemberPart
) {
  reasons.push(`같은 ${input.authorPart} 파트의 글이에요.`);
}

if (popularityScore !== null && popularityScore >= 0.3) {
  reasons.push('조회, 댓글, 리액션 반응이 있는 글이에요.');
}

관심사 키워드가 직접 매칭되면 summary도 더 구체적으로 만든다.

React, 성능 최적화 관심사에 잘 맞아요.

직접 매칭된 키워드가 없더라도 의미 유사도가 있으면 다음처럼 요약한다.

관심도 76%로 추천됐어요.

점수 배지 UI

 

프론트에서는 추천 이유를 카드 안에 바로 보여준다. 큐레이션 카드에서는 RecommendationScoreBadges가 semanticScore, freshnessScore, relevanceScore를 퍼센트로 변환해 표시한다.

const scores = [
  { label: '관심도', value: toPercent(reason.semanticScore) },
  { label: '최신성', value: toPercent(reason.freshnessScore) },
  { label: '관련도', value: toPercent(reason.relevanceScore), hideWhenZero: true },
];

포스트 카드에서는 PostRecommendationBadges가 관심도, 최신성, 인기를 보여준다.

const scores = [
  { label: '관심도', value: toPercent(reason.semanticScore) },
  { label: '최신성', value: toPercent(reason.freshnessScore), hideWhenZero: true },
  { label: '인기', value: toPercent(reason.popularityScore), hideWhenZero: true },
];

이 UI는 추천 시스템을 블랙박스로 두지 않기 위한 장치다. 사용자는 단순히 "맞춤 추천"이라는 결과만 보는 것이 아니라, 이 글이 관심도 때문에 올라왔는지, 최신성 때문인지, 인기나 관련도 때문인지 확인할 수 있다.

짧은 키워드 오탐 막기

추천 이유를 만들 때는 관심사 키워드 매칭도 조심해야 했다. 특히 AI처럼 짧은 영어 키워드는 다른 단어 안에 포함되기 쉽다.

예를 들어 매일메일 같은 문자열 안에 ai가 들어 있다고 해서 사용자의 AI 관심사와 매칭하면 안 된다. 그래서 ASCII 키워드는 단어 경계를 확인하도록 처리했다.

if (isAsciiKeyword(normalizedKeyword)) {
  const pattern = new RegExp(
    `(^|[^a-z0-9+#.])${escapeRegExp(normalizedKeyword)}(?=$|[^a-z0-9+#.])`,
    'i'
  );
  return pattern.test(haystack);
}

이 부분은 테스트도 추가했다.

expect(
  findMatchedKeywords({
    interests: ['AI'],
    title: '브라우저 렌더링 파이프라인',
    description: '매일메일을 참고해 HTML 파서와 DOM 생성 과정을 정리합니다.',
  })
).toEqual([]);

작은 디테일이지만 추천 이유에서는 이런 오탐이 신뢰도를 크게 떨어뜨릴 수 있다. 추천 이유는 사용자에게 직접 노출되는 문장이기 때문에, 점수 계산만큼이나 보수적으로 다뤄야 했다.

5. 임베딩 갱신을 사용자 요청에서 분리하기

임베딩은 중요하지만 필수 경로는 아니다

임베딩 기반 추천을 붙이면서 가장 조심한 부분은 사용자 요청 흐름이었다. 프로필을 저장하거나 글을 등록할 때마다 임베딩을 즉시 생성하면, 사용자의 기본 동작이 임베딩 서버 상태에 묶인다.

예를 들어 임베딩 서버가 느리거나 첫 요청에서 모델을 로딩하느라 시간이 오래 걸리면, 사용자는 단순히 프로필을 저장했을 뿐인데 응답이 늦어진다. 더 나쁘게는 임베딩 서버가 실패했을 때 프로필 저장이나 글 등록 자체가 실패한 것처럼 보일 수 있다.

추천 데이터는 중요하지만, 글 등록과 프로필 저장은 추천 시스템보다 더 기본적인 기능이다. 그래서 임베딩 갱신을 사용자 요청의 필수 경로에서 분리했다.

Next.js after로 응답 이후 실행하기

웹 서버에서는 직접 임베딩을 만들지 않는다. 대신 packages/web/src/lib/embedding-refresh.ts에 임베딩 갱신 요청을 예약하는 함수를 두었다.

export function scheduleMemberPreferenceEmbeddingRefresh(memberId: string): void {
  after(async () => {
    try {
      await postToBot('/api/internal/embedding/member-preference', { memberId });
    } catch (error) {
      console.error('[embedding-refresh] 멤버 취향 임베딩 갱신 실패:', error);
    }
  });
}

after() 안에서 봇 내부 API를 호출하기 때문에, 사용자에게 응답을 반환한 뒤 임베딩 갱신을 이어서 수행할 수 있다.

큐레이션과 포스트도 같은 방식이다. 여러 ID가 들어올 수 있는 작업은 중복을 제거하고 최대 100개까지만 보낸다.

export function schedulePostEmbeddingRefresh(postIds: string[]): void {
  const uniquePostIds = [...new Set(postIds)].slice(0, 100);
  if (uniquePostIds.length === 0) return;

  after(async () => {
    try {
      await postToBot('/api/internal/embedding/batch', { postIds: uniquePostIds });
    } catch (error) {
      console.error('[embedding-refresh] 포스트 임베딩 일괄 갱신 실패:', error);
    }
  });
}

내부 API 호출에는 BOT_API_SECRET을 Bearer 토큰으로 붙이고, 요청 timeout도 10초로 제한했다.

const response = await fetch(`${BOT_API_URL}${path}`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${BOT_API_SECRET}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(body),
  signal: AbortSignal.timeout(10_000),
});

실제 호출 지점

프로필 온보딩과 프로필 수정에서는 멤버 정보를 저장한 뒤 취향 임베딩 갱신을 예약한다.

scheduleMemberPreferenceEmbeddingRefresh(existingMember.id);

수동으로 글을 등록하거나 기존 글을 수정할 때는 포스트 임베딩 갱신을 예약한다.

schedulePostEmbeddingRefresh([newPost.id]);

관리자 큐레이션 크롤링에서는 새로 들어온 큐레이션 아이템 ID들을 모아서 일괄 갱신한다.

const totalNewItems = results.reduce((sum, r) => sum + r.newItemsAdded, 0);
scheduleCurationItemEmbeddingRefresh(insertedItemIds);

이 구조 덕분에 웹 API는 사용자 요청을 먼저 처리하고, 추천용 임베딩은 뒤에서 따라오게 된다.

봇 내부 API로 임베딩 갱신하기

실제 임베딩 생성은 봇 서버가 담당한다. 봇 서버에는 내부 API 4종을 추가했다.

POST /api/internal/embedding/member-preference
POST /api/internal/embedding/curation-item
POST /api/internal/embedding/post
POST /api/internal/embedding/batch

내부 API는 Bearer token 인증을 거치고, trigger endpoint에는 rate limit도 걸었다.

const triggerLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 10,
});

batch API에서는 memberIds, curationItemIds, postIds를 받아서 각각 중복 제거 후 최대 100개까지만 처리한다. 또한 모든 ID가 UUID 형식인지 검증한다.

const uniquePostIds = [...new Set(postIds)].slice(0, MAX_EMBEDDING_BATCH_SIZE);

if (
  allIds.length === 0 ||
  allIds.some((id) => typeof id !== 'string' || !UUID_RE.test(id))
) {
  return res.status(400).json({ error: '유효한 임베딩 갱신 ID가 필요합니다' });
}

검증을 통과하면 EmbeddingService의 refreshMemberPreference, refreshCurationItem, refreshPost를 호출해 실제 임베딩을 갱신한다.

실패한 임베딩은 백필로 복구하기

비동기 갱신 구조에서는 실패 가능성을 인정해야 한다. after() 안에서 호출한 임베딩 갱신이 실패해도 사용자 요청은 이미 성공했기 때문에, 나중에 누락된 임베딩을 복구할 경로가 필요하다.

그래서 백필 스크립트 3종을 추가했다.

backfill-curation-embeddings
backfill-post-embeddings
backfill-member-preference-embeddings

각 스크립트는 EmbeddingService의 백필 메서드를 호출한다.

const updated = await getEmbeddingService().backfillMissingPosts(limit);
console.log(`Updated ${updated} post embeddings`);

큐레이션과 포스트 백필은 기본적으로 임베딩이 없거나 embedded_at이 비어 있는 데이터를 찾아 채운다. 멤버 취향 백필은 추천 화면에서 의미가 있는 active, ob, dormant 상태의 멤버를 대상으로 한다.

이 구조는 추천 시스템을 사용자 요청의 동기 작업이 아니라 백그라운드에서 따라오는 보조 시스템으로 만든다. 사용자의 기본 흐름은 빠르게 유지하고, 추천 품질은 내부 API와 백필 스크립트로 점진적으로 따라오게 하는 방식이다.

6. 결과와 배운 점

두 화면에 맞춤 추천 탭을 붙였다

이번 작업의 결과로 /curation과 /posts 두 화면에 맞춤 추천 탭을 추가했다.

/curation에서는 외부 아티클과 컨퍼런스 콘텐츠를 사용자 취향에 맞게 정렬한다. 기존에는 전체, 컨퍼런스, 아티클처럼 콘텐츠 종류 중심으로만 볼 수 있었는데, 여기에 맞춤 탭을 추가했다.

/posts에서는 스터디원이 올린 글을 추천순으로 볼 수 있게 했다. 최신순과 인기순만 있던 흐름에 사용자 취향 기반 추천을 추가했고, 본인이 작성한 글은 추천 대상에서 제외했다.

화면에서는 추천 이유와 점수 배지도 함께 노출한다.

관심도 89% · 최신성 96% · 관련도 86%
관심도 72% · 최신성 82% · 인기 35%

사용자는 단순히 "맞춤 추천"이라는 결과만 보는 것이 아니라, 이 글이 어떤 신호 때문에 추천됐는지 확인할 수 있다.

fallback과 backfill까지 포함한 구현

이번 작업에서 중요했던 것은 "잘 될 때"만 보는 것이 아니었다.

취향 임베딩이 없는 사용자가 있을 수 있다. 큐레이션 글에 태그가 없을 수 있다. 임베딩 서버가 실패할 수 있다. 기존 데이터에는 아직 임베딩이 없을 수 있다.

그래서 fallback과 backfill을 같이 넣었다.

큐레이션 추천에서는 취향 임베딩이 없으면 기존 태그 overlap 방식으로 fallback한다. 임베딩 기반 추천으로 바꿨다고 해서 태그를 완전히 버리지 않은 이유다. 태그는 필터, 추천 이유, fallback에서 여전히 유용하다.

기존 데이터는 백필 스크립트로 추천 시스템에 편입할 수 있게 했다.

backfill-curation-embeddings
backfill-post-embeddings
backfill-member-preference-embeddings

이 구조 덕분에 배포 시점에 이미 쌓여 있던 큐레이션, 포스트, 멤버 데이터도 점진적으로 임베딩 추천에 포함할 수 있다.

테스트한 것들

작은 추천 시스템이라도 문자열 처리와 추천 이유 생성에는 예상치 못한 오탐이 생기기 쉽다. 그래서 다음 테스트를 추가했다.

  • 임베딩 텍스트 생성 규칙과 해시 결정성
  • 추천 이유 생성과 키워드 매칭
  • 짧은 ASCII 키워드 오탐 방지
  • 큐레이션 태그 추론 규칙
  • 태그 추론 결과가 허용된 관심사 목록 안에 있는지 확인하는 property test

특히 AI 같은 짧은 키워드가 다른 단어 내부에서 잘못 매칭되지 않도록 테스트한 점이 중요했다. 추천 이유는 사용자에게 직접 노출되는 문장이기 때문에, 작은 오탐도 신뢰도를 떨어뜨릴 수 있다.

배운 점

이번 작업을 하면서 추천 기능은 알고리즘만의 문제가 아니라는 걸 다시 느꼈다.

처음에는 "태그 기반 추천을 임베딩 기반 추천으로 바꾸면 된다"고 생각하기 쉽다. 하지만 실제 서비스 흐름에 넣으려면 더 많은 질문에 답해야 한다.

  • 추천 결과를 사용자가 납득할 수 있는가?
  • 추천 이유가 실제 점수 계산과 연결되어 있는가?
  • 임베딩 서버가 느리거나 실패해도 기본 기능은 동작하는가?
  • 기존 데이터는 어떻게 추천 시스템에 편입할 것인가?
  • 추천 데이터가 없을 때는 어떤 fallback을 쓸 것인가?

이번 구현에서 가장 중요했던 판단은 "AI를 어디에 둘 것인가"였다. 매 요청마다 LLM을 호출해 추천 이유를 생성하는 대신, 임베딩은 미리 만들고 추천 이유는 구조화된 점수 신호로 만들었다. 사용자 요청은 먼저 성공시키고, 임베딩 갱신은 백그라운드로 보냈다.

결국 추천 시스템에서 중요한 것은 점수를 잘 계산하는 것만이 아니었다. 사용자가 결과를 신뢰할 수 있어야 하고, 운영자가 실패를 복구할 수 있어야 하며, 기본 사용자 흐름이 추천 시스템 때문에 느려지지 않아야 했다.

작은 사이드 프로젝트였지만, 추천을 알고리즘이 아니라 제품 흐름과 운영 구조까지 포함해서 볼 수 있었던 작업이었다.