1. 현상

 

npx create-react-app을 통해 react 앱을 생성하면 Critical 레벨의 이슈가 포함된 채 생성됩니다.

 

audit fix를 실행해도 해당 문제는 수정되지 않습니다.

 

 

 

2. 원인

 

결론부터 말하면 npm에 대한 버그입니다.

 

보다 자세한 내용은 다음 글을 참고해 주세요: Help, npm audit says I have a vulnerability in react-scripts!

 

Help, `npm audit` says I have a vulnerability in react-scripts! · Issue #11174 · facebook/create-react-app

npm audit is broken for front-end tooling by design Bad news, but it's true. See here for a longer explanation. If you think you found a real vulnerability in react-scripts If you know that it ...

github.com

 

 

 

3. 수정

 

package.json을 열어 다음과 같이 수정해 줍니다

//package.json
...

  "dependencies": {
    "@testing-library/jest-dom": "^5.14.1",
    "@testing-library/react": "^11.2.7",
    "@testing-library/user-event": "^12.8.3",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "web-vitals": "^1.1.2"
  },
  "devDependencies": {
    "react-scripts": "4.0.3"
  },
  
 ...

 

주요한 수정사항은 react-scripts입니다. 기존 dependencies에 있던 react-scripts를 devDependencies로 이동시킵니다.

그 후 npm audit --production을 실행시킵니다.

취약점 보고 내용이 0건으로 바뀌었습니다.

 

 

 

4. 문제가 해결된 게 아니라 무시한 거 아닌가요?

 

얼핏 보면 위와 같은 수정 내용이 문제를 수정한 게 아닌 devDependencies로 react-scripts를 이동시킴으로써 경고 메시지만 없앤 것처럼 보일 수 도 있습니다. 이 내용은 위의 링크된 원문 글에도 나와있으며 간단히 번역해보면 다음과 같습니다.

 

Create React App은 빌드 도구입니다. 즉, 실행 중인 Node 애플리케이션을 생성하지 않습니다. 개발 중 빌드 시간에 실행되며 정적 자산을 생성합니다.

그러나 npm 감사는 Node 앱용으로 설계되었으므로 프로덕션에서 실제 Node 코드를 실행할 때 발생할 수 있는 문제에 플래그를 지정합니다. 이것은 Create React App이 작동하는 방식이 아닙니다.

이것은 우리가 종속성에 대해 받는 압도적인 양의 "취약성" 보고서가 잘못되었음(false positive)을 의미합니다.

 

애초에 npm audit이 검사하는 방식에 문제가 있음을 내포하고 있습니다.

 

 

 

반응형

이 글은 다음 글을 번역한 글입니다: React Folder Structure in 5 Steps

 

React Folder Structure in 5 Steps - RWieruch

How to structure large React apps into folders and files for a scalable React project ...

www.robinwieruch.de

 

 

 

1. 앞선 글

 

https://www.robinwieruch.de/react-folder-structure

 

큰 규모의 리액트 앱을 폴더와 파일로 구성하는 방법은 서로 다른 의견이 많은 주제입니다. 정답이 존재하지 않는 주제이기 때문에 이 주제에 대해 글을 쓰는 한동안 고생했습니다. 하지만 매주 사람들이 내게 자신의 리액트 프로젝트를 어떻게 구성해야 하는지 물어봅니다. 폴더 구조는 작은 규모의 리액트 프로젝트뿐 아니라 확장 가능한 리액트 애플리케이션에서 더 중요합니다. 수년간 개인 프로젝트, 고객의 프로젝트의 리액트 애플리케이션을 구현했고 이러한 문제에 내가 어떻게 접근했는지 설명하도록 하겠습니다. 5개의 단계만 거치면 여러분에게 의미가 있는 게 어떤 것인지 정할 수 있고 어느 정도까지 원하는지 알 수 있습니다.  그럼 시작해 보도록 하겠습니다.

 

"난 내가 좋다고 느껴질 때까지 파일을 옮깁니다"라고 말하는 사람은 아마 혼자 개발하는 사람이나 소규모 팀에서는 괜찮을 수도 있습니다. 하지만 한 기능을 4명이 개발하고 5개의 팀이 있는 회사에서 실제로 수행할 수 있을까요? 더 큰 규모의 팀에서는 "명확한 비전 없이 그냥 파일을 이동하는 것"은 더 까다롭습니다. 게다가 고객이 내게 이 문제에 대해 물어봤을 때 말할 수 있는 내용이 아닙니다. 따라서 이 연습은 좀 더 명확한 것을 찾는 모든 이들을 위한 참조 가이드입니다.

 

 

 

2. 단일 리액트 파일.

 

첫 번째 단계는 다음 규칙을 따릅니다: 하나의 파일로 모두를 규정짓습니다. 대부분의 리액트 프로젝트는 src 폴더와 App 컴포넌트가 있는 하나의 src/App.js 파일로 시작합니다. creatr-react-app을 사용했을 때 얻을 수 있는 최소한의 것입니다. 이 App 함수형 컴포넌트는 다음과 같은 것을 렌더링 합니다:

 

import React from 'react';
const App = () => {
  const title = 'React';
  return (
    <div>
      <h1>Hello {title}</h1>
    </div>
  );
}
export default App;

 

결국 이 컴포넌트에 더 많은 기능을 추가하고 자연스럽게 사이즈가 커지게 되며 일부를 독립된 리액트 컴포넌트로 추출해내야 합니다. 다음은 App 컴포넌트에서 자식 컴포넌트로 list 컴포넌트를 추출해 낸 것입니다:

 

import React from 'react';
const list = [
  {
    id: 'a',
    firstname: 'Robin',
    lastname: 'Wieruch',
    year: 1988,
  },
  {
    id: 'b',
    firstname: 'Dave',
    lastname: 'Davidds',
    year: 1990,
  },
];
const App = () => <List list={list} />;
const List = ({ list }) => (
  <ul>
    {list.map(item => (
      <ListItem key={item.id} item={item} />
    ))}
  </ul>
);
const ListItem = ({ item }) => (
  <li>
    <div>{item.id}</div>
    <div>{item.firstname}</div>
    <div>{item.lastname}</div>
    <div>{item.year}</div>
  </li>
);

 

새로운 리액트 프로젝트를 시작할 때마다 한 파일에 여러 컴포넌트를 포함시키는 것이 괜찮다고 말합니다. 심지어 큰 규모의 프로젝트에서 한 컴포넌트가 다른 컴포넌트에 긴밀히 묶이는 것도 괜찮습니다. 하지만 이 시나리오에서는 결국 리액트 프로젝트에서 한 파일만으로는 더 이상 충분하지 않습니다. 이제 2단계로 전환을 해야 할 때입니다.

 

 

 

3. 여러 리액트 파일.

 

두 번째 단계는 다음 규칙을 따릅니다: 여러 파일로 모두를 규정짓습니다. List와 ListItem 컴포넌트를 갖는 이전 App 컴포넌트를 예시로 들어보겠습니다. 모든 것을 가진 하나의 src/App.js 파일 대신 컴포넌트들을 여러 개의 파일로 분리할 수 있습니다. 여기서 여러분이 얼마나 멀리 갈지 정합니다. 예를 들어 전 다음 폴더구조 까지만 갈 겁니다:

 

- src/
--- App.js
--- List.js

 

src/List.js에는 List와 ListItem 컴포넌트의 자세한 구현을 갖고 있을 수도 있지만 List 컴포넌트를 분리해 공용 API로 다음과 같은 파일로 내보냅니다.

 

const List = ({ list }) => (
  <ul>
    {list.map(item => (
      <ListItem key={item.id} item={item} />
    ))}
  </ul>
);
const ListItem = ({ item }) => (
  <li>
    <div>{item.id}</div>
    <div>{item.firstname}</div>
    <div>{item.lastname}</div>
    <div>{item.year}</div>
  </li>
);
export default List;

 

그러면 src/App.js는 List 컴포넌트를 import해와 사용할 수 있습니다.

 

import React from 'react';
import List from './List';
const list = [
  {
    id: 'a',
    firstname: 'Robin',
    lastname: 'Wieruch',
    year: 1988,
  },
  {
    id: 'b',
    firstname: 'Dave',
    lastname: 'Davidds',
    year: 1990,
  },
];
const App = () => <List list={list} />;

 

만약 더 나아가길 원한다면 ListItem 컴포넌트도 별도 파일로 추출하고 List 컴포넌트가 ListItem 컴포넌트를 import 하도록 할 수 있습니다.

 

- src/
--- App.js
--- List.js
--- ListItem.js

 

하지만 앞서 말한 것처럼 ListItem 컴포넌트는 List 컴포넌트와 강하게 묶여 있기 때문에 좀 더 멀리 가야 할 수도 있습니다. 그래서 ListItem을 src/List.js 파일에 남겨두는 것도 괜찮습니다. 저는 리액트 컴포넌트가 재사용 가능한 리액트 컴포넌트가 될 때마다 다른 리액트 컴포넌트에서 액세스 할 수 있도록 마치 앞서 List 컴포넌트에서 했던 것처럼 독립적인 파일로 나누는 경험의 법칙을 따릅니다. 

 

 

 

4. 리액트 파일에서 리액트 폴더로.

 

여기부터 더 흥미로워지고 의견이 다양해집니다. 모든 리액트 컴포넌트는 결국 복잡해집니다. 더 많은 조건부 렌더링이나 리액트 훅과 같은 더 많은 로직이 더해지고 스타일링과 테스트 같은 더 많은 기술적 고려사항 해문입니다. 별다른 지식이 필요하지 않은 접근 방식은 리액트 컴포넌트 옆에 더 많은 파일을 추가하는 것입니다. 예를 들어 모든 리액트 컴포넌트에 테스트와 스타일 파일이 있다고 가정해 봅시다.

 

- src/
--- App.js
--- App.test.js
--- App.css
--- List.js
--- List.test.js
--- List.css

 

src 폴더 내의 모든 추가 컴포넌트에 대해 개별 컴포넌트를 보기 힘들어질 것이므로 잘 확장되지 않는다는 것을 이미 알 수 있습니다. 이로 인해 저는 각각의 리액트 컴포넌트에 하나의 폴더를 갖게 합니다.

 

- src/
--- App/
----- index.js
----- test.js
----- style.css
--- List/
----- index.js
----- test.js
----- style.css

 

이 파일들의 네이밍은 여러분에게 달려있습니다. 예를 들어 index.js, component.js, test.js, spec.js가 될 수 있습니다. 게다가 만약 여러분이 CSS를 사용하지 않고 Styled Components와 같은 것을 사용하고 싶다면 파일 확장자도 style.css에서 style.js가 될 수 있습니다. 여러분들의 네이밍 규칙에 익숙해졌다면 IDE에서 "List index" 혹은 "App test"를 검색해 파일을 열 수도 있습니다. 컴포넌트의 폴더를 모두 축소하면 여러분은 매우 간결하고 명확한 폴더 구조를 볼 수 있습니다.

 

- src/
--- App/
--- List/

 

예를 들어 특정 컴포넌트에 대한 커스컴 훅을 파일로 추출해야 하는 것과 같이 컴포넌트에 기술적으로 고려해야 할 점이 많다면 컴포넌트 폴더 내에서 수평적으로 확장하는 방법으로 접근할 수 있습니다.

 

- src/
--- App/
----- index.js
----- test.js
----- style.css
--- List/
----- index.js
----- test.js
----- style.css
----- hooks.js

 

ListItem 컴포넌트를 별도의 파일로 추출해 List/Indes.js를 좀 더 가볍게 유지하기로 정했다면 다음과 같은 폴더구조를 가질 수 있습니다.

 

- src/
--- App/
----- index.js
----- test.js
----- style.css
--- List/
----- index.js
----- test.js
----- style.css
----- ListItem.js

 

여기에 다시 한 단계 더 나아가 컴포넌트 폴더에 테스트와 스타일과 같은 기술적 고려사항을 더할 수도 있습니다.

 

- src/
--- App/
----- index.js
----- test.js
----- style.css
--- List/
----- index.js
----- test.js
----- style.css
----- ListItem/
------- index.js
------- test.js
------- style.css

 

중요한 점은 여기부터 컴포넌트가 서로 너무 깊게 중첩되지 않도록 신경 써야 합니다. 제 경험에 따라 저는 컴포넌트가 두 레벨 이상 중첩되지 않도록 합니다. 따라서 List 폴더 내 ListItem 폴더는 괜찮지만 ListItem 폴더에 다른 중첩된 폴더가 있어서는 안 됩니다. 

 

중간 사이즈의 리액트 프로젝트 규모를 넘어설 것이 아니라면 이것이 여러분의 리액트 컴포넌트를 구조화하는 방법이라고 생각합니다. 하지만 제가 언급했듯이 이는 이견이 있을 수 있고 모든 이들의 입맛에 맞지는 않을 수 있습니다.

 

 

 

5. 기술적인 폴더를 분리.

 

다음 단계는 중대형 이상 규모의 리액트 애플리케이션을 구조화하는데 도움이 될 것입니다. 여러 컴포넌트에서 사용하는 기능을 컴포넌트로부터 분리합니다. 다음 폴더 구조에서 예를 들어보도록 하겠습니다.

 

- src/
--- components/
----- App/
------- index.js
------- test.js
------- style.css
----- List/
------- index.js
------- test.js
------- style.css

 

이전 리액트 컴포넌트를 새로운 component 폴더로 그룹화합니다. 이것은 다른 카테고리 폴더를 생성하기 위한 수직적 레이어를 제공해 줍니다. 예를 들어 어느 시점에 여러 컴포넌트에서 사용할 수 있는 리액트 훅이 있을 수도 있습니다. 이 훅을 컴포넌트에 강하게 결합하는 대신 모든 컴포넌트에서 사용할 수 있도록 전용 폴더에 훅의 구현을 위치시킬 수 있습니다.

 

- src/
--- components/
----- App/
------- index.js
------- test.js
------- style.css
----- List/
------- index.js
------- test.js
------- style.css
--- hooks/
----- useClickOutside/
------- index.js
----- useData/
------- index.js

 

모든 훅이 폴더 내에 있어야 한다는 의미는 아닙니다. 한 컴포넌트에만 사용되는 리액트 훅은 여전히 컴포넌트 파일에 남아 있거나 별도의 hook.js 파일에 있어야 합니다. 오로지 모든 리액트 컴포넌트에서 사용될 수 있는 훅만이 hook 폴더에 있어야 합니다. 

 

만약 여러분이 프로젝트에서 다른 모든 파일에서 글로벌하게 액세스 되어야 하는 React Context를 사용하는 경우 같은 전략이 적용될 수 있습니다.

 

- src/
--- components/
----- App/
------- index.js
------- test.js
------- style.css
----- List/
------- index.js
------- test.js
------- style.css
--- hooks/
----- useClickOutside/
------- index.js
----- useData/
------- index.js
--- context/
----- Session/
------- index.js

 

여기에 컴포넌트와 훅에 접근이 필요한 다른 유틸리티가 있을 수도 있습니다. 다양한 유틸리티의 경우 저는 주로 service 폴더를 생성합니다. 이름을 여러분에게 달려있지만 이 기술적인 분리를 이끄는 것은 우리 프로젝트의 다른 코드에서 로직을 사용할 수 있도록 한다는 원칙입니다. 

 

- src/
--- components/
----- App/
------- index.js
------- test.js
------- style.css
----- List/
------- index.js
------- test.js
------- style.css
--- hooks/
----- useClickOutside/
------- index.js
----- useData/
------- index.js
--- context/
----- Session/
------- index.js
--- services/
----- ErrorTracking/
------- index.js
------- test.js
----- Format/
------- Date/
--------- index.js
--------- test.js
------- Currency/
--------- index.js
--------- test.js

 

예를 들어 Data/index.js 파일을 봅시다. 자세한 구현은 다음과 같습니다:

 

export const formatDateTime = (date) =>
  new Intl.DateTimeFormat('en-US', {
    year: 'numeric',
    month: 'numeric',
    day: 'numeric',
    hour: 'numeric',
    minute: 'numeric',
    second: 'numeric',
    hour12: false,
  }).format(date);
export const formatMonth = (date) =>
  new Intl.DateTimeFormat('en-US', {
    month: 'long',
  }).format(date);

 

다행히도 자바스크립트의 Intl API는 날짜 변환을 위한 훌륭한 도구들을 제공합니다. 하지만 리액트 컴포넌트에서 API를 직접적으로 사용하는 것 대신에 서비스를 제공하는 방식이 더 좋습니다. 이 방식을 사용하면 내 컴포넌트에 애플리케이션에서 사용할 수 있는 날짜 형식 옵션을 보장할 수 있기 때문입니다. 그렇지 않으면 애플리케이션이 커가면서 많은 다른 날짜 포맷이 있을 겁니다. 

 

이제 날짜 형식 함수를 개별적으로 import 해올 수 있습니다.

 

import { formatMonth } from '../../services/format/date';
const month = formatMonth(new Date());

 

하지만 제가 더 선호하는 서비스로서 캡슐화된 코드는 다음과 같습니다.

 

import * as dateService from '../../services/format/date';
const month = dateService.formatMonth(new Date());

 

상대 경로가 있는 항목을 가져오는 것은 어려울 수도 있습니다. 따라서 전 항상 별칭을 위한 Babel의 Module Resolver를 사용합니다. 이후 import는 다음과 같이 바꿔 쓸 수 있습니다.

 

import * as dateService from '@format/date';
const month = dateService.formatMonth(new Date());

 

저는 모든 폴더에 전용의 목적을 부여하고 애플리케이션에서 기능을 공유하도록 하기 때문에 이러한 기술적인 분리의 고려를 좋아합니다.

 

 

 

6. 도메인 폴더 분리.

 

마지막 단계는 대규모 리액트 애플리케이션을 구성하는데 도움이 됩니다. 기술적으로 분리된 폴더에 많은 서브 폴더가 있는 경우 발생할 수 있는 일입니다. 예시는 전체를 보여주진 않지만 요점을 이해하길 바랍니다.

 

- src/
--- components/
----- App/
----- List/
----- Input/
----- Button/
----- Checkbox/
----- Profile/
----- Avatar/
----- MessageItem/
----- MessageList/
----- PaymentForm/
----- PaymentWizard/
----- ErrorMessage/
----- ErrorBoundary/

 

여기부터는 UI 컴포넌트처럼 재사용 가능한 컴포넌트만 components 폴더를 사용합니다. 다른 모든 컴포넌트는 도메인 중심의 폴더로 이동시켜야 합니다. 폴더 이름은 여러분에게 달려있습니다.

 

- src/
--- domain/
----- User/
------- Profile/
------- Avatar/
----- Message/
------- MessageItem/
------- MessageList/
----- Payment/
------- PaymentForm/
------- PaymentWizard/
----- Error/
------- ErrorMessage/
------- ErrorBoundary/
--- components/
----- App/
----- List/
----- Input/
----- Button/
----- Checkbox/

 

PaymentForm이 Button이나 Input에 액세스 해야 하는 경우 재사용 가능한 UI 컴포넌트 폴더에서 가져와 사용하면 됩니다. MessageList 컴포넌트에 추상 List 컴포넌트가 필요하면 List 컴포넌트를 가져와 사용하면 됩니다. 더 나아가 이전 단계의 서비스가 도메인에 강하게 결합되어 있으면 서비스를 특정 도메인 폴더로 이동시킵니다. 앞서 기술적으로 분리된 폴더에도 동일하게 적용될 수 있습니다.

 

- src/
--- domain/
----- User/
------- Profile/
------- Avatar/
----- Message/
------- MessageItem/
------- MessageList/
----- Payment/
------- PaymentForm/
------- PaymentWizard/
------- services/
--------- Currency/
----------- index.js
----------- test.js
----- Error/
------- ErrorMessage/
------- ErrorBoundary/
------- services/
--------- ErrorTracking/
----------- index.js
----------- test.js
--- components/
--- hooks/
--- context/
--- services/
----- Format/
------- Date/
--------- index.js
--------- test.js

 

각 도메인 폴더에 중간중간 services 폴더가 필요한지 여부는 여러분에게 달려있습니다. services 폴더를 제외하고 ErrorTracking폴더의 내용을 Error폴더에 직접 넣을 수도 있습니다만 ErrorTracking은 리액트 컴포넌트가 아닌 서비스로 표시되어야 하기 때문에 혼란스러울 수도 있습니다. 대안으로 ErrorTracking대신 error-tracking처럼 PascalCase보다 kebab-case를 사용해 명명할 수 있습니다.

 

여기에는 개인적인 공간이 존재합니다. 경국 이 단계는 도메인을 하나로 모아 회사의 여러 팀이 프로젝트 전체에서 특정 도메인에서 작업할 수 있도록 하는 것입니다.

 

 

 

7. 맺는 글

 

모든 글이 끝났습니다. 여러분 또는 다른 팀이 리액트 프로젝트를 구성하는데 도움이 되기를 바랍니다. 표시된 접근 방식 중 어느 것도 바꾸기 어렵거나 불가능한 것이 아닙니다. 오히려 전 여러분들이 이것들을 개인에 맞게 적용해 사용하기를 바랍니다. 모든 리액트 프로젝트는 시간이 지남에 따라 규모가 커지므로 대부분의 폴더 구조도 그에 맞게 자연스레 변합니다. 따라서 이 5단계의 과정은 문제가 발생할 경우 몇 가지 지침을 제공하는 가이드일 뿐입니다.

 

 

 

 

 

반응형

웹팩 관련 에러 중 "Error: Cannot find module 'webpack-cli/bin/config-yargs'"의 해결 방법에 대해 알아봅니다.

 

 

 

1. 현상

 

webpack-dev-server를 실행하면 Error: Cannot find module 'webpack-cli/bin/config-yargs' 와 같은 에러가 발생한다.

 

 

 

 

2. 원인

 

webpack, webpack-cli와 webpack-dev-server의 버전이 맞지 않은 경우 발생합니다.

 

이 경우 전 웹팩이 곧 5버전이 나온다고 하여 5.0.0-rc.1을 사용했으며 webpack-dev-server은 현재 latest버전을 사용해 두 버전에 대해 서로 아직 지원하지 않아 발생한 것으로 추정됩니다.

 

 

 

3.  해결

 

현재 버전의 webpack 및 webpack-cli를 제거하고 webpack-dev-server 버전에 맞춰서 재설치합니다.

 

> npm uninstall -D webpack webpack-cli

> npm install -D webpack webpack-cli

 

 

 

 

반응형

 

이 글은 다음 영상의 튜토리얼을 글로 풀어쓴 내용을 담고 있습니다: Intro to Vue.js: Build a Todo App

 

 

 

1. 앞선 글.

 

 

이 글에선 위의 링크에서 안내되는 튜토리얼을 따라 해 볼 예정입니다. 이 영상에서 소개하는 내용은 가장 기초적인 내용으로 Vue JS의 기능을 이용해 Todo list를 관리하는 앱을 만드는 것입니다.

 

소개된 튜토리얼의 난이도는 초심자 급으로 VueJS를 갓 시작한 개발자를 위한 튜토리얼입니다. 영상의 길이는 약 19분으로 짧은 편이므로 영상을 직접 보시는 것을 추천드립니다.

 

또한 모든 소스코드는 다음 경로에서 확인할 수 있습니다: CodingGarden/vue-todo

 

CodingGarden/vue-todo

Intro to Vue.js: Build a Todo App. Contribute to CodingGarden/vue-todo development by creating an account on GitHub.

github.com

 

 

 

2. 목표 알아보기.

 

이 튜토리얼은 다음과 같은 순서로 진행됩니다. 

 

  • Index.html 생성.
  • Index.html에 Vue.js 추가하기.
  • app.js에 뷰 인스턴스 생성하기.
  • 페이지에 뷰 인스턴스로부터 메시지 표시하기.
  • 새로운 Todo 폼 생성하기.
  • 폼이 제출되었을 때 함수를 호출하기.
  • 유저가 입력한 값을 저장하기 위한 문자열 속성 생성하기.
  • Todo를 저장할 배열 속성 생성하기.
  • Todo항목을 Todos 배열에 넣고 완료 상태를 false로 설정하기.
  • Todos의 값을 화면에 보여주기.
  • Todo 완료 체크박스를 만들기.
  • Todo 완료 체크박스가 체크된 경우 Todo에 취소선을 추가하기.
  • Todo를 삭제하는 버튼을 추가하기.
  • 모든 Todo를 완료하는 버튼을 추가하기.

 

 

 

3. 뷰 프로젝트 생성.

 

뷰 프로젝트를 생성하는 방법은 여러 가지가 있습니다. 이 글에선 index.html이라는 정적 파일을 생성 한 뒤 vue.js를 script에 추가한 뒤 app.js에서 뷰 인스턴스를 생성해 사용하는 방법에 대해 알아보도록 합니다.

 

다음 명령어로 프로젝트 폴더를 만들고 index.html 파일을 생성해 주세요.

 

mkdir todo-app
cd todo-app
touch index.html

 

그리고 생성한 index.html을 다음과 같이 코딩해 주세요.

 

<!DOCTYPE html>
<html lang="en" dir="ltr">
    <head>
        <meta charset="utf-8">
        <title></title>
    </head>
    <body>
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    </body>
</html>

 

index.html을 생성하고 vue.js를 추가했습니다. 지금 우리는 vue.js의 개발 버전을 추가한 상황입니다. 추가할 vuejs에 대해서는 공식 홈페이지를 참고해 주세요: VueJS의 설치방법 - 직접 script에 추가.

 

 

 

4. 뷰 인스턴스 생성.

 

이제 뷰 인스턴스를 생성할 준비가 되었습니다. app.js 파일을 생성하고 다음 코드처럼 뷰 인스턴스를 생성해 보도록 합시다.

 

// app.js
const app = new Vue({
    el: '#app',
    data: {},
    methods: {},
});

 

위의 코드는 뷰 인스턴스를 생성해주는 코드입니다. el에 id를 정의해 주었으며 이 id를 가진 엘리먼트에 해당 뷰 인스턴스가 적용됩니다. 그러면 이제 app이라는 id를 가지는 엘리먼트를 index.html에 추가해 줍시다.

 

<!DOCTYPE html>
<html lang="en" dir="ltr">
    <head>
        <meta charset="utf-8">
        <title></title>
    </head>
    <body>
        <main id="app">
        </main>
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
        <script src="app.js"></script>
    </body>
</html>

 

main에 id를 app으로 지정해 주었으며 app.js에 대한 참조도 추가했습니다. 우리의 뷰가 제대로 동작하는지 한번 확인해 봅시다. app.js를 다음과 같이 수정해 주세요.

 

// app.js
const app = new Vue({
    el: '#app',
    data: {
        title: 'Hello VueJS',
    },
    methods: {},
});

 

data 객체에 title이란 문자열을 추가했습니다. 정상적으로 뷰 인스턴스를 생성했다면 html에서 해당 데이터를 가져다 사용할 수 있습니다. 이때 뷰 인스턴스에서 가져온 데이터라는 것을 표시하기 위해 중괄호 두 개를 사용합니다. {{title}}과 같이요. 다음과 같이 index.html을 수정 해 데이터를 가져와 표시해 보도록 합시다.

 

<!-- index.html -->
<!DOCTYPE html>
<html lang="en" dir="ltr">
    <head>
        <meta charset="utf-8">
        <title></title>
    </head>
    <body>
        <main id="app">
            <h3>{{title}}</h3>
        </main>
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
        <script src="app.js"></script>
    </body>
</html>

 

그리고 index.html 파일을 열면 정상적으로 title에 입력한 문자열을 출력하는 것을 확인할 수 있습니다.

 

 

 

5. Todo를 위한 폼 생성하기.

 

이제 본격적으로 TodoList를 위한 작업을 시작하도록 합시다. 가장 먼저 Todo 작업을 입력받을 텍스트 박스와 제출 버튼을 추가해 보도록 합시다. index.html을 다음과 같이 수정해 주세요.

 

<!-- index.html -->
<!DOCTYPE html>
<html lang="en" dir="ltr">
    <head>
        <meta charset="utf-8">
        <title></title>
        <link rel="stylesheet" href="https://ez-css.now.sh">
    </head>
    <body>
        <main id="app">
            <h3>{{title}}</h3>
            <form>
                <label for="newTodo">New Todo</label>
                <input type="text" name="newTodo" value="">
                <button type="submit" name="button">Add</button>
            </form>
        </main>
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
        <script src="app.js"></script>
    </body>
</html>

 

기본적인 스타일 시트를 제공해주는 사이트를 추가했습니다. 또한 h3아래에 form을 추가했습니다. 이제 제출 버튼이 클릭되었을 때 호출되는 함수를 정의해 주도록 합시다. app.js를 다음과 같이 수정해 주세요.

 

// app.js
const app = new Vue({
    el: '#app',
    data: {
        title: 'Hello VueJS',
    },
    methods: {
        addTodo: function(){
            console.log(`Todo is submitted.`)
        },
    },
});

 

앞으로 뷰에서 사용할 함수는 methods 객체에 추가하면 사용할 수 있습니다. addTodo함수를 추가했으며 해당 함수가 호출되면 콘솔 창에 메시지를 출력하도록 했습니다. 이제 이 함수를 폼에 바인드 해보도록 합시다. index.js의 폼을 다음과 같이 수정해 주세요.

 

<!-- index.html -->
...
            <form @submit.prevent="addTodo">
                <label for="newTodo">New Todo</label>
                <input type="text" name="newTodo" value="">
                <button type="submit" name="button">Add</button>
            </form>
...

 

@를 이용해 이벤트를 바인드 할 수 있습니다. .prevent는 제출 이벤트가 페이지를 다시 로드하는 것을 방지해줍니다. 폼이 제출되면 addTodo가 호출되고 페이지가 다시 로드되지 않도록 작성되었습니다. 페이지로 이동해 폼을 제출해 보세요.

 

 

우리가 작성한 메시지가 정상적으로 보입니다.

 

 

 

6. Todo 저장하기.

 

이제 함수가 호출되는 것을 확인했으니 Todo를 추가해 보도록 하겠습니다. app.js를 다음과 같이 수정해 주세요.

 

// app.js
const app = new Vue({
    el: '#app',
    data: {
        title: 'Hello VueJS',
        newTodo: '',
        todos: [],
    },
    methods: {
        addTodo: function(){
            this.todos.push({
                title: this.newTodo,
                done: false,
            });
            this.newTodo = '';
        },
    },
});

 

data에 newTodo와 todos가 추가되었습니다. newTodo는 사용자가 입력할 새 작업입니다. 텍스트 박스에 입력될 것입니다. todos는 현재 사용자가 추가한 todo의 배열입니다. 사용자가 todo를 추가하면 이 배열에 저장됩니다.

 

methods에 addTodo도 같이 수정되었습니다. 해당 인스턴스의 todos에 새로운 객체를 생성해 push 하게 됩니다. 해당 객체에는 title과 done을 가지며 title에 사용자가 입력한 작업이 저장되며 done은 해당 작업이 완료되었는지에 대한 플래그가 저장됩니다. 새로운 todo가 추가되면 기존 텍스트 박스의 값을 비워주는 로직도 추가하였습니다. 

 

이제 우리가 구현한 기능이 적용될 수 있도록 index.html을 수정해 줍시다.

 

<!-- index.html -->
<!DOCTYPE html>
<html lang="en" dir="ltr">
    <head>
        <meta charset="utf-8">
        <title></title>
        <link rel="stylesheet" href="https://ez-css.now.sh">
    </head>
    <body>
        <main id="app">
            <h3>{{title}}</h3>
            <form @submit.prevent="addTodo">
                <label for="newTodo">New Todo</label>
                <input v-model="newTodo" type="text" name="newTodo" value="">
                <button type="submit" name="button">Add</button>
            </form>
            <ul>
                <li v-for="todo in todos">
                    <input type="checkbox" v-model="todo.done">
                    <span>{{todo.title}}</span>
                </li>
            </ul>
        </main>
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
        <script src="app.js"></script>
    </body>
</html>

 

form 내부의 input에 v-model이 추가된 것을 확인할 수 있습니다. 이 명령어를 통해 우린 이 input의 값을 newTodo에 바인딩 할 수 있습니다.

 

아래의 li에 보면 v-for이 추가되어 있습니다. 이는 C#의 foreach와 매우 유사하게 동작합니다. todo in todos에서 우리는 해당 루프 문을 수행할 때 뷰 인스턴스의 todos에 들어 있는 내용을 토대로 루프문을 실행합니다. 즉 todos의 위치에 있는 내용은 뷰 인스턴스에 동일하게 정의가 되어 있어야 함을 의미합니다. todo는 우리가 html내에서 정의하면 됩니다. 이 값을 내부에서 사용하도록 합니다.

 

내부의 체크박스는 todo의 done 속성을 반영하고 있으며 span에선 유저가 입력한 newTodo(todos에 저장되면 title) 값을 보여줍니다.

 

 

아름답진 않지만 정상적으로 보이고 있습니다.

 

 

 

7. 완료 및 삭제 처리 하기.

 

이제 체크박스를 선택하면 타이틀에 취소선이 추가되는 기능과 Todo를 삭제하는 기능, 현재 추가된 모든 Todo를 완료하는 기능을 추가해 보도록 하겠습니다.

 

app.js를 열고 todo를 삭제하는 로직을 추가해 주세요.

 

// app.js
const app = new Vue({
...
    methods: {
        addTodo: function(){
            this.todos.push({
                title: this.newTodo,
                done: false,
            });
            this.newTodo = '';
        },
        removeTodo: function(todo) {
            const todoIdx = this.todos.indexOf(todo);
            this.todos.splice(todoIdx, 1);
        },
    },
});

 

로직은 간단합니다. 매개변수로 받아온 todo가 todos에서 어디 있는지 찾아낸 후 그 하나를 없애버리는 것이 전부입니다. 이제 이 함수를 index.html에 반영해 봅시다.

 

<!-- index.html -->
...
                <li v-for="todo in todos">
                    <input type="checkbox" v-model="todo.done">
                    <span>{{todo.title}}</span>
                    <button @click="removeTodo(todo)" type="button">Remove</button>
                </li>
...

 

@click을 추가해 해당 버튼이 클릭되었을 때 removeTodo(todo)가 호출되게 만들었습니다. 이제 페이지를 실행시킨 뒤 새 todo를 입력해보면 다음과 같이 보입니다.

 

 

Todo에 추가된 remove버튼을 클릭하면 해당 Todo가 삭제됩니다. 

 

이제 완료 기능을 만들어 보도록 하겠습니다. 체크박스를 클릭하면 Todo가 완료되었다고 판단하고 유저가 입력한 Todo에 취소선을 추가해 완료가 되었다는 표시를 남기도록 해보겠습니다. 또한 이와 함께 모든 작업을 한 번에 완료하는 버튼도 같이 추가해 보도록 하겠습니다.

 

app.js를 다음과 같이 수정해 주세요. 이제 마지막 코드 수정입니다!

 

// app.js
const app = new Vue({
    el: '#app',
    data: {
        title: 'Hello VueJS',
        newTodo: '',
        todos: [],
    },
    methods: {
        addTodo: function(){
            this.todos.push({
                title: this.newTodo,
                done: false,
            });
            this.newTodo = '';
        },
        removeTodo: function(todo) {
            const todoIdx = this.todos.indexOf(todo);
            this.todos.splice(todoIdx, 1);
        },
        allDone: function(){
            this.todos.forEach((todo) => {
                todo.done = true;
            });
        },
    },
});

 

마지막으로 allDone 함수를 추가했습니다. 모든 todos를 순회하면서 done값을 true로 변경합니다.

 

이제 index.html을 수정합시다.

 

<!-- index.html -->
<!DOCTYPE html>
<html lang="en" dir="ltr">
    <head>
        <meta charset="utf-8">
        <title></title>
        <link rel="stylesheet" href="https://ez-css.now.sh">
        <style>
            .done{
                text-decoration: line-through;
            }
        </style>
    </head>
    <body>
        <main id="app">
            <h3>{{title}}</h3>
            <form @submit.prevent="addTodo">
                <label for="newTodo">New Todo</label>
                <input v-model="newTodo" type="text" name="newTodo" value="">
                <button type="submit" name="button">Add</button>
            </form>
            <button @click="allDone" type="button" name="button">All Done</button>
            <ul>
                <li v-for="todo in todos">
                    <input type="checkbox" v-model="todo.done">
                    <span :class="{done: todo.done}">{{todo.title}}</span>
                    <button @click="removeTodo(todo)" type="button">Remove</button>
                </li>
            </ul>
        </main>
        <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
        <script src="app.js"></script>
    </body>
</html>

 

style이 추가되었습니다. .done 클래스가 추가된 항목은 취소선을 추가하도록 하였습니다. 

 

새 버튼이 추가되었습니다. 버튼을 클릭하면 앞서 구현한 allDone함수가 호출됩니다.

 

li의 span에 클래스 바인딩이 추가되었습니다. 만약 내부 조건(:이후의 조건)이 true라면 done이라는 클래스가 추가되도록 하는 논리입니다. 즉 todo.done이 true인 경우 해당 span에 done라는 클래스가 추가됩니다.

 

이제 페이지를 열고 한번 테스트해보세요!

 

 

구현한 기능이 모두 정상적으로 동작하는 것을 확인할 수 있습니다!

 

 

 

 

 

반응형

이 글은 다음 영상의 튜토리얼을 글로 풀어쓴 내용을 담고 있습니다: Build a Weather App in VueJS | VueJS Beginner Tutorial

 

 

 

1. 앞선 글.

 

이 글에선 위의 링크에서 안내되는 튜토리얼을 따라 해 볼 예정입니다. 이 영상에서 소개하는 내용은 외부 API를 사용해 검색한 도시의 날씨 정보를 보여주는 앱을 작성하는 것입니다.

 

소개된 튜토리얼의 난이도는 초심자 급으로 VueJS를 갓 시작한 개발자를 위한 튜토리얼입니다. 영상의 길이는 약 27분으로 짧은 편이므로 영상을 직접 보시는 것을 추천드립니다.

 

또한 모든 소스코드는 다음 경로에서 확인할 수 있습니다: TylerPottsDev/weather-vue

 

TylerPottsDev/weather-vue

Contribute to TylerPottsDev/weather-vue development by creating an account on GitHub.

github.com

 

 

 

2. 날씨 API 준비하기.

 

외부에서 날씨정보를 읽어오기 위해 OpenWeatherMap라는 사이트를 사용할 예정입니다. 여기로 이동해 회원가입을 진행해 주세요.

 

회원가입 후 위의 그림처럼 API -> Current Weather Data -> Subscribe를 클릭합니다.

 

 

별도의 유료 플랜은 선택사항입니다. 우린 무료 플랜을 사용할 예정입니다. 위의 표시된 Get APU key를 클릭해 주세요.

 

 

전 이미 키를 받았기 때문에 이미 키가 있다는 알림이 표시됩니다. 키를 발급받은 후 메인 페이지에서 볼 수 있는 API Keys 메뉴로 이동합니다. 위의 가려진 곳에 우리가 발급받은 API 키가 존재합니다.

 

만약 새로운 키를 발급받고 싶으시다면 우측의 Create Key에 원하는 이름을 입력 후 Generate버튼을 클릭해 새로운 키를 발급받을 수 있습니다.

 

키를 여기서 확인할 수 있다는 사실을 기억해 두세요!

 

 

 

3. VueJS 프로젝트 만들기.

 

VueJS 프로젝트를 생성하는 방법은 여러 방법이 있습니다만 여기에서는 yarn과 vue-cli을 이용해 프로젝트를 생성하는 방법에 대해 알아보도록 하겠습니다. 

 

** yarn을 설치하는 방법에 대해선 이 글에서 설명하지 않습니다. 설치 방법은 yarn 공식 홈페이지의 설치 문서를 참고해 주세요.

 

yarn을 설치했다면 다음 명령어를 통해 vue-cli를 글로벌로 설치합니다.

 

yarn global add @vue/cli

 

 

위와 같이 vue-cli 설치가 완료되면 이제 vue 프로젝트를 생성할 준비가 다 되었습니다. 다음 명령어를 통해 vue 프로젝트를 생성해 봅시다.

 

vue create weather-app

 

위 명령어를 입력하면 다음과 같이 프리셋을 고르는 옵션이 보입니다. 

 

 

특별히 원하는 옵션이 없는 경우 Vue2를 사용하는 Default 옵션을 선택해 줍시다.

 

 

약간의 시간이 지난 후 위와 같이 Vue 프로젝트가 생성되게 됩니다. 안내에 표시된 대로 다음 명령어를 통해 프로젝트를 실행시켜 봅시다.

 

cd weather-app
yarn serve

 

 

정상적으로 실행이 되었다면 http://localhost:8080/으로 이동해 다음과 같은 기본 페이지를 확인할 수 있습니다.

 

 

 

 

4. 페이지 가다듬기.

 

이제 기본 페이지에서 불필요한 파일과 코드를 제거하고 날씨 정보를 표시해 줄 수 있도록 페이지를 수정해 보도록 하겠습니다.

 

우선 여기로 이동해 백그라운드 이미지를 다운로드하여줍니다. cold-bg.jpg와 warm-bg.jpg파일을 다운 받아 asset폴더에 저장해 주세요. 그리고 assets 폴더 내의 logo.png파일을 삭제한 뒤 App.vue 파일을 열어 내용을 다음과 같이 수정해 주세요.

 

<template>
  <div id="app">
    <main>
      <div class="search-box">
        <input type="text" class="search-bar" placeholder="Search..."/>
      </div>
    </main>
  </div>
</template>

<script>
export default {};
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
body {
  font-family: 'montserrat', sans-serif;
}
#app {
  background-image: url('./assets/cold-bg.jpg');
  background-size: cover;
  background-position: bottom;
  transition: 0.4s;
}
main {
  min-height: 100vh;
  padding: 25px;
  background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.75));
}
.search-box {
  width: 100%;
  margin-bottom: 30px;
}
.search-box .search-bar {
  display: block;
  width: 100%;
  padding: 15px;
  
  color: #313131;
  font-size: 20px;
  appearance: none;
  border:none;
  outline: none;
  background: none;
  box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.25);
  background-color: rgba(255, 255, 255, 0.5);
  border-radius: 0px 16px 0px 16px;
  transition: 0.4s;
}
.search-box .search-bar:focus {
  box-shadow: 0px 0px 16px rgba(0, 0, 0, 0.25);
  background-color: rgba(255, 255, 255, 0.75);
  border-radius: 16px 0px 16px 0px;
}
</style>

 

파일을 저장하고 다시 localhost:8080으로 이동해 봅시다.

 

 

화면이 위와 같이 바뀐 게 보이시나요? 이제 우리는 임시 데이터를 갖고 날씨 정보를 표시해 볼 예정입니다. 다시 App.vue를 열고 코드를 수정해 주세요.

 

<template>
  <div id="app">
    <main>
      <div class="search-box">
        <input type="text" class="search-bar" placeholder="Search..." />
      </div>
      <div class="weather-wrap">
        <div class="location-box">
          <div class="location">Northampton, UK</div>
          <div class="date">Monday 20 January 2020</div>
        </div>
        <div class="weather-box">
          <div class="temp">9℃</div>
          <div class="weather">Rain</div>
        </div>
      </div>
    </main>
  </div>
</template>

<script>
export default {};
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
body {
  font-family: "montserrat", sans-serif;
}
#app {
  background-image: url("./assets/cold-bg.jpg");
  background-size: cover;
  background-position: bottom;
  transition: 0.4s;
}
main {
  min-height: 100vh;
  padding: 25px;
  background-image: linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.25),
    rgba(0, 0, 0, 0.75)
  );
}
.search-box {
  width: 100%;
  margin-bottom: 30px;
}
.search-box .search-bar {
  display: block;
  width: 100%;
  padding: 15px;

  color: #313131;
  font-size: 20px;
  appearance: none;
  border: none;
  outline: none;
  background: none;
  box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.25);
  background-color: rgba(255, 255, 255, 0.5);
  border-radius: 0px 16px 0px 16px;
  transition: 0.4s;
}
.search-box .search-bar:focus {
  box-shadow: 0px 0px 16px rgba(0, 0, 0, 0.25);
  background-color: rgba(255, 255, 255, 0.75);
  border-radius: 16px 0px 16px 0px;
}
.location-box .location {
  color: #fff;
  font-size: 32px;
  font-weight: 500;
  text-align: center;
  text-shadow: 1px 3px rgba(0, 0, 0, 0.25);
}
.location-box .date {
  color: #fff;
  font-size: 20px;
  font-weight: 300;
  font-style: italic;
  text-align: center;
}
.weather-box {
  text-align: center;
}
.weather-box .temp {
  display: inline-block;
  padding: 10px 25px;
  color: #fff;
  font-size: 102px;
  font-weight: 900;
  text-shadow: 3px 6px rgba(0, 0, 0, 0.25);
  background-color: rgba(255, 255, 255, 0.25);
  border-radius: 16px;
  margin: 30px 0px;
  box-shadow: 3px 6px rgba(0, 0, 0, 0.25);
}
.weather-box .weather {
  color: #fff;
  font-size: 48px;
  font-weight: 700;
  font-style: italic;
  text-shadow: 3px 6px rgba(0, 0, 0, 0.25);
}
</style>

 

위와 같이 코드를 수정하고 다시 localhost:8080로 이동해 보세요.

 

 

우리가 직접 넣은 데이터가 잘 표시되는 것을 확인할 수 있습니다.

 

 

 

5. API를 통해 데이터를 불러오기.

 

소스코드를 찬찬히 살펴보면 사실 지금까지 우리가 작성한 코드는 단지 html 약간과 화면을 꾸미기 위한 css가 전부임을 확인할 수 있습니다.

 

이제 우리가 앞에서 발급받은 API 키를 이용해 날씨 정보를 받아와 화면에 뿌려주는 로직을 작성해 보도록 하겠습니다.

 

먼저 App.vue의 script 영역을 다음과 같이 수정해 줍시다.

 

<script>
export default {
  data: function () {
    return {
      api_key: "YOUR_API_KEY_HERE",
      url_base: "https://api.openweathermap.org/data/2.5/",
      query: "",
      weather: {},
    };
  },
  methods: {
    fetchWeather: function (e) {
      if (e.key == "Enter") {
        let fetchUrl = `${this.url_base}weather?q=${this.query}&units=metric&APPID=${this.api_key}`;
        fetch(fetchUrl)
          .then((res) => {
            console.log(res);
            return res.json();
          })
          .then((results) => {
            return this.setResult(results);
          });
      }
    },
    setResult: function (results) {
      this.weather = results;
    },
    dateBuilder: function () {
      let d = new Date();
      let months = [
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December",
      ];
      let days = [
        "Sunday",
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
      ];
      let day = days[d.getDay()];
      let date = d.getDate();
      let month = months[d.getMonth()];
      let year = d.getFullYear();
      return `${day} ${date} ${month} ${year}`;
    },
  },
};
</script>

 

위에서부터 차근차근 보도록 합시다.

 

data 함수에는 vue 컴포넌트에서 사용할 데이터가 담겨 있습니다. 우리는 이제 이 컴포넌트에서 api_key, url_base, query, weather을 사용할 수 있습니다.

  • api_key에는 앞서 우리가 발급받은 API 키값을 넣어 주시면 됩니다.
  • url_base는 API를 호출할 URL입니다.
  • query는 search시 우리가 입력한 데이터가 들어갈 공간입니다.
  • weather은 검색 결과 데이터가 들어갈 공간입니다.

 

methods에는 이 컴포넌트에서 사용할 method들이 정의되어 있는 곳입니다.

  • fetchWeather 함수는 search에 값을 입력하고 엔터를 누를 경우 해당 값을 사용해 날씨를 검색해오는 작업을 수행합니다. 내부에서 fetch 함수를 사용하고 있으며 수행 결과를 promise를 사용해 json으로 변환한 뒤 결과를 data에 저장하는 작업을 수행합니다.
  • setResult는 입력받은 결과 값을 앞서 정의한 weather에 저장하는 역할을 수행합니다.
  • dateBuilder는 현재 시간을 보기 좋게 만들어주는 역할을 수행합니다.

 

다음으로 스크립트를 사용할 수 있도록 탬플릿을 수정해 줍시다. App.vue의 template영역을 다음과 같이 수정합니다.

 

<template>
  <div id="app">
    <main>
      <div class="search-box">
        <input
          type="text"
          class="search-bar"
          placeholder="Search..."
          v-model="query"
          @keypress="fetchWeather"
        />
      </div>
      <div class="weather-wrap" v-if="typeof weather.main != 'undefined'">
        <div class="location-box">
          <div class="location">{{weather.name}}, {{weather.sys.country}}</div>
          <div class="date">{{dateBuilder()}}</div>
        </div>
        <div class="weather-box">
          <div class="temp">{{weather.main.temp}}℃</div>
          <div class="weather">{{weather.weather[0].main}}</div>
        </div>
      </div>
    </main>
  </div>
</template>

 

새로 추가된 속성에 대해 차근차근 알아봅시다.

  • v-model은 해당 input에 입력된 값을 앞서 정의한 query에 저장해 주는 역할을 합니다. 
  • @keyprss는 해당 input에서 키가 입력될 때마다 fetchWeather함수를 실행시킵니다.
  • v-if는 if문의 역할을 합니다. weather.main이 'undefined'가 아닐 경우에만 해당 div를 표시합니다.
  • {{}}는 data에서 정의한 변수와 methods에 정의한 함수를 사용할 수 있도록 해줍니다.

 

이제 다시 페이지를 새로고침 후 seoul을 검색해 보세요.

 

 

위와 같이 표시되면 정상입니다. 그런데 기억하고 계신가요? 우리는 앞서 warm-bg.jpg도 같이 다운로드하였습니다. 위의 결과인 27.7℃는 충분히 따뜻한 온도이므로 배경화면을 바꿔서 표시해 주는 것이 좋아 보입니다.

 

앞서 설명한 v-if를 사용하면 됩니다. 마지막으로 코드를 다음과 같이 수정해 줍시다.

 

<template>
  <div
    id="app"
    :class="typeof weather.main !='undefined' && Math.round(weather.main.temp) > 16 ? 'warm' : ''"
  >
    <main>
      <div class="search-box">
        <input
          type="text"
          class="search-bar"
          placeholder="Search..."
          v-model="query"
          @keypress="fetchWeather"
        />
      </div>
      <div class="weather-wrap" v-if="typeof weather.main != 'undefined'">
        <div class="location-box">
          <div class="location">{{weather.name}}, {{weather.sys.country}}</div>
          <div class="date">{{dateBuilder()}}</div>
        </div>
        <div class="weather-box">
          <div class="temp">{{Math.round(weather.main.temp)}}℃</div>
          <div class="weather">{{weather.weather[0].main}}</div>
        </div>
      </div>
    </main>
  </div>
</template>

<script>
export default {
  data: function () {
    return {
      api_key: "YOUR_API_KEY_HERE",
      url_base: "https://api.openweathermap.org/data/2.5/",
      query: "",
      weather: {},
    };
  },
  methods: {
    fetchWeather: function (e) {
      if (e.key == "Enter") {
        let fetchUrl = `${this.url_base}weather?q=${this.query}&units=metric&APPID=${this.api_key}`;
        fetch(fetchUrl)
          .then((res) => {
            console.log(res);
            return res.json();
          })
          .then((results) => {
            return this.setResult(results);
          });
      }
    },
    setResult: function (results) {
      this.weather = results;
    },
    dateBuilder: function () {
      let d = new Date();
      let months = [
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December",
      ];
      let days = [
        "Sunday",
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
      ];
      let day = days[d.getDay()];
      let date = d.getDate();
      let month = months[d.getMonth()];
      let year = d.getFullYear();
      return `${day} ${date} ${month} ${year}`;
    },
  },
};
</script>

<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
body {
  font-family: "montserrat", sans-serif;
}
#app {
  background-image: url("./assets/cold-bg.jpg");
  background-size: cover;
  background-position: bottom;
  transition: 0.4s;
}
#app.warm {
  background-image: url("./assets/warm-bg.jpg");
}
main {
  min-height: 100vh;
  padding: 25px;
  background-image: linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.25),
    rgba(0, 0, 0, 0.75)
  );
}
.search-box {
  width: 100%;
  margin-bottom: 30px;
}
.search-box .search-bar {
  display: block;
  width: 100%;
  padding: 15px;

  color: #313131;
  font-size: 20px;
  appearance: none;
  border: none;
  outline: none;
  background: none;
  box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.25);
  background-color: rgba(255, 255, 255, 0.5);
  border-radius: 0px 16px 0px 16px;
  transition: 0.4s;
}
.search-box .search-bar:focus {
  box-shadow: 0px 0px 16px rgba(0, 0, 0, 0.25);
  background-color: rgba(255, 255, 255, 0.75);
  border-radius: 16px 0px 16px 0px;
}
.location-box .location {
  color: #fff;
  font-size: 32px;
  font-weight: 500;
  text-align: center;
  text-shadow: 1px 3px rgba(0, 0, 0, 0.25);
}
.location-box .date {
  color: #fff;
  font-size: 20px;
  font-weight: 300;
  font-style: italic;
  text-align: center;
}
.weather-box {
  text-align: center;
}
.weather-box .temp {
  display: inline-block;
  padding: 10px 25px;
  color: #fff;
  font-size: 102px;
  font-weight: 900;
  text-shadow: 3px 6px rgba(0, 0, 0, 0.25);
  background-color: rgba(255, 255, 255, 0.25);
  border-radius: 16px;
  margin: 30px 0px;
  box-shadow: 3px 6px rgba(0, 0, 0, 0.25);
}
.weather-box .weather {
  color: #fff;
  font-size: 48px;
  font-weight: 700;
  font-style: italic;
  text-shadow: 3px 6px rgba(0, 0, 0, 0.25);
}
</style>

 

완성된 코드입니다. 탬플릿의 #app에 v-if를 사용해 온도가 16℃보다 높은 경우 .warm 클래스를 추가하도록 했습니다. 추가된 클래스에 따라 백그라운드가 변경되도록 #app.warm에 대해 background-image가 변경되도록 스타일을 변경하였습니다. 또한 기존 소수점까지 표시되는 기온을 Math.round를 사용해 정수만 표시되도록 수정했습니다.

 

페이지에서 다시 서울을 검색해 보도록 하겠습니다.

 

 

이제 서울을 검색하니 백그라운드 이미지가 따뜻한 이미지로 변경되었습니다. 혹시 모르니 추운 도시를 검색해 봅시다. 남반구는 8월 지금 겨울입니다. 시드니를 검색해 보세요.

 

 

백그라운드 이미지가 정상적으로 변경되어 출력되는 것을 확인할 수 있습니다.

 

 

 

 

 

반응형

이 글은 Local Authentication Using Passport in Node.js를 번역한 글입니다.

 

Local Authentication Using Passport in Node.js

Paul Orac shows how Passport, Node.js, Express, and MongoDB can be used to implement local authentication with a MongoDB back end.

www.sitepoint.com

 

 

1. 앞선 글.

 

 

웹앱을 만들 때 일반적인 요구사항은 사용자가 보호된 뷰 또는 리소스에 액세스 하기 전에 자신을 인증할 수 있도록 로그인 시스템을 구현하는 것입니다. 운 좋게도 NodeJS에서 앱을 구축하는 사람들에게는 Passport라는 미들웨어가 존재합니다. 이 미들웨어는 Express 기반의 웹 애플리케이션에 탑재되어 단지 몇 개의 명령만으로 인증 메커니즘을 제공해줍니다.

 

이 튜토리얼에서는 Passport를 사용해 MongoDB 백엔드로의 로컬 인증 즉, ID와 비밀번호로 로그인하는 것에 대해 구현하는 방법을 설명합니다. Facebook 또는 GitHub을 통해 인증을 구현하려는 경우 여기를 참조해 주세요.

 

이 글의 모든 코드는 여기서 다운로드할 수 있습니다.

 

 

 

2. 준비사항.

 

이 튜토리얼을 진행하기 위해선 NodeJS와 MongoDB가 설치되어 있어야 합니다. MongoDB는 Community Server를 설치해 주세요.

 

각각의 홈페이지에서는 훌륭한 문서를 제공하고 있으며 이 글에서 별도의 설치방법에 대해서 다루지 않겠습니다. 

 

 

 

3. 인증 전략: 세션과 JWT.

 

시작하기 전에 인증방법에 대해 간단히 이야기하겠습니다.

 

오늘날 온라인으로 제공되는 많은 튜토리얼은 JWT를 이용한 토큰 기반 인증을 선택합니다. 이 접근법은 아마 최근 가장 단순하고 가장 인기 있는 방법일 겁니다. 인증 책임의 일부를 클라이언트에 위임하고 모든 요청과 함께 토큰을 전송하며 서명된 토큰을 통해 사용자를 계속 인증하는 방법입니다. 

 

세션 기반 인증은 더 오래 지속된 방법입니다. 이 방법은 인증의 무게를 서버에 두는 방법입니다. 쿠키를 사용하여 노드 애플리케이션과 DB가 함께 동작하며 사용자의 인증 상태를 추적합니다.

 

이 튜토리얼에서는 Passport의 로컬 인증 전략의 핵심인 세션 기반 인증을 사용합니다.

 

두 방법 모두 장단점이 존재합니다. 두 방법의 차이점에 대해 더 자세히 알고 싶다면 여기를 참고해 주세요.

 

 

 

4. 프로젝트 생성.

 

사전 준비가 끝나면 이제 시작할 수 있습니다.

 

먼저 프로젝트 폴더를 생성 한 뒤 폴더로 이동합니다: 

 

mkdir AuthApp
cd AuthApp

 

Node앱을 만들기 위해 다음 명령어를 수행합니다: 

 

npm init

 

Node앱의 package.json에 제공할 정보를 묻는 메시지가 나타납니다. 기본 구성을 사용하려면 Return키를 누르시거나 -y플래그를 사용하세요.

 

 

 

5. Express 설정하기.

 

이제 Express를 설치해야 합니다. 터미널에서 다음 명령어를 수행해 주세요:

 

npm install express

 

또한 Passport가 사용자를 인증하는 데 사용하는 요청문을 파싱 하는 데 사용하기 위해 body-parser 미들웨어도 설치해야 합니다. 그리고 세션을 사용하기 위해 express-session 미들웨어도 설치해줘야 합니다.

 

설치해 봅시다. 다음 명령을 실행해 주세요.

 

npm install body-parser express-session

 

설치가 완료되면 프로젝트의 루트 폴더 경로에 index.js 파일을 생성 한 뒤 다음과 같이 코딩해 주세요.

 

/*  EXPRESS SETUP  */

const express = require('express');
const app = express();

app.use(express.static(__dirname));

const bodyParser = require('body-parser');
const expressSession = require('express-session')({
  secret: 'secret',
  resave: false,
  saveUninitialized: false
});

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(expressSession);

const port = process.env.PORT || 3000;
app.listen(port, () => console.log('App listening on port ' + port));

 

먼저 Express가 필요(require)하고 express()를 호출하여 Express 앱을 만듭니다. 그런 다음 정적 파일을 제공할 디렉터리를 정의합니다.

 

다음 줄에서 body-parser 미들웨어가 require 되었음을 볼 수 있습니다. 이는 요청 구문을 파싱 하는 데 사용됩니다. 또한 세션 쿠키를 저장할 수 있도록 express-session 미들웨어를 추가했습니다.

 

세션 ID 쿠키(고유한 값을 입력하는 것이 좋습니다.)에 서명하기 위해 'secret'로 express-session을 구성하고 다른 두 필드인 resave와 saveUninitialize도 구성해 줍니다. resave 필드는 세션이 세션 저장소에 다시 저장되는 것을 허용하는 필드이며 saveUninitialized 필드는 초기화되지 않은 세션이 세션 저장소에 저장되는 것을 허용하는 필드입니다. 이에 대한 자세한 내용은 express-session의 공식 문서를 참하세요. 그러나 지금은 우리가 이 두 필드를 false로 유지하면 된다는 것을 하는 것으로 충분합니다.

 

그 뒤 process.env.PORT를 이용해 환경변수에 PORT가 있으면 그 값으로 port를 설정합니다. 그렇지 않으면 기본값으로 3000 포트를 로컬에서 사용하도록 합니다. 이는 개발 환경에서 Heroku와 같이 포트를 직업 설정하는 서비스 제공업체로의 전환에 충분한 유연성을 제공합니다. 그 바로 아래에 우리는 app.listen()을 설정한 포트와 간단한 로그와 함께 호출합니다. 모두 제대로 동작한다면 앱이 어떤 포트에서 수신하고 있는지 알려줍니다.

 

이것으로 Express 설정이 끝났습니다. 이제 Passport를 설정할 차례입니다.

 

 

 

6. Passport 설정하기.

 

먼저 다음 명령으로 Passport를 설치합니다:

 

npm install passport

 

그런 뒤 index.js에 다음 코드를 추가합니다: 

 

/*  PASSPORT SETUP  */

const passport = require('passport');

app.use(passport.initialize());
app.use(passport.session());

 

여기서 우리는 우리의 express내에서 passport를 require 했고 세션 인증 미들웨어와 함께 초기화했습니다.

 

 

 

7. MongoDB 데이터 저장소 생성.

 

이미 Mongo를 설치했다고 가정하고 다음 명령을 사용하여 Mongo 셸을 시작합니다. 

 

mongo

 

셸 내에서 다음 명령어를 수행해 주세요:

 

use MyDatabase;

 

이 명령어는 간단히 MyDatabase라는 데이터 저장소를 생성합니다.

터미널은 그대로 두세요. 나중에 다시 사용할 거예요.

 

 

 

8. Mongoose를 이용해 MongoDB와 Node앱을 연결하기.

 

이제 레코드가 있는 데이터베이스가 있으므로 애플리케이션에서 데이터베이스와 통신할 수 있는 방법이 필요합니다. 이를 위해 Mongoose를 사용할 것입니다. Mongoose는 간단하게 더 편하고 더 우아한 코드를 만들게 해 줍니다.

 

다음 명령어를 통해 설치를 진행해 줍니다:

 

npm install mongoose

 

또한 로컬 인증을 위해 Mongoose와 Passport의 통합을 단순하게 해 주기 위해 passport-localmongoose를 사용할 것입니다. 이는 해시된 암호와 salt 값을 저장할 수 있도록 스키마에 해시와 솔트 필드를 추가해 줍니다. 암호는 DB에 일반 텍스트로 저장되면 안 되므로 이를 이용하는 것이 좋습니다.

 

다음 명령어를 통해 패키지를 설치해 주세요:

 

npm install passport-local-mongoose

 

이제 Mongoose를 설정해야 합니다. 다행히도 우린 이제 드릴을 알고 있습니다: 다음 코드를 index.js 파일에 추가해 주세요.

 

/* MONGOOSE SETUP */

const mongoose = require('mongoose');
const passportLocalMongoose = require('passport-local-mongoose');

mongoose.connect('mongodb://localhost/MyDatabase',
  { useNewUrlParser: true, useUnifiedTopology: true });

const Schema = mongoose.Schema;
const UserDetail = new Schema({
  username: String,
  password: String
});

UserDetail.plugin(passportLocalMongoose);
const UserDetails = mongoose.model('userInfo', UserDetail, 'userInfo');

 

이전에 설치한 패키지들을 require 해 줍니다. 그 뒤 mongoose.connect를 이용해 DB에 연결하고 DB경로를 제공합니다. 다음으로 스키마를 이용해 데이터 구조를 정의합니다. 이 경우 username과 password필드를 사용하여 UserDetail스키마를 만들었습니다.

 

마지막으로 passportLocalMongoose를 plugin으로 스키마에 추가합니다. 이것은 우리가 앞서 이야기한 마술의 일무가 될 겁니다. 그런 뒤 스키마에서 모델을 만들어줍니다. 첫 번째 매개변수는 DB의 collection이름입니다. 두 번째 매개변수는 스키마에 대한 참조이고 세 번째 매개변수는 Mongoose 내부의 collection에 해당하는 이름입니다.

 

이게 Mongoose설정의 전부입니다. 이제 Passport의 전략을 구현할 수 있습니다.

 

 

 

9. 로컬 인증 구현하기.

 

그리고 마침내 우리가 여기 도달했습니다! 이제 로컬 인증을 설정해 보도록 하겠습니다. 아래에 보이는 것처럼 코드를 작성해 주세요:

 

/* PASSPORT LOCAL AUTHENTICATION */

passport.use(UserDetails.createStrategy());

passport.serializeUser(UserDetails.serializeUser());
passport.deserializeUser(UserDetails.deserializeUser());

 

여기에는 꽤 대단한 마술이 있습니다. 먼저 passport-local-mongoose에서 제공해주는 UserDeatilas모델에서 createStarategy()를 호출하여 Passport가 로컬 전략을 사용하도록 함으로써 우리는 전략에 대해 더 이상 설정을 해 줄 필요가 없도록 합니다. 꽤나 편리한 기능입니다.

 

그런 다음 serializeUser 및 deserializeUser 콜백을 사용합니다. 첫 번째는 인증 시 호출되며 사용자 인스턴스에 전달한 정보로 사용자 인스턴스를 serialize 하고 쿠키를 통해 세선에 저장하는 역할을 합니다. 두 번째는 모든 후속 요청을 호출해 인스턴스를 deserialize 하고 고유한 쿠키 식별자를 "credential"로 제공합니다. 자세한 내용은 Passport의 문서를 참고해 주세요.

 

 

 

10. 라우트.

 

이제 라우트를 추가해 모든 것을 하나로 묶어보도록 하겠습니다. 먼저 마지막으로 패키지를 추가하도록 합니다. 터미널로 이동해 다음 명령을 실행해 주세요:

 

npm install connect-ensure-login

 

connect-ensure-login 패키지는 사용자가 로그인을 할 수 있도록 하는 미들웨어입니다. 인증되지 않은 요청이 수신되면 로그인 페이지로 리디렉션 해줍니다. 이를 이용하여 우리의 라우트를 보호합니다. 이제 index.js에 다음 코드를 추가해 주세요:

 

/* ROUTES */

const connectEnsureLogin = require('connect-ensure-login');

app.post('/login', (req, res, next) => {
  passport.authenticate('local',
  (err, user, info) => {
    if (err) {
      return next(err);
    }

    if (!user) {
      return res.redirect('/login?info=' + info);
    }

    req.logIn(user, function(err) {
      if (err) {
        return next(err);
      }

      return res.redirect('/');
    });

  })(req, res, next);
});

app.get('/login',
  (req, res) => res.sendFile('html/login.html',
  { root: __dirname })
);

app.get('/',
  connectEnsureLogin.ensureLoggedIn(),
  (req, res) => res.sendFile('html/index.html', {root: __dirname})
);

app.get('/private',
  connectEnsureLogin.ensureLoggedIn(),
  (req, res) => res.sendFile('html/private.html', {root: __dirname})
);

app.get('/user',
  connectEnsureLogin.ensureLoggedIn(),
  (req, res) => res.send({user: req.user})
);

 

가장 위에 connect-ensure-login을 require 했습니다. 다음에 다시 확인하도록 하겠습니다. 

 

다음으로 /login 경로에 대한 POST 요청을 처리하도록 하는 라우트를 설정했습니다. 핸들러 내에서 passport.authenticate메서드를 사용합니다. 이 메서드는 첫 번째 매개변수로 받는 전략으로 인증을 시도하며 우리는 로컬 인증 전략을 사용했습니다. 인증에 실패하면 /login으로 리디렉션 되며 오류 메시지가 포함된 쿼리 매개변수인 info가 추가됩니다. 인증에 성공하면 '/' 경로로 리디렉션 됩니다.

 

그런 다음 /login 라우트를 설정해 로그인 페이지를 전송하도록 합니다. 이를 위해 res.sendFile()을 사용하였으며 매개변수로 파일 경로와 루트 디렉터리를 전달합니다. 이때의 디렉터리는 __dirname입니다.

 

/login은 누구나 액세스 할 수 있지만 login 다음의 경로는 아무나 액세스 할 수 없습니다. / 및 /private 경로에선 각각 HTML 페이지를 보내며 이 페이지들에서 다른 작업을 안내할 수 있습니다. 콜백 전에 connectEnsureLogin.ensureLoggedIn()을 호출하고 있습니다. 이는 우리의 경비원과 같은 역할을 합니다. 해당 경로를 볼 수 있는지 세션을 확인하는 역할을 수행합니다. "서버가 무거운 작업을 수행하게 한다"는 말의 의미를 알고 있나요? 우리는 지금 매번 사용자 인증을 수행하도록 하고 있습니다.

 

마지막으로 /user 라우트가 필요합니다. 이 라우트는 사용자 정보가 포함된 객체를 반환합니다. 이것은 단지 서버로부터 정보를 얻는 방법을 보여주기 위한 것입니다. 사용자들은 이 라우트에 요청을 하게 되고 결과를 표시하도록 합니다.

 

클라이언트에 대한 이야기를 지금 해보도록 합시다.

 

 

 

11. 클라이언트.

 

클라이언트는 단순해야 합니다. 간단한 HTML 페이지와 CSS 파일을 만들어 보도록 합시다. 홈페이지나 인덱스부터 시작하도록 하겠습니다. 프로젝트 루트에 html이라는 폴더를 만들고 index.html의 파일을 추가해 주세요. 그리고 다음 코드를 작성해 주세요:

 

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title> Home </title>
  <link rel="stylesheet" href="css/styles.css">
</head>

<body>
  <div class="message-box">
    <h1 id="welcome-message"></h1>
    <a href="/private">Go to private area</a>
  </div>

  <script>
    const req = new XMLHttpRequest();
    req.onreadystatechange = function () {
      if (req.readyState == 4 && req.status == 200) {
        const user = JSON.parse(req.response).user;
        document.getElementById("welcome-message").innerText = `Welcome ${user.username}!!`;
      }
    };
    req.open("GET", "http://localhost:3000/user", true);
    req.send();
  </script>
</body>
</html>

 

비어있는 h1태그가 있으며 여기에 환영 메시지를 표시합니다. 그 아래에 /private에 대한 링크를 넣어주고 있습니다. 여기서 중요한 부분은 사용자 이름으로 환영 메시지를 작성하도록 처리하는 태그 아래의 스크립트 부분입니다.

 

이 스크립트는 네 부분으로 나뉘어 있습니다.

  1. 새로운 XMLHttpRequrest()를 사용해 요청 객체를 인스턴스화 합니다.
  2. 결과를 얻은 후 호출할 함수로 onreadystatechange 속성을 설정합니다. 콜백에서 성공적인 결과를 받았는지 확인하고 성공했다면 파싱 후 user 객체를 가져와 welcome-message 엘리먼트를 찾아와 innerText를 user.username을 이용해 설정합니다. 
  3. open()으로 GET 요청을 생성합니다. 첫 번째 매개변수는 요청의 타입이고 두 번째 매개변수는 user URL이며 마지막 매개변수를 true로 설정해 비동기 요청을 활성화합니다.
  4. 마지막으로 send()를 이용해 요청을 보냅니다.

이제 로그인 페이지를 작성하도록 합니다. 이전과 같이 HTML폴더에 login.html이라는 파일을 생성하고 다음과 같이 코딩해 주세요:

 

<!DOCTYPE html>
<html lang="en">
<head>
  <title> Login </title>
  <link rel="stylesheet" href="css/styles.css">
</head>

<body>
  <form action="/login" method="post">
    <div class="title">
      <h3>Login</h3>
    </div>
    <div class="field">
      <label>Username:</label>
      <input type="text" name="username" />
      <br />
    </div>
    <div class="field">
      <label>Password:</label>
      <input type="password" name="password" required />
    </div>
    <div class="field">
      <input class="submit-btn" type="submit" value="Submit" required />
    </div>
    <label id="error-message"></label>
  </form>

  <script>
    const urlParams = new URLSearchParams(window.location.search);
    const info = urlParams.get('info');

    if(info) {
      const errorMessage = document.getElementById("error-message");
      errorMessage.innerText = info;
      errorMessage.style.display = "block";
    }
  </script>
</body>
</html>

 

사용자 이름, 비밀번호, 제출 버튼이 있는 간단한 로그인 폼 페이지입니다. 아래에는 오류 메시지를 표시할 레이블이 위치합니다. 이것들은 쿼리 문자열에 포함되어 있음을 잊지 마세요.

 

하단에 있는 이번 스크립트는 훨씬 간단합니다. 우리의 URL에 매개변수로 포함되어 있는 window.location.search 속성을 전달하고 있는 URLSearchParams객체를 인스턴스화 하고 있습니다. 그런 뒤 URLSearchParams.get() 메서드를 사용해 우리가 찾고 있는 매개변수 이름을 전달합니다.

 

이때, 우린 info메시지가 있을 수도 없을 수도 있습니다. 따라서 error-message요소를 가져와 뭐든 간에 innerText로 설정한 뒤 style.display속성을 block로 설정합니다. 기본값으로 disaply: none으로 표시되는 것을 볼 수 있습니다. 

 

이제 private 페이지를 만들어 보겠습니다. HTML 폴더 안에 private.html 파일을 생성한 뒤 다음과 같이 코딩해주세요:

 

<!DOCTYPE html>
<html lang="en">
<head>
  <title> Private </title>
  <link rel="stylesheet" href="css/styles.css">
</head>

<body>
  <div class="message-box">
    <h2>This is a private area</h2>
    <h3>Only you can see it</h3>
    <a href="/">Go back</a>
  </div>
</body>
</html>

 

매우 간단합니다. 단순한 메시지를 표시해주고 돌아가기 링크를 누르면 홈페이지로 돌아갑니다.

 

아마 알고 있겠지만 HTML에서 CSS를 참조하고 있습니다. 이제 그 CSS파일을 추가해 보도록 하겠습니다. 프로젝트 루트에 css라는 폴더를 생성 한 뒤 style.css 파일을 생성해주세요. 그리고 다음과 같이 코딩해주세요:

 

body {
  display: flex;
  align-items: center;
  background: #37474F;
  font-family: monospace;
  color: #cfd8dc;
  justify-content: center;
  font-size: 20px;
}

.message-box {
  text-align: center;
}

a {
  color: azure;
}

.field {
  margin: 10px;
}

input {
  font-family: monospace;
  font-size: 20px;
  border: none;
  background: #1c232636;
  color: #CFD8DC;
  padding: 7px;
  border: #4c5a61 solid 2px;
  width: 300px;
}

.submit-btn {
  width: 100%
}

.title {
  margin: 10px 0px 20px 10px
}

#error-message {
  color: #E91E63;
  display: block;
  margin: 10px;
  font-size: large;
  max-width: fit-content;
}

 

이러면 이제 페이지가 보기 좋게 변할 겁니다. 확인해 봅시다.

 

프로젝트 루트 경로에서 다음 명령어를 실행해 주세요:

 

node index.js

 

이제 브라우저를 열고 http://localhost:3000/으로 이동해 보세요 로그인 페이지로 리디렉션 될 겁니다. 그 뒤 http://localhost:300/private로 이동하면 다시 로그인 페이지로 리디렉션 됩니다. 우리의 라우터 가드가 일을 하고 있다는 겁니다.

 

이제 서버를 중지하고 index.js 파일로 가서 다음 코드를 추가해 주세요:

 

/* REGISTER SOME USERS */

UserDetails.register({username:'paul', active: false}, 'paul');
UserDetails.register({username:'jay', active: false}, 'jay');
UserDetails.register({username:'roy', active: false}, 'roy');

 

passport-local-mongoose의 register 메서드를 이용해 암호를 지정해 줍니다. 우린 단지 일반적인 텍스트로 전달해 주기만 하면 됩니다.

 

다시 index.js를 실행시키면 유저가 생성됩니다. 

 

아까 열어둔 mongo 터미널을 다시 사용해 봅시다. 다음 쿼리를 전송해 보세요:

 

db.userInfo.find()

 

세명의 사용자를 볼 수 있으며 해시값과 salt값이 화면의 대부분을 차지할 겁니다.

 

이것들이 앱이 동작하는데 필요한 전부입니다. 끝났습니다!

 

브라우저로 돌아가서 우리가 등록한 유저로 로그인을 시도하면 사용자 이름이 포함된 로그인 메시지가 보일 겁니다.

 

 

 

12. 다음 단계.

 

지금까지 우린 단지 딱 이 앱이 동작할 정도만의 모듈만 추가했습니다. 배포 환경의 앱을 위해선 다른 미들웨어를 추가하고 코드를 분리해야 합니다. 현재 상태에서는 깔끔하고 확장 가능한 환경으로 발전해 나가는데 어려울 수 있습니다.

 

다음 해야 할 가장 쉬운 도전은 Passport의 req.logout() 메서드를 사용해 로그아웃을 추가해 보는 것입니다.

 

그러면 회원 등록을 구현해 볼 수 있습니다. 회원 등록 폼과 라우트가 필요하게 될 겁니다. 앞서 구현한 UserDetails.register()를 탬플릿으로 사용할 수 있습니다. 이메일 인증을 위해서는 nodemailer를 체크아웃받아야 할 수 도 있습니다.

 

시도해 볼 수 있는 다른 일은 SPA(Single Page Application, 단일 페이지 애플리케이션)에 적용시켜보는 것입니다. 아마 Vue.js와 Vue의 라우터를 사용할 수도 있을 겁니다. 그렇게 주말을 보내보세요!

 

 

 

13. 맺음말.

 

마침내 끝을 맺었습니다. 이 글에서는 Node.js 애플리케이션에서 Passport를 이용해 로컬 인증을 구현하는 방법에 대해 알아봤습니다. 이 과정에서 Mongoose를 이용해 MongoDB와 연결하는 방법도 설명했습니다. 

 

이 과정이 페인트칠하는 것만큼 쉬운 일은 아니었지만 적어도 우린 백그라운드에서 마술과 같은 툴을 이용하면 우리가 원하는 것에만 신경 쓸 수 있도록 만들어 주어 일이 더 쉬워진다는 것을 알게 되었습니다.

 

마술과 같은 도구는 항상 이상적이진 않습니다만 평판이 좋고 활발히 유지 보수 되는 도구를 사용하면 코드를 적게 만드는데 도움이 됩니다. 작성하지 않은 코드는 유지 보수할 필요가 없으며 유지 보수할 필요가 없다는 것은 당신이 실수할 일이 없다는 걸 의미합니다.

 

또한 핵심적인 팀이 적극적으로 유지 보수하는 경우 그 팀은 우리보다 그 일에 대해 더 잘할 겁니다. 적극적으로 사용하세요.

 

이 튜토리얼을 즐기고 다음 프로젝트에 대한 영감을 얻었길 바랍니다. 즐거운 코딩 하세요!

 

 

 

 

 

반응형

 

서버 운영에 있어서 성능과 안정성은 매우 중요한 요소들 중 하나입니다. 이 글에선 Express 서버를 이용할 때 여러 워커를 클러스터 구성을 이용해 서비스하고 NGINX를 이용해 이중화 구성을 하는 방법에 대해 알아보도록 합니다.

 

 

 

0. 사전 준비.

 

이 글에서 웹서버는 React와 Express를 사용해 배포한 서버를 사용할 예정입니다. 실제 배포는  Docker를 사용해 도커 이미지를 생성한 후 실행시키도록 할 예정입니다. 미리 NPM와 Docker를 준비합시다.

마지막으로 모든 작업 결과물을 한번에 구동시키기 위해 Docker compose를 사용할 예정이니 Docker compose도 준비해 주세요.

 

 

 

1. NodeJS

 

 

NodeJS는 구글이 구글 크롬에 사용하려고 제작한 V8 오픈소스 자바스크립트 엔진을 기반으로 제작된 자바스크립트 런타임입니다. NodeJS는 다음과 같은 특징이 있습니다.

  1. 단일 스레드.
  2. 비동기 방식.
  3. 이벤트 루프를 사용.
  4. NPM.

NodeJS는 싱글 스레드이기 때문에 하나의 CPU를 여럿이 나눠 갖는 건 비효율적입니다. 따라서 CPU 숫자에 맞춰서 서버를 띄워보겠습니다.

 

 

 

2. 웹서버 생성.

 

 

우선 프론트를 준비합니다. 다음 명령어로 react app을 생성해 주세요.

 

$ npx create-reac-app my-react

 

이 앱을 Express 서버로 서비스할 예정입니다. 폴더로 들어가서 다음 패키지를 설치하세요.

 

$ cd my-react

 

$ npm i express express-favicon 

 

이제 express 서버를 실행할 코드를 작성해야 합니다. server 폴더에 index.js파일을 만드신 후 다음과 같이 코딩해 주세요.

 

//// ./server/index.js

const express = require('express');
const favicon = require('express-favicon');
const path = require('path');

const app = express();
const port = 3000;
const server = app.listen(port, () => {
    console.log(`Server is listening on port ${server.address().port}.`);
});

app.use(favicon(path.join(__dirname, '../public/favicon.ico')));
app.use(express.static(__dirname));
app.use(express.static(path.join(__dirname, '../build')));

app.get('/ping', (req, res) => {
    return res.send('pong');
});
app.get('/*', (req, res) => {
    return res.sendFile(path.join(__dirname, '../build', 'index.html'));
});

 

이제 기본으로 생성된 react를 빌드해 줍시다.

 

$ npm run build

 

여기까지 수행하셨으면 폴더 구조가 다음과 같을 겁니다.

 

 

우리가 작성한 express서버가 react app을 잘 서빙하는지 확인해 봅시다. localhost:3000으로 이동해 react 앱을 확인해 보세요. 그리고 localhost:3000/ping으로 이동해 pong 메시지를 잘 수신하는지도 확인합시다.

 

 

 

3. 클러스터 구성.

 

앞서 설명한 대로 NodeJS는 단일 스레드입니다. 이제 우린 서버의 CPU개수에 맞춰서 여러 워커를 띄어서 서버를 운영해 보도록 하겠습니다.

 

여러 워커가 돌아갈 때 워커가 동작하는 서버를 구분하기 위해 uuid 패키지를 설치합니다.

 

$ npm i uuid

 

그리고./server/index.js를 다음과 같이 수정해주세요.

 

//// ./server/index.js
const cluster = require('cluster');
const os = require('os');
const uuid = require('uuid');

const port = 3000;
const instance_id = uuid.v4();

//// Create worker.
const cpu_count = os.cpus().length;
const worker_count = cpu_count / 2;

//// If master, create workers and revive dead worker.
if(cluster.isMaster){
    console.log(`Server ID: ${instance_id}`);
    console.log(`Number of server's CPU: ${cpu_count}`);
    console.log(`Number of workers to create: ${worker_count}`);
    console.log(`Now create total ${worker_count} workers ...`);

    //// Message listener
    const workerMsgListener = (msg) => {
        const worker_id = msg.worker_id;
        //// Send master's id.
        if(msg.cmd === 'MASTER_ID'){
            cluster.workers[worker_id].send({cmd: 'MASTER_ID', master_id: instance_id});
        }
    }

    //// Create workers
    for(var i = 0; i < worker_count; i ++){
        const worker = cluster.fork();
        console.log(`Worker is created. [${i +1}/${worker_count}]`);
        worker.on('message', workerMsgListener);
    }

    //// Worker is now online.
    cluster.on('online', (worker) => { console.log(`Worker is now online: ${worker.process.pid}`); });

    //// Re-create dead worker.
    cluster.on('exit', (deadWorker) => {
        console.log(`Worker is dead: ${deadWorker.process.pid}`);
        const worker = cluster.fork();
        console.log(`New worker is created.`);
        worker.on('message', workerMsgListener);
    });
}
//// If worker, run servers.
else if(cluster.isWorker){
    const express = require('express');
    const favicon = require('express-favicon');
    const path = require('path');

    const app = express();
    const worker_id = cluster.worker.id;
    const server = app.listen(port, () => { console.log(`Server is listening on port ${server.address().port}.`); });

    let master_id = "";

    //// Request master's id to master.
    process.send({worker_id: worker_id, cmd: 'MASTER_ID'});
    process.on('message', (msg) => {
        if(msg.cmd === 'MASTER_ID'){
            master_id = msg.master_id;
        }
    });

    app.use(favicon(path.join(__dirname, '../public/favicon.ico')));
    app.use(express.static(__dirname));
    app.use(express.static(path.join(__dirname, '../build')));

    app.get('/ping', (req, res) => { return res.send('pong'); });
    app.get('/where', (req, res) => { return res.send(`Running server: ${master_id} \n Running worker: ${worker_id}`); });
    app.get('/kill', (req, res) => { cluster.worker.kill(); return res.send(`Called worker killer.`);})
    app.get('/*', (req, res) => { return res.sendFile(path.join(__dirname, '../build', 'index.html')); });
}

 

소스가 길어졌으니 주석을 확인하면서 차근차근 코딩해 주세요. 간단히 설명해드리자면 다음과 같습니다.

  1. 우리는 한 서버의 CPU개수의 절반만큼의 워커를 생성할 겁니다.
  2. 만약 현재 생성된 클러스터가 마스터라면 워커 클러스터를 생성하고 관리하는 역할을 수행합니다.
  3. 마스터는 미리 정해진 개수만큼 워커를 생성하고 만약 죽은 워커가 발견된 경우 새 워커를 생성시켜 항상 일정 개수의 워커가 서버에서 동작할 수 있도록 합니다.
  4. 워커는 express 서버를 구동합니다.
  5. 워커에는 어느 서버에서 수행되고 있는지 확인할 수 있는 기능과 현재 워커를 죽일 수 있는 기능이 추가되었습니다.

코딩이 끝났다면 다시 서버를 실행시켜 보도록 합시다.

 

$ node server

 

콘솔에서 다음과 같은 메시지를 확인할 수 있습니다.

 

 

이제 localhost:3000/where로 이동해 보세요. 현재 우리가 접속한 서버와 워커의 번호를 확인할 수 있습니다. 여기서 localhost:3000/kill로 이동하면 워커를 죽일 수 있으며 이때 마스터는 죽은 워커를 확인 해 새 워커를 생성합니다. 

 

 

이후 다시 localhost:3000/where로 이동하면 워커의 번호가 변경된 것을 확인할 수 있습니다.

 

 

 

4. Docker를 이용한 서버 구동

 

 

이제 우리가 작성한 서버를 도커를 이용해 배포해봅시다. 간단히 배포하기 위해 Dockerfile을 사용해 배포할 예정입니다. 

 

Dockerfile 파일을 생성 한 뒤 다음과 같이 작성해 주세요.

 

# ./Dockerfile

FROM node:slim

# app 폴더 생성.
RUN mkdir -p /app

# 작업 폴더를 app폴더로 지정.
WORKDIR /app

# dockerfile과 같은 경로의 파일들을 app폴더로 복사
ADD ./ /app

# 패키지 파일 설치.
RUN npm install

# 환경을 배포 환경으로 변경.
ENV NODE_ENV=production

#빌드 수행
RUN npm run build

ENV HOST=0.0.0.0 PORT=3000
EXPOSE ${PORT}

#서버 실행
CMD ["node", "server"]

 

아주 간단한 Dockerfile을 작성했습니다. 주석을 참고하시면 모두 이해하실 수 있을 겁니다. 이제 다음 명령어를 통해 작성한 Dockerfile을 이용해 도커 이미지를 생성합니다.

 

$ docker build . -t my-react:0.0.1

 

 

도커 이미지가 정상적으로 생성된 것을 확인할 수 있습니다. 다음 명령어를 통해 직접 실행시켜 주세요.

 

$ docker run -itd -p 8080:3000 my-react:0.0.1

 

이제 localhost:8080로 이동하면 모든 기능을 정상적으로 사용할 수 있습니다.

 

 

 

 

 

 

반응형

 

Storybook 패키지에 대해 알아봅니다.

 

 

 

1. Storybook이란?

 

최근 프론트엔드 개발은 페이지 단위가 아닌 컴포넌트 단위로 개발된 다는 것을 들어본 적이 있을 겁니다. 프로젝트 내에서 컴포넌트를 개발하다 보면 초기에는 별 문제가 없지만 프로젝트의 규모가 커질수록 컴포넌트 개발에 독립성을 유지하기 힘들어집니다. 이런 경우 스토리북을 이용하면 보다 편하게 컴포넌트를 개발할 수 있습니다.

 

Build bulletproof UI components faster
Storybook is an open source tool for developing UI components in isolation for React, Vue, and Angular. It makes building stunning UIs organized and efficient.

 

스토리북의 공식 홈페이지를 방문하면 가장 먼저 우리를 반겨주는 문구입니다. 위의 설명대로 스토리북은 독립된 환경에서 컴포넌트를 개발하고 이를 확인해 볼 수 있는 오픈소스 라이브러리 입니다. 이러한 환경에서 개발된 컴포넌트는 독립성이 유지되기 때문에 자연히 재사용성이 좋아집니다. 또한 스토리북은 개발뿐 아니라 문서화, 테스팅에 대해서도 좋은 기능을 제공해 줍니다.

 

 

 

2. Storybook 사용해보기.

 

먼저 스토리북을 사용하기 위해 예시 프로젝트를 생성합니다. 이 글에서는 create-react-app을 통해 예시 프로젝트를 생성하겠습니다.

 

> create-react-app storybook-ex

> cd storybook-ex 

> npx -p @storybook/cli sb init --type react_scripts

 

여기까지 진행한 뒤 package.json파일을 확인합니다. script항목에 보면 전에 없던 스토리북에 관련된 스크립트를 확인할 수 있습니다. 

 

  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "storybook": "start-storybook -p 9009 -s public",
    "build-storybook": "build-storybook -s public"
  },

 

이제 스토리북을 실행시켜 9009번 포트로 접속해 봅시다.

 

> npm run strorybook

> http://localhost:9009/로 이동.

 

 

정상적으로 스토리북이 실행된 것을 확인할 수 있습니다. 좌측에 "Welcom"과 "Button"이 보이시나요? 이것들을 스토리북에선 스토리라고 부릅니다. 스토리란 결국 우리가 컴포넌트를 구현하게 될 독립적 공간이 되는것 입니다. 

 

이제 다시 프로젝트로 돌아가서 위의 스토리들이 어디에 위치하는지 확인해 봅시다. ../.storybook 폴더 내의 main.js 파일을 열어보세요.

 

module.exports = {
  stories: ['../src/**/*.stories.js'],
  addons: [
    '@storybook/preset-create-react-app',
    '@storybook/addon-actions',
    '@storybook/addon-links',
  ],
};

 

스토리북에서 사용하는 애드온들과 스토리에 대한 설정이 정의되어 있습니다. stories에 설정된 값을 확인해보면 src 폴더 내에 있는 .stories.js로 끝나는 모든 파일을 스토리 파일로 인식하도록 설정되어 있습니다. 실제로 위의 "Welcom"과 "Button" 스토리들은 src폴더 내의 stories폴더에 존재하고 있습니다.

 

새로운 스토리를 생성하기 위해서는 src폴더 내 임의 폴더에 파일이름.stories.js로 파일을 생성 한 뒤 컴포넌트를 작성하면 됩니다.

 

 

 

 

 

반응형

 

Express 서버에서 Graphql을 사용하는 방법에 대해 알아봅니다.

 

 

 

1. express-graphql 설치

 

이 글에서는 express 서버에서 graphql을 사용하는 방법에 대해 알아보기로 했습니다. GraphQL을 따로 사용할 수도 있지만 이 경우에 어울리는 패키지가 이미 존재합니다. 

 

다음 명령어를 통해 Graphql과 espress-graphql을 설치합니다.

 

> npm install graphql express-graphql express

 

이제 다음과 같이 코딩한 뒤 직접 접속해 봅시다.

 

const express = require('express');
const graphqlHTTP = require('express-graphql');
const app = express();

app.use('/graphql', graphqlHTTP({
    schema: MyGraphQLSchema,
    graphiql: true
}));

app.get('/', function(req, res){
    res.send('Hello Express!');
});

app.listen(3300, function(){
    console.log("Express server is listening on port 3300.");
});

 

접속이 되시나요? MyGraphQLSchema 때문에 실행이 안된다면 정상입니다. express-graphql 공식 가이드에 따르면 schema 항목에 대해 다음과 같은 설명이 있습니다.

 

schema: A GraphQLSchema instance from GraphQL.js. A schema must be provided.

 

이제 진짜 스키마를 제공하기 위해 직접 작성해 봅시다.

 

 

 

2. Schema 작성 및 사용.

 

이제 GraphQL의 buildSchema를 사용해 스키마를 만들고 사용해 보도록 하겠습니다.

 

다음과 같이 코드를 수정합니다.

 

const express = require('express');
const graphqlHTTP = require('express-graphql');
const { graphql, buildSchema } = require('graphql');
const app = express();

var mySchema = buildSchema(`
  type Query {
    hello: String
  }
`);

app.use('/graphql', graphqlHTTP({
    schema: mySchema,
    graphiql: true,
}));

app.get('/', function(req, res){
    res.send('Hello Express!');
});

app.listen(3300, function(){
    console.log("Express server is listening on port 3300.");
});

 

이제 실행이 되시나요? graphiql 페이지로 이동한 후 직접 쿼리를 날려봅시다

 

 

슬프게도 null이 리턴됩니다. 아직 우리의 작업이 다 끝나지 않았다는 말이죠. 생각해 봅시다. 우리가 지금까지 hello에 어떤 값을 리턴해줘라 라는 코드를 작성한 적이 있나요?

 

이제 hello가 어떤 값을 리턴해야 하는지에 대한 코드를 작성해 보겠습니다. 겸사겸시 다른 스키마도 정의해서 수정해 보도록 합시다.

 

const express = require('express');
const graphqlHTTP = require('express-graphql');
const { graphql, buildSchema } = require('graphql');
const app = express();

var mySchema = buildSchema(`
  type Query {
    hello: String
    users: [User]
  }
  type User{
    email: String
    name: String
  }
`);

var root = { 
    hello: () => 'Hello world!',
    users: () => { 
        return [
            {email: 'A@A.com', name: 'A'},
            {email: 'B@B.com', name: 'B'},
            {email: 'C@C.com', name: 'C'},
        ];
    },
};

app.use('/graphql', graphqlHTTP({
    schema: mySchema,
    graphiql: true,
    rootValue: root,
}));

app.get('/', function(req, res){
    res.send('Hello Express!');
});

app.listen(3300, function(){
    console.log("Express server is listening on port 3300.");
});

 

이제 다시 Graphiql로 이동해 다음 쿼리를 수행해 봅시다.

 

 

 

정상적으로 데이터가 출력되는 것을 확인 할 수 있습니다.

 

 

 

 

반응형

'Programming > JavaScript' 카테고리의 다른 글

Express를 이용한 서버 클러스터 구성.  (1) 2020.06.18
Storybook  (0) 2020.06.02
NodeJs - PostgreSql 연동하기.  (0) 2020.04.04
[React] 불변성 관리 - Immer  (0) 2019.12.30
[Redux] Redux란?  (0) 2019.10.04

 

Express 서버와 pg 패키지를 이용해 PostgreSql 커넥터를 만들고 DB에서 데이터를 가져옵니다.

 

 

 

0. Postgre DB 준비.

 

코딩에 앞서 먼저 DB를 준비합니다. 이 글에서 별도로 Postgre DB를 설치하는 방법에 대해 작성하진 않습니다.

 

다음 글을 참조하셔서 미리 DB를 준비해 주시기 바랍니다.

 

 

 

1. Express서버 준비.

 

express 패키지를 설치 한 뒤 다음과 같이 코딩해 express서버를 준비합니다.

 

> npm intall express

 

var express = require('express');
var app = express();

app.get('/', function(req, res){
    res.send('Hello World!');
});

app.listen(3300, function(){
    console.log("Express server is listening on port 3300.");
});

 

 

 

2. PostgreSql 커넥터 작성.

 

pg 패키지를 설치한 뒤 다음과 같이 코딩해 Postgresql 커넥터를 준비합니다.

 

> npm install pg

 

const vals =  require('./const.js');
const { Pool, Client } = require('pg');

const client = new Client({
    user: vals.user, password: vals.pass,
    host: vals.host, port: vals.port,
    database: vals.db
});

function GetUserList() {
    client.connect();
    client.query('SELECT * FROM users', (err, res) => {
        console.log(res);
        client.end();
    });
};
 
module.exports = {
    getUserList: GetUserList
}

 

** const.js에는 DB 접속에 필요한 정보가 담겨있습니다.

** 별도의 js 파일이 아닌 해당 값을 직접 user, password, hose, port, databse에 넣어주셔도 됩니다.

 

 

 

3. Express에서 커넥터 호출하기

 

작성한 커넥터를 호출하기 위해 Express의 코드를 수정합니다.

 

var express = require('express');
var app = express();

var pgDBConn = require('./pgDBConn.js');

pgDBConn.getUserList();

app.get('/', function(req, res){
    res.send('Hello World!');
});

app.listen(3300, function(){
    console.log("Express server is listening on port 3300.");
});

 

이제 Express 서버를 실행시키면 콘솔 창에 정상적으로 데이터를 가져오는 것을 확인할 수 있습니다.

 

 

 

 

 

반응형

'Programming > JavaScript' 카테고리의 다른 글

Storybook  (0) 2020.06.02
Express에서 GraphQL 사용하기: express-graphql  (0) 2020.04.04
[React] 불변성 관리 - Immer  (0) 2019.12.30
[Redux] Redux란?  (0) 2019.10.04
[jqGrid] 컬럼 순서 변경시 오류가 날 경우  (0) 2019.08.23

+ Recent posts