본문 바로가기

2024-06-25 TypeScript로 국가 선택 앱 만들기 (1)

codeConnection 2024. 6. 26.

과제를 처음부터 끝까지 자세히 기록해보고자 한다. 따라서 글이 길어질 수 있고 여러 편으로 나눠서 작성할 수 있다.

 

과제 요구사항 확인하기

사용 기술 스택

  • 패키지 매니저 : vite
  • 라이브러리 : react
  • 언어 : typescript

필수 구현 사항

  • 제공된 API를 호출하여 응답값을 useState를 통한 상태 관리
    • 적절한 타입이 꼭 명시되어 있어야 함
  • useState로 상태 관리 되고 있는 값들을 화면에 렌더링
  • 사용자와 인터렉션(선택/해제)가 가능하여야 함
  • 이 모든 과정에서 사용하는 함수에는 타입이 적절하게 명시되어 있어야 함.

과제 구현 순서

  • 프로젝트 셋업 (vtie + react + typescript)
  • API 호출 설정
    • API URL : https://restcountries.com/v3.1/all
    • 요청 : GET
    • API 응답값을 src/types 폴더에 타입을 지정
    • API 호출 로직을 담당하는 함수롸 응답값에 적절한 타입 명시
  • CountryList 컴포넌트 작성
    • API에서 받아온 각 나라들에 대한 기본 정보를 렌더링 할 리스트 컴포넌트 제작
    • 제작한 CountryList는 App.tsx에 렌더링
    • API를 호출하고 useState를 이용해 응답값을 CountryList 컴포넌트 내부에서 상태관리
    • 위 모든 로직에서 적절한 타입을 명시
  • CountryCard 컴포넌트 작성
    • CountryList에서 map 메서드를 통해 렌더링 할 카드를 제작
    • CountryList 컴포넌트에서 호출받은 API 응답값을 관리하는 상태를 prop으로 받아와 화면에 카드를 렌더링
    • 위 모든 로직에서 적절한 타입을 명시
  • 추가 로직
    • 각 카드가 클릭 되었을 때 선택된 카드를 저장할 수 있는 state를 만들고 관리. [selectedCountries, setSelectedCountries]
    • Country를 클릭하면 selectedCountries에 해당 나라정보를 등록해주고 다시 클릭하면 제거 되도록 함
    • 위 모든 로직에서 적절한 타입을 명시

과제 구현하기

프로젝트 셋업하기

VSCode 우클릭 - 새 창

 

열기

 

 

데스크톱 또는 만들고자 하는 폴더를 선택 - 열기

 

터미널 열고 (Mac 기준 option + shift + `)

yarn create vite

 

원하는 폴더명(프로젝트명) 설정

 

프레임워크 선택 (React)

 

언어 선택(TypeScript)

 

+ SWC는 개선된 버전이라고 하나 큰 차이 체감 못하겠음. 필요하면 자세히 알아보기.

 

셋업이 완료되면 뭘 더 해야하는지 터미널에서 힌트를 준다.

먼저 cd 프로젝트폴더명 을 입력해서 디렉토리를 이동시킨다.

 

다음 yarn 또는 yarn install 을 입력해서 의존성을 전부 설치한다.

 

리액트 + 타입스크립트 셋업이 잘 됐는지 보려면 tsconfig.json 파일과

src 폴더 내에 jsx 파일이 아닌 tsx 파일이 잘 만들어졌는지 확인한다.

 

그리고 app.css와 같은 불필요한 파일을 전부 지워준다.

폴더 구조 설정하기

src/
├── api/
│   └── api.ts
├── components/
│   ├── CountryCard.tsx
│   └── CountryList.tsx
├── hooks/
│   └── useCountryQuery.ts
├── types/
│   └── countryTypes.ts
├── App.tsx
└── index.tsx

API 호출하기

응답값 확인하기

API 응답값이 어떻게 되는지 미리 확인을 해본다.

크롬 확장 프로그램을 설치하고 웹 브라우저에서 직접 주소를 넣어서 접속해봐도 되고, VSCode Extension에서 THUNDER CLIENT라는 확장 프로그램으로 미리 API 요청을 보내고 응답값을 확인해봐도 좋다.

 

사실 가장 좋은 것은 API 명세서를 보고 파악하는 것이겠지만, 본인이 못 찾는 것인지 위 API에서는 명세만 보고 파악하기가 어려웠다.

썬더 클라이언트를 설치하면 VSCode 좌측 메뉴 아래에 아래와 같은 아이콘이 생기는데 이곳에서 New Request를 누른다.

 

그 다음 별도로 파라미터(엔드포인트)를 설정할 것은 없으니, 해당 URL로 GET 요청을 보내보면 응답값과 용량, HTTP 상태와 응답에 소요된 시간까지 출력해준다.

 

 

국가 카드를 렌더링 하기 위해서 나에게 필요한 정보는 (1) 국기 이미지 파일 url (2) 국가명 (3) 수도이다. 이게 어떤 필드에 담겨 있는지 체크해봐야 한다.

응답값 타입 지정하기

그리고 해당 필드가 값이 있을 수도 있고 없을 수도 있기 때문에 API 요청의 타입을 지정해줄 때 프로퍼티에 ? 물음표를 붙이든 | undefined 처리를 해주든 union 타입으로 설정해주는 것이 제일 best practice이긴 하나, API 명세에서도 그 내용을 알려주지 않는 것 같고(못 찾는 것일 수도 있다) 데이터 양이 너무 많아 다 검토하기 어려우니, 일단 모든 필드가 있다고 가정하고 API 응답값의 타입을 지정해주겠다.

 

여기서 두 가지 방법이 있을 것 같다.

 

1. API 응답값 전체 프로퍼티를 모두 타입 지정하기

2. 그 응답값 중 필요한 프로퍼티만 타입을 지정하기

 

API 응답값이 너무 길어 읽기가 어렵다면 노동력이 많이 필요하겠지만 1번을 하고 2번처럼 지워도 될 것 같다.

이번 API가 그렇다. 너무 API 응답값이 길어서 필요한 것만 추려내기가 어렵다. 물론 API 명세에 가보면 해당 필드만 응답 받을 수 있는 파라미터를 제공한다. 하지만 이번 과제의 요구조건이 엔드포인트 /all 로 API 요청을 보내는 것이기에 일단 보내고 응답받은 데이터 중 필요한 데이터를 추려내도록 한다.

 

// src/types/countryTypes.ts

export type CountryData = {
    name: {
        common: string;
        official: string;
    };
    cca3: string;
    capital: string[];
    flags: {
        png: string;
    };
};

 

 

API의 응답값 중 하나의 아이템(인덱스)가 어디까지인지 response 를 보고 파악해야 하고, 그 만큼을 긁어 와서 타입을 만들어 줄 파일에 넣고 응답값을 보고 타입을 지정해주면 된다.

 

여기서 중복되는 타입이나 객체들은 extends 등으로 더 축약해도 좋다.

 

가장 먼저 해야 할 일은 API 응답값 중 어떤 필드가 중복되지 않는 고유한 값인지 파악해서 그 값을 map 돌릴 때 id로 사용해야 한다. map 메서드를 사용할 때 파라미터로 index를 주고 그것을 key로 줘도 되지만, 추후 페이지가 많아져서 URL 파라미터의 값으로 넘겨야 할 때, 원본 데이터 배열의 값이 바뀌어서 인덱스가 틀어지면 고유한 값이 아니게 되기 때문에 좋은 방법은 아닌 것 같다.

 

위 API에서는 cca3가 국가별 고유한 코드라고 하니 이것을 아이템의 id로 사용하면 될 것 같다.

API 요청 함수 작성하기

// src/api/api.tsx

import axios from "axios";
import { CountryData } from "../types/countryTypes";

export const fetchCountries = async () : Promise<CountryData[]> => {
    const response = await axios.get('https://restcountries.com/v3.1/all');
    return response.data;
}

 

  • import axios from "axios";
    • fetch 함수 대신 axios 라이브러리를 사용할 것이니 axios를 먼저 설치해주어야 한다. (yarn add axios)
    • 이번 과제는 GET 요청 하나 뿐이라 axios 인스턴스는 별도로 설정하지 않겠다.
  • import { CountryData } from ".../types/countryTypes";
    • CountryData는 아까 만든 API 요청값에 대한 타입을 지정해준 파일이다. 함수의 return 값을 Generic으로 설정해주기 위해 필요하다.
    • 제네릭이란, 타입스크립트에서 함수나 클래스의 타입을 지정해줄 때 사용하는 것인데,  타입을 여기서 지정하지 않고 사용하는 시점에 정하겠다는 의미이다. 쉽게 이야기하자면 다른 곳에서 지정하겠다는 이야기이다.
    • CountryData라는 타입은 하나의 객체다. 응답값 전체의 타입을 지정한 게 아니라 대표로 아이템 하나만 뽑아서 지정해준 것이다. 그리고 그것들이 모여서 API의 전체 응답값인 배열이 된다. 따라서 CountryData라는 타입이 모여서 [] 배열로 반환될 것이라고는 알려준 것인데, CountryData는 외부에서 지정되었기 때문에 어떤 타입이 오는 지는 이 fetch 함수에서 정할 게 아니라 그곳에서 정하는 것이다. 제네릭에 대한 자세한 설명은 바로 밑에서 더 다뤄보겠다.
  • export const fetchCountries = async () : Promise<CountryData[]> => { ... }
    • 이 부분이 타입스크립트에서 가장 핵심이다.
    • fetch 함수의 return값은 Promise 타입이다. 따라서 return 값의 타입으로 Promise를 지정해주고, <CountryData[]> 과 같이 Generic으로 아까 만든 API 요청 응답값의 타입을 지정해주면 된다. 그리고 이 API 응답값은 하나의 배열로 반환되기에 대괄호까지 넣어준다.
  • const response = await axios.get('url...');
    • 이 외에는 자바스크립트에서 axios로 API 요청하는 패턴과 동일하다.

Generic(제네릭)이란?

타입스크립트에서는 함수든 변수든 표현식이든 타입을 지정해주어야 한다. 그런데 아래와 같이 제네릭으로 설정하면 함수를 선언하는 당시에 타입을 지정하는 것이 아니라 사용하는 시점에서 제네릭을 받아서 타입을 지정해줄 수 있기 때문에 조금 더 유연하게 함수를 작성할 수 있다.

단, 어떤 타입의 결과값이 오는지 [] 배열인지 정도는 작성해주어야 한다. 단일 타입, 객체 타입, 배열 타입 모두 가능하다. 이는 내용이 길어지므로 별도 포스트로 다뤄보겠다.

function getArray<T>(items: T[]): T[] {
    return new Array<T>().concat(items);
}

// 사용 예시
let numberArray = getArray<number>([1, 2, 3, 4]);
let stringArray = getArray<string>(["hello", "world"]);

TanStack Query 커스텀 훅 작성하기

이 과제에 이 과정이 필요할 지 잘 모르겠지만, 팀 프로젝트에서의 경험을 기반으로 API 요청 중 특히 GET 요청에 해당하는 내용은 탄스택쿼리로 작성하면 캐싱을 통하여 대역폭을 절약할 수 있어 매우 도움이 되었고, 또 이것을 커스텀 훅으로 작성해두면 데이터가 필요하거나 캐싱된 데이터가 필요한 곳에서 커스텀 훅만 import 하면 되기 때문에 매우 편리했다.

+ 참고로 탄스택 쿼리가 데이터를 stale하다고 판단해서 데이터 fetch를 새로 요청하는 조건이 세 가지 정도 있는데, 그것은 React 카테고리에서 별도로 작성하였다.

 

어쨌든 탄스택쿼리 사용 습관을 들이기 위해 이번에도 이 과정을 추가해보았다.

// src/hooks/useCountryQuery.ts

import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { fetchCountries } from '../api/api';
import { CountryData } from '../types/countryTypes';

const useCountryQuery = (): UseQueryResult<CountryData[], Error> => {
  return useQuery<CountryData[], Error>({
    queryKey: ['country'],
    queryFn: fetchCountries,
  });
};

export default useCountryQuery;
  • 먼저 탄스택 쿼리를 설치한다. (yarn add @tanstack/react-query)
  • import { useQuery, UseQueryResult } from '@tanstack/react-query';
    • useQuery 훅을 사용하는 것은 자바스크립트 때와 같다. 근데 처음 보는 UseQueryResult 때문에 애를 많이 먹었는데, 파스칼 케이스로 작성된 것을 보아 타입임을 알 수 있다.
    • UseQueryResult는 tanstack에서 제공하는 useQuery의 반환값 타입이다. 편하게 갖다 쓰면 된다.
  • import { fetchCountires } from '../api/api';
    • 아까 만든 axios GET 요청 함수이다. 쿼리 펑션에 넣기 위해 필요하다.
  • import { CountryData } from '../types/countryTypes';
    • API 요청으로 받은 응답값의 타입을 지정했던 것이다.
  • const useCountryQuery = () : UseQueryResult<CountryData[], Error> => { ... }
    • useCountryQuery라는 커스텀 훅을 만들 것이다. 나중에 다른 컴포넌트에서 이 쿼리 키를 호출할 때는 이 훅을 호출한다.
    • 이 함수의 반환 값은 탄스택 쿼리에서 제공하는 UseQueryResult를 사용한다.
    • 그리고 제네릭으로 아까 만든 CountryData라는 응답값의 타입을 사용하고 배열로 지정한다.
    • Promise는 Error 도 반환하기 때문에 명시해준다.
  • return useQuery<CountryData[], Error>( { ... } )
    • useQuery 훅으로 쿼리 키와 쿼리 펑션을 지정할 것인데, 이렇게 fetch 해서 나온 반환 값을 제네릭으로 설정해준다.
    • 그 외 나머지 쿼리 키와 쿼리 펑션은 자바스크립트에서 쓰던 패턴과 동일하다.

CountryList.tsx 컴포넌트 구현

기본 레이아웃 구성

// src/components/CountryList.tsx

import styled from 'styled-components';
import CountryCard from './CountryCard';

const CardSection = styled.div`
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
`;

const CountryList = () => {

    return (
        <>
            <h1>내가 고른 카드</h1>
            <CardSection>
                <CountryCard />
            </CardSection>
            <h1>국가 목록</h1>
            <CardSection>
                <CountryCard />
            </CardSection>
        </>
    )
}

 

과제 하다 생긴 궁금중

첫번째

// types/countryTypes.ts

// 물음표 없는 것

export type CountryData = {
    name: {
        common: string;
        official: string;
    };
    cca3: string;
    capital: string[];
    flags: {
        png: string;
    };
};

// 전부 다 물음표 있는 것

export type CountryData = {
    name?: {
        common?: string;
        official?: string;
    };
    cca3?: string;
    capital?: string[];
    flags?: {
        png?: string;
    };
};

// 없을 수도 있을 것 같은 필드에만 물음표 붙이는 것

export type CountryData = {
    name: {
        common: string;
        official: string;
    };
    cca3: string;
    capital?: string[];
    flags: {
        png: string;
    };
};
  • 위 세 개가 전부 동일하게 작동하는 점
  • 그렇다면 API 명세에서 필드의 값이 있을 수도 있고 없을 수도 있다는 정보를 불성실하게 주는 경우 어떤 것이 best practice인지? 일일이 샘플링 한다는 것은 말이 안 되는 것 같은데...

두번째

// types/countryTypes.ts

export type CountryData = {
    name: {
        common: number;
        official: string;
    };
    cca3: string;
    capital: string[];
    flags: {
        png: string;
    };
};

 

  • 위에서 common 필드의 타입이 원래는 string이 맞는데 number로 바꿔도 컴파일 에러도 없고 브라우저 에러도 없음. 잘 작동됨. 이러면 타입스크립트에서 API 호출값의 타입을 지정하는 이유가 무엇인가?
  • 타입 자체를 없애버리면 컴파일 에러는 나는데 자바스크립트(브라우저) 작동은 됨.

세번째

  const test = () => {
    if (countries) {
      console.log(countries[0].name.common + 1111);
    } else {console.log('no')}
  }
  
  useEffect() => {
    test();
  , []}
  • common의 타입을 string으로 지정한 상태에서 number 타입을 더했을 때, 타입스크립트에서는 number 타입 간의 연산만을 허용하는 것으로 알고 있는데, 콘솔에 아래처럼 찍힘.

 

  • 타입스크립트에서 타입을 지정하는 이유가 아래와 같은 계산을 코드 작성하는 단계에서 막기 위함으로 알고 있는데, 잘못 알고 있었던 것인가? 에러가 뜨지 않음!
const a : number = 1;
const b : string = "1";
console.log(a + b);

 

댓글