영화앱 13. 프로필 페이지 디자인 하기

jonyChoiGenius·2023년 2월 24일
0

Navbar를 만들었으니 프로필 페이지를 만들면 끝난다.

Profile페이지를 어떻게 만들까 고민도 하고, 삽질도 많이 하다가
결과적으로 내 프로필 페이지는 static으로, 다른 유저의 프로필 페이지는 SSR로 제작하기로 했다.
그 이유는
1. 내 프로필 페이지와 다른 사람의 프로필 페이지는 사용자 경험이 다르다. 다른 사람의 프로필 페이지에서는 팔로우 여부가 중요해진다.
2. 따라서 둘을 통합하여 쿼리 스트링으로 렌더링하려고 했는데, 초기 렌더링 상태와 쿼리가 반영되어 hydration된 후의 결과물이 달라 layout shift가 심하게 일어났다.
3. 그리고 결과적으로 쿼리를 반영하기 위해 많은 부분을 삼항 연산자를 이용해 조건부 렌더링하여 코드 가독성이 심하게 나빠졌다.

따라서 컴포넌트 재사용성이 나빠지더라도, 같은 레이아웃을 가진 두 개의 컴포넌트, profile/index.tsxprofile/[uid].tsx를 만들기로 했다.

커스텀 링크 태그 만들기

프로필 부분은 영화 일기 카드를 만들면서 사용한 footer 부분을 재사용하기로 했다.
이때 로그인된 uid와 이동하려는 uid가 같으면, profile/[uid].tsx가 아닌 profile/index.tsx로 이동시키도록 하였다.

//componets/ProfileLink.tsx
import Link from "next/link";
import React from "react";

const ProfileLink = ({ uid, myUid, className = "text-white", children }) => {
  const pathname = myUid === uid ? "/profile" : `/profile/${uid}`;
  return (
    <Link
      href={{
        pathname: pathname,
      }}
      className={className}
    >
      {children}
    </Link>
  );
};

export default ProfileLink;

내 프로필 페이지 만들기

프로필 페이지의 코드가 복잡하여 간단하게 구조만 설명하고자 한다.

return(
    <StyledProfile>
      <컨테이너>
        <프로필카드>
          <h4>{profile.nickname}</h4>
          <img
            src={profile.image}
            style={{ maxWidth: "240px" }}
            className="m-3"
          />
          <button
            type="button"
            className={`btn btn-dark
            `}
            onClick={onLogout}
          >
            로그아웃 하기
          </button>
          <ROW>
            <h6>{profile.articles.length}</h6>
            <h6 data-bs-toggle="modal" data-bs-target="#followersModal">
              {profile.followers.length}
            </h6>
            <h6 data-bs-toggle="modal" data-bs-target="#followingsModal">
              {profile.followings.length}
            </h6>
          </ROW>
        </프로필카드>
        <컨테이너>
          <ROW>
              <컨테이너>
                  <h4 className="m-3 mr-0">나의 인생영화</h4>
                  <div role="button" onClick={()=>setIsUpdationg(true)}
                  >{isUpdating? '수정하기' : '취소'}</div>
              </컨테이너>
              <MovieRow moviesData={profile.myMovies}></MovieRow>
          </ROW>
          <ROW>
              <h4 className="m-3">나를 위한 추천 영화</h4>
              <MovieRow moviesData={profile.myMovies}></MovieRow>
          </ROW>
          <ROW>
              <h4 className="m-3">내가 작성한 글</h4>
              <CardRow articles={profile.articles}></CardRow>
          </ROW>
        </컨테이너>
    </컨테이너>
  </StyledProfile>
  )

프로필 카드 부분에는 닉네임, 이미지, 로그아웃 버튼 작성글 갯수, 팔로워 수, 팔로윙 수가 표시된다.

팔로워 수, 팔로윙 수를 누르면 부트스트랩 모달창이 표시된다.

하단에는 '나의 인생영화', '나를 위한 추천 영화', '내가 작성한 글'이 표시된다.

로그아웃 버튼 구현하기

파이어베이스에서 기본적으로 signOut이라는 메서드를 지원한다.
해당 메서드를 사용하고, redux-persist의 데이터를 지우기 위해 세션 스토리지를 지워준다.

  const onLogout = useCallback(() => {
    authService.signOut();
    sessionStorage.clear();
    router.push("/");
  }, []);

인생영화 수정 구현하기

프로필 수정을 어디까지 허용할까 고민했다.
프로필 이미지와 닉네임도 수정이 가능해야 했으나,
비관계형 데이터베이스인 파이어스토어에서 이미지와 닉네임을 수정하게 되면, 이미지와 닉네임을 복제하여 저장한 내가 작성한 모든 글과 나를 팔로우 중인 모든 유저, 내가 팔로우중인 모든 유저를 업데이트 해야하고, 결과적으로 프로필 수정 한번에 1000번이 넘는 요청이 오가게 될 것이다.

따라서 인생영화 수정한 허용했다.

인생영화 수정은 프로필 생성 페이지에서 작성한 내용을 재사용했다.
단, 인생영화가 [{object{array}}, {object{array}}, {object{array}}]의 형식이라 스프레드 연산자로 수정하는 경우 (immer를 사용하는 리덕스 툴킷임에도 불구하고) 리덕스에서 에러를 뱉어낸다.

따라서 JSON.stringify(), JSON.parse()를 이용해서 얕은 복사를 진행하기로 하였다.

  const onRequest = async (e) => {
    e.preventDefault();
    setIsLoading(true);
    const myRecommendations = await newRecommendations(myMovies);
    updateProfile(profile.documentId, myMovies, myRecommendations)
      .then(async (res) => {
        dispatch(
          patchUserProfile({
            myMovies: JSON.parse(JSON.stringify(myMovies)),
            myRecommendations: JSON.parse(JSON.stringify(myRecommendations)),
          }),
        );
        setIsLoading(false);
        setIsUpdating(false);
        toastSuccess("프로필이 수정되었습니다.");
      })
      .catch((err) => {
        console.log(err);
        setIsLoading(false);
        setIsUpdating(false);
        toastError("프로필 수정에 실패하였습니다.");
      });
  };
//store/authSlice.tsx
    patchUserProfile(state, action) {
      state.userProfile = { ...state.userProfile, ...action.payload };
    },

patchUserProfile이 스프레드 연산자를 씀에도 인식이 잘 되는 것을 보니, depth가 싶은 영화 객체만 제대로 인식을 못한 것 같다.

특정 유저가 작성한 게시글 불러오기 (파이어스토어 복합쿼리)

특정 유저가 불러온 게시글을 불러오기 위해서 fetchArticles 요청을 수정했다.
조건부로 query를 추가해나가는 방식이다.

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

  let query = dbRef;
  if (authorId) {
    query = query.where("author.uid", "==", authorId);
  } else {
    if (published_date) {
      query = query.startAfter(published_date);
    }
    query = query.limit(40);
  }

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

authorId가 있을 때에는 where 쿼리만 추가해주어 모든 게시글을 불러온다. 참고로 파이어 스토어 맵 객체의 특정 키에 접근하기 위해서는 자바스크립트에서 객체를 다룰때와 마찬가지로 "author.uid"와 같이 .으로 접근하면 된다.

authore가 없을 때에는 기존과 같이 startAfter와 limit을 추가한다.

이렇게 작성하면...

아래와 같이 에러가 뜬다.

파이어스토어에서 복합쿼리를 이용하려고하면 위와 같이 인덱스를 생성하라고 알려준다. (파이어스토어는 인덱스를 자동으로 생성하지 않는다.)

해당 경고창의 주소를 누르면


이렇게 실행한 쿼리에 필요한 필드와 상태를 자동으로 만들어준다.

한편 쿼리를 칠 때에,
객체의 프로퍼티에 접근하려면 .으로 접근 (dbRef.collection("Table").where("exObject.dataToQuery", "==", "value"))
배열이 특정 요소를 포함하는지는 in, not-in, array-contains-any 등을 사용한다.
(citiesRef.where('country', 'in', ['USA', 'Japan']);)

복합쿼리 제거하기

복합쿼리를 사용했더니 프로필을 불러올 때마다 작성한 글 갯수만큼 읽기 요청이 들어간다.
복합 쿼리를 제거하고, where 쿼리만 사용하기로 하였다.

export const fetchAticles = (published_date = 0, authorId = "") => {
  let dbRef = dbService.collection("articles");
  let query;
  if (authorId) {
    query = dbRef.where("author.uid", "==", authorId);
  } else {
    query = dbRef.orderBy("published_date", "desc");
    if (published_date) {
      query = query.startAfter(published_date);
    }
    query = query.limit(40);
  }

  // console.log(JSON.stringify(query));

  return query.get().then((Snapshot) => {
    const Articles = Snapshot.docs.map((doc) => {
      const documentId = doc.id;
      const documentData = doc.data();
      return { documentId, ...documentData } as Article;
    });
    if (authorId) {
      Articles.sort((a, b) => b.published_date - a.published_date);
    }
    return Articles;
  });
};

authorId 일 때에는 orderBy 쿼리를 제거하고, 자바스크립트의 sort로 대체하는 방식이다. 이후 색인을 제거한 뒤에도 잘 작동하는 것을 확인할 수 있었다.

다른 사람 프로필 페이지 만들기

redux 스토어와 useEffect로 fetch한 데이터를 적극적으로 참조하여 활용했던 내 프로필 페이지와 달리,

다른 사람 프로필 페이지는 SSR로 받은 props를 활용하기로 하였다.

쿼리 스트링을 받아서 프로필과 게시글을 불러오는 부분이다.

export const getServerSideProps = async (context) => {
  try {
    const { uid } = context.query; // get the URL parameter

    const profileSnapshot = await fetchProfile(uid as string);
    const articlesSnapshot = await fetchAticles(0, profileSnapshot.uid);

    return {
      props: {
        profileSnapshot,
        articlesSnapshot,
      },
    };
  } catch {
    return {
      notFound: true,
    };
  }
}

getServerSideProps의 첫번째 인자인 context에서 query에 접근할 수 있다.
한편 서버의 요청이 실패하면, notFound: true를 반환하고 404페이지로 이동하게 된다.

이 상태로 진행을 하면, 내가 팔로우 했는지 여부 등을 redux 등의 상태와 비교하며 렌더링하게 되어 hydration이 일어난다. 따라서 서버사이드 렌더링을 진행할 때에 로그인 된 사용자의 정보를 받기로 했다.
한편 팔로우, 팔로잉 기능에서 요청 횟수를 너무 아끼면 서버와 렌더링 된 정보가 불일치하는 문제가 발생할 수 있어, SSR 과정에서 리덕스 스토어의 현재 프로필도 수정하도록 하였다. 불필요한 fetchProfile 요청이긴 하지만 팔로우 팔로잉 기능을 위한 최소한의 안전장치랄까...

리덕스 wrapper를 사용하여 SSR을 하는 방법에 대해서는 Firebase : 로그인 구현하기 with SSR에서 작성한 바 있다.

export const getServerSideProps = wrapper.getServerSideProps(
  (store) => async (context) => {
    try {
      const { uid } = context.query; // get the URL parameter
      const { value: myUid } = context.req.headers.cookie
        .split("; ")
        .map((cookie) => {
          const [key, value] = cookie.split("=");
          return { key: key, value: value };
        })
        .find((e) => e.key === "uid");

      const profileSnapshot = await fetchProfile(uid as string);
      const myProfile = await fetchProfile(myUid);
      const articlesSnapshot = await fetchAticles(0, profileSnapshot.uid);

      store.dispatch(setUserProfile(myProfile));
      return {
        props: {
          profileSnapshot,
          articlesSnapshot,
          myProfile,
          myUid,
        },
      };
    } catch {
      return {
        notFound: true,
      };
    }
  },
);

위 코드에서 fetchProfile(uid as string)fetchProfile(myUid)은 Promise.allSettled()로 합칠 수 있..지만 패스.
fetchProfile(myUid) 요청을 추후에 삭제할 수도 있어서 일단 저렇게 2개로 분리해두어야 겠다.

팔로우 팔로잉 기능

팔로우 팔로잉 기능은 기존 게시글 좋아요 기능 with debounce과 로직이 비슷하다.

  const [followed, setFollowed] = useState(
    !!profileSnapshot.followers.find((e) => e.uid === myUid),
  );
  useEffect(()=>{
    대충 profileSnapshot.followers에 
    내 프로필 정보 추가하는 로직
  }, [followed])

  const [isClicked, setIsClicked] = useState(false);
  const onClickHandler = () => {
    setIsClicked(true);
    setFollowed(!followed);
  };

  const debouncedFollowed = useDebounce(followed, 200);
  useEffect(()=>{
    isClicked가 true이면
  	대충 서버에 update 요청 보내고
    isClicked를 false로 바꾸는 로직
  }, [debouncedFollowed])

useEffect(()=>{}, [followed])는 실시간으로 follow 수를 렌더링 한다.

isClicked 상태는 마지막 업데이트가 있은 후, 사용자가 '팔로우 하기', '팔로우 취소'를 누른적이 있는지를 나타낸다.

서버에 요청 보내기

useEffect(()=>{}, [debouncedFollowed])의 조건은
1. 최초에 마운트 됐을 때
2. debouncedFollowed값이 바뀌었을 때
둘 중 하나일 때 실행된다.

debouncedFollowed값이 바뀌는 조건은
1. followed가 마지막으로 바뀐 후 0.2초 동안 바뀌지 않았을 때,
2. 그 바뀐 follwed 값이 debouncedFollowed와 다를 때,
두 가지가 모두 충족되어야 한다.

따라서 불필요한 조건을 크게 줄일 수 있는데,
마운트 됐을 때에는 무조건 해당 이펙트가 실행된다. 'isClicked'가 false이면 빠른 반환하도록 하여 이를 막았다.

파이어스토어에서 팔로우 팔로잉 기능을 다룰 때 유의할 점은,

  1. 비관계형 데이터베이스이기 때문에 관계된 모든 문서를 업데이트 해야 한다는 점이다.

내가 팔로우를 했다면, 상대방의 follower에 내가 추가되고, 나의 follwing에서 상대방이 추가된다.

  1. 맵 객체의 특정 인스턴스로 배열값을 찾을 수 없다는 점이다.
    'uid가 특정 값인 객체를 제거하라'와 같은 쿼리가 불가능하다.
    이러한 쿼리를 위해서는 자바스크립트이 find 메서드를 이용해서 아래와 같이 실행해야 한다.
docRef.update({
  followers: firebaseInstance.firestore.FieldValue.arrayRemove(스냅샷배열.find(e=> e.uid==='찾을uid')
),

이번에는 find 인덱스를 사용하지 않고 작성해보았다.

  useEffect(() => {
    if (!isClicked) return;
    if (debouncedFollowed) {
      Promise.allSettled([
        docRef.update({
          followers: firebaseInstance.firestore.FieldValue.arrayUnion({
            uid: myProfile.uid,
            nickname: myProfile.nickname,
            image: myProfile.image,
          }),
        }),
        myDocRef.update({
          followings: firebaseInstance.firestore.FieldValue.arrayUnion({
            uid: profile.uid,
            nickname: profile.nickname,
            image: profile.image,
          }),
        }),
      ]).then(() => {
        setIsClicked(false);
      });
    } else if (!debouncedFollowed) {
      Promise.allSettled([
        docRef.update({
          followers: firebaseInstance.firestore.FieldValue.arrayRemove({
            uid: myProfile.uid,
            nickname: myProfile.nickname,
            image: myProfile.image,
          }),
        }),
        myDocRef.update({
          followings: firebaseInstance.firestore.FieldValue.arrayRemove({
            uid: profile.uid,
            nickname: profile.nickname,
            image: profile.image,
          }),
        }),
      ]).then(() => {
        setIsClicked(false);
      });
    }
  }, [debouncedFollowed]);

이렇게 update 요청이 반영되면, 조건부 렌더링을 통해 그 사람이 작성한 글과 그 사람의 추천 영화를 볼 수 있게 된다.

(로딩 속도 왤케 느림....;)

이렇게 해서 프로필 기능까지 모든 기능을 완성했다.

최적화와 배포만 남았다.

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

0개의 댓글