나만의 지역기반커뮤니티 사이트 만들기[넘블 프로젝트] 회고록

kind J·2022년 12월 1일
1

Project Info

서비스 명 : 소분소분
Github : https://github.com/SobunSobun/SobunSobun-FrontEnd
배포주소: http://sobunsobun.s3-website.ap-northeast-2.amazonaws.com/

되돌아보며...

10/21 - 12/1 동안 진행된 넘블에서 주최한 나만의 지역기반 커뮤니티 사이트의 프로젝트가 곧 끝이난다.

우리 12팀은 디자이너 1명, 백앤드 2명, 프론트 3명로 구성되어 있다.

40일 정도의 기간동안 머리를 맞대고 좋은 서비스를 만들기 위한 노력의 결과가 내일이면 공개되는데, 설레기도하지만 아쉽기도 하다.

꾸준히 개발 기록을 남기고 싶었는데 그동안 개발 기록 남길 틈도 없이 프로젝트 하느라 바빴던것같다.

마감 직전까지 디자인 업데이트와 여러 개발 이슈들을 다루느라 정신이 없을 것 같지만 회고록은 꼭 남기는게 좋을것 같아서 글을 남긴다.

우리 팀은 초반 기획에 시간을 많이 쏟았던것 같다. 기획을 확실하게 잡아서 주제가 명확히 잡혀있어야 나중에 혼란스럽지 않을 것이기 때문이다.

기획회의, 컨셉회의를 1주일 정도 진행한 뒤 '소분소분'이라는 지역 주민들끼리 모집해서 장보기 재료를 나누는 서비스를 구현하기로 했다.

프론트 팀

기술스택에 대한 고민

디자인 시안이 나오기 전에 프론트 팀은 기술 스택과 레이아웃을 어떻게 잡을것인지 고민했다.

언어 - Typescript

일단 넘블에서 제시한 리액트 라이브러리와 함께 우리는 타입스크립트를 도입하기로 했다. 타입스크립트는 정적타입의 컴파일 언어이기 때문에 코드 작성 단계에서 타입을 체크해 오류를 확인할 수 있는 장점이 있다.

타입스크립트를 처음 내 개인 프로젝트에 도입했을때 미숙하기도 하고 알 수 없는 에러 때문에 시간을 많이 쏟았었다. 그래도 과거 경험을 바탕으로 이번 프로젝트에서 타입스크립트를 잘 사용해보고 싶었다.

그런데 확실히 이전에 고생해서 축적해놓은 지식이 있었기 때문에 이번 프로젝트에서 타입스크립트를 쓰는 것이 그렇게 어려운 일은 아니였던것 같다.

중앙 데이터 저장소 - Recoil

전역 상태관리를 어떤것을 쓰면 좋을지 고민을 했다.
기간이 길지 않기 때문에 다 같이 써본 스택이거나 러닝커브가 높지 않은 것을 택해야했다.

Context API는 비동기 통신을 할 때 요청, 성공, 실패 를 직접 구현해야하므로 귀찮은 점이 있고
Redux는 안정성은 있지만 코드량이 매우 길어지고 요즘은 Redux Toolkit 을 같이 쓰는 추세라 모두 공부를 하고 프로젝트에 도입해야했기 때문에 어려움이있었다.
그래서 우리가 쓰기로 한것은 Recoil 이다. 러닝커브가 높지 않고 React query 와 조합이 좋기 때문이다.

비동기 통신 - React Query

리액트 쿼리는 서버의 값을 클라이언트에 가져오거나 캐싱, 동기화, 에러핸들링 등의 비동기 과정을 쉽게 만들어주는 라이브러리이다. 클라이언트 자체 내에서 필요한 데이터와 서버에서 가져온 데이터를 분리 시켜서 관리 할 수 있는 장점이 있다.

우리가 만들 서비스가 지역 커뮤니티이기 때문에 지역 곳곳에서 글이 생성되고 삭제되고 수정된다. 데이터가 수시로 업데이트 되는 서비스에 리액트 쿼리를 도입하는 것이 좋은 것 같아서 선택하게 되었다.

style - SCSS

스타일드 컴포넌트는 가독성이 떨어져서 선택하지 않았고, CSS 보다 훨씬 편리하고 가독성도 좋은 SCSS 를 사용하기로 하였다.

프로젝트 전략 정하기 및 레이아웃 잡기

전략

3명이서 협업하다보니 각각 코드 스타일이 다를 수 있다. 그래서 규칙이 필요했다. Eslint, Prettier, Stylelint 를 도입하기로 하였다. 그리고 import 순서도 정했다.

Eslint는 코딩 컨벤션에 위배되는 코드나 안티 패턴을 검출해준다.
prettier 는 팀에서 정한 코딩 스타일을 따르도록 변환시켜준다.
Stylelint 를 쓰면 불 필요한 css 코드를 제거하고 작성 순서가 지켜져서 가독성을 높혀준다.

폴더구조

리액트 폴더 구조는 아래와 같이 잡았다.

레이아웃

시간이 많이 없어서 mobile 을 대응하기로 했다.
레이아웃이 두가지로 나뉘는데 하단에 GNB 툴바가 있는 형태와 없는 형태이다.

레이아웃은 react-router-dom 에 Outlet 라는 것을 이용했다.

그런데 이런식으로 레이아웃을 잡는것이 맞는지는 확실하지가 않다.
Routes 안에 Route 가 크게 두게 있는데 이렇게 해도 되는걸까?

좋은 예시가 있으면 꼭 참고 하고 싶다.

GNB 가 있는 페이지는 아이폰 등에서 발생하는 스크롤 이슈로 가려지는 현상을 고려해서 계산한 값이 들어가도록 CSS 를 짰다.

이 과정에서 스크롤시 리액트 랜더링 문제 때문에 Debounce 를 사용해 함수 호출 횟수를 줄였다.

기능 구현

디자인을 바탕으로 역할 분배를 했다.

로그인 기능

나는 로그인 기능을 맡게 되었다.

로그인시에 서버는 사용자 식별에 필요한 쿠키 또는 토큰을 발급해준다.
우리는 사용자 식별을 위해 쿠키와 세션을 이용하는 대신 JWT 를 사용하기로 하였다.

refresh token과 aceess token을 활용해서 사용자 인증 하는 것을 이번 프로젝트에 적용해보고 싶었는데, 서버 비용 문제 때문에 refresh token 발급은 백앤드 쪽에서 어렵다고 했다.
그래서 access token 의 유효기간을 길게 가져가기로 했다. 그 점은 좀 아쉽다.

먼저, axios 의 사용자 정의 구성을 사용하는 axios 인스턴스를 생성했다.

import axios from 'axios'

const BASE_URL = 'http://15.164.112.119:8080'

const baseSettings = {
  baseURL: BASE_URL,
  withCredentials: false,
  timeout: 10 * 1000,
}

const axiosApi = () => {
  const instance = axios.create(baseSettings)
  return instance
}

const axiosAuthApi = () => {
  const instance = axios.create({
    ...baseSettings,
    headers: { SOBUNSOBUN: `${localStorage.getItem('sobunsobun')}` },
  })
  return instance
}

const defaultInstance = axiosApi()
const authInstance = axiosAuthApi()

export const getInstance = (withAuth?: boolean) => (withAuth ? authInstance : defaultInstance)

위와 같이 만들어 놓은 인스턴스를 사용하면 요청마다 옵션을 적어주는 반복작업을 할 필요가 없다.
서버 주소가 바뀐다면 인스턴스를 정의한 곳에서 한번에 바꿔주기 때문에 유지보수 측면에서도 좋다.
인스턴스를 사용하는 곳에서는 getInstance만 호출하면된다.

인증이 필요없는 요청에는

getInstance()

위와 같이 호출하고 인증이 필요한 곳 (마이 페이지, 내 게시물 수정 등) 에서는

getInstance(true)

위와 같이 호출 하면된다.

그런데 위의 코드로만은 문제가 있었다. 계속 에러가 났다. access 토큰이 만료되서 나는 에러였다. 그래서 Axios 인터셉터를 추가했다.

인터셉터는 then 또는 catch로 처리되기 전에 요청과 응답을 가로챌 수 있다. 요청이 전송되기 전에 요청을 변경하거나 전달되기 전에 응답을 수정하는 데 사용될 수 있다.

Axios interceptor 는 만료가 임박한 액세스 토큰을 모니터링하는데 유효하다. 토큰이 만료되기 전에 토큰을 업데이터하는 데 함수를 사용할 수 있다.

그리고 http 응답이 기존 토큰이 만료되었음을 의미하는 오류를 반환할 때마다 메서드를 호출하여 엑세스 토큰을 가져올 수 있다.

authInstance.interceptors.request.use((config) => {
  const token = localStorage.getItem('sobunsobun')
  if (!config.headers || !token) return config
  const headerToken = config.headers.SOBUNSOBUN
  if (headerToken === token) return config

  config.headers.SOBUNSOBUN = token
  return config
})

config 는 아래와 같은 정보들을 담고 있는 옵션이다.

로그인을 요청을 하여 응답받은 토큰을 로컬스토리지에 저장을 하는데,
토큰을 받기 전에는 config.headers 과 변수 token 이 비어있다.
그 때는 기본 config 를 return 한다.
config.headers 과 변수 token 둘 다 있을 값이 있을 경우는 config.headers.SOBUNSOBUN 와 변수 token 두 값을 비교해서 값이 같다면 config 를 리턴하고, 다르다면 config.headers.SOBUNSOBUN 에 token 을 할당한 뒤 config 를 리턴한다.

이렇게 하여 토큰이 필요한 요청에 만료된 토큰으로 인한 에러를 막을 수 있었다.

react-hook-form 사용해보기

로그인 폼과 회원가입 폼에서는 input 들이 많이 필요하다. input + label 은 Input 컴포넌트로 따로 만들었지만 state 를 관리할 때 많은 비슷한 코드가 있어야되기 때문에 react-hook-form 을 사용하기로 하였다. 유효성 검사하는 것도 편리하고 코드량을 많이 줄여주기 때문이다.

<Input htmlFor='email' text='이메일'>
  <input
    type='email'
    id='email'
    placeholder='예)sobunsobun@subun.co.kr'
    {...register('email', { required: true })}
    />
</Input>

value 와 onChange 이벤트를 따로 작성해줄 필요가 없다. state, setState 를 react-hook-form 에서 관리를 해준다.

register() 함수의 첫번째 자리는 input 의 id 값이 들어간다. {require:true} 를 써주면 해당 인풋 이 입력되지 않았을 때 submit 되지 않는다.


미입력시 위와 같이 에러메세지를 출력할 수 있다. 경고 메세지도 바로 아래 적어주기 때문에 유지보수 측면에서 좋은것 같다.

디자인상 두가지 인풋이 모두 입력되었을 경우 제출 버튼이 활성화가 되야한다.
그때는 watch 메서드를 사용한다.

  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
  } = useForm<FormValues>()

  const watchEmailValue = watch('email', '')
  const watchPasswordValue = watch('password', '')
<Button
  type={watchEmailValue && watchPasswordValue ? 'primary' : 'negative'}
  text='로그인'
  submit
  loading={isLoading}
  />

react query 사용하기

로그인 form을 서버에 보낼 때는 react query 의 useMutation을 사용하였다.
로그인은 유저의 중요한 정보가 담겨있기 때문에 http 요청 메서드로 post를 써야한다
react query 에서 get 을 제외한 다른 요청은 useMutation을 사용하여야 한다.

react query를 사용하면 mutate, isLoading 등을 쉽게 구현할 수 있어서 편리하다.

카테고리 선택, 게시물 작성, 수정, 삭제

게시물 작성, 수정 하는데 많은 시간이 소요되었던것 같다.
게시물 작성 페이지와 수정 페이지가 거의 모양이 동일하고 글씨 입력 여부의 차이이기 때문에 Editor 컴포넌트를 만들었다.
Editor 컴포넌트에는 Text input, Counter, Map, 날짜및 시간 선택 등 많은 요소가 포함되어있어서 최대한 컴포넌트로 분리했는데도 코드량이 많다.

카테고리 선택 부터 선택한 데이터가 넘어가서 최종적인 폼에 제출이 되야하고

뒤로가기 버튼을 클릭시 데이터가 유지되야 했기 때문에 더 까다로웠던것 같다.

지역 state를 쓰고 싶었는데, 페이지를 이동해도 데이터를 유지하기 위해 전역 상태관리(recoil)을 썼다.

form 제출 또는 취소를 했을 때 form 의 데이터를 전부 날려야 해서 코드량이 많아지는데

그래서 서버에 요청하는 비동기 코드들은 hooks/usePosting.ts 에 분리하였다.

그리고 업로드, 수정, 삭제시 홈으로 이동 했을 때 업데이트가 안 되는 문제가 있었다. 그것은 데이터를 불러올 때 staleTime: Infinity 로 옵션을 설정 했기 때문이였다. 캐시된 데이터가 무한으로 유지되기 때문에 그것을 수정, 업로드, 삭제 시 invalidateQueries 를 통해 기존에 조회했던 쿼리를 무효화시키고 데이터를 새로 가져왔다.

지도 구현

게시글 입력에 만날 장소를 입력하는 부분에서 카카오 지도를 띄웠다.
카카오 공식 개발 문서가 리액트가 아니라 자바스크립트로 되어있어서 변형이 많이 필요했다. 내 위치를 기반으로 주변 마트를 마커로 띄우고 검색시 해당 키워드에 맞는 결과가 마커로 뜨도록 구현했다.

마커를 클릭하면 마트가 선택이 된다.

HTML5 GeoLocation 을 이용해 접속 위치를 얻어와서 위도와 경도를 넣는 식으로 구현하고 싶었으나 로컬에서는 문제가 없던 것이 배포하고 보니 지도가 안 뜨는 현상이 있었다. 알고보니 Chrome 브라우저는 https 환경에서만 geolocation을 지원을 한다. 시간이 얼마 안 남은 시점에서 https로 전환하기 힘들어져서 회원가입시 입력한 동네의 위도 경도를 받아서 위치를 잡는 식으로 변경하였다.

달력/시간 구현

달력은 react-datepicker 라이브러리를 이용했다. 기존 디자인이 너무 올드해서 커스텀이 필요했다.
Time picker는 Dropdown 컴포넌트를 만들어 활용했다. 드롭다운으로 선택한 시간을 합쳐서 서버로 보내고, 게시물 수정일 경우 합쳐서 온 시간을 다시 쪼개어 수정 페이지와 시간 선택 모달에도 반영해야 되서 좀 까다로웠던것 같다.

마이페이지 구현

마이페이지는 크게 두 가지로 나뉘는데, 현재 상태와 로그아웃 탈퇴 하기가 보여지는 화면과 정보 수정 하는 페이지이다.

회원 정보 수정시 닉네임이 회원가입과 동일하게 중복을 막기위해 중복 확인 api 를 이용했다.

프로필 이미지 변경시 이미지 미리보기를 FileReader 객체를 이용해서 구현했다.

그리고 닉네임과 이미지 둘중 하나만 바꿔도 수정이 되야 했고 API가 이미지 바꾸는 요청과 닉네임 바꾸는 요청이 따로 되어있어서 어떻게 해야할지 고민을 했다.

1) 이미지만 바꾼 경우
2) 닉네임만 바꾼 경우
3) 둘다 바꾼 경우

이렇게 3가지로 나눠서 따로 따로 요청을 하되
성공시 페이지가 이동되야 했기 때문에
react-query 의 useMutation 의 mutateAsync 를 이용해서 모든 요청이 마쳤을때 페이지 이동이 있도록 수정하였다.

그 과정에서 코드가 복잡하고 길어진것 같아 아쉬웠고 나중에 더 깔끔하게 리팩토링 해봐야겠다.

image

스플래쉬, 게시물 작성, 수정, 마감, 삭제시 등장하는 페이지에 gif 가 들어가는데 처음에는 gif 용량이 너무 커서 서버에 올리기도 무리가 있었고 gif 움직임 속도가 너무 느려지는 문제가 있었다.

그래서 1500이 넘어갔던 사이즈를 768이하로 줄여봤다. 너무 줄여버리니 깨지고 화질이 낮아지는 문제가 있었다. 웹에 기본적으로 올리는 이미지 용량은 1MB - 2MB 사이라고 해서 gif 가 2.xx MB 정도 나오되 최대한 깨지지않도록 압축했다.

어려웠던점

  • 토큰 에러
    로그인 후 홈에 접속시 서버에 계속 이런 에러가 떠서 문제가 뭔가 했었는데 토큰 때문에 난 에러였다. 로그인 기능 구현할 때 인터셉터를 따로 사용안했더니 이전 토큰이 남아있어서 요청이 에러가 난것 이었다.

팀원분이 인터셉터를 붙이면서 해결 되었다. 인터셉터를 부분은 좀 더 공부를 해봐야 할 것 같다.

  • React-Query 동작 방식
    useQuery를 잘 활용하려면 useQuery 동작 방식과 어떤 옵션, 기능이 있는지 확실히 알아 두면 좋겠다. 옵션 설정에 따라 서버 데이터 수정시 다른 페이지의 데이터를 업데이트하는 방법을 정확하게 알아두면 좋겠다.

팀원들과 협업 경험

프론트 개발자가 3명이여서 작업이 좀 더 수월했던 것 같다. 내가 모르는 부분을 다른 팀원분이 채워주시고 나도 다른분이 잘 모르는걸 채워주면서 서로 발전하는 계기가 된것같다. 의견 충돌이 가끔 있기도 했지만 서로 이해하고 양보하면서 별 탈없이 잘 진행되었던것 같다.

회사말고 다른 회사사람들과 작업할 기회가 많지 않았는데 이런 기회가 있어서 너무 좋다. 각자의 코드 스타일도 엿보고 좋은거는 서로 공유할 수 있어서 더 좋았던것 같다.

그리고 다른 직군(백앤드, 디자이너)의 분들과 협업하면서 다른 분야에 대한 이해가 깊어진것 같다. 분야별로 중요하게 생각하는 포인트도 다르고 의사소통 방식도 조금씩 달랐지만 최대한 서로를 존중하고 맞춰가려고 노력했다. 그래서 지금의 좋은 결과물이 나온것 같다.

오늘 넘블 프로젝트 제출로 이렇게 마치지만 서비스의 완성도를 높이기 위해서 우리들은 우리가 만든 서비스를 더 들여다보고 개선할 수 있도록 노력할 것이다.

뿌듯하고 알찬 시간을 보낸 우리 팀원들, 넘블러들에게 격려의 박수를 보낸다.

profile
프론트앤드 개발자로 일하고 있는 kind J 입니다.

0개의 댓글