소개팅 앱의 추천 점수를 궁합 점수 하나로 두지 않은 이유

@givemethatsewon· June 24, 2026· 7 min read

이번 구현에서 가장 먼저 분리한 것은 사용자에게 보여주는 궁합 점수실제 추천 순서를 정하는 내부 랭킹 점수였다.

소개팅 앱의 라운지에서는 궁합 점수가 높다고 무조건 먼저 보여주면 안 된다. 사용자가 그 사람을 마음에 들어 할 가능성도 중요하지만, 상대가 다시 반응할 가능성도 함께 봐야 하기 때문이다.

예를 들어 거리가 멀면 추천 순서는 낮출 수 있다. 하지만 그 이유로 사용자에게 "궁합이 낮다"고 보여주면 설명이 이상해진다. 거리는 노출 순서를 조정하는 신호이지, 두 사람의 궁합을 설명하는 신호는 아니다.

그래서 match_rate는 사용자에게 보여주는 설명용 점수로 두고, /discover/profiles 내부 정렬에는 별도의 RankScore를 사용했다. ML 모델을 바로 붙이기보다, 같은 입력이면 항상 같은 결과를 내는 MVP ranker와 200명 synthetic evaluation loop를 먼저 만들었다.

소개팅 앱 추천 시스템에서 표시용 사주 궁합 점수와 내부 랭킹 점수를 분리한 구조
소개팅 앱 추천 시스템에서 표시용 사주 궁합 점수와 내부 랭킹 점수를 분리한 구조

요약

  • 문제: 소개팅 앱 추천을 단순 궁합 점수 정렬로 만들면 실제 대화 가능성, 상호 관심, 노출 쏠림을 설명하기 어렵다.
  • 판단: 사용자에게 보이는 match_rate는 관계 설명용으로 유지하고, /discover/profiles 내부 정렬에는 별도의 RankScore를 둔다.
  • 구현: 궁합, 관심사, 거리, 나이 proxy, 프로필 완성도, freshness를 섞은 MVP ranker를 만들고, 남자 100명/여자 100명 synthetic profile로 기존 ranker와 비교했다.
  • 결과: MVP ranker는 대화 시작 가능성은 조금 높였지만, 서로 좋아할 가능성과 노출 분산은 나빠졌다.
  • 배운 점: synthetic 평가는 실제 성과를 증명하는 도구가 아니라, 새 ranker가 어떤 지표를 개선하고 어떤 지표를 망치는지 미리 보는 도구다.

상황

이번에 작업한 제품은 사주 기반 궁합, 라운지 스와이프, 대화 신청, 채팅을 가진 소개팅 앱이다. 기존에는 MBTI, 출생연도 기준 띠, 관심사, 거리로 궁합 점수를 계산하고, 그 점수를 라운지 카드에서 보여줬다.

처음에는 궁합 점수가 추천 정렬에도 자연스럽게 쓰일 수 있어 보였다. 예를 들어 궁합 95점인 후보를 궁합 70점인 후보보다 먼저 보여주는 것은 직관적이다. 하지만 제품 흐름을 놓고 보면 이 판단은 너무 단순했다.

라운지 추천은 "높은 점수를 보여주는 것"에서 끝나지 않는다. 더 중요한 목표는 "좋은 대화가 시작될 가능성을 높이는 것"이다. 이 둘은 비슷해 보이지만 다르다.

궁합 점수와 추천 랭킹은 목적이 다르다

사용자가 보는 궁합 점수는 설명용이다.

이 사람과 왜 잘 맞는다고 말하는가?
어떤 성향, 관심사, 거리 조건이 점수에 반영됐는가?
사용자가 이 점수를 보고 납득할 수 있는가?

반면 추천 랭킹은 노출 순서를 정하는 운영용 점수다.

이 후보를 지금 보여주면 사용자가 반응할 가능성이 높은가?
상대도 응답할 가능성이 있는가?
차단, 신고, 즉시 pass 가능성은 낮은가?
특정 후보에게 노출이 과도하게 몰리지 않는가?

그래서 match_rate를 내부 랭킹 점수처럼 쓰지 않기로 했다. 사용자에게 보이는 match_rate는 계속 표시용 궁합 점수로 유지하고, 내부 정렬에는 별도 RankScore를 둔다.

response.match_rate = DisplayCompatibility
internal.rank_score = DiscoveryRank

특히 거리 때문에 이 분리가 필요했다. 거리가 멀다는 이유로 추천 순서를 낮추는 것은 자연스럽다. 하지만 그 이유로 사용자에게 "궁합이 낮다"고 말하면 설명이 틀어진다. 거리는 노출 순서에서 감점할 수 있지만, 궁합 설명 자체를 망가뜨리면 안 된다.

아래 sequence diagram은 실제 라운지 요청에서 이 분리가 어떻게 쓰이는지 보여준다.

라운지 추천 요청에서 표시용 궁합 점수와 내부 RankScore를 분리해 응답하는 sequence diagram
라운지 추천 요청에서 표시용 궁합 점수와 내부 RankScore를 분리해 응답하는 sequence diagram

소개팅 추천은 reciprocal 문제다

일반 상품 추천은 대체로 P(user likes item)을 높이는 문제로 시작할 수 있다. 하지만 소개팅 추천에서는 item도 사람이다. 내가 상대를 좋아해도 상대가 응답하지 않으면 제품의 핵심 흐름인 대화가 시작되지 않는다.

그래서 장기 objective는 이런 형태가 더 맞다.

maximize ExpectedSuccessfulConversation

ExpectedSuccessfulConversation =
  P(A likes B)
* P(B likes A)
* P(first_reply_or_chat_continues)
* QualityWeight
- RiskPenalty

이 관점에서는 총 궁합 점수의 합을 최대화하는 것만으로는 부족하다. 남자 100명, 여자 100명이 있고 모든 사람을 1:1로 묶는 batch matching 문제라면 stable matching이나 maximum weight bipartite matching을 생각할 수 있다. 하지만 라운지 피드는 최종 짝을 바로 정하는 기능이 아니다. 사용자가 여러 후보를 보고, pass/like/대화 신청을 하고, 상대가 다시 응답하는 반복 추천 문제다.

그래서 기본 구현은 batch matching이 아니라 feed ranking으로 잡았다.

MVP에서는 ML보다 평가 가능한 proxy를 먼저 둔다

운영 이벤트가 충분히 쌓이면 P(mutual_like)P(first_reply)를 학습할 수 있다. 문제는 초기에는 데이터가 없다는 점이다. 이 단계에서 복잡한 모델을 먼저 붙이면 그럴듯한 모델 이름만 생기고, 실제로 무엇이 좋아졌는지 설명하기 어렵다.

여기서 ranker는 후보를 어떤 순서로 보여줄지 정하는 코드다. 처음부터 ML 모델을 학습시키지는 않았다. 대신 지금 DB에 있는 값으로 계산할 수 있는 proxy, 그러니까 "실제 행동을 직접 본 것은 아니지만 그 행동과 관련이 있을 것 같은 대리 신호"로 시작했다.

RankScore =
  0.35 * Compatibility
+ 0.20 * SharedInterest
+ 0.15 * DistanceFit
+ 0.10 * AgePreferenceFit
+ 0.10 * ProfileCompletenessAndActivity
+ 0.10 * ExplorationFreshness
- RiskPenalty

각 항목의 역할은 다르다.

항목 의도
Compatibility 제품의 차별점인 궁합 설명을 랭킹에도 반영한다.
SharedInterest 실제 대화 주제가 생길 가능성을 proxy로 본다.
DistanceFit 가까운 후보를 노출상 유리하게 둔다.
AgePreferenceFit 명시 선호 schema가 생기기 전까지 나이대 적합성을 약하게 반영한다.
ProfileCompletenessAndActivity 사진, 소개글, 관심사, 온보딩 완료 여부를 반영한다.
ExplorationFreshness 신규 후보가 최소한의 탐색 기회를 갖게 한다.
RiskPenalty 이미 본 후보, 차단/신고 후보 같은 부정 신호를 제외하거나 감점한다.

여기서 중요한 점은 이 값들이 "최종 정답"이 아니라는 점이다. 지금 단계의 목표는 완벽한 추천 모델이 아니다. 교체 가능한 점수 함수와 평가 루프를 만드는 것이다.

200명 synthetic profile과 event를 만든 이유

처음에는 "남자 100명, 여자 100명 더미를 넣자"가 요구사항처럼 보였다. 그런데 추천 시스템을 평가하려면 프로필만으로는 부족하다. 추천은 노출 이후 행동으로 평가된다.

여기서 synthetic은 실제 유저 데이터가 아니라 평가를 위해 코드로 만든 가짜 데이터라는 뜻이다. synthetic profile은 가짜 사용자 프로필이고, synthetic event는 그 가짜 사용자가 봤다, 좋아요를 눌렀다, 답장을 했다 같은 행동 로그를 확률 규칙으로 만든 것이다.

이 방식은 실제 성과를 보장하지 않는다. 대신 같은 조건에서 두 ranker를 비교할 수 있게 해준다. 실제 유저가 없는 초기 단계에서는 "완벽한 수치"보다 "무엇이 좋아지고 무엇이 나빠지는지 볼 수 있는 반복 실험"이 더 중요했다.

그래서 synthetic profile과 synthetic event를 나눴다.

프로필은 이렇게 만들었다.

  • 남성 100명, 여성 100명
  • 22세부터 38세까지의 생년월일
  • MBTI 16종 분산
  • 거리 bucket
  • 관심사 3-6개
  • 사진 수, 소개글, 온보딩 완료 여부 차이

그리고 이벤트는 DB schema를 늘리지 않고 메모리에서 생성했다.

  • impression
  • like
  • chat request
  • first reply
  • block/report proxy

이렇게 하면 production DB를 건드리지 않으면서도 현재 ranker와 새 ranker를 같은 조건에서 비교할 수 있다.

평가 결과가 한 방향으로 좋지는 않았다

평가 명령은 단순하게 만들었다.

cd apps/api
uv run python scripts/evaluate_matching.py

결과는 한 방향으로만 좋지는 않았다.

표는 이렇게 읽으면 된다. like_rate는 사용자가 좋아요를 누를 가능성, mutual_like_rate는 양쪽이 서로 좋아할 가능성, first_reply_rate는 대화가 실제로 시작될 가능성에 가깝다. unique_exposed_users는 노출된 후보의 다양성이고, exposure_gini는 노출 쏠림이다. gini는 높을수록 일부 후보에게 노출이 몰렸다고 보면 된다.

metric legacy mvp 변화
like_rate 0.7030 0.7035 거의 동일
mutual_like_rate 0.4424 0.3227 나빠짐
first_reply_rate 0.5345 0.5588 좋아짐
block_report_rate 0.0570 0.0520 좋아짐
unique_exposed_users 181 170 나빠짐
exposure_gini 0.2758 0.4341 나빠짐

즉, MVP ranker는 대화가 시작될 가능성은 조금 높였지만, 서로 좋아할 가능성노출 분산은 망쳤다.

이 결과는 실패라기보다 쓸 만한 신호였다. like rate 하나만 봤다면 "비슷하거나 조금 좋아졌다"고 말했을 수 있다. 하지만 mutual-like와 노출 집중도를 같이 보니 다음에 무엇을 봐야 하는지가 드러났다.

아마 MVP ranker가 프로필 완성도, 활동성, first reply 가능성에 더 무게를 주면서 "서로 취향이 맞는 후보"보다 "응답 가능성이 높은 후보"를 더 위로 올린 것으로 보인다. 그래서 대화 시작 가능성은 올라갔지만, 양방향 선호와 노출 다양성은 떨어졌다.

물론 이 평가는 실제 유저 행동이 아니라 synthetic event 기반이다. 그래서 절대적인 수치보다 중요한 것은 "새 ranker가 어떤 지표를 개선하고 어떤 지표를 악화시키는지 비교할 수 있게 됐다"는 점이다.

다음 튜닝은 weight를 더 만지는 것보다 exposure cap이나 diversity slot이 먼저다.

배운 점

이번 작업에서 가장 크게 바뀐 생각은 "좋은 매칭 알고리즘"을 하나의 산식으로 정의하려고 하면 안 된다는 점이다.

초기 단계에서 더 중요한 것은 다음 세 가지였다.

  1. 사용자에게 설명하는 점수와 내부 운영 점수를 분리한다.
  2. 상호 관심, 대화 시작, 부정 경험, 노출 집중도를 같이 본다.
  3. production에 연결하기 전에 synthetic 평가라도 재현 가능한 비교 루프를 둔다.

결국 추천 시스템은 "누가 가장 잘 맞는가"만의 문제가 아니다. "누구에게 어떤 순서로 기회를 줄 것인가"의 문제다. 사람과 사람을 연결하는 제품에서는 한쪽 만족만 최적화해도 전체 경험이 쉽게 무너진다.

지금의 MVP ranker는 최종 답이 아니다. 다만 다음 질문을 더 정확하게 만들었다.

  • exposure cap을 얼마나 둘 것인가?
  • diversity slot을 top N 안에서 어떻게 섞을 것인가?
  • 실제 impression, like, pass, reply 이벤트를 저장하면 synthetic proxy와 얼마나 달라지는가?
  • 궁합 점수는 어디까지 추천에 반영하고, 어디부터는 설명용으로만 둘 것인가?

이 질문들이 생겼다는 점만으로도 이번 구현은 의미가 있었다.

다음에는 이렇게 할 것

다음 단계는 새 모델을 붙이는 것이 아니라 운영 이벤트를 남기는 것이다.

실제 impression, like, pass, chat request, first reply 이벤트를 저장하고, score bucket별 전환율과 exposure concentration을 함께 볼 예정이다. 신규 유저에게 줄 exploration slot과 특정 후보에게 노출이 몰리는 것을 막는 exposure cap도 작은 비율로 실험할 수 있다.

그 다음에야 learned ranker를 검토할 수 있다. 데이터가 충분하지 않은 상태에서 모델부터 붙이는 것보다, 작지만 검증 가능한 ranker를 두고 지표를 쌓는 편이 낫다.

참고

givemethatsewon profile
@givemethatsewon
프로젝트를 만들고 운영하면서 배운 개발, 제품, 디버깅 기록을 남깁니다.