링크 자동 요약 & 자동 태깅 개발기

LinkDropper·2025년 11월 13일
9

Link Dropper

목록 보기
14/15
post-thumbnail

“링크만 잔뜩 쌓이는 북마크를, 한 번에 읽을 수 있는 노션 스타일 요약 문서로 바꿔보자.”

링크 드라퍼에 웹 링크 자동 요약 + 자동 태깅 기능을 추가하면서 겪었던 기술적 선택과 시행착오를 정리해 보았습니다.
단순히 제목/설명 메타태그를 긁어오는 수준이 아니라, 실제 본문을 분석해서 구조화된 마크다운 문서와 상위 카테고리 태그까지 뽑아내는 것이 목표였습니다.

참고: 현재는 영상 위주 콘텐츠본문이 거의 없는 페이지는 요약이 실패하는 한계가 있습니다. 이 부분은 빠르게 개선 중입니다.


1. 무엇을 해결하고 싶었나

북마크 서비스나 “나중에 읽기” 도구를 써보면, 결국 이렇게 되기 쉽습니다.

  • 제목만 잔뜩 쌓이고
  • 나중에 보면 왜 저장했는지 기억 안 나고
  • 다시 읽기엔 시간이 부족한 링크 더미…

그래서 링크 드라퍼에서는 “링크를 저장하면, 바로 요약과 태그까지 자동으로 만들어주는 기능”을 만들기로 했습니다.

  • 링크를 저장하면
  • 서버에서 HTML을 크롤링 → 본문 추출 → LLM으로 요약/태깅
  • 결과를 노션 스타일 마크다운 문서 + 상위 카테고리 태그로 저장

사용자는 “링크 목록”이 아니라 “요약된 문서 컬렉션”을 보게 되는 경험을 목표로 했습니다.


2. 전체 아키텍처 한 번에 보기

기술 스택

  • Backend: FastAPI
  • LLM: Upstage Solar Pro
  • HTML 파싱/조작: BeautifulSoup4, lxml

처리 플로우

1. 사용자가 URL 저장
   ↓
2. 웹 크롤링으로 HTML 수집
   ↓
3. HTML에서 본문 텍스트 추출
   ↓
4. 노이즈 제거 및 텍스트 정제
   ↓
5. LLM에 요약 + 태그 요청
   ↓
6. 구조화된 마크다운(summary) + 태그(tags) 반환
   ↓
7. DB 저장 및 링크 드라퍼에서 노션 스타일로 표시

API 설계 (텍스트 직접 요약용)

# 텍스트 직접 입력 방식
POST /summarize-text
{
  "text": "요약할 본문 텍스트..."
}

# 응답 형식
{
  "summary": "## 제목\n\n### 섹션1\n내용...",
  "tags": ["개발", "AI"]
}

실제 서비스에서는 URL을 받아서 내부에서 HTML → 텍스트 추출 후, 이 /summarize-text 흐름으로 연결해 사용하고 있습니다.


3. 본문 추출: 노이즈 제거가 품질의 절반

“아무리 좋은 LLM도, 쓰레기 입력에는 쓰레기 출력(Garbage In, Garbage Out)”
그래서 제일 먼저 신경 쓴 건 본문만 깔끔하게 추출하는 것이었습니다.

3.1 노이즈 제거: XPath + 스팸 키워드

HTML에는 우리가 원하지 않는 것들이 너무 많이 섞여 있습니다.

  • 댓글
  • 사이드바
  • 푸터
  • 광고
  • 스팸 링크 블록 등

lxml의 XPath를 활용해서, 이런 영역을 사전에 날려버리는 전략을 썼습니다.

# XPath 기반 노이즈 블록 제거
NOISE_XPATHS = [
    "//*[contains(@id,'comment')]",
    "//*[contains(@class,'sidebar')]",
    "//*[contains(@id,'footer')]",
    "//*[contains(@class,'ad')]",
]

# 스팸 키워드 필터링
SPAM_KEYWORDS = [
    "casino", "betting", "카지노", "토토", "대출"
]

구현 포인트는 대략 이런 느낌입니다.

  • lxml으로 DOM을 파싱하고, NOISE_XPATHS에 해당하는 노드를 통째로 제거
  • 특정 스팸 키워드를 다수 포함한 블록은 과감히 필터링
  • 다중 링크만 잔뜩 들어 있는 라인도 스팸 후보로 간주

이렇게 본문 후보를 최대한 깨끗하게 만든 뒤에, Trafilatura로 넘깁니다.

3.2 Trafilatura로 본문 추출

BeautifulSoup만으로는 “어디까지가 본문인지” 판단하기 어렵습니다.
그래서 머신러닝 기반 본문 추출 라이브러리인 Trafilatura를 사용했습니다.

def parse_content(html: str) -> Optional[str]:
    """Trafilatura를 활용한 본문 추출"""
    text = trafilatura.extract(
        html,
        include_comments=False,  # 댓글 제외
        include_tables=True,     # 표 포함
        no_fallback=False        # 폴백 활성화
    )

    # 최소 100자 이상일 때만 유효
    if text and len(text.strip()) > 100:
        return text.strip()

    return None

선택 이유는 다음과 같습니다.

  • BeautifulSoup는 “태그 파서”일 뿐, 본문/비본문 구분에 특화되어 있지 않음
  • Trafilatura는 다양한 사이트 구조에 대해 경험적으로 꽤 높은 정확도를 보여줌
  • 옵션으로 댓글 제외, 테이블 포함 등을 섬세하게 조정할 수 있음

len(text) > 100 같은 간단한 기준이지만, 실제로 너무 짧은 텍스트는 요약의 의미가 없는 경우가 많아서 유효성 체크에 꽤 도움이 됐습니다.


4. LLM 요약 서비스: Upstage Solar Pro

본문 텍스트가 준비되면, 이제 LLM에 넘겨서 “노션 스타일 요약 문서 + 태그”를 만들어야 합니다.

4.1 서비스 구조

class SummarizationService:
    def __init__(self):
        self.client = OpenAI(
            api_key=UPSTAGE_API_KEY,
            base_url="https://api.upstage.ai/v1/solar"
        )

    def summarize(self, text: str) -> dict:
        response = self.client.chat.completions.create(
            model="solar-pro",
            messages=[
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": user_prompt}
            ],
            temperature=0.1,      # 일관성 중시
            max_tokens=8192,      # 충분한 응답 길이
        )

        result = parse_llm_json(response.choices[0].message.content)
        return result

설계 선택 포인트:

  • Temperature 0.1
    창의적인 문장보다는, 일관되고 재현 가능한 요약이 더 중요하다고 판단했습니다.
  • max_tokens 8192
    긴 글도 통으로 넣고 싶어서 토큰을 넉넉하게 잡았습니다.
    (초기에는 4096으로 했다가, 긴 문서가 뒷부분에서 잘리는 문제가 있었고, 이후 8192로 올렸습니다.)

4.2 JSON 파싱 지옥 탈출기

LLM에게 “JSON으로만 답해줘”라고 아무리 말해도, 현실은 이렇게 옵니다.

1. ```json
   { ... }
  1. { ... }
  2. 설명 텍스트... { ... } 설명 텍스트...
  3. 심지어 후행 쉼표(trailing comma)까지…

그래서 응답 파싱 로직을 꽤 튼튼하게 만들 필요가 있었습니다.

```python
def extract_json_string(raw_text: str) -> str:
    # 1순위: 마크다운 코드 블록 내 JSON 추출
    code_block_match = re.search(
        r"```(?:json)?\s*(\{[\s\S]*?\})\s*```",
        raw_text
    )
    if code_block_match:
        return code_block_match.group(1)

    # 2순위: 문자열 시작부터 JSON
    if raw_text.lstrip().startswith("{"):
        return raw_text

    # 3순위: 텍스트 중간에 포함된 JSON 객체
    json_match = re.search(r"\{[\s\S]*\}", raw_text)
    if json_match:
        return json_match.group(0)

    raise ValueError("JSON 응답을 찾을 수 없습니다")

여기에 더해:

  • 후행 쉼표(trailing comma) 제거
  • json.loads 실패 시 로그 남기고 재시도/실패 처리

등을 유틸 함수(utils/llm_json.py)로 분리해서, 다른 LLM 연동에도 재사용할 수 있게 만들었습니다.


5. 프롬프트 엔지니어링: “추측하지 마”를 가르치기

가장 시간이 많이 들어간 부분은 프롬프트 엔지니어링이었습니다.
원하는 건 “간단한 요약”이 아니라:

  • 원문에 없는 내용은 절대 넣지 않고
  • 수치/날짜 등은 정확히 보존하면서
  • 노션 페이지처럼 구조화된 마크다운을 뱉는 것이었습니다.

5.1 시스템 프롬프트: 엄격 원칙

SYSTEM_PROMPT = """당신은 정확한 요약 및 상위 분류 전문가입니다.

[엄격 원칙]
1) 제공된 본문만 사용하고, 배경지식/추측/외부 사실을 추가하지 마세요.
2) 수치·날짜·고유명사·단위 등 사실 요소는 원문 그대로 보존하세요.
3) 정보가 불명확하거나 부족하면 그 사실을 간단히 언급하고 추론하지 마세요.
4) 콘텐츠가 아닌 잡음(메뉴/광고/저작권 문구)은 무시하세요.
5) 어조는 중립적·비평가적이며 과장이나 축소 없이 기술하세요.
"""

이런 원칙이 없으면 LLM은 쉽게:

  • 배경지식을 섞어서 “그럴듯한 이야기”를 만들어내거나
  • 숫자/날짜를 자기 멋대로 바꾸는 할루시네이션을 일으키거나
  • “~ 것으로 보인다” 같은 애매한 추론 문장을 추가합니다.

원문을 기반으로 한 정확한 요약이 목표이기 때문에, 이 부분을 계속 강조했습니다.

5.2 마크다운 출력 구조 강제

요약 결과는 Link Dropper에서 노션 페이지처럼 바로 읽히는 문서여야 합니다.
그래서 아예 마크다운 구조를 프롬프트에 못 박았습니다.

[마크다운 문서 작성 규칙]
- 이모지 사용 절대 금지
- 노션 페이지처럼 제목과 섹션으로 구조화된 완성된 문서
- 반드시 다음 구조를 따르세요:
  * ## [본문의 핵심 주제] - 문서 제목 (H2)
  * ### [섹션명] - 주요 섹션 구분 (H3, 2-3개 섹션 권장)
  * 각 섹션은 2-3개의 완전한 문장으로 구성
  * **굵은 글씨**: 중요 키워드, 수치, 날짜 강조
- 전체 길이: 일반적으로 1,200자 이내

예상하는 결과물은 이런 느낌입니다.

## AI 에이전트의 미래와 도전과제

### 기술 발전 현황
최근 **LangChain**과 **AutoGPT** 같은 프레임워크가 등장하면서
AI 에이전트 개발이 대중화되고 있습니다. **2024년 1분기** 기준으로
관련 오픈소스 프로젝트가 **300% 증가**했습니다.

### 남은 과제들
그러나 환각(hallucination) 문제와 비용 증가가 상용화의 걸림돌로
작용하고 있습니다. 특히 **GPT-4**를 사용할 경우 토큰당 비용이
이전 모델 대비 **10배** 높아졌습니다.

이렇게 구조를 강제해 두면, 링크 드라퍼 쪽에서는 별도의 후처리 없이 바로 보여줄 수 있습니다.

5.3 카테고리 태그 시스템

태그는 자유롭게 적게 두면 너무 세분화되어서 관리가 안 되기 때문에, 상위 카테고리 집합을 만들어 두었습니다.

ALLOWED_TAG_CATEGORIES = {
    "기술/개발": ["개발", "AI", "클라우드", "데이터", "보안"],
    "비즈니스": ["경제", "금융", "스타트업", "마케팅"],
    "사회/문화": ["사회", "정치", "교육", "문화"],
    "과학": ["과학", "의료", "환경"],
    "기타": ["스포츠", "엔터테인먼트", "게임"]
}

[태그 선택 규칙]
- 가능한 한 상위(넓은) 범주를 고르세요
- 태그 수는 2-4개를 넘지 마세요

의도한 효과:

  • 태그가 지나치게 쪼개지지 않도록 제어
  • 검색/필터링에 의미 있는 상위 카테고리 수준 유지
  • LLM이 태그를 마구 찍어내지 않도록 제한

5.4 체크리스트로 자가 검증 유도

프롬프트 끝에는 LLM이 스스로 출력물을 점검해 보도록 체크리스트를 추가했습니다.

[출력 점검 체크리스트]
- [ ] "summary"는 ## 제목으로 시작하는 노션 스타일 문서인가?
- [ ] ### 섹션 제목이 2-3개 포함되어 있는가?
- [ ] 이모지가 전혀 사용되지 않았는가?
- [ ] 핵심 수치/이름/날짜가 **굵게** 강조되었는가?
- [ ] 본문에 없는 주장·해석이 없는가?
- [ ] "tags"는 2-4개이며 허용 집합에서만 선택했는가?

체감상:

  • 이모지 사용 금지
  • 섹션 개수/구조 준수

같은 부분에서 프롬프트 준수율이 30~40% 정도는 올라간 느낌이었습니다.


6. 데이터 검증 & 에러 처리

LLM 응답을 그대로 신뢰할 수는 없기 때문에, 검증 레이어를 여러 단계로 두었습니다.

6.1 요청 검증: Pydantic

너무 짧은 텍스트는 요약해도 의미가 없기 때문에, 요약 API 요청 자체를 검증했습니다.

class SummarizeTextRequest(BaseModel):
    text: str

    @field_validator('text')
    @classmethod
    def validate_text(cls, v: str) -> str:
        if not v or not v.strip():
            raise ValueError("텍스트는 비어있을 수 없습니다")

        word_count = len(v.split())
        if word_count < 10:
            raise ValueError(
                f"텍스트가 너무 짧습니다. "
                f"최소 10단어 이상 필요합니다. (현재: {word_count}단어)"
            )

        return v

이 단계에서 걸러지면, 굳이 LLM 호출 비용을 쓰지 않아도 됩니다.

6.2 응답 검증: 필수 필드 체크

LLM 응답이 JSON이라 해도, 필수 필드가 빠져 있을 수 있습니다.

def summarize(self, text: str) -> dict:
    result = parse_llm_json(content)

    # 필수 필드 검증
    if "summary" not in result or "tags" not in result:
        raise ValueError("응답에 필수 필드가 없습니다")

    # summary 검증
    if not result["summary"]:
        raise ValueError("summary가 비어있습니다")

    # tags 검증
    if not isinstance(result["tags"], list) or len(result["tags"]) == 0:
        raise ValueError("tags는 비어있지 않은 리스트여야 합니다")

    return result

이렇게 검증 레이어를 여러 개 두면:

  • 디버깅 포인트가 명확해지고
  • “요약이 안 됐다”는 이슈가 왔을 때, 어느 단계에서 실패했는지 빠르게 확인할 수 있습니다.

7. 1차 구현 → 2차 리팩토링: 뭐가 달라졌나

7.1 초기 문제들

처음 구현했을 때는 다음과 같은 문제들이 있었습니다.

  • max_tokens=4096으로 인해 긴 문서 요약이 중간에서 잘림
  • 프롬프트가 너무 자유로워서 출력 형식이 제각각
  • LLM이 JSON을 깨끗하게 안 보내서 파싱 실패 케이스 다수

7.2 개선 작업 (커밋 로그 느낌으로)

[설정] 요약 기능을 위한 상수 및 설정 파일 추가
[유틸리티] 텍스트 정제 및 노이즈 제거 유틸리티 추가
[서비스] LLM 기반 텍스트 요약 서비스 추가
[Refactor] LLM JSON 파싱 유틸 도입
[Refactor] 요약 프롬프트 상수 구조화
[요약] maxToken 놀리기 (4096 → 8192)

주요 변경 사항은 네 가지였습니다.

  1. 프롬프트 상수 분리

    • 하드코딩된 프롬프트 → constants/summarize.py로 분리
  2. JSON 파싱 유틸 모듈화

    • 응답 파싱 로직 → utils/llm_json.py로 분리, 재사용 가능하게
  3. maxTokens 증가 (4096 → 8192)

    • 긴 문서에서도 요약이 중간에서 끊기지 않도록
  4. 텍스트 정제 강화

    • 노이즈 제거, 최소 길이 체크 등 입력 품질 높이기

7.3 성능 변화

항목개선 전개선 후
요약 형식 준수율~60%~90%
JSON 파싱 성공률~75%~98%
평균 응답 시간3–4초3–4초 (동일)
긴 문서 처리잘림 다수안정적

수치 자체는 체감 기반이지만, 실제로 운영하면서 느끼는 에러 빈도와 수동 수정 빈도는 확실히 줄어든 편입니다.


8. 현재 한계와 앞으로의 계획

8.1 현재 한계

  1. 영상 위주 콘텐츠 요약 미지원

    • 유튜브처럼 영상만 있고, 본문 텍스트가 거의 없는 페이지는 현재 요약에 실패합니다.
    • 자막/트랜스크립트 기반 요약은 별도의 파이프라인이 필요해서, 추후 작업으로 분리해두었습니다.
  2. 본문이 없는 랜딩 페이지

    • 이미지/레이아웃 위주의 랜딩 페이지도 마찬가지로, 텍스트 추출 자체가 어려워서 요약이 실패합니다.

이 부분은 사용자에게도 명시적으로 안내하고, 빠른 시일 내에 개선할 예정입니다.

8.2 앞으로 하고 싶은 것들

  1. 스트리밍 응답 지원

    • 현재는 서버에서 요약이 완전히 끝날 때까지 기다렸다가 한 번에 반환합니다.
    • 추후에는 스트리밍 형태로 문서가 채워지는 UX를 검토하고 있습니다.
  2. 캐싱 전략

    • 동일한 URL을 여러 번 요약하는 것은 낭비이기 때문에,
      Redis 같은 캐시를 두고 중복 요약을 피하는 전략을 도입할 계획입니다.
  3. 다국어 지원 강화

    • 지금은 한국어/영어 중심으로 동작하지만,
    • 언어별로 프롬프트를 최적화해서 다국어 페이지에 더 잘 대응하고 싶습니다.
  4. 요약 품질 평가 메트릭 도입

    • 현재는 사람 눈으로만 “좋다/별로다”를 판단하고 있습니다.
    • 가능하면 ROUGE, BERTScore 같은 지표를 참고해 정량적인 지표를 도입해 보는 것이 목표입니다.

9. 마치며

겉으로 보기에는 “링크 요약 기능 하나 추가했다”처럼 보이지만, 실제 구현은:

  • 웹 크롤링
  • 본문 추출
  • 텍스트 정제
  • LLM 프롬프트 설계
  • JSON 파싱
  • 데이터 검증 & 에러 처리

까지 여러 레이어가 겹쳐 있는 꽤 긴 파이프라인이었습니다.

특히 느낀 점은:

  • 프롬프트 엔지니어링은 한 번에 끝나지 않는다
    → 실제 사용 데이터를 보면서 계속 다듬어야 합니다.
  • LLM 응답은 항상 예외 케이스가 있다
    → 파싱과 검증 로직을 충분히 탄탄하게 짜두는 게 중요합니다.
  • 노이즈 제거가 요약 품질의 50%
    → 좋은 모델을 쓰는 것만큼, 좋은 입력을 만들어 주는 것이 중요합니다.

지금은 링크 드라퍼에서 링크를 저장하면 자동으로 요약 문서와 태그가 붙는 경험을 제공하고 있고,
앞으로는 영상/비텍스트 페이지까지 커버하도록 계속 확장해 나갈 예정입니다.

🧪 링크 드라퍼, 정식 출시!

링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.

• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장

👉 링크 드라퍼 사용하러 가기
👉 크롬 웹스토어에서 설치하기

💬 카카오톡 채널 추가하고 소식 받기

서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 카카오톡 채널 추가하기

profile
“기록하는 습관을 도구로 만들다 — 두 개발자의 링크 드라퍼 구축기”

0개의 댓글