주제 : API 를 호출하고 받은 응답값을 화면에 보여주는 과정에서 타입스크립트를 사용해봅시다.
🔗 웹사이트 : https://countries-gold.vercel.app/
🔗 깃허브 링크 : https://github.com/yeliinbb/countries
기존 로직은 선택 취소 시 취소된 카드를 찾아서 그 카드를 가장 처음 순서에 넣고 새로운 배열로 상태를 업데이트하는 방식이기 때문에 기존의 위치로 돌아가지 않는 문제가 있었다.
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)
);
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 || []);
}
예를 들면 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>
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 정책를 만들어 사용자가 모든 행을 볼 수 있도록 허용해줘야 삭제도 가능하다.
-- 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 (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()
원대대로라면 upsert매서드가 제대로 작동해야하지만, 401오류가 뜨면서 실제로 코드에서 upsert자체를 사용할 수는 없었다.
해당 아티클 내용을 잘 읽어보면 PostgreSQL(supabase에도 해당됨)에서 upsert 기능을 사용 가능 하지만, 이를 사용하기 위해서는 INSERT ... ON CONFLICT 구문을 사용해야한다고 나와있다.
strict mode에서는 useEffect안에 있어도 데이터가 두 번 그려지게 된다. 따라서 데이터가 두 번 저장되는 방식으로 로직이 실행되어 콘솔창에 중복되는 키에 대한 오류가 떴던 것이다. 따라서 strict mode를 주석처리해주어 비활성화하면 된다.
// 데이터 삽입 함수
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}`);
}
}
};
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();
}, []);
( 참고 링크 )
npm i supabase@">=1.8.1" --save-dev
npx supabase login
npx supabase init
YOUR_PROJECT_ID는 Project Settings에서 확인가능하다.
supabase link --project-ref YOUR_PROJECT_ID
supabase gen types typescript --linked > src/types/supabase.ts
환경 변수 등록 후에는 재배포(redeploy)를 해줘야 환경변수가 설정된 상태로 다시 배포가 된다.
타입스크립트 환경에서 버셀에 배포 시에는 개발환경에서는 문제가 되지 않았던 오류들도 모두 삭제해줘야 제대로 배포가 된다.
위의 경우
import React from "react";
const [sortOption, setSortOption] = useState("Default");
React를 사용해주고 있지 않은데 코드에 남아있었고, 마찬가지로 sortOption를 정의를 했지만 사용해주고 있지 않아 배포 시 이 부분에서 오류가 생겼다. 문제가 되는 부분을 삭제하고 코드를 업데이트 하니 제대로 배포가 완료됐다. 자바스크립트 환경보다 타입스크립트 환경에서 더 철처하게 이러한 부분에 대해서도 오류 검사를 하는 것 같다.