리액트 컴포넌트 스타일링, API 연동 실습 – NASA 에서 오늘의 우주 사진 가져오기


이번 튜토리얼에서는 Sass 를 사용하여 컴포넌트 스타일링을 연습삼아 해보고, NASA 에서 Open API 로 제공하는 Astronomy Picture of the Day (오늘의 우주사진)들을 가져와서 화면에 띄워주는 작업을 진행해보겠습니다.

리액트 프로젝트에서는 다양한 방법으로 컴포넌트를 스타일링 해줄 수 있는데요 (리액트 컴포넌트 스타일링 포스트), 그 중에서 우리는 Sass +CSS Module 의 조합으로 프로젝트를 만드는 방법을 실습해보겠습니다. 이 과정에서 프로미스기반의 HTTP Client 인 axios 를 사용하여 간단한 웹 요청을 처리하는 방법도 배워보겠습니다.

sass 를 잘 모르신다면 다음 자료들이 큰 도움이 될 것 입니다.

  • https://velopert.com/1712
  • https://sass-guidelin.es/ko/

추가적으로, CSS Module 이 익숙하지 않다면, 리액트 컴포넌트 스타일링 포스트를 쭉 훑어보고 오세요 🙂

이 포스트는 Fastcampus 의 오프라인 리액트 강의 에서 사용된 자료로서, 부연설명이 조금 생략되어있습니다.

프로젝트 코드는 Github 에서 확인 하실 수 있습니다.

작업환경 설정

create-react-app 으로 프로젝트를 생성하세요.

$ create-react-app nasa-apod

그 다음에는, 우리는 Sass 를 사용하기 위하여 프로젝트 설정을 커스터마이징 해주어야 하니 yarn eject 를 통하여 내부 설정들이 우리에게 보여질 수 있도록 밖으로 꺼내주세요.

$ cd create-react-app
$ yarn eject

필요한 모듈 설치

우리가 앞으로 프로젝트를 진행하면서 필요하게 될 라이브러리들을 설치하겠습니다.

$ yarn add axios classnames sass-loader node-sass include-media open-color better-react-spinkit react-icons moment
  • axios: Promise 기반 웹 요청 클라이언트
  • classnames: CSS Module 과 조건부 className 을 설정 하는 것을 도와주는 라이브러리
  • sass-loader, node-sass: 프로젝트에서 Sass 를 사용하기 위하여 필요한 도구
  • include-media, open-color: Sass 라이브러리 (반응형 디자인, 색상 팔레트)
  • better-react-spinkit: 로딩 시 보여줄 컴포넌트
  • react-icons: SVG 형태의 리액트 컴포넌트 모음 라이브러리
  • moemnt: 날짜 관련 라이브러리

Sass + CSS Module 적용

우리의 프로젝트에는 Sass 와 CSS Module 을 함께 사용하겠습니다.

config 디렉토리 내부의 webpack.config.dev.js 파일을 열어서 style-loader 를 검색해보세요.

config/webpack.config.dev.js – css 설정 부분

          {
            test: /\.css$/,
            use: [
              require.resolve('style-loader'),
              {
                loader: require.resolve('css-loader'),
                options: {
                  importLoaders: 1,
                },
              },
              {
                loader: require.resolve('postcss-loader'),
                options: {
                  // Necessary for external CSS imports to work
                  // https://github.com/facebookincubator/create-react-app/issues/2677
                  ident: 'postcss',
                  plugins: () => [
                    require('postcss-flexbugs-fixes'),
                    autoprefixer({
                      browsers: [
                        '>1%',
                        'last 4 versions',
                        'Firefox ESR',
                        'not ie < 9', // React doesn't support IE8 anyway
                      ],
                      flexbox: 'no-2009',
                    }),
                  ],
                },
              },
            ],
          },

이런 부분이 보여질 것입니다. 해당 부분을 그대로 복사하여 바로 아래에 붙여넣으세요. 그리고, 확장자를 scss 로 변경하고, css-loader 의 options 를 설정하고, 배열의 끝에 sass-loader 를 설정하세요.

config/webpack.config.dev.js

          {
            test: /\.css$/,
            (...)
          },
          {
            test: /\.scss$/,
            use: [
              require.resolve('style-loader'),
              {
                loader: require.resolve('css-loader'),
                options: {
                  importLoaders: 1,
                  modules: true,
                  localIdentName: '[name]__[local]__[hash:base64:5]'
                },
              },
              {
                loader: require.resolve('postcss-loader'),
                options: {
                  // Necessary for external CSS imports to work
                  // https://github.com/facebookincubator/create-react-app/issues/2677
                  ident: 'postcss',
                  plugins: () => [
                    require('postcss-flexbugs-fixes'),
                    autoprefixer({
                      browsers: [
                        '>1%',
                        'last 4 versions',
                        'Firefox ESR',
                        'not ie < 9', // React doesn't support IE8 anyway
                      ],
                      flexbox: 'no-2009',
                    }),
                  ],
                },
              },
              {
                loader: require.resolve('sass-loader'),
                options: {
                  includePaths: [paths.styles]
                }
              }
            ],
          },

여기서, sass-loader 쪽에 includePaths 를 넣어주었는데, 이 값은 sass 에서 공통적으로 사용되는 유틸 함수들을 필요할 때 import ../../styles/utils 형식으로 작성 할 필요 없이 @import 'utils'; 형태로 불러 올 수 있게 해주는 설정입니다.

그러려면, paths.styles 를 설정해주어야 하는데요, 이 값은 config.paths.js 파일 내부에 있습니다.

다음과 같이 파일의 맨 끝에 styles 값도 설정하세요.

config/paths.js

(...)

// config after eject: we're in ./config/
module.exports = {
  dotenv: resolveApp('.env'),
  appBuild: resolveApp('build'),
  appPublic: resolveApp('public'),
  appHtml: resolveApp('public/index.html'),
  appIndexJs: resolveApp('src/index.js'),
  appPackageJson: resolveApp('package.json'),
  appSrc: resolveApp('src'),
  yarnLockFile: resolveApp('yarn.lock'),
  testsSetup: resolveApp('src/setupTests.js'),
  appNodeModules: resolveApp('node_modules'),
  publicUrl: getPublicUrl(resolveApp('package.json')),
  servedPath: getServedPath(resolveApp('package.json')),
  styles: resolveApp('src/styles')
};

이제 yarn start 를 통하여 개발서버를 구동시키세요. 문제 없이 실행 되나요?

프로젝트 초기화

불필요한 파일들을 제거하겠습니다. 다음 파일들을 제거하세요:

  • src/App.css
  • src/App.test.js
  • src/logo.svg
  • src/index.css

이에 따라 코드들도 바꿔주어야겠죠? 우선 App 컴포넌트부터 비워줍시다.

src/App.js

import React, { Component } from 'react';

class App extends Component {
  render() {
    return (
      <div>
        App
      </div>
    );
  }
}

export default App;

그리고, index.js 에서 index.css 를 불러오는 코드를 지워주겠습니다.

src/index.js

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

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

스타일 유틸과 base 스타일 설정

페이지의 기반 스타일링을 할 base 파일과, 추후 유틸로 불러와서 사용할 utils 파일을 생성해주겠습니다.

src 디렉토리에 styles 디렉토리를 만드세요. 그리고, 그 안에 utils.scss 파일을 작성하세요.

src/styles/utils.scss

@import '~open-color/open-color';
@import '~include-media/dist/include-media';

경로에 ~ 이 들어간다는 것은, node_modules 내부의 디렉토리를 사용한다는 것 입니다.

우리는 색상 팔레트와 반응형 디자인을 쉽게 해주는 라이브러리를 적용하였습니다. 현 프로젝트에서 위 라이브러리들이 필수적인 것은 아니지만, Sass 를 사용하면서 이런식으로 사용 할 수 있다는 것을 알아보고자 적용 한 것입니다.

그 다음에는 base.scss 파일을 작성하세요.

src/base.scss

@import 'utils';

body {
  margin: 0;
  background: $oc-gray-8;
  box-sizing: border-box;
}

* {
  box-sizing: inherit;
}

여기서 사용한 $oc-gray-8 이 open-color 에 등록된 색상 변수입니다. open-color 에 등록된 변수를 사용하려면, 가이드를 참고하여 거기서 나타나는 색상값과 명암값을 다음과 같은 형식으로 변수명으로 작성하면 됩니다: $oc-[색상]-[명암]

이 파이릉ㄹ 다 작성하셨다면, index.js 에서 불러오세요.

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import './styles/base.scss';

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

이렇게 하고나면 페이지가 회색으로 변할 것입니다.

컴포넌트 틀 준비하기

우리는 앞으로 3가지의 컴포넌트를 만들게 됩니다. 그리고, 우리는 CSS Module 과, Sass 를 적용하여 컴포넌트를 만들기에, 각 컴포넌트마다 디렉토리를 하나씩 만들어 줄 것이고, 각 디렉토리에 3종류의 파일을 만들게됩니다.

  1. ComponentName.js
  2. ComponentName.scss
  3. index.js

index.js 의 경우엔, 우리가 나중에 컴포넌트를 불러오게 될 때 src/components/ComponentName/ComponentName 이 아니라 src/components/ComponentName 형식으로 불러올 수 있도록, 컴포넌트를 불러와서 바로 내보내주는 파일입니다. 다음과 같이 말이죠:

export { default } from './ComponentName';

각 컴포넌트 파일은 다음과 같은 형식을 갖추고 있습니다:

import React from 'react';
import styles from './ComponentName.scss';
import classNames from 'classnames/bind';

const cx = classNames.bind(styles);

const ComponentName = () => {
  return (
    <div className={cx('component-name')}>
      
    </div>
  );
};

export default ComponentName;

그러면, 각 컴포넌트들의 틀을 준비해볼까요?

ViewerTemplate

이 컴포넌트는 템플릿 컴포넌트로서 JSX 형태의 props 인 viewer, spaceNavigator 를 받아와서 적당한 위치에 렌더링해줍니다.

다음 파일들을 생성하세요:

  • src/components/ViewerTemplate/ViewerTemplate.js
  • src/components/ViewerTemplate/ViewerTemplate.scss
  • src/components/ViewerTemplate/index.js

src/components/ViewerTemplate/ViewerTemplate.js

import React from 'react';
import styles from './ViewerTemplate.scss';
import classNames from 'classnames/bind';

const cx = classNames.bind(styles);

const ViewerTemplate = ({ viewer, spaceNavigator }) => {
  return (
    <div className={cx('viewer-template')}>
      
    </div>
  );
};

export default ViewerTemplate;

src/components/ViewerTemplate/ViewerTemplate.scss

.viewer-template {
  
}

src/components/ViewerTemplate/index.js

export { default } from './ViewerTemplate';

Viewer

이 컴포넌트는 이미지 혹은 동영상을 보여주는 컴포넌트입니다. 데이터의 형식은 mediaType 에 “video” 혹은 “image” 라는 값으로 전달이 될 것이고, 이에 따라 url 을 사용하여 동영상이나 이미지를 보여주게 됩니다. 추가적으로, loading 값은 데이터를 불러올 때 로딩표시를 하기 위하여 사용되는 props 입니다.

  • src/components/Viewer/Viewer.js
  • src/components/Viewer/Viewer.scss
  • src/components/Viewer/index.js

src/components/Viewer/Viewer.js

import React from 'react';
import styles from './Viewer.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);

const Viewer = ({mediaType, url, loading}) => {
  return (
    <div className={cx('viewer')}>
      
    </div>
  );
};

export default Viewer;

src/components/Viewer/Viewer.scss

.viewer {
  
}

src/components/Viewer/index.js

export { default } from './Viewer';

SpaceNavigator

이 컴포넌트는 앞, 혹은 뒤로 넘기는 버튼들을 내장하고 있습니다. 각 버튼에 연결 될 함수 onPrev 와 onNext 를 props 로 받습니다.

Navigator 가 아닌 SpaceNavigator 라는 이름을 붙여준 이유는 브라우저 상에 이미 Navigator 값이 존재하기 때문입니다.

  • src/components/SpaceNavigator
    /SpaceNavigator
    .js
  • src/components/SpaceNavigator
    /SpaceNavigator.scss
  • src/components/SpaceNavigator/index.js

src/components/SpaceNavigator/SpaceNavigator.js

import React from 'react';
import styles from './SpaceNavigator.scss';
import classNames from 'classnames/bind';

const cx = classNames.bind(styles);

const SpaceNavigator = ({ onPrev, onNext }) => {
  return (
    <div className={cx('space-navigator')}>
      
    </div>
  );
};

export default SpaceNavigator;

src/components/SpaceNavigator/SpaceNavigator.scss

.space-navigator {
  
}

src/components/SpaceNavigator/index.js

export { default } from './SpaceNavigator';

후! 드디어 끝났습니다. 이렇게 컴포넌트를 만들 때 마다 세가지의 파일을 만드는 것이 번거롭다면, 도구의 힘을 빌려보는 것도 좋습니다. VS Code 의 generate-react-component 라는 패키지를 사용하면 컴포넌트를 좀 더 편리하게 만들 수 있습니다. (이 부분은 나중에 알아보겠습니다. 커스터마이징이 조금 필요합니다.)

ViewerTemplate 컴포넌트 완성하기

ViewerTemplate 컴포넌트를 완성해봅시다.

`src/components/ViewerTemplate/ViewerTemplate.js

import React from 'react';
import styles from './ViewerTemplate.scss';
import classNames from 'classnames/bind';

const cx = classNames.bind(styles);

const ViewerTemplate = ({ viewer, spaceNavigator }) => (
  <div className={cx('viewer-template')}>
    <header>
      Astronomy Picture of the Day
    </header>
    <div className={cx('viewer-wrapper')}>
      {viewer}
      <div className={cx('space-navigator-wrapper')}>
        {spaceNavigator}
    </div>
    </div>
  </div>
);

export default ViewerTemplate;

space-navigator-wrapper 를 viewer-wrapper 내부에 넣어준 이유는, 추후 SpaceNavigator 컴포넌트에서 위치선정을 하게 될 때 viewer-wrapper 의 크기에 기반하여 설정 할 것이기 때문입니다.

src/components/ViewerTemplate/ViewerTemplate.scss

@import 'utils';

.viewer-template {
  header {
    background: $oc-gray-9;
    height: 5rem;
    color: white;
    padding: 1rem;

    display: flex;
    align-items: center;

    font-size: 2rem;
    font-weight: 600;

    // 태블릿 사이즈에서  폰트 크기 줄이기
    @include media("<tablet") {
      font-size: 1.25rem;
    }
  }

  .viewer-wrapper {
    position: relative;
    width: 100%;
    height: calc(100vh - 5rem); // 페이지에서 헤더를 제외한 영역 모두 채우기
  }
}

그럼 이 컴포넌트를 App 에서 불러와서 사용해볼까요?

src/App.js

import React, { Component } from 'react';
import ViewerTemplate from './components/ViewerTemplate';

class App extends Component {
  render() {
    return (
      <ViewerTemplate/>
    );
  }
}

export default App;

그럼, 이러한 모양이 완성됩니다:

SpaceNavigator 완성하기

이 컴포넌트에선 좌측과 우측에 버튼을 만들어 줄 것인데요, 이 과정에서 우리는 react-icons 에서 받은 아이콘을 사용해보게 됩니다.

react-icons 에서 제공 가능한 아이콘 목록은 여기 에서 확인 하실 수 있습니다.

src/components/SpaceNavigator/SpaceNavigator.js

import React from 'react';
import styles from './SpaceNavigator.scss';
import classNames from 'classnames/bind';
import LeftIcon from 'react-icons/lib/md/chevron-left';
import RightIcon from 'react-icons/lib/md/chevron-right';

const cx = classNames.bind(styles);

const SpaceNavigator = ({onPrev, onNext}) => (
  <div className={cx('space-navigator')}>
    <div className={cx('left', 'end')}>
      <div className={cx('circle')} onClick={onPrev}>
        <LeftIcon/>
      </div>
    </div>
    <div className={cx('right', 'end')}>
      <div className={cx('circle')} onClick={onNext}>
        <RightIcon/>
      </div>
    </div>
  </div>
);

export default SpaceNavigator;

src/components/SpaceNavigator/SpaceNavigator.scss

@import 'utils';

.space-navigator {
  .end {
    position: absolute;
    top: 0;
    height: 100%;
    display: flex;
    align-items: center;
    color: white;
    &.left {
      padding: 1rem;
    }
    &.right {
      right: 1rem;
    }
    .circle {
      width: 3rem;
      height: 3rem;
      background: transparent;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 50%;
      cursor: pointer;
      &:hover {
        background: rgba(0,0,0,0.25);
      }
      &:active {
        background: rgba(0,0,0,0.5);
      }
      svg {
        font-size: 2rem;
      }
    }
  }
}

컴포넌트를 다 작성하셨다면, SpaceNavigator 를 App 에서 불러와서 ViewerTemplate 안에 spaceNavigator 값으로 설정해주세요.

src/App.js

import React, { Component } from 'react';
import ViewerTemplate from './components/ViewerTemplate';
import SpaceNavigator from './components/SpaceNavigator';

class App extends Component {
  render() {
    return (
      <ViewerTemplate
        spaceNavigator={<SpaceNavigator/>}
      />
    );
  }
}

export default App;

그러면 이제 페이지의 양 쪽에 화살표가 보여질것입니다.

Viewer 컴포넌트 완성하기

우리가 나중에 호출 할 NASA Open API 는 두가지 형태의 데이터를 반환합니다.

{
  "media_type": "video", 
  "url": "https://www.youtube.com/embed/uj3Lq7Gu94Y?rel=0"
}

이렇게 유튜브 비디오를 반환 할 때도 있고,

{
  "media_type": "image", 
  "url": "https://apod.nasa.gov/apod/image/1712/GeminidsYinHao1024.jpg"
}

이렇게 이미지를 반환 할 때도 있습니다. 우리의 Viewer 컴포넌트에서는 각 상황에 따라 알맞는 뷰를 보여주도록 설정하겠습니다.

우선, Viewer 컴포넌트를 작성하기 전에, 먼저 App 에서 불러와서 이미지 형태의 데이터를 주입해보세요.

src/App.js

import React, { Component } from 'react';
import ViewerTemplate from './components/ViewerTemplate';
import SpaceNavigator from './components/SpaceNavigator';
import Viewer from './components/Viewer';

class App extends Component {
  render() {
    return (
      <ViewerTemplate
        spaceNavigator={<SpaceNavigator/>}
        viewer={(
          <Viewer 
            url="https://apod.nasa.gov/apod/image/1712/GeminidsYinHao1024.jpg" 
            mediaType="image"/>
        )}
      />
    );
  }
}

export default App;

이렇게 props 를 직접 주입해주었습니다. 이제 Viewer 컴포넌트에서 이에 따라 렌더링을 해봅시다.

src/components/Viewer/Viewer.js

import React from 'react';
import styles from './Viewer.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);

const Viewer = ({mediaType, url, loading}) => {
  if(!url) return null;

  return (
    <div className={cx('viewer')}>
      {
        mediaType === 'image' ? (
          <img onClick={() => window.open(url)} src={url} alt="space"/>
        ) : (
          <div/>
        )
      }
    </div>
  );
};

export default Viewer;

src/components/Viewer/Viewer.scss

@import 'utils';

.viewer {
  height: 100%;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  img {
    display: block;
    width: auto;
    max-width: calc(100% - 10rem);
    max-height: calc(100% - 10rem);
    cursor: pointer;
    transition: all 0.3s ease-in;
    box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
    &:hover {
      box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22);
    }
  }
}

여기까지 작성을 하고, 페이지를 새로 불러오면 다음과 같은 결과가 나타납니다:

멋진 우주 사진이 나타났지요? 이번에는, 동영상을 렌더링 해봅시다. App 컴포넌트에서 전달하는 props 를 다음과 같이 변경하세요.

src/App.js

import React, { Component } from 'react';
import ViewerTemplate from './components/ViewerTemplate';
import SpaceNavigator from './components/SpaceNavigator';
import Viewer from './components/Viewer';

class App extends Component {
  render() {
    return (
      <ViewerTemplate
        spaceNavigator={<SpaceNavigator/>}
        viewer={(
          <Viewer 
            url="https://www.youtube.com/embed/uj3Lq7Gu94Y?rel=0" 
            mediaType="video"/>
        )}
      />
    );
  }
}

export default App;

이렇게 video 형태로 전달이 되었을땐, iframe 태그를 사용해서 보여줍니다.

src/components/Viewer/Viewer.js

import React from 'react';
import styles from './Viewer.scss';
import classNames from 'classnames/bind';
const cx = classNames.bind(styles);

const Viewer = ({mediaType, url, loading}) => {
  if(!url) return null;

  return (
    <div className={cx('viewer')}>
      {
        mediaType === 'image' ? (
          <img onClick={() => window.open(url)} src={url} alt="space"/>
        ) : (
          <iframe title="space-video" src={url} frameBorder="0" gesture="media" allow="encrypted-media" allowFullScreen></iframe>
        )
      }
    </div>
  );
};

export default Viewer;

그리고, iframe 부분을 위한 스타일링도 해줘야겠지요?

src/components/Viewer/Viewer.scss

@import 'utils';

.viewer {
  height: 100%;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  img {
    display: block;
    width: auto;
    max-width: calc(100% - 10rem);
    max-height: calc(100% - 10rem);
    cursor: pointer;
    transition: all 0.3s ease-in;
    box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
    &:hover {
      box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22);
    }
  }
  iframe {
    background: black;
    box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22);
    width: calc(100% - 10rem);
    height: calc(100% - 10rem);
  }
}

여기까지 하시면 동영상도 적당한 사이즈로 나옵니다

API 사용하기

API Key 요청

현재 NASA OpenAPI 는 하루에 1000회의 실행제한이 설정되어있습니다. 만약에 이 튜토리얼을 진행하는 사람들이 모두 같은 API 키를 사용하게 된다면, 1000번의 요청 횟수를 초과하면 그 날은 작동하지 않을 것입니다. 그러므로, API 키를 따로 신청하여 진행하세요.

NASA API 페이지 에 들어가서 스크롤을 조금 내리면 다음과 같이 API Key 요청 폼이 있습니다. 이 양식을 채우시면 이메일로 키가 전달됩니다.

신청 양식

이메일

위에 빨간색 텍스트가 바로 API Key 입니다.

Promise 란?

우리는 Promise 기반으로 웹 요청 라이브러리 axios 를 사용해볼것입니다. Promise란 ES6 에서 비동기 처리를 다루기 위해 사용되는 객체입니다. 예를들어서, 숫자를 1초뒤에 콘솔에 기록하는 코드를 작성해보겠습니다. (해당 코드를 크롬 개발자 도구를 통하여 따라해보세요. 새 줄 입력은 SHIFT + Enter)

function printLater(number) {
    setTimeout(
        function() { 
            console.log(number); 
        },
        1000
    );
}

printLater(1);

이렇게 printLater 함수를 호출하면 1초뒤에 콘솔에 기록합니다. 이번엔, 1초에 걸쳐서 숫자를 더해가며 1,2,3,4 를 호출하는 코드를 작성해보세요.

function printLater(number, fn) {
    setTimeout(
        function() { console.log(number); fn(); },
        1000
    );
}

printLater(1, function() {
    printLater(2, function() {
        printLater(3, function() {
            printLater(4);
        })
    })
})

비동기적으로 해야 할 작업이 많아진다면, 코드의 구조는 자연스레 깊어질 것이고 그러면 코드를 읽기 힘들어지겠죠? 이를 콜백 지옥이라고도 부릅니다.

기존의 자바스크립트의 이러한 문제에서 구제해주는것이 바로 Promise 입니다. 한번 위 코드를 Promise 로 해결해보겠습니다. 추가적으로, 코드를 더 읽기 쉽게 작성하기위해서 화살표 함수도 사용해볼게요.

function printLater(number) {
    return new Promise( // 새 Promise 를 만들어서 리턴함
        resolve => {
            setTimeout( // 1초뒤 실행하도록 설정
                () => {
                    console.log(number);
                    resolve(); // promise 가 끝났음을 알림
                },
                1000
            )
        }
    )
}

몇번 하던간에 코드의 깊이는 일 합니다. 따라서 콜백지옥에 빠질일이 없겠죠?
Promise 에서는 값을 리턴 하거나, 에러를 발생 시킬 수도 있습니다.

코드를 다음과 같이 입력해보세요.

```javascript
function printLater(number) {
    return new Promise( // 새 Promise 를 만들어서 리턴함
        (resolve, reject) => { // resolve 와 reject 를 파라미터로 받습니다
            setTimeout( // 1초뒤 실행하도록 설정
                () => {
                    if(number > 5) { return reject('number is greater than 5'); } // reject 는 에러를 발생시킵니다
                    resolve(number+1); // 현재 숫자에 1을 더한 값을 반환합니다
                    console.log(number);
                },
                1000
            )
        }
    )
}

printLater(1)
.then(num => printLater(num))
.then(num => printLater(num))
.then(num => printLater(num))
.then(num => printLater(num))
.then(num => printLater(num))
.then(num => printLater(num))
.then(num => printLater(num))
.catch(e => console.log(e));

결과:

1
2
3
4
5
number is greater than 5

API 요청 함수 만들기

src 디렉토리 내부에 lib 라는 디렉토리를 만들고, 그 안에 api.js 를 만드세요. 현재 프로젝트의 경우엔 해당 요청 함수를 바로 App 에서 선언 하여도 무방하지만, 대부분의 프로젝트에서는 다루는 API 의 갯수가 많으므로 이를 따로 파일로 분리하여 관리하면 조금 편해집니다.

src/lib/api.js

import axios from 'axios';

export function getAPOD(date = '') {
  return axios.get(`https://api.nasa.gov/planetary/apod?api_key=5q6uswo7lQPq6HcC05xDRdcoikRkPCVdIqk6mbxe&date=${date}`);
}

(axios 사용법)

api_key= 부분에 여러분이 받은 key 를 직접 넣어주세요. 추가적으로, 위 함수에서는 date 의 기본값을 공백으로 설정하였습니다. 만약에 date 가 주어지지 않았는데 ES6 template literal 문법 ` 가 사용된다면 undefined 가 전달됩니다. undefind 가 전달되면 서버쯕에서 처리하지 못하므로, 해당 값이 비어있을 땐 공백을 넣도록 설정하세요.

API 함수 사용하기

자, 우리가 만든 API 를 사용해봅시다. 컴포넌트에서 API 를 요청을 하고, API 가 응답되었을때 특정 작업을 하려면 다음과 같이 Promise 의 then 을 사용 할 수 있습니다

src/App.js

import React, { Component } from 'react';
import ViewerTemplate from './components/ViewerTemplate';
import SpaceNavigator from './components/SpaceNavigator';
import Viewer from './components/Viewer';

import * as api from './lib/api';

class App extends Component {
  getAPOD = (date) => {
    api.getAPOD(date).then((response) => {
      console.log(response);
    });
  }

  componentDidMount() {
    this.getAPOD();
  }
  

  render() {
    return (
      <ViewerTemplate
        spaceNavigator={<SpaceNavigator/>}
        viewer={(
          <Viewer 
            url="https://www.youtube.com/embed/uj3Lq7Gu94Y?rel=0" 
            mediaType="video"/>
        )}
      />
    );
  }
}

export default App;

페이지를 열어서 다음과 같이 response 객체가 잘 기록되는지 확인해보세요.

비동기 작업을 하게 될 때, Promise 도 이미 충분히 편하지만, 더욱 편하게 사용하는 방법이 있습니다. 바로 async/await 를 사용하는 것이죠.

async 를 사용하려면 함수 선언을 하게 될 때 앞부분에 async 키워드를 붙여주어야 합니다. 그리고, 내부에서는 Promise 의 앞부분에 await 키워드를 넣어서 사용하면 됩니다.

src/App.js

import React, { Component } from 'react';
import ViewerTemplate from './components/ViewerTemplate';
import SpaceNavigator from './components/SpaceNavigator';
import Viewer from './components/Viewer';

import * as api from './lib/api';

class App extends Component {
  getAPOD = async (date) => {
    try {
      const response = await api.getAPOD(date);
      console.log(response);
    } catch (e) {
      // 오류가 났을 경우
      console.log(e);
    }
  }

  componentDidMount() {
    this.getAPOD();
  }
  

  render() {
    return (
      <ViewerTemplate
        spaceNavigator={<SpaceNavigator/>}
        viewer={(
          <Viewer 
            url="https://www.youtube.com/embed/uj3Lq7Gu94Y?rel=0" 
            mediaType="video"/>
        )}
      />
    );
  }
}

export default App;

결과가 아까처럼 나타나는지 확인하세요.

상태 관리하기

그럼, 요청 값에 따라 컴포넌트의 상태를 관리해봅시다.

src/App.js

import React, { Component } from 'react';
import ViewerTemplate from './components/ViewerTemplate';
import SpaceNavigator from './components/SpaceNavigator';
import Viewer from './components/Viewer';

import * as api from './lib/api';

class App extends Component {
  state = {
    loading: false,
    maxDate: null,
    date: null,
    urL: null,
    mediaType: null
  }

  getAPOD = async (date) => {
    if (this.state.loading) return; // 이미 요청중이라면 무시

    // 로딩 상태 시작
    this.setState({
      loading: true
    });

    try {
      const response = await api.getAPOD(date);
      // 비구조화 할당 + 새로운 이름 
      const { date: retrievedDate, url, media_type: mediaType } = response.data;

      if(!this.state.maxDate) {
        // 만약에 maxDate 가 없으면 지금 받은 date 로 지정
        this.setState({
          maxDate: retrievedDate
        })
      }
     
      // 전달받은 데이터 넣어주기
      this.setState({
        date: retrievedDate,
        mediaType,
        url
      });
    } catch (e) {
      // 오류가 났을 경우
      console.log(e);
    }

      // 로딩 상태 종료
      this.setState({
        loading: false
      });
  }

  componentDidMount() {
    this.getAPOD();
  }
  

  render() {
    const{ url, mediaType, loading } = this.state;

    return (
      <ViewerTemplate
        spaceNavigator={<SpaceNavigator/>}
        viewer={(
          <Viewer 
            url={url}
            mediaType={mediaType}
            loading={loading}/>
        )}
      />
    );
  }
}

export default App;
  • 요청이 시작하면 loading 값을 true 로, 끝났다면 false 로 설정합니다.
  • 나중에 다음 이미지를 보여주게 될 때, 오늘 이후의 데이터는 존재하지 않기 때문에 maxDate 를 설정해둡니다.
  • 비구조화 할당이 되는 과정에서, 새로운 이름으로 값을 지정했습니다. 예를들어, 위의 코드는 response.data 안에 있는 media_type 이란 값을 mediaType 이라고 부르겠다 라는 의미와 동일합니다.
  • 상태의 url, mediaType, loading 값을 Viewer 로 전달해줍니다.

이렇게 입력해놓고, 페이지에서 이미지 혹은 동영상이 제대로 나타나는지 확인하세요.

다음 / 이전 이미지로 넘기기

이제 다음 / 이전 이미지로 넘기는 작업을 진행하겠습니다. 다음 혹은 이전 이미지로 넘길 때에는, API 에서 처음 주어진 날짜 (오늘) 를 기준으로 하루전, 이틀전 데이터를 불러 올 수 있으며, 과거의 이미지를 보고 있을 때에는 다시 다음날, 그 다음날 사진으로 넘길 수도 있습니다. 날짜의 파라미터는 YYYY-MM-DD 형태로 전달이 되어야 하는데됴, 직접 Date 객체를 사용하여 날짜를 더하고 빼고 원하는 형식으로 프린트를 할 수 있긴 하겠지만, 편의상 moment 라는 라이브러리를 사용하겠습니다.

handleNext 와 handlePrev 메소드를 구현 후, SpaceNavigator 의 onNext 와 onPrev 로 연결하세요.

src/App.js

import React, { Component } from 'react';
import ViewerTemplate from './components/ViewerTemplate';
import SpaceNavigator from './components/SpaceNavigator';
import Viewer from './components/Viewer';
import moment from 'moment';
import * as api from './lib/api';

class App extends Component {
  state = {
    loading: false,
    maxDate: null,
    date: null,
    urL: null,
    mediaType: null
  }

  getAPOD = async (date) => {
    if (this.state.loading) return; // 이미 요청중이라면 무시

    // 로딩 상태 시작
    this.setState({
      loading: true
    });

    try {
      const response = await api.getAPOD(date);
      // 비구조화 할당 + 새로운 이름 
      const { date: retrievedDate, url, media_type: mediaType } = response.data;

      if(!this.state.maxDate) {
        // 만약에 maxDate 가 없으면 지금 받은 date 로 지정
        this.setState({
          maxDate: retrievedDate
        })
      }
     
      // 전달받은 데이터 넣어주기
      this.setState({
        date: retrievedDate,
        mediaType,
        url
      });
    } catch (e) {
      // 오류가 났을 경우
      console.log(e);
    }

      // 로딩 상태 종료
      this.setState({
        loading: false
      });
  }

  handlePrev = () => {
    const { date } = this.state;
    const prevDate = moment(date).subtract(1, 'days').format('YYYY-MM-DD');
    console.log(prevDate);
    this.getAPOD(prevDate);
  }

  handleNext = () => {
    const { date, maxDate } = this.state;
    if(date === maxDate) return;

    const nextDate = moment(date).add(1, 'days').format('YYYY-MM-DD');
    this.getAPOD(nextDate);
  }

  componentDidMount() {
    this.getAPOD();
  }

  render() {
    const{ url, mediaType,  loading } = this.state;
    const { handlePrev, handleNext } = this;

    return (
      <ViewerTemplate
        spaceNavigator={<SpaceNavigator onPrev={handlePrev} onNext={handleNext}/>}
        viewer={(
          <Viewer 
            url={url}
            mediaType={mediaType}
            loading={loading}/>
        )}
      />
    );
  }
}

export default App;

자, 이제 페이지를 열어서 왼쪽 버튼을 눌러보고, 오른쪽 버튼도 눌러보세요. 화면이 잘 바뀌고 있나요?

로딩시 로더 보여주기

데이터를 로딩중일 때에는 로더를 보여주도록 설정할건데요, 로더를 보여주는 방법은 SVG 를 사용하거나, CSS 를 사용하거나, 정말 여러가지 방법이 있겠지만, 우리는 최소한의 공수를 사용하여 구현하기 위하여 better-react-spinkit 이라는 라이브러리를 사용하겠습니다.

구현방법은 정말 간단합니다. Viewer 컴포넌트에서 loading 값이 참이라면 로더를 렌더링하세요. 색상과 크기는 props 로 지정해줄 수 있습니다. (로더의 종류가 여러개 있으니 링크에 들어가서 원하는걸 골라보세요)

src/components/Viewer/Viewer.js

import React from 'react';
import styles from './Viewer.scss';
import classNames from 'classnames/bind';
import {
  ChasingDots
} from 'better-react-spinkit'

const cx = classNames.bind(styles);

const Viewer = ({mediaType, url, loading}) => {

  if(loading) {
    // 로딩중일때 로더 보여주기
    return <div className={cx('viewer')}>
      <ChasingDots color="white" size={60}/>
    </div>
  }

  if(!url) return null; 

  return (
    <div className={cx('viewer')}>
      {
        mediaType === 'image' ? (
          <img onClick={() => window.open(url)} src={url} alt="space"/>
        ) : (
          <iframe title="space-video" src={url} frameBorder="0" gesture="media" allow="encrypted-media" allowFullScreen></iframe>
        )
      }
    </div>
  );
};

export default Viewer;

이제 로딩을 하게 될 땐 멋진 로더가 나타날 것입니다!

마치면서

이번 튜토리얼에서는 리액트의 컴포넌트 활용법과 스타일링 방법들을 배워가면서, 웹연동을 간단히 해보는 시간을 가졌습니다. 이번 프로젝트처럼, 규모가 작은 프로젝트는 App.js 에서 모든 상태를 관리하는 것이 충분하지만, 규모가 좀 커지면 모든 상태를 App 에서 관리하려면 조금 버거워질수도있습니다. 코드의 양이 App 쪽에 너무 많이 쏠릴 수도 있구요.

예를 들어서, 우리가 만들 웹 어플리케이션이 여러 페이지로 구성이 되어있다면, 각 페이지에서 공유하는 상태가 존재 할 수도 있습니다.

그럴 땐, 리덕스를 배울 차례입니다! (리덕스 관련 포스트는 여기에서 확인 하실 수 있으며, 조만간 새로 개선된 포스트가 올라올 예정입니다)

  • Hyunsung Kim

    오.. 오랫만의 강좌! 잘 읽었습니다!!

    • Hyunsung Kim

      $ cd create-react-app <- 오타가 있는 것 같네요. cd nasa-apod
      $ yarn eject

  • Wonkun Kim

    좋은 강의 감사합니다. 따라하면서 영어로 번역해서 미디움에 올렸습니다. https://medium.com/@wongni/react-component-styling-sass-and-css-module-and-integrating-api-419b150d64f9 혹시 문제가 된다면 알려주세요.

  • 육민형

    react의 많은 것을 배울수 있어서 좋은것 같아요 정말 감사합니다.
    오타 하나 발견했습니다.
    src/base.scss ==>> src/styles/base.scss로 경로를 변경해야 할것 같아요

  • skh

    api key 부분
    axios.get(`https://api.nasa.gov/planetary/apod?api_key=5q6uswo7lQPq6HcC05xDRdcoikRkPCVdIqk6mbxe&date=${date}`);
    invalid date 오류나는데 혹시 방법 있나요 ?ㅠ

  • skh

    axios.get(`https://api.nasa.gov/planetary/apod?api_key=5q6uswo7lQPq6HcC05xDRdcoikRkPCVdIqk6mbxe&date=${date}`);
    이 부부에서 invalid date 계속 에러나는데 혹시 잘못되었나요?

  • Jae-yun Song

    안녕하세요 ‘Promise란’ 부분에서 코드 블록 하나가 안 닫혔네요. 좋은 강좌 감사합니다.

  • GT M

    어음 오타이신거 같은데 모르겠지만 초반에 모듈 깔때

    moemnt: 날짜 관련 라이브러리 << moment 아닌가요

  • Seongho Roh

    좋은 강의 항상 감사합니다~!

  • eugene

    안녕하세요:) 포스팅 잘 읽고 있습니다!
    SpaceNavigator.js 에서 react-icons 모듈에서 화살표 가져오는 코드
    import LeftIcon from ‘react-icons/lib/md/chevron-left’;
    import RightIcon from ‘react-icons/lib/md/chevron-right’;
    이 두 부분이 에러가 나서 찾아보니 react-icons 버전 업 과정에서 Import path가 변경되었다고 하네요.
    import {MdArrowBack} from ‘react-icons/md/’;
    import {MdArrowForward} from ‘react-icons/md’;
    이렇게 바꿔주면 제대로 작동합니당.

  • js

    많은 도움이 되었습니다. 감사합니다 🙂

  • JONGSOO LIM

    base.scss 에서 @import ‘utils’; 부분이 /src/styles/utils.scss 가 아닌 /src/styles/_utils.scss 을 import 하고 있습니다. 문제가 뭘까요?? 아시는 분은 답변 좀 부탁드립니다.