홈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

소개JSON Formatter개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

소개|JSON Formatter|개인정보처리방침|이용약관

블로그에 임베딩 기반 관련 글 추천 시스템 구축하기

정기창·2026년 1월 29일

왜 임베딩 기반 관련 글 추천인가?

블로그에 "관련 글" 기능은 이미 있었다. 태그가 겹치는 글을 찾아서 보여주는 단순한 방식이었는데, 몇 가지 한계가 있었다.

  • 태그를 꼼꼼하게 달지 않으면 관련 글이 아예 안 나온다

  • 태그가 같아도 실제 내용이 다른 경우가 있다

  • 반대로 태그는 다르지만 내용이 비슷한 글을 놓친다

결국 "글의 실제 내용"을 기반으로 유사도를 계산해야 정확한 관련 글을 찾을 수 있다. 이걸 가능하게 하는 기술이 바로 텍스트 임베딩이다.

임베딩이란?

임베딩은 텍스트를 고차원 벡터(숫자 배열)로 변환하는 기술이다. 비슷한 의미의 텍스트는 벡터 공간에서 가까운 위치에 배치된다.

예를 들어:

  • "React Hooks 사용법" → [0.12, -0.34, 0.56, ...]

  • "React useState 가이드" → [0.11, -0.33, 0.57, ...] (비슷한 벡터)

  • "요리 레시피" → [-0.45, 0.23, -0.12, ...] (다른 벡터)

두 벡터 간의 유사도를 계산하면 글이 얼마나 비슷한지 수치로 알 수 있다. 이걸 코사인 유사도라고 한다.

기술 스택

  • Gemini Embedding API (gemini-embedding-001): 3072차원 벡터 생성

  • NestJS: 백엔드 API

  • MongoDB: 임베딩 벡터 저장

  • MCP (Model Context Protocol): Claude Code에서 직접 호출

구현 과정

1. EmbeddingService 구현

NestJS에 EmbeddingService를 추가했다. 핵심 기능은 세 가지:

@Injectable()
export class EmbeddingService {
  // 1. 단일 글 임베딩 생성
  async generateEmbedding(slug: string, language: string, force: boolean)
  
  // 2. 전체 글 일괄 임베딩 (Rate Limiting 적용)
  async generateAllEmbeddings(language: string, force: boolean)
  
  // 3. 유사도 계산 및 관련 글 업데이트
  async updateRelatedPostsBidirectional(slug: string, language: string)
}

2. 변경 감지로 불필요한 API 호출 방지

임베딩 생성은 API 호출 비용이 발생한다. 글이 바뀌지 않았는데 매번 임베딩을 새로 만들 필요는 없다.

// 콘텐츠 해시로 변경 감지
const newHash = generateContentHash(text);
if (!force && post.contentHash === newHash) {
  return { skipped: true, skipReason: '콘텐츠 변경 없음' };
}

SHA-256 해시로 콘텐츠 변경을 감지하고, 변경이 없으면 임베딩 생성을 스킵한다.

3. Rate Limiting 처리

Gemini 무료 티어는 분당 15 요청 제한이 있다. 40개 글을 한 번에 처리하면 에러가 발생한다.

// 1개씩 + 5초 대기 = 분당 12 요청 (안전)
for (let i = 0; i < posts.length; i += rateLimit) {
  const batch = posts.slice(i, i + rateLimit);
  await Promise.all(batch.map(post => this.generateEmbedding(post.slug)));
  
  // Rate limiting
  await new Promise(resolve => setTimeout(resolve, 5000));
}

4. 양방향 관련 글 업데이트

A 글의 관련 글에 B가 있다면, B 글의 관련 글에도 A가 있어야 자연스럽다.

async updateRelatedPostsBidirectional(slug: string) {
  // 1. 대상 글과 모든 글의 유사도 계산
  const similarities = await this.calculateSimilarity(slug);
  
  // 2. 대상 글의 relatedPosts 업데이트
  await this.blogPostModel.updateOne(
    { slug },
    { $set: { relatedPosts: similarities.slice(0, 5) } }
  );
  
  // 3. 관련 글들의 relatedPosts에도 대상 글 추가 (양방향)
  for (const related of similarities.slice(0, 5)) {
    // related 글의 relatedPosts에 현재 글 추가
    // 이미 있으면 점수 업데이트, 없으면 추가
  }
}

5. 게시 시 자동 처리

가장 중요한 부분이다. 글을 게시할 때 자동으로 임베딩과 관련 글이 처리되어야 한다.

async publishDraft(draftId: string) {
  const draft = await this.findDraft(draftId);
  
  // 1. slug 생성 (없으면)
  if (!draft.slug) {
    draft.slug = generateSlug(draft.title);
  }
  
  // 2. 임베딩 + 관련 글 처리 (게시 전에 실행!)
  await this.generateEmbeddingAndRelatedPosts(draft.slug);
  
  // 3. 성공 후에만 게시 상태로 저장
  draft.isDraft = false;
  draft.isPublished = true;
  return draft.save();
}

핵심 포인트: 임베딩 생성을 게시 저장 전에 실행한다. 임베딩 실패 시 예외가 발생하고, 글은 초안 상태로 유지된다. 이렇게 해야 "게시됐는데 임베딩이 없는" 불일치 상태를 방지할 수 있다.

데이터 구조

MongoDB에 저장되는 임베딩 관련 필드:

interface BlogPost {
  // 기존 필드들...
  
  // 임베딩 관련
  embedding: {
    vector: number[];      // 3072차원 벡터
    model: string;         // "gemini-embedding-001"
    updatedAt: string;     // ISO 날짜
  };
  contentHash: string;     // 변경 감지용 해시
  previousContent: string; // 변경률 계산용
  
  // 관련 글
  relatedPosts: Array<{
    slug: string;
    title: string;
    relevanceScore: number;  // 0-100
    reason: string;          // "임베딩 유사도: 85%"
    analyzedAt: string;
  }>;
}

MCP 도구 확장

Claude Code에서 직접 임베딩 작업을 할 수 있도록 MCP 도구도 추가했다:

  • generate_embedding: 단일 글 임베딩 생성

  • generate_all_embeddings: 전체 글 일괄 처리

  • calculate_similarity: 유사도 계산

  • update_related_posts_bidirectional: 관련 글 양방향 업데이트

덕분에 "이 글의 관련 글 업데이트해줘"라고 말하면 Claude가 알아서 처리해준다.

결과

40개 글에 임베딩을 생성하고 관련 글을 업데이트했다. 예를 들어 "Claude Code에서 Grafana MCP 연동하기" 글의 관련 글:

  1. Claude Code에서 Coolify MCP 연동하기 - 86%

  2. Claude Code에서 블로그 글 작성하기: MCP 활용 - 84%

  3. NestJS + Prometheus + Grafana 모니터링 구축기 - 77%

  4. Claude Code 사용량 관리 크롬 익스텐션 - 75%

  5. 요즘 고민하는 것: 추상화 - 74%

MCP 관련 글, 모니터링 관련 글이 상위에 올라왔다. 태그만으로는 찾기 어려웠을 연관성을 임베딩이 잡아낸 것이다.

비용

Gemini Embedding API는 무료 티어가 넉넉하다:

  • 분당 15 요청

  • 일일 1,500 요청

개인 블로그 수준에서는 충분하다. 40개 글 전체 처리에 약 4분 정도 걸렸다.

마무리

태그 기반에서 임베딩 기반으로 관련 글 추천을 개선했다. 구현하면서 느낀 점:

  • 임베딩은 생각보다 쉽다: API 호출 한 번이면 벡터가 나온다

  • Rate Limiting이 핵심: 무료 티어 제한을 고려한 배치 처리 필수

  • 상태 일관성 중요: 게시-임베딩 순서를 잘못 잡으면 불일치 발생

  • 양방향 업데이트: 사용자 경험을 위해 필요한 로직

AIEmbeddingNestJSGemini API블로그 개발

관련 글

Claude Code에서 블로그 글 작성하기: MCP를 직접 만들어 활용한 경험

Claude Code로 개발하면서 얻은 지식을 블로그에 정리하고 싶었습니다. 하지만 AI에게 블로그 관리자 권한을 모두 주기엔 불안했고, 필요한 API만 분기 처리하기엔 번거로웠습니다. MCP(Model Context Protocol)를 직접 만들어서 권한을 명확히 분리하고, Few-shot 예시와 SEO 가이드라인을 1회 호출로 제공하도록 개선한 경험을 공유합니다.

관련도 92%

개인 블로그에 AI 검색 달기 (1) - 왜 하이브리드 검색인가

블로그 검색 기능을 개선하면서 키워드 검색의 한계를 느꼈습니다. 벡터 검색과 키워드 검색을 결합한 하이브리드 검색을 선택한 이유와 아키텍처 설계 과정을 공유합니다.

관련도 91%

개인 블로그에 AI 검색 달기 (2) - MongoDB Atlas Vector Search 구현

MongoDB Atlas Vector Search 인덱스 설정부터 NestJS에서 하이브리드 검색을 구현하는 과정. $vectorSearch의 null 필터 제한사항과 RRF 알고리즘, 유사도 임계값 튜닝까지.

관련도 91%