0428 CLANBE 개발일지

dowon kim·2024년 4월 28일
0

CLANBE

목록 보기
9/11

매일 올리자니 개발하는 파트의 덩어리가 점점 큰 단계로 오면서

딱 떨어지게 끝나지 않거나 정신없이 예상외의 문제를 해결하는 상황이 자주 오게되면서

개발일지의 갱신 텀이 길어진 느낌이 있다..

체계적으로 개발 프로세스를 잡지 않은것에 대한 여파가 여러군데서 오는 느낌이라

이에서 파생되는 반성과 경험을 쌓게되었다.

import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";

export const config = {
  providers: [
    Credentials({
      credentials: {
        email: { label: "Username", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        const { email, password } = credentials;

        try {
          const response = await fetch(`${process.env.NEXT_AUTH_URL}`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ signInState: { email, password } }),
        });

          const data = await response.json();

          if (response.ok && data.user) {
            return data.user; // 성공적으로 사용자 정보를 받음
          } else {
            // API에서 반환된 에러 메시지 사용 또는 기본 에러 메시지 설정
            throw new Error(data.message || '로그인에 실패했습니다. 다시 시도해 주세요.');
          }
        } catch (error:any) {
          // catch 블록에서 오류를 잡아 에러를 던집니다.
          throw new Error(error.message || '로그인 처리 중 오류가 발생했습니다.');
        }
      }
    }),
  ],
  pages: {
    signIn: "/AUTH/signin", // 사용자 정의 로그인 페이지
    // 기타 페이지 설정 생략...
  },
  basePath: "/api/auth",
  callbacks: {
    authorized({ request, auth }) {
      const { pathname } = request.nextUrl;
      if (pathname === "/AUTH/signin") return !!auth;
      return true;
    },
    jwt({ token, user }) {
      if (user) {
        // 사용자 정보 확장 필드 추가
        token.avatar = user.avatar;
        token.name = user.name;
        token.nickname = user.nickname;
        token.role = user.role;
        token.grade = user.grade;
        token.point = user.point;
        token.tear = user.tear;
        token.BELO = user.BELO;
        token.team = user.team;
      }
      return token;
    },
    session({ session, token }) {
      // 세션 정보에 토큰에서 사용자 정보를 추가
      if (token) {
        session.user = {
          avatar: token.avatar as string,
          name: token.name as string,
          nickname: token.nickname as string,
          point: token.point as number,
          BELO: token.BELO as any,
          email: token.email as string,
          role: token.role as string,
          grade: token.grade as number,
          tear: token.tear as string,
          team: token.team as string,
          id: "default-id", // 필요한 경우 token에서 id를 가져오거나 기본 id 제공
          emailVerified: null, // emailVerified는 null이 가능
        };
      }
      return session;
    },
  },
   session: {
    strategy: "jwt",
    maxAge: 3600, // 1시간 동안 세션 유지
  },
  jwt: {
    maxAge: 3600, // JWT 토큰의 최대 유효 시간은 1시간
  },
  secret: `${process.env.NEXT_AUTH_SECRET}`,
} satisfies NextAuthConfig;

export const { handlers, auth, signIn, signOut } = NextAuth(config);

next-auth의 auth.ts를 이와같이 구성하여

jwt토큰 및 세션의 생명주기를 1시간으로 잡고 각 페이지에서 세션을 통해 로그인된 유저의 정보를 조회하고

이에 따른 조건부 렌더링 및 로직을 짤 수 있었다.


게시글 상세보기 페이지 및 게시글 작성 CRUD에 대한 API와 기능 / 서버액션을 1차 개발완료 하였다.

각 페이지에 대해 서버액션 및 REVALIDATETAG를 구현하기 위하여 기존 코드를 리팩토링 하였다.

import BoardLayout from "@/components/BoardLayout";
import PostForm from "@/components/PostForm/PostForm";
import { Post } from "@/service/posts";
import { revalidateTag } from "next/cache";
import React from "react";

async function getAllPost() {
  "use server";
  const response = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/posts`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ category: "allposts" }),
    next: { tags: ["post"] },
  });
  const posts = await response.json();
  return posts;
}

type Props = {
  params: {
    slug: string;
    categoryId: string;
  };
};

// 페이지 컴포넌트 정의
export default async function PostPage({
  params: { slug, categoryId },
}: Props) {
  const posts = await getAllPost();
  revalidateTag("post");

  const post = posts.data.find((post: Post) => post._id === slug);

  // PostForm 컴포넌트에 post 데이터 전달
  return (
    <div className="w-full mt-8">
      <PostForm post={post} />
      <BoardLayout
        boardTitle={"전체 게시글"}
        announce={posts.data}
        posts={posts.data}
      />
    </div>
  );
}

위와 같이 서버컴포넌트와 클라이언트컴포넌트를 필요에 따라 사용할 수 있는 구조로 리팩토링 하며

상황에 따라 클라이언트 컴포넌트 안에서 서버컴포넌트를 사용해야 할때는

import { Button, Card, CardHeader, Textarea, User } from "@nextui-org/react";
import { CardFooter, Link as MyLink } from "@nextui-org/react";
import UserProfile from "../UserProfile";
import { useSession } from "next-auth/react";
import CommentComponent from "../CommentComponent/CommentComponent";
import ReplyComponent from "../ReplyComponent/ReplyComponent";
import SubmitModal from "../SubmitModal";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { revalidatePath } from "next/cache";
import { deleteComment, updateComment } from "./actions";

type CommentCardProps = {
  commentid: string;
  author: string;
  text: string;
  postid: string;
  date: Date;
  category: string;
};

export default function CommentCard({
  author,
  text,
  date,
  postid,
  commentid,
  category,
}: CommentCardProps) {
  const router = useRouter();
  const { data: session, status } = useSession();
  const isLoggedIn = status === "authenticated";
  const user = session?.user;

  const [isDelete, setIsDelete] = useState(false);
  const [editMode, setEditMode] = useState(false);
  const [editedText, setEditedText] = useState(text);

  const handleDelete = async () => {
    try {
      const response = await deleteComment({ postid, commentid });
      if (!response.ok) {
        throw new Error(`Error: ${response.statusText}`);
      }
      router.push(`/post/read/${postid}/${category}`);
    } catch (error) {
      console.error("Failed to delete the comment:", error);
    }
  };

  const handleUpdate = async () => {
    try {
      await updateComment({
        postid,
        commentid,
        author,
        editedText,
      });
      setEditMode(false);
    } catch (error) {
      console.error("Failed to update the comment:", error);
    }
  };

  const isAuthor = user?.email === author;

  // Determine the card's border style based on authorship
  const cardStyle = isAuthor
    ? { border: "1px solid #0070f3", boxShadow: "0 2px 6px #0070f350" }
    : {};

  return (
    <div className="m-4 max-w-[700px]">
      <Card style={cardStyle}>
        <CardHeader className="justify-between">
          <UserProfile email={author} />
          {editMode ? (
            <div className="hidden md:flex gap-2">
              <Button
                color="success"
                size="sm"
                variant="ghost"
                onClick={handleUpdate}
              >
                저장
              </Button>
              <Button
                color="danger"
                size="sm"
                variant="ghost"
                onClick={() => {
                  setEditMode(false);
                  setEditedText(text);
                }}
              >
                취소
              </Button>
            </div>
          ) : (
            isLoggedIn &&
            isAuthor && (
              <div className="hidden md:flex gap-2">
                <Button
                  color="primary"
                  size="sm"
                  variant="ghost"
                  onClick={() => setEditMode(true)}
                >
                  수정
                </Button>
                <Button
                  color="danger"
                  size="sm"
                  variant="ghost"
                  onClick={handleDelete}
                >
                  삭제
                </Button>
              </div>
            )
          )}
        </CardHeader>
        <Card
          className="flex-1 p-2 m-2 overflow-hidden"
          style={{ maxWidth: "700px", overflowWrap: "break-word" }}
        >
          {editMode ? (
            <Textarea
              value={editedText}
              onChange={(e) => setEditedText(e.target.value)}
              fullWidth
              autoFocus
            />
          ) : (
            text
          )}
        </Card>
      </Card>
      {editMode ? (
        <div className="block md:hidden flex gap-2">
          <Button
            color="success"
            size="sm"
            variant="ghost"
            onClick={handleUpdate}
          >
            저장
          </Button>
          <Button
            color="danger"
            size="sm"
            variant="ghost"
            onClick={() => {
              setEditMode(false);
              setEditedText(text);
            }}
          >
            취소
          </Button>
        </div>
      ) : (
        isLoggedIn &&
        isAuthor && (
          <div className="block md:hidden flex gap-2">
            <Button
              color="primary"
              size="sm"
              variant="ghost"
              onClick={() => setEditMode(true)}
            >
              수정
            </Button>
            <Button
              color="danger"
              size="sm"
              variant="ghost"
              onClick={handleDelete}
            >
              삭제
            </Button>
          </div>
        )
      )}
    </div>
  );
}
"use server";

import { revalidateTag } from "next/cache";

type deleteCommentProps = {
  postid: string;
  commentid: string;
};

type updateCommentProps = {
  postid: string;
  author: string | null | undefined;
  editedText: string;
  commentid: string;
};

export async function deleteComment({ postid, commentid }: deleteCommentProps) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_URL}/api/comment/delete`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ postid, commentid }),
    }
  );
  revalidateTag("post");
  return await response.json();
}

export async function updateComment({
  postid,
  commentid,
  author,
  editedText,
}: updateCommentProps) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_URL}/api/comment/update`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        postData: { postid, author, editedText, commentid },
      }),
    }
  );
  revalidateTag("post");
  //return await response.json();
}

위와같이 폴더구조를

액션과 컴포넌트 코드로 나누고 병용하는 방식으로 하여 서버액션 및 revalidatetag를 통해

조건부 재렌더링을 할 수 있도록 변경하였다.


이러한 방식을 댓글 및 대댓글에 대한 CRUD에도 똑같이 반영하여

vercel에 배포된 ISG 페이지에서도 CSR을 유지하며 데이터의 변경에 대한 즉각적인 피드백이 페이지에서 이루어지도록

할 수 있었다.

그 이외에도 구현한 세션에서 유저의 이메일을 뽑아 저자일 경우와 아닐경우에 따라 조건부 렌더링으로 자신의 게시물에서만

테두리 포인팅 및 수정 삭제 버튼이 렌더 되도록 구현했다.

여기까지 구현하는데 거진 일주일에 해당하는 시간이 소요가 되었는데,

예상보다 훨씬 더 긴시간이 걸렸고

개발을 하면서 전혀 예상 못한 문제가 발생함에 따라 레퍼런스 및 자료를 통해 이를 해결해야 하지만

현재 사용중인 nextjs14 버전의 레퍼런스가 기존 리액트에 비해 얻을 수 있는 자료가 너무 부족하여

개발문서 및 개발문서 공식예제 소스를 엄청나게 분석하는 경험을 하기도 하고

기존에 HAPOOM을 개발할때와 달라진 방식의 NEXTJS를 사용하게 되어서 변수가 굉장히 많았다.

우선 NEXTJS14 버전을 사용하며 프론트 한곳에서 풀스택을 채용하게 되면서

이번 서비스를 최대한 불필요한 외부라이브러리 없이 NEXTJS의 내장 라이브러리 및 기능을 사용하려고 했다.

그러면서 리액트쿼리와 AXIOS를 빼버리게 되었는데,

이로 인해 Optimitic UI 구현과 데이터의 StaleTime , 키를 통해 관리하는 데이터 선택 refetch 등에

어려움을 크게 겪었다.

로컬에서 작동하는 revalidateTag와 vercel에 배포된뒤 작동하는 revalidateTag가 다른 결과를 보여주는 것과

기존 fetch 로직들이 로컬과 배포버전의 동작이 달랐기 때문인데,

vercel에서는 대부분 isg로 페이지를 지정하여 대부분의 페이지 및 데이터를 캐싱 해버렸기 때문이고

이에 대한 정확한 파악과 해결에 시간을 많이 쏟게되었다.

결과적으로 서버액션 및 코드 컨벤션 변화를 통해 배포된 ISG 페이지에서도 문제없이

로직과 fetch가 동작하게 만들었고 , 서버액션 성공후 곧바로 reavalidateTag를 통해

CSR을 유지하며 렌더링 데이터에 변화를 즉시 확인할 수 있게 변경했기 때문에 어려움을 잘 넘긴 것 같다.

하지만.

이 방향이 내가 계속 진화하고 발전하는 방향이었다는 것에 의심은 없다.

앞으로 회사에 입사하고 겪을것들이 나에게 익숙한 것 일때보다 나에게 새로운 것 일때가 훨씬 많을 것 이고

이번 프로젝트로 가장 크게 배우게 된건

내가 갈고 닦고 성장해야 하는 기량은

알고있는 지식의 파이를 키우는 것보다

똑같은 크기의 어려움을 직면했을때 이전 보다 더 빠르게 효율적으로 이겨내고 다음으로 넘어가는 것이라는 걸

배울 수 있었기 때문이다.

서비스의 핵심 CRUD가 기본 개발이 끝났기 때문에

본격적으로 홈페이지의 역할을 할 수 있도록 1차 프로토 타입 완성까지 계속 달려보겠다.

profile
The pain is so persistent that it is like a snail, and the joy is so short that it is like a rabbit's tail running through the fields of autumn

0개의 댓글