무사히 한시간만에 구현한 마니또 웹을 가지고 지난 주말 동기들과 동창회를 성공적으로 마무리했다. 이 웹을 만들 당시에는 단순히 동창회를 위한 1회성 마니또 웹을 한시간만에 만드는 것이었다
그러다 모임 몇일 후 모 동기에게서 받은 카톡
그렇다, 기존에 만든 마니또 웹은 일회성으로 만들어졌기 때문에 다른 그룹 추가 기능이 없었고, 최종 결과 또한 개발자만이 firestore에 접속해야만 확인할 수 있었기 때문에 확장성이 0에 가까웠다.
이번 시간에는 기존 웹이 가진 장점은 살리면서 (리더가 이름을 입력해 참가자 등록, 리더도 참여 가능하게 비밀번호 기반 결과 확인, 랜덤 순환 배열 매칭 방식) 다른 모임에서도 활용가능하게 확장해보았다.
지난주에 작성한 글을 확인해보자면 당시의 프로젝트 요구사항은 다음과 같다 :
여기서 시각적 요구사항과 기술스택은 그대로 유지하면서 주요기능에서 몇가지 기능을 더 추가해보고자 한다.
💡 그룹 생성 기능: 리더 이름, 그룹 이름 기반 고유한 그룹을 생성하여 비밀번호 발급
💡 구성원 삭제 및 수정 : 잘못 입력한 구성원 정보를 수정하거나 삭제할 수 있는 기능
💡 비밀번호 기반 결과 확인 및 공유 : 비밀번호를 통해 결과를 조회하고, 결과를 안전하게 카카오톡으로 공유할 수 있는 기능
💡 기존 결과 조회 : 리더 이름, 그룹 이름, 비밀번호를 통해 db에 직접 접속하지 않아도 웹에서 전체 결과를 확인할 수 있게
가장 먼저 firestore의 "groups" 컬렉션에 새로운 문서를 추가하고, 리더명 leaderName
, 그룹명 groupName
, 랜덤 비밀번호 password
, 생성일 createdAt
을 함께 저장하는 함수를 만들었다.
export const addGroupToFirestore = async (leaderName, groupName) => {
const password = getRandomColorAnimal();
try {
const docRef = await addDoc(collection(db, "groups"), {
leaderName,
groupName,
password,
createdAt: new Date(),
});
return docRef.id;
} catch (error) {
console.error("Firestore에 그룹 추가 중 오류 발생:", error);
throw new Error("그룹 생성 실패");
}
};
또한 getRandomColorAnimal()
함수를 사용하여 색깔과 동물을 융합하여 생성한 랜덤 비밀번호를 그룹에 할당하였다. 이 비밀번호는 리더가 참여자를 등록하는 페이지에서 안내할 예정이다.
보통 마니또나 비밀 산타 이벤트가 일회성이라는 것을 고려하여 로그인 기능을 만들지 않았다. 따라서 사이트에서도 그룹명과 리더명을 기반으로 고유성을 유지하기 때문에 Firestore에서 동일한 그룹이 이미 존재하는지 확인하는 과정이 필요했다.
export const checkGroupDuplicate = async (groupName, leaderName) => {
const groupRef = collection(db, "groups");
const q = query(
groupRef,
where("groupName", "==", groupName),
where("leaderName", "==", leaderName)
);
const querySnapshot = await getDocs(q);
return !querySnapshot.empty;
};
query() 함수를 사용해 특정 조건에 맞는 그룹을 찾고, where("groupName", "==", groupName)
와 where("leaderName", "==", leaderName)
조건을 통해 그룹명과 리더명이 동일한 그룹을 찾는다.
const handleCreateGroup = async () => {
if (!leaderName.trim() || !groupName.trim()) {
setSnackbarMessage("리더 이름과 그룹 이름을 모두 입력하세요.");
setAlertSeverity("warning");
setOpenSnackbar(true);
return;
}
const isDuplicate = await checkGroupDuplicate(groupName, leaderName);
if (isDuplicate) {
setSnackbarMessage(
"이미 같은 이름의 그룹 또는 리더가 존재합니다. 다른 이름을 사용해주세요."
);
setAlertSeverity("error");
setOpenSnackbar(true);
return;
}
try {
const groupId = await addGroupToFirestore(leaderName, groupName);
navigate(`/inputNames/${groupId}`);
} catch (error) {
console.error("그룹 생성 중 오류:", error);
setSnackbarMessage("그룹 생성에 실패했습니다. 다시 시도해주세요.");
setAlertSeverity("error");
setOpenSnackbar(true);
}
};
새롭게 createGroupPage
를 만들어서 handleCreateGroup
함수를 통해 리더 이름과 그룹 이름을 검증하고, 중복 여부를 확인한 뒤 Firestore에 그룹을 추가하는 기능을 넣었다.
const addName = () => {
setInputName((currentInput) => {
const trimmedName = currentInput.trim();
if (trimmedName) {
if (names.includes(trimmedName)) {
setSnackbarMessage("동일한 이름이 이미 존재합니다.");
setSnackbarOpen(true);
} else {
setNames((prevNames) => [...prevNames, trimmedName]);
setInputName("")
}
}
return "";
});
};
trim
을 통해 입력된 이름이 공백이 아닌지 확인하고, 기존 목록에 중복되지 않으면 추가하는 기능을 넣었다. 혼자 쓰는 웹이면 상관없지만, 외부 배포용이라면 충분히 발생할 수 있는 이러한 사용자의 상황들도 고려해야한다.
const handleDelete = (index) => {
setNames((prev) => prev.filter((_, i) => i !== index));
};
이름 삭제 기능은 filter
를 사용하여 특정 인덱스를 받아 해당 이름을 삭제하였다. 이는 기존 상태를 필터링하여 삭제된 이름만 제거된 새로운 배열을 만들 수 있게 한다.
const handleEdit = (index) => {
setInputName(names[index]);
setSelectedName(index);
};
const updateName = () => {
if (selectedName !== null) {
const updatedNames = [...names];
updatedNames[selectedName] = inputName.trim();
setNames(updatedNames);
setInputName("");
setSelectedName(null);
}
};
또한 이름 수정 기능으로 handleEdit()
을 통해 사용자가 특정 이름을 수정하고 싶을 때 해당 이름을 선택하여 inputName
에 해당 이름을 설정한 후에 updateName()
으로 수정된 이름을 반영하고 상태를 업데이트할 수 있다.
useEffect(() => {
const fetchData = async () => {
const results = await fetchMatchesFromFirestore(groupId);
setMatches(results[0]?.matches || []);
};
fetchData();
const fetchGroupDetails = async () => {
const details = await getGroupDetailsFromFirestore(groupId);
setGroupName(details.groupName);
setLeaderName(details.leaderName);
};
fetchGroupDetails();
}, [groupId]);
역시나 기존 코드 그대로 useEffect
훅을 사용하여 컴포넌트가 마운트될 때 Firebase Firestore에서 매칭 결과와 그룹 정보를 비동기로 가져온다. 이를 통해 matches
, groupName
, leaderName
상태를 업데이트하였다.
저번에 구현했을때 비밀번호를 직접 하나하나 참가자들에게 갠톡했던 것이 너무나 불편했다.
그러다 문득 네이버 지도에서 잘 활용하고 있는 이 '공유하기' 버튼에서 아이디어를 얻어서
카카오 developers에 앱을 등록하고..
<script src="https://developers.kakao.com/sdk/js/kakao.js"></script>
<script>
Kakao.init("initialkey");
</script>
index.html
에 sdk 연결도 완료했다.
const handleKakaoShare = (match) => {
if (window.Kakao && window.Kakao.isInitialized()) {
window.Kakao.Link.sendDefault({
objectType: "feed",
content: {
title: `${leaderName}님이 생성한 ✨${groupName}✨ 마니또 뽑기 결과가 나왔어요!`,
description: `${match.giver}님의 마니또 비밀번호는 "${match.password}" 입니다! 지금 이름과 비밀번호를 입력해서 바로 확인하고, 선물을 준비하세요! 🎁`,
imageUrl: "https://i.ibb.co/QbHpY2p/Landing.png",
link: {
mobileWebUrl: `https://manitto-73651.web.app/showResult/${groupId}`,
webUrl: `https://manitto-73651.web.app/showResult/${groupId}`,
},
},
buttons: [
{
title: "결과 보기",
link: {
mobileWebUrl: `https://manitto-73651.web.app/showResult/${groupId}`,
webUrl: `https://manitto-73651.web.app/showResult/${groupId}`,
},
},
],
});
} else {
alert("Kakao SDK가 초기화되지 않았습니다.");
}
};
카카오톡 SDK를 사용하여 특정 매칭 결과를 공유할 수 있도록 하는 handleKakaoShare
도 만들었다. window.Kakao.isInitialized()
를 통해 카카오 SDK가 초기화되었는지 확인하고, 공유 메시지의 제목, 설명, 링크 등을 설정하여 window.Kakao.Link.sendDefault
로 공유를 시작한다.
최근에 외부 api를 이용하여 개발하는 일이 많았는데 그 중에서도 정말 간단하게 구현 가능한 기능이었다. 카카오만세
리더가 다시 결과를 확인할 수 있는 페이지를 구현하는 과정에서 마니또 전체 결과를 열람할 수 있게 할지, 아니면 기존처럼 참가자들의 개별 비밀번호만 열람할 수 있게 할지에 대해 고민했었다.
{results?.map((result, index) => (
<Card key={index} sx={{ mt: 2 }}>
<CardContent>
{showFullResults ? (
<>
<Typography variant="h6">전체 정보</Typography>
<Typography variant="h6">
{result.groupName} {result.leaderName && `(${result.leaderName})`}
</Typography>
{result.matches &&
result.matches.map((match, idx) => (
<Typography key={idx} sx={{ mt: 1 }}>
{match.giver} ➞ {match.receiver} (비밀번호: {match.password})
</Typography>
))}
</>
) : (
<>
<Typography variant="h6">비밀 정보</Typography>
{result.matches &&
result.matches.map((match, idx) => (
<Typography key={idx} sx={{ mt: 1 }}>
{match.giver} (비밀번호: {match.password})
</Typography>
))}
</>
)}
</CardContent>
</Card>
))}
그러다 그냥 간단한 스위치를 통해 사용자가 전체 정보를 볼지, 비밀번호만 볼지를 결정권을 사용자에게 넘기기로 했다.
그리고 소소하지만 기존 참여자의 결과 조회 성공 시에는 react-confetti
를 이용해서 폭죽을 날려주기로 했다.
{confettiActive && <Confetti />}
흩날리는 눈에.. 폭죽에.. 뭔가 하늘에서 너무 많이 떨어진다 😝
이로서 급하게 만든 웹..
덜 급하게 확장해보았으니 연말에 쓸 일 있으면 추천한다~!