- SnapRoad 페이지 바로가기
- SnapRoad 프로젝트 소개
- 팀원 구성 및 역할
- 개발 기간 및 작업 관리
- SnapRoad는 이런 걸 할 수 있어요
- SnapRoad는 이런 기술을 사용했어요
- SnapRoad의 데이터 테이블 구조
- SnapRoad의 MVP 데이터 요청 흐름도
- 트러블 슈팅
- Sentry
- LightHouse
- 개선 목표
- 기술 블로그
- 서비스명 : SnapRoad
- 기획의도 : 개인 혹은 그룹별로 여행에 대한 추억을 남기고 서로 공유할 수 있는 프라이빗 서비스를 만들어 보고자 기획하였습니다.
- 서비스 한줄 소개 : 여행이 끝난 뒤 추억을 기록하여 언제든지 해당 여행의 기록을 다시 찾아볼 수 있는 서비스입니다.
- 서비스 소개
SnapRoad는 혼자 혹은 원하는 그룹원들과 사용할 수 있는 여행 기록 서비스입니다.
Next와 React, TypeScript를 사용하여 개발하였으며 사진과 위치를 지정해 게시물을 등록하고 그룹 구성원들이 남긴 게시물에서 소통하고, 추억을 저장할 수 있는 서비스입니다! - 팀 노션 : 도전 A팀(팀이름)
- 최종 MVP 스펙 : 로그인/회원가입, 회원정보 확인/수정, 그룹생성/수정, 그룹 초대수락, 이미지와 지역정보를 포함한 게시글 작성, 게시글에 댓글과 대댓글 작성
-
Next JS- 왜 NextJS를 사용했나?
- 데이터 캐싱 및 쉬운 SSR지원으로 UX 상승
- SEO를 상승시켜 서비스 노출 증가
- 왜 NextJS를 사용했나?
-
TypeScript- 왜 TypeScript를 사용했나?
- 정적 타입 검사로 안정적인 개발환경을 만들기 위해 사용
- 타입을 미리 지정하면 개발시에도 도움을 주기 때문에 개발에 용이
- 타입 지정에 시간이 더 사용되지만, 해당 부분은 supabase 자동 타입 생성기능을 사용하여 시간을 절약함
- 왜 TypeScript를 사용했나?
-
Zustand- 왜 Zustand를 사용했나?
- 전역상태로 관리될 필요가 있는 값을 간단하게 세팅하고 사용하기 위해 선택
- Redux는 보일러플레이트와 짧게 주어진 개발기간에 러닝커브의 문제로 제외
- 왜 Zustand를 사용했나?
-
Tanstack-query- 왜 Tanstack query를 사용했나?
- 데이터의 stale time에 따라 사용자에게 최신화된 데이터 제공
- useQuery, useMutation, useInfiniteQuery같은 다양한 훅에서 제공해주는 isError, isPending, refetch등의 메서드를 사용해 개발에 편리함 제공
- 왜 Tanstack query를 사용했나?
-
Tailwind CSS- 왜 Tailwind를 사용했나?
- 유틸리티 퍼스트 방식으로 클래스 네임 충돌을 방지하고, 빠르게 스타일링이 가능함
- 미리 정의된 유틸리티 클래스로 디자인 시스템에 맞는 일관된 스타일 유지 가능
- 왜 Tailwind를 사용했나?
-
StoryBook- 왜 Storybook을 사용했나?
- 개별 컴포넌트를 독립적으로 개발하고 일관된 UI/UX를 유지하며 컴포넌트 재사용 가능
- 각 컴포넌트의 예제를 제공하여 시각적으로 팀원들과 공유하며 문서화 가능
- 왜 Storybook을 사용했나?
-
Supabase- 왜 Supabase를 사용했나?
- 각 데이터끼리의 관계를 지정하여 쉽게 여러 데이터를 가져와 사용할 수 있다.
- sql문을 작성하여 프론트에서 생기는 성능이슈를 방지할 수 있다.
- 왜 Supabase를 사용했나?
-
Kakao Map API- 왜 카카오 지도를 사용했나?
- 국내 여행에 관련된 서비스를 제공하다보니 카카오 지도 사용하여 여러 데이터를 받을 수 있음
- 왜 카카오 지도를 사용했나?
-
ZodReact Hook Form- 왜 Zod와 React-Hook-Form을 사용했나?
- 간결한 유효성 검사 통합
- Zod의 Schema, React Hook Form의 resolver를 결합하여 폼의 유효성 검사를 쉽게 관리
- Zod 스키마를 zodResolver로 연결하여, 설정된 상태에 따라 Zod Schema를 사용해 유효성 검사를 수행
- 코드 가독성 및 유지 보수성 향상
- 유효성 검사 로직과 Zod Schema 분리로 폼 관련 로직을 쉽게 이해하고 유지보수 가능
- 타입 안전성
- Zod Schema를 통해 생성된 타입을 활용하여 타입스크립트가 제공하는 타입 안정성을 확보
- 잘못된 데이터 타입을 방지하고 폼 데이터를 안전하게 처리 가능
- 간결한 유효성 검사 통합
- 왜 Zod와 React-Hook-Form을 사용했나?
-
LightHouse- 왜 LightHouse를 사용했나?
- 웹 성능 측정: 페이지 로딩 속도와 렌더링 최적화를 위해 사용
- 접근성 분석: 다양한 사용자 환경에서 접근성을 점검
- SEO 개선: 검색 엔진에서 사이트 순위를 높이기 위해 SEO 상태 분석
- PWA 품질 검토: Progressive Web App 지원 상태를 확인
- 자동화된 테스트: 성능 및 품질 문제를 자동으로 진단하고 해결 방안을 도출
- 왜 LightHouse를 사용했나?
-
Sentery- 왜 Sentery 사용했나?
- 실시간 오류 추적: 사용자 테스트 중 발생한 오류를 즉시 감지 및 분석
- 사용자 행동 기록: 오류 전후의 사용자 행동을 추적해 문제를 재현
- 환경 데이터 수집: 브라우저, 운영체제 등 유저 환경 정보를 분석
- 성능 문제 진단: 느린 응답 시간 및 성능 저하를 모니터링
- 효율적 디버깅: 스택 트레이스와 오류 로그를 통해 문제 해결 속도 향상
- 왜 Sentery 사용했나?
- 폼 제출 시 여러개의 뮤테이션이 순차적으로 실행되어야 하는 상황이 발생
- 포스트 생성(createPostMutation)
- 이미지에 생성된 포스트 ID 업데이트(updateImagesPostIdMutation)
- 태그 저장(saveTagsMutation)
- 각 뮤테이션은 이전 뮤테이션의 결과를 기반으로 실행되어야 하므로, 순서가 보장되지 않으면 작업의 의도와 다르게 동작할 수 있었습니다. 예를 들어, 포스트가 생성되기 전에 이미지나 태그를 업데이트하려고 하면 오류가 발생할 수 있었습니다.
- 각 mutate 대신 mutateAsync를 사용하여 비동기 뮤테이션 작업을 순차적으로 실행하도록 수정했습니다. mutateAsync는 Promise를 반환하므로 await를 사용해 각 뮤테이션이 완료될 때까지 기다릴 수 있습니다. 이렇게 하면 작업 순서가 보장되며, 비동기 작업이 의도대로 수행됩니다.
const submitPost = async () => { try { // 1. 포스트 생성 const postResult = await createPostMutation.mutateAsync(postData); // 2. 포스트 생성 후, 이미지에 생성된 postId 업데이트 await updateImagesPostIdMutation.mutateAsync({ postId: postResult.data.post_id, uploadSessionId, }); // 3. 태그를 병렬로 저장 await Promise.all( tags.map((tag) => saveTagsMutation.mutateAsync({ tag, postId: postResult.data.post_id, groupId }) ) ); // 작업이 성공적으로 끝난 후, 페이지 이동 router.push(`/group/${groupId}`); } catch (error) { console.error('폼 제출 중 오류 발생:', error); } };
- mutateAsync는 순차적으로 의존성 있는 비동기 작업을 처리해야 할 때 매우 유용하고, 명확한 순서 보장이 필요할 때 사용하며, 병렬 처리할 수 있는 부분은 Promise.all 등을 사용해 최적화하는 것이 좋습니다.
- 클러스터 마커는 본래 합쳐진 마커들의 수를 보여주는 기능이지만 합쳐진 마커들 중 하나의 게시물 이미지를 마커로 띄워줘야 하는 상황
- 카카오맵 api에서 클러스터 마커 스타일을 지원하는 방법
- 클러스터 마커에 적용할 스타일들의 객체로 이루어진 배열을 설정
- 클러스터 시 마커의 사이즈가 10개 이하면 1번에서 설정한 스타일 배열의 0번째 스타일 적용 100개 이하면 1번째 스타일 적용하는 형식
// 마커 스타일 설정 방법 var styles = [{ width : '53px', height : '52px', background: 'url(cluster_small.png) no-repeat', color: '#fff', textAlign: 'center', lineHeight: '54px' }, { width : '73px', height : '72px', background: 'url(cluster_large.png) no-repeat', color: '#fff', textAlign: 'center', lineHeight: '74px' }];
// 마커 스타일 지정 방법 clusterer.setStyles(styles); // 클러스터 크기를 구분하는 값을 배열로 지정한다. // 아래와 같이 구분값을 2개 지정하면 클러스터는 // 50보다 작은경우, 50보다 크거나 같고 100보다 작은경우, 100보다 크거나 같은경우, 이렇게 3개의 크기로 구분된다. clusterer.setCalculator([ 50, 100 ]); // 또는 // 클러스터 크기를 구분하는 값을 반환하는 함수를 지정할 수 있다. // 함수 인자로는 클러스터가 포함하는 마커의 개수가 넘어온다. // 반환값은 클러스터 사이즈 별 스타일 혹은 문구 배열의 인덱스 값이어야 한다. clusterer.setCalculator(function( size ) { var index; // 클러스터에 포함된 마커의 개수가 50개 미만이면 리턴할 index값을 0으로 설정한다. if ( size < 50 ) { index = 0; } else if ( size < 100 ) { index = 1; } else { index = 2; } return index; });
- 클러스터 마커의 스타일을 결정하는
calculator가 마커의 사이즈만 매개변수로 받아와서 적정 스타일을 각 클러스터 마커들에게 지정할 수 없는 문제
- 클러스터 마커 스타일 배열을
state로 관리하기 - 클러스터 마커가 생기는 이벤트 발생 시 클러스터 마커의 중심 좌표와 구성 마커의 이미지를 찾아서 클러스터 마커 스타일 배열에 함께 저장하기
- 카카오맵 api에서 제공하는
getBounds()로 보여지는 맵의 영역 범위를 구하고kakao.maps.LatLngBounds()의 인자로 넣어 만든viewport를 만들고 이viewport에 클러스터 마커가 들어오면 마커 스타일 배열state에 클러스터 마커의 중심 좌표가 들어오면 그 스타일의 인덱스를 구해서 그 인덱스를calculator에 반환하여 클러스터 마커에 해당 클러스터 마커를 구성 마커의 이미지를 적용
- 카카오맵 api에서 지원하지 않는 기능을 다른 api들을 조합하여 구현 완료
- 로그인한 유저가 속한 그룹에서 작성된 포스트 중 랜덤한 포스트들을 가져와 유저의 화면에서 보여주는 로직에서 생긴 성능 이슈
- userId로 소속 그룹을 전체를 가져오기
- sort를 사용해 랜덤하게 그룹Id배열을 섞기
- 랜덤 그룹을 promise.allsettled를 사용해 post테이블에 post가 있는지 확인
- promise결과가 fulfilled인 결과만 filter
- filter된 배열의 이미지파일명을 Promise.all을 사용해 SignedUrl을 받아오는 로직 실행
- 복잡한 로직을 프론트에서 처리하며 여러 요청을 페이지 첫 로딩에 보내어 1회 HTTP요청 한계(6개)에 걸리고(첫 로딩 시 해당 페이지에서 요청이 17개), 그에따라 첫 데이터 요청에 3~4초가 걸리는 성능이슈가 발생함

- sql을 활용한 supabase function을 만들어 프론트에서 진행하면 성능 문제를 일으키는 요청들을 백엔드(supabase)에서 처리하도록 최적화 진행
CREATE OR REPLACE FUNCTION get_user_posts_by_user_id(input_user_id uuid) RETURNS TABLE( post_thumbnail_image text, post_address text, post_id uuid, created_at timestamp with time zone, group_id uuid ) LANGUAGE plpgsql AS $$ begin return query select p.post_thumbnail_image, p.post_address, p.post_id, p.created_at, p.group_id from public.posts p where p.group_id IN ( select ug.group_id from public.user_group ug where ug.user_id = input_user_id ) order by random() limit 5; end; $$;
async () => { const { data } = await browserClient.auth.getUser(); let dataList: PostDataListType = []; if (data.user?.id) { const userId = data.user.id; const { data: postDataList }: { data: PostDataListType } = await browserClient.rpc( 'get_user_posts_by_user_id', { input_user_id: userId }, ); if (postDataList?.length) { const imgNameArray = postDataList.map((postData) => `${postData.group_id}/${postData.post_thumbnail_image}`); const signedUrls = await getSignedImgUrls('tour_images', 60 * 60, imgNameArray); if (signedUrls) { dataList = postDataList.map((data, idx) => ({ ...data, post_thumbnail_image: signedUrls[idx].signedUrl, })); } } } return dataList; }
- 소셜 로그인 시 각 기관에서 제공하는 사용자의 이름을 public.profiles 에 저장하기 위해 트리거를 설정해주어야하는데 각 기관마다 사용자 이름을 제공하는 필드명이 달랐다.
- 각 기관에서 어떤 응답으로 사용자의 정보를 주는지 분석
- 분석 결과 full_name, user_name 등 다양했음
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles(
user_id,
user_email,
user_nickname
)values (
new.id,
new.email,
coalesce(new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'user_name',new.raw_user_meta_data->>'user_nickname')
);
return new;
end;
$$;
-- trigger the function every time a user is created
create trigger on_auth_user_created
after insert on auth.users
for each row
execute function public.handle_new_user();- 트리거 함수 만들 때 coalesce 을 사용하여 모든 경우에 대비하도록 함
- 실시간 오류 확인: 사용자 테스트 중 발생한 오류를 즉시 감지하고 원인을 파악했습니다.
- 사용자 행동 추적: 오류 발생 전 유저의 행동(클릭, 페이지 이동 등)을 기록해 문제 상황을 재현했습니다.
- 환경 정보 수집: 브라우저, OS 등 유저 환경 데이터를 통해 특정 환경에서의 문제를 분석했습니다.
- 성능 이슈 모니터링: 로드 시간 지연이나 성능 저하를 발견하고 최적화 기회를 찾았습니다.
- 효율적인 디버깅: 스택 트레이스와 행동 로그로 오류를 빠르게 해결할 수 있었습니다.
LightHouse는 웹 성능 최적화, 접근성 확인, SEO 분석, PWA 지원 점검, 그리고 자동화된 문제 탐지를 통해 사용자 경험을 개선하고, 개발 중 발생할 수 있는 품질 문제를 효과적으로 진단 및 해결하기 위해서 사용하였습니다.
- 회원 탈퇴
- 그룹 탈퇴/추방
- 지도 마커 고도화
- 앨범 기능 고도화
- PWA 적용
- 디바이스 맞춤 반응형 최적화
- 위치 선택하지 않을 시 사진 데이터로 주소 매핑 처리
SnapRoad에서 SignedUrl을 사용한 이유
Storybook 도입기
카카오맵 api 클러스터 마커에 게시물 이미지 적용하기
Signed URL 과 캐싱

















