시작하며…

벌써 2주간의 KPT가 끝났다. 리팩토링은 여유롭게 해도 되겠지~ 라는 생각과는 달리 생각보다 할일이 많았고 결국 마감날 자정까지 다들 고생을 했다. 하지만 제출하고 이전보다 훨씬 발전된 로직과 새로운 기능들이 들어간 우리 프로젝트를 보니 후련했다. 주말간 여유롭게 회고 작성하고 다음주는 원래 가려고 했던 도쿄로 떠나볼까 한다…

멘토님 comment 반영

미니프로젝트가 끝나고 주말간 멘토님이 comment를 달아주셨다. 2주간의 짧은 기간동안 급하게 개발하느라 부족한 부분이 있을것이라는 생각을 하긴 했지만 멘토님이 내 예상보다 훨씬 많은 comment를 달아주셨다…(너무 양이 많아 렉이 걸릴 정도였다 😂)

image

공통적으로 지적해주신 내용은 아래와 같았다.

  • semantic 태그 사용 (div이 너무 많습니다…ㅠ)
  • DOM api 및 JS의 기능들 활용
  • 통일된 코드 스타일
  • 코드의 중복 방지

그리고, 각 파일마다 개선할 점들을 적어주셨는데, 우리는 각자 맡은 page나 component에 해당하는 comment 내용을 반영하기로 하였다.

내 부분만 해도 작업 내용이 상당해서 몇개만 추려서 정리해보도록 하겠다.

헬퍼 적용

accommodationId: number | null;

첫번째는 위와 같은 패턴이 자주 반복된다는 점이었다.

type Nullable<T> = T | null;

따라서, 제너릭을 이용한 헬퍼를 만들어서 사용하라고 comment를 남겨주었다. 확실히 중복 패턴을 보다 깔끔하게 처리할 수 있는 좋은 방법인 것 같았다.

URLSearchparams 적용

let url = `${API_BASE_URL}/accommodations?`;

if (accomodationName) url += `keyword=${accomodationName}&`;
if (selectedDistrict) url += `district=${selectedDistrict}&`;
if (startDate) url += `start_date=${startDate}&`;
if (endDate) url += `end_date=${endDate}&`;
if (category) url += `category=${category}&`;

url += `page_num=${pageNum}&page_size=${pageSize}`;

기존에는 querystring 설정 시 string을 이어붙인 구조로 작성하였는데, 이런 구조에서는 실수할 여지가 많아 보인다는 지적을 해주셨다.

const queryParams = new URLSearchParams({
  ...(accomodationName && { keyword: accomodationName }),
  ...(selectedDistrict && { district: selectedDistrict }),
  ...(startDate && { start_date: startDate }),
  ...(endDate && { end_date: endDate }),
  ...(category && { category: category }),
  page_num: String(pageNum),
  page_size: String(pageSize),
});

const url = `${API_BASE_URL}/accommodations?${queryParams.toString()}`;

따라서, URLSearchparams을 사용하여 query parameter들을 객체로 만들어주었고, 넘겨줄 때는 string 형태로 변환하여 querystring을 작성하는 형식으로 바꾸었다.

타입 시스템 활용

  // 타입지정 아무리해도 안돼서 any로 해놨음
  const handleDateChange = (value: any) => {

기존 코드에서 react calendar 라이브러리 사용 시, date값을 parameter로 받아 오는데, type 지정이 잘 안되어서 handleDateChange 함수의 type을 any로 지정해놓았었다.

멘토님이 제안해주신 솔루션은 handleDateChange의 type을 CalendarProps['onChange']로 지정하거나, Parameters<typeof Calendar>[0]['onChange']처럼 제공되는 타입 시스템을 활용해보라고 하셨다.

실제로 찾아보니 Calendar 컴포넌트의 property 타입이 제공되었고, 아래와 같이 변경하였다.

  // Calendar 컴포넌트의 property 타입
  type CalendarProps = Parameters<typeof Calendar>[0];

  const handleDateChange: CalendarProps["onChange"] = (value) => {

image

이밖에도 위와 같이 상당한 양의 comment에 대한 리팩토링을 완료하였다.

에러 핸들링

다음 섹션은 기존에 남아있던 에러 핸들링에 대한 내용이다.

무한스크롤 에러 핸들링

useEffect(() => {
    setSearchList((prevSearchList) => [
      ...prevSearchList,
      ...data.accommodations
    ]);
    setTotalPage(data.total_pages);
    setIsLoadingMore(false);
}, [page]);

...

const handleScroll = debounce(() => {
    const { scrollTop, clientHeight, scrollHeight } = document.documentElement;

    if (scrollTop + clientHeight >= scrollHeight - 50) {
      if (page < totalPage) {
        setPage((prevPage) => prevPage + 1);
        setIsLoadingMore(true);
				refetch();
      }
    }
}, 200);

처음 구성한 무한스크롤 로직은 위와 같았다. scroll값이 조건을 만족하면 page값이 변화하게 되고, data의 refetch가 일어난다. 이후, useEffect hook의 의존성 배열에 page 값을 넣어주었기 때문에 searchList 가 이전 리스트 요소들 + 새로 받아온 data들로 업데이트되는 로직이었다.

위 로직은 겉보기에는 문제없이 작동하는것처럼 보였지만, 페이지가 넘어갔을 때 넘어간 페이지뿐만 아니라 이미 한번 요청을 보낸 이전 페이지에 대한 데이터도 fetching이 일어났다.

return useSuspenseQuery({
    queryKey: ["searchList", pageNum, pageSize, headers, isRefetched],
    queryFn: () =>
      getSearchList(
        accomodationName,
        endDate,
        category,
        pageNum,
        pageSize,
        headers
        pageSize
      )
});

그 이유를 한참 찾다가 useSearchList hook의 queryKeypageNum 을 넣어줬었는데, 해당 값에 변화가 생기면 refetch 가 일어난다는 사실을 간과하고 있었다.

if (page < totalPage) {
  setPage((prevPage) => prevPage + 1);
  setIsLoadingMore(true);
  refetch();
}

그래서 setPage 가 완료되기 전에 기존 페이지에 대해 refetch가 한번 더 일어나고, setPage 가 완료된 후에 다시 한 번 다음 페이지에 대해 refetch 가 일어났던 것이다.

useEffect(() => {
    setSearchList((prevSearchList) => [
      ...prevSearchList,
      ...data.accommodations
    ]);
    setTotalPage(data.total_pages);
    setIsLoadingMore(false);
  }, [data]);

...

useEffect(() => {
  if (page > 1) {
    refetch();
  }
}, [page]);

...

const handleScroll = debounce(() => {
  const { scrollTop, clientHeight, scrollHeight } = document.documentElement;

  if (scrollTop + clientHeight >= scrollHeight - 50) {
    if (page < totalPage) {
      setPage((prevPage) => prevPage + 1);
      setIsLoadingMore(true);
    }
  }
}, 200);

원인을 찾고 나니,queryKey 에서 pageNum 을 삭제하고, searchList 컴포넌트도 setPage가 일어난 후에 refetch가 될 수 있도록 수정을 거쳐 간단하게 해결할 수 있었다.

검색하기 버튼 두번 눌러야 검색되는 에러 핸들링

기존 search 컴포넌트는 keyword, category, district 값들은 querystring에서 값을 가져와 각각 state의 초깃값으로 설정해줬고, start_dateend_date 는 모달에 setter를 넘겨서 state 관리를 하는 약간 기형적인 구조를 띄고 있었다. 심지어 start_date, end_date, district, category 값들은 동시에 전역으로 관리해주고있어서 더욱 복잡한 로직이었다.

위와 같이 복잡한 로직이 탄생하게 된 배경은 searchList 컴포넌트에서 사용하는 useSearchList hook을 search 컴포넌트에서도 재사용하여 검색하기 버튼 클릭 시 refetch 를 해주기 위함이었다. 그런데, useSearchList hook의 parameter로 전달해줄 값들이 많다 보니까 컴포넌트간 상태의 공유가 필요했고, 기존에 querystring 지정이 되어 있던 것들을 가져오는 로직과 전역으로 상태를 관리하는 로직이 섞여서 복잡한 로직이 탄생하였다.

안그래도 복잡한 로직이라 리팩토링 대상이었는데, state의 업데이트가 비동기적으로 처리되기때문에 refetch 시에 이전 state값을 가지고 동일한 get요청을 보내는 이슈가 있었다. 그래서 검색하기 버튼을 처음에 누르면 화면에 변화가 없고, 다시 클릭해서 다음 리렌더링이 발생하는 시점이 되어서야 검색 결과가 업데이트되는 현상이 발생하였다.

그래서 복잡한 로직을 모두 querystring으로 통일하기로 마음먹었다.

const Search = ({
  keyword,
  district,
  start_date,
  end_date,
  category
}: SearchProps) => {

상위 컴포넌트에서 querystring을 가져와 search 컴포넌트에 props로 전달하고, 해당 값들은 search 컴포넌트의 내부 state로 관리하였다.

const handleSearchClick = () => {
  const queryParams = new URLSearchParams({
    ...(accommodationName && { keyword: accommodationName }),
    ...(selectedDistrict && { district: selectedDistrict }),
    ...(startDate && { start_date: startDate }),
    ...(endDate && { end_date: endDate }),
    ...(selectedCategory && { category: selectedCategory }),
    page_num: '1',
    page_size: '10',
  });

  window.location.href = `/searchResult?${queryParams.toString()}`;
};

이후, 검색하기 버튼을 클릭했을 때, refetch를 하는 것이 아니라 queryParams를 설정해주고 페이지의 새로고침을 유발하는 로직으로 수정하여 해결하였다.

locale 설정에 따른 date parse 과정에서의 에러 핸들링

useEffect(() => {
  if (selectedDate && selectedDate[0] && selectedDate[1]) {
    const newStartDate = selectedDate[0].replace(/\s+/g, '');
    const newEndDate = selectedDate[1].replace(/\s+/g, '');

    const parsedStartDate = parse(newStartDate, 'yyyy.MM.dd.', new Date());
    const parsedEndDate = parse(newEndDate, 'yyyy.MM.dd.', new Date());

    const formattedStartDate = format(parsedStartDate, 'yyyy-MM-dd');
    const formattedEndDate = format(parsedEndDate, 'yyyy-MM-dd');

    setStartDate(formattedStartDate);
    setEndDate(formattedEndDate);
  }
}, [selectedDate]);

search 컴포넌트에서는 selectedDate 값이 변경될 때마다 그 값을 parse 후 formatting하는 작업을 해주었다. 로컬에서 테스트할때는 parse과정에서 에러가 없었는데, 테스팅을 하다보니 parse 에러가 발생했다. 날짜 형식이 맞지 않는다는 내용의 에러였는데, 처음에는 null 값이 들어와서 parse가 안되는줄 알았다. 하지만, console에 로그를 찍어보니 날짜 값은 정상적으로 설정되고 있었다.

useEffect(() => {
  const formattedDates = dateRange.map((date) =>
    date ? date.toLocaleDateString() : ''
  );

  setSelectedDate(formattedDates);
}, [dateRange]);

parse시에 날짜 형식도 바꿔보고 여러 시도를 해도 해결되지 않다가 “dateRange 값도 이상이 없고, selectedDate 값도 이상이 없으면 formattedDates 로 변환하는 과정에서의 문제인가?” 라는 생각이 들어서 console에 formattedDates 값을 찍어보니 그 로그가 조금 달랐다.

image

image

위 사진은 locale을 적용하지 않았을 때의 formattedDate , selectedDate 값이고 아래 사진은 locale을 적용했을 때의 값이다. 크롬 브라우저에서는 toLocaleDateString 함수의 default값이 “ko-KR” 이어서 로컬에서 동작했을 때는 parse과정에서 에러가 발생하지 않았지만, chromium 브라우저에서는 default값이 달라서 selectedDate 값이 다르게 불러와진 것이었다.

useEffect(() => {
  const formattedDates = dateRange.map((date) =>
    date ? date.toLocaleDateString('ko-KR') : ''
  );

  setSelectedDate(formattedDates);
}, [dateRange]);

그래서 tolocaleDateString 함수 호출 시 “ko-KR” 옵션을 주어서 다른 브라우저에서 date 변환을 할때도 통일된 값으로 변환될 수 있도록 하여 에러를 해결하였다.

테스트 코드 작성

우리 조는 playwright를 이용한 E2E 테스트를 리팩토링때 해보는 것을 목표로 하였다. 막상 테스트를 작성하려니 KPT 마감일이 얼마 남지 않아서 걱정되었지만, 코드를 작성하는 것은 그리 어렵지 않았다.

처음에는 하나의 spec 파일에 해당 페이지에 해당하는 test들을 쭉 나열하는 형식으로 코드를 작성하였다. playwright에서 extension도 제공해주어서 디버깅 해보면서 모든 케이스들에 대해서 테스트를 작성하고, 전체 테스트를 돌려보았다.

이때, 가장 큰 문제점은 디버깅할때와 실행시의 테스트 속도 차이때문에 테스트를 원하는 DOM Element가 로드되기도 전에 다음 테스트 코드가 실행되버리는 문제였다. 이를 해결하기 위해 waitForSelectorwaitForLoadState 사용해서 특정 dom이 로드되면 다음 테스트 코드를 실행하도록 delay를 주었다.

1차적으로 테스트 코드를 작성하고, 나영님이 추후에 본인 파트의 테스트코드를 작성하셔서 pr을 올려주셨다. 내 테스트 코드와 비교했을 때 각 폴더별로 역할이 명확히 나누어져있었고, POMROM을 사용하셔서 중복 사용되는 함수들을 모듈화해서 사용해서 엄청 깔끔한 느낌을 받았다. 그리고, global-setup을 통해 로그인 과정을 자동화하여 코드 길이를 획기적으로 줄이셨다. (밤새서 작업하시더니 엄청 고퀄의 테스트 코드를 뚝딱 작성하신 능력자 나영님… respect 합니다 ㅎㅎ)

마지막날 시간이 조금 남아서 나영님 방식을 벤치마킹하여 테스트 코드를 리팩토링하였다. 대략적인 폴더 구조는 아래와 같았다.

  /models
    - loginPage.ts
    - searchPage.ts
    - ...
  /services
    - loginRequest.ts
    - userDataRequest.ts
    - ...
  /helpers
    - customTest.ts
    - ...
  /specs
    - loginTest.spec.ts
    - searchTest.spec.ts
    - ...
  • /models: 페이지와 상호작용하는 클래스가 포함된 폴더
  • /services: 백엔드 API와 상호작용하는 클래스가 포함된 폴더
  • /helpers: 테스트를 위한 사용자 정의 함수나 헬퍼 클래스가 포함된 폴더
  • /specs: 실제 테스트 스펙(spec) 파일이 포함된 폴더

model 폴더에는 아래와 같이 페이지와 상호작용하는 함수들을 담은 class 파일들을 만들어주었다.

import { Locator, Page } from "@playwright/test";

export class SearchResultPage {
    page: Page;

    constructor(page: Page) {
        this.page = page;
    }

    async navigateTo(): Promise<void> {
        await this.page.goto("http://localhost:5173/searchResult");
    }

    async waitForImageLoad(): Promise<void> {
        await this.page.waitForSelector("css=img");
    }

    async clickKeywordInput(): Promise<void> {
        await this.page.getByPlaceholder("숙소명 입력").click();
    }

    ...

}

services 폴더에는 아래와 같이 해당 페이지에 대한 get요청들에 대한 res가 잘 오는지 확인하는 class 파일들을 만들어주었다. (post 요청은 필요시 spec 파일 내에서 page.route로 req를 가로채서 검증해보고, ROM 구현해서 res와 화면과 교차 검증하였다.)

import { APIRequestContext, expect } from '@playwright/test';
import { Nullable } from '@/types/nullable';

export class SearchRequest {
  request: APIRequestContext;
  authToken: Nullable<string> = null;

  constructor(request: APIRequestContext) {
    this.request = request;
  }

  async setAuthToken(token: string): Promise<void> {
    this.authToken = token;
  }

  async getSearchResult(): Promise<any> {
    try {
      const headers: { [key: string]: string } = {};

      if (this.authToken) {
        headers['Authorization'] = `Bearer ${this.authToken}`;
      }

      ...

      const response = await this.request.get(
        `https://api.anti-bias.kr/api/accommodations?${queryParams.toString()}`,
        { headers: headers }
      );
      const body = await response.text();
      console.log(body);

      expect(response.ok()).toBeTruthy();

      return JSON.parse(body);
    } catch (error) {
      console.error(error);

      throw error;
    }
  }
}

마지막으로, spec 파일에는 아래와 같이 실제 테스트 코드를 작성해주었다.

import { Nullable } from '@/types/nullable';
import { SearchResultPage } from './../models/searchResultPage';
import { SearchRequest } from '@tests/services/searchRequest';
import { test, expect } from '@playwright/test';

test.describe('검색결과 프로세스', () => {
  let searchResultPage: any;
  let authToken: Nullable<string> = null;

  test.beforeAll(async ({ page, context, request }) => {
    authToken = await context.storageState().then((state) => {
      const localStorage = state.origins.find(
        (origin) => origin.origin === 'http://localhost:5173'
      )?.localStorage;
      return (
        localStorage?.find((storage) => storage.name === 'access-token')
          ?.value ?? null
      );
    });

    searchResultPage = new SearchResultPage(page);
    await searchResultPage.navigateTo();
    const searchRequest = new SearchRequest(request);
    if (authToken) {
      await searchRequest.setAuthToken(authToken);
    }
    await searchRequest.getSearchResult();
    await searchResultPage.waitForImageLoad();
  });

  test('1. 숙소명으로 조회', async () => {
    const keyword = '울산 굿모닝 관광호텔';

    await searchResultPage.clickKeywordInput();
    await searchResultPage.fillKeywordInput(keyword);
    await searchResultPage.clickSearchButton();

    const title = await searchResultPage.clickTitle(keyword);

    await expect(title).toContainText(keyword);
  });

  ...
});

리팩토링 결과, 상술하였듯이 역할별로 폴더를 분리하였고 POMROM을 사용하여 중복 사용되는 함수들을 모듈화하였다. 마지막으로, global-setup을 통해 로그인 과정을 자동화하여 코드 길이를 획기적으로 줄일 수 있었다.

gpt api를 이용한 맛집 추천

gpt api를 연동하여 맛집 추천하자는 아이디어는 꽤 오래전에 해보고싶어서 제안한 아이디어였다. KPT 기간이 널널해서 기간 내에 구현할 수 있을 것 같아서 내 todo에 넣어놓았었다. 하지만, 테스트 코드를 다 작성하고 나니 어느새 마감일 저녁이어서 부랴부랴 레퍼런스들을 찾아봤다. OpenAPI의 공식 document를 읽었을 때는 엄청 쉽게 사용할 수 있어서 안심이 되었다.

바로 document를 따라해보았는데 이런 에러가 발생했다.

OpenAI API giving error: 429 Too Many Requests

찾아보니, credit이 부족해서 발생한 에러였다. 하지만, 5$의 무료 크레딧이 제공된다고 분명히 적혀 있었고, 실제로 usage 그래프에도 아무것도 찍혀있지 않았다. API Key를 재발급받아도 같은 에러가 반복되자 나는 코드가 잘못된줄 알고 열심히 구글링하며 열심히 삽질을 했다.

삽질의 결과 다른 에러를 모두 잡고 API 호출을 시킨 결과는…? 또 429 에러였다 ㅠㅠ

결국 돌고 돌아 모든 change들을 discard 시키고 처음으로 돌아와서 울며 겨자먹기로 billing address를 추가하였다. 나는 카드만 등록해놓으면 무료 크레딧을 이용할 수 있을 줄 알았는데, 얼마 결제할거냐는 모달이 뜨길래 “이거 맞나…?” 라는 생각이 들었지만 안되는걸 어떡하겠는가 ㅋㅋ 결국 지갑을 열었다.

카드를 등록하고 지갑까지 열었지만 “또 똑같은 에러가 나면 어떡하지?”, “내가 API Key를 잘못 입력한거면?” 이런 걱정이 되어서 얼른 테스트를 해봤다.

처음에 또 또 429 에러가 뜨길래 OpenAPI에게 내 소중한 5$를 빼앗겼구나 생각했지만, API Key를 재발급받으니 정상적으로 API 호출이 되었다 😊

번외로, 처음에 pr을 올릴 때 API Key를 env 설정을 안한 채로 그대로 GitHub에 올려버리는 실수를 했다. 뒤늦게 알아차리고 급하게 수정을 하려고 컴퓨터를 켰는데 메일이 하나 와있었다.

내용은 API Key가 누출되어서 disable 되었다는 내용이었다. 역시 대기업이라그런가 API Key 누출까지 관리해서 자동으로 disable 시켜주는 서비스까지👍 지갑을 연 보람이 있었다 ㅎㅎ

image

결론적으로 위와 같이 gpt api를 사용하여 숙소 주변 맛집을 추천하는 기능을 추가하였다. 아직 응답 시간이 길고 급하게 작업하느라 텍스트 형태로만 출력하였지만, 이번에 삽질을 했으니 다음번에는 gpt api를 활용하여 보다 유용한 기능을 개발해볼 수 있지 않을까 기대해본다.

마무리

여유있을줄알았던 2주였지만, 생각보다 할일이 많았던 2주였다. 어쩌면 간단한 기능 몇개 추가하고 comment에 대한 피드백 진행도 설렁설렁 할 수도 있었지만, 다들 열정을 가지고 마감날 밤 늦게까지 작업해주어서 목표한 퀄리티를 낼 수 있었던 것 같다. 제출 후에 나영님이 다른 조보다 우리 조가 잘한 것 같다고 말씀해주셨는데 엄청 뿌듯했다😊 유능한 팀장은 아니었지만, 믿고 잘 따라와준 팀원들 덕분에 만족스러운 프로젝트를 완성할 수 있었던 것 같다 ㅎㅎ

image

다들 파이널 프로젝트도 화이팅하고 나중에 뒷풀이도 해보면 좋을 것 같다 🖐️🖐️

카테고리:

업데이트: