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

저번 포스팅까지 해서 목록을 구성하고 탭 메뉴를 만들어서 탭을 전환할 때마다 탭에 맞는 Naver API를 호출하도록 해보았습니다. 현재까지 작업한 파일 및 폴더 구조는 아래와 같습니다.

먼저 만들어 볼 UI를 확인해 보겠습니다. 01. Concept 포스팅에서 보면 아래와 같습니다.

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

 

/src/component/ 경로에 searchbar.component.jsx 파일을 생성하고 아래와 같이 UI Rendering 부분을 작성합니다.

import React from "react";

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

export default SearchBar;

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

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

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

const App = () => {
    return (
        <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;}

여기까지 하면 검색에 사용할 컴포넌트를 생성하고 페이지에 컴포넌트를 추가했고, 컴포넌트의 모양을 잡아주는 stylesheet도 만들었습니다. 실행시켜 보면 아래와 같은 화면을 확인할 수 있습니다.

 

UI를 어느 정도 만들었으니 이제 검색 기능을 구현해 보겠습니다. 먼저 app.js 에 검색 키워드 상태 값 지정에 필요한 atom을 하나 추가로 생성합니다. 기본 검색어는 "코로나"로 지정했습니다.

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

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

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

const App = () => {
    return (
        <RecoilRoot>
            <div>
                <SearchBar />
                <ListView />
                <TabList />
            </div>
        </RecoilRoot>
    )
};

export default App;

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

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

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

    const search = () => {
        const searchKeyword = document.querySelector('#keyword').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;

값을 저장하는 부분까지 진행했으니 이제 ListView 컴포넌트에서 저장한 값을 추출해서 사용하도록 구현합니다. 먼저 위와 동일하게 RecoilState를 하나 더 선언합니다.

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

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

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

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

const ListView = () => {

    const [articles, setArticles] = useState(null);
    const [selTabId, setSelTabId] = useRecoilState(selectedTabId);
    const [keyword, setKeyword] = useRecoilState(searchKeyword);

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

    };

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

    return (
        <div className="listArea">
            <ul className="listView">
            {
                articles &&
                (selTabId === 'news') ?
                    articles.map((v, inx) => {
                        return <NewsRow key={inx} row={v} />
                    })
                : (selTabId === 'book') ?
                    articles.map((v, inx) => {
                        return <BookRow key={inx} row={v} />
                    })
                : ''
            }
            </ul>
        </div>
    );
}

여기까지가 끝인 것 같습니다. 검색을 해보고 탭 이동을 해보면 검색 결과가 정상적으로 잘 표시됨을 확인할 수 있습니다.

 

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

저번 포스팅까지 해서 뉴스와 도서 목록을 각각 구성해서 UI까지 만들어 보았습니다. 지금은 뉴스와 도서를 번갈아가며 조회하려면 소스코드를 수정해가면서 조회해야 합니다. 이번 포스팅에서는 [뉴스]와 [도서] 탭을 만들어서 각 탭을 선택하면 원하는 목록이 조회되도록 해보겠습니다.

 

01. Concept 포스트에서 이야기한대로 탭은 화면 하단에 아래와 같은 모양으로 만들어 보겠습니다.

/src/component/ 하위에 tablist.component.jsx 파일을 하나 생성합니다. 이 파일에 탭을 구성하여 보겠습니다. 먼저 탭 한 칸에 대한 컴포넌트를 만들어 보겠습니다.

const Tab = (props) => {
    const tabName = props.tab.tabName;
    const on = props.tab.on;
    const tabId = props.tab.id;

    const changeTab = (id) => {
        document.querySelector('.tabList li a.on').classList.remove('on');
        document.querySelector('.tabList li a#' + id).classList.add('on');
    };

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

Tab 이라는 이름의 class를 생성하고 props를 전달 받습니다. return 하는 UI는 단순합니다. <li> 아래 실제 연결 동작을 담당할 <anchor> 태그를 하나 생성하고 id 속성을 부여합니다. 탭의 초기 구성을 위해 props로 전달 받은 on 속성에 따라 className 속성을 부여합니다.

 

그리고 탭을 선택했을 경우에 동작을 담당하는 changeTab 이라는 함수를 만들고 onClick 속성에 할당합니다. UI 에 표시되는 텍스트는 props로 전달 받은 tabName을 표시합니다.

 

changeTab 함수에서 현재 하는 동작은 on/off 에 대한 class만 변경합니다. 현재 on class가 붙어 있는 <a> 태그에서 on class를 제거하고 현재 입력 받은 탭에 on class를 추가합니다.

 

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

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

    return (
        <div className="tabBox">
            <ul className="tabList" role="tablist">
            {
                tabList.map((v, inx) => {
                    return <Tab key={inx} tab={v} />
                })
            }
            </ul>
        </div>
    )
};

export default TabList;

먼저 tabList 배열을 하나 생성하고 표시할 탭 목록을 작성합니다. 우리는 "뉴스"와 "도서" 두 개의 탭을 사용할 것이므로 두 개를 만들고 초기에는 뉴스 탭을 활성화 시키기위해 뉴스 탭에는 on: true 로 세팅합니다. UI에 Rendering 할 부분은 <ul> 태그 아래 <Tab> 컴포넌트를 넣어 줍니다.

 

tablist.component.jsx의 전체 소스코드는 아래와 같습니다.

import React from "react";

const Tab = (props) => {
    const tabName = props.tab.tabName;
    const on = props.tab.on;
    const tabId = props.tab.id;

    const changeTab = (id) => {
        document.querySelector('.tabList li a.on').classList.remove('on');
        document.querySelector('.tabList li a#' + id).classList.add('on');
    };

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

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

    return (
        <div className="tabBox">
            <ul className="tabList" role="tablist">
            {
                tabList.map((v, inx) => {
                    return <Tab key={inx} tab={v} />
                })
            }
            </ul>
        </div>
    )
};

export default TabList;

이제 전에 만든 ListView 컴포넌트와 TabList 컴포넌트를 같이 사용하기 위해 별도의 프로그램을 하나 생성합니다.

/src/app.js 를 하나 만들고 아래와 같이 작성합니다. 이제부터는 main.js 에서는 app.js의 <App /> 컴포넌트를 사용하고 다른 컴포넌트들의 시작점은 app.js 에서 관리하겠습니다.

import React from "react";
import ListView from "./component/listview.component.jsx";
import TabList from "./component/tablist.component.jsx";

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

export default App;

main.js는 아래와 같이 수정합니다.

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

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

이제 TabList UI에 사용할 stylesheet를 "대강" 추가합니다. 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는 확인할 수 있습니다. UI를 확인해보면 아래와 같습니다.

[뉴스]와 [도서] 탭을 선택하면 아래와 같이 토글이 되는 것을 확인할 수 있습니다.

탭 토글까지 확인되었으면 이제 탭 선택에 따라 뉴스, 도서가 인터페이스 되도록 개발해 보겠습니다. 탭을 선택할 때 선택한 탭에 맞는 데이터를 인터페이스 하려면 ListView 컴포넌트가 현재 선택한 탭이 무엇인지를 알아야 합니다. 이렇게 컴포넌트에서 다른 컴포넌트 등과 데이터나 상태를 공유할 때 React에서는 React Redux, Context API, Recoil 등의 상태 관리를 사용합니다.

 

대표적으로 사용하는 라이브러리에 대한 설명은 아래 링크로 대신합니다.

 

[React Redux]

https://react-redux.js.org/tutorials/quick-start

 

Quick Start | React Redux

 

react-redux.js.org

[Context API]

https://ko.reactjs.org/docs/context.html

 

Context – React

A JavaScript library for building user interfaces

ko.reactjs.org

[Recoil]

https://recoiljs.org/ko/

 

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.js 소스코드를 열어 가장 상위 레벨을 <RecoilRoot>로 감싸 줍니다. <RecoilRoot> 하위에 포함된 컴포넌트끼리는 데이터나 상태를 공유할 수 있습니다. app.js 소스코드는 아래와 같이 됩니다.

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

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

export default App;

관리해야 할 상태나 데이터들이 많다면 별도로 파일을 분할해서 관리하겠지만 지금은 선택된 탭이나 검색 키워드 정도만 관리할 예정이므로 그냥 app.js 에 추가하겠습니다.

import React from "react";
//MODIFY :: Start
import { atom, RecoilRoot } from "recoil";
//MODIFY :: End
import ListView from "./component/listview.component.jsx";
import TabList from "./component/tablist.component.jsx";

//ADD :: Start
export const selectedTabId = atom({
    key: 'tabId',
    default: 'news'
});
//ADD :: End

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

export default App;

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

const [selTabId, setSelTabId] = useRecoilState(selectedTabId);

그 다음 탭을 Rendering 하는 부분에서 이 상태 변경을 이용해서 탭의 현재 상태를 변경하도록 합니다. 변경 전은 컴포넌트 생성 시에 props를 전달 받아 UI를 변경하거나, 탭을 선택할 때 JavaScript로 해당 Element의 class 속성을 변경하는 방법을 사용했었는데 변경 후에는 현재 atom에 저장된 값과 각 탭 아이디가 일치하는지를 확인해서 on/off 처리해 보겠습니다.

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

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

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

 

TabList 컴포넌트에서는 탭 목록 선언 시에 on 항목을 삭제합니다. (상태 값을 참조하는 것으로 변경하였으므로 해당 값은 더 이상 사용하지 않습니다.) 변경한 tablist.component.jsx 파일의 소스코드는 아래와 같습니다.

import React from "react";
import { useRecoilState } from "recoil";
import { selectedTabId } from "../app";

const Tab = (props) => {
    const tabName = props.tab.tabName;
    const tabId = props.tab.id;

    const [selTabId, setSelTabId] = useRecoilState(selectedTabId);

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

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

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

    return (
        <div className="tabBox">
            <ul className="tabList" role="tablist">
            {
                tabList.map((v, inx) => {
                    return <Tab key={inx} tab={v} />
                })
            }
            </ul>
        </div>
    )
};

export default TabList;

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

const [selTabId, setSelTabId] = useRecoilState(selectedTabId);

다음은 Naver API를 호출할 때 해당 탭 아이디를 참조하여 뉴스나 도서 API를 사용하도록 합니다. 그리고 useEffect 부분을 변경하여 탭을 변경할 경우마다 API를 조회하도록 변경합니다. (아직 검색어에 대한 처리는 하드코딩입니다.)

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

마지막으로 UI를 Rendering 하는 부분도 저장된 탭 아이디를 참조해서 뉴스를 조회할 경우 NewsRow를, 도서를 조회할 경우 BookRow 컴포넌트를 사용하여 목록을 표시하도록 변경합니다.

return (
    <div className="listArea">
        <ul className="listView">
        {
            articles &&
            (selTabId === 'news') ?
                articles.map((v, inx) => {
                    return <NewsRow key={inx} row={v} />
                })
            : (selTabId === 'book') ?
                articles.map((v, inx) => {
                    return <BookRow key={inx} row={v} />
                })
            : ''
        }
        </ul>
    </div>
);

아래 그림과 같은 결과를 보실 수 있습니다.

 

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

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

여기까지 작성한 listview.component.jsx 파일의 소스코드는 아래와 같습니다.

import React from "react";
import { useEffect, useState } from "react"
import moment from "moment";
import { useRecoilState } from "recoil";
import { selectedTabId } from "../app";

const NewsRow = (props) => {
    const title = props.row.title;
    const pubDate = moment(props.row.pubDate).format('YYYY.MM.DD HH:mm');
    const desc = props.row.description;

    return (
        <li>
            <div className="title">
                <a href="#" dangerouslySetInnerHTML={{__html: title}}></a>
            </div>
            <div className="cont">
                <span className="date">{pubDate}</span>
                <span dangerouslySetInnerHTML={{__html: desc}} />
            </div>
        </li>
    );
};

const BookRow = (props) => {
    const image = props.row.image;
    const title = props.row.title;
    const author = props.row.author;
    const desc = props.row.description;

    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: desc}} />
                    </div>
                </div>
            </a>
        </li>
    );
};

const ListView = () => {

    const [articles, setArticles] = useState(null);
    const [selTabId, setSelTabId] = useRecoilState(selectedTabId);

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

    };

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

    return (
        <div className="listArea">
            <ul className="listView">
            {
                articles &&
                (selTabId === 'news') ?
                    articles.map((v, inx) => {
                        return <NewsRow key={inx} row={v} />
                    })
                : (selTabId === 'book') ?
                    articles.map((v, inx) => {
                        return <BookRow key={inx} row={v} />
                    })
                : ''
            }
            </ul>
        </div>
    );
}

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

저번 포스트에서 Naver News API를 호출해서 아래 형태로 결과를 받아보는 부분까지 진행해 보았습니다.

인터페이스 결과 데이터의 형태를 확인했으니, 이제 ListView의 UI를 만들어 보겠습니다.

 

ListView 컴포넌트가 Rendering 할 HTML은 아래와 같이 <ul> 태그만 하나 생성해서 return 한 상태입니다. News API를 호출하여 받은 데이터를 <ul> 안에 <li>로 반복해서 넣어주면 될 것 같지만, <li> 내부에도 여러 태그와 데이터를 바인딩하는 부분이 있을 것이고, 뉴스일 경우와 도서일 경우를 분리해서 <li>를 구성해야 합니다.

 

하여 <li> 부분은 별도의 컴포넌트로 작성하겠습니다. 일단 NewsRow 컴포넌트를 아래와 같이 작성합니다.

const NewsRow = (props) => {
    const title = props.row.title;
    const pubDate = moment(props.row.pubDate).format('YYYY.MM.DD HH:mm');
    const desc = props.row.description;

    return (
        <li>
            <div className="title">
                <a href="#" dangerouslySetInnerHTML={{__html: title}}></a>
            </div>
            <div className="cont">
                <span className="date">{pubDate}</span>
                <span dangerouslySetInnerHTML={{__html: desc}} />
            </div>
        </li>
    );
};

NewsRow 컴포넌트는 행에 표시할 데이터를 props로 전달 받습니다. 뉴스 한 건 당 한 행을 표시할 것이므로 props로 전달하는 데이터의 형식은 아래와 같습니다.

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

props로 넘어오는 데이터가 위와 같은 형태이고 row라는 이름으로 전달하도록 하겠습니다. 우리는 이 데이터 중 title(제목), pubDate(작성일시), description(요약) 3가지 데이터를 사용하겠습니다. 이 중 pubDate는 momentJs 모듈을 사용하여  YYYY.MM.DD HH:mm 형식으로 변환해 줍니다.

 

그리고 NewsRow의 return 에는 위의 내용과 같이 Rendering 할 HTML을 작성합니다. 작성하는 내용 중 특이한 부분이 있습니다. JavaScript에서는 innerHTML로 사용하던 부분을 React에서는 dangerouslySetInnerHTML을 사용하여 표현합니다. 관련된 짧은 설명이 React 공식 페이지에 있으므로 아래 링크를 참고하시면 됩니다.

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

 

DOM 엘리먼트 – React

A JavaScript library for building user interfaces

ko.reactjs.org

이제 ListView 컴포넌트에서 조회한 데이터를 NewsRow 컴포넌트를 사용해서 바인드 합니다. ListView 컴포넌트의 return 부분을 아래와 같이 수정합니다.

return (
    <div className="listArea">
        <ul className="listView">
        {
            articles &&
            articles.map((v, inx) => {
                return <NewsRow key={inx} row={v} />
            })
        }
        </ul>
    </div>
);

데이터를 조회해 온 후 setArticles useHook을 사용해서 articles에 값이 바인드 되면 다시 Rendering 을 하게 됩니다. articles 의 수만큼 NewsRow 컴포넌트를 생성하며 row라는 이름으로 뉴스 데이터를 props로 전달합니다. 

 

 

여기까지 작성 후 다시 프로젝트를 기동하여 화면을 확인합니다. 아직 stylesheet를 작성하지 않아 아래와 같은 모양으로 표시됩니다.

이제 모양을 잡아 줄 stylesheet를 작성합니다. 

/public/ 아래 css 폴더를 생성하고 main.css 파일을 아래와 같이 작성합니다. 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 파일까지 생성한 프로젝트 파일 구조는 아래와 같습니다.

그리고 /public/index.html 파일에 main.css 를 추가합니다.

<!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="main.js"></script>
    </body>
</html>

이제 stylesheet까지 작성했으니 다시 한 번 UI를 확인해 보시면 아래와 같이 표시됩니다.

우리는 뉴스만 조회할 것이 아니라 도서 목록도 조회하기로 했습니다. NewsRow에 이어 BookRow 컴포넌트도 한 번 만들어 보겠습니다. 일단 도서 API는 데이터가 어떻게 넘어오는지 확인해 보아야 하므로 임시로 살짝 수정해서 데이터만 확인해  보겠습니다. 

 

ListView 컴포넌트의 useEffect Hook에서 apiGet 함수를 호출할 때 type 파라미터를 news 대신 book으로 변경합니다. 책은 "코스피"라는 키워드 대신 "React"로 키워드를 변경해 보겠습니다.

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

다시 한 번 새로고침을 한 후 Network 탭을 확인하면 아래와 같이 데이터를 확인할 수 있습니다.

BookRow 컴포넌트에서 사용할 데이터 형식은 아래와 같습니다.

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

BookRow 컴포넌트에서는 위 데이터 중 title(제목), image(도서 이미지), author(저자), description(요약) 4개의 항목을 사용해서 구성해 보겠습니다. BookRow 컴포넌트는 아래와 같이 작성합니다.

const BookRow = (props) => {
    const image = props.row.image;
    const title = props.row.title;
    const author = props.row.author;
    const desc = props.row.description;

    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: desc}} />
                    </div>
                </div>
            </a>
        </li>
    );
};

설명은 NewsRow 컴포넌트의 동일한 내용이므로 다시 설명하지 않겠습니다. 이렇게 변경한 다음 ListView 컴포넌트에서 Rendering 하는 부분을 BookRow로 변경합니다.

return (
    <div className="listArea">
        <ul className="listView">
        {
            articles &&
            articles.map((v, inx) => {
                return <BookRow key={inx} row={v} />
            })
        }
        </ul>
    </div>
);

여기까지만 변경을 하면 위에서 작성한 NewsRow stylesheet가 반영되어 아래와 같이 UI가 만들어 집니다. (이..이미지가 깨지는 게시물도 있네요)

BookRow에 사용할 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;}

좌측에 도서 이미지를 배치하고 오른쪽에 제목, 저자, 요약 내용을 배치 했습니다.

이번 포스트는 여기까지 작성해 보겠습니다. NewsRow와 BookRow 컴포넌트를 포함하고 있는 listview.component.jsx 프로그램의 현재까지의 소스는 아래와 같습니다.

import React from "react";
import { useEffect, useState } from "react"
import moment from "moment";

const NewsRow = (props) => {
    const title = props.row.title;
    const pubDate = moment(props.row.pubDate).format('YYYY.MM.DD HH:mm');
    const desc = props.row.description;

    return (
        <li>
            <div className="title">
                <a href="#" dangerouslySetInnerHTML={{__html: title}}></a>
            </div>
            <div className="cont">
                <span className="date">{pubDate}</span>
                <span dangerouslySetInnerHTML={{__html: desc}} />
            </div>
        </li>
    );
};

const BookRow = (props) => {
    const image = props.row.image;
    const title = props.row.title;
    const author = props.row.author;
    const desc = props.row.description;

    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: desc}} />
                    </div>
                </div>
            </a>
        </li>
    );
};

const ListView = () => {

    const [articles, setArticles] = useState(null);

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

    };

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

    return (
        <div className="listArea">
            <ul className="listView">
            {
                articles &&
                articles.map((v, inx) => {
                    return <BookRow key={inx} row={v} />
                })
            }
            </ul>
        </div>
    );
}

export default ListView;

 

 

300x250

PC 홈페이지와 모바일 홈페이지를 같이 운영하다 보면 사용자가 어느 쪽으로 접속하던 사용하는 매체에 맞는 환경으로 이동시켜 주기 위해 user-agent 속성으로 현재 접속한 환경을 판단합니다.

 

 

iOS의 같은 경우 보통 아래와 같은 로직으로 판단합니다.

const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i) === null ? false : true;

iOS 디바이스의 브라우저나 애플리케이션의 웹뷰에서는 "iPhone" 이나 "iPad" 등이 포함되어 있기 때문에 위와 같이 판단할 수 있었습니다만 iOS 13으로 올라오면서 iPad의 user-agent 값이 Mac과 동일하게 아래와 같이 바뀌어 버렸습니다.

Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko)

그래서 해당 디바이스를 사용하는 사용자는 모바일 도메인으로 접근해도 PC 홈페이지로 이동해 버리는 현상이 발생하였는데, user-agent를 봐도 Mac과 구분할 수 있는 방법이 없습니다.

 

navigator.maxTouchPoints

navigator 속성 중 디바이스의 최대 동시 터치 수를 return 하는 속성이 있고, iPad의 경우 5를 return 합니다. 일단 또 다른 형태의 디바이스가 나오면 이 방법도 무용지물이 될 수 있겠지만 아직까지는 iOS 13 이상의 iPad를 구분할 때는 해당 속성을 사용하여 구분할 수 있습니다.

const isIOS = navigator.userAgent.match(/iPhone|iPad|iPod/i) === null && navigator.maxTouchPoints != 5 ? false : true;

navigator.maxTouchPoints 의 속성의 상세 내용에 대해서는 아래 링크를 참고합니다.

https://developer.mozilla.org/en-US/docs/Web/API/Navigator/maxTouchPoints

 

Navigator.maxTouchPoints - Web APIs | MDN

The maxTouchPoints read-only property of the Navigator interface returns the maximum number of simultaneous touch contact points are supported by the current device.

developer.mozilla.org

 

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

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

 

js, jsx 파일은 /src 아래 경로에 작성하면 됩니다.

 

먼저 /src 하위에 /component 라는 폴더를 만들고 listview.component.jsx 파일을 하나 생성하고 아래 설명을 참고하여 목록 컴포넌트를 작성해 봅니다. 중간중간 코드에 추가한 부분은 아래와 같이 블럭으로 표시하겠습니다.

//ADD :: START
추가한 코드
//ADD :: END

 

먼저 컴포넌트를 생성하고, 현재 컴포넌트는 목록 컴포넌트이므로 데이터를 조회할 때마다 상태를 변경하고 목록을 갱신하기 위해 useState Hook을 사용하여 상태를 선언하고 초기 값은 null로 설정합니다.

import React from "react";
import { useEffect, useState } from "react"

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

export default ListView;

목록에 필요한 데이터는 Naver API를 호출하여 받아와야 합니다. fetch API를 사용하여 Naver API를 GET 방식으로 호출하는 apiGet이라는 이름의 함수를 하나 정의합니다.

import React from "react";
import { useEffect, useState } from "react"

const ListView = () => {
    const [articles, setArticles] = useState(null);
	
    //ADD :: START
    const apiGet = async (type, param) => {
        const apiUrl = 'https://openapi.naver.com/v1/search/' + type + '?query=' + param;
        const resp = await fetch(apiUrl, {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
                'X-Naver-Client-Id': CLIENT_ID,
                'X-Naver-Client-Secret': CLIENT_SECRET
            }
        });
        resp.json().then(data => {
            setArticles(data.items);
        });
    };
    //ADD :: END
}

export default ListView;

Naver Open API를 사용하기 위해서는 API 사용 신청을 하고 인증에 필요한 정보를 발급 받아야 합니다. 여기서는 검색 API를 사용하므로 아래 URL에서  오픈 API 이용신청해서 먼저 발급 받습니다.

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

 

검색 - SERVICE-API

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

developers.naver.com

apiGet 함수는 type과 param을 전달 받도록 작성했습니다. type은 뉴스(news)인지 도서(book)인지를 결정하는 인자이고, param은 검색어입니다. 두 인자를 받아서 Naver API URL을 조립하고 GET 방식으로 호출합니다. 이 때 Naver Open API 규격에 따라 headers에 인증 정보를 전달합니다.

 

Ajax는 비동기지만 aync/await를 사용했으므로 동기처럼 사용할 수 있습니다. 조회를 하고 나면 컴포넌트의 상태를 변경해 줍니다. 위에서 선언한 setArticles 함수를 사용해서 조회한 결과로 상태를 변경합니다.

 

다음은 useEffect Hook을 사용하여 컴포넌트가 마운트 될 때 한 번만 실행할 수 있도록 두 번째 인자로 빈 배열([])을 넘겨 아래와 같이 작성합니다. "코스피"라는 단어가 들어가는 뉴스를 검색하기 위해 아래와 같이 작성해 봅니다.

import React from "react";
import { useEffect, useState } from "react"

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

    const apiGet = async (type, param) => {
        const apiUrl = 'https://openapi.naver.com/v1/search/' + type + '?query=' + param;
        const resp = await fetch(apiUrl, {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
                'X-Naver-Client-Id': CLIENT_ID,
                'X-Naver-Client-Secret': CLIENT_SECRET
            }
        });
        resp.json().then(data => {
            setArticles(data.items);
        });
    };
	
    //ADD :: START
    useEffect(() => {
        apiGet('news', '코스피');
    }, []);
    //ADD :: END
}

export default ListView;

이제 마지막으로 컴포넌트에서 Rendering 하는 부분을 작성합니다. 일단은 데이터가 어떻게 넘어오는지 아직 모르기 때문에 그냥 <ul> 태그만 하나 출력해 두겠습니다.

import React from "react";
import { useEffect, useState } from "react"

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

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

    useEffect(() => {
        apiGet('news', '코스피');
    }, []);
	
    //ADD :: START
    return (
    	<div className="listArea">
            <ul className="listView">

            </ul>
        </div>
    );
    //ADD :: END
}

export default ListView;

여기까지 작성했다면 일단 ListView 컴포넌트는 대강 작성했습니다. 이제 main.js 에서 ListView 컴포넌트를 사용하도록 아래와 같이 변경합니다.

import React from 'react';
import ReactDOM from 'react-dom';
import ListView from './component/listview.component.jsx';

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

이제 실행을 한 번 시켜봐야 하는데, <ListView /> 등의 JSX 요소는 React의 컴포넌트를 표현하는 확장된 형태일 뿐, 정규 HTML 문법이 아니기 때문에 브라우저가 해석을 할 수 없습니다. 브라우저가  이해할 수 있는 순수 JavaScript로 변환하는 과정인 transpile 이라는 과정을 거쳐야 하며, 이 과정은 babel을 사용해서 진행합니다.

 

webpack.config.js 파일을 열어서 babel 설정을 추가합니다.

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

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

확장자가 jsx인 파일은 babel-loader를 사용해서 transpile 하겠다는 의미입니다. babel 에서 transpile에 사용할 규칙은 preset을 사용하도록 하겠습니다. 프로젝트 루트에 .babelrc 라는 파일을 생성하고 아래와 같이 작성합니다.

{
    "presets": [
        "@babel/preset-env",
        "@babel/preset-react"
    ]
}

여기까지 작성하셨으면 프로젝트의 파일 구조는 아래와 같습니다.

 

이제 yarn start 명령으로 프로젝트를 기동합니다. 잘 따라하셨다면 특별한 오류 없이 기동될 것 같습니다. 정상 기동되었다면 터미널에 아래와 같은 형태로 표시됩니다.

D:\workspace\searchNaverApi>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 D:\workspace\searchNaverApi\node_modules\react-dom\cjs\react-dom.development.js as it exceeds the max of 500KB.
asset main.js 1.13 MiB [emitted] (name: main)
runtime modules 27 KiB 12 modules
modules by path ./node_modules/ 1.02 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/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/ 78.1 KiB 2 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 ./src/ 15.8 KiB
  ./src/main.js 219 bytes [built] [code generated]
  ./src/component/listview.component.jsx 15.6 KiB [built] [code generated]
webpack 5.66.0 compiled successfully in 1866 ms

프로젝트가 기동되었다면 이제 데이터를 확인하기 위해 http://localhost:8080으로 접근하여 Network 탭을 확인합니다. 하지만  인터페이스가 정상 수행되지는 않았고 Chrome Console 에 아래와 같은 에러 메시지가 남아 있습니다.

Chrome 브라우저의 CORS 정잭을 예외하기 위해서 Chrome의 바로가기에 아래처럼 예외 옵션을 적용합니다.

"C:\Program Files\Google\Chrome\Application\chrome.exe" --disable-web-security --user-data-dir="C:\Chrome"

브라우저를 모두 종료 후에 새로 시작해서 다시 http://localhost:8080 으로 접근해서 Network 탭을 확인하면 아래와 같이 인터페이스 결과를 확인할 수 있습니다.

다음 포스트에서는 인터페이스한 내용으로 ListView UI를 완성해 보도록 하겠습니다.

300x250

본 포스트는 제우스에서 세션 확인 및 유지를 위해 jSessionId를 사용할 때 주의사항에 대한 포스트입니다.

 

HTTP 프로토콜은 기본적으로 Stateless 합니다. 클라이언트에서 서버에 요청할 때마다 새로운 연결을 생성하고 응답을 받은 후에는 연결을 끊어 버리기 때문에 상태를 유지할 수가 없습니다.

 

jSessionId

그래서 대부분의 WAS Container에서는 jSessionId라는 값을 발급합니다. 브라우저나 혹은 앱의 HttpClient 등 클라이언트가 최초에 JEUS에 접근하면 Response Header에 jSessionId 값을 발급하여 응답합니다. 클라이언트는 이후 요청부터는 이 jSessionId를 Request Header의 Cookie에 넣어서 요청합니다.

 

이 값을 Key로 서버에서는 세션 영역을 사용하고 유지할 수 있고, Request Header에 jSessionId가 전달되었을 경우에는 새로운 jSessionId를 발급하지 않습니다.

 

중복 로그인 방지

보통 서비스에서 동시 로그인을 허용하지 않을 경우 [사용자 아이디 + jSessionId] 값으로 동일한 사용자가 여러 경로로 접속했는지를 판단합니다. 사용자가 로그인을 하면 테이블에 현재 로그인한 jSessionId 값을 저장하고 매 트랜잭션에서 현재 동일한 사용자 아이디로 다른 jSessionId에서 접속하고 있지 않은지 검사합니다.

 

아래와 같은 테이블을 사용합니다. (별도의 테이블을 사용할 수도 있고, 사용자 원장을 사용할 수도 있습니다.)

-- USER table
USER_ID VARCHAR(20),
SESSION_ID VARCHAR(100)

사용자가 로그인에 성공하면 SESSION_ID를 현재 Request 영역에 전달된 jSessionId로 Update 합니다.

UPDATE  USER
   SET  SESSION_ID = ${jSessionId}
 WHERE  USER_ID = ${userId}

애플리케이션에서 비교 로직은 테이블에서 해당 사용자의 SESSION_ID를 읽어 현재 Request 영역의 jSessionId와 비교합니다.

String sessionId = userVo.getSessionId();
String jSessionId = request.getSession().getId();

if (!jSessionId.equals(sessionId)) {
	throw new Exception(~~~);
}

 

이중화 환경일 경우

AP 인스턴스가 여러 대일 경우에도 세션 클러스터링 설정을 해두었으므로 세션을 잘 복제가 됩니다. 하지만 위 로직으로 중복 로그인 방지를 구현했을 경우 접속하는 인스턴스가 변경될 경우 로그인이 풀리는 현상이 발생했습니다.

부하 분산을 Sticky Round Robin 방식으로 하여, 사용자가 접속하는 인스턴스의 변경이 거의 발생하지는 않지만 그래도 인스턴스 별 reboot 등의 작업을 수행할 때 사용자들이 불편을 겪곤 했습니다.

 

JEUS의 경우 세션 클러스터링을 하면 동일한 jSession 아이디로 잘 복제가 되지만 아래와 같이 인스턴스 구분자가 postFix로 추가됩니다. 

17:31:33.406 jSession ID : aaaaaaaaaaaaaaaaaaaaa.xyz01 -> inst01 접속 시
17:31:33.406 jSession ID : aaaaaaaaaaaaaaaaaaaaa.vwy01 -> inst03 접속 시

하여 JEUS의 세션 클러스터링 환경에서 중복 로그인 방지 등을 구현할 때는 위의 로직이 아니라 postFix 앞 쪽만 비교하는 로직으로 변경이 필요합니다. 예를 들면 아래와 같습니다.

String sessionId = (userVo.getSessionId().indexOf(".") > -1) ? userVo.getSessionId().substring(0, userVo.getSessionId().indexOf(".")) : userVo.getSessionId();
String jSessionId = (request.getSession().getId().indexOf(".") > -1) ? request.getSession().getId().substring(0, request.getSession().getId().indexOf(".")) : request.getSession().getId();

if (!jSessionId.equals(sessionId)) {
	throw new Exception(~~~);
}
300x250

JEUS 7에서 운영 중인 솔루션에 기능을 add-on 하면서 솔루션의 Spring framework 버전을 4.1.6에서 4.3.18로 올렸더니 아래와 같은 오류가 나며 WAS가 구동되지 않는 현상이 있었습니다.

java.lang.illegalStateException: zip file closed
	at java.util.zip.ZipFile.ensureOpen(ZipFile.java:634)
    at java.util.zip.ZipFile.getEntry(ZipFile.java:305)
    at java.util.jar.JarFile.getEntry(JarFile.java:227)
    ...

처음엔 신규 솔루션 인스턴스를 추가하면서 WAS 설정이 잘못된 게 아닌가 한참을 찾았지만 혹시나 하고 Spring framework 버전을 4.2.x 버전으로 낮추어 보았더니 Container가 정상 구동되었습니다.

 

구글링으로는 관련 내용이 잘 나오지 않았고, JEUS 제조사인 티맥스 온라인 메뉴얼을 찾아보니 아래와 같은 내용을 발견할 수 있었습니다.

설치되어 있는 JEUS 버전을 확인해 보니, Fix #4 였고 엔지니어의 협조를 받아 Fix #5로 업데이트 한 후에는 문제를 해결할 수 있었습니다.

 

JEUS 온라인 매뉴얼 링크입니다.

https://technet.tmaxsoft.com/upload/download/online/jeus/pver-20171211-000001/release-note/chapter_jeus_7_5.html#d4e1595

 

제2장 JEUS 7 Fix#5

본 장에서는 JEUS 7 Fix#5 릴리즈에서 추가된 새로운 기능과 변경된 기능에 대해 간략히 설명한다. 본 절에서는 JEUS의 신규 추가사항에 대하여 설명한다. deploy target이 all-target인 경우 DAS에는 deploy되

technet.tmaxsoft.com

 

300x250

본 포스트는 트랜잭션 설정에 따른 데드락이 발생하는 원인과 Spring의 Transaction Propagation Level (전파 레벨)에 대한 글입니다.

 

 

프로젝트 오픈 후 특정 인원 몇몇 한정으로 로그인을 시도하면 Timeout이 발생하곤 했습니다. 모니터링 툴에서 보면  해당 트랜잭션이 timeout이 발생할 때까지 active 상태로 있다가 좀비 트랜잭션(?)이 되어 버리는 현상이었습니다.

(인스턴스를 재기동 하기 전까지는 사라지지 않고 계속 문제 트랜잭션으로 표시됩니다.)

 

운좋게 내부 개발자 중 한 명에게 해당 현상이 발생해서 어렵지 않게 원인을 찾을 수 있었습니다. 로그인 시 비밀번호를 틀리거나 다른 사유로 로그인을 실패하게 되면 실패 횟수를 Update 합니다.

UPDATE  TB_USER
   SET  ERR_CNT = ERR_CNT + 1
 WHERE  USER_ID = #{userId}

이렇게 한 번 로그인을 실패한 후 다시 로그인을 해서 성공하게 되면 ERR_CNT를 0으로 Update하는 부분이 있는데 해당 Query에서 트랜잭션이 멈추어 있었습니다. (비밀번호를 틀린 적이 없는 사용자는 해당 Update를 수행하지 않습니다.)

UPDATE  TB_USER
   SET  ERR_CNT = 0
 WHERE  USER_ID = #{userId}

위 Query를 수행하는 Service 및 Dao를 찾아보니 아래 Annotation으로 별도의 트랜잭션으로 설정되어 있었습니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)

Propagation Level

Spring framework에서는 크게 두 가지 방법으로 트랜잭션을 제어합니다. AOP 설정을 사용하는 선언적 트랜잭션 설정과, 위와 같이 별도의 annotation을 사용하여 트랜잭션을 제어하는 방법이 있습니다. 그 중 위와 같이 annotation을 사용하는 방법의 트랜잭션 전파 레벨(Propagation Level)의 종류는 아래와 같습니다.

Propagation.REQUIRED
- default 값이기 때문에 생략할 수 있습니다.
- 부모  트랜잭션 내에서 실행하며, 부모 트랜잭션이 없을 경우 새로운 트랜잭션을 생성합니다.

Propagation.REQUIRES_NEW
- 매번 새로운 트랜잭션을 시작합니다.(새로운 연결을 생성하여 실행합니다.)

Propagation.NESTED
- 해당 method가 부모 트랜잭션에서 진행될 경우 commit 되거나 rollback 될 수 있습니다.
- 부모 트랜잭션이 없을 경우 Propagation.REQUIRED와 동일하게 작동합니다.

Propagation.MANDATORY
- 부모 트랜잭션 내에서 실행되며, 부모 트랜잭션이 없을 경우 Exception이 발생합니다.

Propagation.SUPPORT
- 부모 트랜잭션이 존재하면 부모 트랜잭션으로 동작하고, 없을 경우 non-transactional 하게 동작합니다.

Propagation.NOT_SUPPORT
- non-transactional 로 실행되며 부모 트랜잭션이 존재하면 일시 정지합니다.

Propagation.NEVER
- non-transactional 로 실행되며 부모 트랜잭션이 존재하면 Exception이 발생합니다.

문제점

정상적으로 로그인을 완료하게 되면, 다른 Session에서 중복 로그인하는 것을 방지하기 위해 아래와 같이 해당 테이블의 SESS_ID를 현재 Session의 jSessionID로 Update 합니다.

UPDATE  TB_USER
   SET  SESS_ID = #{jSessionId}
 WHERE  USER_ID = #{userId}

로그인 프로세스를 진행하고 있는 부모 트랜잭션에서 해당 Record를 Update 중이고 아직 commit 되지 않은 상태에서 REQUIRES_NEW annotation이 붙어 있는 Update가 실행되면서 두 트랜잭션 간 교차 상태가 발생한 내용 이었습니다.

 

해결

오류 횟수를 0으로 Update하는 부분을 별도 처리하게 된 히스토리가 명확하지 않았지만, 충분히 영향도 검토를 진행한 후에 해당 annotation을 삭제하여 문제를 해결하였습니다.

300x250

webpack-cli 4.9.x 버전을 사용하고 있는데 아래와 같은 오류를 만난다면 webpack-cli 버전을 4.10.0 으로 재설치하시면 됩니다.

 

 

 

[webpack-cli] TypeError: cli.isMultipleCompiler is not a function
    at Command.<anonymous> (D:\workspace\searchNaverApi\node_modules\@webpack-cli\serve\lib\index.js:146:35)
    at async Promise.all (index 1)
    at async Command.<anonymous> (D:\workspace\searchNaverApi\node_modules\webpack-cli\lib\webpack-cli.js:1672:7)
Note: This command was run via npm module 'win-node-env'
error Command failed with exit code 2.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

webpack-cli 를 4.10.0 으로 업데이트 한 후에는 해당 오류가 발생하지 않습니다.

 

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

 

저번 시간에는 프로젝트 경로를 생성하고 개발에 필요한 모듈들을 설치해 보았습니다. 설치하는 과정에서 의존성에 대해서도 잠시 알아보았습니다. 이번에는 VSCode에 프로젝트를 반입해서 프로젝트 구조 및 보일러 플레이트 코드를 작성한 후 간단하게 "Hello World"를 출력해보겠습니다.

VSCode로 프로젝트 열기

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

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

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

프로젝트 구조 생성

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

 

[./public]

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

 

[./src]

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

 

먼저 public 폴더를 생성하고 하위에 index.html 파일을 생성하고 아래와 같이 작성합니다. html 파일에는 별 내용은 없으며 React 모듈을 초기화 할 main.js 파일을 포함하는 부분과 React 애플리케이션을 Rendering 할 "app"라는 아이디를 가진 div를 하나 선언합니다.

<!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="main.js"></script>
    </body>
</html>

그 다음에는 index.html 파일에 사용할 main.js 파일을 ./src 경로 하위에 생성하고 아래와 같이 작성합니다.

import ReactDOM from 'react-dom';

ReactDOM.render('Hello World', document.getElementById('app'));

현재까지 작성한 경로 및 파일들의 구조는 아래와 같습니다.

애플리케이션 실행

이제 이 상태에서 "Hello World"를 출력해보기 위해 터미널에서 프로젝트 루트 경로로 이동하여 아래 명령어를 입력합니다.

yarn start

package.json 파일에 설정한 start command를 읽어서 프로그램을 기동하는 명령어 입니다. 우리는 아직 package.json에 start command를 선언하지 않았으므로 터미널에 아래와 같은 오류가 발생합니다.

yarn run v1.22.17
error Command "start" not found.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

package.json 파일에 start command를 설정해 보겠습니다. package.json 파일을 열어 가장 아래 부분에 start command를 아래와 같이 추가합니다.

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

 

 

그리고 애플리케이션 실행을 위해서 webpack-dev-server를 사용해야 하므로 webpack 설정 파일을 프로젝트 root에 하나 생성합니다. 아래와 같은 내용으로 webpack.config.js 파일을 생성합니다.

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

module.exports = {
    entry: {
        main: ['./src/main.js']
    },
    devServer: {
        static: './public',
        host: 'localhost',
        port: 8080
    }
};

간단한 설정만 일단 추가합니다. entry point를 main.js로 설정하고 개발 서버 루트를 ./public 경로 하위로 설정합니다.

다시 애플리케이션을 실행해 보겠습니다.  다시 프로젝트 루트 경로에서 터미널로 아래 명령어를 입력합니다.

yarn start

webpack.config.js 파일에 devServer port를 8080으로 설정했으므로 개발 서버는 8080 포트로 기동됩니다. 브라우저에서는 아래 경로로 접근하면 됩니다.

http://localhost:8080

모든 작업이 정상적으로 이루어졌다면 화면에 아래와 같이 "Hello World"가 표시됩니다. index.html에 작성한 코드에서 <div id="app" /> 영역에 "Hello World"가 표시되었습니다.

다음 편에서는 목록을 표시하는 부분인 목록 Component를 만들어 보겠습니다.

 

300x250

+ Recent posts