프로젝트명
소소 (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.
•
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 포맷 변환 또는 이미지 지연 로딩 등 성능 최적화 과정을 추가하면 더 나은 사용자 경험을 제공할 수 있을 것입니다.