Instagram Clone : Frontend - part 3 [ FEED]

정관우·2021년 9월 26일
0
post-thumbnail

Header

Layout Header

Header는 로그인하거나 가입할 때, 숨겨지기 때문에 따로 Header와 함께 Route를 같이 렌더링해주는 컴포넌트가 필요하다.

// Layout.tsx
...
// 내용을 Header와 띄우고, 좌우 여백을 같은 constraint로 지정
const Content = styled.main`
  margin: 0 auto;
  margin-top: 45px;
  max-width: 930px;
  width: 100%;
`;

function Layout({ children }: Props) {
  return (
    <>
      <Header />
      <Content>{children}</Content>
    </>
  );
}

Layout 컴포넌트로 내려주는 children은 모든 내용이 들어가는 Home 컴포넌트다. 이런 식으로 Layout 컴포넌트의 자식으로 다른 컴포넌트를 내려주면 Header와 함께 내용이 깔끔하게 렌더링되는 것을 볼 수 있다.

// App.tsx
...
return(
...
{isLoggedIn ? (
	<Layout>
		<Home />
	</Layout>
 ) : (
  <Login />
)}
...

useUser Hook

현재 로그인 방식은 token이 localStorage에 저장되어있으면, 유효성과 상관없이 정상적으로 로그인한 것으로 판단한다. 서버 요청을 통해, token이 서버가 발급한 것이 맞는지 확인할 필요가 있다. token을 확인하는 함수는 Header 이외에 다른 곳에서 사용할 수도 있으므로, hook으로 따로 만들어준다.

query 요청은 useQuery를 사용한다. skip안에 조건을 넣으면, 조건에 해당되면 요청을 보내지 않는다.

그럼, token이 있을 때만 useUser로 서버에 요청을 보내게된다. useEffect로 useUser의 응답 데이터가 변할 때 응답이 null이면 강제로 로그아웃 시킨다.

// useUser.ts

function useUser() {
  const hasToken = useReactiveVar(isLoggedInVar);
  const { data, error } = useQuery(ME_QUERY, {
    skip: !hasToken,
  });

  useEffect(() => {
    if (data?.me === null) {
      logUserOut();
    }
  }, [data]);
  return { data };
} 

setContext in Apollo Client

이제 token의 유효성을 확인할 수 있지만, 아직 요청에 token을 추가하지 않았기 때문에 제대로 작동하지 않는다. 클라이언트의 모든 요청에 어떤 항목을 추가하고 싶다면 setContext를 사용해야한다.

그 전에, link를 추가해야하는데, link란 Apollo Client가 데이터 소스와 소통하는 방식을 의미한다. Http 요청은 httpLink, 웹소켓 요청은 webSocketLink, authorization 요청은 authLink를 사용한다. 다음과 같이 세팅해주자.

// apollo.ts

const httpLink = createHttpLink({
  uri: "http://localhost:4000/graphql",
});

// setContext
// 1번째 인자 : function (GQL req) - 필요 x
// 2번째 인자 : prevContext - 예전 headers 항목에 새로운 headers 추가
const authLink = setContext((_, { headers }) => {
  return {
    headers: {
      ...headers,
      // localStorage의 token을 headers에 저장
      token: localStorage.getItem(TOKEN),
    },
  };
});

export const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
});

백엔드에서 http 요청이 들어오면, 우선적으로 token을 확인하여 로그인 유저의 정보를 가져오는데 여기서 token이 유효하지 않으면 null을 리턴한다. token이 유효하면, useUser의 query 요청을 정상적으로 수행한 후 유저 정보를 리턴해준다.

// server.ts
...
const apollo = new ApolloServer({
  typeDefs,
  resolvers,
  // 로그인 유저의 정보에 접근 가능
  context: async (ctx) => {
    // http 요청인 경우
    if (ctx.req) {
      return {
        loggedInUser: await getUser(ctx.req.headers.token),
        client,
      };
...

// users.utils.ts
export const getUser = async (token) => {
  try {
    if (!token) {
      return null;
    }
    // find user with id inside token
    const verifiedToken: any = await jwt.verify(token, process.env.SECRET_KEY);
    if ("id" in verifiedToken) {
      const user = await client.user.findUnique({
        where: { id: verifiedToken["id"] },
      });
      if (user) {
        return user;
      }
    }
    // token이 유효하지 않으면 null 리턴
    return null;
...

// me.resolvers.ts
...
  Query: {
    me: protectedResolver((_, __, { loggedInUser }) =>
      // userUser 요청 성공 시, id와 일치하는 user 리턴
      client.user.findUnique({ where: { id: loggedInUser.id } })
...

이제 token의 유효성 여부에 따라, 로그인 / 로그아웃 처리가 자동적으로 이루어지는 것을 확인할 수 있다.

로그아웃

로그인 성공

Avatar

Header의 user 아이콘을 useUser로 가져온 avatar로 변경한다. Avatar 컴포넌트를 만든 후, props로 url을 내려주는데 없다면 빈 문자열을 할당해준다.

또, Avatar의 사이즈를 변경해주기 위해, lg라는 boolean 형태의 props를 내려주었다. lg의 유무에 따라서 Avatar의 크기가 변한다. lg가 없다면, false를 할당해준다.

// Avatar.tsx
interface Props {
  url: string | undefined | null;
  lg?: boolean;
}

interface SAvatar {
  lg: boolean;
}

const SAvatar = styled.div<SAvatar>`
  width: ${(props) => (props.lg ? "30px" : "25px")};
  height: ${(props) => (props.lg ? "30px" : "25px")};
   ...
`;

function Avatar({ url = "", lg = false }) {
  return <SAvatar lg={lg}>{url !== "" ? <Img src={url} /> : null}</SAvatar>;
}

Header에서 useUser로 가져온 유저 데이터 중 avatar을 Avatar 컴포넌트의 props로 내려준다.

// Header.tsx
...
const { data } = useUser();
return (
	...
	  <Icon>
		<Avatar url={data?.me?.avatar} />
	  </Icon>
	...

Photo Component

Feed Query

seeFeed query로 피드에 필요한 데이터들을 가져와서 Home 컴포넌트에 적절히 렌더시킨다.

// Home.tsx
...
const FEED_QUERY = gql`
  query seeFeed {
    seeFeed {
      id
      user {
        username
        avatar
      }
      file
      caption
      likes
      comments
      createdAt
      isMine
    }
  }
`;
...

function Home() {
  const { data } = useQuery<seeFeed>(FEED_QUERY);
  return (
    <div>
      {data?.seeFeed?.map((photo) => (
        <PhotoContainer key={photo?.id}>
          <PhotoHeader>
            <Avatar url={photo?.user?.avatar} />
            <Username>{photo?.user?.username}</Username>
          </PhotoHeader>
        </PhotoContainer>
      ))}
    </div>
...

Feed UI

Feed는 다음과 같이 UI를 구성하였다.

isLiked

로그인 유저가 사진에 좋아요를 눌렀는지 확인할 수 있는 방법이 없다. 백엔드의 photos resolvers에 추가해야한다.

// photo.typeDefs.ts
...
	type Photo {
		...
		isLiked : Boolean!
	}
...

// photo.resolvers.ts
...
     photo : {
    ...
    // photo의 id와 로그인 유저의 id를 가져옴
    isLiked: async ({ id }, _, { loggedInUser }) => {
      // 비로그인이면
      if (!loggedInUser) {
        // 로그인이 되지 않았으므로 좋아요 X
        return false;
      }
      // photoId = id 이고 userId = 로그인 유저 id인 like를 찾음
      const ok = await client.like.findUnique({
        where: { photoId_userId: { photoId: id, userId: loggedInUser.id } },
        // 존재만 찾기 때문에 id만 뽑아옴
        select: { id: true },
      });
      // 있으면
      if (ok) {
        // 좋아요 했음
        return true;
      }
      // 없으면, 좋아요 안했음
      return false;
    },
    ...

이제 Photo의 typeDefs에 isLiked가 추가되었기 때문에, seeFeed query에서 가져올 수 있게 되었다.

isLiked의 상태에 따라 다른 아이콘을 띄워준다. 참고로, icon은 svg 태그로 사이즈 변경이 가능하다.

// Home.tsx
...
<FontAwesomeIcon
	style={{ color: photo?.isLiked ? "tomato" : "inherit" }}
	icon={photo?.isLiked ? SolidHeart : faHeart}
/>

Liking Photos

Write Mutation

Like를 이미 눌렀으면 unlike, 안눌렀으면 like가 되는 toggleLike mutation을 적어준다.

// Photo.tsx
...
const TOGGLE_LIKE_MUTATION = gql`
  mutation toggleLike($id: Int!) {
    toggleLike(id: $id) {
      ok
      error
    }
...

Use Mutation

mutation을 useMutatoin hook으로 요청한다. 아이콘을 클릭했을 때, mutation function을 실행한다.

// Photo.tsx
function Photo({ id, user, likes, isLiked, file }: Props) {
  const [toggleLikeMutation, { loading }] = useMutation(TOGGLE_LIKE_MUTATION, 
		{variables: { id },
  });
  return (
	...
	<PhotoAction onClick={() => toggleLikeMutation()}>
	...

Refetching Queries

Mutation을 실행한 후, 백엔드에 에러가 없으면 query를 다시 호출한다. 이 방법으로 새로고침을 하지 않고 isLiked의 변경된 상태를 프론트엔드에 적용시킬 수 있다. 하지만, 이 방법은 Apollo Cache를 업데이트하긴 하지만 직접적으로 변경시키는 방법이 아니다.

이 방법은 Apollo가 data를 fetch한 후 바뀐 부분만 업데이트하지만, query 전체를 refetch하기 때문에 좋은 방법은 아니다. query가 크고 실시간 처리가 필요한 곳이면 적합하지 않다. 반대로, query가 작거나 prototype으로 금방 구현해야하는 상황에는 적합하다.

// Photo.tsx
...
function Photo({ id, user, likes, isLiked, file }: Props) {
  const [toggleLikeMutation, { loading }] = useMutation(TOGGLE_LIKE_MUTATION, {
    variables: { id },
    // 원하는 만큼 어떤 query든지 refetch 가능 {query: query_name, variables: { ... }}
    refetchQueries: [{ query: FEED_QUERY }], }); 
    ...

writeFragment

Refetch하지 않고 Apollo Cache에서 원하는 특정 object의 일부분을 수정할 수 있다. 다음과 같은 인자가 필요하다.

  1. id : Cache에 저장된 type의 id. Apollo가 query에 있는 id로 저장하기 때문에, query의 id를 쓰자.
  2. fragment : 수정할 데이터의 일부분
  3. data : Cache에 새로 쓸 데이터
// photo.tsx
...
function Photo({ id, user, likes, isLiked, file }: Props) {
  // data : 백엔드에서 준 데이터, cache : cache를 제어할 수 있는 link
  const updateToggleLike: MutationUpdaterFn<toggleLike> = (cache, result) => {
    const ok = result.data?.toggleLike.ok;
    if (ok) {
      // fragment AnyName on Photo{ target } : type이 Photo인 아무 이름의 fragment
      cache.writeFragment({
        id: `Photo:${id}`,
        fragment: gql`
          fragment AnyName on Photo {
            isLiked
	    likes
          }
        `,
        data: {
          isLiked: !isLiked,
	  likes: cacheIsLiked ? cacheLikes - 1 : cacheLikes + 1,
	}
	...

ReadFragment

Prop 없이도 cache에서 data를 만들어 사용할 수 있다. 만약 likes와 isLiked가 없더라도 아래와 같이 cache에서 data를 만든 후 writeFragment로 작동하게 할 수 있다.

// photo.tsx
...
const ok = result.data?.toggleLike.ok;
    if (ok) {
			// fragmentId와 fragment를 생성
      const fragmentId = `Photo:${id}`;
      const fragment = gql`
        fragment AnyName on Photo {
          isLiked
          likes
        }
      `;
      // fragment를 읽어온 후(read) 쓰기(write)
      const result: any = cache.readFragment({
        id: fragmentId,
        fragment,
      });
      // isLiked와 likes가 cache에 있으면
      if ("isLiked" in result && "likes" in result) {
        const { isLiked: cacheIsLiked, likes: cacheLikes } = result;
        // cache를 이용하여, 값을 바꿔줌. props 없이 cache에서만 일어남
        cache.writeFragment({
          id: fragmentId,
          fragment,
          data: { ... }
	...

Comment

CommentNumber & Comments

Back-End

댓글을 보여주기 위해, photo에 달린 총 comment의 개수와 comments가 필요하다. 이 두 가지는 백엔드에서 구현해서 가져오는 것이 좋다.

// photos.typeDefs.ts
...
type Photo {
    ...
    commentNumber: Int!
    comments: [Comment]
  }
...

// photos.resolvers.ts
Photo : {
...
// photoId가 일치하는 모든 comment를 가져와서 개수를 구함
commentNumber: ({ id }) => client.comment.count({ where: { photoId: id } }),
// photoId가 일치하는 comment와 함께 user 정보까지 추가
comments: ({ id }) => client.comment.findMany({ where: { photoId: id }, include: { user: true } }), ...

Front-End

이제 seeFeed에 다음 항목들을 추가한다.

// Home.tsx
...
const FEED_QUERY = gql`
	...
	comments { // comment
        id // comment의 id
        user { // comment 작성자
          username // 작성자 이름
          avatar // 작성자 사진
        }
        payload // 내용
        isMine // 로그인 유저가 쓴 글인가?
        createdAt // comment 만든 시간
      }
      commentNumber // 총 comment 개수
      ... 

가져온 데이터들을 UI로 렌더시킨다. 컴포넌들을 최대한 작은 단위로 쪼개어 구성한다.

// Home.tsx
...
function Home() {
  const { data } = useQuery<seeFeed>(FEED_QUERY);
  return (
    <div>
      <PageTitle title="Home" />
      {data?.seeFeed?.map((photo) => (
        <Photo key={photo!.id} {...photo!} />
      ))}
   ...
// Photo.tsx
...
function Photo({ ... }: seeFeed_seeFeed) {
  ...
  return (
        ...
        <Likes>{likes === 1 ? "1 like" : `${likes} likes`}</Likes>
        <Comments
          author={user.username}
          caption={caption}
          commentNumber={commentNumber}
          comments={comments}
        />
	...
// comments.tsx
...
function Comments({ ... }: Props) {
  return (
      ...
      <Comment author={author} payload={caption} />
      <CommentCount>
        {commentNumber === 1 ? "1 comment" : `${commentNumber} comments`}
      </CommentCount>
      {comments?.map((comment) => (
        <Comment
          key={comment!.id}
          author={comment!.user.username}
          payload={comment!.payload}
        />
      ...
function Comment({ ... }: Props) {
  ...
  return (
      ...
      <FatText>{author}</FatText>
      <CommentCaption>{payload}</CommentCaption>
      ...

Parsing Hashtags

Set innerHTML in React

Replace Regex

Caption에 있는 Hashtag를 링크로 바꿔주기 위해서 정규표현식을 사용해야한다. 정규표현식 함수 중 Replace를 사용한다. 문자열을 정규식과 일치하는 부분을 바꿔주는 함수인데, $& 패턴을 사용하면 변경된 문자열을 리턴해준다.

String.prototype.replace() - JavaScript | MDN

dangerouslySetInnerHTML

직접 HTML 태그를 렌더시키면 React가 텍스트로 변환하여 그대로 텍스트 그대로 띄워버린다. 유저가 직접적으로 코드를 삽입하지 못하도록 보호하는 것이다.

HTML 태그를 넣어주려면, dangerouslySetInnerHTML이라는 props에 입력하면 된다. 하지만, 앞서 말했듯이 유저가 직접 코드를 삽입할 수도 있으므로 방지책이 필요하다.

sanitize-html

sanitize-html이라는 라이브러리를 사용하여, HTML 태그 삽입을 제어할 수 있다. 먼저 내용을 한번 sanitizeHtml로 검열한 후, 허용된 태그만 걸러서 렌더시킨다. 이 3가지 방법을 다음과 같이 사용할 수 있다.

// Comment.tsx
...
const cleanedPayload = sanitizeHtml(
    payload!.replace(/#[ㄱ-ㅎ|ㅏ-ㅣ|가-힣|\w]+/g, "<mark>$&</mark>"),
    { allowedTags: ["mark"] }
  );
  return (
    <CommentContainer>
      <FatText>{author}</FatText>
      <CommentCaption
        dangerouslySetInnerHTML={{
          __html: cleanedPayload,
        }}
      ></CommentCaption>
    </CommentContainer>
  );
...

지금 필요한 것은 React 앱 안에서 Link를 타고 다른 컴포넌트로 이동하는 것이기 때문에, 굳이 위와 같은 방법을 쓸 필요는 없다. JS Array 함수로 정규표현식을 만족하는 단어들만 Link를 걸어, hashtag를 클릭 시 이동할 수 있게 만들자. 빈 태그 (Fragment)에 key를 부여하기 위해선, <>가 아닌 <React.Fragment> 를 사용해야한다.

// Comment.tsx
...
return (
      ...
      <CommentCaption>
        {payload?.split(" ").map((word, index) =>
          /#[ㄱ-ㅎ|ㅏ-ㅣ|가-힣|\w]+/g.test(word) ? (
            <React.Fragment key={index}>
              {" "}
              <Link to={`/hashtags/${word}`}>{word}</Link>{" "}
            </React.Fragment>
          ) : (
            <React.Fragment key={index}>{word} </React.Fragment>
          )
        )}
      </CommentCaption>
      ...
  );

cache.modify

writeFragment 없이 훨씬 더 간결한 방법으로 Apollo cache의 데이터를 쓰는 것이 가능하다. cache.modify는 다음과 같은 방법으로 사용 가능하다.

// Photo.tsx
...
function Photo({
  ...
  likes,
  isLiked,
}: seeFeed_seeFeed) {
  const updateToggleLike: MutationUpdaterFn<toggleLike> = (cache, result) => {
    const ok = result.data?.toggleLike.ok;
    if (ok) {
      const photoId = `Photo:${id}`;
      // id와 수정하고 싶은 field
      cache.modify({
        id: photoId,
        fields: {
          // 수정할 값 대신, 이전 값을 쓸 수 있는 함수가 제공
          isLiked(prev) {
            // 새로운 값
            return !prev;
          },
          likes(prev) {
            if (isLiked) {
              return prev - 1;
            }
            return prev + 1;
          },
	 ...

Create Comment

createComment Mutation

새로운 comment를 만드는 요청을 한다. comment를 DB에 저장시키고 다시 comment를 가져오는 방식이 아닌, Apollo cache를 이용하여 로컬에서 만든 comment를 바로 화면에 띄운다.

새로운 comment를 만들기 위해서 필요한 항목들은 모두 프론트에서 구할 수 있지만 유일하게 id는 백엔드에서 comment를 생성한 후 가져와야한다. 그래서, createComment의 응답으로 id를 가져올 수 있도록 한다.

// shared.ts
// 모든 Mutation에 대한 응답
type MutationResponse {
    ok: Boolean!
    error: String
    id: Int // optional
  }

// createComment.ts
...
createComment: protectedResolver(
      async (_, { photoId, payload }, { loggedInUser }) => {
				...
        const newComment = await client.comment.create({
          data: {
            // comment의 내용
            payload,
            // photo의 id와 연결
            photo: { connect: { id: photoId } },
            // 로그인 유저의 id와 연결
            user: { connect: { id: loggedInUser.id } },
          },
        });
        // 요청 성공
        // + id를 응답으로 보냄
        return { ok: true, id: newComment.id };
      }
	...

writeFragment

이제 로컬에서 만든 comment가 바로 프론트로 반영될 수 있게 cache를 조작한다. 우선, cache에 올라간 comment와 똑같은 형태로 데이터를 만든다.

// comment.tsx
...
function Comments({ ... }: Props) {
  // useUser hook으로 로그인 유저 데이터 가져오기
  const { data: userData } = useUser();
  const createCommentUpdate: MutationUpdaterFn<createComment> = (
    cache,
    result
  ) => {
    // input 창에 있는 내용 저장
    const { payload } = getValues();
    // 저장 후 input 창 지우기
    setValue("payload", "");
    const { ok, id } = result!.data!.createComment;
    // comment가 DB에 성공적으로 만들어지면 (ok), cache에 들어갈 데이터 조작
    if (ok && userData.me) {
      // comment와 같은 형태의 데이터
      const newComment = {
        __typename: "Comment",
        createdAt: Date.now() + "", // 현재 시간
        id, // mutation 응답으로 가져온 id
        isMine: true, // 로그인 유저가 쓴 글
        payload, // form에 적은 내용
        user: { ...userData.me }, // 로그인 유저 정보
      };

이 데이터로 writeFragment 를 사용하여 cache에 올릴 fragment를 만든다. 이 과정을 거치지 않고, 바로 위조된 comment 데이터를 Photo cache에 올려도 렌더링에 필요한 모든 데이터가 있기 때문에 프론트에선 제대로 작동하는 것처럼 보인다.

하지만, Apollo cache는 Photo와 Comment 간의 참조 관계(__refs)를 모르기 때문에 올라온 데이터를 삭제하는건 불가능하다. 진짜 백엔드와 통신하는 것처럼 cache를 똑같이 구현하기 위해선 아래와 같은 과정을 거쳐야한다.

// comment.tsx
...
const newCacheComment = cache.writeFragment({
        // 새로운 fragment이기 때문에 id X
        data: newComment,
				// fragment를 GraphQL 형태로 나타내야함
        fragment: gql`
          fragment AnyName on Comment {
            id
            createdAt
            isMine
            payload
            user {
              username
              avatar
            }
          }
        `,
      });

cache.modify

새로 만든 Comment fragment (newCacheComment)를 Photo에 추가한다. 백엔드에서 모든 comment 데이터를 가져오지 않아 리소스를 낭비하지 않고, 자체적으로 로컬에서 comment를 화면에 띄우기 때문에 훨씬 빠르다.

// comment.tsx
...
cache.modify({
        id: `Photo:${photoId}`,
        fields: {
          // prev : 원래 cache에 있는 값
          comments(prev) {
            // 위조 comment로 덮어씀
            return [...prev, newCacheComment];
          },
          // comment 개수 추가
          commentNumber(prev) {
            return prev + 1;
          },
        },
      });
      ...

Delete Comment

Sending Props

comment를 삭제하는 요청을 하기 위해서, comment의 id가 필요하다. 또, 로그인 유저가 쓴 글만 삭제 버튼이 보이게 하기 위해서 isMine이 필요하다. 마지막에, cache에서 comment가 달린 Photo가 무엇인지 알아야 comment를 삭제한 다음 comment의 개수에 1을 뺄 수 있기 때문에 photoId도 필요하다. 이 세 가지를 Comment 컴포넌트로 내려준다.

// Comments.tsx
...
return(
	...
	{comments?.map((comment) => (
        <Comment
          ...
          id={comment.id}
          photoId={photoId}
          isMine={comment.isMine}
        />
      ))}
      ...

deleteComment Mutation

comment의 id로 삭제 요청을 한다. Mutation 함수를 버튼에 이벤트로 걸어준다. 버튼을 클릭하면, 일단 백엔드에서는 정상적으로 해당 id의 comment가 삭제된다.

// Comment.tsx
...
const DELETE_COMMENT_MUTATION = gql`
  mutation deleteComment($id: Int!) {
    deleteComment(id: $id) {
      ok
    }
  }
`;

const [deleteCommentMutation] = useMutation(DELETE_COMMENT_MUTATION, {
    variables: {
      id,
    },
  });

const onDeleteClick = () => {
    deleteCommentMutation();
  };

return (
...
{isMine && <button onClick={onDeleteClick}>X</button>}
...

Update using cache

이제 삭제된 comment를 프론트에 반영한다. 이전과 마찬가지로, 백엔드에서 굳이 refetch를 하지 않고 cache에서 데이터를 지운 후 바로 프론트 화면에 띄워지도록 한다.

우선, update mutation 함수를 만든다. cache.evict를 사용하면 매우 간단하게 cache에 저장된 데이터를 id만으로 삭제 가능하다. comment를 삭제한 후, photoId로 Photo cache의 commentNumber field 값을 1 줄인다. 이렇게 만든 update mutation을 useMutationupdate 옵션에 넣어주면 프론트에서도 deleteComment가 바로 반영된다.

// Comment.tsx
...
function Comment({ id, photoId, isMine, ... }) {
  const updateDeleteComment: MutationUpdaterFn<deleteComment> = (
    cache,
    result
  ) => {
    const { ok } = result!.data!.deleteComment;
    if (ok) {
      cache.evict({ id: `Comment:${id}` });
      cache.modify({
        id: `Photo:${photoId}`,
        fields: {
          commentNumber(prev) {
            return prev - 1;
          },
        },
      });
    ...

const [deleteCommentMutation] = useMutation(DELETE_COMMENT_MUTATION, {
    ... 
    update: updateDeleteComment,
  });
profile
작지만 꾸준하게 성장하는 개발자🌳

0개의 댓글