노티노티 서비스를 운영한 지도 벌써 4년 차가 되었다. 그간 여러 기능을 넣고, 빼고, 다시 다듬으면서 서비스 유지에 힘이 든다는 것을 느껴 유지보수 리소스를 최소화 하고자 스스로에게 질문을 던졌다.
이 서비스의 핵심 가치는 무엇인가?
재학 시절에 내게 필요한 공지사항이 언제쯤 올라올지, 올라왔는지 매번 확인하느라 불편함을 겪었다. 이건 비단 나뿐만이 아니라 재학생 모두가 겪고 있는 상황일 거라 판단했다. 그래서 이 키워드 알림 기능을 앞세워 서비스를 만들었고, 이제는 이 기능을 개선하고자 노력하고 있다.
단어 하나로 문장을 이해할 수 있을까?
내가 만들고자 하는 알림 시스템에는 제약사항이 존재한다. 유저는 반드시 수신하고자 하는 내용의 키워드를 입력하는 것으로 끝이어야 하고, 그 외에는 어떠한 input을 받지 않기로 한다.
“장학금”, “수강신청”, “졸업”
위 키워드와 관련된 공지사항을 찾아 알림을 보내야 한다. 겉으로 보기에는 크게 어렵지 않은 문제였다.
그 단어가 제목에 포함되어 있으면 알림을 보내면 되지 않을까?
그렇게 첫 번째 버전을 만들게 되었다.
1. 제목에 키워드가 들어가면 알림을 보내자
로직은 단순했다.
if (title.includes(keyword)) {
sendNotification();
}
빠르고, 명확하고, 설명 가능했다. 하지만 곧 한계를 마주했다.
예를 들어 다음과 같은 공지사항 제목이 있다.
2026학년도 장학제도 개편 안내
유저가 등록한 “장학금”.
거리가 아주 가까운 공지사항이지만 겨우 “금”이라는 글자 하나 때문에 알림을 수신하지 못하는 경우가 생기게 된 것이다.
이 외에도 여러 문제가 있었다.
- 키워드가 제목이 아닌 본문에만 등장하는 경우
- 유사 표현 (채용 vs 인턴)
관련이 있는데 알림이 안 가는 문제가 다수 발생했고, 개선할 필요성을 느꼈다.
그래서 두 번째 시도를 했다.
2. 의미를 이해하게 만들자
욕심이 생겼다.
단어 매칭이 아니라, 의미를 이해하게 만들면 되지 않을까?
조금은 사람처럼 문맥을 이해하고, 판단할 수 있으면 좋겠다는 생각이 들었다.
공지사항, 키워드를 전부 벡터화 하고, Vector Search를 통해 유사성을 검색해 알림을 보내기로 한다.
키워드와 공지의 문맥을 비교하기 위해서는 제목 뿐만 아니라 본문의 내용까지 필요했다.
- 공지사항의 본문에는 이미지만 있는 경우도 다수 존재
- 공지사항 본문에는 너무나도 세부적인 내용이 많아 텍스트가 embedding 모델에 곧바로 들어가기 어려움
따라서 공지사항을 llm으로 요약하는 전처리 과정을 추가로 넣어 embedding에 적합하게 구성했다.
// 전처리
const res = await client.responses.create({
model: "gpt-4o",
temperature: 0,
input: [...],
});
const text = res.output_text;
// 임베딩
const embedding = await client.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
pinecone이라는 외부 Serverless 벡터 DB에 저장해 검색을 하고, 유사도 점수가 일정 threshold 이상이면 알림을 전송했다.
기대했던 건 다음과 같다.
- 채용에 관한 글 vs 인턴 키워드에 대한 매칭 (유사 표현 매칭)
- 장학제도에 관한 글 vs 장학금 키워드에 대한 매칭 (유사 토픽 매칭)
벡터 검색의 본질은 고차원 벡터 공간에서 가까운 표현을 찾는 것이다.
등록한 키워드가 “졸업”이라고 했을 때
- [채용] 교육혁신처 학사운영팀 행정조교 채용 공고
- 2025학년도 전기(2026년 2월) 졸업대상자 명단 안내
- 2025학년도 전기 학위수여식 안내
과연 어떤 글이 “졸업”과 매칭을 이루어 알림을 보내게 할 것인가? 이런 식의 이분법의 결정을 짓기에는 어려움이 컸다.
Embedding 공간에서는 이런 공지들이 서로 완전히 멀리 떨어지지 않는다. 대부분의 공지사항과 유저가 등록하는 키워드의 비교에서 점수대가 비슷했고, threshold를 조정하는 것만으로 알림을 보낼지 말지 선택할 수 없었다.
상대적인 유사도에 사용하기에는 적합하지만, 절대적인 기준을 제공하기는 어렵다.
3. 형태소 기반(NLP) 매칭으로 알림을 보내자
문득 새로운 아이디어가 떠올랐다.
한국어를 의미가 담긴 명사만 추출해서 유저의 키워드와 매칭하면 정확도를 매우 높일 수 있지 않을까?
다시 돌아와 다음을 비교해보자.
“2026학년도 수강과목 신청 안내” vs “수강신청”
이는 다음과 같이 명사로 분리되어 비교할 수 있다.
“학년, 수강, 과목, 신청, 안내” vs “수강, 신청”
이 방식은 벡터 검색처럼 과한 의미의 확장을 막고, 유사 토픽(장학제도 vs 장학금)에 대해 방지할 방안으로 충분했다.
from kiwipiepy import Kiwi
kiwi = Kiwi()
tokens = list(
{token.form for token in kiwi.tokenize(text) if token.tag in ("NNG", "NNP")}
)
감사하게도 한국어 형태소 분석기 라이브러리인 Kiwi를 이용해 쉽게 구현할 수 있었다.
또한, 노이즈를 줄이기 위해 의미가 있는 [NNG: 일반 명사, NNP: 고유 명사]만을 추출해서 사용했다.
const required = Math.max(1, Math.ceil(q.kiwi["length"] * 0.4));
if (matched < required) continue;
그리고 과도한 Recall을 방지하기 위해 유저 키워드도 형태소로 분리해 40% 이상 매칭이 되면 알림을 보내게끔 조정했다.
그리고 아마 최종적으로 이 방안을 채택하지 않을까 싶다.
Trade-off
결과적으로 키워드 알림 시스템 구현을 위한 심층적인 방법을 크게 두 가지 고민했다.
- 벡터 검색(의미 확장을 위한 문맥 분석과 유사성 검색)
- 형태소 매칭(명사로 분리 및 동일 내용 매칭)
각각의 장단점을 소개하자면
Vector Search
장점
-
유사 표현 매칭 가능
- 채용 vs 인턴
- 졸업 vs 학위수여식
- 키워드가 직접적으로 등장하지 않아도 탐지 가능
단점
- 대부분의 공지사항이 비슷한 점수대로 수렴 (threshold를 결정하는 새로운 기준 필요)
- Embedding + Vector DB 비용 발생
가장 관련 있는 키워드 후보. 즉, 랭킹을 탐색하고자 한 것이 아니라 이 키워드가 이 공지사항에 해당되는지를 판단하는 이분법적인 문제에는 애매한 방법이지 않을까 싶다.
형태소 매칭
장점
- 외부 비용 문제 없음
- threshold 설계가 직관적
단점
- 의미 확장 불가
결국에는 Trade-off 단계를 마주한 것 같다. Precision을 높이고 Recall을 줄일 것인지, Recall을 높이고 Precision을 줄일 것인지.
내 개인적인 선택은 Precision을 높이는 방향이다. 하루에 공지사항이 4~5개 등록되는 상황에 원치 않는 알림이 도착하면 그것대로 불편할 것 같다.