본문 바로가기
IT/React

[React] 서버리스 메모앱-5

by 밤톨엽 2022. 5. 10.

해당 글은 [React] 서버리스 메모앱-4와 이어집니다.

이번 글에서는 메모 상세조회, 수정, 삭제, 보안 페이지, 배포에 대해서 작성을 하였습니다.


메모 상세조회

메모 목록을 구성했으므로 이제 사용자가 등록한 메모를 수정할 수 있는 페이지를 구성해보자.

우선 src/Routes.tsx 파일에서 /notes/new 경로 아래에 다음 경로를 추가해주자. 

import Notes from "./containers/Notes"; // 상단에 추가

<Route exact path="/notes/:id">
  <Notes />
</Route>

여기서 URL에 메모 ID를 담아서 보낼 계획이기 계획이다. (React Router URL Parameter)

/notes/:id 라우터에 일치하는 경로를 노트 상세 페이지로 보낼 예정이다. 여기서 주의할 점이 /notes/new 경로도 /notes/:id 에 포함되기 때문에 반드시 /notes/new 항목 밑에 경로를 설정해야한다.

메모 조회 페이지

src/containers/Notes.tsx 파일을 생성하고 아래처럼 코드를 구성하자.

import React, { useRef, useState, useEffect } from "react";
import { useParams, useHistory } from "react-router-dom";
import { API, Storage } from "aws-amplify";
import { onError } from "../lib/errorLib";

export default function Notes() {
  const file = useRef(null);
  const { id } = useParams();
  const history = useHistory();
  const [note, setNote] = useState(null);
  const [content, setContent] = useState("");

  useEffect(() => {
    function loadNote() {
      return API.get("notes", `/notes/${id}`, {});
    }

    async function onLoad() {
      try {
        const note = await loadNote();
        const { content, attachment } = note;

        if (attachment) {
          note.attachmentURL = await Storage.vault.get(attachment);
        }

        setContent(content);
        setNote(note);
      } catch (e) {
        onError(e);
      }
    }

    onLoad();
  }, [id]);

  return (
    <div className="Notes"></div>
  );
}

위에 코드를 살펴보자.

  1. useEffect Hooks를 사용하여 페이지가 렌더링되면 메모를 로드한 후에 상태값에 저장한다.
    React Router와 함께 제공되는 useParams Hooks 를 사용하여 URL에서 메모ID 를 얻는다. (/notes/:id)
  2. 첨부 파일이 있는 경우 키를 사용하여 S3에 업로드한 파일에 대한 보안 링크를 획득하고 메모 객체를 저장한다.
  3. note 와 함께 상태에 객체 데이터로 존재하는 이유는 사용자가 메모를 편집할 때 이것을 사용할 예정이다.

메모 양식 렌더링

src/containers/Notes.tsx 파일에서 코드를 아래처럼 수정하자.

import React, { useRef, useState, useEffect } from 'react';
import { useParams, useHistory } from 'react-router-dom';
import { API, Storage } from 'aws-amplify';
import { onError } from '../lib/errorLib';
import Form from 'react-bootstrap/Form';
import LoaderButton from '../components/LoaderButton';
import config from '../config';
import './Notes.css';

type NoteParams = {
    id: string;
}

export default function Notes() {
  const file = useRef<any>(null);
  const { id } = useParams<NoteParams>();
  const history = useHistory();
  const [note, setNote] = useState<any>(null);
  const [content, setContent] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [isDeleting, setIsDeleting] = useState(false);

  useEffect(() => {
    function loadNote() {
      return API.get('notes', `/notes/${id}`, {});
    }

    async function onLoad() {
      try {
        const note = await loadNote();
        const { content, attachment } = note;

        if (attachment) {
          note.attachmentURL = await Storage.vault.get(attachment);
        }

        setContent(content);
        setNote(note);
      } catch (e) {
        onError(e);
      }
    }

    onLoad();
  }, [id]);

  function validateForm() {
    return content.length > 0;
  }

  function formatFilename(str: string) {
    return str.replace(/^\w+-/, '');
  }

  function handleFileChange(event: any) {
    file.current = event.target.files[0];
  }

  async function handleSubmit(event: React.MouseEvent<HTMLFormElement>) {
    let attachment;

    event.preventDefault();

    if (file.current && file.current.size > config.MAX_ATTACHMENT_SIZE) {
      alert(
        `Please pick a file smaller than ${
          config.MAX_ATTACHMENT_SIZE / 1000000
        } MB.`
      );
      return;
    }

    setIsLoading(true);
  }

  async function handleDelete(event: React.MouseEvent<HTMLButtonElement>) {
    event.preventDefault();

    const confirmed = window.confirm(
      'Are you sure you want to delete this note?'
    );

    if (!confirmed) {
      return;
    }

    setIsDeleting(true);
  }

  return (
    <div className="Notes">
      {note && (
        <Form onSubmit={handleSubmit}>
          <Form.Group controlId="content">
            <Form.Control
              as="textarea"
              value={content}
              onChange={(e) => setContent(e.target.value)}
            />
          </Form.Group>
          <Form.Group controlId="file">
            <Form.Label>첨부파일</Form.Label>
            {note.attachment && (
              <p>
                <a
                  target="_blank"
                  rel="noopener noreferrer"
                  href={note.attachmentURL}
                >
                  {formatFilename(note.attachment)}
                </a>
              </p>
            )}
            <Form.Control onChange={handleFileChange} type="file" />
          </Form.Group>
          <LoaderButton
            block
            size="lg"
            type="submit"
            isLoading={isLoading}
            disabled={!validateForm()}
            className={''}
          >
            저장
          </LoaderButton>
          <LoaderButton
            block
            size="lg"
            variant="danger"
            onClick={handleDelete}
            isLoading={isDeleting}
            className={''}
            disabled={false}
          >
            삭제
          </LoaderButton>
        </Form>
      )}
    </div>
  );
}

 

src/containers/Notes.css 파일을 추가하여 스타일을 구성해주자.

.Notes form textarea {
  height: 300px;
  font-size: 1.5rem;
}

이제 메모 리스트페이지에서 클릭시 아래처럼 표시되어야 한다.

상세 페이지
상세 페이지

메모 변경 사항 저장

기존 메모를 불러올 수 있으므로 해당 메모를 수정하여 저장할 수 있게 구성해보자.

src/containers/Notes.tsx 파일에서 handleSubmit() 함수를 아래처럼 수정하자.

import { s3Upload } from "../lib/awsLib"; // 상단에 추가

// 저장 동작 추가
function saveNote(note: any) {
    return API.put("notes", `/notes/${id}`, {
      body: note
    });
 }
  
 // 아래처럼 수정
 async function handleSubmit(event: any) {
    let attachment;
  
    event.preventDefault();
  
    if (file.current && file.current.size > config.MAX_ATTACHMENT_SIZE) {
      alert(
        `Please pick a file smaller than ${
          config.MAX_ATTACHMENT_SIZE / 1000000
        } MB.`
      );
      return;
    }
  
    setIsLoading(true);
  
    try {
      if (file.current) {
        attachment = await s3Upload(file.current);
      }
  
      await saveNote({
        content,
        attachment: attachment || note.attachment
      });
      history.push("/");
    } catch (e) {
      onError(e);
      setIsLoading(false);
    }
  }

수정한 코드를 순서대로 살펴보자

  1. 업로드할 파일이 있으면 업로드를 s3Upload를 호출하고 S3에서 가져온 키를 저장한다.
    파일이 없다면 note.attachment 상태값으로 저장한다.
  2. PUT API를 요청하여 메모 정보를 담아 전송한다.
  3. 마지막으로 성공 시 사용자를 홈페이지로 리다이렉션한다.

Q. 이전에 저장된 첨부파일들은 어떻게 처리할까?

사실 새 첨부 파일을 업로드하여도 이전 첨부파일을 삭제되지 않는다.

AWS Amplfiy Stroage 왼쪽 링크를 참고하면 파일을 삭제할 수 있는 API를 추가할 수 있다.

이런 세부적인 구현사항을 프로젝트의 퀄리티를 높여주니 구현할 수 있으면 진행해보도록 하자.

메모 삭제

이제 마지막으로 사용자가 메모를 삭제할 수 있도록 구성해보자.

src/containers/Notes.tsx 파일에서 handleDelete() 함수를 아래처럼 수정해주자.

// 새로 구성
function deleteNote() {
  return API.del("notes", `/notes/${id}`, {});
}

// 아래처럼 수정
async function handleDelete(event: React.MouseEvent<HTMLButtonElement>) {
  event.preventDefault();

  const confirmed = window.confirm(
    "정말 이 메모를 삭제하시겠습니까?"
  );

  if (!confirmed) {
    return;
  }

  setIsDeleting(true);

  try {
    await deleteNote();
    history.push("/");
  } catch (e) {
    onError(e);
    setIsDeleting(false);
  }
}
이제 삭제버튼을 누르면 실제로 메모가 삭제된다. 하지만 이전처럼 첨부되어있던 파일을 삭제되지 않으니 직접 구성해보는 것을 추천한다.

보안 페이지 설정

사용자가 로그인하지 않은 경우 접근할 수 없는 페이지들이 있기때문에 보안관련 설정이 필요하다.

위의 문제를 해결하기 위해 여러방법이 존재하지만, 가장 간단한 처리방법으로 컨테이너의 조건을 확인하고 리다이렉션 하는 방법을 사용해보자.

src/components/AuthenticatedRoute.tsx 파일을 생성하고 아래처럼 코드를 구성하자.

import React from "react";
import { Route, Redirect, useLocation } from "react-router-dom";
import { useAppContext, Authentication } from "../lib/contextLib";


export default function AuthenticatedRoute({ children, ...rest }: any) {
  const { pathname, search } = useLocation();
  const { isAuthenticated } = useAppContext() as Authentication;
  return (
    <Route {...rest}>
      {isAuthenticated ? (
        children
      ) : (
        <Redirect to={
          `/login?redirect=${pathname}${search}`
        } />
      )}
    </Route>
  );
}

위 컴포넌트는 인증된 유저에게 필요한 페이지로 이동시켜주기 위해 구성한 것으로 간단하게 살펴보면..

  • Props를 통해 children 하위 구성 요소를 받아서 렌더링
    예) NewNote, Notes 컴포넌트
  • AuthenticatedRoute 컴포넌트는 React Router Route 구성 요소를 반환
  • useAppContext Hooks 사용 하여 사용자가 인증되었는지 확인
  • 사용자가 인증되었다면 children구성 요소를 렌더링하고, 사용자가 인증되지 않은 경우 사용자를 로그인 페이지로 리다이렉션

src/components/UnauthenticatedRoute.tsx 파일을 추가하고 아래처럼 코드를 구성하자

import React, { cloneElement } from "react";
import { Route, Redirect } from "react-router-dom";
import { useAppContext, Authentication } from "../lib/contextLib";

export default function UnauthenticatedRoute(props: any) {
  const { children, ...rest } = props;
  const { isAuthenticated } = useAppContext() as Authentication;

  return (
    <Route {...rest}>
      {!isAuthenticated ? (
        cloneElement(children, props)
      ) : (
        <Redirect to="/" />
      )}
    </Route>
  );
}

위 컴포넌트는 인증이 필요없는 페이지를 이동시켜주기 위해 작성하였다.

인증, 비인증 경로 사용

src/Routes.tsx 파일을 아래처럼 수정해주자.

import React from 'react';
import { Route, Switch } from 'react-router-dom';
import Home from './containers/Home';
import Login from './containers/Login';
import Signup from './containers/Signup';
import NewNote from './containers/NewNote';
import Notes from './containers/Notes';
import NotFound from './containers/NotFound';
import AuthenticatedRoute from './components/AuthenticatedRoute';
import UnauthenticatedRoute from './components/UnauthenticatedRoute';

export default function Routes() {
  return (
    <Switch>
      <Route exact path="/">
        <Home />
      </Route>
      <UnauthenticatedRoute exact path="/login">
        <Login />
      </UnauthenticatedRoute>
      <UnauthenticatedRoute exact path="/signup">
        <Signup />
      </UnauthenticatedRoute>
      <AuthenticatedRoute exact path="/notes/new">
        <NewNote />
      </AuthenticatedRoute>
      <AuthenticatedRoute exact path="/notes/:id">
        <Notes />
      </AuthenticatedRoute>
      <Route>
        <NotFound />
      </Route>
    </Switch>
  );
}
이제 로그인하지 않은 상태에서 노트페이지 경로를 적으면 로그인 페이지로 이동된다.

로그인 시 리다이렉션

로그인 후 다시 리다이렉션 설정을 위해 몇가지 작업이 필요하다.
src/components/UnauthenticatedRoute.tsx 파일을 아래처럼 수정해주자.

import React, { cloneElement } from 'react';
import { Route, Redirect } from 'react-router-dom';
import { useAppContext, Authentication } from '../lib/contextLib';

function querystring(name: string, url = window.location.href) {
  const parsedName = name.replace(/[[]]/g, '\\$&');
  const regex = new RegExp(`[?&]${parsedName}(=([^&#]*)|&|#|$)`, 'i');
  const results = regex.exec(url);

  if (!results || !results[2]) {
    return false;
  }

  return decodeURIComponent(results[2].replace(/\+/g, ' '));
}

export default function UnauthenticatedRoute(props: any) {
  const { children, ...rest } = props;
  const { isAuthenticated } = useAppContext() as Authentication;
  const redirect = querystring('redirect');

  return (
    <Route {...rest}>
      {!isAuthenticated ? (
        cloneElement(children, props)
      ) : (
        <Redirect to={redirect ? redirect : '/'} />
      )}
    </Route>
  );
}

마지막으로 src/containers/Login.tsx 파일에서 handleSubmit() 함수를 아래처럼 수정해주자.

const history = useHistory(); // 삭제해주자!!

async function handleSubmit(event: any) {
    event.preventDefault();

    setIsLoading(true);

    try {
      await Auth.signIn(fields.email, fields.password);
      userHasAuthenticated(true);
    } catch (e: unknown) {
      onError(e);
      setIsLoading(false);
    }
}
이제 마지막으로 Github에 작성한 소스를 올려주자


[React] 서버리스 메모앱-5

  • 메모 상세조회
  • 메모 수정
  • 메모 삭제
  • 페이지 보안 설정

서버리스 메모 애플리케이션의 프로젝트 마지막으로 위와 같이 메모에 대한 상세처리, 페이지 보안 설정에 대해 진행하였다.

해당글을 잘 따라와주신 분들께 감사하며 전반적인 서버리스 웹 어플리케이션의 흐름을 이해하는데 많은 도움이 되었으면 좋겠다.

 

'IT > React' 카테고리의 다른 글

[React] 서버리스 메모앱-4  (0) 2022.05.07
[React] 서버리스 메모앱-3  (0) 2022.04.24
[React] 서버리스 메모앱-2  (3) 2022.04.18
[React] 서버리스 메모앱-1  (1) 2022.04.17