일단 로그인 회원가입 페이지를 만들고 git push를 너무 안해줘서 그 부분 먼저 husky, lint-staged 활용해 업로드하려는데,
commit쪽 메시지를 정해놓지 않은 것 같다
일단 이 정도로 해놓고 부족하면 더 추가해보자
일단 수파베이스에서 crud진행할 posts
라는 테이블을 세팅하였고,
실제 필요한 내용은 게시글의 id, 생성 시간, user_id는 실제 auth.users의 아이디와 연동, title, content, image_url이 무조건 필요할 것 같아 넣어주었다.
이제 실제 코드에서 삽입이 되는지 테스트해보아야 하는데,
그 전에 내가 와이어프레임에서 작성했던
이 부분을 보면 00님의 일상을 남겨보세요를 클릭해야 모달창에서 업로드를 시키는 구조이므로, 일단 닉네임을 가지고 오자
const { user } = data.session
로그인한 유저의 정보를 세션에서 가지고 올 수 있고, 그 중에 어떤 유저의 정보인지 명확히 파악하려면 user.id로 특정 유저를 식별할 수 있다.
supabase.from('users').select('nickname').eq('id',user.id)
결국 supabase의 쿼리를 사용해야 원하는 닉네임을 조회하는것인데
from('users')
users 테이블을 선택한다는 의미
select('nickname
)
users테이블안에 column안에 있는 nickname 선택
eq('id',user.id)
특정 조건을 추가, id 열이 user.id와 같은 값을 가진 행을 필터링
'id'는 그 수많은 users의 nickname만 선택했으므로 그 중에 어떤 아이디의 nickname?을 알려주기 위해 'id' 채택
그 다음 user객체가 로그인된 사용자 정보를 담고 있으므로, 이 객체에서 해당 사용자의 고유 식별자 가져오는 속성임
추가적으로 뒤에다가
single()
까지 넣어준다면 supabase 쿼리에서 반환 결과가 1개의 행 row를 의미한다는 뜻
결국, 로그인된 사용자는 1개여야하므로 무조건 single임
일단 1차 구현이 목표이므로
nickname 가져오기 위해 useState로 상태관리를 하면서
supabase에서 가져온 getSession을 data에 저장 후, 해당 data의 session을 user로 저장 후 users테이블에서 nickname을 불러온다. 그래서 만약 해당 닉네임이 있다면 setNickname에 저장시켜서 불러오는 구조인데,
사실상 회원가입할때 무조건 닉네임을 기입하게 되어있으므로 error처리를 굳이 해줘야할 필요가 있을까 싶어서 빼줬다.(그래도 넣는게 나으려나?)
=> 피드백 받았으나 저정도면 충분하다고 함
shadCN에서의 모달이라는 항목은 없고 Dialog를 다운받으면
이런식으로 모달창을 띄울 것 같다.
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogTrigger>{nickname}님의 일상을 남겨보세요!</DialogTrigger>
<DialogContent>
<DialogHeader>
<Input
placeholder="제목을 작성해주세요"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder={`${nickname}님, 무슨 일상을 공유하고 싶으신가요?`}
value={content}
onChange={(e) => setContent(e.target.value)}
className="mt-2"
/>
<Input
type="file"
accept="image/*"
onChange={(e) => {
if (e.target.files) {
const file = e.target.files[0]
setImageUrl(URL.createObjectURL(file))
}
}}
/>
<Button onClick={handleCreatePost} className="mt-4">
게시
</Button>
{/* <DialogTitle>제목을 작성해주세요</DialogTitle> */}
{/* <DialogDescription>
{nickname}님, 무슨 일상을 공유하고 싶으신가요?
</DialogDescription> */}
</DialogHeader>
</DialogContent>
</Dialog>
최초로 코드를 그대로 가지고 왔을 땐, showModal, setShowModal에 대해 useState로 선언해서 trigger를 눌렀을때 모달 on off되도록 시도하였고,
이후엔 자체적으로 ShadCN이 상태없이도 열고 닫을 수 있는 dialog 자체 코드로 인해
<Dialog open={showModal} onOpenChange={setShowModal}>
여기서 <Dialog>
로 수정하였다.
그리고 이 함수로 인해 posts의 id,title,content,이미지를 포함해서 게시글 생성까진 가능하게 되었고,
형식을 갖춘 후에
<div className="mt-8">
{posts.map((post) => (
<div key={post.id} className="border p-4 mb-4">
<h3 className="font-bold">{post.title}</h3>
<p>{post.content}</p>
{post.image_url && (
<img src={post.image_url} alt="Post Image" className="mt-2" />
)}
</div>
))}
</div>
불러오는 것까진 문제 없는 상황
현재 C한 게시물을 R까진 잘 되는 상황
이제 사진을 불러와보자
이 작업이 필요한 이유는, 사진 파일을 올리면 url로 변환하여 그 url을 근거로 화면에 띄워주기 위해 supabase storage에 images라는 버킷을 만들었다.
목적은 사진을 업로드하면 해당 사진의 url을 만들어서 posts 테이블에 저장시키고 그 url을 통해 원하는 사진을 띄우기 위해!!
const handleCreatePost = async () => {
const { data } = await supabase.auth.getSession()
const user = data.session?.user
let uploadedImageUrl = ""
if (imageUrl) {
const file = imageUrl.split(",")[1] //base64데이터에서 파일 부분 추출..?
const blob = await fetch(`data:image/png;base64,${file}`).then((res) =>
res.blob(),
)
//이미지 파일 이름 설정
const fileName = `image-${Date.now()}.png`
const { data: uploadData, error: uploadError } = await supabase.storage
.from("images")
.upload(fileName, blob, { contentType: "image/png" })
if (uploadError) {
toast({
title: "이미지 업로드 중 오류가 발생하였습니다.",
description: uploadError.message,
})
return
}
uploadedImageUrl = `${process.env.NEXT_PUBLICK_SUPABASE_URL}/storage/v1/object/public/images/${fileName}`
}
const { error } = await supabase.from("posts").insert([
{
user_id: user?.id,
title,
content,
image_url: uploadedImageUrl,
},
])
if (error) {
toast({
title: "게시글 작성 중 오류가 발생하였습니다.",
description: error.message,
})
} else {
toast({ title: "게시글이 작성되었습니다.", description: "upload post" })
setShowModal(false)
setTitle("")
setContent("")
setImageUrl("")
}
}
현재 사진 업로드/ 게시글에 들어갈 제목,내용,imageurl등 다양한 기능을 하는 handlecreatePost 함수를 구현하였고,
한 함수에 너무 많은 기능이 들어가 있고, 아직 image올릴때 fetch 에러가 뜨는 상황 + 미리보기 기능이 없음 아직
const uploadImage = async (file: File) => {
const blob = await file.arrayBuffer().then((buffer) => new Blob([buffer]))
const fileName = `image-${Date.now()}.png`
const { error: uploadError } = await supabase.storage
.from("images")
.upload(fileName, blob, { contentType: "image/png" })
if (uploadError) {
toast({
title: "이미지 업로드 중 오류가 발생하였습니다.",
description: uploadError.message,
})
return
}
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/${fileName}`
}
const createPost = async (uploadedImageUrl: string) => {
const { data } = await supabase.auth.getSession()
const user = data.session?.user
const { error } = await supabase.from("posts").insert([
{
user_id: user?.id,
title,
content,
image_url: uploadedImageUrl,
},
])
if (error) {
toast({
title: "게시글 작성 중 오류가 발생하였습니다.",
description: error.message,
})
} else {
toast({ title: "게시글이 작성되었습니다.", description: "upload post" })
setShowModal(false)
setTitle("")
setContent("")
setImageUrl("")
setImagePreview("")
}
}
const handleCreatePost = async () => {
if (imageUrl) {
const file = imageUrl.split(",")[1]
const base64Response = await fetch(`data:image/png;base64,${file}`)
const blobFile = await base64Response.blob()
const uploadedImageUrl = await uploadImage(blobFile)
if (uploadedImageUrl) {
await createPost(uploadedImageUrl)
}
} else {
await createPost("")
}
}
현재 업로드하는 함수, 실제 게시글 작성하는 함수, 그 두 함수가 존재할때 최종적으로 게시글 만드는 함수 이렇게 종속관계로 3개로 함수를 나누었고,
그렇게 구현한 모달창에선 사진을 업로드하면 미리보기까지는 잘 되나 실제 업로드를 진행하는 순간,
fetch에 대해선 여전히 에러가 뜨는 상황
찾아보니 fetch로 Base64 데이터 저장시 해당 형식으로 호출하면 CORS 문제가 발생할 수 있어서 생기는 문제,
그리고
const uploadedImageUrl = await uploadImage(blobFile)
이 부분도 Blob객체를 File객체로 처리하려 할 때 발생되고 있고, uploadImage함수가 File객체를 기대하고 있으므로 Blob을 File로 변환시켜줘야함
그래서
"use client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
// DialogDescription,
DialogHeader,
// DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { useToast } from "@/components/ui/use-toast"
import { supabase } from "@/lib/supabase"
import { useRouter } from "next/navigation"
import React, { useEffect, useState } from "react"
import { blob } from "stream/consumers"
const HomePage = () => {
const route = useRouter()
const { toast } = useToast()
const [nickname, setNickname] = useState<string>("")
const [title, setTitle] = useState<string>("")
const [content, setContent] = useState<string>("")
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string>("")
const [showModal, setShowModal] = useState<boolean>(false)
const [posts, setPosts] = useState<
Array<{ id: number; title: string; content: string; image_url: string }>
>([])
useEffect(() => {
const checkSession = async () => {
const { data } = await supabase.auth.getSession()
if (!data.session) {
toast({
title: "로그인 상태가 아닙니다.",
description: "로그인을 먼저 진행해주세요.",
})
route.push("/login")
return
}
const { user } = data.session
const { data: userNickname } = await supabase
.from("users")
.select("nickname")
.eq("id", user.id)
.single()
if (userNickname) {
setNickname(userNickname.nickname)
}
}
checkSession()
}, [route])
const fetchPosts = async () => {
const { data, error } = await supabase
.from("posts")
.select("*")
.order("created_at", { ascending: false }) // 최신 게시글 위로 정렬
if (error) {
toast({
title: "게시글 불러오기 오류",
description: error.message,
})
} else {
setPosts(data)
}
}
useEffect(() => {
fetchPosts()
}, [])
const handleLogout = async () => {
const { error } = await supabase.auth.signOut()
if (error) {
toast({
title: "로그아웃 중 오류가 발생하였습니다.",
description: error.message,
})
return
}
route.push("/login")
}
const uploadImage = async (file: File) => {
const fileName = `image-${Date.now()}.png`
const { error: uploadError } = await supabase.storage
.from("images")
.upload(fileName, file)
if (uploadError) {
toast({
title: "이미지 업로드 중 오류가 발생하였습니다.",
description: uploadError.message,
})
return null
}
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/${fileName}`
}
const createPost = async (uploadedImageUrl: string) => {
const { data } = await supabase.auth.getSession()
const user = data.session?.user
const { error } = await supabase.from("posts").insert([
{
user_id: user?.id,
title,
content,
image_url: uploadedImageUrl,
},
])
if (error) {
toast({
title: "게시글 작성 중 오류가 발생하였습니다.",
description: error.message,
})
} else {
toast({ title: "게시글이 작성되었습니다.", description: "upload post" })
setShowModal(false)
setTitle("")
setContent("")
setImageFile(null)
setImagePreview("")
fetchPosts()
}
}
const handleCreatePost = async () => {
if (imageFile) {
const uploadedImageUrl = await uploadImage(imageFile)
if (uploadedImageUrl) {
await createPost(uploadedImageUrl)
}
} else {
await createPost("")
}
}
return (
<div>
<Button onClick={handleLogout}>로그아웃</Button>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogTrigger>{nickname}님의 일상을 남겨보세요!</DialogTrigger>
<DialogContent>
<DialogHeader>
<Input
placeholder="제목을 작성해주세요"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder={`${nickname}님, 무슨 일상을 공유하고 싶으신가요?`}
value={content}
onChange={(e) => setContent(e.target.value)}
className="mt-2"
/>
<Input
type="file"
accept="image/*"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
setImageFile(file)
setImagePreview(URL.createObjectURL(file))
}
}}
/>
{imagePreview && (
<img src={imagePreview} alt="Preview" className="mt-2" />
)}
<Button onClick={handleCreatePost} className="mt-4">
게시
</Button>
{/* <DialogTitle>제목을 작성해주세요</DialogTitle> */}
{/* <DialogDescription>
{nickname}님, 무슨 일상을 공유하고 싶으신가요?
</DialogDescription> */}
</DialogHeader>
</DialogContent>
</Dialog>
<div className="mt-8">
{posts.map((post) => (
<div key={post.id} className="border p-4 mb-4">
<h3 className="font-bold">{post.title}</h3>
<p>{post.content}</p>
{post.image_url && (
<img src={post.image_url} alt="Post Image" className="mt-2" />
)}
</div>
))}
</div>
{/* <Dialog open={showModal}> onOpenchange={setShowModal}></Dialog> */}
</div>
)
}
export default HomePage
바뀐 부분은
이전에는 supabase.storage.upload를 처리하는 uploadImage함수에서 블롭을 생성하는 부분을 File객체를 바로 사용하는 것이 맞다고 하여서 그 부분을 수정해주었고,
imageUrl 미리보기할때 URL.createObjectURL(file)이였던 부분에서,
파고 들어가면 createpost에서 imageUrl데이터를 다시 변환하는 과정이 있었으므로 File 객체를 직접 전달
이렇게 코드를 작성해서 fetch문제는 해결했으나,
이제는,
수파베이스의 RLS정책때문에 업로드가 안되고 있었다.
이 부분을 해결하기 위해서는 직접 policy를 만들어줘야하는데,
맞는지는 모르겠지만
기존의 RLS를 비활성화하였던 posts table의 RLS를 활성화 시키고 authentication의 policy에서 로그인 인증한 유저의 아이디가 게시글 올릴 user_id와 일치하다는 부분을 추가하였다.
응~ 그래도 안되네~~ ㅋㅋ 제기랄
음... 원인을 찾는 과정에서
storage의 policy도 정해줄 필요가 있다고 하여서,
커뮤니티 참고하다
일단 모든것이 가능하도록 체크해주고 정책 저장을 해보았다
와 씨 ㅠㅠㅠㅠㅠㅠ
이 망할 정책때문에 반나절을 날렸네 알고나면 쉬운건데..
결국 RLS정책을 다 비활성화하고 회원가입이나, 게시글 작성을 하였었으나 결국은 막힌거보면, 내 생각이 맞는지는 모르겠지만,
RLS을 켜주고 policy로 얼마만큼의 권한을 주냐에 따라 회원가입도 되고 게시글 작성도 된걸 보아, supabase의 auth와 연동된 유저이기도 하고, supabase에서 session을 받아와야만 로그인된 상태로 메인 페이지에 접근할 수 있으므로, 단순 table만 고려하지 않고 auth까지 고려하다보니 RLS정책을 비활성화하였더라도, 실제 유저에 대한 권한을 주어야 하는 용도로 policy가 필요해서 저런 에러가 뜨지 않았나 싶다.
원래 모달을 열고 닫는 상태 변화를 위해 const [showModal, setShowModal] 을 useState로 관리하였으나, shadCN에서 가져온 Dialog에 x표시로 끄고 킬 수 있으므로, 굳이 필요없을 것 같아 제거해주었다.
근데 그러고 실행해보니,
게시하고나서 직접 손수 x표시를 해주어야만 모달이 꺼지므로 다시 추가해주었다.
<Dialog open={showModal} onOpenChange={setShowModal}>
"use client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
// DialogDescription,
DialogHeader,
// DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { useToast } from "@/components/ui/use-toast"
import { supabase } from "@/lib/supabase"
import { useRouter } from "next/navigation"
import React, { useEffect, useState } from "react"
import { blob } from "stream/consumers"
const HomePage = () => {
const route = useRouter()
const { toast } = useToast()
const [nickname, setNickname] = useState<string>("")
const [title, setTitle] = useState<string>("")
const [content, setContent] = useState<string>("")
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string>("")
const [showModal, setShowModal] = useState<boolean>(false)
const [posts, setPosts] = useState<
Array<{ id: number; title: string; content: string; image_url: string }>
>([])
useEffect(() => {
const checkSession = async () => {
const { data } = await supabase.auth.getSession()
if (!data.session) {
toast({
title: "로그인 상태가 아닙니다.",
description: "로그인을 먼저 진행해주세요.",
})
route.push("/login")
return
}
const { user } = data.session
const { data: userNickname } = await supabase
.from("users")
.select("nickname")
.eq("id", user.id)
.single()
if (userNickname) {
setNickname(userNickname.nickname)
}
}
checkSession()
}, [route])
const fetchPosts = async () => {
const { data, error } = await supabase
.from("posts")
.select("*")
.order("created_at", { ascending: false }) // 최신 게시글 위로 정렬
if (error) {
toast({
title: "게시글 불러오기 오류",
description: error.message,
})
} else {
setPosts(data)
}
}
useEffect(() => {
fetchPosts()
}, [])
const handleLogout = async () => {
const { error } = await supabase.auth.signOut()
if (error) {
toast({
title: "로그아웃 중 오류가 발생하였습니다.",
description: error.message,
})
return
}
route.push("/login")
}
const uploadImage = async (file: File) => {
const fileName = `image-${Date.now()}.png`
const { error: uploadError } = await supabase.storage
.from("images")
.upload(fileName, file)
if (uploadError) {
toast({
title: "이미지 업로드 중 오류가 발생하였습니다.",
description: uploadError.message,
})
return null
}
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/${fileName}`
}
const createPost = async (uploadedImageUrl: string) => {
const { data } = await supabase.auth.getSession()
const user = data.session?.user
const { error } = await supabase.from("posts").insert([
{
user_id: user?.id,
title,
content,
image_url: uploadedImageUrl,
},
])
if (error) {
toast({
title: "게시글 작성 중 오류가 발생하였습니다.",
description: error.message,
})
} else {
toast({ title: "게시글이 작성되었습니다.", description: "upload post" })
setShowModal(false)
setTitle("")
setContent("")
setImageFile(null)
setImagePreview("")
fetchPosts()
}
}
const handleCreatePost = async () => {
if (imageFile) {
const uploadedImageUrl = await uploadImage(imageFile)
if (uploadedImageUrl) {
await createPost(uploadedImageUrl)
}
} else {
await createPost("")
}
}
return (
<div>
<Button onClick={handleLogout}>로그아웃</Button>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogTrigger>{nickname}님의 일상을 남겨보세요!</DialogTrigger>
<DialogContent>
<DialogHeader>
<Input
placeholder="제목을 작성해주세요"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder={`${nickname}님, 무슨 일상을 공유하고 싶으신가요?`}
value={content}
onChange={(e) => setContent(e.target.value)}
className="mt-2"
/>
<Input
type="file"
accept="image/*"
onChange={(e) => {
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
setImageFile(file)
setImagePreview(URL.createObjectURL(file))
}
}}
/>
{imagePreview && (
<img src={imagePreview} alt="Preview" className="mt-2" />
)}
<Button onClick={handleCreatePost} className="mt-4">
게시
</Button>
</DialogHeader>
</DialogContent>
</Dialog>
<div className="mt-8">
{posts.map((post) => (
<div key={post.id} className="border p-4 mb-4">
<h3 className="font-bold">{post.title}</h3>
<p>{post.content}</p>
{post.image_url && (
<img src={post.image_url} alt="Post Image" className="mt-2" />
)}
</div>
))}
</div>
{/* <Dialog open={showModal}> onOpenchange={setShowModal}></Dialog> */}
</div>
)
}
export default HomePage
일단 클라이언트 컴포넌트에서 다 구현하였고, 이 부분에 고민은 추후 다뤄볼 예정(그대로 갈 수도)
const route = useRouter()
const { toast } = useToast()
const [nickname, setNickname] = useState<string>("")
const [title, setTitle] = useState<string>("")
const [content, setContent] = useState<string>("")
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string>("")
const [showModal, setShowModal] = useState<boolean>(false)
const [posts, setPosts] = useState<Array<{ id: number; title: string; content: string; image_url: string }>>([])
input에 들어갈 닉네임,제목,이미지파일, 미리보기, 모달, 게시글에 대해 다 useState로 상태관리를 하였고, toast 알림기능, 페이지 이동 route까지 선언해주었다.
useEffect(() => {
const checkSession = async () => {
const { data } = await supabase.auth.getSession()
if (!data.session) {
toast({
title: "로그인 상태가 아닙니다.",
description: "로그인을 먼저 진행해주세요.",
})
route.push("/login")
return
}
const { user } = data.session
const { data: userNickname } = await supabase
.from("users")
.select("nickname")
.eq("id", user.id)
.single()
if (userNickname) {
setNickname(userNickname.nickname)
}
}
checkSession()
}, [route])
일단 이 컴포넌트가 렌더링될 때 실행하도록 useEffect의 의존성 배열에 route를 넣어주었고,
supabase의 auth-getSession을 통해 데이터가 있으면 이 페이지에 들어올 수 있고, 해당 유저의 세션이 없으면 로그인하라고 로그인 페이지로 이동시켜주었다.
그리고 유저의 닉네임을 화면에 띄울 예정이여서 supabase users 테이블에 nickname을 찾아서 아이디가 일치하면 해당 닉네임을 불러왔다.
여기서 맨 처음 위쪽 if문을 보면 if(!data.session)
이니까 const {user}=data.session
도 위에서 선언해주면 되는거 아닌가 싶었는데, 그렇게 해버리면 혹여나 data가 undefined일때 접근을 할 수 없으니 에러처리를 해주었다. 즉 !data.session이 아닌 경우는 session이 존재한다는 의미이므로, if문 아래에서는 문제가 없는 것이다
그리고 const 변수를 {}로 감싸는 이유는 구조 분해 할당을 해서 객체 안에 다양한 속성 중 원하는 key값만 뽑아오는 것이다 생각하면 된다.
const user = session.user
이거와 const {user} = session
은 같은 의미
그리고 아직 다양한 페이지를 만들진 않았지만, [route]를 적음으로써, 로그인 상태 이 후 세션 상태에 따라 접근 못할 페이지가 있을 필요가 있으므로 route로 세션체킹을 해주는게 좋을 것 같다.
const fetchPosts = async () => {
const { data, error } = await supabase
.from("posts")
.select("*")
.order("created_at", { ascending: false }) // 최신 게시글 위로 정렬
if (error) {
toast({
title: "게시글 불러오기 오류",
description: error.message,
})
} else {
setPosts(data)
}
}
useEffect(() => {
fetchPosts()
}, [])
supabase의 posts 테이블에서 모든 부분을 선택해서 created_at을 통해 구분지으면서 최신 게시글을 위로 정렬시키도록 data,error를 가져옴
select("*")
모든 열을 선택
order("created_at", { ascending: false })
created_at 필드 기준 결과 정렬
{ascending:false}
는 최신 게시글 위로 가도록 내림차순 정렬
이후 컴포넌트 렌더링 되때 게시글 불러오도록 useEffect 활용
const uploadImage = async (file: File) => {
const fileName = `image-${Date.now()}.png`
const { error: uploadError } = await supabase.storage
.from("images")
.upload(fileName, file)
if (uploadError) {
toast({
title: "이미지 업로드 중 오류가 발생하였습니다.",
description: uploadError.message,
})
return null
}
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/${fileName}`
}
이 부분이 매우 생소한 코드를 많이 사용했는데,
const uploadImage = async (file: File) => {
File객체는 사용자가 업로드하려는 이미지 파일 객체임
실제 이미지를 업로드하면 그 이름까지도 지정해줘야해서
const fileName = `image-${Date.now()}.png`;
이렇게 해놨고,
Date.now는 현재 시간을 밀리초 단위로 반환해줘서 파일 이름이 중복될 일이 없게됨.
const { error: uploadError } = await supabase.storage
.from("images")
.upload(fileName, file);
supabase의 storage api호출을 통해 images의 버킷에 접근해서
업로드할 파일명 fileName과 실제 파일 객체 file을 업로드 시켜줌
if (uploadError) {
toast({
title: "이미지 업로드 중 오류가 발생하였습니다.",
description: uploadError.message,
});
return null;
}
그 과정에서 업로드 오류가 될 수 있으므로, toast로 오류 내용 알려주기
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/${fileName}`;
실제 이미지 업로드 되면 그 이미지에 대한 URL을 반환하는 코드인데 매우 생소하였다.
결국 storage에 업로드된 이미지의 fileName을 찾아서 URL로 만들어줌
이러면 웹 사이트에서 주소 복사해서 다른 곳에서 사용하는 것처럼 저장한 이미지에 대해 URL로 다른 곳에 사용할 수 있게 됨
const createPost = async (uploadedImageUrl: string) => {
const { data } = await supabase.auth.getSession()
const user = data.session?.user
const { error } = await supabase.from("posts").insert([
{
user_id: user?.id,
title,
content,
image_url: uploadedImageUrl,
},
])
if (error) {
toast({
title: "게시글 작성 중 오류가 발생하였습니다.",
description: error.message,
})
} else {
toast({ title: "게시글이 작성되었습니다.", description: "upload post" })
setShowModal(false)
setTitle("")
setContent("")
setImageFile(null)
setImagePreview("")
fetchPosts() // 새 게시글을 불러오기 위해 호출
}
}
결국 이 함수가 최종적으로 게시글 생성을 해주지만 함수 안에 너무 많은 여러 내용을 담는건 좋지 않으므로, 세분화했었었다.
이 함수안에선 최종적으로 posts 테이블에 제목 내용 사진을 담아주고, 게시글이 작성되는 순간 모달 닫아주고 기존에 작성했던 제목, 내용 이미지업로드, 미리보기쪽은 다 초기화 시켜주면서 fetchPosts() 를 불러와서 새 게시글을 가져온다.
const handleCreatePost = async () => {
if (imageFile) {
const uploadedImageUrl = await uploadImage(imageFile)
if (uploadedImageUrl) {
await createPost(uploadedImageUrl)
}
} else {
await createPost("")
}
}
인스타그램도 보면 무조건 사진이 필수여서 사진이 있을때 게시물 생성이 되도록 if조건문에 imageFile과 uploadedImageUrl을 조건으로 넣어주었다.
return문에선
URL.createObjectURL
이 부분이 처음보는데,
브라우저에서 제공하는 API로 로컬에 있는 파일을 임시적으로 브라우저에서 사용할 수 있도록 유일한 URL을 생성시켜주는 함수라고 한다.
결국 png, jpg로 넣어도 이 부분때문에 URL로 넣어진다는 의미
그리고 이 부분 때문에 이미지 미리보기가 가능해짐
일단 업데이트를 위해 새로운 policy로 인증된 아이디와 유저 아이디가 일치하면 수정되도록 허용 해주면서~
const handleEditPost = (post: {
id: number
title: string
content: string
image_url: string
}) => {
setEditPostId(post.id)
setTitle(post.title)
setContent(post.content)
setImagePreview(post.image_url)
setShowModal(true)
}
수정하는 post에도 게시글 id, title,content,image_url이 들어있는 post를 인자로 전달받으면, 그 값들을 각각 set useState에 잘 넣어준다
const updatePost = async (uploadedImageUrl: string) => {
const { error } = await supabase
.from("posts")
.update({ title, content, image_url: uploadedImageUrl })
.eq("id", editPostId)
if (error) {
toast({
title: "게시글 수정 중 오류가 발생하였습니다.",
description: error.message,
})
} else {
toast({ title: "게시글이 수정되었습니다." })
setShowModal(false)
setEditPostId(null)
setTitle("")
setContent("")
setImageFile(null)
setImagePreview("")
fetchPosts()
}
}
이후 실제 수파베이스에서도 변경해줘야하므로 비동기 함수로써, posts 테이블에 title,content 사진 세개만 업데이트 시킬 수 있기에 세개를 update에 넣어주고 id와 editPostId 즉, 실제 게시글 올린 아이디를 찾아 그 사람만 업데이트 할 수 있게 만들어준다.
그렇게 에러없이 수정이되면 fetchPosts()한번 더 해서 Read하는 게시글들 업데이트해주기
const handleCreateOrUpdatePost = async () => {
if (editPostId) {
if (imageFile) {
const uploadedImageUrl = await uploadImage(imageFile)
if (uploadedImageUrl) {
await updatePost(uploadedImageUrl)
}
} else {
await updatePost("")
}
}
이전 사진을 업데이트한 값을 받아 create해주는 함수 위쪽에 if문으로 게시글 id있고 이미지 파일 있을때 그 url을 가지고 미리보기할 사진 url을 updatePost에 전달해주면 된다.
여기도 당연히 posts의 policy에서 delete 권한도 유저에게 주어지게 한 후
const deletePost = async (postId: number) => {
const confirmDelete = window.confirm("정말로 이 게시글을 삭제하시겠습니까?")
if (!confirmDelete) return
const { error } = await supabase.from("posts").delete().eq("id", postId)
if (error) {
toast({
title: "게시글 삭제 중 오류가 발생하였습니다.",
description: error.message,
})
} else {
toast({ title: "게시글이 삭제되었습니다." })
fetchPosts()
}
}
바로 삭제되지 않게 확인하는 confirm창 띄워주고, 실제 posts의 postId를 찾아 삭제시켜준다
단, 실제 구현해보니 권한을 모든 유저에게 주다보니, 본인이 작성하지 않은 게시글에 대해서도 삭제 및 수정버튼이 보이게 해놨는데, 원치 않는 과한 의도이므로,
const [posts, setPosts] = useState<
Array<{
id: number
title: string
content: string
image_url: string
user_id: string
}>
>([])
...
useEffect(() => {
// 이전 코드들
const { user } = data.session
setCurrentUserId(user.id)
...
// return문쪽
{post.user_id === currentUserId && (
<>
<Button onClick={() => handleEditPost(post)} className="mt-2">
수정
</Button>
<Button
onClick={() => deletePost(post.id)}
className="mt-2 ml-2"
>
삭제
</Button>
</>
post 상태에 유저 아이디 타입도 추가해주고,
session에서 받은 user의 아이디를 currentUserId에 넣어준 후,
두
post.user_id와 currentUserId가 일치할 경우면 버튼이 보이고 일치하지 않으면 아예 버튼자체를 안보이게 구현하였다.
pnpm add @tanstack/react-query
일단 설치!
useInfiniteQuery를 사용하여 무한 스크롤을 구현할건데
useInfiniteQuery란
React Query 라이브러리에서 제공하는 훅, 페이지네이션에 있는 데이터를 비동기적으로 가져올 때 유용
무한 스크롤에 주로 쓰임(데이터 계속 로드할 때)
일단 query에 대해 설치했어도 import할 수 있는 Provider 등 다른게 아무것도 안되서 처음부터 공식문서 보면서 다시 해보겠다.
next 13버전 이후 RSC가 도입되어 상위 모듈 layout에서 QueryProvider로 QueryClient를 props로 내려줄 수 없다라는 것인데,
RSC
= React Server Components
즉 클라이언트 컴포넌트가 아닌 layout컴포넌트에서 선언하면 렌더링 방식 차이 때문에 선언이 불가능 하다.
즉 서버사이드 렌더링된 HTML과 클라이언트 측에서 실행되는 JS가 일치하도록 하는 과정이 필요함
권장하는 접근 방법
참 영어가 길기도 길다.
어쨋든 이 패키지 사용 시 초기 data fetch 요청 시 서버에서 fetch되는데, useQuery훅 통해 발생한 api요청이 서버에서 초기화 됨.
결국 데이터가 불러와지면 자동으로 클라이언트에서 QueryClient에서 사용될 수 있도록 알아서 처리해줌
pnpm add @tanstack/react-query-next-experimental
일단 설치
그치만 이걸 설치한 이후로 컴포넌트를 ReactQueryStreamedHydration
과 QueryClientProvider
로 감싸줘야하지만 기본 서버 컴포넌트 베이스인 next에서는 아직 저걸 설치한것만으로 루트 컴포넌트를 감싸는 것으로 hydration이 해결되진 않는다.
"use client"
import React from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
const Providers = ({ children }: React.PropsWithChildren) => {
const [client] = React.useState(new QueryClient())
return (
<QueryClientProvider client={client}>
<ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
export default Providers
그렇게 해서 providers.tsx를 루트 디렉토리에 파일 추가(아직 안될수도있음 처음해보는거라)
그리고 ReactQueryStreamedHydration
이 부분이 이 컴포넌트가 서버로부터 클라이언트로 데이터가 스트리밍 되도록 돕는 역할
좀 더 풀어서 말하면 SSR에서 react-query데이터를 클라이언트 측으로 효율적으로 전달하는 역할임
(데이터 전송/데이터 동기화/SSR과 CSR연계)
즉 렌더링 차이로 인한 에러를 막아줄 수 있을거라 기대
(현재 상태)
아 물론 저 에러만 보면 순수객체상태로 서버에서 클라이언트로 보내는 과정에서 에러가 난거긴 한데,
근본적으로 React-query를 사용하려다가 생겨난 에러임
그렇게 따로 클라이언트 컴포넌트 환경에서 만들어준 Providers 컴포넌트를 layout에 넣어주니까
해결!! layout.tsx는 서버클라이언트인데도, 클라이언트 컴포넌트인 Providers를 넣어도 문제가 없는 모습!!
이제 실제 useQuery사용하는데서
suspense:true
이것만 추가하면 사용할 수 있음.
(아닐 수도 많은 오류 접하는 중)
공식문서를 보면 해당 플러그인을 사용하는걸 권장한다고 한다.
코드 통일성, 실수 방지!
pnpm add -D @tanstack/eslint-plugin-query
일단 설치해주고
공식문서에서 넣으라는 파일과 정확히 맞는 파일이 없어서 .eslintrc.json에 추가해주었다.
추가로 사용하려는 규칙을 활성화만했지 어떤 규칙을 구성할건지를 안해놔서 plugins쪽 rules쪽에 에러처리나게 해놨다.
근데 찾다보니 extends에 플러그인을 넣으면 알아서 사용자 정의로 추가하는 rules뿐만 아니라 전체 규칙이 다 활성화된다고 해서 extends만 추가하였고,
그 구성 내용으로는
@tanstack/query/exhaustive-deps
종속성 배열 올바르게 관리시키기(useEffect의 존송성 배열처럼)
즉 훅에 종속성 배열에 필요한 모든 변수를 넣도록 검사함
(추후 많은 에러를 동반할까 두렵다..)
@tanstack/query/prefer-query-object-syntax
객체 구문을 사용할 것을 권장
훅(useQuery, useMutation, useInfiniteQuery)을 사용할 때 객체 구문({queryKey,queryFn}) 사용하길 권장
@tanstack/query/stable-query-client
QueryClient 인스턴스 안정적 유지하는지 확인
QueryClient는 데이터 캐싱과 관리에 중요한 역할을 하는데, 애플리케이션 전반에서 일관되게 유지하며, 인스턴스 불필요하게 재생성되진 않는지 확인 더 나아가 잘못된 위치에서 new QueryClient()를 호출해서 새 인스턴스를 매번 생성하는 실수도 방지
너무 많은 시도를 하였어도 react-query가 잘 적용이 되지 않았다. 그래서 일단 너무 긴 코드를 먼저 리펙토링을 진행하고 리프레시해서 다시 진행해 보려한다. 그러다보니 처음으로 폴더 구조에 대해 고민해 보았는데, 지금 ShadCN을 통해 src/app/components에서 전역에서 쓸법한 ui들은 거기서 가져왔는데, ui뿐만 아니라 기능도 담고 있는 컴포넌트이므로, 전역에서 쓸법한 컴포넌트는 저기서 그리고 해당 페이지에서만 쓰는 컴포넌트는 ex- src/app/home/components이런식으로 구조화 시켜보았다
// 공용 및 개별 컴포넌트 분리
src/
├── components/
│ ├── Button.tsx
│ ├── Input.tsx
│ └── Modal.tsx
├── app/
│ ├── login/
│ │ ├── components/
│ │ │ └── LoginForm.tsx
│ │ └── page.tsx
│ ├── home/
│ │ ├── components/
│ │ │ ├── PostList.tsx
│ │ │ ├── PostForm.tsx
│ │ │ └── PostItem.tsx
│ │ └── page.tsx
일단 해보고 불편하면 나중에 수정하면 되지 머~~
이게 내부에서 컴포넌트 함수 선언할땐 대문자로 해주는건 당연한데 전체적으로 파일명은 소문자로 시작해놨으므로, 그 규칙을 지켜갈 예정이다.
그리고 중복단어의 파일명일땐 kebabcase -
를 이어붙이는 방식으로 진행해보자!
일단 구조는
src/
│
├── app/
│ └── home/
│ ├── page.tsx // HomePage 최상위 컴포넌트
│ └── components/ // 분리된 컴포넌트 폴더
│ ├── post-list.tsx // 게시글 리스트
│ ├── post-item.tsx // 게시글 아이템
│ ├── post-form.tsx // 게시글 작성/수정 폼
│ ├── image-uploader.tsx // 이미지 업로더
│ ├── modal-dialog.tsx // 모달 다이얼로그
│ └── logout-button.tsx // 로그아웃 버튼
이렇게 진행해볼 예정( 고민결과 파일명은 전부 소문자+케밥케이스)
현재 수많은 시행착오를 겪고 최종 리팩토링을 완성했는데,
csr은 page.tsx만 진행하고 있으므로 게시글을 post하면 최신화를 하는곳이 page.이라고 생각하셨죠?
ㄴㄴㄴㄴ
정말 많이 지치지고 하고 잘 안되서 아예 싹다 새로 수정함
지금처럼 컴포넌트를 나눈다기 보다는 그냥 내가 필요한건 page.tsx에 너무 많은 함수가 담겨있던 문제였던 것 같아서,
usePost.ts와 page.tsx로 구분지었다.
그래서 page에는 session체크(로그인 상태 확인) / 로그아웃 버튼 /
usePosts.ts에는 제목,콘텐츠,이미지파일,이미지 미리보기, 게시물 목록, 모달 상태관리 및 게시글 관련된 모든 상태 관리 / crud를 담아놨다.
그리고 useAuth.ts에는 로그인 상태관리 / 로그아웃 관련 기능을 넣어놨다.
결국 page / useAuth / usePosts 3개로 구분짓는게 가장 효율 적일 것 같아 그렇게 처리하였다.
사실살 훅2개와 메인페이지 1개로 구성
이 부분에 대해서 상세히 기재를 하지 않은 것 같아 다시 해보려한다.
fetchPosts 함수가 posts 테이블에서 게시글을 가져오는 비동기 함수인데,
현재는 무한스크롤을 이용해서 구현까지 마친 상태고, pageParam을 통해 현재 페이지 결정 후 range 메서드를 사용하여 해당 페이지의 게시글을 가져옴 이후 nextPage를 있는지 판별하여 설정
그 확인하는 곳은 useInfiniteQuery를 활용하여 게시글을 페이지 단위로 가져옴
이후 useMemo를 사용하여 성능도 최적화 함
loadMorePosts함수는 다음 페이지가 있으면 추가 게시글 로드해주는 역할
const fetchPosts = async (
pageParam: number = 1,
): Promise<FetchPostsResult> => {
const { data, error } = await supabase
.from("posts")
.select("*", { count: "exact" })
.order("created_at", { ascending: false })
.range((pageParam - 1) * ROWS_PER_PAGE, pageParam * ROWS_PER_PAGE - 1)
if (error) {
throw new Error(error.message)
}
return {
data: data || [],
nextPage: data?.length === ROWS_PER_PAGE ? pageParam + 1 : undefined,
}
}
const {
data: postsData,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 1,
})
const posts = useMemo(
() => postsData?.pages.flatMap((page) => page.data) || [],
[postsData],
)
const loadMorePosts = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}