Search

소소(souso) - 중년 세대를 위한 커뮤니티 서비스

Tags
Javascript
React
MySQL
Created
2022.10.21 - 2022.12.20

프로젝트명

소소 (Souso) : 소년소녀들의 모임

참여기간

2022.10.21 - 2022.12.20

개발스택

프론트엔드: Javascript, React 18, React-query, Axios, Recoil, Styled-Components
백엔드: Java 11, Spring Boot, JPA, QueryDSL, MySQL, Redis, AWS, Docker, CI/CD
그 외: Kakao Maps API, Postman/Swagger (API 테스트 및 문서화), GitHub, Notion, Figma

프로젝트 소개

소소(souso)는 가사일에 지친 어머니와 은퇴 후 삶이 무료한 아버지 등 6070 부모님 세대를 위한 동네 친구 만들기 플랫폼입니다. 부모님 세대의 일상을 ‘소소’하지만 활력있게 만들어드리고 싶다는 취지로 기획을 시작했습니다. 웹 기반 프로젝트였지만 모바일 SNS 형식을 최대한 유지하여 사용자가 모바일 앱과 유사한 경험을 할 수 있도록 구현했습니다. 본 프로젝트는 Numble이라는 사이드 프로젝트 플랫폼에서 팀을 구성하여 웹 개발 챌린지 형식으로 약 2달간 진행하였고 최종 2위를 수상했습니다.

개발 인원

총 5명 (디자이너 1명, 프론트엔드 2명, 백엔드 2명)

주요 특징

휴대폰 번호 인증을 통한 회원가입 및 로그인
카카오맵 API를 활용한 위치 기반 서비스
8가지 카테고리의 커뮤니티 게시판
좋아요 & 북마크한 게시글 모아보기
게시글에 댓글, 대댓글 추가 기능

프로젝트 담당

전체 시스템 설계 및 구현:

기획 단계에서 팀원들과 서비스 주제와 타겟층을 정하고 필요한 기능명세서를 작성했습니다. 정해진 개발기간동안 빠르게 MVP 개발을 하기위해 1주단위로 스프린트를 만들었고 '페이지' 단위로 작업을 나누어 디자인/FE/BE 개발을 병렬적으로 진행하는 애자일 방식으로 개발했습니다. 이를 통해 개발 병목현상을 최소화하고 효율적인 작업 흐름을 구축할 수 있었습니다.

프론트엔드:

2명의 FE 개발자로 구성된 팀에서 상대 개발자의 경험 부족을 고려해 TypeScript 대신 JavaScript를 선택했습니다. 제가 전체적인 UI와 공통 컴포넌트를 담당하였고 기능 구현은 분담했습니다. 제 담당 파트는 전체 UI 및 공통 컴포넌트 제작, Kakao Map, 피드 목록 무한스크롤 구현, multipart/form-data를 활용한 이미지 업로드 기능이었습니다.

배포 및 운영:

본 프로젝트는 Netlify를 사용해서 배포 자동화를 구현했습니다. GitHub 저장소와 연동하여 main 브랜치에 변경사항이 push될 때마다 자동으로 빌드 및 배포가 이루어지도록 설정했습니다.

프로젝트 기획서

시연 영상

프로젝트 주요 기능

1. Join Form - Phone number Verification

Implemented sending verification code by connecting backend API using React-Query.
Organized endpoints and queries for Join Page in a separated API folder.
Created a reusable Input component and useForm hook.
Code Preview
// api/queries/join.js import { api } from 'api'; import { JOIN, CHECK_NICKNAME, SEND_CODE, VERIFY_CODE } from 'api/endpoints'; export const join = { submit: async req => { const res = await api.post(JOIN, req); return res.data; }, nickname: async req => {...}, sendCode: async req => {...}, verifyCode: async req => {...} };
JavaScript
복사
// components/Join/InputVerified import React, { useState, useEffect } from 'react'; import { Input } from 'components/Join'; import { useMutation } from 'react-query'; import { join } from 'api/queries/join'; export const InputVerified = ({ values, onChange, errors, isVerified, setIsVerified}) => { const [isSent, setIsSent] = useState(false); // 👉 response from the verification code API const { mutate: sendCodeMutate } = useMutation(join.sendCode, { onSuccess: () => { errors.phone_number = ''; toast.success('Sent successfully'); setIsSent(true); setWaiting(true); }, onError: error => { if (error.response.data.message === 'Already Auth Code Exist') { errors.phone_number = 'You can send a code after 3mins.'; } else if (error.response.data.message === 'Already Phone Number Exist') { errors.phone_number = 'The phone number already exists.'; } } }); // 👉 request verification when button is clicked const sendCode = async e => { e.preventDefault(); const errorMessage = await validate(values); errors.phone_number = errorMessage.phone_number || ''; if (!errors.phone_number) { sendCodeMutate({ phone_number: values.phone_number }); } setRender(values); }; useEffect(() => { setIsSent(false); }, [setIsSent]); return ( <Input name="phone_number" placeholder="phone number" onChange={onChange} values={values} errors={errors} > <button onClick={sendCode}> {isSent ? 'Resend' : 'Send'} </button> </Input> // ... ); };
JavaScript
복사

2. KakaoMap API for Geolocation

When signing up, this API will help you to get the neighborhood name for the user's location.
 Read more about KakaoMap API
Adopted Recoil state storage to manage the neighborhood name globally.
Utilized Geolocation to get the current latitude and longitude of the user.
Code Preview
// components/TownAuth/KakaoMap import React, { useEffect, useRef, useState } from 'react'; import { Map, MapMarker } from 'react-kakao-maps-sdk'; import { useSetRecoilState } from 'recoil'; import * as S from './styles'; export const KakaoMap = ({ openModal }) => { const [currentGeo, setCurrentGeo] = useState({ lat: 0, lng: 0 }); const [pickedGeo, setPickedGeo] = useState(currentGeo); const [address, setAddress] = useState([]); const setSaveAddress = useSetRecoilState(addressState); const { kakao } = window; // 👉 call KakaoAPI from <head> const mapRef = useRef(); // 👉 get latitude and longitude of picked location on the map const getPickedGeo = (_, mouseEvent) => { setPickedGeo({ lat: mouseEvent.latLng.getLat(), lng: mouseEvent.latLng.getLng() }); }; // 👉 move the pin to the current location on the map. const moveToCurrent = () => { const center = new kakao.maps.LatLng(currentGeo.lat, currentGeo.lng); if (mapRef.current) { mapRef.current.panTo(center); setPickedGeo(currentGeo); } }; useEffect(() => { // 👉 get the current location using GeoLocation if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(({ coords }) => { setCurrentGeo({ lat: coords.latitude, lng: coords.longitude }); setPickedGeo({ lat: coords.latitude, lng: coords.longitude }); }); } }, []); useEffect(() => { const geocoder = new kakao.maps.services.Geocoder(); // 👉 request the detailed address of coordinates const getAddressFromGeo = (coords, callback) => { geocoder.coord2Address(coords.lng, coords.lat, callback); }; // 👉 return the address when the specific point is clicked on the map. getAddressFromGeo(pickedGeo, function (result, status) { if (status === kakao.maps.services.Status.OK) { setAddress([ result[0].address.region_1depth_name, result[0].address.region_2depth_name, result[0].address.region_3depth_name ]); } }); }, [kakao, pickedGeo, setAddress]); useEffect(() => { setSaveAddress(address); // save the address }, [setSaveAddress, address]); return ( <S.MapContainer> <Map center={currentGeo} onClick={getPickedGeo} className="kakao_map" ref={mapRef} > <MapMarker position={pickedGeo} /> </Map> <SearchSection openModal={openModal} moveToCurrent={moveToCurrent} /> </S.MapContainer> ); };
JavaScript
복사

3. Infinite Scroll useInfiniteQuery by React-Query

Implemented the infinite scroll when the feed list is loaded
If the scroll is down to the FetchObserver, it will send request to fetch the next page.
Code Preview
// pages/FeedPage import { useInfiniteQuery } from 'react-query'; import { feed } from 'api/queries/feed'; export const FeedPage = () => { const params = pageParam => { return { cursorId: isLatest ? pageParam : 0, pageId: !isLatest ? pageParam : 0, sortType: isLatest ? 'LATEST' : 'POPULAR' }; }; // request fetching more feed data if the next page exists. const infiniteResponse = useInfiniteQuery( ['feed'], ({ pageParam = 0 }) => feed.list(params(pageParam)), { getNextPageParam: lastPage => isLatest ? lastPage.feed_list.length > 0 && lastPage.feed_list.slice(-1)[0].feed_id : lastPage.page_id + 1 } ); return ( <PostList active={active} setActive={setActive} infiniteResponse={infiniteResponse} /> ); };
JavaScript
복사
// components/Feed/PostList export const PostList = ({ infiniteResponse, active, setActive }) => { const { data, isLoading, isFetching, fetchNextPage, refetch } = infiniteResponse; const { pathname } = useLocation(); const isEmpty = !isLoading && ('feed_list' in data.pages[0] ? !data.pages[0].feed_list.length : !data.pages[0].category_feed_list.length); if (isEmpty) return <EmptyList message="Empty Feed" />; return ( <S.PostListContainer> {active === 'popluar' && (isTabLoading ? ( <SkeletonThRight /> ) : ( <S.PostLists> {data.pages.map(page => (page.feed_list || page.category_feed_list).map(post => ( <ThumbRight key={post.feed_id} postData={post} /> )) )} </S.PostLists> ))} {/* Latest feed or categorized feed */} {active !== 'popluar' && (isTabLoading ? ( <SkeletonThBottom /> ) : ( <S.PostLists> {data.pages.map(page => (page.feed_list || page.category_feed_list).map(post => ( <ThumbBottom key={post.feed_id} postData={post} refetch={refetch} /> )) )} </S.PostLists> ))} {/* If the scrollbar scroll down to this, fetch next page */} <FetchObserver data={data} fetchNextPage={fetchNextPage} isFetching={isFetching} /> </S.PostListContainer> ); };
JavaScript
복사

4. Custom Skeleton UI

Created a skeleton component as placeholders when texts and images are loading.
Code Preview
// components/Common/Skeleton import React from 'react'; import { Skeleton } from './Skeleton'; import styled from 'styled-components'; export const SkeletonCategory = () => { return ( <Container> {[...Array(8)].map((_, i) => ( <div key={i}> <Skeleton type="circle" size={40} /> <Skeleton type="text" width={40} /> </div> ))} </Container> ); }; export const Skeleton = ({ type, height, width, size, line }) => { return [...Array(line)].map((_, i) => ( <SkeletonItem key={i} className={type} height={height} width={width} size={size} > <HighLight /> </SkeletonItem> )); };
JavaScript
복사

프로젝트 회고

What 무엇을 했나요?

페이지 단위로 작업을 나누어 디자인, 백엔드, 프론트엔드 개발을 병렬적으로 진행하며 효율적인 협업 프로세스를 만들었습니다.
기획부터 배포까지 전체 프로덕트 개발 라이프사이클을 경험하며 전체적인 개발 프로세스를 이해했습니다.
디자이너, 백엔드 개발자와의 협업을 통해 효과적인 소통 방법과 협업 툴 활용법을 배웠습니다.
NUMBLE 웹 개발 챌린지에서 2등을 수상하는 성과를 달성했습니다.

How 어떻게 해결했나요?

이전 프로젝트 경험을 바탕으로 개발 방법론을 폭포수 모델에서 애자일 방식으로 전환하여 기존 문제점을 개선했습니다.
예) “(월-화) 디자인 시안 (수-목) 서버 & 프론트 개발 진행 (금) 클라이언트-서버 API 연결” 과 같은 플로우로 작업했습니다.
주간 스프린트 회고 시간에 BE, FE의 구현 영역을 크로스 체크하며 놓친 부분이나 개선점에 대해 피드백을 주고받았습니다.

Then 앞으로의 개선점?

프로젝트 초기에 더 명확한 API 명세와 디자인 가이드라인을 정하면 개발 생산성에 효율적일 것으로 예상됩니다.
WebP 포맷 변환 또는 이미지 지연 로딩 등 성능 최적화 과정을 추가하면 더 나은 사용자 경험을 제공할 수 있을 것입니다.
메인으로 돌아가기