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

React + TypeScript Naver API로 목록 만들기

 

저번 포스트에서는 Naver API를 호출하여 응답 데이터를 확인해 보고, 응답 데이터에 맞는 interface를 구성하고 데이터 타입을 정의해서 사용해 보았습니다. TypeScript의 장단점은 분명한 것 같습니다. 어느 정도 러닝 커브가 필요하고 소스코드가 길어지는 단점이 존재하지만, JavaScript의 유연한 성격과 인터프리터 언어라는 이유로 런타임에서 발생하는 수많은 오류를 컴파일 시점에 잡아내지 못하는 단점을 상당 부분 보완해 줍니다.

 

이번 포스트에서는 Naver API의 응답 데이터로 ListView 컴포넌트를 완성하고 UI를 표시하는 부분을 작성해 보겠습니다. 현재 UI 부분은 <ul> 태그만 구성해 놓았습니다. 목록 데이터로 <li>를 반복적으로 채워 넣으면 되나, <li> 내부에도 여러 태그와 데이터를 바인딩 하는 부분들이 들어가고, 뉴스와 도서의 목록 형태가 상이합니다. 이렇게 반복적으로 사용하는 부분은 되도록 별도의 컴포넌트로 분리해서 사용합니다.

 

먼저 뉴스 목록 UI를 표시할 NewsRow 컴포넌트를 아래와 같이 작성합니다.

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>
    )
};

NewsRow의 역할은 props로 뉴스 정보를 전달 받아 <li> 태그를 응답하는 역할을 합니다. NewsRow에서 전달 받는 props의 형태는 INewsData 타입으로 전달 받습니다. 지난 포스트에서 정의한 INewsData 타입은 아래와 같습니다.

 

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

제목, 원문 링크, 링크, 요약, 발행일시 정보로 구성되지만 NewsRow 컴포넌트에서는 제목, 발행일시, 요약 데이터만 사용하므로 props는 위와 같은 형태로 { title, pubDate, description }: INewsData와 같이 정의 했습니다. 만약 다른 정보들을 더 표시하고 싶은 경우 추가적으로 전달 받을 수 있습니다.

 

제목과 요약에는 HTML 데이터가 포함되어 있으므로 dangerouslySetInnerHTML 속성을 사용하여 정의하며, 발생일시는 일시 데이터를 보기 좋게 표시하기 위해 moment 모듈을 이용해서 formatting 합니다. dangerouslySetInnerHTML 속성은 innerHTML로 사용하던 부분인데 React에서는 dangerouslySetInnerHTML를 사용합니다.

 

관련 설명은 React 공식 문서 중 아래 링크를 참고하시면 됩니다.

https://ko.reactjs.org/docs/dom-elements.html

 

DOM 엘리먼트 – React

A JavaScript library for building user interfaces

ko.reactjs.org

NewsRow 컴포넌트를 완성했으면 ListView 컴포넌트에서 조회한 데이터를 NewsRow 컴포넌트에 바인드 합니다. ListView 컴포넌트의 return 부분을 아래와 같이 수정합니다.

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} />
                    })
                : ''
            )
        }
        </ul>
    </div>
);

articles의 타입은 IHttpResp 입니다. articles와 articles.item 속성이 모두 있을 경우 NewsRow를 생성하라는 의미의 소스코드 입니다. 그리고 IHttpResp 정의를 보면 아래와 같이 items의 속성은 INewsData[] | IBookData[] 입니다.

 

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

하여 map으로 반복문 작성 시 articles.items.map((v: INewsData, inx: number) => ); 같은 형식으로 작성하면 TypeScript 컴파일러가 타입 추론 시 v에 대한 타입과 속성이 맞지 않아 아래와 같은 오류가 발생합니다.

 

그래서 위와 같이 as를 사용하여 articles.items as INewsData[] 로 데이터 타입을 명시하여 타입 추론 시 오류가 발생하지 않도록 작성합니다. NewsRow 컴포넌트는 props를 INewsData 형태로 전달받도록 선언하였습니다. 컴포넌트 사용 시에도 동일한 형태의 데이터를 전달할 수 있도록 스프레드 구문으로 아래와 같이 작성합니다.

<NewsRow key={inx} {...v} />

 

여기까지 작성한 후 ListView 컴포넌트를 App 컴포넌트에서 사용하도록 app.tsx 파일을 아래와 같이 수정합니다.

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

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

export default App;

 

잊지말고 다시 컴파일, 번들링을 하고 브라우저로 확인합니다.

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

 

아직 stylesheet를 작성하지 않았으므로 아래와 같은 모양으로 표시됩니다.

모양을 잡아 줄 stylesheet는 React 때와 동일하게 복사해서 사용하겠습니다. 아래 내용을 Copy&Paste 해서 /public/css/main.css 로 작성합니다.

html{font-size:10px;}
html, body, div, span, ul, li, h1, h2, h3, table{margin:0;padding:0;}
body, button, input {font-family:'나눔고딕', 'NanumGothic', 'Dotum', '돋움', 'gulim', '굴림', Helvetica, sans-serif;color:#000;font-size:1.4rem;font-weight:400;}
li {list-style:none;}
a:link, a:hover{text-decoration:none;}

.content{position:absolute;top:52px;bottom:0px;overflow-x:hidden;}

.listArea{width:100%;margin-bottom:50px;}
.listView li{border-bottom:1px solid #DDD;padding:10px 15px;display:inline-block;}
.listView li .title {padding:10px 0px;color:#000;font-size:1.6rem;font-weight:400;}
.listView li .title a{color:#000;}
.listView li .cont span{display:inline-block;color:#555;}
.listView li .cont span.date{margin-bottom:5px;color:#999;font-size:1.2rem}

 

css 파일까지 생성한 프로젝트의 현재 구조는 아래와 같습니다.

작성한 main.css를 /public/index.html 파일에 추가합니다.

<!DOCTYPE html>
<html>
    <head>
        <title>Search Naver API</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">
        <link rel="stylesheet" href="./css/main.css" />
    </head>
    <body>
        <div id="app"></div>
        <script type="text/javascript" src="./js/build.js"></script>
    </body>
</html>

 

stylesheet까지 반영 후 UI를 다시 확인해 보면 아래와 같이 잘 표시됩니다.

 

뉴스 목록까지 확인을 했으니 이번에는 도서 목록을 표시하는 BookRow 컴포넌트를 아래와 같이 작성합니다.

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>
    )
};

 

NewsRow와 비교해서 별 다른 특이한 사항은 없으므로 별도 설명은 생략하겠습니다. BookRow 컴포넌트도 정상적으로 동작하는지 확인하기 위해 ListView의 Rendering 부분에 articles.type이 book일 경우 BookRow를 사용하도록 조건을 추가합니다.

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>
);

 

apiGet을 호출하는 useEffect 부분의 조회 조건을 아래와 같이 변경합니다.

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

 

도서 목록을 위한 stylesheet를 main.css 파일에 추가합니다.

.listView li .bookImg{position:relative;float:left;}
.listView li .bookDesc{float:left;width:100%;}
.listView li a.bookRow{padding:0px 10px 0px 100px;display:block;height:auto;}
.listView li a.bookRow .bookImg{margin-left:-100px;margin-top:10px;}
.listView li a.bookRow .cont span.author{margin-bottom:8px;color:darkblue;}

 

다시 컴파일, 번들링을 수행한 후 화면을 확인할 수 있습니다.

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

이번 포스트에서는 뉴스와 도서의 목록을 위한 컴포넌트를 작성해 보았습니다. 타입에 따라 목록에서 분기하여 데이터에 맞는 목록을 표시하도록 작성해 보았고, 각 UI에 맞는 stylesheet를 작성해 보았습니다.

 

다음 포스트에서는 뉴스/도서를 선택할 수 있는 탭 버튼을 작성해서 탭 전환에 따라 목록을 표시하는 부분을 작성해 보겠습니다.

 

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

React + TypeScript Naver API로 목록 만들기

 

저번 포스트까지 TypeScript로 Hello World를 출력하는 프로그램을 작성해보고 컴파일, 번들링을 진행해서 브라우저에서 실행하는 부분까지 실습을 해보았습니다. 이번 포스트에서는 Naver 검색 API를 호출하는 부분의 소스코드를 변경하여 TypeScript 기반으로 작성해 보겠습니다.

 

이번에도 동일하게 Naver Open API를 사용해 보겠습니다. Naver API는 아래 URL에서 신청하시면 됩니다.

https://developers.naver.com/products/service-api/search/search.md

 

검색 - SERVICE-API

검색 NAVER Developers - 검색 API 소개 웹, 뉴스, 블로그 등 분야별 네이버 검색 결과를 웹 서비스 또는 모바일 앱에서 바로 보여 줄 수 있습니다. 또한 ’OO역맛집’과 같은 지역 검색을 할 수도 있으

developers.naver.com

Naver 검색 API는 아래와 같은 형식으로 데이터를 응답합니다. (아래는 뉴스 API 입니다.)

 

정리하면 뉴스 검색 API, 도서 검색 API는 아래와 같은 형식이며 items 항목에 각자의 레이아웃으로 데이터를 응답합니다.

{
    lastBuildDate: '',
    total: 0,
    start: 0,
    display: 0,
    items: []
}

 

items 항목에는 뉴스, 도서 구분에 따라 아래와 같은 데이터를 응답니다.

 

[뉴스 API 데이터]

{
    title: '',
    originallink: '',
    link: '',
    description: '',
    pubDate: ''
}

 

[도서 API 데이터]

{
    title: '',
    link: '',
    image: '',
    author: '',
    price: '',
    discount: '',
    publisher: '',
    pubdate: '',
    isbn: '',
    description: ''
}

TypeScript는 각 변수의 데이터와 목적에 맞는 데이터 타입을 선언해야 합니다. 데이터 타입은 interface를 사용해서 선언하겠습니다. /src/interface 라는 경로를 생성하고 apidata.interface.ts 파일을 생성합니다.

 

먼저 뉴스 API에 대한 데이터 타입을 선언합니다. 뉴스 API의 응답 데이터는 모두 문자 형태이므로 string 으로 선언합니다.

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

 

다음 도서 API에 대한 데이터 타입을 선언합니다. 뉴스 API 마찬가지로 모두 문자 형태이므로 string 으로 선언합니다.

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

마지막으로 뉴스 API와 도서 API가 공통으로 사용하는 응답을 IHttpResp 라는 이름으로 선언합니다. 건수를 나타내는 항목의 데이터 유형은 number로 선언하고, items 항목은 뉴스나 도서 데이터를 목록 형태로 응답하므로 각 데이터의 배열을 or(|) 기호로 연결하여 작성합니다.

 

그리고 Naver API 응답부에는 없었던 type이라는 변수를 하나 추가합니다. 우리는 뉴스 데이터와 도서 데이터를 모두 IHttpResp 라는 형태로 수신하는데, 현재 인터페이스의 응답이 어떤 API에 대한 응답인지를 식별할 필요가 있습니다. interface가 아니라 class로 생성해서 사용한다면 resp instanceof IHttpResp 같은 형태로 식별이 가능하겠지만 우리는 단순히 데이터의 타입만 정의했으므로 instanceof 로는 현재 데이터가 뉴스 데이터인지, 도서 데이터인지 식별이 불가능합니다.

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

 

배열 요소는 []로 선언해도 되고, 아래와 같이 Array로 선언할 수도 있습니다.

items: Array<INewsData> | Array<IBookData>;

 

마지막으로 3가지 유형의 interface를 export 합니다.

export {
    IHttpResp,
    INewsData,
    IBookData
}

 

apidata.interface.ts 파일의 전체 소스코드는 아래와 같습니다.

interface IHttpResp {
    lastBuildDate: string;
    total: number;
    start: number;
    display: number;
    type: string;
    items: 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 {
    IHttpResp,
    INewsData,
    IBookData
}

 

다음은 React와 마찬가지로 /src/component 경로를 생성하고 listview.component.tsx 파일을 생성합니다. 먼저 ListView 컴포넌트를 생성하고 useState Hook으로 상태를 선언하고 초기 값은 null로 설정합니다. React만 사용해서 개발할 때는 articles, setArticles 튜플에 대한 데이터 타입은 선언하지 않았으나 TypeScript 기반으로 개발하므로 데이터 타입을 선언합니다. 전체 데이터를 의미하는 IHttpResp를 사용하며 초기에는 빈 값이므로 null로 허용하고, 초기 값은 null로 선언합니다.

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

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

export default ListView;

 

목록에 필요한 데이터는 Naver API를 호출해서 받아와야 하므로 React로 개발할 때와 동일하게 fetch API를 사용하여 Naver API를 GET 방식으로 호출하는 apiGet이라는 이름의 함수를 하나 정의합니다.

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);
    });
};

각 변수의 데이터 타입을 선언합니다. fetch API의 결과는 then()을 통해 Response 타입으로 받을 수 있으며, Response의 json() API를 통해 Response body를 추출할 수 있습니다. 이렇게 추출한 응답부는 위에서 선언한 IHttpResp 형태가 됩니다.

받아온 IHttpResp 데이터를 setArticles로 상태를 저장할 때 type 속성을 추가해서 상태를 저장합니다. ...resp 같은 형태를 spread 구문 (전개 구문)이라고 하며, 아래 MDN 링크를 참고하시면 됩니다.

 

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/Spread_syntax

 

전개 구문 - JavaScript | MDN

전개 구문을 사용하면 배열이나 문자열과 같이 반복 가능한 문자를 0개 이상의 인수 (함수로 호출할 경우) 또는 요소 (배열 리터럴의 경우)로 확장하여, 0개 이상의 키-값의 쌍으로 객체로 확장시

developer.mozilla.org

 

이제 useEffect를 추가하여 apiGet 함수로 뉴스 검색을 호출합니다. 코로나가 다시 2만명 대가 되어서 걱정이네요. "코로나" 키워드로 검색해 보겠습니다.

useEffect(() => {
    apiGet('news', '코로나');
}, []);

 

이번 포스트에서는 UI 부분까지는 진행하지 않을 예정이므로 Rendering 하는 부분은 그냥 <ul> 태그를 하나 추가만 해놓고, 다음 포스트에서 데이터를 바인드 해보겠습니다.

return (
    <div className="listArea">
        <ul className="listView">

        </ul>
    </div>
);

 

현재까지 진행한 listview.component.tsx 파일의 전체 소스코드는 아래와 같습니다.

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

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

    const apiGet = async(type: string, param: string): Promise<void> => {
        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('news', '코로나');
    }, []);

    return (
        <div className="listArea">
            <ul className="listView">

            </ul>
        </div>
    );
};

export default ListView;

 

소스코드 작성을 마치셨으면 컴파일과 번들링을 수행합니다.

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

 

프로젝트를 기동합니다.

D:\workspace\searchNaverApiTs>yarn start

 

 

실행을 위한 환경 설정은 지난 포스트에서 모두 마쳤으니 한 번 실행해 보겠습니다. Local 데몬에서 Naver API를 호출하는 방식이니 Chrome 브라우저의 CORS 차단을 해제하고 실행해야 합니다. 관련된 내용은 이전 아래 포스트를 참고해주세요.

 

https://redballs.tistory.com/entry/React-%EA%B8%B0%EC%B4%88-%EB%AA%A9%EB%A1%9D-%EB%A7%8C%EB%93%A4%EA%B8%B0-04-ListView-%EB%A7%8C%EB%93%A4%EA%B8%B0?category=564726 

 

React 기초 (목록 만들기) 04 - API 호출

저번 포스트에서는 간단하게 "Hello World"를 출력하는 프로그램을 작성하고 실행시켜서 확인해 보았습니다. 이번 포스트에서는 현재 과정의 주 목적인 ListView를 구성하기 위해 Naver News API를 호출

redballs.tistory.com

 

잘 따라하셨다면 아래와 같이 Network 탭에 인터페이스 이력을 확인할 수 있습니다.

저번 포스트와 동일한 내용을 TypeScript 기반으로 변경하여 API 인터페이스까지 진행해 보았습니다. 다음 포스트에서는 인터페이스한 데이터를 목록에 바인드해서 목록 UI 구성을 마무리 해보겠습니다.

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

React + TypeScript Naver API로 목록 만들기

 

저번 포스트에서는 tsc 명령으로 TypeScript를 JavaScript로 컴파일 해보았고, 컴파일 한 JavaScript를 html에 포함시켜 브라우저에서 실행시켜 보는 부분까지 진행했습니다. 물론 실행은 되지 않았고 아래와 같이 오류가 발생한 상태입니다.

위의 오류는 CommonJS 모듈 시스템으로 컴파일된 프로그램을 브라우저가 해석할 수 없어서 발생한 오류이며, 우리는 이런 문제들을 해결하기 위해 TypeScript를 사용할 때에는 Webpack 같은 도구로 번들링하여 사용합니다.

 

그럼 번들링한 소스를 사용하여 실행하기 위한 설정을 추가해 보겠습니다. 먼저 tsconfig.json 파일에서 outDir 속성을 ./dist로 변경합니다. /public 경로 아래는 실제로 배포할 번들링한 프로그램만을 올리기 위해 컴파일 단계의 소스는 별도 경로로 옮깁니다.

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

 

/public 아래 dist 폴더는 이제 삭제합니다. 삭제 후 다시 tsc 명령을 입력하여 컴파일을 수행하면 /dist 아래 컴파일된 JavaScript 파일이 생성됩니다.

다음은 package.json 파일에 build script를 추가합니다.

{
  "name": "searchNaverApiTs",
  "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.14",
    "@types/react-dom": "^18.0.5",
    "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": {
    "start": "NODE_ENV=development webpack-dev-server",
    //추가
    "build": "webpack"
  }
}

 

build script를 추가하고 나면 아래 명령어로 번들링이 가능합니다.

npm run build

 

아직 번들링한 JavaScript의 파일명과 저장할 경로를 지정하지 않았습니다. 해당 내용은 webpack.config.js에서 설정할 수 있습니다. module.export 하위에 아래와 같이 output object를 선언해서 설정할 수 있습니다.

output: {
    path: path.resolve(__dirname, './public/js'),
    filename: 'build.js'
}

 

그리고 이제 번들링할 JavaScript의 시작점이 ./dist/main.js 로 변경되므로 아래와 같이 entry 속성도 변경합니다.

entry: {
    main: ['./dist/main.js']
}

 

여기까지 진행했으면 npm run build 명령을 입력하여 번들링을 진행합니다.

npm run build

 

틀린 부분이 없다면 아래와 같이 outDir로 지정한 ./public/js 경로 아래 build.js 와 build.js.LICENSE.txt 파일이 생성됩니다.

 

 

이제 생성한 build.js 파일을 사용하기 위해 index.html 에서 script 경로를 ./js/build.js 로 변경합니다.

<!DOCTYPE html>
<html>
    <head>
        <title>Search Naver API</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>

 

다시 http://localhost:8080 을 브라우저에서 실행해 보면 "Hello World"를 볼 수 있고, 소스코드 역시 번들링된 JavaScript 소스코드를 볼 수 있습니다.

간단하게 tsx 파일을 작성하고, 컴파일하고, 번들링하는 과정을 실습해 보았습니다. 다음 포스팅에서는 Naver API를 호출하는 부분에 타입 선언을 추가하여 TypeScript로 변경해서 컴파일, 번들링 해보도록 하겠습니다.

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

React + TypeScript Naver API로 목록 만들기

 

저번 포스트에서는 프로젝트 경로를 생성하고 개발에 필요한 모듈들을 설치해 보았습니다. React 외에 TypeScript를 사용하기 위한 준비도 마쳤습니다. 이제 VSCode에 프로젝트를 반입해서 간단하게 "Hello World"를 출력하는 프로그램을 작성해 보겠습니다.

 

VSCode로 프로젝트 열기

먼저 빈 VSCode 창을 하나 열고 [폴더 열기] 버튼을 클릭합니다.

만들어 둔 searchNaverApiTs 폴더를 선택하여 엽니다.

해당 폴더를 선택하면 VSCode에 프로젝트가 아래와 같이 반입됩니다.

 

프로젝트 구조 생성

프로젝트 하위에 폴더를 두 개 만들어 보겠습니다.

 

[./public]

public 이라는 폴더에는 html, css, image 등 정적인 Resources를 관리합니다.

 

[./src]

src 라는 폴더에는 React 기반으로 개발하는 JavaScript 소스 코드를 관리합니다.

 

일단 public 경로는 비워두고 이번에는 src 경로부터 작성해 보겠습니다. 먼저 애플리케이션을 초기화 할 main.tsx 파일을 ./src 경로 하위에 생성하고 아래와 같이 작성합니다. 확장자를 .js 가 아닌 .tsx 로 작성합니다.

import ReactDOM from 'react-dom';

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

<App /> 컴포넌트가 있는 app.tsx 파일도 아래와 같이 작성합니다.

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

export default App;

VSCode에서 TypeScript로 작성하게 되면 위와 같이 작성했을 때 main.tsx에는 App 컴포넌트를 import 하지 않았으므로 아래와 같은 Syntax Error가 표시됩니다.

오류가 발생한 곳에 커서를 가져가 보면 아래와 같이 오류에 대한 설명이 나오고 VSCode가 바로 수정할 수 있는 방법이 있는 상태라면 [빠른 수정] 이라는 링크가 표시됩니다.

[빠른 수정] 링크를 클릭해보면 App 컴포넌트를 ./app 파일에서 참조하겠다는 처리 방안이 표시되고, 선택할 경우 모듈을 import 시킵니다.

현재까지 작성한 프로젝트의 폴더 및 파일 구조는 아래와 같습니다.

 

컴파일 옵션 설정

TypeScript는 브라우저에서 바로 해석할 수 있는 형태가 아니므로 컴파일 과정을 거쳐 JavaScript로 변환이 필요합니다. 컴파일은 tsc 명령을 통해서 수행할 수 있습니다. 아래 명령어를 실행시키면 tsc 명령 형식 및 옵션을 조회할 수 있습니다.

tsc -help

컴파일을 할 때마다 tsc 명령에 옵션을 주고 컴파일 할 수도 있겠지만 일관성 있는 컴파일 정책을 위해서 tsconfig.json 파일을 생성하고 프로젝트에서 사용할 컴파일 옵션을 정의합니다. tsconfig 파일에 설정할 수 있는 옵션은 정말 다양하게 있지만 우리는 아래 정도의 옵션만으로 컴파일을 진행하겠습니다.

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

전체 옵션에 대한 내용은 아래 링크의 공식 문서를 참고하시면 됩니다.

https://www.typescriptlang.org/tsconfig

 

TSConfig Reference - Docs on every TSConfig option

From allowJs to useDefineForClassFields the TSConfig reference includes information about all of the active compiler flags setting up a TypeScript project.

www.typescriptlang.org

사용한 옵션들에 대해서는 간략하게 짚어 보겠습니다.

옵션 설명
jsx  JSX 코드를 컴파일 하는 방법을 설정합니다.
target  컴파일 할 ECMAScript 버전을 설정합니다.
module  사용할 모듈 시스템을 설정합니다.
outDir  컴파일 결과물인 .js 파일을 생성할 위치를 설정합니다.
rootDir  컴파일 Source의 루트 경로를 설정합니다.
esModuleInterop  CommonJS와 ES Modules 간의 상호 운용성이 생기게 할 지 여부를 설정합니다.
forceConsistentCasingInFileNames  사용할 파일명을 정확히 작성할 지 여부를 설정합니다.
strict  엄격한 타입 검사를 수행할지 설정합니다. (TypeScript를 사용하는 이유이니 true를 권장합니다.)
skipLibCheck  선언 파일 (.d.ts)의 타입 확인 여부를 설정합니다.

 

여기까지 진행한 다음 tsc 명령을 한 번 수행해 봅니다.

D:\workspace\searchNaverApiTs> tsc

컴파일 옵션에서 rootDir을 ./src, outDir을 ./public/dist로 설정했으므로 /src 아래 생성한 app.tsx 파일과 main.tsx 파일이 컴파일 되어 ./public/dist 경로 하위에 js 파일을 생성한 것을 확인할 수 있습니다.

TypeScript 컴파일러가 tsconfig.json에 선언한 옵션에 따라 TypeScript를 es5 기준의 JavaScript로 변환해서 브라우저에서 실행할 수 있는 상태로 변경했습니다. 단순히 tsc 명령을 수행하면 실행하는 경로 하위에 있는 모든 *.tsx 및 *.ts 파일을 컴파일 합니다. 

 

원하는 파일만 컴파일하기 위해서는 tsc [파일명] 형식으로 실행하는 방법도 있고, tsconfig.json 파일에 compilerOptions와 동일한 레벨에 files를 선언해서 관리할 수도 있습니다.

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "target": "es5",
    "module": "commonjs",
    "outDir": "./public/dist",
    "rootDir": "./src",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "files": [
    "main.tsx",
    "app.tsx"
  ]
}

여기까지 진행했다면 한 번 실행해 보겠습니다. ./public 하위 경로에 index.html 파일을 생성하고 /dist/main.js 파일을 참조합니다.

<!DOCTYPE html>
<html>
    <head>
        <title>Search Naver API</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="./dist/main.js"></script>
    </body>
</html>

그리고 전과 동일하게 webpack.config.js 파일을 현재 환경에 맞춰서 생성합니다.

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

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

package.json 파일에도 start script를 추가합니다.

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

위 내용까지 모두 마쳤다면 yarn start로 프로젝트를 구동합니다.

D:\workspace\searchNaverApiTs> yarn start
yarn run v1.22.17
$ NODE_ENV=development webpack-dev-server
<i> [webpack-dev-server] Project is running at:
<i> [webpack-dev-server] Loopback: http://localhost:8080/, http://127.0.0.1:8080/
<i> [webpack-dev-server] Content not from webpack is served from './public' directory
[BABEL] Note: The code generator has deoptimised the styling of /Users/a20201022/Documents/searchNaverApiTs/node_modules/react-dom/cjs/react-dom.development.js as it exceeds the max of 500KB.
asset main.js 1.16 MiB [emitted] (name: main)
runtime modules 27 KiB 12 modules
modules by path ./node_modules/ 1.06 MiB
  modules by path ./node_modules/webpack-dev-server/client/ 62.1 KiB 12 modules
  modules by path ./node_modules/webpack/hot/*.js 4.4 KiB 4 modules
  modules by path ./node_modules/react/ 120 KiB 4 modules
  modules by path ./node_modules/html-entities/lib/*.js 115 KiB 4 modules
  modules by path ./node_modules/scheduler/ 29.4 KiB 4 modules
  modules by path ./node_modules/react-dom/ 732 KiB 2 modules
  ./node_modules/ansi-html-community/index.js 4.26 KiB [built] [code generated]
  ./node_modules/events/events.js 14 KiB [built] [code generated]
  ./node_modules/object-assign/index.js 2.17 KiB [built] [code generated]
modules by path ./public/dist/*.js 753 bytes
  ./public/dist/main.js 488 bytes [built] [code generated]
  ./public/dist/app.js 265 bytes [built] [code generated]
webpack 5.66.0 compiled successfully in 2314 ms

오류 없이 잘 구동되었다면 http://localhost:8080 으로 접속하여 확인을 합니다. "Hello World"가 출력되면 좋겠지만 아래와 같은 오류가 발생하고 화면에는 아무것도 출력되지 않습니다.

 

이어지는 내용부터는 다음 포스트에서 다루겠습니다. 추가 설정을 마치고 화면에 "Hello World"를 출력하는 부분까지 다음 포스트에서 진행해 보겠습니다.

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

React + TypeScript Naver API로 목록 만들기

 

이전 포스트에서 개발할 내용을 확인해 보았으니, 이제 개발에 필요한 환경을 구성하고 프로젝트를 생성해 보겠습니다. 이전 React 기초 포스트와 동일한 내용이지만 이 포스트만 보시는 분들을 위해 다시 한 번 작성합니다.

 

코드 편집기

코드 편집기는 손에 익은 편한 도구를 사용하면 됩니다. 여기서는 요즘 가장 많이 사용하고 있는 듯 한 VSCode를 사용하겠습니다. VSCode는 아래의 링크에서 각자의 환경 (Windows, MAC)에 맞는 버전을 다운로드 받아서 설치하시면 됩니다.

https://code.visualstudio.com/

 

Visual Studio Code - Code Editing. Redefined

Visual Studio Code is a code editor redefined and optimized for building and debugging modern web and cloud applications.  Visual Studio Code is free and available on your favorite platform - Linux, macOS, and Windows.

code.visualstudio.com

Node.js

JavaScript 애플리케이션의 Package 의존성을 관리하기 위해 npm이나 yarn을 사용합니다.  npm이나 yarn은 Node.js 기반으로 동작하므로 Node.js 인스톨러를 다운로드하여 설치합니다. 아래의 링크에서 각자의 환경 (Windows, MAC)에 맞는 버전을 다운로드 받아서 설치하시면 됩니다.

https://nodejs.org/ko/download/

 

다운로드 | Node.js

Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

nodejs.org

npm과 yarn의 차이, 장단점에 대해서는 TOSS의 기술 블로그에서 재미나게 설명하고 있으니 참고하시면 될 듯 합니다. 아래 링크 첨부합니다.

https://toss.tech/article/node-modules-and-yarn-berry

 

node_modules로부터 우리를 구원해 줄 Yarn Berry

토스 프론트엔드 레포지토리 대부분에서 사용하고 있는 패키지 매니저 Yarn Berry. 채택하게 된 배경과 사용하면서 좋았던 점을 공유합니다.

toss.tech

Yarn

Node.js까지 설치를 했다면 터미널에서 아래 명령어를 실행하여 Yarn을 설치합니다.

npm install -g yarn

프로젝트 초기화

Yarn까지 설치했다면 프로젝트 루트로 사용할 workspace 아래 프로젝트 디렉토리를 하나 생성합니다. 저번 포스트의 프로젝트 디렉토리가 searchNaverApi 였으므로 이번에는 뒤에 TypeScript라는 의미로 Ts를 붙여 searchNaverApiTs로 명명하겠습니다.

 

workspace 하위에 searchNaverApiTs 디렉토리를 생성하고 해당 디렉토리로 이동합니다.

mkdir searchNaverApiTs
cd searchNaverApiTs

해당 디렉토리에 프로젝트 설정 파일을 생성합니다. 프로젝트 설정 파일은 package.json 이며 yarn init 명령을 수행해서 생성해도 되고 그냥 편집기로 json 파일을 하나 생성해서 작성해도 됩니다. yarn init 명령을 입력하면 아래 정보를 입력하라고 나오는데 성실하게 입력하셔도 되고, 나중에 채워도 되는 내용이니 그냥 엔터 키로 넘어가도 상관 없습니다.

D:\workspace\searchNaverApiTs> yarn init

name (searchNaverApiTs) : 
version (1.0.0) :
description :
entry point (index.js) : 
repository url : 
author : 
license (MIT) :
private :

일단 모두 입력 or skip 하게 되면 프로젝트 하위 경로에 package.json 파일이 하나 생성됩니다.

기본 설정으로 만들고 싶을 경우 yarn init -y 명령으로 생성하면 별도 질문 없이 package.json이 기본 설정으로 생성됩니다.

 

현재까지 진행한 프로젝트의 파일 구조는 아래와 같습니다.

searchNaverApiTs
 └── package.json

 

 

모듈 설치

현재 프로젝트 개발에 필요한 모듈들을 설치합니다. 각 모듈의 버전 별로 호환성 문제나 코드가 상이할 수 있으므로 설치할 버전을 지정하여 설치하겠습니다.

모듈을 설치할 때는 yarn add 명령으로 설치합니다. yarn add 명령의 형식은 아래와 같습니다.

yarn add [packages ...][flags]

yarn help 명령을 입력하면 flags에 대한 상세한 내용을 조회할 수 있지만 이번에는 의존성에 대한 부분만 간략하게 보고 넘어가겠습니다. yarn help add 명령으로 조회한 설명은 아래와 같습니다.

D:\workspace\searchNaverApiTs> yarn help add
...
-D, --dev         save package to your `devDependencies`
-P, --peer        save package to your `peerDependencies`
-O, --optional    save package to your `optionalDependencies`
...

위 내용 중 --dev (개발 의존성)와 아무것도 입력하지 않았을 경우인 일반 의존성으로만 구분하여 설치를 진행하겠습니다. 간단하게 일반 의존성은 런타임에서 사용하는 모듈을 설치할 때 사용하고, 개발 의존성 (--dev)은 빌드 타임(babel이나 webpack 등 개발 workflow에서 사용)에서만 사용하는 모듈을 설치할 때 사용합니다.

 

이번 프로젝트에서 사용할 모듈들을 설치해 보겠습니다.

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

저번 프로젝트와 동일하게 위의 모듈들을 설치합니다. 이번 프로젝트에서는 TypeScript를 사용하려고 하므로 위의 모듈에 추가적으로 TypeScript를 설치합니다.

D:\workspace\searchNaverApiTs> yarn add --dev typescript
D:\workspace\searchNaverApiTs> yarn add --dev @types/react @types/react-dom

기본적인 React package 및 compile, bundling을 위한 도구를 설치하는 과정입니다. react 및 react-dom을 제외하고는 런타임에 필요한 모듈이 아니므로 개발 의존성으로 설치합니다. 개발 시에 필요한 모듈은 추가적으로 날짜 formatting을 위해 moment 정도만 설치합니다. (나중에 추가적으로 필요한 모듈이 있으면 그때그때 설치하면 됩니다.)

yarn add moment@2.29.1

설치를 한 모듈은 package.json 파일에서 의존성 관리를 합니다. 설치를 완료한 후 package.json 파일을 확인해 보면, 아래와 같이 개발 의존성으로 설치한 모듈들은 devDependencies 하위에, 일반 의존성으로 설치한 모듈들은 dependencies 하위에 관리되고 있음을 알 수 있습니다.

{
  "name": "searchNaverApiTs",
  "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.14",
    "@types/react-dom": "^18.0.5",
    "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"
  }
}

여기까지 작업을 완료했다면 현재 프로젝트 구조는 아래와 같습니다.

다음 편에서는 프로젝트 구조를 잡아보고 .ts 파일을 컴파일하여 화면에 "Hello World"를 간단하게 출력해 보도록 하겠습니다.

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

React + TypeScript Naver API로 목록 만들기

우리가 사용하는 JavaScript라는 언어는 동적 타입의 인터프리터 언어입니다. 따라서 별도로 컴파일이라는 과정을 거치지 않으며 작성한 코드를 브라우저가 실행하는 런타임에 오류가 발생합니다. 작성한 코드가 그대로 브라우저에서 해석되어서 실행된다는 것은 개발의 편리함이라는 관점에서 보면 아주 좋지만, 프로그램 작성 단계에서는 타입 등에 대한 오류를 걸러내지 못한다는 단점도 있습니다.

 

2012년에 마이크로소프트는 JavaScript에 정적 타입을 추가한 형태인 TypeScript를 발표했습니다. TypeScript는 정적 타입의 컴파일 언어로 코드 작성 시 목적에 맞는 타입을 선언하고 이에 맞지 않을 경우 컴파일 단계에서 오류를 발생시킵니다. TypeScript는 역사가 그리 길지 않은 언어이지만 강력한 생태계를 가지고 성장하고 있습니다.

 

저번 포스팅에서는 Naver 검색 API를 사용하여 뉴스와 도서를 검색하는 애플리케이션을 JavaScript 기반으로 작성해 보았습니다. 이번 포스팅에서는 동일한 애플리케이션을 TypeScript 기반으로 작성해 보겠습니다.

 

이번 포스팅부터 보시는 분들을 위해 개발할 애플리케이션의 Concept를 간단하게 설명합니다.

아래 그림과 같이 뉴스와 도서를 검색할 수 있는 애플리케이션을 작성합니다. 탭 버튼으로 조회할 카테고리를 결정할 수 있고 검색어를 입력하면 해당 키워드에 맞는 뉴스나 도서 목록을 검색할 수 있습니다.

 

[뉴스] 검색

[도서] 검색

다음 포스트에서는 React + TypeScript로 개발하기 위한 프로젝트를 생성해 보겠습니다.

300x250

+ Recent posts