My Boundary As Much As I Experienced

(패스트캠퍼스X야놀자 테크 스쿨) 숙박 예약 사이트 '빨리잡아' 구현 본문

FrontEnd/React

(패스트캠퍼스X야놀자 테크 스쿨) 숙박 예약 사이트 '빨리잡아' 구현

Bumang 2023. 12. 21. 15:41

프로젝트를 시작하며 (기획, 디자인)

이번 프로젝트는 숙박 예약을 위한 서비스의 전과정을 구현하는 프로젝트였다. 2주라는 짧은 기간동안 전과정을 구현하는 것은 매우 도전적인 과제였고, 멘토님께서 언급한 '기술적으로 단단한 서비스'를 만드는 것을 목표로 진행하였다.

 

어김없이 내가 초반 이틀 정도는 피그마 디자인을 만들어 제공했는데, 자연스럽게 모바일 - 태블릿 사이즈를 모두 지원하는 느낌보단,

전체적으로 모바일 사이즈를 무리하게 늘려놓은듯한, 요소 하나하나가 비대한 디자인이 되었다는 점은 조금 아쉽다. 내가 개발했던(그리고 지속적으로 관찰했던) 메인페이지 파트의 요소들만 웹 기준으로 적합한 느낌이 든다😅

 

전 프로젝트(8lack)에선 나름 시간이 남아서 그래픽적인 부분까지 필요하면 신경을 좀 썼더니 팀원들의 무한 긍정을 받을 수 있었는데 (개발자들 눈에는 뭔가 대단한 디자인을 한줄 아나부다..ㅎㅎ)

 

이번 프로젝트에선 내가 가장 도전적인 기능 축에 드는 것들을 무리하게 도맡아서 디자인까지 시간을 투자할 여유가 없었다. 내가 팀에 제공할 수 있는 능력을 모두 발휘해서 팀원들을 서포트해주고 싶었는데 그러지 못해 아쉬운 점이 남는다.

 

 

 

프로젝트 결과

서비스 링크:  빨리 잡아!

깃허브 링크:  깃허브

 

 

 

내가 맡은 파트 소개

헤더 - 전역 필터

 

- 지역/날짜/인원 조회

  - recoil을 통한 전역 상태 관리 (지역, 일정, 인원)

  - react calender library를 활용.

    - state업데이트를 하는 시점을 내 컴포넌트에 맞게 조정

    - 라이브러리 스타일을 서비스에 맞게 개조 

  - 조회 시 react query를 통한 캐싱데이터 업데이트

 

 

헤더 - 검색

 

- 검색 / 필터 모드 전환

- 지역 및 숙소 이름 검색 가능

- 검색 전으로 돌아갈 수 있는 해제 버튼

 

 

숙박 카테고리 조회 (ex. 호텔, 모텔, 펜션, 게스트하우스)

- 리액트 쿼리를 통한 데이터 캐싱 (한 번 로딩했던 데이터는 다시 불러올 때 바로 보여주도록 설계)

 

 

숙박 세부 필터 조회

- 등록순, 가격 낮은 순, 가격 높은 순, 가나다순 정렬

- 서버 호출의 결과를 담은 전역 데이터를 필터 로직에 따라 다시 분류해서 detailState에 담아서 보여주는 방식을 택했다.

- 페이지 이탈을 하거나, 다른 카테고리 선택을 할 시 detail 설정은 풀린다.

(퍼블리싱과 정렬 필터 부분은 이예O님께서 멋지게 진행해주셨고, 실제 비동기 호출을 일으켜서 값을 업데이트 하는 부분을 담당하였다.)

 

 

메인페이지 - 무한 스크롤 구현

 

- intersection observer와 useRef를 이용하여 무한 스크롤 구현

(한 12시간 이상은 고민했던 문제...)

 

 

 

기술적으로 고민했던 문제1  - 헤더 필터의 전역상태 구조 설계

 

가장 기술적으로 고민을 많이 한 부분이다.

유저가 서비스를 사용하며 설정한 지역, 날짜, 인원의 값들을 계속 유지하고 있어야 한다.

이를 filterState라고 명명하고 관리하였는데 처음에는 이 부분을 유저가 선택하는 족족 전역 상태를 업데이트했다.

 // 대략적인 처음 필터 전역 상태 값
 export const filterState = atom<filterStateTypes>({
        key: "filterState",
        default: {
        locale: "ALL", // 지역
        startDate: new Date(), // 시작일
        endDate: new Date(), // 종료일
        category: "ALL", // 숙박업소 타입
        amount: 2, // 인원
    }
 }

 

 

그러나 사용자가 이리저리 둘러보고 실제 '조회 버튼'을 눌러 찾아보지 않은 상황에서

개별 숙소로 이동하면 '시험 삼아 눌러보던 필터 값'들이 적용되어 숙소 예약 여부를 파악한다는 문제점이 발생하였다.

// 변경된 구조
export const filterState = atom<filterStateTypes>({
  key: "filterState",
  default: {
    current: { // 가장 최근 '조회'를 눌러서 저장한 값
      locale: "ALL",
      startDate: new Date(),
      endDate: new Date(),
      category: "ALL",
      amount: 2,
    },
    // 아래는 유저가 시험삼아 눌러본 값
    locale: "ALL",
    startDate: new Date(),
    endDate: new Date(),
    category: "ALL",
    amount: 2,
  },
});

그래서 '가장 최근 조회 버튼을 눌렀을 때의 값'을 기억하여 페이지를 이동할 때 이때의 값을 뿌려주는 것으로 설계를 변경하였다.

 

 

 

사실 이럴거면 유저가 눌러보는 값은 아예 전역상태에서 빼버리면 되지 않나?

라고 충분히 물어볼 수 있다.

하지만 헤더 컴포넌트의 depth는 5단계 정도되며 헤더의 최상단에서 지역, 일정, 인원 필터들의 관련 props들이 10개가 넘는다.

처음 설계 때 지옥의 props drilling을 맛보고 전역상태로 관리하도록 한 것이다.

가장 최근에 선택한 값들과 유저가 선택해본 값들을 같은 전역 state에 저장함으로써 업데이트의 순서 보장도 지킬 수 있었다.

// 조회 버튼을 누르면 생기는 일
  const changeFilterHandler = () => {
    setFilterStates(prevStates => ({
      ...prevStates, // 다른 값들은 유지시킨다.
      current: { // 유저가 눌러보던 값들을 current에 반영시킨다.
        locale: filterStates.locale,
        startDate: filterStates.startDate,
        endDate: filterStates.endDate,
        category: filterStates.category,
        amount: filterStates.amount,
      },
    }));
    refetch(); // 리액트 쿼리 재조회 (리액트 쿼리는 filterState.current에 의존한다)
  };

 

다른 조의 구현사항을 보니 url의 쿼리스트링을 사용하여 구현한 조들도 있었다.

가장 직관적인 페이지 간 데이터 송수신 방법이며 이렇게 state를 많이 관리할 필요도 없는 효율적인 방법인거 같다.

 

그러나 쿼리 스트링에 의존할 경우에도 장단점이 있는데, 단점은 뒤로가기 시 이전의 값으로 롤백된다는 단점이 있다. 장점은 새로고침해도 값이 유지된다는 점이다.

 

그래서 실제 야놀자의 경우, 지역/일정/인원 조회를 어떻게 관리하는가를 궁금했는데, 팀원들과 찾아본 결과 야놀자는 쿠키를 이용하여 관리한다는 점을 발견할 수 있었다!

페이지 간 이동에도 값이 유지되며, 새로고침해도 살아남는 이 쿠키란 녀석을 왜 고려하지 않았을까 싶다. 다음에 전역 상태를 관리해야되는 이슈가 생기면 쿠키를 사용하는 것도 옵션으로 삼아야겠다.

 

 

 

기술적으로 고민했던 문제2  - 무한 스크롤이 useRef가 초기화되기 전에 자기가 먼저 초기화 되어 null을 구독하는 문제

useQuery와 '캐싱되는 응답 배열'을 사용하여 리스트를 구현하였고, intersection observer와 useRef를 통해 비동기 호출 시점을 구현하였다. 푸터를 ref로 구독하였고, 푸터가 뷰포트 이내로 들어오면 다음 페이지를 비동기 호출하도록 설계하였다.

 

const scrollRef = useRef<HTMLDivElement | null>(null);

그런데 intersection observer가 useRef가 푸터 DOM요소를 구독하기전에 intersection observer가 먼저 ref를 구독해서 동작이 안 하는 문제가 발생했다. 처음에는 useEffect보다 useRef가 초기화되는게 빠를줄 알았는데 아니어서 당황했다.

이는 intersection observer가 쓰인 useEffect의 의존성 배열에 useRef.current만 추가해주면 해결되는 일이지만,

마감 직전에 내가 선택했던 방법은 setTimeout으로 useRef 구독 후 Interection observer를 초기화시키는 것이었다.

추후 리팩토링 시간에 의존성 배열을 통해 해결했다. (+ 이때 클린업 함수를 꼭 써줘서 복수의 intersection observer를 만드는걸 방지해야한다.)

 

또한 기존 리스트에 새로 비동기 통신을 해서 불러온 리스트를 append해주는 것도 구조적으로 고민이 많았다. 나는 useQuery를 통하여 무한스크롤을 구현했는데 사실 리액트 쿼리의 useInfiniteQuery를 사용하면 응답 배열의 아래 append하는 구조를 더 쉽게 구현할 수 있었다. 이 경우엔 백엔드에서 리스트를 paging하지 않고 통째로 보내주면, useInfiniteQuery가 알아서 paging을 해주는 방식이다.

 

그러나 useQuery를 사용할 수 밖에 없기도 하였다. 왜냐하면 백엔드 개발자 분과의 첫 회의 때 무한스크롤 구현을 어떻게 할지 잘 모르는 상태에서, '12개씩 paging해서 응답해주세요'라고 전달드렸기 때문이다. 추후 마감이 얼마 안 남은 상황에서 "무한스크롤 구현이 쉬운 라이브러리 쓸건데 paging 나눠주던거 없애주세요"라고 할 수 없는 노릇이었기 때문이다.

 

useQuery의 refetch시점을 intersection obsever가 footer를 발견하는 시점으로 만들고, refetch가 발생하면 캐싱되는 배열을 하나 구현해서 여기에 추가하도록 설정하였다. (이것도 recoil 전역 상태를 이용하여 구현하였다.)

 

 

 

백엔드와 협업에서 겪은 문제 - CORS 에러

기본적으로 웹서버는 동일 출처 정책 (Same Origin Policy)를 따른다.

프로토콜(http, https, ...) + 도메인(localhost, ...) + 포트번호(5173, 3000, ...) 등이 다 같은 서버끼리만 통신할 수 있는 것이다.

그러나 최근의 웹개발에는 프론트 서버의 origin과 백엔드 서버의 origin이 다른 경우가 많이 발생하여, 백엔드 측에서 white list에 허용해줄 출처를 추가하여 관리해줘야 한다.

 

그러나 '빨리 잡아'의 백엔드 서버는 EC2 서버 호스트와 80 포트에서 돌아가고, 프론트 서버는 localhost 와 5173이란 포트로 요청을 보내서 CORS에러가 발생하였다. 처음엔 프론트에서 요청을 잘못한 것인지 체크하느라 시간을 썼지만, 아무리 봐도 프론트에서 입력값을 잘못 적은 부분은 없는 것 같아 백엔드 팀과 상의하였다.

 

임시 해결 방법)

이 과정에서 우리 팀 프론트 멘토님께 여쭤보니 이런 경우 proxy 서버를 이용해서 우선적으로 해결하고 나중에 백엔드에서 해결하도록 시간을 주는 방향이 좋을 것이라고 말씀해주셨다.

 

관련해서 같은 팀원 솔O님께서 다뤄주신 블로그 글.

https://sol-mi.tistory.com/81

 

근본적 해결 방법)

백엔드 단에서 web config설정을 하여 localhost:5173 으로 오는 요청을 열어 두어 문제를 해결했다.

사실 이미 web config 설정을 해두셨는데, 시큐리티 설정에서 다른 출처인 부분을 제외하는 로직이 있었다고 한다. 그래서 web config 설정이 걸러지고, localhost:5173이 걸러졌던 것이다. 추후 이 부분을 인지하고 추가로 수정해주셨다!

 

 

 

 

후기

- 멘토님께서 백엔드 api가 나오기 전까진 msw를 이용하여 비동기 통신을 미리 구현해보는게 어떻냐는 제안을 주셨고, 실제로 api가 나온 이후로는 URL만 교체해주면 끝날 정도로 실제와 비슷하게 구현 가능했던게 인상깊었다. 앞으로의 프로젝트에서도 적극 활용할 생각이다.

 

- 개인적으로 일이 즐거워야 사는 맛도 난다고 생각한다. 방어적인 태도보다 선한 태도가 팀워크는 물론 능률조차 올려준다고 믿고 있다. 짧은 기간에 많은 기능을 구현해야되는 꽤나 스트레스가 심한 상황이지만 매사 산뜻한 태도로 팀을 리드해준 팀원들이 고맙다.