⭐️ 서비스 소개
주제 선정 및 동기 :
이번 프로젝트에서는 실제로 서비스를 론칭했을 때 많은 사람들이 관심을 가지고 서비스의 사용까지 이어질 수 있는 프로젝트를 기획하고 싶었다.
이러한 고민을 가지고 많은 리서치를 통해 "사랑"이라는 주제를 선정하였고, knockknock이 탄생했다.
knockknock을 기획하는 단계에서, 시장조사를 통해 다른 소개팅 어플들이 갖는 불편한 점들을 찾아보았다.
가장 대표적인 문제점들로는 사용자에게 시간과 노력을 많이 요구하면서도, 실제 만남이 진전되지 않는 경우가 많다는 것, 일대일 만남을 부담스러워하는 사용자가 많다는 것, 마지막으로 자신의 가치관과 취미가 잘 맞는 사람을 찾기 어려운 경우가 많다는 것이었다.
따라서, knockknock에서는 타깃층을 명확히 하였다. 사회생활을 시작하는 단계이면서, 새로운 사람들은 만나고 새로운 관계를 형성하려는 연령대 25~40세이다.
마지막으로, knockknock의 차별성인 오픈이벤트가 있다. 이것은 프로젝트를 한층 더 고도화하고 실제 서비스를 운영할 때 시행하려고 기획했던 내용이다. 1대 1 만남을 부담스러워하는 유저들을 위해, knockknock이 가지고 있는 회원 데이터를 이용하여 모임에 신청한 유저들 간의 가치관과 이상형 등을 분석하고 매칭하여 knockknock이 실제 오프라인장소에 초대를 하고, 모임에 참석한 유저들은 나와 잘 맞고 나의 이상형들이 많은 장소에서 여러 명과 만나서 이야기해 보고 만남을 이어 나갈 것인지 결정할 수 있다. 모임에 참석함으로써 내가 바라는 이상형을 실제로 만날 확률이 높아지고, 연인으로써 관계가 이어지지 않더라도, 훌륭한 사람들과 새로운 인연의 기회를 제공받을 수 있다는 차별점이 있다.
🕹️ 구조 및 기술 스택:
❗️마주했던 문제와 해결방법:
🧩 실시간 채팅 기능의 구현
웹 소켓이라는 기술을 사용하는 Socket.io 라이브러리를 사용하였다.
일반적으로 웹 서버 통신은 단방향 통신인 HTTP 통신을 사용하는데, 그림에서와 같이 1번 유저가 2번 유저에게 채팅을 보내면 2번 유저는 동시에 메시지가 도착하였는지 확인할 수 없었다. 서버가 1번 유저에게 request를 받아 1번 유저에게만 response를 할 수 있기 때문이다.
socket 통신을 쓰면 이 모든 문제가 해결이 되면서 실시간 기능 구현이 가능해진다라는 것을 리서치를 통해 알았다.
웹 소켓은 양방향 통신을 가능하게 해주는 기술로, 이를 통해 두 유저 간의 상호작용이 원활하게 이루어질 수 있게 된다.
그러나 이러한 양방향 통신 구현에서 어려움이 있었다.
예를 들어, A와 B라는 두 유저가 있을 때, A의 메시지 창에서는 A는 발신자이지만, B의 메시지 창에서는 A가 수신자이다. 즉, 발신 유저의 상태가 수신 상태로 변함과 동시에 수신 유저의 상태도 발신으로 변하는 반대의 상황이 발생하여 복잡했다.
클라이언트 측으로, 값을 전달할 때 동일한 형식으로 값을 전달해 주어야 하는데 위에 예시처럼 발신자 수신자가 뒤바뀌는 문제가 생겨, 전달값을 어떤 식으로 해야 하나 많은 고민을 하였다.
해결방법으로는, db에 저장되는 값을 그대로 전달하는 것이 아닌, db 데이터를 커스텀 로직으로 처리하여서, 클라이언트 측에서 수신자와 발신자가 누구인지 명확히 알 수 있도록 하였다.
결과적으로, 명확한 값 지정을 통해 실시간 채팅 기능을 성공적으로 구현하였고, 사용자 간의 실시간 상호작용이 원활하게 이루어질 수 있게 되었다. 웹 소켓과 Socket.io의 활용은 서비스의 사용자 경험을 향상하는 중요한 단계였으며, 이를 통해 실시간 상호작용이 필수인 현대 웹 서비스의 요구를 충족시킬 수 있었다.
실시간 채팅기능에서 보완해야 할 부분은, 채팅한 데이터들을 어느 시점에 db에 저장해야 하는지 고민해봐야 하는 것이다. 지금은 아무런 설정 없이 채팅메시지가 생성될 때마다 db에 즉시 저장되는데, 사용하는 유저가 조금만 많아지면 db장애가 발생할 것이다. 따라서 채팅이 다 끝난 시점에 생성되어 있는 메시지들을 한 번에 저장하는 방법으로 보완해야 할 것 같다.
🧩 무한스크롤 데이터 정렬
회원가입할 때 입력한 이상형 태그와 성격 태그의 데이터를 이용하여, 게시자와 신청자들의 태그 일치 수를 계산하여 가장 많은 태그가 중복되는 신청자를 우선순위로 정렬시켜 신청자 리스트를 보여주고, 커서기반 무한스크롤을 구현하려고 하였다.
커서기반의 페이지네이션은 커서기준이 되는 id 값이 중복되지 않아야 하고, 순차적이어야 한다라는 조건이 있는데, 위에서 계산한 matching count 값은 중복될수 있으며 순차적이지 않았다. 그래서 첫 번째 시도로 배열을 사용해서 필요한 값들을 만들어주는 로직을 작성하여 커서는 아니지만 커서같이... 작동하게 만들었다.
const participants = await ParticipantModel.getParticipants(postId);
const { ideal } = await getIdealAndPersonality(user);
for (const participant of participants) {
const { personality } = await getIdealAndPersonality(participant.User);
const matchingCount = ideal.filter(tag => personality.includes(tag)).length;
await ParticipantModel.update({
participantId: participant.User.userId,
participantId: participant.participantId,
updateField: 'matchingCount',
newValue: matchingCount,
});
}
let updatedParticipants = [];
const updatedparticipants = await ParticipantModel.getParticipants(postId);
const participantsList = await getParticipantsList(participants, ideal);
// matchingCount를 기준으로 내림차순으로 정렬
participantsList.sort((a, b) => b.matchingCount - a.matchingCount);
// 커서를 이용하여 페이지네이션을 위한 시작 인덱스 계산
let startIndex = 0;
if (cursor) {
const participantIndex = updatedparticipants.findIndex(participant => participant.participationId == cursor);
startIndex = participantIndex !== -1 ? participantIndex + 1 : 0;
}
// limit 적용
const paginatedList = updatedparticipants.slice(startIndex, startIndex + limit);
하지만, 이렇게 하면 db를 조회, 수정 할때마다 모든 정보를 불러와 값을 계산하여 새로운 배열을 만들어주는 방식이기 때문에 말도 안 되는 성능 저하를 일으키고...(애초에 커서기반 페이지네이션을 쓸려고 한 의미를 버리게 된다) 어떻게하면 db 상에서 중복되지 않고 순차적인 커서 id를 지정해야 할까 고민하다가, 커스텀 커서를 만드는 아이디어를 리서치를 통해 알게 되어 시도해 보았다.
SQL의 CONCAT과 LPAD 문을 사용하여 matching count 값과 user_id값을 합쳐서 커스텀 커서 id를 만들었다.
SELECT
IFNULL(matchings.matching, 0) AS matching, -- matchings.matching은 게시자, 신청자 태그네임 일치하는 수
pt.user_id,
CONCAT(LPAD(IFNULL(matchings.matching, 0), 6, '0'), LPAD(pt.user_id, 6, '0')) AS `cursor` -- 커스텀커서 ex)000005000045
FROM
participants pt -- 신청자 테이블
LEFT JOIN (
SELECT
pt.user_id,
COUNT(*) AS matching -- 게시자와 신청자 태그네임 일치하는 수
FROM
participants pt
INNER JOIN
user_tags ut ON pt.user_id = ut.user_id AND ut.tag_category_id = 2 -- 신청자의 태그 카테고리가 성격인 것 조인
INNER JOIN
tags t ON ut.tag_id = t.tag_id -- 신청자의 tag_id를 조인
INNER JOIN
user_tags ut1 ON ut1.user_id = (SELECT user_id FROM posts WHERE post_id = 75) AND ut1.tag_category_id = 3 -- 게시자의 태그 카테고리가 이상형인 것 조인
INNER JOIN
tags t1 ON ut1.tag_id = t1.tag_id AND t1.tag_name = t.tag_name -- 게시자의 tag_id를 조회하고 신청자의 태그네임이 일치하는 수를 Count함.
WHERE
pt.post_id = 75 AND
pt.status = 'pending' -- 수락 혹은 거절 대기 중인 사람만 matching수를 볼 것
GROUP BY
pt.user_id -- 게시자와 일치하는 태그 수를 user_id 기준으로 그룹화
) AS matchings ON pt.user_id = matchings.user_id
WHERE
pt.post_id = 75
ORDER BY
matching DESC, pt.user_id DESC;
SQL문으로 구현하는 것은 성공했으나, sequelize로 구현하는 것을 실패하였다. 이 부분에서 orm의 어려운점? 단점? 을 배우게 됐는데, SQL문이 복잡해지면 sequelize의 난이도가 급상승하였다.
이것을 구현하던 시기가 프로젝트의 마감기한과 가까운때였는데, 구현하지 못한 부분때문에 기존에 작성한 코드를 다시 뜯어 고칠 것인지, 기존의 형태를 유지하면서 원래 계획했던 기능의 느낌을 가질 수있도록 설계를 변경할것인지에대한 회의를 가졌고, 2번 대안을 선택하여 결국, 신청자가 신청한 시점의 태그데이터들을 기준으로 수정하지 못하도록 하고, 정렬 기능을 포기하여 db의 성능을 지켜냈다.
이러한 과정에서, 업무에 문제가 생겼을때 시간과 퍼포먼스 측면 등 여러가지를 생각해서 가성비를 고려한 의사결정도 매우 중요하다라는 생각을 하게 되었다.
👨💻 보완하고 공부할 점
- 로그인시 유효세션 설정
유저가 로그인 할때, 인증토큰과 사용토큰을 유효기간을 달리 발급하여 보안적인 측면을 더 신경쓰고 싶다.
- https로 배포
HTTPS를 적용하여 AWS EC2를 이용하여 배포해보고 싶다.
- CI/CD 구축
CI/CD를 적용하여 자동배포 환경을 구축해보고 싶다.