
“링크만 잔뜩 쌓이는 북마크를, 한 번에 읽을 수 있는 노션 스타일 요약 문서로 바꿔보자.”
링크 드라퍼에 웹 링크 자동 요약 + 자동 태깅 기능을 추가하면서 겪었던 기술적 선택과 시행착오를 정리해 보았습니다.
단순히 제목/설명 메타태그를 긁어오는 수준이 아니라, 실제 본문을 분석해서 구조화된 마크다운 문서와 상위 카테고리 태그까지 뽑아내는 것이 목표였습니다.
참고: 현재는 영상 위주 콘텐츠나 본문이 거의 없는 페이지는 요약이 실패하는 한계가 있습니다. 이 부분은 빠르게 개선 중입니다.
북마크 서비스나 “나중에 읽기” 도구를 써보면, 결국 이렇게 되기 쉽습니다.
그래서 링크 드라퍼에서는 “링크를 저장하면, 바로 요약과 태그까지 자동으로 만들어주는 기능”을 만들기로 했습니다.
사용자는 “링크 목록”이 아니라 “요약된 문서 컬렉션”을 보게 되는 경험을 목표로 했습니다.
1. 사용자가 URL 저장
↓
2. 웹 크롤링으로 HTML 수집
↓
3. HTML에서 본문 텍스트 추출
↓
4. 노이즈 제거 및 텍스트 정제
↓
5. LLM에 요약 + 태그 요청
↓
6. 구조화된 마크다운(summary) + 태그(tags) 반환
↓
7. DB 저장 및 링크 드라퍼에서 노션 스타일로 표시
# 텍스트 직접 입력 방식
POST /summarize-text
{
"text": "요약할 본문 텍스트..."
}
# 응답 형식
{
"summary": "## 제목\n\n### 섹션1\n내용...",
"tags": ["개발", "AI"]
}
실제 서비스에서는 URL을 받아서 내부에서 HTML → 텍스트 추출 후, 이 /summarize-text 흐름으로 연결해 사용하고 있습니다.
“아무리 좋은 LLM도, 쓰레기 입력에는 쓰레기 출력(Garbage In, Garbage Out)”
그래서 제일 먼저 신경 쓴 건 본문만 깔끔하게 추출하는 것이었습니다.
HTML에는 우리가 원하지 않는 것들이 너무 많이 섞여 있습니다.
lxml의 XPath를 활용해서, 이런 영역을 사전에 날려버리는 전략을 썼습니다.
# XPath 기반 노이즈 블록 제거
NOISE_XPATHS = [
"//*[contains(@id,'comment')]",
"//*[contains(@class,'sidebar')]",
"//*[contains(@id,'footer')]",
"//*[contains(@class,'ad')]",
]
# 스팸 키워드 필터링
SPAM_KEYWORDS = [
"casino", "betting", "카지노", "토토", "대출"
]
구현 포인트는 대략 이런 느낌입니다.
NOISE_XPATHS에 해당하는 노드를 통째로 제거이렇게 본문 후보를 최대한 깨끗하게 만든 뒤에, 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
선택 이유는 다음과 같습니다.
len(text) > 100 같은 간단한 기준이지만, 실제로 너무 짧은 텍스트는 요약의 의미가 없는 경우가 많아서 유효성 체크에 꽤 도움이 됐습니다.
본문 텍스트가 준비되면, 이제 LLM에 넘겨서 “노션 스타일 요약 문서 + 태그”를 만들어야 합니다.
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
설계 선택 포인트:
LLM에게 “JSON으로만 답해줘”라고 아무리 말해도, 현실은 이렇게 옵니다.
1. ```json
{ ... }
그래서 응답 파싱 로직을 꽤 튼튼하게 만들 필요가 있었습니다.
```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 응답을 찾을 수 없습니다")
여기에 더해:
json.loads 실패 시 로그 남기고 재시도/실패 처리등을 유틸 함수(utils/llm_json.py)로 분리해서, 다른 LLM 연동에도 재사용할 수 있게 만들었습니다.
가장 시간이 많이 들어간 부분은 프롬프트 엔지니어링이었습니다.
원하는 건 “간단한 요약”이 아니라:
SYSTEM_PROMPT = """당신은 정확한 요약 및 상위 분류 전문가입니다.
[엄격 원칙]
1) 제공된 본문만 사용하고, 배경지식/추측/외부 사실을 추가하지 마세요.
2) 수치·날짜·고유명사·단위 등 사실 요소는 원문 그대로 보존하세요.
3) 정보가 불명확하거나 부족하면 그 사실을 간단히 언급하고 추론하지 마세요.
4) 콘텐츠가 아닌 잡음(메뉴/광고/저작권 문구)은 무시하세요.
5) 어조는 중립적·비평가적이며 과장이나 축소 없이 기술하세요.
"""
이런 원칙이 없으면 LLM은 쉽게:
원문을 기반으로 한 정확한 요약이 목표이기 때문에, 이 부분을 계속 강조했습니다.
요약 결과는 Link Dropper에서 노션 페이지처럼 바로 읽히는 문서여야 합니다.
그래서 아예 마크다운 구조를 프롬프트에 못 박았습니다.
[마크다운 문서 작성 규칙]
- 이모지 사용 절대 금지
- 노션 페이지처럼 제목과 섹션으로 구조화된 완성된 문서
- 반드시 다음 구조를 따르세요:
* ## [본문의 핵심 주제] - 문서 제목 (H2)
* ### [섹션명] - 주요 섹션 구분 (H3, 2-3개 섹션 권장)
* 각 섹션은 2-3개의 완전한 문장으로 구성
* **굵은 글씨**: 중요 키워드, 수치, 날짜 강조
- 전체 길이: 일반적으로 1,200자 이내
예상하는 결과물은 이런 느낌입니다.
## AI 에이전트의 미래와 도전과제
### 기술 발전 현황
최근 **LangChain**과 **AutoGPT** 같은 프레임워크가 등장하면서
AI 에이전트 개발이 대중화되고 있습니다. **2024년 1분기** 기준으로
관련 오픈소스 프로젝트가 **300% 증가**했습니다.
### 남은 과제들
그러나 환각(hallucination) 문제와 비용 증가가 상용화의 걸림돌로
작용하고 있습니다. 특히 **GPT-4**를 사용할 경우 토큰당 비용이
이전 모델 대비 **10배** 높아졌습니다.
이렇게 구조를 강제해 두면, 링크 드라퍼 쪽에서는 별도의 후처리 없이 바로 보여줄 수 있습니다.
태그는 자유롭게 적게 두면 너무 세분화되어서 관리가 안 되기 때문에, 상위 카테고리 집합을 만들어 두었습니다.
ALLOWED_TAG_CATEGORIES = {
"기술/개발": ["개발", "AI", "클라우드", "데이터", "보안"],
"비즈니스": ["경제", "금융", "스타트업", "마케팅"],
"사회/문화": ["사회", "정치", "교육", "문화"],
"과학": ["과학", "의료", "환경"],
"기타": ["스포츠", "엔터테인먼트", "게임"]
}
[태그 선택 규칙]
- 가능한 한 상위(넓은) 범주를 고르세요
- 태그 수는 2-4개를 넘지 마세요
의도한 효과:
프롬프트 끝에는 LLM이 스스로 출력물을 점검해 보도록 체크리스트를 추가했습니다.
[출력 점검 체크리스트]
- [ ] "summary"는 ## 제목으로 시작하는 노션 스타일 문서인가?
- [ ] ### 섹션 제목이 2-3개 포함되어 있는가?
- [ ] 이모지가 전혀 사용되지 않았는가?
- [ ] 핵심 수치/이름/날짜가 **굵게** 강조되었는가?
- [ ] 본문에 없는 주장·해석이 없는가?
- [ ] "tags"는 2-4개이며 허용 집합에서만 선택했는가?
체감상:
같은 부분에서 프롬프트 준수율이 30~40% 정도는 올라간 느낌이었습니다.
LLM 응답을 그대로 신뢰할 수는 없기 때문에, 검증 레이어를 여러 단계로 두었습니다.
너무 짧은 텍스트는 요약해도 의미가 없기 때문에, 요약 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 호출 비용을 쓰지 않아도 됩니다.
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
이렇게 검증 레이어를 여러 개 두면:
처음 구현했을 때는 다음과 같은 문제들이 있었습니다.
max_tokens=4096으로 인해 긴 문서 요약이 중간에서 잘림[설정] 요약 기능을 위한 상수 및 설정 파일 추가
[유틸리티] 텍스트 정제 및 노이즈 제거 유틸리티 추가
[서비스] LLM 기반 텍스트 요약 서비스 추가
[Refactor] LLM JSON 파싱 유틸 도입
[Refactor] 요약 프롬프트 상수 구조화
[요약] maxToken 놀리기 (4096 → 8192)
주요 변경 사항은 네 가지였습니다.
프롬프트 상수 분리
constants/summarize.py로 분리JSON 파싱 유틸 모듈화
utils/llm_json.py로 분리, 재사용 가능하게maxTokens 증가 (4096 → 8192)
텍스트 정제 강화
| 항목 | 개선 전 | 개선 후 |
|---|---|---|
| 요약 형식 준수율 | ~60% | ~90% |
| JSON 파싱 성공률 | ~75% | ~98% |
| 평균 응답 시간 | 3–4초 | 3–4초 (동일) |
| 긴 문서 처리 | 잘림 다수 | 안정적 |
수치 자체는 체감 기반이지만, 실제로 운영하면서 느끼는 에러 빈도와 수동 수정 빈도는 확실히 줄어든 편입니다.
영상 위주 콘텐츠 요약 미지원
본문이 없는 랜딩 페이지
이 부분은 사용자에게도 명시적으로 안내하고, 빠른 시일 내에 개선할 예정입니다.
스트리밍 응답 지원
캐싱 전략
다국어 지원 강화
요약 품질 평가 메트릭 도입
겉으로 보기에는 “링크 요약 기능 하나 추가했다”처럼 보이지만, 실제 구현은:
까지 여러 레이어가 겹쳐 있는 꽤 긴 파이프라인이었습니다.
특히 느낀 점은:
지금은 링크 드라퍼에서 링크를 저장하면 자동으로 요약 문서와 태그가 붙는 경험을 제공하고 있고,
앞으로는 영상/비텍스트 페이지까지 커버하도록 계속 확장해 나갈 예정입니다.
링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.
• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ 크롬 익스텐션 원클릭 저장
👉 링크 드라퍼 사용하러 가기
👉 크롬 웹스토어에서 설치하기
서비스 업데이트
기능 꿀팁
카카오톡 채널을 통해 빠르게 받아보세요!
👉 카카오톡 채널 추가하기