본 과정에서는 React와 TypeScript 기반으로 낱말퍼즐 게임을 그려 보겠습니다.  첫 번째 포스트에서는 하려는 작업의 Concept와 환경설정에 대한 부분을 다루어 보겠습니다.

 

Concept

이번에 짬짬이 만들어서 가로세로 낱말퍼즐 게임 앱을 하나 출시했는데, 그 중에 Front-end 요소만 뽑아내어서 진행 과정을 설명해 보겠습니다. 어릴 때 스포츠 신문 등에서 많이 보던 가로세로 낱말퍼즐이라 모양은 익숙하실 것 같습니다.

 

실제로 동작하는 부분은 앱을 다운로드 받으셔서도 확인해 볼 수 있습니다. (안드로이드만 있습니다.)

https://play.google.com/store/apps/details?id=com.moa.puzzlekit 

 

퍼즐킷 - 낱말퍼즐 퀴즈 - Google Play 앱

가로세로 낱말퍼즐 게임입니다. 게임도 하고 상식도 쑥쑥! 키우세요

play.google.com

 

구현한 낱말 퍼즐 모양은 아래 그림과 같이 구성됩니다. (아래는 해당 애플리케이션 캡쳐 화면이며, 본 포스트에서는 퍼즐 모양에 대한 구현만 설명합니다.)

낱말퍼즐 게임

 

위 애플리케이션은 문제은행 방식이라 서버를 가지고 개발했지만 이번 포스트에서는 서버 데이터는 샘플 데이터를 만들어서 사용하겠습니다.

 

환경설정

코드 편집기는 VSCode를 사용하고, React TypeScript를 사용합니다. 패키지 관리도구는 yarn을 사용합니다. 개발에 필요한 환경구성은 아래 포스트를 참고하셔도 되고, 이번 과정에서도 동일한 구성으로 진행하겠습니다.

 

https://redballs.tistory.com/entry/React-%EA%B8%B0%EC%B4%88-%EB%AA%A9%EB%A1%9D-TypeScript-02-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1

 

React 기초 (목록 - TypeScript) 02 - 프로젝트 생성

본 포스트에서는 리액트와 타입스크립트를 이용하여 네이버 API로 데이터를 검색해서 목록 화면을 구성해 보겠습니다. 이전 포스트에서 개발할 내용을 확인해 보았으니, 이제 개발에 필요한 환

redballs.tistory.com

 

워크스페이스 생성

먼저 개발에 사용할 워크스페이스 경로를 하나 만들고 패키지를 초기화 합니다. 필요한 파일들의 설치 등은 링크한 포스트를 참고하셔서 설치하시면 됩니다.

D:\workspace> mkdir makePuzzle
D:\workspace> cd makePuzzle
D:\workspace\makePuzzle> yarn init -y

 

연습으로 해보는 프로젝트이므로 패키지 관련 내용은 모두 초기값으로 설정하겠습니다. 위의 과정까지 완료하였으면 VSCode에서 프로젝트를 반입합니다.

 

모듈 설치

VSCode에서 터미널을 하나 열어서 필요한 모듈을 설치합니다. 편의를 위해 위 포스트와 동일한 버전으로 설치하겠습니다.

D:\workspace\makePuzzle> yarn add react@17.0.2
D:\workspace\makePuzzle> yarn add react-dom@17.0.2
D:\workspace\makePuzzle> yarn add --dev babel-core@6.26.3
D:\workspace\makePuzzle> yarn add --dev babel-loader@8.2.3
D:\workspace\makePuzzle> yarn add --dev babel-preset-react-app@10.0.1
D:\workspace\makePuzzle> yarn add --dev webpack@5.66.0
D:\workspace\makePuzzle> yarn add --dev webpack-dev-server@4.7.3
D:\workspace\makePuzzle> yarn add --dev webpack-cli@4.10.0
D:\workspace\makePuzzle> yarn add --dev typescript
D:\workspace\makePuzzle> yarn add --dev @types/react @types/react-dom

 

런타임에 필요한 모듈은 일반 의존성으로, 컴파일 타임에만 필요한 모듈들은 개발 의존성으로 설치하시면 됩니다.

 

Hello World

이제 실제로 프로그램을 작성해서 브라우저에 "Hello World"를 출력하는 부분까지 진행해 보겠습니다.

먼저 프로젝트 루트에 /src 라는 경로를 생성하고 아래와 같이 app.tsx 파일을 작성합니다.

const App = () => (
    <>Hello World</>
);

export default App;

 

동일한 경로에 해당 Component를 사용하는 main.tsx 파일도 아래와 같이 작성합니다.

import ReactDOM from 'react-dom';
import App from './app';

ReactDOM.render(<App />, document.getElementById('app'));

 

위 두 프로그램을 작성했으면 컴파일, 번들링을 진행하여 브라우저에서 한 번 실행해 보겠습니다. 일단은 빨간 줄이 떠도 무시합니다. 먼저 컴파일을 위해 프로젝트 루트에 tsconfig.json 파일을 아래와 같이 작성합니다.

{
    "compilerOptions": {
      "jsx": "react-jsx",
      "target": "es5",
      "module": "commonjs",
      "outDir": "./dist",
      "rootDir": "./src",
      "esModuleInterop": true,
      "forceConsistentCasingInFileNames": true,
      "strict": true,
      "skipLibCheck": true
    }
}

 

컴파일 할 대상은 ./src 하위에 있는 파일들로 설정하며 컴파일 완료한 파일은 ./dist 경로 하위에 배포하도록 설정합니다. 여기까지 완료하였으면 tsc 명령으로 컴파일 할 수 있습니다. 컴파일을 정상적으로 수행하고 나면 프로젝트에 ./dist 라는 경로가 생성되고 하위에 app.js, main.js 파일이 생성됩니다. 현재까지 프로젝트 구조는 아래와 같습니다.

프로젝트 구조

 

컴파일 한 js 파일들을 번들링하기 위해 webpack을 설정합니다. 가장 기본적인 설정만 넣어서 아래와 같이 webpack.config.js 파일을 프로젝트 루트에 작성합니다.

'use strict'
const path = require('path');

module.exports = {
    entry: {
        main: ['./dist/main.js']
    },
    output: {
        path: path.resolve(__dirname, './public/js'),
        filename: 'build.js'
    },
    module: {
        rules: [{
            test: /\.jsx?$/,
            use: {
                loader: 'babel-loader'
            }
        }]
    },
    devServer: {
        static: './public',
        host: 'localhost',
        port: 8080
    }
};

 

실행할 때 시작점은 컴파일 경로인 ./dist/main.js 로 설정하고 개발 서버의 루트는 ./public 하위 경로로 설정합니다. (아직은 만들지 않아서 해당 경로는 아직 없는게 맞습니다.) 포트는 무난하게 8080으로 합니다. 

output 항목에서는 번들링한 결과를 ./public/js/build.js 로 생성하도록 설정합니다.

 

번들링하고 해당 개발 서버를 start 할 수 있도록 package.json 파일에도 script를 추가합니다.

"scripts": {
    "start": "NODE_ENV=development webpack-dev-server",
    "build": "webpack"
}

 

이제 개발 서버의 루트 경로가 될 ./public 경로를 하나 생성하고 index.html을 작성합니다. index.html 파일에서는 번들링 결과인 build.js 파일을 import 합니다.

<!DOCTYPE html>
<html>
    <head>
        <title>낱말퍼즐</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    </head>
    <body>
        <div id="app"></div>
        <script type="text/javascript" src="./js/build.js"></script>
    </body>
</html>

 

Component를 렌더링할 영역으로는 app 라는 이름으로 영역을 하나 만들어 줍니다. 여기까지 완료했으면 이제 위에서 컴파일한 js 파일들을 번들링하여 build.js 로 변환합니다.

D:\workspace\makePuzzle> npm run build

 

명령을 실행하면 번들링 결과로 ./public/js/build.js 파일이 생성됩니다. 현재까지의 모두 진행한 프로젝트 파일 구조는 아래와 같습니다.

프로젝트 구조

 

이제 개발 서버를 시작하고 화면에 Hello World가 출력되는지 확인합니다.

D:\workspace\makePuzzle> yarn start

실행화면

 

이번 포스트에서는 개발 준비 작업으로 프로젝트 구조를 만들고 컴파일, 번들링 및 실행 환경까지 구성해 보았습니다. 다음 포스트에서는 이어서 낱말 퍼즐을 작성해 보겠습니다.

300x250

본 포스트는 React 애플리케이션 작성 시 로딩 다이얼로그, 프로그레스를 표시할 때 사용할 수 있는 오픈 소스인 React Spinners 사용에 대한 글입니다.

 

 

애플리케이션에서 서버 인터페이스나 모바일 Device의 기능을 사용하기 위해서 인터페이스 할 때, 아니면 내부적으로 무언가를 분석해야 해서 사용자와의 인터페이스를 잠시 막아두고 싶은 경우 Loading Dialog를 많이 사용합니다. jQuery를 사용할 때도 괜찮은 오픈 소스가 있어서 사용하거나, 아니면 그냥 Layer를 하나 뒤집어 씌운 다음에 괜찮은(?) 그림이나 애니메이션 하나를 올리는 방법을 사용했습니다.

 

React 애플리케이션에서 사용하기 괜찮은 오픈 소스가 하나 있어 소개합니다. 사실 코드에 대한 설명은 더 필요없을 정도로 깔끔하게 아래 사이트에 정리되어 있습니다.

https://mhnpd.github.io/react-loader-spinner/docs/intro

 

Getting Started | React Spinners

Installation

mhnpd.github.io

 

저 같은 경우는 Audio Component가 마음에 들어서, 해당 Component를 아래와 같이 사용해 보았습니다. 먼저 Loading Progress로 사용할 Component를 하나 생성합니다.

const Loading = () => {
    return (
        <div className="loadingContainer">
            <Audio
                height="60"
                width="60"
                color="#FFF"
                ariaLabel="audio-loading"
                wrapperStyle={{}}
                wrapperClass="wrapper-class"
                visible={true}
            />
        </div>
    )
};

 

Audio Component를 사용하고 백그라운드를 애플리케이션에 어울리는 형태로 커스터마이징 하기 위해서 loadingContainer라는 style을 사용하는 div로 한 번 감쌌습니다. 해당 CSS는 아래와 같습니다.

.loadingContainer {
    width:100%;
    height:100%;
    overflow:hidden;
    position:fixed;
    top:0;
    right:0;
    bottom:0;
    left:0;
    background-color:rgba(0,0,0,0.4);
    z-index:100;
    text-align:center;
}
.loadingContainer svg {
    position:absolute;
    top:calc(50% - 30px);
    left:calc(50% - 30px);
}

 

백그라운드를 화면 전체에 반투명으로 적용하고 Component의 위치를 선언했습니다. 이제 Loading Component를 사용할 Component 내부에 state를 선언합니다.

const [loading, setLoading] = useState<boolean>(false);

 

처음에는 Loading Component는 표시되지 않아야 하므로 초기 state는 false로 선언합니다. 이제 UI를 블록할 필요가 있는 부분에 Loading Component를 적용합니다. 아래 예시는 모바일 데이터를 통해 현재 위치를 가져오는 부분에 적용한 예시입니다.

setLoading(true);
navigator.geolocation.getCurrentPosition((pos) => {
    const slat: number = pos.coords.latitude;
    const slng: number = pos.coords.longitude;
	//TODO :: 구현
    setLoading(false);
}, () => {
    setLoading(false);
});

 

현재 위치를 수신하기 전에 loading state를 true로 변경한 후 작업이 정상으로 완료되거나, 실패로 완료되어도 loading state는 false로 변경합니다. 이제 마지막으로 UI Rendering code를 return 하는 부분에 state에 변경에 따라 Loading Component를 표시하거나 제거하는 로직을 추가합니다.

{
    loading ? <Loading /> : ''
}

 

위와 같이 작업하면 간단하게 Loading Dialog를 표시할 수 있습니다. 위의 예시를 사용한 화면은 아래와 같습니다.

 

300x250

블로그에 정리한 내용들 중 React와 Express로 애플리케이션을 구성하여 이전 회차들의 당첨번호를 수집하여, 해당 데이터들로 통계를 분석하여 로또 추천(예측/예상) 번호를 생성해주는 애플리케이션을 사이드 프로젝트로 만들어 보았습니다.

 

아래 URL에서 다운로드 하실 수 있습니다.

https://play.google.com/store/apps/details?id=com.nextact.lottoapp

 

로또킷(lottoKit) - Google Play 앱

로또 추천번호 생성, 주변 로또판매점 바로가기, 로또 번호 선물하기 등 다양한 로또 정보를 제공하는 로또킷(lottoKit)입니다.

play.google.com

 

 

 

애플리케이션은 아래 포스트에서 설명한 구조로 구성되어 있습니다.

https://redballs.tistory.com/entry/React-Express-Typescript-애플리케이션-구성

 

React + Express + Typescript - 01. 서버 환경 구성

본 포스트에서는 Front-end는 리액트, Back-end는 Express 기반의 Node.js를 사용하여 하나의 프로젝트로 간단한 Application을 구성해 보겠습니다. 구성할 Application은 이전 포스트에서 진행했던 네이버 API

redballs.tistory.com

 

Back-end는 살짝 변경해서 NestJS 기반으로 구성했지만 별반 다를 내용은 없습니다. Front-end는 React + Typescript로 구성해 보았습니다. 원리는 수집한 자료를 기반으로 나름의 알고리즘으로 서버에서 분석을 해서 번호를 생성한 후 Front-end에 전달하면 Front-end에서 추천번호를 보여주는 방식입니다.

 

UX는 아래와 같이 구성되어 있습니다.

Lotto-Kit 메인 화면

 

로또 번호를 5개까지 생성할 수 있고 (마음에 들지 않는 번호는 재생성할 수 있습니다.) 근처에 있는 1등 당첨 판매점과 일반 로또 판매점을 조회할 수 있습니다. 또한 우리 앱에서 생성한 번호 중 실제로 1등~5등에 당첨된 번호가 있으면 [당첨내역]에서 조회할 수 있습니다. 

그리고 회차 별 당첨번호도 조회할 수 있고, 생성한 번호를 지인에게 선물할 수도 있습니다.

 

한 번쯤 애플리케이션에 관심이 있으신 분은 다운로드 하셔서 평가 부탁 드립니다.

 

구글 플레이스토어에서 "lottokit"을 검색하시거나 아래 URL로 바로 다운로드 하실 수 있습니다.

https://play.google.com/store/apps/details?id=com.nextact.lottoapp

 

로또킷(lottoKit) - Google Play 앱

로또 추천번호 생성, 주변 로또판매점 바로가기, 로또 번호 선물하기 등 다양한 로또 정보를 제공하는 로또킷(lottoKit)입니다.

play.google.com

 

300x250

본 포스트에서는 Front-end는 리액트, Back-end는 Express 기반의 Node.js를 사용하여 하나의 프로젝트로 간단한 Application을 구성해 보겠습니다. 구성할 Application은 이전 포스트에서 진행했던 네이버 API 조회 Application을 재사용 하겠습니다.

 

 

React + Express + TypeScript 프로젝트 구성하기

 

이전 포스트에서는 express 서버에서 개발한 API에서 사용하는 데이터와 동일한 형태로 uiModule에도 /src/interface/apidata.interface.ts 파일에 데이터를 선언했습니다. 이번 포스트에서는 해당 데이터 타입을 사용하는 API 인터페이스 부분을 작성해 보겠습니다.

 

/src/component/listview.component.tsx 파일을 생성하고 API 데이터를 저장할 state를 하나 선언합니다. articles 상태에는 API 본문을 저장할 예정이므로 IRespData 형태로 선언합니다.

const ListView = () => {
    const [articles, setArticles] = useState<IRespData | null>(null);
}

export default ListView;

 

목록에 필요한 데이터는 로컬 서버에 Query 해야 하므로 fetch API를 사용해서 서버 API를 Query 하는 apiGet 함수를 하나 정의합니다.

const apiGet = async(type: string, param: string) => {
    const apiUrl: string = '/search/' + type + '/' + param;
    await fetch(apiUrl, {
        method: 'GET',
        headers: {
            'Content-Type': 'application/json'
        }
    })
    .then((resp: Response) => resp.json())
    .then((resp: IHttpResp) => {
        if (resp.code === '00') {
            setArticles(resp.data as IRespData);
            console.log(resp.data);
        } else {
            //handle error
        }
    });
};

 

Naver API는 서버 API에서 호출하므로 UI에서는 /search/{type}/{param} 형식으로 서버 API를 호출합니다.  도서 검색을 React 라는 파라미터로 검색을 하려면 /search/book/React 로 검색할 수 있습니다. apiGet 함수는 useEffect에서 호출하여 화면이 Loading 될 때 조회하도록 합니다.

useEffect(() => {
    apiGet('book', 'React');
}, []);

 

apiGet 함수를 호출할 때 React 관련 도서를 검색할 수 있도록 위와 같이 useEffect Hook을 작성합니다. 이번 포스트에서는 console.log로 resp.data가 올바르게 조회되는지 까지만 확인해 보겠습니다. 그러므로 UI는 별도로 작성하지 않고 <></> 만 return 하도록 합니다. 여기까지 작성한 listview.component.tsx 프로그램의 전체 소스코드는 아래와 같습니다.

import { useEffect, useState } from "react";
import { IHttpResp, IRespData } from "../interface/apidata.interface";

const ListView = () => {
    const [articles, setArticles] = useState<IRespData | null>(null);

    const apiGet = async(type: string, param: string) => {
        const apiUrl: string = '/search/' + type + '/' + param;
        await fetch(apiUrl, {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json'
            }
        })
        .then((resp: Response) => resp.json())
        .then((resp: IHttpResp) => {
            if (resp.code === '00') {
                setArticles(resp.data as IRespData);
                console.log(resp.data);
            } else {
                throw new Error('error');
            }
        });
    };

    useEffect(() => {
        apiGet('book', 'React');
    }, []);

    return (
        <></>
    );
};

export default ListView;

 

다음은 /src/app.tsx 프로그램을 아래와 같이 하나 작성하여 ListView 컴포넌트를 추가합니다.

import ListView from "./component/listview.component";

const App = () => {
    return (
        <ListView />
    )
};

export default App;

 

/src/main.tsx 소스코드에서는 <App /> 컴포넌트를 렌더링 하도록 아래와 같이 변경합니다.

import ReactDOM from 'react-dom';
import App from './app';

ReactDOM.render(<App />, document.getElementById('app'));

 

이제 서버와 인터페이스하여 수신한 데이터를 Logging 하는 부분까지는 준비를 마쳤습니다. 이제 타입스크립트를 컴파일하고 생성한 js 파일을 번들링하여 build.js를 생성합니다.

D:\workspace\expressServer\uiModule> tsc
D:\workspace\expressServer\uiModule> npm run build

 

/public/build.js 가 재생성 되었음을 확인하고 express 서버를 구동합니다.

D:\workspace\expressServer> yarn start

 

이제 브라우저로 http://localhost:8001 에 접속하면 아래와 같은 콘솔 로그를 확인할 수 있습니다.

인터페이스 결과 로그

 

아직 UI는 하나도 만들지 않았지만 이번 시리즈의 목적은 달성했습니다. 이번 시리즈의 목적은 아래 그림과 같았습니다.

서버 to 서버 인터페이스

 

NAVER API를 브라우저에서 직접 호출하지 않고 로컬에 express 서버를 하나 띄운 다음, express 서버에서 Naver API를 호출하여 중계하도록 Front-end, Back-end 프로그램을 작성해 보았습니다.

 

이후 UI를 만들어 보는 과정은 [React 기초 (List - TypeScript)] 시리즈의 "06 - ListView UI 구성" 포스트와 거의 동일한 내용이 될 것 같아서 작성하지 않겠습니다. 계속 진행해 보실 분들은 아래 링크를 참고하시면 됩니다.

React 기초 (목록 - TypeScript) 06 - ListView UI 구성 :: 즐거운인생 (tistory.com)

 

React 기초 (목록 - TypeScript) 06 - ListView UI 구성

본 포스트에서는 리액트와 타입스크립트를 이용하여 네이버 API로 데이터를 검색해서 목록 화면을 구성해 보겠습니다. 저번 포스트에서는 Naver API를 호출하여 응답 데이터를 확인해 보고, 응답

redballs.tistory.com

 

300x250

본 포스트에서는 Front-end는 리액트, Back-end는 Express 기반의 Node.js를 사용하여 하나의 프로젝트로 간단한 Application을 구성해 보겠습니다. 구성할 Application은 이전 포스트에서 진행했던 네이버 API 조회 Application을 재사용 하겠습니다.

 

 

React + Express + TypeScript 프로젝트 구성하기

 

이전 포스트에서는 expressServer 프로젝트 아래 uiModule 이라는 경로를 생성하고 해당 경로를 React 프로젝트로 구성한 후 소스코드를 컴파일 및 번들링해서 Node.js 서버에서 실행해보는 부분까지 진행해 보았습니다. 이번 포스트에서는 이전 포스트들에서 만든 서버 API에서 응답하는 데이터를 확인해 보고, Front-end에서 사용할 데이터 타입을 선언해 보겠습니다.

 

먼저 서버 API의 데이터 구조를 정의한 포스트는 아래 주소를 참고합니다.

React + Express + Typescript - 02. 데이터 타입 선언 :: 즐거운인생 (tistory.com)

 

React + Express + Typescript - 02. 데이터 타입 선언

본 포스트에서는 Front-end는 리액트, Back-end는 Express 기반의 Node.js를 사용하여 하나의 프로젝트로 간단한 Application을 구성해 보겠습니다. 구성할 Application은 이전 포스트에서 진행했던 네이버 API

redballs.tistory.com

 

먼저 로컬 서버를 구동하고 http://localhost:8001/search/news/React 로 접근해서 데이터를 확인해 보겠습니다.

 

HTTP 응답

HTTP 응답은 아래와 같은 형태로 응답코드, 응답메시지, 응답데이터가 return 됩니다.

HTTP 응답

 

DATA 영역

HTTP 응답 중 데이터 영역은 아래와 같이 출력 건수(display), 시작건수(start), 전체건수(total), 검색유형(type), 검색 결과 데이터(items) 가 return 됩니다.

데이터부 응답

 

검색 결과 데이터 영역

검색 결과 데이터 영역은 두 가지 형태로 return 됩니다. type이 news일 경우에는 뉴스 데이터의 배열 형태로 return 되고, type이 book일 경우에는 도서 데이터의 배열 형태로 return 됩니다.

 

[type이 new일 경우]

뉴스 유형의 검색 결과

[type이 book일 경우]

도서 유형의 검색 결과

 

데이터 타입 선언

위 데이터를 분석해서 데이터 타입을 정의하면 되는데, 기본적으로는 서버 애플리케이션에서 정의한 데이터와 동일하게 정의하면 됩니다. 서버 애플리케이션의 정의를 참조하여 아래 경로에 프로그램을 작성합니다.

- ./uiModule/src/interface/apidata.interface.ts

 

먼저 HTTP 응답은 아래와 같이 작성합니다.

interface IHttpResp {
    code: string;
    message: string;
    data: IRespData | null
}

 

서버 요청에 대한 응답은 기본적으로 응답코드와 메시지, 조회한 데이터를 응답하도록 하고 조회한 데이터는 IRespData 형태로 아래와 같이 정의합니다. 오류일 경우 데이터가 없을 수도 있으니 null도 허용해줍니다.

interface IRespData {
    lastBuildDate: string;
    total: number;
    start: number;
    display: number;
    type: string;
    items: INewsData[] | IBookData[];
}

 

Naver API에서 주는 응답의 형태에 현재 데이터의 유형을 구분할 수 있는 type 속성을 추가합니다. 실제 조회한 데이터는 뉴스 데이터일 경우 INewsData의 배열 형태, 도서 데이터일 경우 IBookData의 배열 형태로 구성합니다. INewsData와 IBookData의 정의는 아래와 같습니다.

interface INewsData {
    title: string;
    originallink: string;
    link: string;
    description: string;
    pubDate: string;
}

interface IBookData {
    title: string;
    link: string;
    image: string;
    author: string;
    price: string;
    discount: string;
    publisher: string;
    pubdate: string;
    isbn: string;
    description: string;
}

 

다른 모듈에서 사용할 수 있도록 export를 추가합니다.

export {
    IHttpResp,
    IRespData,
    INewsData,
    IBookData
}

 

JSON 형태로 보면 아래와 같은 형태입니다.

{
    "code": "",
    "message": "",
    "data": {
        "start": 0,
        "total": 0,
        "display": 0,
        "lastBuildDate": "",
        "type": "",
        "items": [{
            "title": "",
            "description": "",
            "link": "",
            "originallink": "",
            "pubDate": ""
        }]
    }
}

 

데이터 타입 선언 부분은 서버와 클라이언트가 동일한 형태로 데이터를 참조해야 하므로 서버 개발자와 클라이언트 개발자가 별개로 있다면 협의해서 인터페이스 하는데 문제 없이 맞추어 가야합니다.

 

다음 포스트에서는 서버에서 제공한 API와 인터페이스하여 선언한 데이터 타입으로 바인드해서 확인해 보겠습니다.

300x250

본 포스트에서는 Front-end는 리액트, Back-end는 Express 기반의 Node.js를 사용하여 하나의 프로젝트로 간단한 Application을 구성해 보겠습니다. 구성할 Application은 이전 포스트에서 진행했던 네이버 API 조회 Application을 재사용 하겠습니다.

 

 

React + Express + TypeScript 프로젝트 구성하기

 

저번 포스트까지 Node.js 서버를 express 기반으로 구성한 후 간단하게나마 서버 프로그램을 작성해서 Naver API를 호출하는 API를 작성하고 테스트 해보았습니다. 이번 포스트에서는 해당 API를 사용하여 UI를 구성할 React 프로젝트의 환경을 구성해 보겠습니다.

 

서버와 클라이언트 프로젝트는 별도의 Repository로 구성해도 되고, 하나의 통합 Repository로 구성해도 됩니다. 여기서는 별도의 프로젝트로 구성하지 않고 express 프로젝트 안에 uiModule이라는 경로를 생성하여 React 프로젝트를 구성해 보도록 하겠습니다.

 

먼저 expressServer 프로젝트의 루트 경로에 uiModule이라는 경로를 생성합니다.

D:\workspace\expressServer> mkdir uiModule
D:\workspace\expressServer> cd uiModule
D:\workspace\expressServer\uiModule>

 

yarn init 명령으로 package.json 파일을 생성합니다.

D:\workspace\expressServer\uiModule> yarn init -y

 

다음은 React 프로젝트에서 사용할 모듈을 설치합니다.  프로젝트에 사용할 모듈에 대한 설명은 아래 포스트를 참고합니다.

React 기초 (목록 - TypeScript) 02 - 프로젝트 생성 :: 즐거운인생 (tistory.com)

 

React 기초 (목록 - TypeScript) 02 - 프로젝트 생성

본 포스트에서는 리액트와 타입스크립트를 이용하여 네이버 API로 데이터를 검색해서 목록 화면을 구성해 보겠습니다. 이전 포스트에서 개발할 내용을 확인해 보았으니, 이제 개발에 필요한 환

redballs.tistory.com

 

설치할 모듈은 아래와 같습니다.

D:\workspace\expressServer\uiModule> yarn add react@17.0.2
D:\workspace\expressServer\uiModule> yarn add react-dom@17.0.2
D:\workspace\expressServer\uiModule> yarn add --dev babel-core@6.26.3
D:\workspace\expressServer\uiModule> yarn add --dev babel-loader@8.2.3
D:\workspace\expressServer\uiModule> yarn add --dev babel-preset-react-app@10.0.1
D:\workspace\expressServer\uiModule> yarn add --dev webpack@5.66.0
D:\workspace\expressServer\uiModule> yarn add --dev webpack-cli@4.10.0
D:\workspace\expressServer\uiModule> yarn add --dev typescript
D:\workspace\expressServer\uiModule> yarn add --dev @types/react @types/react-dom
D:\workspace\expressServer\uiModule> yarn add moment@2.29.1

 

위 포스트에서 사용했던 webpack-dev-server는 이미 express로 구성한 서버가 있으므로 설치하지 않습니다.

 

/uiModule/src 경로를 생성하고, 아래와 같이 main.tsx 파일을 작성합니다

import ReactDOM from 'react-dom';

ReactDOM.render(<div>Hello World</div>, document.getElementById('app'));

 

타입스크립트를 사용하도록 tsx 파일로 생성했으므로 uiProject 하위에 tsconfig.json 파일을 아래와 같이 생성합니다.

{ 
    "compilerOptions": {
        "jsx": "react-jsx",
        "target": "es6",
        "module": "commonjs",
        "outDir": "./dist",
        "rootDir": "./src",
        "esModuleInterop": true,
        "forceConsistentCasingInFileNames": true,
        "strict": true,
        "skipLibCheck": true
    }
}

 

rootDir 이 ./src 이므로 uiModule/src 하위 파일들에 대해 컴파일을 수행하고 outDir 이 ./dist 이므로 uiModule/dist 하위에 컴파일 결과 파일을 생성합니다.

 

 

uiModule 의 package.json 파일에 build 스크립트를 아래와 같이 추가합니다.

{
  "name": "uiModule",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "moment": "2.29.1",
    "react": "17.0.2",
    "react-dom": "17.0.2"
  },
  "devDependencies": {
    "@types/react": "^18.0.15",
    "@types/react-dom": "^18.0.6",
    "babel-core": "6.26.3",
    "babel-loader": "8.2.3",
    "babel-preset-react-app": "10.0.1",
    "typescript": "^4.7.4",
    "webpack": "5.66.0",
    "webpack-cli": "4.10.0",
    "webpack-dev-server": "4.7.3"
  },
  "scripts": {
    "build": "webpack"
  }
}

 

여기까지 작업을 하면 uiModule 하위의 프로젝트 구조는 아래와 같습니다.

uiModule 프로젝트 구조

 

이제 tsc 명령으로 컴파일을 하면 아래 그림과 같이 dist 폴더가 생성되고 그 하위에 컴파일 결과가 생성됩니다.

D:\workspace\expressServer\uiModule> tsc

컴파일 결과 생성 확인

 

여기까지 잘 되었다면 이제 Node.js 서버에서 uiModule을 사용하기 위해 expressServer 프로젝트의 /public/js 하위로 컴파일 결과 파일들을 번들링하기 위해 아래와 같이 uiModule/webpack.config.js 파일을 생성합니다.

'use strict'
const path = require('path');

module.exports = {
    entry: {
        main: ['./dist/main.js']
    },
    output: {
        path: path.resolve(__dirname, '../public/js'),
        filename: 'build.js'
    },
    module: {
        rules: [{
            test: /\.jsx?$/,
            use: {
                loader: 'babel-loader'
            }
        }]
    }
};

 

애플리케이션의 시작점은 ./dist/main.js 로 컴파일 되므로 entry는 main.js 로 설정합니다. 번들링한 결과는 uiProject 외부에 있는 프로젝트 루트의 ./public/js로 전송해야 하므로 output은 위와 같이 설정하고 번들링한 결과는 build.js로 생성합니다.

D:\workspace\expressServer\uiProject> npm run build

 

npm run build 명령으로 번들링하면 아래 그림과 같이 결과가 생성됩니다.

번들링 결과 확인

 

번들링한 결과를 사용할 수 있도록 아래와 같이 index.html 파일을 수정합니다.

<!DOCTYPE html>
<html>
    <head>
        <title>Express Sample</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
    </head>
    <body>
        <div id="app"></div>
        <script type="text/javascript" src="js/build.js"></script>
    </body>
</html>

 

이제 프로젝트 루트로 이동해서 yarn start 명령으로 서버를 실행시키고 화면에 Hello World가 표시되는지 확인합니다.

D:\workspace\expressServer> yarn start

 

 

실행 결과 확인

300x250

본 포스트에서는 리액트와 타입스크립트를 이용하여 네이버 API로 데이터를 검색해서 목록 화면을 구성해 보겠습니다.

 

 

 

이번 과정에서 작성한 전체 소스코드는 중간중간 포스트에서 표시했습니다.

하지만 혹시 잘 안되시는 분들이 있으면 아래 Github에서 다운로드 해서 확인해 보시면 됩니다.

 

https://github.com/lgcjh0s/searchNaverApiTs

 

GitHub - lgcjh0s/searchNaverApiTs

Contribute to lgcjh0s/searchNaverApiTs development by creating an account on GitHub.

github.com

 

Checkout 받는 방법은 아래 포스트를 참고해주세요.

https://redballs.tistory.com/entry/Github-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-VSCode%EB%A1%9C-Checkout-%ED%95%98%EA%B8%B0?category=568554 

 

Github 프로젝트 VSCode로 Checkout 하기

본 포스트는 협업을 위해 Github에 업로드 되어 있는 프로젝트를 비주얼 스튜디오 코드로 체크아웃 하는 방법을 설명합니다. 1. 체크아웃 대상 프로젝트 다른 포스트에서 진행했던 searchNaverApiTs 프

redballs.tistory.com

 

300x250
본 포스트에서는 리액트와 타입스크립트를 이용하여 네이버 API로 데이터를 검색해서 목록 화면을 구성해 보겠습니다.
 
 

React + TypeScript Naver API로 목록 만들기

 

이제 이번 프로젝트의 마지막 과정입니다. 지난 포스팅까지 해서 목록을 구성하고 탭 메뉴로 각자의 탭에 맞는 Naver API를 호출하여 목록을 변경하는 부분까지 해보았습니다. 컴포넌트 간 상태 및 데이터를 공유하기 위해서는 Recoil을 설치하고 사용해 보았습니다. 이제 이번 프로젝트의 마지막으로 키워드 검색 상자를 만들고 키워드에 맞는 검색 결과를 조회해 보겠습니다.

 

먼저 이제까지 진행한 프로젝트의 폴더 및 파일 구조를 한 번 확인하겠습니다.

혹시나 빠진 파일이 있으신 분들은 한 번 체크해 보시면 좋을 것 같습니다. 먼저 목표 UI를 확인해 보겠습니다. 저번 TypeScript를 사용하지 않고 React만 사용해서 개발했을 때 아래와 같이 UI를 확인했었고, 이번에도 동일합니다.

 

 

UI는 위와 같이 구성하고, 스크롤 영역에는 포함시키지 않고 상단에 고정합니다. 키워드를 입력하고 검색 버튼을 터치하면 현재 탭과 검색 키워드에 맞는 검색 결과를 표시합니다.

 

/src/component/ 경로에 searchbar.component.tsx 파일을 생성하고 아래와 같이 UI Rendering 부분을 작성합니다. UI를 구성하는 부분만 있어서 딱히 JavaScript 버전과 다른 내용이 없습니다.

import React from "react";

const SearchBar = () => {
    return (
        <div className="header">
            <input type="text" className="iptSearch" id="keyword" />
            <button type="button" className="search">
                <span>검색</span>
            </button>
        </div>
    )
};

export default SearchBar;

 

생성한 SearchBar 컴포넌트를 사용하기 위해 app.tsx에 SearchBar 컴포넌트를 추가합니다.

import { atom, RecoilRoot, RecoilState } from "recoil";
import ListView from "./component/listview.component";
import SearchBar from "./component/searchbar.component";
import TabList from "./component/tablist.component";

export const selectedTabId: RecoilState<string> = atom({
    key: 'tabId',
    default: 'news'
});

const App = () => (
    <RecoilRoot>
        <div>
            {/* ADD :: Start */}
            <SearchBar />
            {/* ADD :: End */}
            <ListView />
            <TabList />
        </div>
    </RecoilRoot>
)

export default App;

 

상단에 고정할 수 있도록 stylesheet를 아래와 같이 대강 추가해 줍니다.

.header{padding:10px;border-bottom:1px solid #DDD;position:fixed;top:0;display:inline-block;z-index:100;width:100%;background-color:#FFF;}
.iptSearch{width:calc(100% - 110px);border:1px solid #EEE;line-height:27px;padding:0px 10px;margin-right:10px;}
button.search{padding:5px 10px;}

 

Header 영역에 검색 상자를 추가하면서 목록 영역은 상단 부분의 자리를 좀 비켜줘야 합니다. 목록 영역의 class인 listArea class에 상단 여백을 추가합니다.

.listArea{width:100%;margin-bottom:50px;margin-top:52px;}

 

 

여기까지 진행을 하면 검색 상자에 대한 UI는 구성을 마쳤습니다. 컴파일과 번들링을 진행해서 UI를 확인하고 넘어가겠습니다.

D:\workspace\searchNaverApiTs> tsc
D:\workspace\searchNaverApiTs> npm run build

 

 

기능 구현은 아직 하지 않아, 검색이 되지는 않습니다. UI를 완성했으니 이제 검색 기능을 구현해 보겠습니다. 먼저 app.tsx 에 검색 키워드를 관리할 atom을 하나 추가로 생성합니다. 기본 검색어는 "코로나"로 지정했습니다.

import { atom, RecoilRoot, RecoilState } from "recoil";
import ListView from "./component/listview.component";
import SearchBar from "./component/searchbar.component";
import TabList from "./component/tablist.component";

export const selectedTabId: RecoilState<string> = atom({
    key: 'tabId',
    default: 'news'
});

//ADD :: Start
export const searchKeyword: RecoilState<string> = atom({
    key: 'keyword',
    default: '코로나'
});
//ADD :: End

const App = () => (
    <RecoilRoot>
        <div>
            <SearchBar />
            <ListView />
            <TabList />
        </div>
    </RecoilRoot>
)

export default App;

 

다음은 SearchBar 컴포넌트에서 검색어를 입력하고 [검색] 버튼을 터치하면 입력한 검색어를 상태 값에 저장하는 기능을 구현합니다. 아래와 같이 먼저 RecoilState를 선언한 후 [검색] 버튼을 터치하면 setter를 호출하여 상태 값에 저장합니다.

import { useRecoilState } from "recoil";
import { searchKeyword } from "../app";

const SearchBar = () => {
	//ADD :: Start
    const [keyword, setKeyword] = useRecoilState<string>(searchKeyword);

    const search = () => {
        const searchKeyword: string = (document.querySelector('#keyword') as HTMLInputElement).value;
        setKeyword(searchKeyword);
    };
    //ADD :: End

    return (
        <div className="header">
            <input type="text" className="iptSearch" id="keyword" />
            {/* onClick 추가 */}
            <button type="button" className="search" onClick={search}>
                <span>검색</span>
            </button>
        </div>
    )
};

export default SearchBar;

 

SearchBar 컴포넌트에서 searchKeyword atom에 값을 저장하는 부분을 구현하였으니 이제 ListView 컴포넌트에서 searchKeyword atom에서 검색 키워드를 추출해서 해당 검색어로 검색하도록 수정합니다.

 

먼저 SearchBar 컴포넌트와 동일하게 useRecoilState를 선언합니다.

const [keyword, setKeyword] = useRecoilState<string>(searchKeyword);

 

useEffect를 구현한 부분에 keyword를 하드코딩 값이 아닌 keyword 값으로 변경해주고, 탭 아이디나 키워드가 변경될 때마다 인터페이스를 수행하도록 변경합니다.

useEffect(() => {
    apiGet(selTabId, keyword);
}, [selTabId, keyword]);

 

ListView 컴포넌트의 전체 소스코드는 아래와 같습니다.

import moment from "moment";
import { useEffect, useState } from "react";
import { useRecoilState } from "recoil";
import { searchKeyword, selectedTabId } from "../app";
import { IBookData, IHttpResp, INewsData } from "../interface/apidata.interface";

const NewsRow = ({ title, pubDate, description }: INewsData) => {  
    return (
        <li>
            <div className="title">
                <a href="#" dangerouslySetInnerHTML={{__html: title}} />
            </div>
            <div className="cont">
                <span className="date">{moment(pubDate).format('YYYY.MM.DD HH:mm')}</span>
                <span dangerouslySetInnerHTML={{__html: description}} />
            </div>
        </li>
    )
};

const BookRow = ({ image, title, author, description }: IBookData) => {
    return (
        <li>
            <a href="#" className="bookRow">
                <div className="bookImg">
                    <img src={image} />
                </div>
                <div className="bookDesc">
                    <div className="title" dangerouslySetInnerHTML={{__html: title}} />
                    <div className="cont">
                        <span className="author">{author}</span>
                        <span dangerouslySetInnerHTML={{__html: description}} />
                    </div>
                </div>
            </a>
        </li>
    );
};

const ListView = () => {
    const [articles, setArticles] = useState<IHttpResp | null>(null);
    const [selTabId, setSelTabId] = useRecoilState<string>(selectedTabId);
    const [keyword, setKeyword] = useRecoilState<string>(searchKeyword);

    const search = () => {
        const searchKeyword: string = (document.querySelector('#keyword') as HTMLInputElement).value;
        setKeyword(searchKeyword);
    };

    const apiGet = async(type: string, param: string) => {
        const apiUrl: string = 'https://openapi.naver.com/v1/search/' + type + '?query=' + param;
        await fetch(apiUrl, {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
                'X-Naver-Client-Id': CLIENT_ID,
                'X-Naver-Client-Secret': CLIENT_SECRET
            }
        })
        .then((resp: Response) => resp.json())
        .then((resp: IHttpResp) => {
            const typedResp: IHttpResp = {
                ...resp,
                type: type
            }
            setArticles(typedResp);
        });
    };

    useEffect(() => {
        apiGet(selTabId, keyword);
    }, [selTabId, keyword]);

    return (
        <div className="listArea">
            <ul className="listView">
            {
                articles &&
                articles.items &&
                (
                    (articles.type === 'news') ?
                        (articles.items as INewsData[]).map((v: INewsData, inx: number) => {
                            return <NewsRow key={inx} {...v} />
                        })
                    : (articles.type === 'book') ?
                        (articles.items as IBookData[]).map((v: IBookData, inx: number) => {
                            return <BookRow key={inx} {...v} />
                        })
                    : ''
                )
            }
            </ul>
        </div>
    );
};

export default ListView;

 

여기까지가 마무리입니다. 컴파일과 번들링을 수행하고 실행하면 결과를 확인할 수 있습니다. 검색과 탭 기능 모두 정상적으로 동작하네요.

 

[검색] 기능 확인

 

[탭 이동] 확인

300x250
본 포스트에서는 리액트와 타입스크립트를 이용하여 네이버 API로 데이터를 검색해서 목록 화면을 구성해 보겠습니다.
 
 

React + TypeScript Naver API로 목록 만들기

 

지난 포스트에서 탭을 선택하여 토글하는 부분까지 확인을 했습니다. 이번 포스트에서는 선택한 탭에 따라 뉴스, 도서 인터페이스를 하고 결과를 Rendering 하는 부분까지 작성해 보겠습니다.

 

탭을 선택할 때 선택한 탭에 맞는 데이터를 인터페이스 하려면 ListView 컴포넌트가 현재 선택한 탭이 무엇인지를 알아야 합니다. 이렇게 여러 컴포넌트끼리 데이터나 상태를 공유할 때 React에서는 React Redux, Context API, Recoil 등의 상태 관리를 사용합니다.

 

대표적으로 사용하는 라이브러리에 대한 설명은 아래 링크를 참고하면 됩니다. 

 

[React Redux]

Quick Start | React Redux (react-redux.js.org)

 

Quick Start | React Redux

 

react-redux.js.org

 

[Context API]

Context – React (reactjs.org)

 

Context – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

[Recoil]

Recoil (recoiljs.org)

 

Recoil

A state management library for React.

recoiljs.org

 

Context API 같은 경우에는 React에 포함된 기능으로 추가적인 라이브러리 설치는 필요 없습니다. 하지만 여기서는 보일러 플레이트 코드가 가장 짧은 Recoil을 설치하여 사용해 보도록 하겠습니다.

 

아래 명령으로 Recoil을 설치합니다.

D:\workspace\searchNaverApi> yarn add recoil@0.7.3

 

설치 후 package.json의 dependencies 하위를 확인하면 recoil이 정상적으로 포함되어 있음을 알 수 있습니다.

"dependencies": {
    "moment": "2.29.1",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "recoil": "0.7.3"
  },

 

Recoil을 적용하기 위해서 app.tsx 소스코드를 열어 가장 상위 레벨을 <RecoilRoot>로 감싸 줍니다. <RecoilRoot> 하위에 포함된 컴포넌트 끼리는 애플리케이션의 데이터나 상태를 공유할 수 있습니다. app.tsx 소스코드를 아래와 같이 <RecoilRoot>를 최상단에 위치하도록 추가합니다.

import { RecoilRoot } from "recoil";
import ListView from "./component/listview.component";
import TabList from "./component/tablist.component";

const App = () => (
    <RecoilRoot>
        <div>
            <ListView />
            <TabList />
        </div>
    </RecoilRoot>
)

export default App;

 

공유해야 할 상태 값을 atom으로 저장합니다. 관리해야 할 상태나 데이터들이 많다면 atom들을 별도의 파일로 분할하여 관리하겠지만 지금은 선택한 탭이나 다음 포스트에서 다룰 검색어 정도가 전부이므로 그냥 app.tsx에 추가하겠습니다. 이번 포스트에서는 선택한 탭 아이디를 추가합니다.

import { atom, RecoilRoot, RecoilState } from "recoil";
import ListView from "./component/listview.component";
import TabList from "./component/tablist.component";

//ADD :: Start
export const selectedTab: RecoilState<string> = atom({
    key: 'tabId',
    default: 'news'
});
//ADD :: End

const App = () => (
    <RecoilRoot>
        <div>
            <ListView />
            <TabList />
        </div>
    </RecoilRoot>
)

export default App;

 

이제 Tab 컴포넌트에서 selectedTabId atom을 사용하여 상태를 변경하고, 변경된 상태를 참조하여 탭의 UI를 변경하겠습니다. 먼저 탭의 상태를 변경할 수 있도록 useRecoilState Hook을 선언합니다. useRecoilState Hook은 useState Hook과 유사한 방법으로 사용할 수 있습니다.

const [setTabId, setSelTabId] = useRecoilState<string>(selectedTabId);

 

다음은 탭을 Rendering 하는 부분에서 이 상태 변경을 이용해서 탭의 현재 상태를 변경합니다. 변경 전은 컴포넌트 생성 시에 props를 전달 받아 UI를 변경하거나, 탭을 선택하는 사용자의 click action을 받아 해당 Element의 class 속성을 변경하는 방법을 사용했는데, 변경 후에는 현재 atom에 저장된 tabId가 일치하는 탭에만 on class를 할당하는 방법으로 변경하겠습니다.

const changeTab = (id: string) => {
    setSelTabId(id);
};

return (
    <li>
        <a href="#"
            id={tabId}
            className={setTabId === tabId ? 'on' : ''}
            onClick={() => changeTab(tabId)}>
            <span>{tabName}</span>
        </a>
    </li>
)

 

changeTab 함수에서는 RecoilState에 저장한 tabId를 변경하도록 setSelTabId 함수만 호출합니다. UI를 Rendering 할 때 해당 탭의 아이디와 저장한 selTabId를 비교하여 동일한 탭일 경우 "on" class를 부여하고, 다른 탭일 경우에는 부여하지 않습니다.

 

TabList 컴포넌트에서는 탭 목록 선언 시에 on 항목을 삭제합니다. (Tab 컴포넌트에서 상태 값을 참조하는 것으로 변경하였으므로 해당 값은 더 이상 사용하지 않습니다.) TabList 및 Tab 컴포넌트에서 on 항목을 삭제하기 위해서는 ITabInfo 타입에서 먼저 on 항목을 삭제합니다.

interface ITabInfo {
    tabId: string;
    tabName: string;
}

export {
    ITabInfo
}

 

ITabInfo 타입을 변경한 후, 맞추어 변경한 tablist.component.tsx 프로그램의 소스 코드는 아래와 같습니다.

import { useRecoilState } from "recoil";
import { selectedTabId } from "../app";
import { ITabInfo } from "../interface/tabinto.interface";

const Tab = ({ tabId, tabName }: ITabInfo) => {

    const [setTabId, setSelTabId] = useRecoilState<string>(selectedTabId);

    const changeTab = (id: string) => {
        setSelTabId(id);
    };

    return (
        <li>
            <a href="#"
                id={tabId}
                className={setTabId === tabId ? 'on' : ''}
                onClick={() => changeTab(tabId)}>
                <span>{tabName}</span>
            </a>
        </li>
    )
};

const TabList = () => {
    const tabList: ITabInfo[] = [
        {tabName: '뉴스', tabId: 'news'},
        {tabName: '도서', tabId: 'book'}
    ];

    return (
        <div className="tabBox">
            <ul className="tabList">
            {
                tabList.map((v: ITabInfo, inx: number) => {
                    return <Tab key={inx} {...v} />
                })
            }
            </ul>
        </div>
    )
};

export default TabList;

 

 

이제 ListView 컴포넌트에서 상태 값을 이용해 탭 변경 시 뉴스와 도서를 조회하는 부분을 작성해 보겠습니다. 상태 값을 참조해야 하므로 먼저 useRecoilState를 선언합니다.

const [setTabId, setSelTabId] = useRecoilState<string>(selectedTabId);

 

다음은 Naver API를 호출할 때 현재 선택한 탭 아이디를 참조하여 뉴스나 도서 API를 사용하도록 변경합니다. useEffect 부분을 변경하여 탭을 변경할 때마다 API를 다시 호출하도록 변경합니다. (아직 검색어에 대한 처리는 하드코딩을 유지합니다.)

useEffect(() => {
    apiGet(setTabId, '타입스크립트');
}, [setTabId]);

 

마지막으로 UI를 Rendering 부분은 이미 데이터에 따른 분기가 적용되어 있으므로 변경할 내용은 없습니다. 여기까지 작성한 listview.component.tsx 파일의 소스코드는 아래와 같습니다.

import moment from "moment";
import { useEffect, useState } from "react";
import { useRecoilState } from "recoil";
import { selectedTabId } from "../app";
import { IBookData, IHttpResp, INewsData } from "../interface/apidata.interface";

const NewsRow = ({ title, pubDate, description }: INewsData) => {  
    return (
        <li>
            <div className="title">
                <a href="#" dangerouslySetInnerHTML={{__html: title}} />
            </div>
            <div className="cont">
                <span className="date">{moment(pubDate).format('YYYY.MM.DD HH:mm')}</span>
                <span dangerouslySetInnerHTML={{__html: description}} />
            </div>
        </li>
    )
};

const BookRow = ({ image, title, author, description }: IBookData) => {
    return (
        <li>
            <a href="#" className="bookRow">
                <div className="bookImg">
                    <img src={image} />
                </div>
                <div className="bookDesc">
                    <div className="title" dangerouslySetInnerHTML={{__html: title}} />
                    <div className="cont">
                        <span className="author">{author}</span>
                        <span dangerouslySetInnerHTML={{__html: description}} />
                    </div>
                </div>
            </a>
        </li>
    );
};

const ListView = () => {
    const [articles, setArticles] = useState<IHttpResp | null>(null);
    const [setTabId, setSelTabId] = useRecoilState<string>(selectedTabId);

    const apiGet = async(type: string, param: string) => {
        const apiUrl: string = 'https://openapi.naver.com/v1/search/' + type + '?query=' + param;
        await fetch(apiUrl, {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
                'X-Naver-Client-Id': 'z73CVZ80v0SYgrwfwbfz',
                'X-Naver-Client-Secret': 'dFoN8oBtKB'
            }
        })
        .then((resp: Response) => resp.json())
        .then((resp: IHttpResp) => {
            const typedResp: IHttpResp = {
                ...resp,
                type: type
            }
            setArticles(typedResp);
        });
    };

    useEffect(() => {
        apiGet(setTabId, '타입스크립트');
    }, [setTabId]);

    return (
        <div className="listArea">
            <ul className="listView">
            {
                articles &&
                articles.items &&
                (
                    (articles.type === 'news') ?
                        (articles.items as INewsData[]).map((v: INewsData, inx: number) => {
                            return <NewsRow key={inx} {...v} />
                        })
                    : (articles.type === 'book') ?
                        (articles.items as IBookData[]).map((v: IBookData, inx: number) => {
                            return <BookRow key={inx} {...v} />
                        })
                    : ''
                )
            }
            </ul>
        </div>
    );
};

export default ListView;

 

컴파일 및 번들링을 진행한 후 결과를 보면 아래와 같습니다.

D:\workspace\searchNaverApiTs> tsc
D:\workspace\searchNaverApiTs> npm run build

 

[뉴스] 탭을 선택했을 경우

 

[도서] 탭을 선택했을 경우

 

이번 포스트에서는 탭 버튼 동작을 마무리 해보았습니다. Recoil을 사용해서 컴포넌트 간 상태를 공유해서 Tab 컴포넌트에서 설정한 값을 ListView 컴포넌트에서 읽어서 API 호출을 진행해 보았습니다. 다음 포스트에서는 키워드 검색 부분까지 진행해서 프로젝트를 완성해 보겠습니다.

300x250
본 포스트에서는 리액트와 타입스크립트를 이용하여 네이버 API로 데이터를 검색해서 목록 화면을 구성해 보겠습니다.
 
 

React + TypeScript Naver API로 목록 만들기

 

저번 포스트에서 뉴스와 도서 목록을 각각 구성해서 목록 UI를 완성해 보았습니다. 뉴스와 도서를 전환하기 위해서는 소스코드를 수정해가면서 조회해야 하는 부분까지 작성해 보았고, 이번 포스트에서는 [뉴스]와 [도서] 탭을 선택하면 원하는 데이터 목록이 조회되도록 탭 기능을 추가해 보겠습니다.

 

탭은 이전 React 버전과 동일하게 아래와 같은 모양으로 만들어 보겠습니다.

먼저 탭에서 사용할 데이터의 타입을 정의해 보겠습니다. /src/interface/ 아래에 tabinfo.interface. ts 파일을 생성하고 아래와 같이 작성합니다.

interface ITabInfo {
    tabId: string;
    tabName: string;
    on: boolean;
}

export {
    ITabInfo
}

 

탭 아이디와 탭 이름은 string으로 선언하고 탭의 on/off를 나타내는 "on" 항목은 boolean으로 선언합니다.

 

다음에는 /src/component/ 하위에 tablist.component.tsx 파일을 생성합니다. 먼저 탭 한 칸에 대한 컴포넌트를 작성해 보겠습니다. 아래 소스코드를 참고하여 작성합니다.

const Tab = ({ tabId, tabName, on }: ITabInfo) => {
    const changeTab = (id: string) => {
        (document.querySelector('.tabList li a.on') as HTMLAnchorElement).classList.remove('on');
        (document.querySelector('.tabList li a#' + id) as HTMLAnchorElement).classList.add('on');
    };

    return (
        <li>
            <a href="#"
                id={tabId}
                className={on ? 'on' : ''}
                onClick={() => changeTab(tabId)}>
                <span>{tabName}</span>
            </a>
        </li>
    )
};

Tab 컴포넌트는 ITabInfo 타입의 props를 전달 받습니다. 일단 changeTab 함수는 사용자가 탭을 선택했을 때 토글하는 코드만 작성합니다. TypeScript를 사용하지 않고 JavaScript만 사용할 때에는 아래와 같이 작성 했었지만 TypeScript를 사용하면 오류가 발생합니다.

컴파일 타이밍에는 해당 Selector의 결과가 있는지 없는지 알 수가 없어, 위와 같은 오류를 발생 시킵니다. 하여 TypeScript를 사용할 경우 아래 세 가지 방법 중 하나를 선택하여 소스코드를 작성합니다.

 

1. 해당 Selector의 결과에 대한 타입을 명확하게 선언합니다.

(document.querySelector('.tabList li a.on') as HTMLAnchorElement).classList.remove('on');
(document.querySelector('.tabList li a#' + id) as HTMLAnchorElement).classList.add('on');

 

2. ? (Optional) 연산자를 사용해 해당 Selector의 결과가 필수가 아님으로 선언합니다. 이 방법은 컴파일 오류는 피할 수 있지만 의미가 맞지는 않아 보입니다.

document.querySelector('.tabList li a.on')?.classList.remove('on');
document.querySelector('.tabList li a#' + id)?.classList.add('on');

 

3. ! (null forgiving) 연산자를 사용해 해당 Selector는 null이나 undefined가 할당되지 않을 것이라는 것을 표현합니다.

document.querySelector('.tabList li a.on')!.classList.remove('on');
document.querySelector('.tabList li a#' + id)!.classList.add('on');

 

Runtime에 영향을 주는 코드는 아니지만 각 방법 중 가장 의미에 맞는 방법을 사용하시면 될 것 같습니다.

 

 

다음엔 Tab 컴포넌트를 사용해 전체 탭 (뉴스, 도서)을 표시하는 TabList 컴포넌트를 아래와 같이 작성합니다.

const TabList = () => {
    const tabList: ITabInfo[] = [
        {tabName: '뉴스', tabId: 'news', on: true},
        {tabName: '도서', tabId: 'book', on: false}
    ];

    return (
        <div className="tabBox">
            <ul className="tabList">
            {
                tabList.map((v: ITabInfo, inx: number) => {
                    return <Tab key={inx} {...v} />
                })
            }
            </ul>
        </div>
    )
};

export default TabList;

ITabInfo 형식의 배열로 탭 메뉴를 선언한 후 해당 탭의 개수만큼 <Tab /> 컴포넌트를 Rendering 합니다. 이 때 뉴스는 초기에 활성화해야 하므로 "on" 항목은 true로 선언합니다. Tab 컴포넌트에 props를 전달할 때는 스프레드 구문을 사용하여 ITabInfo 형태의 Object를 모두 전달합니다. 

 

tablist.component.tsx 프로그램의 현재까지의 소스코드는 아래와 같습니다.

import { ITabInfo } from "../interface/tabinto.interface";

const Tab = ({ tabId, tabName, on }: ITabInfo) => {
    const changeTab = (id: string) => {
        (document.querySelector('.tabList li a.on') as HTMLAnchorElement).classList.remove('on');
        (document.querySelector('.tabList li a#' + id) as HTMLAnchorElement).classList.add('on');
    };

    return (
        <li>
            <a href="#"
                id={tabId}
                className={on ? 'on' : ''}
                onClick={() => changeTab(tabId)}>
                <span>{tabName}</span>
            </a>
        </li>
    )
};

const TabList = () => {
    const tabList: ITabInfo[] = [
        {tabName: '뉴스', tabId: 'news', on: true},
        {tabName: '도서', tabId: 'book', on: false}
    ];

    return (
        <div className="tabBox">
            <ul className="tabList">
            {
                tabList.map((v: ITabInfo, inx: number) => {
                    return <Tab key={inx} {...v} />
                })
            }
            </ul>
        </div>
    )
};

export default TabList;

 

App 컴포넌트에 TabList 컴포넌트를 표시하기 위해 app.tsx 프로그램에 <TabList /> 컴포넌트를 추가합니다.

import ListView from "./component/listview.component";
import TabList from "./component/tablist.component";

const App = () => (
    <div>
        <ListView />
        <TabList />
    </div>
)

export default App;

 

마지막으로 TabList UI를 위해 아래 css를 main.css 파일에 추가합니다.

.tabBox{position:fixed;bottom:0px;width:100%;height:50px;border-top:1px solid #DDD;background-color:#FFF;}
.tabList{display:table;width:100%;table-layout:fixed;}
.tabList li{display:inline-block;text-align:center;width:49%;}
.tabList li:first-child{border-right:1px solid #DDD;}
.tabList li a{display:block;color:#000;font-size:1.6rem;line-height:48px;}
.tabList li a span{display:inline-block;padding:0px 8px;width:100%;}
.tabList li a.on span{background-color:#999;color:#FFF;font-weight:700;}

 

이제 UI 확인을 위해 컴파일, 번들링 한 후 확인합니다.

D:\workspace\searchNaverApiTs> tsc
D:\workspace\searchNaverApiTs> npm run build

 

확인을 해보면 탭 UI가 잘 표현되고, 토글도 정상적으로 동작합니다.

[도서] 탭 선택 시 토글 확인
[뉴스] 탭 선택 시 토글 확인

 

하지만 아직 선택한 탭에 맞는 API를 호출하여 데이터를 바인드 하는 부분은 작성해 보지 않았습니다. 이번 글이 좀 길어진 관계로 해당 내용들은 다음 포스트에서 진행 해보겠습니다.

300x250

+ Recent posts