영화앱 10. 게시판 CRUD (+무한 스크롤)

jonyChoiGenius·2023년 2월 18일
0

지난번 글에서 게시판 카드 디자인과 생성까지 했다.

이제 '카드' 컴포넌트가 상황에 따라 역할을 변화하도록 만들 것이다.
즉 조건에 따라 하위 컴포넌트들이 변한다.

이를 위해서 먼저 상황에 따라 메세지를 변화시키는 useEffect를 만들었다.

  const Overlay = useMemo(() => {
    // 수정 모드인 경우에는 방해되지 않도록 아무 것도 리턴하지 않는다.
    if (isEditing) return <></>;
    if (isAuth) {
      // 내가 작성자인 경우
      return <div onClick={() => setIsEditing(true)}>클릭해서 수정하기</div>;
    } else if (likes.includes(uid)) {
      //내가 이미 좋아요를 누른 게시물인 경우
      return (
        <div
          onClick={() => {
            likes.splice(likes.indexOf(uid), 1);
            setArticleSnapshot({
              ...articleSnapshot,
              likes: [...likes],
            });
          }}
        >
          클릭해서 좋아요 취소
        </div>
      );
    } else {
      return (
        //내가 좋아요를 누르지 않은 경우
        <div
          onClick={() =>
            setArticleSnapshot({
              ...articleSnapshot,
              likes: [...likes, uid],
            })
          }
        >
          클릭해서 좋아요
        </div>
      );
    }
  }, [likes, isEditing]);

이렇게 반환받은 overlay를 디자인을 씌워서 삽입 시켜준다. 부모 컴포넌트가 hover일 때 해당 문구가 보이도록 하면 된다.

        </div>
        <StyledOverlay className="overlay">
          {Overlay}
        </StyledOverlay>
      </div>
    </StyledCard>

이후 할 것

  1. 수정하기 모드일 때에 현재의 cardBody와 프로필footer 대신 수정하기용 cardBody와 footer가 나오도록 한다.
  2. 좋아요와 좋아요 취소는 클릭하는 대로 토글되도록 한다.
  3. 좋아요 토글은 너무 많이 클릭할 경우를 대비해 '디바운스'를 이용해서 서버에 응답을 보낸다.

좋아요 토글에 쓰로틀링을 걸수도 있지만, 눌렀는대 반응 안하는건 너무 답답할 것 같다. 디바운스를 이용해 요청 수를 줄이고자 한다.

좋아요 기능 구현 (디바운스)

좋아요 얘기가 나온 김에 좋아요 부터 구현한다.

  const [initiated, setInitiated] = useState(false);
  const hasLiked = useDebounce(likes.includes(uid), 500);
  const docRef = dbService.collection("articles").doc(documentId);
  useEffect(() => {
    if (!initiated) return setInitiated(true);
    console.log(hasLiked);
    if (hasLiked) {
      docRef.update({
        likes: firebaseInstance.firestore.FieldValue.arrayUnion(uid),
      });
    } else if (!hasLiked) {
      docRef.update({
        likes: firebaseInstance.firestore.FieldValue.arrayRemove(uid),
      });
    }
  }, [hasLiked]);

간단한 로직이다. 마지막으로 버튼을 누르고 500ms 이상 추가 입력이 없을 경우 hasLiked가 업데이트 된다.

이러한 로직을 구현할 때에, 기존에 좋아요를 눌렀는지, 안눌렀는지를 snapshot과 함께 판별해야 하는데, snapshot을 업데이트 하는 것도 요청이기 때문에 요청 횟수가 줄지 않는다고 판단했다.
useEffect를 사용했더니 hasLiked의 상태가 이전과 같은 경우 요청을 보내지 않는다. useEffect가 일종의 스냅샷 역할을 하는 것이다.

하지만 useEffect를 사용할 때에 문제가 있는데, 최초로 hasLiked값이 할당되는 경우에 대해서는 무의미하게 요청을 보낸다는 점이다.
이를 방지하지 위해 initiated라는 플래그를 세워 최초인 경우에는 서버에 요청을 보내지 않고 빠른 반환하도록 하였다.


(테스트 해본다고 새로고침 하다가 요청을 엄청 보내버렸다. ㅠㅠ)

한편 배열의 업데이트는 파이어베이스의 내부에 있는 arrayUnion과 arrayRemove를 이용해 할 수 있다.

dbService.collection("articles").doc(documentId).update({
	배열명: 파이어베이스.firestore.FiledValue.arrayUnion(넣을 값)
})

arrayUnion은 배열에 해당 값이 없는 경우 값을 추가한다. 결과적으로 배열이지만 set과 같이 중복된 데이터가 없다.
arrayRemove는 배열에서 해당 값을 찾아 삭제하며, 없는 경우 무시된다.

아래는 실제 작동모습이다. 여러번 동시에 클릭할 때에는 반응이 없으며, 클릭을 멈춘 후의 상태가 이전의 상태와 같다면 역시나 반응이 없다.
클릭을 멈춘 후, 이전의 상태와 달라졌을 때에만 서버에 요청을 한다.

로직 일부 수정

위 로직은 최초 로딩은 잘 막는데,
핫모듈리로딩과 같은 리렌더링 시에는 안통한다는 점이 문제였다.

  useEffect(() => {
    if (!initiated) return;
    console.log(hasLiked);
    if (hasLiked) {
      docRef.update({
        likes: firebaseInstance.firestore.FieldValue.arrayUnion(uid),
      });
      setInitiated(false);
    } else if (!hasLiked) {
      docRef.update({
        likes: firebaseInstance.firestore.FieldValue.arrayRemove(uid),
      });
      setInitiated(false);
    }
  }, [hasLiked]);

업데이트 요청을 한 후에 무조건 initiated를 false로 바꿔주기로 한다.

그리고 대신

      const onClickHandler = () => {
        setInitiated(true);
        setArticleSnapshot({
          ...articleSnapshot,
          likes: [...likes, uid],
        });
      };

위와 같이 클릭 이벤트가 발생됐을 때 setInitiated를 true로 바꿔준다.
이로서 initiated는 클릭 이벤트가 발생했으나 처리되지 않았음을 의미하는 상태이며,
한편으로는 initiated가 아니라면 클릭 이벤트가 발생되지 않았으니 서버에 요청을 보내지 말라는 의미가 된다.

게시글 수정

수정모드인지 아닌지에 따라 카드 컴포넌트의 내부를 갈아 끼워줘야 한다.

Card Body는 현재의 카드바디에서, 지난번 게시글 생성기능 때 구현한 CardBodyEditing으로 교체된다.

CardBodyEditing에 필요한 변수들을 card에 선언하자.

  const [movie, setMovie] = useState(() => ({
    backdrop_path,
    title,
  }));
  const [input, setInput] = useState(() => {
    return body;
  });

Create와 대동소이 하다.
다른 점은 state의 초기값을 준다는 점. 초기값은 articleSnapshot으로 준다.

이번엔 클릭 이벤트를 작성하자.

  const onUpdate = () => {
    const payload = {
      title: movie.title,
      body: input,
      backdrop_path: movie.backdrop_path,
      // published_date: Date.now(),
      // author: author,
      // likes: likes,
    };

    if (uid && uid === author.uid && movie.title && input) {
      if (
        movie.title === title &&
        input === body &&
        movie.backdrop_path === backdrop_path
      ) {
        toastDefault("변경 사항이 없습니다.");
        setIsEditing(false);
      } else {
        updateArticles(documentId, payload).then(() => {
          toastSuccess("게시글이 수정되었습니다.");
          setArticleSnapshot({
            ...articleSnapshot,
            title: movie.title,
            body: input,
            backdrop_path: movie.backdrop_path,
          });
          setIsEditing(false);
        });
      }
    }
  };

클릭이 됐을 때, 위에서 선언한 state를 참조하여 내용이 snapshot과 동일한지 여부를 참조한다.
동일하다면 요청을 보내지 않고,
달라진 점이 있다면 update 요청을 보낸다.

update 요청이 수락되면 snapshot을 업데이트한다.

파이어베이스의 업데이트 요청 역시 매우 간단하다.
문서 번호만 잘 받으면 원하는 정보를 곧바로 덮어 씌울 수 있다.

export const updateArticles = (documentId, payload) => {
  return dbService.doc(`articles/${documentId}`).update(payload);
};

조건부 렌더링

이제 조건부 렌더링이다.
앞서 말했듯 isEditing 여부에 따라서 렌더링하는 내용이 달라져야 한다.

본래는 useMemo나 switch~case를 이용하는 거창한 계획을 세웠지만.....

변수와 상태를 제대로 참조하지 못하는 문제(클로저)가 생겼고,
또 input 태그가 의존자로 들어가니 결국에는 useMemo를 쓰나 안쓰나 그게 그거인 상황이 돼버렸다.

그래서 언제나 그렇듯 삼항 연산자로 구현한다.

  const cardBody = isEditing ? (
    <CardBodyEditing
      setInput={setInput}
      movie={movie}
      setMovie={setMovie}
      input={input}
    ></CardBodyEditing>
  ) : (
    <>
      <CardBody article={articleSnapshot} likesCount={likes.length} />
      <div className="card-footer likes">
        <small>
          <span style={likes.includes(uid) ? { color: "pink" } : {}}>
            {likes.length}</span>
          의 좋아요
        </small>
      </div>
    </>
  );

editing 모드가 아닐 때에는 기존의 카드 바디를 반환하고,
editing 모드인 경우에는 CardBodyEditing을 반환한다.

  const cardFooter = isEditing ? (
    <div onClick={onUpdate}>수정하기</div>
  ) : (
    <CardFooter author={author} published_date={published_date} />
  );

마찬가지로 Footer도 교체해준다.

        <div className="card-img-overlay d-flex flex-column">
          {cardBody}
          {cardFooter}
        </div>

카드 안에 예쁘게 넣어주면 된다.

이쯤되면 새로운 컴포넌트를 하나 더 파는게 낫지 않냐고 할 수 있겠지만,
아무튼 아님 ㅇㅇ 암튼 아님.

삭제

CRUD가 어느덧 다 구현되고 삭제만 남았다.

삭제 버튼을 따로 정의해서 만들자.

  const [isDeleting, setIsDeleting] = useState(false);
  const onDelete = () => {};
  const deleteButton = isDeleting ? (
    <>
      <div>
        <span>삭제하시겠습니까? </span>{" "}
        <UpdateSpan role="button" className="mx-3" onClick={onDelete}></UpdateSpan>{" "}
        <UpdateSpan
          role="button"
          onClick={() => {
            setIsDeleting(false);
          }}
        >
          아니오
        </UpdateSpan>
      </div>
    </>
  ) : (
    <div onClick={() => setIsDeleting(true)}>삭제하기</div>
  );

UpdateSpan은 별건 아니고, hover했을 때 글자 색이 바뀌는 녀석이다.

'삭제하기'를 클릭하면 바로 삭제되지 않고 isDeleting을 변경시켜서 '삭제하시겠습니까?'라고 묻는 방식이다.

아래는 삭제하기를 눌렀을 때 실행될 파이어베이스 요청이다.

export const deleteArticle = (documentId) => {
  return dbService.doc(`articles/${documentId}`).delete();
};

매우 간단하다.

생성을 할 때처럼, 삭제 요청을 보낸 후에도 articles 리스트를 조작해주어야 한다.

역시나 서버와 통신하여 articles 배열을 최신으로 만들기보다는
그냥 로컬에서 직접 조작하는 것으로 눈속임을 할 예정이다.

그런데 findIndex 등으로 배열을 찾을 때에, 여러 개의 setState 요청이 겹치는 등의 문제가 발생하면 리스트에서 엉뚱한 글이 지워질 수 있다.
이를 방지하고 항상 최신 상태의 state를 가져오는 방법은 함수형 업데이트를 이용하는 것이다.

  const onDelete = () => {
    deleteArticle(documentId).then(() => {
      toastSuccess("삭제되었습니다.");
      setArticles((prevArticles) => {
        const newArticles = [...prevArticles];
        newArticles.splice(
          newArticles.findIndex((e) => e.documentId === documentId),
          1,
        );
        return newArticles;
      });
    });
  };

위 delete 함수에서

setArticles((prev) => {})

이 부분이 바로 함수형 업데이트이다. setState에 콜백 함수를 넘기면, setState는 해당 콜백함수의 첫번째 인자로 최신 상태의 state를 넘긴다.

최신 상태의 state를 받아, state를 조작한 후, 반환값에 넣어주면 여러 setState가 순차적으로 state에 접근하고 반환한다.

함수형 업데이트를 사용한 덕분에 레이아웃 쉬프트도 없이 한방에 됐다.

무한 스크롤 구현하기

사실 파이어베이스를 사용할 때에는 무한 스크롤을 쓰지 않는게 비용적인 측면에서 도움이 되는 것 같기도....
하지만 게시글이 엄청 늘어날 수도 있기 때문에 intersection observer api를 이용해 무한 스크롤 구현하기 를 활용하여 무한 스크롤을 해보고자 한다.

파이어베이스는 커서 기반 페이지 네이션이 가능하다. startAt, startAfter 등에 시작지점을 넣고, .limit(갯수)로 불러올 최대 갯수를 불러오면 된다.

가장 기본적인 방법은 startAt(현재까지 불러온 갯수)를 하는 것이지만,
.orderby(필드).startAt(필드값) 으로 특정 값을 지닌 녀석 이후로도 불러올 수 있다.

fetchArticles를 바꿔준다.

export const fetchAticles = (published_date = 0) => {
  const dbRef = dbService
    .collection("articles")
    .orderBy("published_date", "desc");

  const query = published_date
    ? dbRef.startAfter(published_date).limit(5)
    : dbRef.limit(5);

  return query.get().then((Snapshot) => {
    console.log(Snapshot.docs);
    const Articles = Snapshot.docs.map((doc) => {
      const documentId = doc.id;
      const documentData = doc.data();
      return { documentId, ...documentData } as Article;
    });
    return Articles;
  });
};

날짜가 없는 경우엔 초기 로딩으로 판별하여 5개를 불러오고,
날짜자 있는 경우엔 후속 로딩으로 판별하여 startAfter로 불러온다.

      <div
        role="button"
        onClick={() => {
          const published_date = articles.length
            ? articles.at(-1).published_date
            : 0;
          fetchAticles(published_date).then((articles) => {
            setArticles((prev) => {
              return [...prev, ...articles];
            });
          });
        }}
      >
        버튼
      </div>

버튼을 추가해주고

눌러보면

5개씩 잘 로딩이 된다.

이제 intersection observer api를 추가해주면 된다.

이번 프로젝트에서는 글을 생성했을 때에도 초기 로딩이 진행되기 때문에, 초기 로딩 상태에서는 intersection observer api가 관찰하는 요소가 화면 안에 있으면 안된다.

cardGrid를 아래와 같이 수정해준다.

  1. 가장 밑에 트리거를 두기
  const trigger = useRef();


    <div className="container">
      <div className="row">{grid}</div>
      <div ref={trigger} style={{ width: "100%", marginBottom: "20px" }}></div>
    </div>
  1. useIntersectionObserber로 triggered를 바꿔주도록 함수 넣기
  useIntersection(
    trigger,
    () => {
      setTriggered(true);
    },
    true,
  );
  1. 해당 triggered 상태를 추적하는 useEffect 함수를 만들기
  useEffect(() => {
    if (!triggered) return;
    if (isLastPage) return setTriggered(false);

    fetchAticles(publishedDate).then((articles) => {
      if (articles.length === 0) return setIsLastPage(true);
      setPublishedDate(articles.at(-1).published_date);
      if (!publishedDate) return setArticles(articles);
      setArticles((prev) => {
        return [...prev, ...articles];
      });
    });
    setTriggered(false);
  }, [triggered]);

이때 publishedDate가 0이라는 뜻은 최초 로딩이라는 뜻이며, 초기 로딩시에는 배열에 추가하지 않고 배열을 통째로 교체한다.
isLastPage는 파이어스토어에서 빈 배열을 반환한 적이 있으니 더 이상 실행할 필요가 없다는 의미이다.

이제 나머지 fetchArticles를 사용하던 부분도 setTriggered를 변경하도록 바꾸면 fetchArticles가 해당 useEffect 내에서만 관리된다.

대표적으로 글을 생성했을 때 새로운 글을 불러오던 부분...

  useEffect(() => {
    if (creating) {
      setGrid([
        <CardCreate
          setCreating={setCreating}
          setUpdated={setUpdated}
          key="CardCreate"
        />,
        ...cardList,
      ]);
    } else {
      if (updated) {
        setGrid([...cardList]);
      } else {
        if (publishedDate) {
          setPublishedDate(0);
          setIsLastPage(false);
          setTriggered(true);
        }
      }
    }
  }, [creating]);

본래 else 부분에 fetchArticles 명령이 있었으나

        if (publishedDate) {
          setPublishedDate(0);
          setIsLastPage(false);
          setTriggered(true);
        }

위와 같이 상태를 바꾸는 명령으로 하였다.

if (publishedDate)는 최초 로딩시에 반응하지 않도록 넣어준 부분이다.

이후 setPublishedDate를 0으로, setIsLastPage를 false로 초기화해주어 초기 상태와 같게 만든 후
setTriggered(true)로 트리거를 발동시킨다.

40개씩 로딩하도록 설정한 후 이다.

스크롤을 내림에 따라 적절하게 추가로 로딩되는 것을 볼 수 있다.

intersection observer 방식의 단점은, 처음 로딩 때부터 화면에 들어와 한 번도 화면 밖으로 나가지 않은 경우 이벤트가 발생하지 않는다는 점이다.

이를 해결하기 위해서는 container에 높이를 지정하여 강제로 overflow를 일으켜야 한다.

실제로 원본 프로젝트 때는 그렇게 구현 했었지만, 디자인이 답답해보였고, 사람들 반응도 별로 좋지 않았다.

그래서 이번에는 그냥 버튼만 하나 만들어주는 걸로 퉁치려고 한다.

    <div className="container">
      <div className="row">{grid}</div>
      <div
        ref={trigger}
        style={{ width: "100%", marginBottom: "20px" }}
        role="button"
        onClick={() => setTriggered(true)}
      >
        {!isLastPage && articles.length ? "추가로 로딩하기" : ""}
      </div>
    </div>

이렇게 엄청 거대하고 길쭉한 화면을 쓰는 유저라면, 아래 버튼을 눌러서 추가 로딩할 수 있다.

날짜 표시하기

마지막으로 작성일 표시 기능만 손보고 마무리.

네이티브 앱 → 리액트 네이티브 앱 전환 그리고 1년 후라는 글에 아래와 같은 대목이 있었다.

라프텔의 에피소드 대여 잔여 시간을 표기하는 규칙은 다음과 같습니다.

72시간 이상 → 00일 남음
99일 초과시 99+일 남음
3시간 ~ 72시간 사이 → 00시간 남음
1시간 ~ 3시간 사이 → 00시간 00분 남음
1시간 미만 → 00분 남음

이거에서 영감을 받아 나도 아래와 같이 표기하기로 했다. (Date 객체를 국내 시간에 맞춰서 다루는게 어렵기도 하고...)

  const now = Date.now();
  const gap = now - published_date;
  let published_date_messge;
  if (gap < 60 * 1000) published_date_messge = "방금 전";
  else if (gap < 60 * 60 * 1000)
    published_date_messge = `${Math.floor(gap / (60 * 1000))}분 전`;
  else if (gap < 24 * 60 * 60 * 1000)
    published_date_messge = `${Math.floor(gap / (60 * 60 * 1000))}시간 전`;
  else if (gap < 15 * 24 * 60 * 60 * 1000)
    published_date_messge = `${Math.floor(gap / (24 * 60 * 60 * 1000))}일 전`;
  else if (gap < 365 * 24 * 60 * 60 * 1000)
    published_date_messge = `${Math.floor(
      gap / (30 * 24 * 60 * 60 * 1000),
    )}개월 전`;
  else
    published_date_messge = `${Math.floor(
      gap / (365 * 24 * 60 * 60 * 1000),
    )}년 전`;

결과물

profile
천재가 되어버린 박제를 아시오?

0개의 댓글