안녕하세요

댓글 기능구현(1): 댓글 작성, 수정, 삭제, 리스트 조회 본문

Next13 블로그 프로젝트

댓글 기능구현(1): 댓글 작성, 수정, 삭제, 리스트 조회

sakuraop 2023. 9. 23. 23:51

목차 

1. 전체 기능 설계

2. 유저/게스트에 따라 Form 다르게 하기

3. 댓글 작성기능 구현

4. 댓글 리스트 구현

 

댓글 기능 구현(2): https://sakuraop.tistory.com/602

5. 댓글 수정/삭제 구현 (유저/게스트/관리자에 따라 수정/삭제 권한 다르게 하기)


1. 전체 기능 설계

 

로그인/게스트 댓글 작성

로그인 유저와 비로그인 유저 모두 DB에 저장하는 데이터는 동일합니다.

하지만 로그인 유저의 경우에는 id와 password 입력을 필요로 하지 않습니다. 

로그인을 구분하면서도, 데이터 형식은 유지하기 위해서 아래와 같이 구성합니다.

로그인: password: "", isLoggedIn: true,

게스트: email: "", isLoggedIn: false


2. 유저/게스트에 따라 Form 다르게 하기

게스트 상태에서 나타나는 폼
로그인 상태에서 나타나는 폼

댓글 작성 시에 새로고침 없이 바로 댓글이 나타나도록 하려면

댓글을 작성하면 새로고침 없이 곧바로 화면에 적용하기 위해서 댓글 컴포넌트는 'use client'로 구현합니다.

server 컴포넌트에서는 로그인 상태를 댓글 컴포넌트에 props로 전달합니다.

// Post.tsx
const Post = async ({ postId }: { postId: string }) => {
  const token = await getServerSession();
  
  return (<Comment postId={postId} userEmail={userEmail} />)
  
export default Post;

 

컴포넌트 역할을 구분합니다.

CommentForm은 댓글 작성 역할만을 맡도록 하고
CommentList는 댓글 조회 및 렌더링 역할을 맡도록 합니다.

// Comment.tsx
const Comment = ({ postId, userEmail }: CommentProps) => {
  return (
    <div>
      <CommentForm {...commentFormProps} />
      <CommentList {...commentListProps} />
    </div>
  );
};

export default Comment;
// CommentForm.tsx
const CommentForm = ({userEmail}: CommentFormProps) => {
  return (
    //...
  )
export default CommentForm;
  • Comment -> CommentForm 으로 userEmail을 전달해주었습니다.

 

userEmail이 없다면 nickname/password 인풋은 값을 고정해줍니다. 
onChage가 없는 input에 대해서는 readOnly 속성을 추가해주어야 경고가 발생하지 않습니다. 

그렇지 않은 경우에는 state를 이용하여 각각의 인풋의 onChange에 setState()를 전달합니다.

const CommentForm = ({ postId, userEmail, newUpdate, setNewUpdate }: CommentFormProps) => {
  const clickSubmitComment = async (e: React.FormEvent<HTMLFormElement>) => {
  const [nickname, setNickname] = useState<string>(userEmail || "");
  const [password, setPassword] = useState<string>("");
  const [comment, setComment] = useState<string>("");
  
  // ...
  
  return (
    <div className={styles.write}>
      {userEmail ? (
        <div className={`${styles.account} ${styles.sameAuthor}`}>
          <input type="text" placeholder="닉네임" value={userEmail} readOnly />
          <input type="text" placeholder="비밀번호" value={""} readOnly />
        </div>
      ) : (
        <div className={styles.account}>
          <inputplaceholder="닉네임" value={nickname}  onChange={(e) => {setNickname(e.target.value);}}/>
          <input placeholder="비밀번호" value={password} onChange={(e) => { setPassword(e.target.value); }}/>
        </div>
      )}
    </div>
  );
};

export default CommentForm;

3. 댓글 작성기능 구현

댓글 작성 순서는 다음과 같습니다.

(++는 해주면 좋은데 중심 역할은 아닙니다.)

 

Client

1) state로 input 입력 받기

2) 작성 버튼 클릭하면 유효성(빈칸) 검사하기 ++

3) server에 POST 요청 보내기

 

Server

4) 유효성(빈칸) 검사하기 ++

5) 비밀번호 hash화 하기 ++

6) DB에 저장하기

7) client에 응답하기

 

Client

8) 컴포넌트 렌더링하기

9) input 초기화하기 ++


1) Client: state로 input 입력받기

저는 입력을 받을 때 state를 여러개 만들어서 하는 것을 선호합니다.

하나의 객체에 date = { a,b,c } 이렇게 담는 것보다 코드를 간결하고 직관적이게 해주는 것 같습니다.

  const [nickname, setNickname] = useState<string>(userEmail || "");
  const [password, setPassword] = useState<string>("");
  const [comment, setComment] = useState<string>("");

        <div className={styles.account}>
          <inputplaceholder="닉네임" value={nickname}  onChange={(e) => {setNickname(e.target.value);}}/>
          <input placeholder="비밀번호" value={password} onChange={(e) => {setPassword(e.target.value); }}/>
        </div>

 

2) Client: 작성 버튼 클릭하면 유효성(빈칸) 검사하기 ++

제출 할 때 문자열의 길이가 짧은 경우에는 경고를 출력하는 유효성 검사 함수를 만듭니다.

export const COMMENT_FORM_LENGTH = {
  MIN_NICKNAME: 2,
  MIN_PASSWORD: 4,
  MIN_COMMENT: 1,
  MAX_NICKNAME: 10,
  MAX_PASSWORD: 20,
  MAX_COMMENT: 500,
};

  const { MIN_NICKNAME, MIN_PASSWORD, MIN_COMMENT, MAX_NICKNAME, MAX_PASSWORD, MAX_COMMENT } =
    COMMENT_FORM_LENGTH;

  const checkValidInput = () => {
    // 비로그인 유저가 nickname또는 password입력을 했는지 검사합니다.
    if (!userEmail && (nickname.length < MIN_NICKNAME || password.length < MIN_PASSWORD)) {
      window.alert("닉네임 또는 비밀번호를 입력해주세요.");
      return false;
    }

    // 댓글을 입력했는지 검사합니다.
    if (comment.length < MIN_COMMENT) {
      window.alert("댓글을 입력해주세요.");
      return false;
    }

    return true;
  };

 

 

input 에는 maxLength 를 이용하여 최대 길이 제한을 정해줍니다.

              <input
                placeholder="닉네임"
                value={nickname}
                maxLength={MAX_NICKNAME}
                onChange={(e) => {
                  setNickname(e.target.value);
                }}
              />

3) Client: server에 POST 요청 보내기

작성 버튼을 클릭하면 유효성 검사 함수를 먼저 실행하여 유효하지 않은 경우에는 경고를 출력하고 함수를 종료합니다.

  // 댓글 작성
  const clickSubmitComment = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // input이 유효한지 검사합니다.
    const isValidInput = checkValidInput();
    if (!isValidInput) return false
    

    const commentForm: CommentsForm = { nickname, password, comment };

    // POST 요청을 보냅니다.
    try {
      const response = await fetch(`/api/posts/${postId}/comments`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          ...commentForm,
        }),
      });

      successSubmit(); // 댓글 작성요청 성공 시 실행할 함수
    } catch (err) {
      console.error(err);
      window.alert("댓글 작성 중에 오류가 발생했습니다. 잠시 후에 다시 시도해주세요.");
    }
  };

 

요청을 보내고 오류가 없는 경우에는 comment 초기화해줍니다.

  const successSubmit = () => {
    setComment(""); //  comment 초기화
    setNewUpdate(!newUpdate); // client component 업데이트
  };

 

4) Server: 유효성(빈칸) 검사하기 ++

input으로 length를 제한하였지만 server에서도 유효성 검사를 해주어야 합니다.

브라우저의 스크립트를 조작하여 input의 제한을 넘어서는 문자열로 요청을 보낼 수도 있기 때문입니다.

  // 댓글 작성 요청
  if (req.method === "POST") {
    let { nickname, password, comment }: CommentsForm = req.body;

    // nickname또는 password를 입력했는지 검사합니다.
    if (!token && (nickname.length < MIN_NICKNAME || password.length < MIN_PASSWORD)) {
      return res.status(400).json({ message: "닉네임 또는 비밀번호를 입력해야합니다." });
    }

    // nickname또는 password가 길이를 초과했는지 검사합니다.
    if (!token && (nickname.length > MAX_NICKNAME || password.length > MAX_PASSWORD)) {
      return res.status(400).json({ message: "닉네임 또는 비밀번호의 길이가 너무 짧습니다." });
    }

    // comment를 입력했는지 검사합니다.
    if (comment.length < MIN_COMMENT) {
      return res.status(400).json({ message: "댓글을 입력해야합니다." });
    }

    // comment가 길이를 초과했는지 검사합니다.
    if (comment.length > MAX_COMMENT) {
      return res.status(400).json({ message: "댓글의 길이가 500자를 초과했습니다." });
    }

 

5) Server: 비밀번호 hash화 하기 ++

비밀번호는 저장할 때 있는 그대로 저장을 하면 안됩니다.

bcrypt 라이브러리를 이용하여 단방향 암호화를 해주어야 합니다.

ex) "$2b$10$ZQlGVJLzWj10e/J1vj.8NOqEBDH.ZzFsnTies/QeqTLOa1HM9CKcW"

import { hash } from "bcrypt";

const hashedPassword: string = await hash(password, 10);

 

6) Server: DB에 저장하기

입력받은 데이터 이외에 부가적인 정보를 담아 DB에 저장하도록 합니다.

저는 MongoDB insertOne 메서드로 데이터를 저장해주었습니다.

    // DB에 저장할 데이터
    const saveData: Comments = {
      parentId: Number(postId),
      nickname: token ? (token.name as string) : nickname,
      author: token ? (token.email as string) : "",
      password: token ? "" : hashedPassword,
      comment,
      date: new Date(),
      thumbnail: "",
      isLoggedIn: token ? true : false,
    };

    // 댓글 작성 결과
    const insertResult = await commentsCollection.insertOne({ ...saveData });

저장된 결과)

7) Server: client에 응답하기

잘 됐으면 200을 응답하고, 잘 안됐으면 404를 응답하도록 했습니다.

    // 결과 응답
    if (insertResult) {
      return res.status(200).json({ message: "댓글 작성 완료" });
    }
    return res.status(404).json({ message: "Not found any post" });
  }

 

8) Client: 컴포넌트 렌더링하기

부모 컴포넌트인 Comment에서 props 변경을 감지하기 위한 state를 전달하여 List에서 렌더링을 할 수 있도록 하였습니다.

const Comment = ({ postId, userEmail }: CommentProps) => {
  const [newUpdate, setNewUpdate] = useState<boolean>(false);
  
    return (
    <div>
      <CommentForm {...commentFormProps} />
      <CommentList {...commentListProps} />
    </div>
  );

9) Client: input 초기화하기 ++

POST요청에 성공하면 실행할 함수입니다.

comment를 초기화하고,

List에서 변경을 감지할 수 있도록 newUpdate state에 변경을 주도록 합니다.

 

(처음에는 기능을 설계할 때, 한 애플리케이션에서 작성 갯수를 제한하기 위해 숫자를 카운트하려 했습니다.

bot으로 무제한 댓글 작성 이런걸 막기 위함이라는 취지였으나, => "0,1,2,3,4,... 100개 작성 > 이제 금지!"

이러한 기능을 구현하려 했는데, 만들다 보니 굳이인 듯 하여 삭제하고 true false 간에 전환하도록 했습니다.)

  const successSubmit = () => {
    setComment(""); //  comment 초기화
    setNewUpdate(!newUpdate); // client component 업데이트
  };

4. 댓글 리스트 구현

댓글 목록을 GET 요청하여 state에 저장합니다.

useEffect를 이용해 컴포넌트가 렌더링 될 때 댓글 목록을 요청하도록 합니다.

const CommentList = ({ postId, newUpdate, userEmail }: CommentListProps) => {
  const [commentList, setCommentList] = useState<Comments[]>([]); // API 요청하여 조회할 댓글 목록

  // 게시물의 댓글을 조회하여 state에 저장합니다.
  useEffect(() => {
    (async () => {
      try {
        // GET 요청을 보냅니다.
        const response = await fetch(`/api/posts/${postId}/comments`, { method: "GET" });
        const parsedData = await response.json();
        const foundComments: Comments[] = parsedData.comments;

        // 불러온 댓글을 state에 저장합니다.
        setCommentList(foundComments);
      } catch (err) {
        console.error(err);
      }
    })();
  }, [postId, newUpdate]);