관리 메뉴

거니의 velog

(21) 프론트엔드 프로젝트 : 수정/삭제 기능 구현 및 마무리 3 본문

React_프론트엔드 프로젝트

(21) 프론트엔드 프로젝트 : 수정/삭제 기능 구현 및 마무리 3

Unlimited00 2024. 2. 23. 21:23

2. 포스트 삭제

* 마지막으로 구현할 프로젝트의 기능은 포스트 삭제이다. 이에 대한 작업을 마치고 나서 프로젝트를 마무리할 것이다.

* 삭제 버튼을 누를 때 포스트를 바로 삭제하는 것이 아니라, 사용자의 확인을 한 번 더 요청하고 나서 삭제하려고 한다. 이렇게 하는 이유는 사용자가 실수로 삭제하는 것을 방지하기 위해서이다.

* 사용자에게 한 번 더 확인을 요청하기 위해 모달 컴포넌트를 만들 것이다. 모달(modal)이란 페이지에 나타난 내용 위에 새 레이어로 어떠한 창을 보여 주는 것을 말한다.

* 이 프로젝트에서는 모달 컴포넌트를 포스트 읽기 페이지에서만 사용하지만, 컴포넌트의 재사용성을 고려하여 common 디렉터리에 만들어 볼 것이다.

* AskModal 이라는 컴포넌트를 다음과 같이 만들어 보자.

[components/common/AskModal.js]

import React from 'react';
import styled from 'styled-components';
import Button from './Button';

const Fullscreen = styled.div`
  position: fixed;
  z-index: 30;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.25);
  display: flex;
  justify-content: center;
  align-items: center;
`;

const AskModalBlock = styled.div`
  width: 320px;
  background: white;
  padding: 1.5rem;
  border-radius: 4px;
  box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.125);
  h2 {
    margin-top: 0;
    margin-bottom: 1rem;
  }
  p {
    margin-bottom: 3rem;
  }
  .buttons {
    display: flex;
    justify-content: flex-end;
  }
`;

const StyledButton = styled(Button)`
  height: 2rem;
  & + & {
    margin-left: 0.75rem;
  }
`;

const AskModal = ({
  visible,
  title,
  description,
  confirmText = '확인',
  cancelText = '취소',
  onConfirm,
  onCancel,
}) => {
  if (!visible) return null;
  return (
    <Fullscreen>
      <AskModalBlock>
        <h2>{title}</h2>
        <p>{description}</p>
        <div className="buttons">
          <StyledButton onClick={onCancel}>{cancelText}</StyledButton>
          <StyledButton cyan onClick={onConfirm}>
            {confirmText}
          </StyledButton>
        </div>
      </AskModalBlock>
    </Fullscreen>
  );
};

export default AskModal;

* 방금 만든 AskModal을 기반으로 post 디렉터리에 AskRemoveModal 이라는 컴포넌트를 만들어 보자.

[components/post/AskRemoveModal.js]

import React from 'react';
import AskModal from '../common/AskModal';

const AskRemoveModal = ({ visible, onConfirm, onCancel }) => {
  return (
    <AskModal
      visible={visible}
      title="포스트 삭제"
      description="포스트를 정말 삭제하시겠습니까?"
      confirmText="삭제"
      onConfirm={onConfirm}
      onCancel={onCancel}
    />
  );
};

export default AskRemoveModal;

* AskRemoveModal 컴포넌트를 굳이 이렇게 별도의 파일로 분리하여 만들어 줄 필요는 없다. 그냥 모달을 사용하는 곳에서 AskModal을 직접 렌더링해도 상관없다. 다만, 모달별로 이렇게 파일을 만들어 주면 나중에 모달의 개수가 많아졌을 때 관리하기가 매우 편해진다.

* 컴포넌트를 다 만들었으면 PostActionButtons 내부에서 사용해 보자.

[components/post/PostActionButtons.js]

import React, { useState } from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import AskRemoveModal from './AskRemoveModal';

(...)

const PostActionButtons = ({ onEdit, onRemove }) => {
  const [modal, setModal] = useState(false);
  const onRemoveClick = () => {
    setModal(true);
  };
  const onCancel = () => {
    setModal(false);
  };
  const onConfirm = () => {
    setModal(false);
    onRemove();
  };

  return (
    <>
      <PostActionButtonsBlock>
        <ActionButton onClick={onEdit}>수정</ActionButton>
        <ActionButton onClick={onRemoveClick}>삭제</ActionButton>
      </PostActionButtonsBlock>
      <AskRemoveModal
        visible={modal}
        onConfirm={onConfirm}
        onCancel={onCancel}
      />
    </>
  );
};

export default PostActionButtons;

* 이제 삭제 버튼을 눌러 보자. 모달이 잘 나타날 것이다. 취소 버튼을 눌러서 모달이 잘 사라지는 지도 확인해 보자. 모달 내부의 삭제 버튼을 누르면 오류가 발생할 것이다. 아직 onRemove를 넣어 주지 않았기 때문이다.

모달 창 생성

* onRemove를 구현하기에 앞서 lib/api/posts.js 파일에 removePost 함수를 구현해 주자.

[lib/api/posts.js]

(...)

export const removePost = (id) => client.delete(`/api/posts/${id}`);

* 이제 PostViewer 에서 onRemove 함수를 만들어 removePost를 호출하도록 구현해 볼 것이다. removePost의 경우 API를 요청한 후 따로 보여 주어야 할 결과가 없으니 리덕스 액션과 사가를 만드는 작업을 생략하고 바로 API를 사용해 볼 것이다.

[containers/post/PostViewerContainer.js]

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { readPost, unloadPost } from '../../modules/post';
import PostViewer from '../../components/post/PostViewer';
import PostActionButtons from '../../components/post/PostActionButtons';
import { setOriginalPost } from '../../modules/write';
import { removePost } from '../../lib/api/posts';

const PostViewerContainer = ({ match, history }) => {
  (...)

  const onRemove = async () => {
    try {
      await removePost(postId);
      history.push('/'); // 홈으로 이동
    } catch (e) {
      console.log(e);
    }
  };

  const ownPost = (user && user._id) === (post && post.user._id);

  return (
    <PostViewer
      post={post}
      loading={loading}
      error={error}
      actionButtons={
        ownPost && <PostActionButtons onEdit={onEdit} onRemove={onRemove} />
      }
    />
  );
};

export default withRouter(PostViewerContainer);

* 컴포넌트 수정이 끝나고 나면, 다시 삭제 버튼을 눌러서 모달을 열고 삭제 버튼을 눌러 보자. 홈 화면으로 이동했는가? 삭제한 포스트가 홈 화면에서 사라졌는지도 확인해 보자.

삭제 버튼을 누르면?
최상단에 위치한 포스트가 정상적으로 삭제되었다!

* 이제 이 프로젝트의 주요 기능을 모두 구현했다!


3. react-helmet-async로 meta 태그 설정하기

* 현재 우리가 만든 웹 애플리케이션을 브라우저에서 열어 보면 상단에 React App이라는 제목이 나타난다.

페이지 제목

* 구글, 네이버 같은 검색 엔진에서 웹 페이지를 수집할 때는 meta 태그를 읽는데, 이 meta 태그를 리액트 앱에서 설정하는 방법을 한번 알아 보자.

* 우선 yarn을 사용하여 클라이언트 프로젝트에 react-helmet-async 라는 라이브러리를 설치하자.

$ yarn add react-helmet-async

* 다음으로 src/index.js 파일을 열어서 HelmetProvider 컴포넌트로 App 컴포넌트를 감싸자.

(...)
import { HelmetProvider } from 'react-helmet-async';

(...)

ReactDOM.render(
  <Provider store={store}>
    <BrowserRouter>
      <HelmetProvider>
        <App />
      </HelmetProvider>
    </BrowserRouter>
  </Provider>,
  document.getElementById('root'),
);

* 그리고 나서 meta 태그를 설정하고 싶은 곳에 Helmet 컴포넌트를 사용하면 된다. App 컴포넌트를 다음과 같이 수정해 보자.

[App.js]

import React from 'react';
import { Route } from 'react-router-dom';
import PostListPage from './pages/PostListPage';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import WritePage from './pages/WritePage';
import PostPage from './pages/PostPage';
import { Helmet } from 'react-helmet-async';

const App = () => {
  return (
    <>
      <Helmet>
        <title>REACTERS</title>
      </Helmet>
      <Route component={PostListPage} path={['/@:username', '/']} exact />
      <Route component={LoginPage} path="/login" />
      <Route component={RegisterPage} path="/register" />
      <Route component={WritePage} path="/write" />
      <Route component={PostPage} path="/@:username/:postId" />
    </>
  );
};
export default App;

* 이제 브라우저에서 페이지 제목이 REACTERS로 바뀐 것을 볼 수 있다.

Helmet 사용하기

* react-helmet-async 에서는 더 깊숙한 곳에 위치한 Helmet이 우선권을 차지한다. 예를 들어 App과 WritePage에서 Helmet을 사용할 경우, WritePage는 App 내부에 들어 있기 때문에 WritePage에서 설정하는 title 값이 나타난다.

* WritePage 에서도 Helmet을 한번 사용해 보자.

[pages/WritePage.js]

import React from 'react';
import Responsive from '../components/common/Responsive';
import EditorContainer from '../containers/write/EditorContainer';
import TagBoxContainer from '../containers/write/TagBoxContainer';
import WriteActionButtonsContainer from '../containers/write/WriteActionButtonsContainer';
import { Helmet } from 'react-helmet-async';

const WritePage = () => {
  return (
    <Responsive>
      <Helmet>
        <title>글 작성하기 - REACTERS</title>
      </Helmet>
      <EditorContainer />
      <TagBoxContainer />
      <WriteActionButtonsContainer />
    </Responsive>
  );
};

export default WritePage;

* 코드를 저장한 후 /write 페이지로 들어가 보자.

write 페이지 제목

* 제목이 새 글 작성하기로 잘 바뀌었는가?

* 그 다음에는 PostViewer 컴포넌트에서 Helmet을 사용하여 포스트의 제목이 페이지의 제목이 되도록 설정해 보자.

[components/post/PostViewer.js]

import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
import SubInfo from '../common/SubInfo';
import Tags from '../common/Tags';
import { Helmet } from 'react-helmet-async';

(...)

const PostViewer = ({ post, error, loading, actionButtons }) => {
  (...)
  return (
    <PostViewerBlock>
      <Helmet>
        <title>{title} - REACTERS</title>
      </Helmet>
      <PostHead>
        <h1>{title}</h1>
        <SubInfo
          username={user.username}
          publishedDate={publishedDate}
          hasMarginTop
        />
        <Tags tags={tags} />
      </PostHead>
      {actionButtons}
      <PostContent dangerouslySetInnerHTML={{ __html: body }} />
    </PostViewerBlock>
  );
};

export default PostViewer;

* Helmet을 적용한 후 아무 포스트나 열어 보자. 페이지 제목이 잘 바뀌는 것을 확인할 수 있다.


4. 프로젝트 마무리

* 프로젝트를 완성한 뒤에는 어떠한 작업을 해야 하는지 알아보자.

(1) 프로젝트 빌드하기

* 우선 백엔드 서버를 통해 리액트 앱을 제공할 수 있도록 빌드해 주어야 한다. 클라이언트 프로젝트 디렉터리에서 다음 명령어를 실행하자.

$ yarn build

* 작업이 끝나면 blog-frontend에 build 디렉터리가 생성된다.


(2) koa-static 으로 정적 파일 제공하기

* 서버를 통해 blog-frontend/build 디렉터리 안의 파일을 사용할 수 있도록 koa-static을 사용하여 정적 파일 제공 기능을 구현해 보자.

* 서버 프로젝트 디렉터리에서 다음 명령어를 실행하여 koa-static을 설치하자.

$ yarn add koa-static

* 이어서 main.js를 다음과 같이 수정해 보자.

require('dotenv').config();
import Koa from 'koa';
import Router from 'koa-router';
import bodyParser from 'koa-bodyparser';
import mongoose from 'mongoose';
import serve from 'koa-static';
import path from 'path';
import send from 'koa-send';

import api from './api';
import jwtMiddleware from './lib/jwtMiddleware';

// 비구조화 할당을 통하여 process.env 내부 값에 대한 레퍼런스 만들기
const { PORT, MONGO_URI } = process.env;

mongoose
  .connect(MONGO_URI, { useNewUrlParser: true, useFindAndModify: false })
  .then(() => {
    console.log('Connected to MongoDB');
  })
  .catch((e) => {
    console.error(e);
  });

const app = new Koa();
const router = new Router();

// 라우터 설정
router.use('/api', api.routes()); // api 라우트 적용

// 라우터 적용 전에 bodyParser 적용
app.use(bodyParser());
app.use(jwtMiddleware);

// app 인스턴스에 라우터 적용
app.use(router.routes()).use(router.allowedMethods());

const buildDirectory = path.resolve(__dirname, '../../blog-frontend/build');
app.use(serve(buildDirectory));
app.use(async (ctx) => {
  // Not Found 이고, 주소가 /api 로 시작하지 않는 경우
  if (ctx.status === 404 && ctx.path.indexOf('/api') !== 0) {
    // index.html 내용을 반환
    await send(ctx, 'index.html', { root: buildDirectory });
  }
});

// PORT 가 지정되어있지 않다면 4000 을 사용
const port = PORT || 4000;
app.listen(port, () => {
  console.log('Listening to port %d', port);
});

* use-static을 사용하여 blog-frontend/build 디렉터리에 있는 파일들을 서버를 통해 조회할 수 있게 해 주었다. 추가로 하단에 send 라는 함수를 사용하는 미들웨어를 작성했는데, 이 미들웨어는 클라이언트 기반 라우팅이 제대로 작동하게 해 준다. HTTP 상태가 404이고 주소가 /api로 시작하지 않으면, index.html의 내용을 응답한다. 이 미들웨어를 적용하지 않으면 http://localhost:4000/write 페이지를 브라우저 주소창에 직접 입력하여 들어갈 경우, 페이지가 제대로 나타나지 않고 Not Found 가 나타나게 된다.

* 이제 브라우저 주소창에 http://localhost:4000/ 주소를 입력하여 들어가 보자. 개발 서버에서 보았던 화면에 제대로 나타나는가? 포스트 페이지를 열고 새로고침을 했을 때도 포스트 내용이 잘 나타나는지 확인해 보자.

빌드 후 확인하기