채식 지도 서비스 웹 앱
Table of Contents
- 임시 계정 👉 ID:
test123
PW:goodtoseeyou^9^
- 로그인이 필요한 서비스:
북마크
,좋아요
,게시글/댓글/식당 리뷰 작성
- 로그인이 필요한 서비스:
- PWA 다운로드 가능
-
데이터 구성: 서울시 지정·인증업소 현황(800개) + 웹 스크래핑
-
검색 모드
- 반경탐색 모드
- 옵션: 300m, 500m (기본값), 1km, 2km, 3km
- 지역탐색 모드
- 옵션: 시도 / 시군구 (기본값: 현재 위치) * 위치정보 허용 필수
- 키워드 검색
- 업종, 채식 인증 여부, 정렬 선택
- 정렬 기본값:
관련도
샐러디 화곡
을 검색했을 때샐러디 (화곡역점)
이 우선 검색
- 공통: 업종필터
- 반경탐색 모드
-
식당 북마크(+ 좋아요)
- 북마크 그룹으로 관리(기본값: 기본그룹), 추가 및 이동
- 좋아요 목록 확인
-
게시판(커뮤니티)
-
나머지 스크린샷 참조
- Deployment: Vercel, cloudtype
- Build : vitejs with vite-plugin-ssr,
- Front-End: React
- Back-End: express, mongodb, AWS S3
-
이 화면은 위치 액세스 권한이 허락되어야 사용할 수 있습니다.
-
화면을 이동하면 현재 뷰의 좌표를 기준으로 해당 다각형 내에 있는 식당 정보를 가져와 마커를 그립니다.
-
반경탐색 모드(기본값)와 지역탐색 모드 2가지가 있습니다. 반경 선택 시 반경 표시가 화면 내에 들어오도록 지도 레벨이 조절됩니다.
첫화면 | 반경탐색 | 반경탐색결과 |
---|---|---|
- 지역 탐색 시 현재 위치의 시/도가 기본값입니다. 주소 문자열을 나눠서 각각 시/도와 시군구로 분류한 후 선택한 지역의 식당 정보를 가져옵니다.
제자리로 | 지역탐색 | 지역탐색결과 |
---|---|---|
-
검색 시 쿼리스트링으로 검색어를 요청 메시지에 보냅니다. 거리순 정렬 기능도 있으므로 body에 현재 위치도 담아 서버에 요청합니다.
-
관련도 점수
- 주소 7, 업종 3, 이름 2, 채식 인증 1
- 주소 문자열은 법정동과 행정동이 같이 표기되어 있는 경우도 있기 때문에 검색어가 존재하는지만 검사합니다.
e.g
...화곡로141(화곡동 698-2)
의 관련도...화곡로141
의 관련도와 같다.
업종필터 | 검색 | 검색결과 |
---|---|---|
- 식당 상세정보 페이지에서 리뷰를 작성할 수 있습니다.
첫화면 | 버튼클릭 | 리뷰작성 |
---|---|---|
-
이미지 처리 과정
-
URL.createObjectURL(File): 사용자에게 받은 이미지를 Object.createURL로 만듭니다.
-
랜덤파일명 생성: 이미지별 랜덤 파일 이름을 생성해줍니다.
-
스토리지에 저장: AWS S3로부터 signedUrl을 할당받고 해당 주소로 PUT 요청을 보냅니다.
-
db에 저장: 스토리지에 저장이 완료되면 도큐먼트의 src 필드에 앞에서 생성한 랜덤 파일 이름을 저장합니다.
-
불러오기:
<img src={IMAGE_URL/client/random_file_name.extension} alt="desc">
형식으로 사용
-
리뷰작성 페이지 | 결과 | 텍스트 리뷰 |
---|---|---|
- 수정 시 이미지가 있을 경우 완료 버튼 클릭 시 src에
amazon
문자열이 있는지 확인합니다.amazon
문자열이 포함되어 있는 경우 이미지는 수정하지 않았으므로 텍스트만 처리
수정 모달 | 수정 페이지 | 수정 결과 |
---|---|---|
- 북마크와 좋아요 목록을 관리하는 화면입니다.
- 회원가입 시
기본 그룹
이 자동으로 생성됩니다. - 그룹별 아이콘과 그룹명을 선택하여 관리할 수 있습니다.
첫화면 | 새그룹 추가 | 새그룹 추가 결과 |
---|---|---|
- 식당을 북마크하면 기본적으로
기본 그룹
에 속하게 됩니다. - 그룹을 이동하려면 식당이 속한 그룹 페이지로 진입한 후
복사
=>원본삭제
순서로 이루어집니다.
그룹 수정 | 그룹 내 목록 | 그룹 이동1 |
---|---|---|
그룹 이동2 | 그룹 이동3 | 그룹 이동 결과 |
---|---|---|
- 로그인 없이 게시판을 읽을 수 있습니다.
- 오늘 게시된 글은
hh:mm
형식으로 나타납니다. - IntersectionObserver API로 페이지네이션된 데이터의 마지막 데이터를 감시하고 다음 페이지를 요청합니다.
첫화면 | 댓글입력 | 결과 |
---|---|---|
- 간단한 텍스트 편집이 가능합니다.
- (작성완료 시) raw html 태그를 문자열로 저장
{ ... content: `<h1>안내 사항</h1>` }
- (읽기) DOMpurify로 감싼 후 에디터 내부에 주입
<TextEditor dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(htmlString) }} />
- (편집 시) 꾸며진 텍스트를 다시 불러오기 위해 db에 저장되어 있는 raw html 문자열을 draftjs의 contentState로 변환
글쓰기 | 결과 | 모달 |
---|---|---|
글 수정 | 말머리 | 검색 |
---|---|---|
- 프로필 섹션에서 계정 정보를 변경할 수 있습니다.
- 프로필 사진을 삭제하거나 변경할 수 있습니다.
- 닉네임을 변경할 수 있습니다.
- 정규식 검사 => 중복 검사
- 비밀번호를 변경할 수 있습니다.
- 기존 비밀번호 일치 검사 => 정규식 검사
첫화면 | 프로필사진 변경 | 닉네임 변경 |
---|---|---|
- 리뷰를 남긴 식당 이름과 리뷰를 모아볼 수 있습니다.
비밀번호 변경1 | 비밀번호 변경2 | 리뷰 모아보기 |
---|---|---|
- 토큰은 공개키를 사용하여 로그인 시 JWT로 발급합니다.
- 회원가입 기능은 인풋별로 값을 검증하는 스테이지 구조의 UI입니다 e.g 아이디를 통과하지 못하면 닉네임 스테이지로 넘어갈 수 없습니다.
회원가입 | 메시지 | 로그인 |
---|---|---|
로그인 유지
항목을 체크하면 쿠키 만료기한이 7일로 설정됩니다. 이외에는 세션 토큰이 설정됩니다.- 카카오 로그인 시 전달받은
사용자 id
를userId
로 등록하고 프로필 사진과 닉네임을 가져옵니다. 최초 로그인 시 userId를 등록하는 회원가입 로직이 실행됩니다.
화면전환 | 카카오 로그인 | 결과 |
---|---|---|
-
서버 배포 후 db의 registeredAt 시간이 실제 시간과 차이나는 현상
- 서버 빌드 환경변수에
TZ=Asia/Seoul
추가하여 해결
- 서버 빌드 환경변수에
-
jwt 비대칭키 형식 문제
-
올바른 환경변수 형식 사용
// .env PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\nabcd...\n-----END RSA PRIVATE KEY---'; // 환경변수 문자열 내부 개행문자를 실제 개행으로 변환 const privateKey: string = process.env.PRIVATE_KEY.replace(/\\n/g, '\n');
-
-
모바일 웹 100vh 기준 문제 해결
-
데스크탑 크롬 웹에서 100vh는 주소창을 제외한 나머지 뷰의 높이지만, 모바일 웹에서는 주소창 포함 높이로 계산됩니다. 그래서 모바일에서는 주소창 높이만큼 밀려서 네비게이션 바가 보이지 않는 현상이 있었습니다.
-
모바일 환경에서는 높이를 innerHeight로 계산하도록 수정
function App({ children }: { children: React.ReactNode }) { const [isMobile, setIsMobile] = useState(false); const appRef = useRef<HTMLDivElement>(null); useEffect(() => { setIsMobile(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)); }, []); useEffect(() => { if (isMobile) { const element = appRef.current as HTMLDivElement; if (element !== null) { element.style.height = window.innerHeight + 'px'; } } }, [isMobile]); return ( <div className="app" ref={appRef}> {children} </div> ); }
-