넥스트(Next.js)를 이용해 간단한 프로젝트해보기, 초보자를 위한 연습용 프로젝트 world ranks Try a simple project using Next.js, a practice project for beginners world ranks

# 시작

리액트를 사용해 프로젝트를 해오다가 앞으로 사이트를 개발하는 데 서버사이드렌더링이 필요한 경우가 많을 것으로 생각되어 리액트에서 서버사이드렌더링을 설정하는 것보다 넥스트로 시작하는 것이 상대적으로 편하다는 말을 듣고 넥스트를 공부하기 시작했다.
getStaticProps 같은 새로운 요소들이 보여서 조금 어렵게 느껴졌는데 폴더 구조만 봤을 때는 리액트를 기반으로 개선해서 만든 라이브러리인만큼 static, styles, pages, src, components등 폴더식으로 잘 구분해서 파일을 관리하는 방식이 마음에 들었다. 넥스트 사용 경험이 부족해서 유튜브를 찾다 보니 world ranks라는 세계 각국의 이름과 순위를 데이터로 fetch해와서 그 데이터를 보여주는 사이트를 만드는 토이 프로젝트 영상을 찾게 되어 아래와 같이 정리하면서 공부해본다. 리액트를 조금은 겪어보고 시작하는 사람이라는 가정 하에 글을 썼다.
아래는 참고한 유튜브 영상이다.
이제 소스코드를 나열하면서 공부한 내용을 정리할 것인데 내 깃헙에 world-ranks를 클론하는 것도 나쁘지않다. 글이 매우 길다.
완성된 사이트를 캡쳐한 모습이 아래와 같다.
notion image
notion image

# 과정

터미널에서 프로젝트를 생성하고자 하는 위치로 이동한다음 아래와 같이 입력한다. cra와 비슷한 역할이다.
npx create-next-app world-ranks
vscode로 해당 프로젝트 루트를 열고 터미널에서 프로젝트 루트로 이동한다.
cd world-ranks
그다음 프로젝트 루트에서 yarn dev를 입력한다. 개발모드로 Next를 실행해보는 것이다.
yarn dev
그 결과는 대략 다음과 같았다. 리액트와 비슷하지만 Learn이나 Documentation 등을 바로 클릭하도록 제공해주는 등 조금 더 친절하다는 느낌을 받았는데 디자인의 차이인지도 모르겠지만 리액트로 개발하던 사람들은 나중에도 넥스트로 개발 중에 에러가 나면 이런 느낌을 받을 수 있을 것 같다.
notion image
가장 먼저 고쳐야 하는 파일은 styles 폴더로 들어가면 globals.css라는 파일이 있다.
여기에서 전체적으로 적용되는 css파일을 정리하는 것으로 받아들였다. 먼저 코드부터 보여주면 아래와 같다.
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;500;700&display=swap");

:root {
    --text-color: #124a63;
    --text-color-secondary: #b3c5cd;

    --primary-color: #21b6b7;

    --background-color: #f9f9f9;
    --background-color-dark: #eef3f6;
    --background-color-light: white;

    --font-family: "Poppins", sans-serif;
    --box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.05);
}
html,
body {
    padding: 0;
    margin: 0;
    font-family: var(--font-family);
    background-color: var(--background-color);
    color: var(--text-color);
}

a {
    color: inherit;
    text-decoration: none;
}

* {
    box-sizing: border-box;
    color: inherit;
    font: inherit;
}
먼저 import url(,,,)로 폰트를 불러와 적용하는 것을 볼 수 있다. 여기에서는 "fonts.google.com"으로 들어가서 검색창에 poppins를 검색한다음 필요한 폰트(Light 300, Medium 500, Bold 700)의 우측에 "Select this styles"를 클릭한다. 그다음 우측 상단의 아이콘을 클릭하거나 미리 떠 있는 우측 창을 보면, "Selected family"라고 적혀있을 것이다.
해당 창에서 "Use on the Web" 부분의 @import를 체크 표시하면 필요한 코드를 얻을 수 있다. 이 코드를 복사해서 사용해야하는데 <style>,,, </style> 태그의 사이에 있는 부분만 사용하면 된다. 귀찮으면 하는 방식만 알고 위에 적어놓은 코드를 그대로 복사해서 사용하면 된다.
다른 부분들은 계속 사용해왔기 때문에 익숙한데, --background-color와 var(--background-color)로 해당 변수를 이용해서 사용하는 방식은 처음이라 찾아보니 사용자 지정 css 속성을 사용하는 것이라고 한다. 검색해본 결과를 그대로 옮긴다.
사용자 지정 속성(CSS 변수, 종속 변수)은 CSS 저작자가 정의하는 개체로, 문서 전반적으로 재사용할 임의의 값을 담습니다. 사용자 지정 속성은 전용 표기법을 사용해 정의하고, (--main-color: black;) var() 함수를 사용해 접근할 수 있습니다. (color: var(--main-color);)복잡한 웹사이트는 어마어마한 양의 CSS를 가지고 있는데, 종종 많은 값을 반복적으로 사용합니다. 예를 들어, 수 백 곳의 서로 다른 위치에서 같은 색상을 사용한다면, 그 색을 바꿔야 할 상황이 왔을 때 대규모 전역 검색 바꾸기를 피할 수 없습니다. 사용자 지정 속성을 사용하면 한 영역에 값을 저장해놓고 다른 여러 곳에서 참조해갈 수 있습니다. 추가로 오는 장점은 의미를 가지는 식별자를 사용한다는 것으로, #00ff00보다는 --main-text-color가 이해하기 쉽다는 것입니다. 특히 같은 색을 다른 맥락에서 사용할 때 이 장점이 도드라집니다.
이후 pages/index.js로 이동해서 필요없는 코드들을 지우고 main과 footer로 구획을 나누었다.
또한, styles/Home.module.css 파일로 이동해서 css 속성들을 전부 지워버린다.
현재 pages/index.js 코드를 보면 아래와 같아야 한다.(어차피 수정할테니 중요한 부분은 아니다.)
notion image
그리고 다시 개발모드로 실행해보면
yarn dev
notion image
이렇게 출력된다. main, footer라는 글자가 좌측상단부터 적혀있고 배경도 완전한 흰색이 아니라 지정한 배경색으로 표시되고 있음을 확인하게 된다.
다음 과정으로는 src폴더 내부에 Layout 관련 파일들을 만든다. 코드는 다음과 같다.
src/components/Layout 폴더를 생성한다음 Layout.js와 Layout.module.css 를 생성한다.
//Layout.jsimport Head from "next/head";
import styles from "./Layout.module.css";

const Layout = ({ children, title = "world Ranks" }) => {
    return (
        <div className={styles.container}>
            <Head>
                <title>{title}</title>
                <link rel="icon" href="/favicon.ico" />
            </Head>

            <header className={styles.header}>
                <img src="/send.svg" height={24} width={175} />
            </header>

            <main className={styles.main}>{children}</main>

            <footer className={styles.footer}>sjwdev @ devchallenges.io</footer>
        </div>
    );
};

export default Layout;
//Layout.module.css
.container {
    padding: 24px;
    height: 100vh;

    display: grid;
    grid-template-rows: auto 1fr auto;
}

.header {
    display: flex;
    justify-content: center;
    align-items: center;
    margin-bottom: 32px;
}

.footer {
    margin-top: 32px;
    text-align: center;
    font-size: 0.75rem;
}
Layout 관련 파일들을 모두 생성했으니 pages/index.js 파일로 가서 아래와 같이 수정해본다.
notion image
Layout 컴포넌트를 임포트해서 사용하고 있다.
여기서 로고를 넣는 코드가 있어서 실행했을 때 에러가 날 수 있는데 나는 프로젝트루트 아래의 public 폴더 내에 send.svg라는 파일을 넣어 로고 대신 사용하였다. Layout.js의 코드에서 src를 변경시켜 맞춰 사용하면 된다. 중요한 점은 웹브라우저 탭에 적힌 사이트명 좌측에 작은 아이콘 favicon.ico와 로고 아이콘 파일 등은 world-ranks/public 폴더 내에서 관리한다는 점이다. favicon.ico 또한 개인적으로 원하는 것으로 대체해서 사용하면 된다. 나는 파비콘과 로고를 아래와 같이 임의로 사용하였다.
notion image
notion image
이제 pages.index.js 의 코드를 아래와 같이 변경하여 해당 페이지로 들어가는 경우에 async/await를 사용해 데이터를 비동기적으로 받아온 다음 Home의 파라미터로 반환시켜주는 방식으로 데이터를 웹 브라우저에서 보여주려고 한다. fetch에 대해 더 자세히 알아보려면 아래의 사이트를 추천한다.
// index.jsimport Head from "next/head";
import Layout from "../components/Layout/Layout";
import styles from "../styles/Home.module.css";

export default function Home({ countries }) {
    console.log(countries);
    return (
        <Layout>main
        </Layout>
    );
}

export const getStaticProps = async () => {
    const res = await fetch("https://restcountries.eu/rest/v2/all");
    const countries = await res.json();

    return {
        props: {
            countries,
        },
    };
};
이렇게 따라해보면 콘솔에 데이터들이 쭉 출력된다.
다음은 SearchInput(검색창)을 구현하기 위한 코드를 추가한다. 여기서 돋보기 모양 아이콘을 가져다 써야 하는데 여기서 임포트가 제대로 안되는 에러가 많이 나타난다. material-ui를 사용할 때 유독 설치가 제대로 안되는 것 같으나 아래 사이트를 보고 해결하였다. 구글링을 하면서 공통적으로 자주 하는 얘기는 material-ui 라이브러리를 가져다쓸 때 아래와 같이 한꺼번에 설치해야 문제없이 작동한다는 것이였다.
npm install --save @material-ui/core @material-ui/icons @material-ui/styles
아래는 components/SearchInput 폴더를 생성한다음 SearchInput.js SearchInput.module.css를 생성한 것이다.
(나도 유튭으로 따라서 코드를 작성해가고 있는데 특히 css에서는 오타가 나도 모르는 경우가 있어서 웬만하면 복사하고 붙여넣기를 추천한다. 여기서는 css 스타일링에 대해 공부하는 것이 주목적은 아니다.)
// SearchInput.jsimport SearchRounded from "@material-ui/icons/SearchRounded";
import styles from "./SearchInput.module.css";

const SearchInput = ({ ...rest }) => {
    return (
        <div className={styles.wrapper}>
            <SearchRounded color="inherit" />
            <input className={styles.input} {...rest} />
        </div>
    );
};

export default SearchInput;
// SearchInput.module.css
.wrapper {
    display: flex;
    align-items: center;

    background-color: var(--background-color-dark);
    border-radius: 8px;
    padding-left: 16px;
    color: var(--text-color-secondary);
}

.input {
    border: none;
    background-color: transparent;
    padding: 16px;
    width: 100%;
    height: 100%;
    outline: none;
}

.input::placeholder {
    color: var(--text-color-secondary);
}
이제 추가적으로 Home.module.css 파일을 수정해준다.
// world-ranks/styles/Home.module.css
.counts {
    margin: 12px 0;
    color: var(--text-color-secondary);
}
여기까지 수정한 결과를 yarn dev로 실행해보면 다음과 같다.
notion image
완성된 사이트처럼 국가별 이름과 사진 등을 검색창 아래에 출력해야 한다. 따라서 CountriesTable을 생성한다.
먼저 src/components에 CountriesTable 폴더를 생성한다. 이후 폴더 내에 CountriesTable.js, CountriesTable.module.css를 생성하고 아래의 코드를 입력한다.
// CountriesTable.js, CountriesTable.module.cssimport styles from "./CountriesTable.module.css";

const CountriesTable = ({ countries }) => {
    return (
        <div><div className={styles.heading}><button className={styles.heading_name}><div>Name</div></button><button className={styles.heading_population}><div>Population</div></button></div>

            {countries.map((country) => (
                <div className={styles.row}><div className={styles.name}>{country.name}</div><div className={styles.population}>
                        {country.population}
                    </div></div>
            ))}
        </div>
    );
};

export default CountriesTable;
파라미터로 들어온 { countries } 는 index.js의 getStaticProps의 fetch에서 받은 json 데이터일 것이다. 이를 map함수를 이용하여 {country.name}, {country.population}으로 뿌려서 보여주는 방식이다. 그 밖에도 국가별 데이터 필터링을 위해서 button을 생성해놓은 것을 볼 수 있다.
CountriesTable.module.css 는 빈 칸으로 두고 index.js를 아래의 코드로 변경한다. index.js라는 상위 컴포넌트에서 getStaticProps로 반환 받은 countries 데이터를 CountriesTable 컴포넌트를 선언해 props로 내려주는 것을 한눈에 볼 수 있다.
// pages/index.jsimport Head from "next/head";
import CountriesTable from "../components/CountriesTable/CountriesTable";
import Layout from "../components/Layout/Layout";
import SearchInput from "../components/SearchInput/SearchInput";
import styles from "../styles/Home.module.css";

export default function Home({ countries }) {
    console.log(countries);
    return (
        <Layout>
            <div className={styles.counts}>Found {countries.length}</div>

            <SearchInput placeholder="Filter by Name, Region or SubRegion" />
            <CountriesTable countries={countries} />
        </Layout>
    );
}

export const getStaticProps = async () => {
    const res = await fetch("https://restcountries.eu/rest/v2/all");
    const countries = await res.json();

    return {
        props: {
            countries,
        },
    };
};
이 때 yarn dev를 해보면 다음과 같이 SearchInput(검색창) 밑에 fetch 받은 데이터를 나열해주는 것을 확인할 수 있다.
notion image
이제 나라별로 Name과 Population 컬럼명에 맞춰 예쁘게 정렬시키기 위해서 css 파일을 작성한다. CountriesTable.module.css는 아래와 같이 작성한다.
// CountriesTable.module.css
.heading {
    display: flex;
}

.heading button {
    border: none;
    background-color: transparent;
    outline: none;
    cursor: pointer;
}

.heading_name,
.heading_population {
    flex: 1;
    padding: 20px;
    color: var(--text-color-secondary);
    font-weight: 500;
}

.heading_name {
    text-align: left;
}

.row {
    display: flex;
    padding: 20px;

    text-align: center;

    background-color: var(--background-color-light);
    border-radius: 8px;
    margin-bottom: 16px;

    box-shadow: var(--box-shadow);
    font-weight: 500;
}

.name {
    flex: 1;
    text-align: left;
}

.population {
    flex: 1;
}
(중간에 유튜브 영상 제작자가 적용할 폰트를 실수하여 아래의 폰트로 나오는 것이 맞다. 이 글에서 중간에 있는 웹 브라우저의 폰트는 잘못된 폰트로 되어 있으나 코드는 올바른 폰트로 고쳐놨고 여기서부터는 올바른 폰트로 나타난다.)
notion image
CountriesTable.module.css를 적용하고 나니 시안성이 좋아진 것을 볼 수 있다. 하지만 아직도 텍스트만 존재하는 단순한 테이블 형식이다. 우리는 추가적으로 mouse hover 등에 애니메이션이 작동하도록 css를 수정한다. 아래처럼 코드를 수정하면 각 테이블 row에 마우스를 갖다대면 천천히 떠오르는 애니메이션이 추가된다. row 속성에 transition과 row:hover 속성에 transform을 추가한 것을 볼 수 있다.
// CountriesTable.module.css
.heading {
    display: flex;
}

.heading button {
    border: none;
    background-color: transparent;
    outline: none;
    cursor: pointer;
}

.heading_name,
.heading_population {
    flex: 1;
    padding: 20px;
    color: var(--text-color-secondary);
    font-weight: 500;
}

.heading_name {
    text-align: left;
}

.row {
    display: flex;
    padding: 20px;

    text-align: center;

    background-color: var(--background-color-light);
    border-radius: 8px;
    margin-bottom: 16px;

    box-shadow: var(--box-shadow);
    font-weight: 500;

    transition: transform 200ms ease-in-out;
}

.row:hover {
    transform: translateY(-4px);
}

.name {
    flex: 1;
    text-align: left;
}

.population {
    flex: 1;
}
하지만 좀더 극적이고 눈에 띄는 효과를 추가하기 위해 box-shadow를 hover에 추가하면 토이 프로젝트에서 자주 사용하는 스무스한 box-shadow 애니메이션을 완성할 수 있다.
// CountriesTable.module.css
.heading {
    display: flex;
}

.heading button {
    border: none;
    background-color: transparent;
    outline: none;
    cursor: pointer;
}

.heading_name,
.heading_population {
    flex: 1;
    padding: 20px;
    color: var(--text-color-secondary);
    font-weight: 500;
}

.heading_name {
    text-align: left;
}

.row {
    display: flex;
    padding: 20px;

    text-align: center;

    background-color: var(--background-color-light);
    border-radius: 8px;
    margin-bottom: 16px;

    box-shadow: var(--box-shadow);
    font-weight: 500;

    transition: transform 200ms ease-in-out, box-shadow 200ms ease-in-out;
}

.row:hover {
    transform: translateY(-4px);
    box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.1);
}

.name {
    flex: 1;
    text-align: left;
}

.population {
    flex: 1;
}
다음은 Name과 Population 버튼에 따라 각 row를 정렬하는 함수 orderBy를 만들어볼 것이다. 내부에는 잘 알고 있는 sort함수를 이용했다.
// CountriesTable.jsimport styles from "./CountriesTable.module.css";

const orderBy = (countries) => {
    return countries.sort((a, b) => (a.population > b.population ? 1 : -1));
};

const CountriesTable = ({ countries }) => {
    const orderedCountries = orderBy(countries);

    return (
        <div><div className={styles.heading}><button className={styles.heading_name}><div>Name</div></button><button className={styles.heading_population}><div>Population</div></button></div>

            {countries.map((country) => (
                <div className={styles.row}><div className={styles.name}>{country.name}</div><div className={styles.population}>
                        {country.population}
                    </div></div>
            ))}
        </div>
    );
};

export default CountriesTable;

sort함수 내에서 a,b가 있을 때 a.population > b.population일 때 1을 반환하고, 아닐 때 -1을 반환한다. 따라서 (뒤에 있는) a의 population이 클수록 뒷쪽에 그대로 남아 정렬될 것(-1이면 뒷쪽의 a를 앞쪽의 b와 뒤바꿔준다.)이므로 오름차순으로 정렬되는 모습을 볼 수 있다. 여기서 헷갈렸던 점은 항상 아래와 같이 sort 함수를 사용해왔는데,
student.sort(function (a,b){ return b.age - a.age });
여기서는 화살표 함수를 이용해서 작성했기에 화살표 함수에 대해 잘 알지 못하면 실수할 수 있는 부분이 있다.
화살표 함수 - JavaScript | MDN에 따르면 화살표 함수의 경우 괄호()로 감싸진 부분이 return 된다(return문을 작성하지 않아도 return 됨).반면에 중괄호{}로 감싸진 다음과 같은 함수는 return문이 없다면 return 값을 반환하지 않는다.
const Button = () => {
    <button>Hello world</button>
}

console.log(Button); // undefined
따라서 중괄호{}를 사용하여 return 값을 반환하고자 하는 함수를 만드려면 다음과 같이 return 문을 사용하여 코드를 작성해야한다.
const Button = () => {
    return <button>Hello world</button>
}
 
나는  배열.map()함수를 사용하듯이 자연스럽게 중괄호를 사용해서 코드를 작성하는 바람에 원하는 결과가 안 나왔었는데 화살표 함수를 사용할 때 앞으로 유의해야겠다.

이제 orderBy 함수를 통해 오름차순과 내림차순 정렬을 둘다 구현한 코드다.
// CountriesTable.jsimport styles from "./CountriesTable.module.css";

const orderBy = (countries, direction) => {
    if (direction === "asc") {
        return countries.sort((a, b) => (a.population > b.population ? 1 : -1));
    }

    if (direction === "desc") {
        return countries.sort((a, b) => (a.population > b.population ? -1 : 1));
    }

    return countries;
};

const CountriesTable = ({ countries }) => {
    const orderedCountries = orderBy(countries, "desc");

    return (
        <div><div className={styles.heading}><button className={styles.heading_name}><div>Name</div></button><button className={styles.heading_population}><div>Population</div></button></div>

            {countries.map((country) => (
                <div className={styles.row}><div className={styles.name}>{country.name}</div><div className={styles.population}>
                        {country.population}
                    </div></div>
            ))}
        </div>
    );
};

export default CountriesTable;
이렇게 작성하고 나서 화면을 보면 원하는 결과가 안 나올 것이다. 왜냐하면 orderBy 함수 내에 if문을 통과해서 return 해주는 배열이 새로운 배열을 만들어 수정하는 것이 아니기 때문이다. 따라서 받아들인 배열으로 새로운 배열 객체를 만들어 리턴하도록 아래와 같이 수정하는 것이 중요하다. 그렇게 해서 반환 받은 배열은 orderedCountries에 받아서 map()으로 보여주도록 변경한다.
// CountriesTable.jsimport styles from "./CountriesTable.module.css";

const orderBy = (countries, direction) => {
    if (direction === "asc") {
        return [...countries].sort((a, b) =>
            a.population > b.population ? 1 : -1
        );
    }

    if (direction === "desc") {
        return [...countries].sort((a, b) =>
            a.population > b.population ? -1 : 1
        );
    }

    return countries;
};

const CountriesTable = ({ countries }) => {
    const orderedCountries = orderBy(countries, "desc");

    return (
        <div><div className={styles.heading}><button className={styles.heading_name}><div>Name</div></button><button className={styles.heading_population}><div>Population</div></button></div>

            {orderedCountries.map((country) => (
                <div className={styles.row}><div className={styles.name}>{country.name}</div><div className={styles.population}>
                        {country.population}
                    </div></div>
            ))}
        </div>
    );
};

export default CountriesTable;
결과는 다음과 같이 나온다.
notion image
다음으로는 Name, Population 버튼에 정렬을 할 때 사용할 수 있다는 의미로 화살표 표시를 넣는다. material-ui/icons에서 KeyboardArrowDownRounded를 임포트해서 사용한다. 그리고 이 화살표가 정렬에 따라 위아래로 변경되는 것(useState 적용 전)과 스타일을 적용하기 위해서 아래와 같이 코드를 수정했다.
// CountriesTable.module.css
.heading {
    display: flex;
}

.heading button {
    border: none;
    background-color: transparent;
    outline: none;
    cursor: pointer;
}

.heading_name,
.heading_population {
    flex: 1;
    padding: 20px;
    color: var(--text-color-secondary);
    font-weight: 500;

    display: flex;
    justify-content: center;
    align-items: center;
}

.heading_name {
    justify-content: flex-start;
}

.heading_arrow {
    color: var(--primary-color);

    display: flex;
    justify-content: center;
    align-items: center;

    margin-left: 2px;
}

.row {
    display: flex;
    padding: 20px;

    text-align: center;

    background-color: var(--background-color-light);
    border-radius: 8px;
    margin-bottom: 16px;

    box-shadow: var(--box-shadow);
    font-weight: 500;

    transition: transform 200ms ease-in-out, box-shadow 200ms ease-in-out;
}

.row:hover {
    transform: translateY(-4px);
    box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.1);
}

.name {
    flex: 1;
    text-align: left;
}

.population {
    flex: 1;
}
// CountriesTable.jsimport {
    KeyboardArrowDownRounded,
    KeyboardArrowUpRounded,
} from "@material-ui/icons";
import styles from "./CountriesTable.module.css";

const orderBy = (countries, direction) => {
    if (direction === "asc") {
        return [...countries].sort((a, b) =>
            a.population > b.population ? 1 : -1
        );
    }

    if (direction === "desc") {
        return [...countries].sort((a, b) =>
            a.population > b.population ? -1 : 1
        );
    }

    return countries;
};

const SortArrow = ({ direction }) => {
    if (!direction === "desc") {
        return <></>;
    }

    if (direction === "desc") {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowDownRounded color="inherit" />
            </div>
        );
    } else {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowUpRounded color="inherit" />
            </div>
        );
    }
};

const CountriesTable = ({ countries }) => {
    const orderedCountries = orderBy(countries, "desc");

    return (
        <div>
            <div className={styles.heading}>
                <button className={styles.heading_name}>
                    <div>Name</div>

                    <SortArrow />
                </button>

                <button className={styles.heading_population}>
                    <div>Population</div>

                    <SortArrow direction="asc" />
                </button>
            </div>

            {orderedCountries.map((country) => (
                <div className={styles.row}>
                    <div className={styles.name}>{country.name}</div>
                    <div className={styles.population}>
                        {country.population}
                    </div>
                </div>
            ))}
        </div>
    );
};

export default CountriesTable;
notion image
이제 사용자의 입력에 따라 정렬하도록 useState를 적용해본다.
// CountriesTable.jsimport {
    KeyboardArrowDownRounded,
    KeyboardArrowUpRounded,
} from "@material-ui/icons";
import { useState } from "react";
import styles from "./CountriesTable.module.css";

const orderBy = (countries, value, direction) => {
    if (direction === "asc") {
        return [...countries].sort((a, b) => (a[value] > b[value] ? 1 : -1));
    }

    if (direction === "desc") {
        return [...countries].sort((a, b) => (a[value] > b[value] ? -1 : 1));
    }

    return countries;
};

const SortArrow = ({ direction }) => {
    if (!direction === "desc") {
        return <></>;
    }

    if (direction === "desc") {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowDownRounded color="inherit" />
            </div>
        );
    } else {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowUpRounded color="inherit" />
            </div>
        );
    }
};

const CountriesTable = ({ countries }) => {
    const [direction, setDirection] = useState();
    const [value, setValue] = useState();

    const orderedCountries = orderBy(countries, value, direction);

    // NOTE: 방향이 없다면 desc, 방향이 desc면 asc로 변경해준다.
    const switchDirection = () => {
        if (!direction) {
            setDirection("desc");
        } else if (direction === "desc") {
            setDirection("asc");
        } else {
            setDirection(null);
        }
    };

    const setValueAndDirection = (value) => {
        switchDirection();
        setValue(value);
    };

    return (
        <div>
            <div className={styles.heading}>
                <button
                    className={styles.heading_name}
                    onClick={() => setValueAndDirection("population")}
                >
                    <div>Name</div>

                    <SortArrow />
                </button>

                <button
                    className={styles.heading_population}
                    onClick={() => setValueAndDirection("population")}
                >
                    <div>Population</div>

                    <SortArrow direction={direction} />
                </button>
            </div>

            {orderedCountries.map((country) => (
                <div className={styles.row}>
                    <div className={styles.name}>{country.name}</div>
                    <div className={styles.population}>
                        {country.population}
                    </div>
                </div>
            ))}
        </div>
    );
};

export default CountriesTable;
이렇게 하고 Name, Population을 클릭해보면 정렬이 수행되지만 결과가 이상하게 나타나는 것을 알 수 있다.
notion image
우선 이어서 검색어 필터링 기능까지 구현해본다. name, region, subregion을 기준으로 검색어를 포함하고 있으면 보여주는 기능이다. index.js를 수정한다.
// index.jsimport Head from "next/head";
import { useState } from "react";
import CountriesTable from "../components/CountriesTable/CountriesTable";
import Layout from "../components/Layout/Layout";
import SearchInput from "../components/SearchInput/SearchInput";
import styles from "../styles/Home.module.css";

export default function Home({ countries }) {
    console.log(countries);

    const [keyword, setKeyword] = useState("");

    const filteredCountries = countries.filter(
        (country) =>
            country.name.toLowerCase().includes(keyword) ||
            country.region.toLowerCase().includes(keyword) ||
            country.subregion.toLowerCase().includes(keyword)
    );

    const onInputChange = (e) => {
        e.preventDefault();

        setKeyword(e.target.value.toLowerCase());
    };

    return (
        <Layout>
            <div className={styles.counts}>Found {countries.length}</div>

            <SearchInput
                placeholder="Filter by Name, Region or SubRegion"
                onChange={onInputChange}
            />
            <CountriesTable countries={filteredCountries} />
        </Layout>
    );
}

export const getStaticProps = async () => {
    const res = await fetch("https://restcountries.eu/rest/v2/all");
    const countries = await res.json();

    return {
        props: {
            countries,
        },
    };
};
notion image
notion image
이제 국가별로 동적으로 보여주기 위해서 새로운 파일을 생성해야 한다. pages/country 폴더를 생성하고 폴더 내부에 [id].js와 country.module.css를 생성한다.
// [id].jsconst Country = () => {
    return <div>Country</div>;
};

export default Country;
[id]로 한 이유는 국가별로 id를 넣어 동적으로 사용하기 위함이다. Country를 보여주는 CountriesTable.js에서 수정이 필요하다.
import Link from "next/Link";
import {
    KeyboardArrowDownRounded,
    KeyboardArrowUpRounded,
} from "@material-ui/icons";
import { useState } from "react";
import styles from "./CountriesTable.module.css";

const orderBy = (countries, value, direction) => {
    if (direction === "asc") {
        return [...countries].sort((a, b) => (a[value] > b[value] ? 1 : -1));
    }

    if (direction === "desc") {
        return [...countries].sort((a, b) => (a[value] > b[value] ? -1 : 1));
    }

    return countries;
};

const SortArrow = ({ direction }) => {
    if (!direction === "desc") {
        return <></>;
    }

    if (direction === "desc") {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowDownRounded color="inherit" />
            </div>
        );
    } else {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowUpRounded color="inherit" />
            </div>
        );
    }
};

const CountriesTable = ({ countries }) => {

(,,,) // 생략

            {orderedCountries.map((country) => (
                <Link href={`/country/${country.alpha3Code}`}>
                    <div className={styles.row}>
                        <div className={styles.name}>{country.name}</div>
                        <div className={styles.population}>
                            {country.population}
                        </div>
                    </div>
                </Link>
            ))}
        </div>
    );
};

export default CountriesTable;
추가된 Link 문을 보면 알수 있는 dynamic route의 방식은 현재 폴더구조에서 pages아래에 country라는 폴더가 존재한다는 점을 보면 쉽게 파악할 수 있다. /country/${country.alpha3Code}로 되어 있는데 이는 orderedCountries에서 map으로 넘겨주는 객체 내부에 alpha3Code라는 속성값을 가져다가 pages(기본)/country/[id].js의 [id]라는 이름에 그대로 치환시켜서 사용하는 것이다. 이러한 방식은 기존에 해본 적이 없었는데 각 카드별로 해당하는 국가의 페이지로 이동하는 방식에 있어서 훨씬 더 직관적이고 간단한 것 같아 좋다.
다음은 http://localhost:3000/country/AFG의 페이지를 구성하는 코드로, 브라우저에서는 이렇게 보여준다.
notion image
notion image
[id].js 파일에서 getServerSideProps라는 함수를 사용하였는데, 찾아보면 Fetch data on each request. pre-render for Server-side Rendering 라고 한다. 빌드와 상관없이 매 요청시마다 서버에서 데이터를 가져오는 방식으로, SSR를 사용하여 자주 변화되는 데이터에 대해 반응을 해야할 때 사용한다고 한다.
또한 return ()문에는 받아온 {country} 객체의 값들을 css 스타일에 맞춰 보여주기 위한 코드를 추가하였다.
// pages/country/[id].jsimport styles from "./Country.module.css";
import Layout from "../../components/Layout/Layout";

const Country = ({ country }) => {
    return (
        <Layout title={country.name}><div><div className={styles.overviewPanel}><img src={country.flag} alt={country.name}></img><h1 className={styles.overview_name}>{country.name}</h1><div className={styles.overview_region}>
                        {country.region}
                    </div><div className={styles.overview_numbers}><div className={styles.overview_population}><div className={styles.overview_value}>
                                {country.population}
                            </div><div className={styles.overview_label}>
                                Population
                            </div></div><div className={styles.overview_area}><div className={styles.overview_value}>
                                {country.area}
                            </div><div className={styles.overview_label}>Area</div></div></div></div><div className={styles.details_panel}><h4 className={styles.details_panel_heading}>Details</h4><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                            Capital
                        </div><div className={styles.details_panel_value}>
                            {country.capital}
                        </div></div><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                            Subregion
                        </div><div className={styles.details_panel_value}>
                            {country.subregion}
                        </div></div></div></div></Layout>
    );
};

export default Country;

export const getServerSideProps = async ({ params }) => {
    const res = await fetch(
        `https://restcountries.eu/rest/v2/alpha/${params.id}`
    );

    const country = await res.json();

    return {
        props: { country },
    };
};
페이지가 요청될 때 getServerSideProps에 파라미터로 들어오는 { params } 는 무엇이 들어있을까? 우리가 dynamic route를 한다고 해서 /country/AFG로 라우팅했던 기억이 날 것이다. /AFG는 이 페이지의 파일인 [id].js 에 [id]에 대입되므로 AFG가 id라는 속성에 저장되어지면서 params가 만들어진다.
즉, {id: AFG} 가 들어있을 것이다. 그래서
        `https://restcountries.eu/rest/v2/alpha/${params.id}`
이렇게 params.id 로 접근해서 사용하는 것이다.
getServerSideProps에서 리턴된 props: {country}는
const Country = ({ country }) => {
이렇게 Country의 파라미터로 전달되어 사용한다.
css 파일은 다음과 같다.
// pages/country/country.module.css
.overviewPanel img {
    width: 100%;
    border-radius: 4px;
}

.overviewPanel {
    padding: 20px;
    border-radius: 8px;
    box-shadow: var(--box-shadow);
    background-color: var(--background-color-light);
}

.overview_name {
    text-align: center;
    font-size: 32px;
    margin-bottom: 0;
}

.overview_region {
    text-align: center;
    font-size: 14px;
    font-weight: 300;
    margin-top: 4px;
    margin-bottom: 24px;
}

.overview_numbers {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
    text-align: center;
}

.overview_label {
    font-size: 14px;
    color: var(--text-colo-secondary);
}
이제 Details 부분에 Capital, Subregion 이외에 Languages, Currencies 등을 추가해주었다.
// pages/country/[id].jsimport styles from "./Country.module.css";
import Layout from "../../components/Layout/Layout";

const Country = ({ country }) => {
    return (
        <Layout title={country.name}><div><div className={styles.overviewPanel}><img src={country.flag} alt={country.name}></img><h1 className={styles.overview_name}>{country.name}</h1><div className={styles.overview_region}>
                        {country.region}
                    </div><div className={styles.overview_numbers}><div className={styles.overview_population}><div className={styles.overview_value}>
                                {country.population}
                            </div><div className={styles.overview_label}>
                                Population
                            </div></div><div className={styles.overview_area}><div className={styles.overview_value}>
                                {country.area}
                            </div><div className={styles.overview_label}>Area</div></div></div></div><div className={styles.details_panel}><h4 className={styles.details_panel_heading}>Details</h4><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                            Capital
                        </div><div className={styles.details_panel_value}>
                            {country.capital}
                        </div></div><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                            Languages
                        </div><div className={styles.details_panel_value}>
                            {country.languages
                                .map(({ name }) => name)
                                .join(", ")}
                        </div></div><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                            Currencies
                        </div><div className={styles.details_panel_value}>
                            {country.currencies
                                .map(({ name }) => name)
                                .join(", ")}
                        </div></div><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                            Native name
                        </div><div className={styles.details_panel_value}>
                            {country.nativeName}
                        </div></div><div className={styles.details_panel_row}><div className={styles.details_panel_label}>Gini</div><div className={styles.details_panel_value}>
                            {country.gini}
                        </div></div></div></div></Layout>
    );
};

export default Country;

export const getServerSideProps = async ({ params }) => {
    const res = await fetch(
        `https://restcountries.eu/rest/v2/alpha/${params.id}`
    );

    const country = await res.json();

    return {
        props: { country },
    };
};
// pages/country.module.css
.overviewPanel img {
    width: 100%;
    border-radius: 4px;
}

.overviewPanel {
    padding: 20px;
    border-radius: 8px;
    box-shadow: var(--box-shadow);
    background-color: var(--background-color-light);
}

.overview_name {
    text-align: center;
    font-size: 32px;
    margin-bottom: 0;
}

.overview_region {
    text-align: center;
    font-size: 14px;
    font-weight: 300;
    margin-top: 4px;
    margin-bottom: 24px;
}

.overview_numbers {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
    text-align: center;
}

.overview_label {
    font-size: 14px;
    color: var(--text-colo-secondary);
}

.details_panel {
    background-color: var(--background-color-light);
    box-shadow: var(--box-shadow);
    border-radius: 8px;
}

.details_panel_heading {
    padding: 20px;
    padding-bottom: 0;
}

.details_panel_row {
    display: flex;
    justify-content: space-between;

    padding: 20px;
    border-bottom: 1px solid #e0e0e0;
}

.details_panel_label {
    color: var(--text-color-secondary);
}
브라우저 화면
notion image
다음은 이 국가의 borders에 포함되는 국가들을 borders라는 속성의 배열로 가지고 있는데 이걸 받아다가 해당 국기와 이름들을 보여주는 것이다. 이 부분을 useEffect와 useState를 이용해 구현했다.
// pages/country/[id].jsimport styles from "./Country.module.css";
import Layout from "../../components/Layout/Layout";
import { useEffect, useState } from "react";

const getCountry = async (id) => {
    const res = await fetch(`https://restcountries.eu/rest/v2/alpha/${id}`);

    const country = await res.json();

    return country;
};

const Country = ({ country }) => {
    console.log(country);
    const [borders, setBorders] = useState([]);

    const getBorders = async () => {
        const borders = await Promise.all(
            country.borders.map((border) => getCountry(border))
        );

        setBorders(borders);
    };

    useEffect(() => {
        getBorders();
    }, []);

    console.log(borders);

    return (
        <Layout title={country.name}><div className={styles.container}><div className={styles.container_left}><div className={styles.overviewPanel}><img src={country.flag} alt={country.name}></img><h1 className={styles.overview_name}>{country.name}</h1><div className={styles.overview_region}>
                            {country.region}
                        </div><div className={styles.overview_numbers}><div className={styles.overview_population}><div className={styles.overview_value}>
                                    {country.population}
                                </div><div className={styles.overview_label}>
                                    Population
                                </div></div><div className={styles.overview_area}><div className={styles.overview_value}>
                                    {country.area}
                                </div><div className={styles.overview_label}>
                                    Area
                                </div></div></div></div></div><div className={styles.container_right}>
                    {" "}
                    <div className={styles.details_panel}><h4 className={styles.details_panel_heading}>
                            Details
                        </h4><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                                Capital
                            </div><div className={styles.details_panel_value}>
                                {country.capital}
                            </div></div><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                                Languages
                            </div><div className={styles.details_panel_value}>
                                {country.languages
                                    .map(({ name }) => name)
                                    .join(", ")}
                            </div></div><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                                Currencies
                            </div><div className={styles.details_panel_value}>
                                {country.currencies
                                    .map(({ name }) => name)
                                    .join(", ")}
                            </div></div><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                                Native name
                            </div><div className={styles.details_panel_value}>
                                {country.nativeName}
                            </div></div><div className={styles.details_panel_row}><div className={styles.details_panel_label}>
                                Gini
                            </div><div className={styles.details_panel_value}>
                                {country.gini}
                            </div></div><div className={styles.details_panel_borders}><div className={styles.details_panel_borders_label}>
                                Neighbouring Countries
                            </div><divclassName={styles.details_panel_borders_container
                                }
                            >
                                {borders.map(({ flag, name }) => (
                                    <divclassName={styles.details_panel_borders_country
                                        }
                                    ><img src={flag} alt={name}></img><divclassName={styles.details_panel_borders_name
                                            }
                                        >
                                            {name}
                                        </div></div>
                                ))}
                            </div></div></div></div></div></Layout>
    );
};

export default Country;

export const getServerSideProps = async ({ params }) => {
    const country = await getCountry(params.id);

    return {
        props: { country },
    };
};
// pages/country/Country.module.css
.overviewPanel img {
    width: 100%;
    border-radius: 4px;
}

.overviewPanel {
    padding: 20px;
    border-radius: 8px;
    box-shadow: var(--box-shadow);
    background-color: var(--background-color-light);
}

.overview_name {
    text-align: center;
    font-size: 32px;
    margin-bottom: 0;
}

.overview_region {
    text-align: center;
    font-size: 14px;
    font-weight: 300;
    margin-top: 4px;
    margin-bottom: 24px;
}

.overview_numbers {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px;
    text-align: center;
}

.overview_label {
    font-size: 14px;
    color: var(--text-colo-secondary);
}

.details_panel {
    background-color: var(--background-color-light);
    box-shadow: var(--box-shadow);
    border-radius: 8px;
}

.details_panel_heading {
    padding: 20px;
    padding-bottom: 0;

    margin: 0;
}

.details_panel_row {
    display: flex;
    justify-content: space-between;

    padding: 20px;
    border-bottom: 1px solid #e0e0e0;
}

.details_panel_label {
    color: var(--text-color-secondary);
}

.details_panel_borders {
    padding: 20px;
}

.details_panel_borders_container {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
    gap: 24px;
}

.details_panel_borders img {
    width: 100%;
    border-radius: 4px;
}

.details_panel_borders_country {
    text-align: center;
}

.details_panel_borders_label {
    color: var(--text-color-secondary);
    margin-bottom: 20px;
}

.container {
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    gap: 24px;
}

@media screen and (min-width: 720px) {
    .container {
        display: grid;
        grid-template-columns: repeat(12, 1fr);
        gap: 24px;
    }

    .container_left {
        grid-column: 1 / span 4;
    }

    .container_right {
        grid-column: 5 / span 8;
    }
}
// src/Layout/Layout.module.css
.container {
    padding: 24px;
    height: 100vh;

    display: grid;
    grid-template-rows: auto 1fr auto;

    max-width: 1100px;
    margin: 0 auto;
}

.header {
    display: flex;
    justify-content: center;
    align-items: center;
    margin-bottom: 32px;
}

.footer {
    margin-top: 32px;
    text-align: center;
    font-size: 0.75rem;
}
notion image
다음은 메인페이지에서 Area, gini Column을 추가하고, 국가명에 해당하는 flag 사진을 띄워서 정렬해준다.
// src/components/CountriesTable/CountryTable.module.css
.heading {
    display: flex;
    padding: 20px;
}

.heading button {
    border: none;
    background-color: transparent;
    outline: none;
    cursor: pointer;
}

.heading_flag {
    flex: 1;
    margin-right: 16px;
}

.heading_name,
.heading_population,
.heading_area,
.heading_gini {
    flex: 4;

    color: var(--text-color-secondary);
    font-weight: 500;

    display: flex;
    justify-content: center;
    align-items: center;
}

.heading_name {
    justify-content: flex-start;
}

.heading_arrow {
    color: var(--primary-color);

    display: flex;
    justify-content: center;
    align-items: center;

    margin-left: 2px;
}

.row {
    display: flex;
    padding: 20px;

    text-align: center;

    background-color: var(--background-color-light);
    border-radius: 8px;
    margin-bottom: 16px;

    box-shadow: var(--box-shadow);
    font-weight: 500;

    transition: transform 200ms ease-in-out, box-shadow 200ms ease-in-out;
}

.row:hover {
    transform: translateY(-4px);
    box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.1);
}

.name {
    flex: 4;
    text-align: left;
}

.population {
    flex: 4;
}

.area {
    flex: 4;
}

.gini {
    flex: 4;
}

.flag {
    flex: 1;
    margin-right: 16px;
}

.flag img {
    border-radius: 2px;
    width: 100%;
}

@media screen and (max-width: 720px) {
    .area,
    .heading_area {
        display: none;
    }

    .gini,
    .heading_gini {
        display: none;
    }

    .flag,
    .heading_flag {
        display: none;
    }
}
// src/components/CountriesTable/CountriesTable.jsimport Link from "next/Link";
import {
    KeyboardArrowDownRounded,
    KeyboardArrowUpRounded,
} from "@material-ui/icons";
import { useState } from "react";
import styles from "./CountriesTable.module.css";

const orderBy = (countries, value, direction) => {
    if (direction === "asc") {
        return [...countries].sort((a, b) => (a[value] > b[value] ? 1 : -1));
    }

    if (direction === "desc") {
        return [...countries].sort((a, b) => (a[value] > b[value] ? -1 : 1));
    }

    return countries;
};

const SortArrow = ({ direction }) => {
    if (!direction === "desc") {
        return <></>;
    }

    if (direction === "desc") {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowDownRounded color="inherit" />
            </div>
        );
    } else {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowUpRounded color="inherit" />
            </div>
        );
    }
};

const CountriesTable = ({ countries }) => {
    const [direction, setDirection] = useState();
    const [value, setValue] = useState();

    const orderedCountries = orderBy(countries, value, direction);

    // NOTE: 방향이 없다면 desc, 방향이 desc면 asc로 변경해준다.
    const switchDirection = () => {
        if (!direction) {
            setDirection("desc");
        } else if (direction === "desc") {
            setDirection("asc");
        } else {
            setDirection(null);
        }
    };

    const setValueAndDirection = (value) => {
        switchDirection();
        setValue(value);
    };

    return (
        <div>
            <div className={styles.heading}>
                <div className={styles.heading_flag}></div>

                <button
                    className={styles.heading_name}
                    onClick={() => setValueAndDirection("population")}
                >
                    <div>Name</div>

                    {value === "name" && <SortArrow direction={direction} />}
                </button>

                <button
                    className={styles.heading_population}
                    onClick={() => setValueAndDirection("population")}
                >
                    <div>Population</div>

                    {value === "population" && (
                        <SortArrow direction={direction} />
                    )}
                </button>

                <button
                    className={styles.heading_area}
                    onClick={() => setValueAndDirection("area")}
                >
                    <div>
                        Area (km<sup style={{ fontSize: "0.5rem" }}>2</sup>)
                    </div>

                    {value === "area" && <SortArrow direction={direction} />}
                </button>

                <button
                    className={styles.heading_gini}
                    onClick={() => setValueAndDirection("gini")}
                >
                    <div>Gini</div>

                    {value === "gini" && <SortArrow direction={direction} />}
                </button>
            </div>

            {orderedCountries.map((country) => (
                <Link href={`/country/${country.alpha3Code}`}>
                    <div className={styles.row}>
                        <div className={styles.flag}>
                            <img src={country.flag} alt={country.name} />
                        </div>

                        <div className={styles.name}>{country.name}</div>

                        <div className={styles.population}>
                            {country.population}
                        </div>

                        <div className={styles.area}>{country.area || 0}</div>

                        <div className={styles.gini}>{country.gini || 0} %</div>
                    </div>
                </Link>
            ))}
        </div>
    );
};

export default CountriesTable;
이제 다크모드나 검색창 비율 조절 등 디자인을 수정했다.
// index.jsimport Head from "next/head";
import { useState } from "react";
import CountriesTable from "../components/CountriesTable/CountriesTable";
import Layout from "../components/Layout/Layout";
import SearchInput from "../components/SearchInput/SearchInput";
import styles from "../styles/Home.module.css";

export default function Home({ countries }) {
    console.log(countries);

    const [keyword, setKeyword] = useState("");

    const filteredCountries = countries.filter(
        (country) =>
            country.name.toLowerCase().includes(keyword) ||
            country.region.toLowerCase().includes(keyword) ||
            country.subregion.toLowerCase().includes(keyword)
    );

    const onInputChange = (e) => {
        e.preventDefault();

        setKeyword(e.target.value.toLowerCase());
    };

    return (
        <Layout>
            <div className={styles.inputContainer}>
                <div className={styles.counts}>
                    Found {countries.length} countries
                </div>

                <div className={styles.input}>
                    <SearchInput
                        placeholder="Filter by Name, Region or SubRegion"
                        onChange={onInputChange}
                    />
                </div>
            </div>

            <CountriesTable countries={filteredCountries} />
        </Layout>
    );
}

export const getStaticProps = async () => {
    const res = await fetch("https://restcountries.eu/rest/v2/all");
    const countries = await res.json();

    return {
        props: {
            countries,
        },
    };
};
// src/styles/global.css
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;500;700&display=swap");

:root {
    --text-color: #124a63;
    --text-color-secondary: #b3c5cd;

    --primary-color: #21b6b7;

    --background-color: #f9f9f9;
    --background-color-dark: #eef3f6;
    --background-color-light: white;

    --font-family: "Poppins", sans-serif;
    --box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.05);
}

[data-theme="dark"]{
    --text-color: #f0f0f0;
    --text-color-secondary: #b3c5cd;

    --primary-color: #21b6b7;

    --background-color: #252329;
    --background-color-dark: #3c393f;
    --background-color-light: #120f13;
}

body {
    padding: 0;
    margin: 0;
    font-family: var(--font-family);
    background-color: var(--background-color);
    color: var(--text-color);
}

a {
    color: inherit;
    text-decoration: none;
}

* {
    box-sizing: border-box;
    color: inherit;
    font: inherit;
}
// src/styles/Home.module.css
.counts {
    margin: 12px 0;
    color: var(--text-color-secondary);
}

.inputContainer {
    margin-bottom: 40px;
}

@media screen and (min-width: 720px) {
    .inputContainer {
        display: flex;
        justify-content: space-between;
    }

    .counts {
        flex: 1;
    }

    .input {
        flex: 2;
    }
}
// src/Layout/Layout.jsimport { Brightness6Rounded } from "@material-ui/icons";
import Head from "next/head";
import Link from "next/Link";
import { useEffect, useState } from "react";
import styles from "./Layout.module.css";

const Layout = ({ children, title = "world Ranks" }) => {
    const [theme, setTheme] = useState("light");

    useEffect(() => {
        document.documentElement.setAttribute(
            "data-theme",
            localStorage.getItem("theme")
        );

        setTheme(localStorage.getItem("theme"));
    }, []);

    const switchTheme = () => {
        if (theme === "light") {
            saveTheme("dark");
        } else {
            saveTheme("light");
        }
    };

    const saveTheme = (theme) => {
        setTheme(theme);
        localStorage.setItem("theme", theme);
        document.documentElement.setAttribute("data-theme", theme);
    };

    return (
        <div className={styles.container}>
            <Head>
                <title>{title}</title>
                <link rel="icon" href="/favicon.ico" />
            </Head>

            <header className={styles.header}>
                <Link href="/">
                    <img src="/send.svg" height={24} width={175} />
                </Link>

                <button className={styles.themeSwitcher} onClick={switchTheme}>
                    <Brightness6Rounded />
                </button>
            </header>

            <main className={styles.main}>{children}</main>

            <footer className={styles.footer}>sjwdev @ devchallenges.io</footer>
        </div>
    );
};

export default Layout;
// src/Layout/Layout.module.css
.container {
    padding: 24px;
    height: 100vh;

    display: grid;
    grid-template-rows: auto 1fr auto;

    max-width: 1100px;
    margin: 0 auto;
}

.header {
    display: flex;
    justify-content: center;
    align-items: center;
    margin-bottom: 32px;
}

.footer {
    margin-top: 32px;
    text-align: center;
    font-size: 0.75rem;
}

.themeSwitcher {
    border: none;
    background-color: transparent;
    padding: 0;

    color: var(--text-color-secondary);
    margin-left: 4px;

    display: flex;
    justify-content: center;
    align-items: center;
}
// src/component/CountriesTable/CountriesTable.jsimport Link from "next/Link";
import {
    KeyboardArrowDownRounded,
    KeyboardArrowUpRounded,
} from "@material-ui/icons";
import { useState } from "react";
import styles from "./CountriesTable.module.css";

const orderBy = (countries, value, direction) => {
    if (direction === "asc") {
        return [...countries].sort((a, b) => (a[value] > b[value] ? 1 : -1));
    }

    if (direction === "desc") {
        return [...countries].sort((a, b) => (a[value] > b[value] ? -1 : 1));
    }

    return countries;
};

const SortArrow = ({ direction }) => {
    if (!direction === "desc") {
        return <></>;
    }

    if (direction === "desc") {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowDownRounded color="inherit" />
            </div>
        );
    } else {
        return (
            <div className={styles.heading_arrow}>
                <KeyboardArrowUpRounded color="inherit" />
            </div>
        );
    }
};

const CountriesTable = ({ countries }) => {
    const [direction, setDirection] = useState();
    const [value, setValue] = useState();

    const orderedCountries = orderBy(countries, value, direction);

    // NOTE: 방향이 없다면 desc, 방향이 desc면 asc로 변경해준다.
    const switchDirection = () => {
        if (!direction) {
            setDirection("desc");
        } else if (direction === "desc") {
            setDirection("asc");
        } else {
            setDirection(null);
        }
    };

    const setValueAndDirection = (value) => {
        switchDirection();
        setValue(value);
    };

    return (
        <div>
            <div className={styles.heading}>
                <div className={styles.heading_flag}></div>

                <button
                    className={styles.heading_name}
                    onClick={() => setValueAndDirection("population")}
                >
                    <div>Name</div>

                    {value === "name" && <SortArrow direction={direction} />}
                </button>

                <button
                    className={styles.heading_population}
                    onClick={() => setValueAndDirection("population")}
                >
                    <div>Population</div>

                    {value === "population" && (
                        <SortArrow direction={direction} />
                    )}
                </button>

                <button
                    className={styles.heading_area}
                    onClick={() => setValueAndDirection("area")}
                >
                    <div>
                        Area (km<sup style={{ fontSize: "0.5rem" }}>2</sup>)
                    </div>

                    {value === "area" && <SortArrow direction={direction} />}
                </button>

                <button
                    className={styles.heading_gini}
                    onClick={() => setValueAndDirection("gini")}
                >
                    <div>Gini</div>

                    {value === "gini" && <SortArrow direction={direction} />}
                </button>
            </div>

            {orderedCountries.map((country) => (
                <Link
                    href={`/country/${country.alpha3Code}`}
                    key={country.name}
                >
                    <div className={styles.row}>
                        <div className={styles.flag}>
                            <img src={country.flag} alt={country.name} />
                        </div>

                        <div className={styles.name}>{country.name}</div>

                        <div className={styles.population}>
                            {country.population}
                        </div>

                        <div className={styles.area}>{country.area || 0}</div>

                        <div className={styles.gini}>{country.gini || 0} %</div>
                    </div>
                </Link>
            ))}
        </div>
    );
};

export default CountriesTable;
완성된 화면이다.
notion image
이 프로젝트를 통해서 css에 대한 공부(darkmode를 localstorage를 사용해서 적용하는 방법, @media 미디어 쿼리를 이용한 반응형 쿼리 등)를 더 많이 하게 된 것도 같지만 전체적인 내용적으로 풍부하다는 느낌이 들었고 처음에는 그만큼 익힐 시간이 꽤 필요할 것이다. 영상 막바지에 실제로 배포해보는 것도 가르쳐주는데 이것은 다음 글에서 다뤄볼 예정이다.