10월 ~ 12월
교내 데이트 매칭 웹 서비스를 런칭하다
Leets 2기에서는 ItoR 팀으로 활동을 하게 되었다. 원래라면 1기와 동일하게 Zero100 팀에 편성되어 있었지만 더 성장하고 싶었고, 더 개발다운 개발을 해보고 싶었다. 고민하다가 팀 빌딩 마감 하루 전에 성민이형에게 ItoR 팀에서 해보고 싶다고 어필했고, 흔쾌히도 형이 허락을 해줘서 합류를 하게 되었다. 다시 한 번 형에게 고맙다고 말하고 싶다.
지난번 Zero100 팀에서도 그러했듯이 팀 내에서 가장 실력이 낮았기에 ItoR 팀에서 운영했던 "TDD 스터디", "자바의 정석 스터디"를 열심히 하려고 했었다. 하지만 우테코랑 비교과 활동 때문에 목표만큼 하지 못했다. 이때 스터디를 등한시 했던 게 후회가 된다. 팀원들에게 지식을 나눠주기 보다 흡수하기 바빠 도움을 주지 못해 미안했다. 내년 스터디에서는 지식을 받는 것 보다 더 많이 나누어주는 사람이 되어야 겠다는 생각이 든다.
그렇게 스터디 기간이 끝나고 실전 프로젝트의 날이 밝아왔다. 주제는 회의결과 "무드를 통해 1:1 매칭을 시켜주는 데이팅 채팅 웹 서비스"였고, 이름도 "MoodMate"로 지어졌다. 너무 마음에 드는 주제였다. 유저를 끌어당길 수 있고, 나아가 수익 창출까지 기대할 수 있는 그런 주제였다고 생각했기 때문이다. 지금도 물론 너무 좋은 주제였다고 생각한다 ㅎㅎ
주제 회의 다음 날 바로 프론트 팀원들과 유저플로우를 작성했다. draw.io 사이트에서 작성했는데, 데이터베이스 과목 때 draw.io에서 ERD를 설계하는 과제를 해봤기에 사이트는 익숙했다. 그런데 마름모, 평행사변형, 원 등등 ERD 설계를 할 때 사용하지 않았던 도형을 써야했기에 새로웠고 다들 익숙치 않았기에 시간이 오래걸렸다. 그렇지만 만들면서, 만들고 나니 왜 유저플로우를 작성해야 하는지 깨달았다. 유저의 입장에서 서비스를 어떻게 행동할 지 미리 정리함으로서 유저에게 편리한 방향으로 순서를 바꿀 수 있었고, 뭔가 2% 부족했던 프로젝트의 기능 흐름이 시각자료로 정리되면서 fix 될 수 있었다.
유저플로우 설계하고 다음날, 백엔드 팀과 저번 프로젝트와 마찬가지로 ErdCloud라는 서비스를 이용해 ERD를 구축했다. 처음에 했었을때는 오래걸렸었는데, 한번 해봤던거고 팀원들도 DB를 잘해서 구축하는데 1시간 반 밖에 안걸렸다.
이후 프론트에게 완성된 ERD를 보내주고 드디어 역할 분담 회의를 했다. 역할은 크게 "로그인기능", "CI/CD", "채팅기능", "매칭기능" 이였다. 앞에서도 말했듯이 팀원들의 실력이 훨씬 좋았기에 마지막에 남은 파트를 맡자고 속으로 생각했고 로그인 기능이 남을거라고 예상했다. 그렇게 팀원들에게 우선권을 부여하고 남은 파트를 가져가게 되었는데, 남은 파트는 "매칭기능"이였다... "으...응? 제일 핵심기능이 남았다고? 잠시만... 그걸 내가 구현해야 한다고?" 덜컥 겁이났다. "구현하지 못하면 어쩌지?"라는 생각이 들었고, 팀원들에게 역할을 바꾸어 달라고 할까 고민이 들던 와중 "우테코"에 지원당시 나의 다짐이 떠올랐다.
"후회하지 말고 도전해서 최선을 다하자"
내가 실력이 가장 낮다는 이유로 다시 한 번 경험할 수 없을지도 모르는 좋은 기회를 다른 팀원에게 넘겨주고 쉬운 파트를 가져온다면 나중에 후회가 남을 것 같았다. 그래서 매칭기능을 꼭 구현해 내자고 마음을 먹었다.
위 사진은 코드 구현 전 고민의 흔적들 중 일부이다. 어떤 알고리즘을 도입할 지, 같은 학과의 이성은 어떻게 처리할 지, 선호도 리스트 순서는 어떻게 결정할 지 등등 약 1주 동안 기능 설계에만 몰두했다. 기능 설계전 매칭 알고리즘은 구글링을 하면 다 나와있을 줄 알았다. 하지만 전혀 아니였다. 찾고 찾아서 그나마 우리의 프로젝트와 비슷했던 영화관 데이트 매칭 서비스를 발견했지만, 같은 영화를 선택한 사람을 매칭시키기에 이분 매칭(DFS)를 통해서 기능구현이 가능했다. 우리는 고려 조건이 3개 였기 때문에 구현하기에는 어려움이 있었다.
그렇게 지쳐가던 찰나에 GaleShapley(Stable Matching Algorithm)에 대해 서술한 블로그를 발견하였다. 양방향 매칭 방식이였고, 각자마다 이성에 대한 우선순위 리스트를 관리해서 최적의 매칭을 만들어 내는 알고리즘이였다. 이성에 대한 우선순위를 매기는 로직을 구현해 알고리즘에 적용시킨다면 3가지 조건을 충족하는 기능을 구현할 수 있을 것 같다는 생각이 들었다. 이때부터 GaleShapley 알고리즘에 대해 파고들었다. 이후 과정은 나중에 따로 블로그에 이야기 해볼 생각이다 ☺️
최종적으로 배포 전날에 기능 구현을 완성할 수 있었다. 오래걸렸던 이유는 자바로 작성한 알고리즘을 스프링부트로 마이그레이션 하면서 예상치 못한 오류와 스프링부트에 대한 개념 부족 때문이였다. 이 과정속에서 스프링부트를 방학 때 열심히 해야겠다고 100번은 다짐을 했던 것 같다... ㅎ
배포 당일 날 팀원들과 모여서 배포 전 작업들을 마무리 지었다. 나는 Cron으로 오후 9시 5분에 알고리즘을 실행하도록 자동화를 시켜놓는 작업을 했다. 그렇게 다들 마무리 작업이 끝나고 드디어 서비스를 배포했다.
출시한지 5분만에 약 30~40명의 실제 유저의 데이터가 DB에 들어왔다. 너무 감격스러웠고 그동안의 고생을 보상받는 느낌이였다. 이때 까지는 즐거웠지 😁
그렇게 유저의 정보가 잘 들어오는 것을 보고 다같이 회식을 하러갔다. 그렇게 회식을 하고있던 도중 9시 5분이 되었고, 1:1 매칭이 잘 이루어졌는지 DB를 접속해서 확인했다. "엥....? 뭐야 왜 매칭이 안됐지?" 급하게 민호에게 로그를 확인해 달라고 부탁했고, NULL 값으로 인한 ERROR 로그가 찍혀있었다.
"아뿔싸!" 여러가지 문제가 존재했다. 첫번째, 회원가입 시 유저가 정보를 입력하는 단계를 모두 거치지 않거나, 새롭게 지금 막 정보를 차례대로 입력하는 경우 입력되지 않은 값은 NULL로 처리되고 있어 user_id는 존재했지만 user_nickname이 아직 NULL인 유저의 경우 getUserNickName() 호출 시 Nullpointerexception이 발생되는 문제. 두번째, user_id를 외래키로 가지는 prefer entity에 몇몇 유저의 user_id가 중복으로 insert 되는 문제. 세번째, 제일 핵심적인 문제였다고 생각되는데 자바에서 스프링부트로 마이그레이션 하는 과정에서 몇줄의 코드가 누락이 되었었고, 원래 partnerID get 해와야 하는데 partnerUserId를 get 해오는 등의 실수가 있었다.
결국 서비스 제공을 다음 날로 미룰 수 밖에 없었다. 이때 베타테스트의 중요성을 뼈저리게 느꼈던 것 같다. 제일 핵심적인 문제(세번째)가 발생하게 된 계기가 나의 실수였기에 팀원들에게 너무 미안했다. 그래서 회식이 끝나고 난 후 집가는 버스에서부터 집 도착 후 새벽까지 알고리즘을 수정했고, 다행히도 문제를 해결할 수 있었다.
다음 날, 각자 문제를 해결한 후 9시 5분까지 긴장을 하며 기다렸다. 9시 5분이 되고 긴장된 상태로 DB를 확인해봤는데...
약 50개의 room_id가 까지 생성되어 있었고, 서로다른 남녀의 user_id가 insert 되어 있었다. (모자이크 처리)
총 50쌍의 커플이 매칭이 된것이다! 목격한 당시 기쁨보다 안도의 한숨을 내쉬었던 것 같다. 😭
이후로 19~25일까지 서비스는 정상적으로 유저에게 제공할 수 있었고, 400명의 유저를 유치하며 성공적으로 마무리 할 수 있었다. 물론 진행하면서 몇 가지 문제가 더 있기도 했는데, 어찌저찌 잘 해결되어서 다행이였다.
지금와서 가장 기억나는 2가지 문제에 대해 말해보자면,
첫번째, 유저가 200명대 일때는 문제가 발생하지 않았는데 약 300명정도 되었을 때 알고리즘을 돌리면 에러가 발생했다. 로그를 확인해 봤을 때, 총 N명의 쌍이 매칭이 되어서 while문이 종료되어야 했는데 종료되지 않았고 그렇기에 무한루프가 발생하였다. 분명 로직상 문제가 없었고, 다시 알고리즘을 실행하면 정상적으로 매칭이 성공했기 때문에 서비스를 제공하는데는 번거로움만 있었을 뿐 문제는 되지 않았다. 아직 원인을 찾지 못했는데, 3월에 서비스를 다시 오픈해야 하기에 2월에 원인파악을 해볼 생각이다.
while (engagedCount < N) { // 모든 커플이 매칭될 때까지 반복
Man free = null;
for (Man man : men.values()) { // 매칭되지 않은 남자를 찾음
if (!man.isEngaged()) {
free = man;
break;
}
}
if (free != null) { // 매칭되지 않은 남자가 있으면
System.out.println("남자 " + free.getName() + "이(가) 매칭을 시도합니다.");
for (String w : free.getPreferences()) { // 그 남자의 선호도 목록을 순회
Woman woman = women.get(w); // 선호하는 여자를 찾음
if (!free.isEngaged() && !free.getProposed().contains(woman)) { // 남자가 아직 매칭되지 않았고, 아직 고백하지 않은 여성에게만 고백
System.out.println(" " + free.getName() + "이(가) " + woman.getName() + "에게 고백합니다.");
free.getProposed().add(woman); // 남자가 고백한 여성을 기록
if (woman.getPartner() == null) { // 여자가 아직 매칭되지 않았으면
woman.setPartnerUserId(free.getUser().getUserId()); // 여자와 남자를 매칭
woman.setPartner(free.getName());
free.setEngaged(true); // 남자의 상태를 매칭됨으로 변경
engagedCount++; // 매칭된 커플 수 증가
System.out.println(" " + woman.getName() + "은(는) " + free.getName() + "과(와) 매칭되었습니다.");
} else { // 여자가 이미 매칭되어 있으면
Man currentPartner = men.get(woman.getPartner());
System.out.println(" " + woman.getName() + "은(는) 이미 " + currentPartner.getName() + "과(와) 매칭되어 있습니다.");
if (morePreference(currentPartner, free, woman)) { // 여자가 새로운 남자를 더 선호하면
woman.setPartnerUserId(free.getUser().getUserId());// 여자와 새로운 남자를 매칭
woman.setPartner(free.getName()); // 여자와 새로운 남자를 매칭
free.setEngaged(true); // 새로운 남자의 상태를 매칭됨으로 변경
currentPartner.setEngaged(false); // 현재 파트너의 상태를 매칭되지 않음으로 변경
System.out.println(" " + woman.getName() + "은(는) " + free.getName() + "과(와) 매칭되었습니다. (이전 매칭 해제)");
} else {
System.out.println(" " + woman.getName() + "은(는) 여전히 " + currentPartner.getName() + "과(와) 매칭되어 있습니다. (새로운 남자 선호도 부족)");
}
}
}
}
}
}
매칭 알고리즘 코드의 일부이다. 코드를 간략하게 설명하면 N은 총 커플 수이고 남자들과 여자들의 수를 비교했을 때 더 작은 성별의 수가 N이 된다. (ex. 남자 20명, 여자 18명 → N = 18) 따라서 engagedCount(매칭된 커플 수)가 N과 같아지면 while문이 종료되는 방식이다.
예상되는 문제로는 enagedCount++이 실행되어야 했으나, context switching 과정에서 누락되어 +1이 반영이 안되었고, 커플은 매칭이 다 되었으나 N과 engagedCount가 같지 않아 while문이 종료되지 않는 것 같다고 추측이 된다. 물론 추측이기 때문에 좀 더 알아봐야 하는 부분이다.
두번째, "이미 매칭된 커플은 서로의 선호도가 높았기에 매칭되었으므로 또 다시 매칭될 확률이 높다"는 문제가 있었다. 테이블에 저장된 user_id를 무작위가 아닌 순서대로 가져왔기에, 또 다시 매칭될 확률이 매우 높았다.
먼저 문제를 해결하기 위해 user_id를 테이블에 저장된 순서로 가져오기 때문에, 가져와서 shuffle을 하는 방식으로 리스트를 재구성하였다. 그러고 나서 user_id 이미 매칭된 커플을 관리하는 who_meet이라는 테이블을 만들어 매칭 기록을 남기고, 어떠한 남녀를 매칭 시키기 전 who_meet 테이블을 탐색하는 과정을 거쳐 매칭되었던 기록이 있다면 선호도 리스트 후순위에 배치하는 방식으로 해결했다.
public void match() {
// 활성화된 Prefer 객체들을 가져옴
List<Prefer> activeMatchTrueMale = preferRepository.findByUserMatchActiveAndGenderTrue(Gender.MALE);
List<Prefer> activeMatchTrueFemale = preferRepository.findByUserMatchActiveAndGenderTrue(Gender.FEMALE);
// 두 리스트를 합침
List<Prefer> activeMatchTrue = new ArrayList<>(activeMatchTrueMale);
activeMatchTrue.addAll(activeMatchTrueFemale);
// shuffle을 통해 무작위로 섞음
Collections.shuffle(activeMatchTrue);
// Prefer 객체들을 Person 인스턴스로 변환하고 남성과 여성 리스트에 추가
for (Prefer prefer : activeMatchTrue) {
Person person = new Person(prefer.getUser(), prefer);
if (person.getGender() == Gender.MALE) {
men.add(new Man(person.getUser(), person.getPrefer()));
} else if (person.getGender() == Gender.FEMALE) {
women.add(new Woman(person.getUser(), person.getPrefer()));
}
}
}
------------------------------------------------------------------------------------------------
public void grouping() {
Map<String, Man> m = convertListToMap(men);
Map<String, Woman> w = convertListToMap(women);
Map<String, Map<String, Man>> menGroups = groupByMood(m);
Map<String, Map<String, Woman>> womenGroups = groupByMood(w);
// 각 그룹에 대해 Gale-Shapley 알고리즘 실행
for (String mood : menGroups.keySet()) {
Map<String, Man> menGroup = menGroups.get(mood);
Map<String, Woman> womenGroup = womenGroups.get(mood);
System.out.println();
System.out.println("====================================");
System.out.println(mood + " 그룹 매칭을 시작합니다.");
System.out.println("====================================");
// 각 그룹의 남자 목록 출력
System.out.println(mood + " 그룹 남자:");
for (Man man : menGroup.values()) {
System.out.println(man.getName());
}
// 각 그룹의 여자 목록 출력
System.out.println(mood + " 그룹 여자:");
for (Woman woman : womenGroup.values()) {
System.out.println(woman.getName());
}
// 각 남자와 여자의 선호도 설정
setPreferencesForGroup(menGroup, womenGroup);
// Gale-Shapley 알고리즘 실행
new GaleShapley(menGroup, womenGroup, roomRepository, userRepository, whoMeetRepository);
}
}
private void setPreferencesForGroup(Map<String, Man> menGroup, Map<String, Woman> womenGroup) {
for (Man man : menGroup.values()) {
List<String> mainPreferences = new ArrayList<>();
List<String> secondaryPreferences = new ArrayList<>();
List<String> thirdPreferences = new ArrayList<>();
Set<String> metFemalesSet = new HashSet<>();
// 특정 남성과 매칭된 이력 조회
List<WhoMeet> metFemales = whoMeetRepository.findByMetUser2(man.getUser());
for (WhoMeet met : metFemales) {
metFemalesSet.add(met.getMetUser1().getUserNickname()); // 이전에 매칭된 여성 추가
}
// 여자 그룹을 순회하면서 남자의 선호도를 결정
for (Woman woman : womenGroup.values()) {
String womanName = woman.getName();
if (metFemalesSet.contains(womanName)) {
continue; // 이전에 매칭된 여성은 제외
}
if (!man.isDontCareSameDepartment() && man.getDepartment().equals(woman.getDepartment())) {
thirdPreferences.add(womanName);
} else if (woman.getYear() >= man.getMinYear() && woman.getYear() <= man.getMaxYear()) {
mainPreferences.add(womanName);
} else {
secondaryPreferences.add(womanName);
}
}
// 보조 선호도를 전체 선호도 목록에 추가
mainPreferences.addAll(secondaryPreferences);
mainPreferences.addAll(thirdPreferences);
mainPreferences.addAll(metFemalesSet);
// 남자의 preferences 속성에 최종 선호도 목록 설정
man.setPreferences(mainPreferences);
System.out.println(man.getName() + "의 선호도 목록: " + mainPreferences);
}
for (Woman woman : womenGroup.values()) {
List<String> mainPreferences = new ArrayList<>();
List<String> secondaryPreferences = new ArrayList<>();
List<String> thirdPreferences = new ArrayList<>();
Set<String> metMalesSet = new HashSet<>();
// 특정 여성과 매칭된 이력 조회
List<WhoMeet> metMales = whoMeetRepository.findByMetUser1(woman.getUser());
for (WhoMeet met : metMales) {
metMalesSet.add(met.getMetUser2().getUserNickname()); // 이전에 매칭된 남성 추가
}
// 남자 그룹을 순회하면서 여자의 선호도를 결정
for (Man man : menGroup.values()) {
String manName = man.getName();
if (metMalesSet.contains(manName)) {
continue; // 이전에 매칭된 남성은 제외
}
if (!woman.isDontCareSameDepartment() && woman.getDepartment().equals(man.getDepartment())) {
thirdPreferences.add(man.getName());
}
else if (man.getYear() >= woman.getMinYear() && man.getYear() <= woman.getMaxYear()) {
mainPreferences.add(man.getName());
} else {
secondaryPreferences.add(man.getName());
}
}
// 보조 선호도를 전체 선호도 목록에 추가
mainPreferences.addAll(secondaryPreferences);
mainPreferences.addAll(thirdPreferences);
mainPreferences.addAll(metMalesSet);
// 여자의 preferences 속성에 최종 선호도 목록 설정
woman.setPreferences(mainPreferences);
System.out.println(woman.getName() + "의 선호도 목록: " + mainPreferences);
}
}
선호도 목록을 관리하는 코드의 일부이다.
Collections.shuffle(activeMatchTrue);
Collection 클래스에 suffle 메서드를 활용하여 가져온 user_id를 무작위로 섞어 요소를 재배열하는 방법을 사용하였다.
// 특정 남성과 매칭된 이력 조회
List<WhoMeet> metFemales = whoMeetRepository.findByMetUser2(man.getUser());
for (WhoMeet met : metFemales) {
metFemalesSet.add(met.getMetUser1().getUserNickname()); // 이전에 매칭된 여성 추가
}
// 여자 그룹을 순회하면서 남자의 선호도를 결정
for (Woman woman : womenGroup.values()) {
String womanName = woman.getName();
if (metFemalesSet.contains(womanName)) {
continue; // 이전에 매칭된 여성은 제외
}
if (!man.isDontCareSameDepartment() && man.getDepartment().equals(woman.getDepartment())) {
thirdPreferences.add(womanName);
} else if (woman.getYear() >= man.getMinYear() && woman.getYear() <= man.getMaxYear()) {
mainPreferences.add(womanName);
} else {
secondaryPreferences.add(womanName);
}
}
// 보조 선호도를 전체 선호도 목록에 추가
mainPreferences.addAll(secondaryPreferences);
mainPreferences.addAll(thirdPreferences);
mainPreferences.addAll(metFemalesSet);
// 남자의 preferences 속성에 최종 선호도 목록 설정
man.setPreferences(mainPreferences);
남자와 여자의 선호도 리스트 관리 방법이 동일하기에 남자의 선호도 리스트만 첨부하였다. 보면 who_meet 테이블을 탐색하는 과정을 거쳐 for문에서 이전에 매칭된 여성이면 제외한 후 마지막에 mainPreferences.addAll(metFemalesSet)을 해주는데, 이 과정이 선호도 리스트에 제일 후순위에 배치하는 과정이다. HashSet을 사용한 것은 이미 매칭된 사람 끼리 선호도를 관리하는 것은 의미가 없다고 생각했기에, List가 아닌 Set으로 추후 리스트에 add할때 순서를 보장하지 않도록 했다.
이 무드메이트라는 프로젝트를 하면서 너무 재미있었고, 많은 것을 배우고 느낄 수 있었다. 서비스를 만들기까지 고생했던 팀원과 동아리 부원들에게 감사하다는 마음을 표한다. 뿐만 아니라 서비스를 홍보해주기 위해서 힘써주신 나와 팀원들의 지인분들 그리고 서비스의 방향성에 관해서 조언해 주신 조용진 멘토님께 감사인사를 올린다.
'Retrospect' 카테고리의 다른 글
[회고] 회고 2 (0) | 2024.02.15 |
---|---|
[회고] 회고 1 (0) | 2024.02.15 |
[회고] 2023년을 되돌아보며 (2) | 2023.12.31 |