[Personal Project] 나라 API로 받아온 나라 목록 리스팅 (1)

liinyeye·2024년 6월 28일
0

Project

목록 보기
23/44
post-thumbnail

프로젝트 개요

주제 : API 를 호출하고 받은 응답값을 화면에 보여주는 과정에서 타입스크립트를 사용해봅시다.

필수 구현 사항

  • 사용 스택 : vite, react, typescript
  • 제공된 API 를 호출하는 로직을 작성하고 적절한 타입을 사용
  • API 의 응답 값을 컴포넌트에서 useState 를 이용한 상태관리.
    (적절한 타입이 꼭 명시되어야 함.)
  • useState 에서 상태관리되고 있는 값들을 화면에 보여주고, 사용자와 인터렉션 (선택/해제) 가 가능하도록 구현.
    (이 과정에서 적절한 타입이 명시되어 있는 함수 사용)

선택 구현 사항

  • 보여준 데이터를 Sort 할 수 있는 함수 작성
  • Supabase 에 선택되어 있는 나라들을 저장 할 수 있는 로직 작성
    (단, API 에서 받아온 데이터 중 필요한 정보들만 따로 모아서 새로운 Country 타입을 설정하고 그 값을 저장)

프로젝트 결과물

🔗 웹사이트 : https://countries-gold.vercel.app/
🔗 깃허브 링크 : https://github.com/yeliinbb/countries

트러블 슈팅

(1) 선택한 나라 취소 시 원래의 자리로 돌아가는게 아니라, 새로운 항목으로 추가되어 가장 윗부분에 추가되는 문제

기존 로직

기존 로직은 선택 취소 시 취소된 카드를 찾아서 그 카드를 가장 처음 순서에 넣고 새로운 배열로 상태를 업데이트하는 방식이기 때문에 기존의 위치로 돌아가지 않는 문제가 있었다.

const [countryInfos, setCountryInfos] = useState<CountryWithIsSelected[]>([]);
const [selectedCountries, setSelectedCountries] = useState<CountryWithIsSelected[]>([]);
.
.
.
const onToggleSelect = (id: CountryWithIsSelected["id"]): void => {
  // 선택한 나라들
    const selectedCountryList = countryInfos.map((country) =>
      country.id === id
        ? { ...country, isSelected: !country.isSelected }
        : country
    );
// 선택하지 않은 나라들
    const unselectedCountryList = countryInfos.map((country) =>
      country.id !== id
        ? { ...country, isSelected: country.isSelected }
        : country
    );

    const isSelectedCountry = selectedCountries.find(
      (country) => country.id === id
    );

    if (!isSelectedCountry) {
      // 선택 시 selectedCountries 상태 변경 
      setSelectedCountries((prev) => {
        const selectedCountry = selectedCountryList.find(
          (country) => country.id === id
        );

        return selectedCountry ? [...prev, selectedCountry] : prev;
      });
      // 선택 시 countryInfos 상태변경
      setCountryInfos(() => {
        return unselectedCountryList.filter((country) => country.id !== id);
      });
    } else {
      // 선택 취소 시 selectedCountries 상태 변경 
      setSelectedCountries((prev) => {
        return prev.filter((country) => country.id !== id);
      });
      // 선택 취소 시 countryInfos 상태변경
      setCountryInfos((prev) => {
        const selected = selectedCountries.find((country) => country.id === id);
        return selected ? [{ ...selected, isSelected: false }, ...prev] : prev;
      });
    }
  };

수정한 로직

  • 선택/해제 시 countryInfos 상태 변경 X
  • countryInfos 대신 filteredCountries로 ui 그려주기

따라서 선택/해제 시 기존의 countryInfos의 상태를 직접 변경해서 ui로 그려주는게 아니라, selectedCountries 상태를 이용해서 countryInfos를 필터링한 filteredCountries 변수를 따로 만들어주고, 해당 데이터를 ui로 그려 유동적이게 변경될 수 있도록 로직을 수정해줬다.

이 때, some() 메서드를 사용할 수도 있지만 그럴 경우 다시 배열을 순회해야하기 때문에 이 경우에는 Set을 사용하여 필터링해주는 것이 더 효율적이고 코드가 더 직관적이기 때문에 Set을 사용해줬다.

some() 매서드 사용한 로직

// id가 포함되지 않은 경우 즉, 선택되지 않은 국가들만 필터링해줌.
const filteredCountries = countryInfos.filter(
  (country) => !selectedCountries.some((selectedCountry) => selectedCountry.id === country.id)
);

이 로직 작성 시, 두 가지 배열 countryInfos와 selectedCountries의 id를 비교해서 필터링해줘야하기 때문에 하나의 배열 안에서 조건을 비교하여 필터링 해주는 로직보다 좀 더 복잡하게 느껴졌다.

최종 코드

  const onToggleSelect = (id: CountryWithIsSelected["id"]): void => {
      // 선택한 나라들
    const updatedCountryList = countryInfos.map((country) =>
      country.id === id
        ? { ...country, isSelected: !country.isSelected }
        : country
    );

    const isSelectedCountry = selectedCountries.find(
      (country) => country.id === id
    );

    if (!isSelectedCountry) {
      // 선택 시 selectedCountries 상태 변경 
      setSelectedCountries((prev) => {
        const selectedCountry = updatedCountryList.find(
          (country) => country.id === id
        );

        // supabase에 선택된 나라들 저장
        if (selectedCountry) {
          insertData(selectedCountry);
        }

        return selectedCountry ? [...prev, selectedCountry] : prev;
      });
    } else {
      // 선택 취소 시 selectedCountries 상태 변경 
      setSelectedCountries((prev) => {
        // supabase에 저장한 나라 제거
        const selectedCountry = selectedCountries.find(
          (country) => country.id === id
        );
        if (selectedCountry) {
          deleteData(selectedCountry);
        }

        return prev.filter((country) => country.id !== id);
      });
    }
  };

  // 배열의 각 요소에서 id를 추출하여 집합(Set)에 저장
  const selectedCountryIds = new Set(
    selectedCountries.map((country) => country.id)
  );

  // id가 포함되지 않은 경우 즉, 선택되지 않은 국가들만 필터링해줌.
  const filteredCountries = countryInfos.filter(
    (country) => !selectedCountryIds.has(country.id)
  );

(2) 정렬 sortCountries() 함수 default 버튼

  • 오름차순 / 내림차순 버튼 : localeCompare() 문자열 비교 정렬 매서드
  • default 순서 정렬 버튼 : countryInfos와 별도로 initialCountryInfos로 관리

localeCompare()

referenceStr.localeCompare(compareString, locales, options)

반환 값

  • 음수 값 : referenceStr이 compareString보다 앞에 오는 경우
  • 0 : referenceStr이 compareString과 같은 경우
  • 양수 값 : referenceStr이 compareString보다 뒤에 오는 경우

sort()

오름차순 정렬

const numbers = [4, 2, 5, 1, 3];
numbers.sort((a, b) => a - b);
console.log(numbers); // [1, 2, 3, 4, 5]

내림차순 정렬

const numbers = [4, 2, 5, 1, 3];
numbers.sort((a, b) => b - a);
console.log(numbers); // [5, 4, 3, 2, 1]

문자열 정렬

const items = ["banana", "apple", "Cherry"];
items.sort((a, b) => a.localeCompare(b));
console.log(items); // ["apple", "banana", "Cherry"]

객체 배열 정렬

const users = [
  { name: "John", age: 25 },
  { name: "Jane", age: 22 },
  { name: "Bill", age: 30 }
];
// 나이 순으로 오름차순 정렬
users.sort((a, b) => a.age - b.age);
console.log(users);
// [
//   { name: "Jane", age: 22 },
//   { name: "John", age: 25 },
//   { name: "Bill", age: 30 }
// ]

문제점

여기서 default 블록은 sortOption 값이 어떤 case에도 해당하지 않을 때 실행되며, countryInfos 배열을 변경하지 않고 그대로 반환한다.

처음에는 default버튼을 default 블록을 이용해서 구현하려고 했으나, 다른 케이스에서 setCountryInfos로 countryInfos 상태를 직접 변경해서 화면에 그려주는 것이기 때문에 오름차순/내림차순 버튼을 클릭한 이후 default버튼을 눌러도 이전에 변경된 상태가 그대로 적용되어 화면에 변화가 일어나지 않았다.

해결방안

따라서 이를 위해 "Default" case를 따로 설정해주고, API에서 받아온 데이터 저장 시에 따로 만들어준 initialCountryInfos 배열에 값을 넣어주고 해당 값을 리턴해주도록 했다.

let initialCountryInfos: CountryWithIsSelected[] = [];
.
.
.
try {
        const data = await fetchDataAndTransform();
        if (countryInfos.length === 0) {
          for (const country of countryInfos) {
            initialCountryInfos.push(country);
          }
          setCountryInfos(data || []);
        }

그럼, default 블록이 적용되는 경우는?

예를 들면 handleSortChange("Random"); 이것 처럼 함수 안에 다른 값을 넣어주는 경우가 있다. 하지만 사실 현재 로직에서 그런 경우는 없기도 하고, 정렬되는 데이터를 따로 상태관리를 해주고 있지 않기 때문에 default 블록은 삭제해줘도 무방하긴 하지만 안정성을 위해 일단은 넣어두었다.

최종 코드

  const sortCountries = (
    sortOption: string,
    countryInfos: CountryWithIsSelected[]
  ): CountryWithIsSelected[] => {
    const newArr = [...countryInfos];
    switch (sortOption) {
      case "A-Z":
        newArr.sort((a, b) => a.countryName.localeCompare(b.countryName));
        break;
      case "Z-A":
        newArr.sort((a, b) => b.countryName.localeCompare(a.countryName));
        break;
      case "Default":
        return [...initialCountryInfos];
      default:
        return countryInfos;
    }
    return newArr;
  };

  const handleSortChange = (sortOption: string) => {
    const sortedCountries = sortCountries(sortOption, countryInfos);
    setCountryInfos(sortedCountries);
  };
.
.
.
// ui 그려주는 부분
<BtnBox>
          <span>[ Sorted By ]</span>
          <button onClick={() => handleSortChange("Default")}>Default</button>
          <button onClick={() => handleSortChange("A-Z")}>A-Z</button>
          <button onClick={() => handleSortChange("Z-A")}>Z-A</button>
        </BtnBox>

(3) supabase 관련 오류들

문제점 ① : 테이블 데이터 삭제 불가

  • 네트워크 창에서는 삭제 요청이 문제 없이 이뤄지지만, 데이터베이스에서는 삭제되지 않음

해결 방법

① RLS(Row Level Security) disabled (임시 방편)

RLS enabled된 상태에서 데이터베이스에 접근하기 위해서는 별도로 정책을 설정해줘야한다. 정책을 따로 설정해주지 않았을 경우 RLS disabled로 보안을 비활성화해 데이터베이스에 제약 없이 접근할 수 있도록 설정이 가능하다.

사용자가 자신의 데이터에만 접근 가능하도록 한 설정

[ RLS 정책 설정 예시 ]

-- RLS 활성화
ALTER TABLE public.table_name ENABLE ROW LEVEL SECURITY;

-- SELECT 정책 설정 : 데이터 조회
CREATE POLICY select_policy
ON public.table_name
FOR SELECT
USING (user_id = current_user);

-- INSERT 정책 설정 : 데이터 삽입
CREATE POLICY insert_policy
ON public.table_name
FOR INSERT
WITH CHECK (user_id = current_user);

-- UPDATE 정책 설정 : 데이터 업데이트
CREATE POLICY update_policy
ON public.table_name
FOR UPDATE
USING (user_id = current_user)
WITH CHECK (user_id = current_user);

-- DELETE 정책 설정 : 데이터 삭제
CREATE POLICY delete_policy
ON public.table_name
FOR DELETE
USING (user_id = current_user);

모든 사용자에게 모든 데이터에 대한 접근 권한을 부여하는 설정

-- RLS 활성화
ALTER TABLE public.table_name ENABLE ROW LEVEL SECURITY;

-- SELECT 정책
CREATE POLICY select_policy
ON public.table_name
FOR SELECT
USING (true);

-- INSERT 정책
CREATE POLICY insert_policy
ON public.table_name
FOR INSERT
WITH CHECK (true);

-- UPDATE 정책
CREATE POLICY update_policy
ON public.table_name
FOR UPDATE
USING (true)
WITH CHECK (true);

-- DELETE 정책
CREATE POLICY delete_policy
ON public.table_name
FOR DELETE
USING (true);

② 삭제 정책 추가

수파베이스 docs에서 delete가이드를 잘 살펴보면, 기본적으로 RLS는 모든 접근을 차단하므로, 행을 보이게 하기 위해 적어도 하나의 SELECT 정책을 설정해야 한다고 나와있다. 따라서 새로운 DELETE 정책 추가하는 로직 이전에 SELECT 정책를 만들어 사용자가 모든 행을 볼 수 있도록 허용해줘야 삭제도 가능하다.

SQL 스크립트

-- RLS 활성화
ALTER TABLE country_infos ENABLE ROW LEVEL SECURITY;

-- 기존 SELECT 정책 제거 (있는 경우)
DROP POLICY IF EXISTS "Allow all users to select" ON country_infos;

-- 새로운 SELECT 정책 추가: 모든 사용자가 모든 행을 볼 수 있도록 허용
CREATE POLICY "Allow all users to select"
ON country_infos
FOR SELECT
USING (true);

-- 기존 DELETE 정책 제거 (있는 경우)
DROP POLICY IF EXISTS "Allow all users to delete" ON country_infos;

-- 새로운 DELETE 정책 추가: 모든 사용자가 모든 행을 삭제할 수 있도록 허용
CREATE POLICY "Allow all users to delete"
ON country_infos
FOR DELETE
USING (true);

문제점 ② : 데이터 데이터 저장 불가

문제점

  • upsert 매서드 사용

upsert (update + insert)
insert와 update를 합쳐준 매서드. onConflict를 사용하여 중복되는 값을 확인할 coloum를 지정해준다. 해당 값이 테이블에 있다면 update만 해주고, 없다면 insert를 해준다.

const { data, error } = await supabase
  .from('users')
  .upsert({ id: 42, handle: 'saoirse', display_name: 'Saoirse' }, { onConflict: 'handle' })
  .select()

( insert와 upsert 비교한 내용 참고 자료 )

원대대로라면 upsert매서드가 제대로 작동해야하지만, 401오류가 뜨면서 실제로 코드에서 upsert자체를 사용할 수는 없었다.

해결 방법

  • upsert -> insert 수정

upsert매서드를 어떻게 사용해야하는지에 대한 가이드 내용 (링크)

해당 아티클 내용을 잘 읽어보면 PostgreSQL(supabase에도 해당됨)에서 upsert 기능을 사용 가능 하지만, 이를 사용하기 위해서는 INSERT ... ON CONFLICT 구문을 사용해야한다고 나와있다.

문제점 ③ : 중복되는 키 콘솔창 오류

해결 방법

  • strict mode 비활성화

strict mode에서는 useEffect안에 있어도 데이터가 두 번 그려지게 된다. 따라서 데이터가 두 번 저장되는 방식으로 로직이 실행되어 콘솔창에 중복되는 키에 대한 오류가 떴던 것이다. 따라서 strict mode를 주석처리해주어 비활성화하면 된다.

최종 코드

supabase 데이터 삽입 로직

// 데이터 삽입 함수
export const insertData = async (selectedCountry: CountryWithIsSelected) => {
  // console.log("insertDataFn", selectedCountry);
  try {
    const { data, error } = await supabase
      .from("country_infos")
      .insert(selectedCountry, { onConflict: "id" });

    if (error) {
      console.error(`Error inserting data : ${error.message}`);
      throw error;
    }
    console.log("Data inserted successfully:", data);
    // return data;
  } catch (error) {
    if (error instanceof Error) {
      console.error("Failed to insert data :", error);
      throw new Error(`Failed to insert data : ${error.message}`);
    }
  }
};

api 데이터 받아와 상태 관리 로직

  useEffect(() => {
    const fetchCountryData = async () => {
      try {
        const data = await fetchDataAndTransform();
        if (countryInfos.length === 0) {
          for (const country of countryInfos) {
            initialCountryInfos.push(country);
          }
          setCountryInfos(data || []);
        }
      } catch (error) {
        // AxiosError의 에러인지 확인 필요
        if (error instanceof AxiosError) {
          setError(error);
        } else {
          console.error("Error fetching data:", error);
        }
      } finally {
        setIsLoading(false);
      }
    };
    fetchCountryData();
  }, []);

이외 새롭게 알게 된 것들

typescript 환경에서 supabase초기 설정하기

수파베이스 연결

( 참고 링크 )

1. CLI설치 (터미널)

npm i supabase@">=1.8.1" --save-dev

2. 로그인

npx supabase login

3. 수파베이스 프로젝트 초기화

npx supabase init

4. 수파베이스 프로젝트 타입 생성 혹은 프로젝트 연동

YOUR_PROJECT_ID는 Project Settings에서 확인가능하다.

supabase link --project-ref YOUR_PROJECT_ID

5. 수파베이스 프로젝트 타입 생성

supabase gen types typescript --linked > src/types/supabase.ts

vercel 배포 시 유의할 점

.env(환경 변수) 등록하기


환경 변수 등록 후에는 재배포(redeploy)를 해줘야 환경변수가 설정된 상태로 다시 배포가 된다.

모든 에러 해결 후 배포

타입스크립트 환경에서 버셀에 배포 시에는 개발환경에서는 문제가 되지 않았던 오류들도 모두 삭제해줘야 제대로 배포가 된다.

위의 경우

  • import React from "react";
  • const [sortOption, setSortOption] = useState("Default");

React를 사용해주고 있지 않은데 코드에 남아있었고, 마찬가지로 sortOption를 정의를 했지만 사용해주고 있지 않아 배포 시 이 부분에서 오류가 생겼다. 문제가 되는 부분을 삭제하고 코드를 업데이트 하니 제대로 배포가 완료됐다. 자바스크립트 환경보다 타입스크립트 환경에서 더 철처하게 이러한 부분에 대해서도 오류 검사를 하는 것 같다.

profile
웹 프론트엔드 UXUI

0개의 댓글