오랜만에 블로그에 글을 씁니다!
요즘 취업 준비로 바쁘게 지내고 있지만, 이전보다 더 개발에 몰입하며 지내서 만족하며 열심히 살고 있습니다. 😄
이번 글에서는 10월에 있을 대학교 축제를 겨냥해 기획하고 개발 중인 매칭 웹앱 플랫폼 "Now, Here"의 백엔드 개발 과정에서
고민했던 내용을 공유하려 합니다.
특히, 이번에는 실시간 매칭 알고리즘과 데이터 기반 의사결정 방식을 도입한 경험을 소개하려고 합니다.
앞으로 3~4개의 Now, Here 관련 글을 올릴 예정이니 많은 관심 부탁드립니다! 🫡
Situation
과거에 제가 TrueEcho를 개발하면서 유저들 사이에서 친구를 추천하는 FOF 알고리즘을 구현한 적이 있습니다.
관련 글 링크
당시에는 친구의 친구들을 해시맵 형태로 저장하여 중복되는 친구가 많은 FoF(친구의 친구)가 사용자가 알 확률이 높다고 생각해서 해당 방식을 통해 구현했습니다.
하지만 이번에는 좀 더 실제 유저들의 피드백을 받고, 이를 통해 데이터 기반 알고리즘을 구현하고 싶었습니다.
처음에는 매칭시켜 줄 멤버 추천을 위해 MBTI 선호도를 조사하는 설문조사를 진행했습니다
우선 대학교 축제에서 사용할 목적이라 비슷한 나이대(20대)를 타깃으로 조사했습니다.
감사하게도 총 82명 정도 참여해 주셨지만 각 성별 당 MBTI의 선호도는 총 36가지라
82/36 = 2.28 개 정도의 조사로는 유의미한 결과가 아니라고 판단해 다른 방법을 찾아야 했습니다.
그래서 실시간으로 매칭되는 정보를 가지고 성별 -> MBTI에 따라 유저를 추천하는 가중치를 조정하는 방법을 생각했습니다.
그리고 매일 매칭된 전체 데이터를 분석하여 매칭 성공률을 계산하고,
어제와 오늘의 매칭률을 비교해 얼마나 지표가 개선되었는지 자동으로 파악할 수 있으면 좋겠다고 생각했습니다.
이렇게 한다면 실시간으로 유저들의 실제 활동으로부터 피드백 받아 연인의 MBTI 선호도를 파악 및 반영하고
데이터 수집과 분석까지 자동화할 수 있다는 이점이 예상됐습니다.
Task
이 목표들을 위해서 크게 세 가지 나누어 과제를 정했습니다.
1. 가중치 반영
- 매칭 성공률을 바탕으로 실시간 가중치 조정 로직을 설계
2. 데이터 저장 및 통계 처리
- 가중치를 실시간으로 업데이트하기 위한 저장소와 이 데이터를 분석하기 위한 저장소를 나누어 성능 최적화
- 이를 위해서 매번 가중치를 DB를 통해서 가져오는 것이 아니라 실시간 가중치는 ConcurrentMap을 통해서 저장
- 오늘 하루의 총데이터는 DB에 저장해서 데이터 분석에 사용하고, 데이터의 고가용성을 보장
3. 하루간 데이터 분석을 통한 가중치 반영과 인사이트 도출 자동화
- 하루 단위로 매칭 성공률 데이터를 저장하고 어제와의 증감 추이를 저장하여 인사이트 도출하도록 구현
- 실시간 선호도 반영과 더불어 하루 총데이터의 분석을 통한 MBTI 선호도를 반영하여 실시간 - 장기간 데이터를 모두 반영
Action
가중치 반영 로직 설계
- 가중치를 반영하는 매칭 알고리즘은 아래와 같이 설계되었습니다.
- 먼저 잠재적 매칭 후보를 DB에서 가져온 후, 무작위로 정렬한 후보들 중 가중치를 반영한 스코어가 높은 사람을 선택합니다.
@Override
public List<Member> findMembersByMemberIdAndEventIdAndGender(Long memberId, Long eventId, Gender gender) {
try {
return em.createQuery("SELECT m FROM Member m " +
"WHERE m.event.id = :eventId " +
"AND m.gender = :gender " +
"AND m.id != :memberId " +
"AND NOT EXISTS (" +
" SELECT 1 FROM Matching match " +
" WHERE (match.senderMemberId = :memberId AND match.receiverMemberId = m.id) " +
" OR (match.receiverMemberId = :memberId AND match.senderMemberId = m.id) " +
") " +
"AND m.active = true " +
"ORDER BY random()", Member.class) // 무작위 정렬
.setParameter("memberId", memberId)
.setParameter("eventId", eventId)
.setParameter("gender", gender)
.setMaxResults(10) // 최대 10명 선택
.getResultList();
} catch (Exception e) {
log.error("Failed to find members by memberId, eventId and gender: {}", e.getMessage());
throw new RuntimeException("Failed to find members", e);
}
}
- 우선 데이터베이스에서 random() 함수를 통해서 무작위로 10명을 데려왔습니다.
- 이 중에서 가장 가중치를 통해 구한 스코어가 높은 사람을 뽑는 방식으로 구현하였습니다.
// 가중치를 반영한 매칭 결과 계산
List<PreferenceBasedMBTIMatching.MatchResult> matchResults = matcher.findMatches(
memberMbti,
member.getGender(),
potentialMatches.stream()
.map(PreferenceBasedMBTIMatching.PotentialMatch::new) // Member 객체를 직접 PotentialMatch로 변환
.toList()
);
- 가중치 계산하는 부분은, PreferenceBasedMBTIMatching 클래스를 통해서 구현했습니다.
- 해당 클래스 안에는 남성과 여성이 각각 mbti 선호도를 저장하기 위해서 ConcurrentHashmap을 사용했습니다.
*멀티스레드 환경에서 동시성 문제에 안전하기 때문에 ConcurrentHashmap 사용
// 남성과 여성 각각의 MBTI 선호도를 저장하는 ConcurrentHashMap
private final Map<MBTI, Map<MBTI, Double>> malePreferences = new ConcurrentHashMap<>();
private final Map<MBTI, Map<MBTI, Double>> femalePreferences = new ConcurrentHashMap<>();
- 초기화 시에는 누적식으로 저장되어 있는 각 성별의 각 MBTI 가중치 정보를 가져와 반영합니다.
- 만약 이전의 데이터가 없는 경우(첫 날인 경우) 모든 케이스의 가중치를 0.5로 초기화합니다.
// 남성과 여성 각각의 MBTI 선호도를 저장하는 ConcurrentHashMap
@PostConstruct
public void initializeWeights() {
MBTI[] mbtiTypes = MBTI.values();
// 어제의 날짜를 구함
LocalDate yesterday = LocalDate.now().minusDays(1);
// DB에서 어제 날짜의 가중치만 불러오기
List<MatchingStatistics> yesterdayStatistics = matchingStatisticsRepository.findByDate(yesterday);
// 남성과 여성의 가중치 맵 초기화
for (MBTI mbti : mbtiTypes) {
malePreferences.putIfAbsent(mbti, new ConcurrentHashMap<>());
femalePreferences.putIfAbsent(mbti, new ConcurrentHashMap<>());
}
// 어제 데이터를 메모리에서 처리하여 남성 및 여성에 대한 가중치 설정
for (MatchingStatistics stat : yesterdayStatistics) {
MBTI userMbti = stat.getUserMbti();
MBTI matchedMbti = stat.getMatchedMbti();
double weight = stat.getWeight();
if (stat.getUserGender() == Gender.MALE) {
malePreferences.get(userMbti).put(matchedMbti, weight);
} else if (stat.getUserGender() == Gender.FEMALE) {
femalePreferences.get(userMbti).put(matchedMbti, weight);
}
}
// 없는 경우 기본값으로 0.5를 설정
for (MBTI mbti : mbtiTypes) {
for (MBTI otherMbti : mbtiTypes) {
malePreferences.get(mbti).putIfAbsent(otherMbti, 0.5);
femalePreferences.get(mbti).putIfAbsent(otherMbti, 0.5);
}
}
}
- 가중치를 업데이트하는 메서드는 동적 조정법을 적용했습니다.
// 매칭 성공 여부에 따라 선호도 가중치를 업데이트하는 메서드
public void updatePreferences(MBTI userMbti, MBTI matchedMbti, Gender gender, boolean success) {
Map<MBTI, Map<MBTI, Double>> preferences = (gender == Gender.MALE) ? malePreferences : femalePreferences;
preferences.putIfAbsent(userMbti, new HashMap<>());
// 동적 조정법 : 가중치가 극단에 갈수록 안정성을 높임
double baseAdjustment = 0.1;
double currentWeight = preferences.get(userMbti).getOrDefault(matchedMbti, 0.5);
double dynamicAdjustment = baseAdjustment * (1 - Math.abs(currentWeight - 0.5));
double adjustment = success ? dynamicAdjustment : -dynamicAdjustment;
// 상한선과 하한선 설정 (최소 0.2, 최대 0.8)
double newWeight = Math.max(0.2, Math.min(0.8, currentWeight + adjustment));
preferences.get(userMbti).put(matchedMbti, newWeight);
}
- 이는 가중치가 극단으로 갈수록 변동성을 줄여 안정석을 높이는 조정법입니다.
- 동적 조정법을 통한 매칭 알고리즘에 대해 더 설명하자면
- 선호도 기반 가중치 알고리즘 중에서 지나치게 가중치가 높아지거나 낮아지는 것을 방지하는 알고리즘입니다.
- 가중치는 0과 1 사이로 설정하여 0.5에 가까울 때는 빠르게, 0과 1에 가까울수록 천천히 변화시키게 구현했습니다.
- 기본 조정 값(baseAdjustment):
- 가중치를 조정하는 기본 값은 0.1입니다.
- 이는 성공적인 매칭 시 가중치가 얼마나 변할지를 결정합니다.
- 현재 가중치(currentWeight):
- 현재 저장된 가중치 값을 불러옵니다.
- 만약 값이 없다면 기본값인 0.5를 사용합니다.
- 동적 조정 값(dynamicAdjustment):
- 가중치가 0.5에 가까울수록 크게, 0이나 1에 가까울수록 작게 변화하도록 설정합니다.
- 구체적으로는 1 - |currentWeight - 0.5|를 계산하여 가중치가 0.5에서 멀어질수록 조정량이 줄어듭니다.
- 예를 들어, 현재 가중치가 0.5일 경우 조정량은 0.1 * (1 - 0) = 0.1로, 큰 폭의 변화가 일어납니다.
- 반면에 가중치가 0.9일 경우 0.1 * (1 - 0.4) = 0.06으로 조정량이 줄어듭니다.
- 매칭 성공 여부에 따른 조정:
- 매칭이 성공했을 경우에는 가중치를 증가시키고, 실패했을 경우에는 가중치를 감소합니다.
- 또한 상한선과 하한선을 0.8과 0.2로 설정하여 극단적으로 선택되거나 선택이 안 되는 경우를 미연에 방지합니다.
- 그래서 위와 같은 동적 조정법을 통해 가중치를 조정하고 난 이후
실제로 멤버 추천을 어떻게 하는지 아래에 설명하겠습니다.
- 동적 조정법을 통한 매칭 알고리즘에 대해 더 설명하자면
public List<MatchResult> findMatches(MBTI userMbti, Gender userGender, List<PotentialMatch> potentialMatches) {
List<MatchResult> results = new ArrayList<>();
if (userMbti == null || userGender == null) {
throw new IllegalArgumentException("User MBTI or gender cannot be null");
}
for (PotentialMatch match : potentialMatches) {
if (match.getMbti() == null || match.getGender() == null) {
continue;
}
double userPref = getPreferenceScore(userMbti, match.getMbti(), userGender);
double matchPref = getPreferenceScore(match.getMbti(), userMbti, match.getGender());
double score = Math.min(userPref, matchPref);
log.info("Matched score: {}", score);
results.add(new MatchResult(match, score));
}
results.sort((a, b) -> Double.compare(b.score, a.score));
return results.stream().limit(2).collect(Collectors.toList());
}
- 스크린샷과 같이 잠재적 후보들과 나의 성별/MBTI에 따라서 가중치를 확인합니다.
- 이때 score(매칭 가능성)을 구할 때 Math.min() 함수를 사용합니다.
- 이는 양쪽의 선호도가 모두 높아야 유의미하다고 판단해서, 매칭의 안정성을 보장하기 위함입니다.
* 단방향이 아니라 양방향으로 선호도가 높을 때 선호한다고 판단하기 때문 - 이런 과정을 통해 구한 Score를 통해서 잠재적 매칭 후보들 중을 정렬하여 상위 두 명의 결과만을 반환합니다.
- 이때 score(매칭 가능성)을 구할 때 Math.min() 함수를 사용합니다.
- 일간 매칭 가중치와 매칭률 증감 저장
- 서버에 저장했으므로 휘발성 데이터이기 때문에, 데이터의 고가용성과 분석을 통한 인사이트를 위해서 MatchingStatistics 엔티티를 사용해, 각 성별과 MBTI별로 가중치를 기록합니다.
@Scheduled(cron = "0 0 0 * * *") // 매일 자정에 실행
@Transactional // 데이터베이스 트랜잭션 관리
public void updateMatchingWeights() {
log.info("Starting weight update process...");
// 어제 날짜의 매칭 데이터를 가져옴
List<Matching> matchings = matchingRepository.findMatchingsFromYesterday();
int totalMatches = matchings.size();
int acceptedMatches = 0;
for (Matching matching : matchings) {
Member sender = matching.getSender();
Member receiver = matching.getReceiver();
if (sender != null && receiver != null) {
// 매칭 성공 여부에 따라 가중치를 업데이트
boolean accepted = matching.getStatus() == Status.ACCEPTED;
if (accepted) {
acceptedMatches++;
}
matcher.updatePreferences(sender.getMbti(), receiver.getMbti(), sender.getGender(), accepted);
matcher.updatePreferences(receiver.getMbti(), sender.getMbti(), receiver.getGender(), accepted);
// 각 성별과 MBTI별로 가중치 기록
saveMatchingStatistics(sender, receiver);
}
}
- DailyMatchingRate 엔티티를 통해 오늘 총 매칭률과 어제와 오늘의 매칭률 증감을 계산했습니다.
// 전체 매칭률 계산
double matchRate = (totalMatches > 0) ? ((double) acceptedMatches / totalMatches) * 100 : 0;
log.info("Today's matching rate: {}%", matchRate);
// 어제의 매칭률을 가져옴
double yesterdayMatchRate = dailyMatchingRateRepository.findByDate(LocalDate.now().minusDays(1))
.map(DailyMatchingRate::getMatchRate)
.orElse(0.0);
// 매칭률 증감 계산
double matchRateChange = matchRate - yesterdayMatchRate;
// 전체 매칭 성공률을 기록 (하루에 한 번)
saveDailyMatchRate(matchRate, matchRateChange);
- 스케줄링을 통한 자동화
- 마지막으로 매일 자정에 오늘 자정부터 어제 자정까지의 매칭 데이터를 분석하고, 가중치와 매칭 성공률을 자동으로 저장하도록 설정했습니다.
- 이를 통해 사람이 개입하지 않아도 시스템이 자동으로 데이터를 처리하고 개선할 수 있게 자동화하였습니다.
Result
- 매칭 알고리즘을 통해서 실시간으로 가중치를 반영하게 되었습니다.
- 하루 동안의 매칭 성공률을 자동으로 분석 및 기록하여 데이터 분석과 저장을 가능하게 구현하였습니다.
- 이 데이터를 바탕으로 향후 매칭 알고리즘을 개선하고, 사용자 경험을 더욱 향상할 수 있는 기반을 마련하였습니다.
마무리하며
기본적으로 많은 곳에서 사용하는 기능들과 다르게
아이디어부터 구현하는 로직들을 새로이 고민해야 했기에 쉽지 않은 개발이었습니다.
하지만 알고리즘을 구현하면서 코딩테스트를 풀 때 시간복잡도와 공간복잡도를 고민하며 개발하는 것처럼
DB를 거치지 않고 가중치를 반영하는 방법은 무엇일까?
고가용성을 위해서 데이터를 잃지 않고 누적하는 방법은 무엇일까?
가중치를 조정할 때 어떤 방식으로 할까?
추가로 어떤 엔티티가 필요하고, 기존 프로젝트의 어느 부분에 영향을 미칠까? 등등
많은 고민과 함께 개발하였고, 성공적으로 해내서 매우 기쁩니다.
트루에코 프로젝트를 구현할 때와 다르게 이제 하나의 클래스, 메서드를 작성하더라도 여러 조건과 임팩트를 같이 고려하며 개발을
어느 정도 할 수 있게 된 것 같아 성장했음을 체감한 순간이었던 것 같습니다.
최근 Now, Here에 몰두하여 많은 시간을 쏟아부었고,
해당 과정을 통해서 경험했던 내용들을 공유하러 금방 다시 오겠습니다.
😊 긴 글 읽어주셔서 감사합니다.