2024.04.05 TIL - 최종프로젝트 11일차 (커뮤니티 글 수정 - 단일 이미지 포함 update, queryKey 2개 동시 무효화, next auth로 리팩토링)

Innes·2024년 4월 5일
0

TIL(Today I Learned)

목록 보기
108/147
post-thumbnail

📝 오늘 한 일

✔️ 커뮤니티 글 수정
✔️ 개인액션 이미지 삭제버튼 수정
✔️ 이미지 없이는 업로드 못하게 만들기(개인액션, 게시글)
✔️ 내가 쓴 댓글, 내가 쓴 게시글에만 수정,삭제 버튼 보이게 만들기
✔️ triple dot 크기 조절 (next ui css조정 어려움)
✔️ 로그인 상태일때만 글쓰기 가능하게, 로그인 페이지로 이동 (개인액션, 게시글, 댓글)
✔️ 개인액션 : 활동제목, 활동소개 2줄로 뜸
✔️ 커뮤니티 : 개인과 함께해요 2줄로뜸
✔️ 카카오링크로 유효성검사


커뮤니티 글 수정 - 단일 이미지 포함 update

로직 고민

1차 고민

  1. 새롭게 업로드한 이미지 file 있으면 - 스토리지 업로드 + url 반환 -> formData + url을 update
  2. file 없으면 - url없이 텍스트 formData만 update
    formData update (url은 있으면 넣고 없으면 말고) -> 없으면 부분은 ? 이걸로 할수있으려나
    (post_id 해당 id찾아서 update)

2차 고민

  • file은 새로운이미지를 업로드 했을때 set됨
    따라서
    file 있으면 - 스토리지 업로드 + url 반환 -> formData + url을 update
    file 없으면 - url없이 텍스트 formData만 update
    (file이 없다는 얘기는 새로 이미지를 업로드하지 않았단 소리니까 이미 테이블에 이미지가 있을것.
  • 혹은 미리보기에서 이미지 삭제하고 '수정완료'눌러서 file이 없는 경우 - 있을 수 없음(이미지 없이 인증글 올리는걸 허용하지 않을 것이기 때문)
  • 리팩토링 전 : img_url 여부를 파악해 한번 더 업데이트하고 있음(비효율적)
// 2. formData update (url은 있으면 넣고, 없으면 넣지말기)
export const updateEditedPost = async ({
  post_id,
  imgUrl,
  formData,
}: CommunityEditMutation) => {
  try {
    // 텍스트 formData만 update
    const nextData = {
      title: String(formData.get("activityTitle")),
      content: String(formData.get("activityDescription")),
      action_type: String(formData.get("action_type")).substring(0, 2),
    };

    // supabase에서 해당 post_id의 데이터 가져오기
    const { data: existingData, error } = await supabase
      .from("community_posts")
      .select()
      .eq("id", post_id)
      .single();

    if (error) {
      throw error;
    }

    // 기존 데이터와 새 데이터 비교하여 변경된 부분 업데이트 (덮어쓰기)
    const updatedData = { ...existingData, ...nextData };

    // supabase에서 업데이트
    const { error: updateError } = await supabase
      .from("community_posts")
      .update(updatedData)
      .eq("id", post_id);

    if (updateError) {
      throw updateError;
    }

    // img_url이 있는 경우 update (없는경우 update하면 null로 덮어쓰기 되니까 필요한 로직)
    if (imgUrl) {
      // supabase에서 업데이트
      const { error: updateError } = await supabase
        .from("community_posts")
        .update({ img_url: imgUrl })
        .eq("id", post_id);

      if (updateError) {
        throw updateError;
      }
    }
  } catch (error) {
    console.error(error);
    throw error;
  }
};
  • 리팩토링 후 : img_url 여부에 따라 imgUrl선언을 할지말지 결정, 업데이트는 한번만 함
// 2. formData update (url은 있으면 넣고, 없으면 넣지말기)
export const updateEditedPost = async ({
  post_id,
  imgUrl,
  formData,
}: CommunityEditMutation) => {
  try {
    // formData에서 업데이트할 텍스트 데이터 추출
    const nextData = {
      title: String(formData.get("activityTitle")),
      content: String(formData.get("activityDescription")),
      action_type: String(formData.get("action_type")).substring(0, 2),
    };

    // ⭐️⭐️ 이거 한줄로 엄청나게 코드가 줄어들었다!!
    // 이미지 URL 업데이트할 객체 생성
    const imageData = imgUrl ? { img_url: imgUrl } : {};

    // 데이터 업데이트
    const { error: updateError } = await supabase
      .from("community_posts")
      .update({ ...nextData, ...imageData })
      .eq("id", post_id);

    if (updateError) {
      throw updateError;
    }
  } catch (error) {
    console.error(error);
    throw error;
  }
};

총 정리

  1. 수정할 데이터 불러오기
// EditPostModal.tsx

  // post_id 데이터 가져오기 useQuery
  // (데이터 가져옴과 동시에 이미지url 바로 set하기, action_type set하기)
  const {
    data: singlePostForEdit,
    isLoading,
    isError,
  } = useQuery({
    queryKey: [QUERY_KEY_COMMUNITY_POST_FOR_EDIT],
    queryFn: async () => {
      try {
        const data = await getSinglePostForEdit(post_id);
        setUploadedFileUrl(data.img_url);
        if (data.action_type === "개인") {
          setSelectedKeys(new Set(["개인과 함께해요"]));
        }
        setSelectedKeys(new Set(["단체와 함께해요"]));
        return data;
      } catch (error) {}
    },
  });
// communityEdit-api.ts

// 수정할 데이터 가져오기
export const getSinglePostForEdit = async (post_id: string) => {
  try {
    const { data, error } = await supabase
      .from("community_posts")
      .select()
      .eq("id", post_id);

    if (error) {
      throw error;
    }

    return data[0];
  } catch (error) {
    console.error(error);
    throw error;
  }
};
  1. 게시글 수정 mutation
// EditPostModal.tsx

  // 게시글 수정 mutation - 상세모달창 정보 무효화
  const { mutate: updatePostMutation } = useMutation({
    mutationFn: ({ post_id, imgUrl, formData }: CommunityEditMutation) =>
      updateEditedPost({
        post_id,
        imgUrl,
        formData,
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: [QUERY_KEY_COMMUNITY_POST],
      });
    },
  });
  1. 수정완료 클릭시
// EditPostModal.tsx

  // '수정완료' 클릭시
  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    const formData = new FormData(event.target as HTMLFormElement);
    // 드롭다운에서 선택한 값을 formData에 추가
    formData.append("action_type", Array.from(selectedKeys).join(", "));

    try {
      const isConfirmed = window.confirm("수정하시겠습니까?");
      if (isConfirmed) {
        // 새로운 file 업로드한 경우 url 반환
        const imgUrl = await uploadFileAndGetUrl(file);

        // post_id, imgUrl, formData 전달해서 수정내용 update
        updatePostMutation({ post_id, imgUrl, formData });
      }
    } catch (error) {
      console.error("Error updating data:", error);
    }
  };
// communityEdit-api.ts

// 게시글 수정 update
// 1. 스토리지 업로드 + 이미지 url 반환
export const uploadFileAndGetUrl = async (file: File) => {
  try {
    // file 있으면 - 스토리지 업로드 + url 반환 -> formData + url을 update
    // 스토리지에 이미지파일 업로드
    if (file) {
      const uuid = crypto.randomUUID();
      const fileName = `${(file.name, uuid)}`;
      const { error } = await supabase.storage
        .from("community")
        .upload(fileName, file, {
          cacheControl: "3600",
          upsert: true,
        });

      if (error) {
        console.error("Error uploading file:", error);
        return null;
      }

      // 이미지 url 가져오기
      const imgUrl = await supabase.storage
        .from("community")
        .getPublicUrl(fileName);

      if (!imgUrl || !imgUrl.data) {
        console.error("Error getting public URL for file:", imgUrl);
        throw new Error("Error getting public URL for file");
      }

      const uploadedImgUrl = imgUrl.data.publicUrl;
      return uploadedImgUrl;
    }
    // 파일이 없는 경우
    // file 없으면 - url없이 텍스트 formData만 update
    return null;

  } catch (error) {
    console.error(error);
    throw error;
  }
};

// 2. formData update (url은 있으면 넣고, 없으면 넣지말기)
    // post_id 해당 id찾아서 ->
    // formData update하는 로직 (url은 있으면 넣고 없으면 말고)
export const updateEditedPost = async ({
  post_id,
  imgUrl,
  formData,
}: CommunityEditMutation) => {
  try {
    // formData에서 업데이트할 텍스트 데이터 추출
    const nextData = {
      title: String(formData.get("activityTitle")),
      content: String(formData.get("activityDescription")),
      action_type: String(formData.get("action_type")).substring(0, 2),
    };

    // 이미지 URL 업데이트할 객체 생성
    const imageData = imgUrl ? { img_url: imgUrl } : {};

    // 데이터 업데이트
    const { error: updateError } = await supabase
      .from("community_posts")
      .update({ ...nextData, ...imageData })
      .eq("id", post_id);

    if (updateError) {
      throw updateError;
    }
  } catch (error) {
    console.error(error);
    throw error;
  }
};

queryKey 2개 동시 무효화

  • 게시글 수정시 -> 게시글 상세정보, 게시글 리스트 2가지를 한번에 무효화하기
  // 게시글 수정 mutation - 상세모달창 정보 무효화
  const { mutate: updatePostMutation } = useMutation({
    mutationFn: ({ post_id, imgUrl, formData }: CommunityEditMutation) =>
      updateEditedPost({
        post_id,
        imgUrl,
        formData,
      }),
    onSuccess: () => {
      // ⭐️⭐️ 
      queryClient.invalidateQueries({
        queryKey: [QUERY_KEY_COMMUNITYLIST],
      });
      queryClient.invalidateQueries({
        queryKey: [QUERY_KEY_COMMUNITY_POST],
      });
    },
  });

next auth

🤯 인증/인가 부분을 맡은 팀원이 일주일동안 머리를 싸매다가 해결을 못한... 로그인 상태 유지!!!

  • supabase.auth
    supabase.authgetUser해서 현재 로그인한 유저의 user_uid를 가져오는건 가능했다. 그래서 getUser로 로그인 유저정보를 가져오는 방식으로 계속 사용하고 있었는데...
    사실 supabase.auth로 로그인을 하면 로컬스토리지에 유저정보가 다 들어간다. 로컬스토리지는 모두가 열람할 수 있는 공간이기 때문에 보안상 아주 취약한 로직인 것이다.

  • zustand
    그래서 처음에는 로그인 함과 동시에 zustand로그인 상태로그인한 유저정보를 담아두려 했다. 그런데 zustand같은 전역상태관리를 사용하면 새로고침시 정보가 날아가기 때문에 useEffect등 저장된 상태를 유지하기 위한 추가적인 과정이 필요하다. 또한 supabase.auth로 로그인했을 때 로컬스토리지에 유저정보가 저장된다는 점은 변함이 없다.

  • next auth
    이런 단점들을 모두 보완해줄 수 있는게 바로 next auth! next auth를 쓰면 로컬스토리지에 유저정보가 저장되는 것도 막을 수 있고, 굳이 useEffect같은걸 쓰지 않더라도 화면 렌더링시 로그인 유저정보를 바로 가져올 수 있다. supabase authentication과 함께 쓰는 거라서 supabase로 로그인, 회원가입을 진행한다는 점은 변함이 없지만 단점들을 보완해준다고 생각하면 좋을 것 같다.

  • next auth 아쉬운 점
    next auth를 사용하면 컴포넌트에서 불러오는 방법도 매우 간단해서 쉽게 사용이 가능한데, 아쉬운 점은 초기세팅이 좀 어렵다는 점... 서버 컴포넌트에서 코드 작성하는 것, 그리고 필요한 유저정보가 session에 들어있지 않다면 추가적으로 세팅해줘야 한다는 점, 여기에 더해 타입을 지정해주는 파일도 새로 만들어야 한다는 점...
    세팅만 해놓으면 가져다 쓰기는 아주 편리한데, 그 세팅의 진입장벽이 높아보였다. 그래도 쓰다보면 익숙해지겠지...ㅎㅎ
  • 컴포넌트에서 session을 가져다가 사용하는 방식 (useSession())
  • session에는 기본적으로 유저의 email만 들어있는데, user의 uid가 필요한 상황이라 token에 들어있는 sub(supabase에 회원가입을 하면 user의 uid가 sub라는 이름으로 자동생성된다.)를 가져다가 session에 넣어줘야했다.
// route.ts - app/api/auth/...

import { supabase } from "@/utils/supabase/client";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      id: "id-password-credential",
      name: "Credentials",
      type: "credentials",
      credentials: {
        id: {
          label: "아이디",
          type: "text",
          placeholder: "아이디를 입력해주세요.",
        },
        password: {
          label: "비밀번호",
          type: "password",
          placeholder: "비밀번호를 입력해주세요.",
        },
      },
      async authorize(credentials, req) {
        if (!credentials || !credentials.id || !credentials.password) {
          throw new Error("유효하지 않은 자격 증명입니다.");
        }
        try {
          const response = await supabase.auth.signInWithPassword({
            email: credentials.id,
            password: credentials.password,
          });
          if (response) {
            return response.data.user;
          }
          return null;
        } catch (error) {
          console.error("Supabase sign in error:", error);
          return null;
        }
      },
    }),
  ],
  callbacks: {
    async session({ session, token }) {
      // ⭐️⭐️ session에 user_uid를 넣어주는 로직
      session.user.user_uid = token.sub ?? "";
      return session;
    },
  },
  pages: {
    signIn: "/login",
  },
  secret: "secret",
});

export { handler as GET, handler as POST };
// next-auth.ts - /lib
// session의 user 값 타입까지 만들어 줬다.

import NextAuth from "next-auth";

declare module "next-auth" {
  interface Session {
    user: {
      email: string;
      user_uid: string;
    };
  }
}
// Comment.tsx - 컴포넌트에서 사용 예시

import { useSession } from "next-auth/react";


  // 현재 로그인한 유저 uid
  const session = useSession();
  const loggedInUserUid = session.data?.user.user_uid || "";
profile
무서운 속도로 흡수하는 스폰지 개발자 🧽

0개의 댓글