기존 src/app/provider.tsx
, src/providers/counter-store-provider.tsx
, src/components/SessionProvider.tsx
로 중구난방 Provider가 위치해있어서
src/providers/
여기서
CounterStoreProvider.tsx
SessionProvider.tsx
QueryProvider.tsx
로 대문자화 + 위치 통일로 수정해주었다.
==========================================
typescript사용해서 굳이 필요없어서 비활성화
{
"rules": {
"react/prop-types": "off"
}
}
pnpm prettier --write .
그래도 변경이 안되어서
npx prettier --write src
이걸로 재시도
그러나 여전히 강력한 타입때문에 에러가 뜨고 있는 상황
정말 힘들다 어떻게 고쳐나가지 하하하하
일단 이왕 하는 김에 tsconfig.json에서 더 강력한 타입에 대한 옵션을 넣었는데,
noImplicitAny
타입을 명시하지 않은 변수나 매개변수에 대해 TypeScript가 자동으로 any타입 할당하지 않도록 함
strictNullChecks
null
과 undefined
값이 변수에 할당될 수 있는 경우 더 엄격히 검사
기존에도 타입 에러의 향연이였는데 과연??
여전히 버셀 빌드 에러가 뜨는모습
정확히 하나하나 찾아보니
이런 에러들이 뜨고 있다.
일단
"react/prop-types": "off",
"@next/next/no-img-element": "off"
typescript를 사용하고, 추후 성능 개선으로 Image태그를 사용할 것이기에,
prop-types와 img태그에 대한 경고를 off처리해놨다.
찾다보니 일단 배포시점의 lighthouse가 궁금해서 pnpm run build
부터 진행하기로 했는데,
.next가 읽기 전용으로 체크가 되어있어서 읽기전용을 해제해도 여전히 문제가 있는 모습
$ pnpm run build
> share-life@0.1.0 build C:\Users\User\Desktop\web\share-life
> next build
▲ Next.js 14.2.5
- Environments: .env.local
Creating an optimized production build ...
✓ Compiled successfully
Linting and checking validity of types .Failed to compile.
.next/types/app/api/auth/[...nextauth]/route.ts:8:13
Type error: Type 'OmitWithTag<typeof import("C:/Users/User/Desktop/web/share-life/src/app/api/auth/[...nextauth]/route"), "GET" | "POST" | "HEAD" | "OPTIONS" | "PUT" | "DELETE" | "PATCH" | "config" | "generateStaticParams" | ... 6 more ... | "maxDuration", "">' does not satisfy the constraint '{ [x: string]: never; }'.
Property 'authOptions' is incompatible with index signature.
Type 'AuthOptions' is not assignable to type 'never'.
6 |
7 | // Check that the entry is a valid entry
> 8 | checkFields<Diff<{
| ^
9 | GET?: Function
10 | HEAD?: Function
11 | OPTIONS?: Function
Linting and checking validity of types .. ELIFECYCLE Command failed with exit code 1.
그래도 이전의 에러와 다른 문제가 떴는데,
authOptions쪽에서 타입정의가 되지 않아서 발생하는 문제인 것 같다
그래서 블로그를 찾아보니 app router에서는 지금은 src/app/api/auth/[...nextauth]/route.ts에서 선언해놨었는데,
API 라우트 파일은 HTTP 메소드 핸들러를 export 하는데 집중해야해서 authOptions같은 설정 객체를 분리해야한다고 한다.
그래서 실제로 src/lib/authOptions.ts에서 선언하고
api/auth/[...nextauth]/route.ts에서는
// src/app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth"
import { authOptions } from "@/lib/authOptions" // 분리된 authOptions 파일
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
이런식으로 불러와서 build를 하니 해당 오류는 안뜨고 다른 오류로 넘어갔다!!
(오류 왜이리 많아 ^^)
참고 블로그
현재 follow쪽 타입에러가 뜨는데 이부분은 팔로우팔로잉 블로그에서 다뤄서 해결해보도록 하겠다
기존에 잘 되던게 배포때 에러를 잡다보니 찾은 에러인데,
useState의 타입을 number []배열로 세팅해놨는데 null이 생길 수 있는 가능성이 있어서 에러가 발생되고 있었다.
그래서 filter처리를 진행해서 post_id가 null이 아닌 경우만 배열에 포함시키도록 진행하여 해결하였다.
근데 코드가 길어져서 팔로우때 적용했던 강력한 타입 적용인 returns를 활용하여 다시 해결하였다.
게시글의 data는 FetchPostsResult에 있는 data의 Post 타입 정의와 수파베이스에서 반환되는 데이터 타입이 일치하지 않아 발생하고 있었다.
타입은 맞춰줬으나 null일 경우가 있어서 에러가 발생하였는데,
아마 수파베이스쪽 테이블 구조를 다시 짤때 is Nullable을 체크해제를 해줬어야 했는데, 그 부분을 누락해서 발생한 문제 같았다.
그래도 null일 경우엔 fetch를 해줄 필요가 없어서 코드에서도 처리해주는게 좋은 것 같아 이쪽도 코드로 해결해보려한다.
null한 값을 담을 일이 전혀 없으므로 팔로우쪽 해결하며 찾은 쿼리문인 returns에다 타입을 정해줘서 null한 값이 없도록 해결!
배포 시도할때 계속 .next 폴더에 대한 권한을 받아오지 못해서 결국 새로운 폴더에서 깃클론해서 받아와서 배포시도를할때 처음보는 에러가 떴다
CR(Carriage Return) 문자를 발견하여 제거하라고 지적하는 것
이라는데
줄바꿈 형식이 CRLF와 LF가있다던데 그동안 전혀 CRLF로 되어있엇고 문제가 없엇는데 지적하는게 옳지 않다고 생각해서 "endOfLine": "crlf"
를 prettierrc에 넣어줘서 다시 해결해보려한다
npx prettier --write .
명령어로 prettier를 사용하여 코드를 다시 포맷팅하였고 이제 다시 build해보자
그래도 똑같은 에러가 떠서
.eslintrc.json에서도
"prettier/prettier": [
"error",
{
"endOfLine": "crlf"
}
],
를 추가해서 이 에러는 넘어간것같다;; 참 어려운 컴퓨터 코딩의 세계;;
충분히 잘 의존성 값을 넣어줬는데 이런 에러가 뜨는게 내입장에선 굳이 이렇게까지 강력하게 체크할 필요는 없어서 "react-hooks/exhaustive-deps": "off"
로 진행하였다. 물론 개발자 입장에선 안좋다고 느껴질 것 같긴하다..?
ShadCN/UI 컴포넌트를 받아온 파일에서 이런 에러가 뜨는데 굳이 그부분까지 코드를 건드리고 싶진 않아서, "@typescript-eslint/no-empty-object-type": "off",
로 해결하였다.
run build할때 endOfLine에 대해 에러가 떳던게 베포때는 고스란히 그대로 에러로 발생하였다.
그래서 찾아보니 .prettier와 eslintrc에서 endOfLine:CRLF
로 세팅해뒀던걸 빌드 스타트에선 문제가 없었는데 버셀 배포에선 여전히 에러가 났었다.
그래서 endOfLine:auto로 자동으로 알아서 설정할 수 있게 했더니??
이게 얼마만에 버셀 베포여~~ 한잔해~~ㅎㅎ
근데 로그인 하니까
이런 에러가 떠서 어지러웠는데 다행히 이번엔 환경변수를 안넣었던게 빠르게 떠올라서 바로 넣어주었다.
제발 되라...!
드디어 길고도 길었던 배포 해결..ㅠ
pnpm add zustand
로 설치
create()
함수로 상태 정의 후 여러 컴포넌트에서 사용
상태 저장소 / 상태 구독이 zustand의 주요 개념
이미지 최적화 위해 img태그에서 Image 컴포넌트로 도입
반응형을 작업할진 모르지만 부모 요소의 크기에 맞게 이미지 조정 위해
layout="responsive"
도입
자동 이미지 최적화 + 레이지 로딩 적용됨
적용중 기존 이미지 url을 이전 프로젝트 url 도메인을 담고 있어서 최신 프로젝트 도메인으로 수정
import TerserPlugin from "terser-webpack-plugin"
const isProduction = process.env.NODE_ENV === "production"
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["@@@@@@@@@@@], // 이미지 도메인 설정
},
webpack(config) {
if (isProduction) {
config.optimization = {
...config.optimization,
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
terserOptions: {
format: {
comments: false, // 주석 제거
},
compress: {
drop_console: true, // console.* 구문 제거
},
},
extractComments: false, // 주석을 별도의 파일로 추출하지 않음
}),
],
}
}
return config
},
}
export default nextConfig
무한스크롤 useInfiniteQuery를 사용하면서 발생한 문제인데, 실제 fetch해주는 함수 fetchPosts에서는 userId를 인수로 잘 넣어서 해당 유저의 id값을 잘 전달해주었으나, 실제 무한스크롤에선 그 user의 id를 넣어주지않아 변별력이 없이 모든 게시물이 다 보여지는 문제가 발생하였다. 그래서 usePosts 자체적으로 인수로 userId를 넣어줘서 유저 프로필페이지에서 params로 가져온 id를 직접 usePosts의 인수로 넘겨주어서 그값을 infiniteQuery의 값 안에 넣어줘서 해결했다.
무한스크롤 할때마다 너무 빠르게 호출해서 무한 api요청을 방지하기 위해 스크롤 이벤트마다 서버에 요청하는 간격을 제한 시킬 수 있다. 그러면 api요청 수가 줄고, 서버 부하를 방지할 수 있다.
pnpm add lodash
쓰로틀링 함수 추가
const loadMorePosts = throttle(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, 300) // 300ms 동안 하나의 요청만
load할 때 300ms제한 둬서 api 요청 수 감소시키기!
무한스크롤의 throttle
은 사용자가 화면을 내리는 동안 매우 자주 발생, 그래서 일정 간격으로만 데이터 불러오도록 제한, 스크롤이 특정 지점에 도달할 때마다 데이터 불러옴
버튼의 throttle
은 너무 빠르게 여러번 수행 시 서버로 과도한 요청 보내지 않도록 제한, 특정 시간동안 단 한 번의 클릭만 처리되도록 하는게 목표
import useLike from "@/hooks/useLike"
import { throttle } from "lodash"
import React, { useCallback } from "react"
import { AiOutlineHeart, AiFillHeart } from "react-icons/ai"
const LikeButton: React.FC<{ postId: number }> = ({ postId }) => {
const { toggleLike, isPostLikedByUser, getLikeCountForPost } = useLike()
const isLiked = isPostLikedByUser(postId)
const likeCount = getLikeCountForPost(postId)
// throttle된 toggleLike 함수 생성
const throttledToggleLike = useCallback(
throttle((id: number) => toggleLike(id), 300), // 300ms 간격으로 요청
[toggleLike],
)
return (
<div className="flex items-center mt-2">
<button
onClick={() => throttledToggleLike(postId)}
className="mr-2 text-xl"
>
{isLiked ? (
<AiFillHeart className="text-red-500" />
) : (
<AiOutlineHeart className="text-gray-500" />
)}
</button>
<span>{likeCount} Likes</span>
</div>
)
}
export default LikeButton
300ms간격마다 1회 클릭되도록 but 이미 낙관적 업데이트를 적용한 후라 바로바로 ui가 적용되어서 크게 메리트는 없는 것 같다.
기존에 크게 신경 안쓰고 구현했을 땐, 댓글 작성하던 input에서 댓글 수정을 누르면, 기존 댓글이 뜨고 거기서 수정을 했었음.
처음엔 이질감을 못느꼈으나, ux적으로 별로인 것 같아 수정 modal을 만들어주기로 하였음.
그래서 dialog를 shadCN껄로 추가해서 구현하였으나, dialog띄우면, 기존 댓글의 input창에도 댓글 내용이 그대로 적용되고 있었음.
사실상 기능적으로는 모달에서 수정해서 잘 되므로 상관은 없지만 ux적으로 별로여서,
기존 댓글 작성과 수정이 동일한 comment
상태로 관리했었고,
const [comment, setComment] = useState<string>("") // 댓글 작성, 수정 모두 이 상태를 사용
// 댓글 수정 핸들러
const handleEditComment = (comment: Comment) => {
setEditCommentId(comment.id)
setComment(comment.content) // 수정할 댓글을 comment 상태에 설정 (댓글 작성 필드와 공유됨)
setShowCommentModal(true)
}
// 댓글 작성/수정 함수
const handleCreateOrUpdateComment = async () => {
if (editCommentId) {
await updateComment(editCommentId, comment) // 수정할 때도 comment 상태를 사용
} else {
await createComment(comment) // 작성할 때도 comment 상태를 사용
}
resetForm()
setShowCommentModal(false)
}
// 폼 초기화 함수
const resetForm = () => {
setComment("") // 댓글 작성 및 수정 후 동일한 comment 상태를 초기화
setEditCommentId(null)
}
관련된 코드 전부 comment를 사용중이였음.
그래서,
댓글 작성과 수정을 분리된 상태로 관리하기 위해
첫 댓글 작성은 comment
수정은 editComment
상태로 별도로 관리해서 영향 안주게 구현
// 댓글 수정 핸들러
const handleEditComment = (comment: Comment) => {
setEditCommentId(comment.id)
setEditComment(comment.content) // 수정할 댓글은 editComment 상태에 저장
setShowCommentModal(true)
}
이후 setEditComment에 수정 값을 넣어줘서
// 댓글 생성 또는 수정 핸들러
const handleCreateOrUpdateComment = async () => {
if (editCommentId) {
await updateComment(editCommentId, editComment) // 수정할 때는 editComment 상태를 사용
} else {
await createComment(comment) // 작성할 때는 comment 상태를 사용
}
resetForm()
setShowCommentModal(false)
}
// 폼 초기화 함수
const resetForm = () => {
setComment("") // 댓글 작성 필드 초기화
setEditComment("") // 댓글 수정 필드 초기화
setEditCommentId(null)
}
이런식으로 따로 관리해주었다.
// 수정 모달에서 수정할 때 editComment 상태 사용
<Dialog open={showCommentModal} onOpenChange={setShowCommentModal}>
<DialogContent>
<DialogHeader>
<Input
placeholder="댓글을 수정하세요"
value={editComment} // 수정할 때는 editComment 사용
onChange={(e) => setEditComment(e.target.value)}
/>
<Button onClick={handleCreateOrUpdateComment}>수정</Button>
</DialogHeader>
</DialogContent>
</Dialog>
그동안은 서버에서 직접 데이터 페치 수정 삭제를 진행했는데 api를 적용할 생각만하고 실제 사용은 안했기 때문에 app/api에서 만든 코드를 활용해보려한다.
사실 그냥 프론트엔드 개발자 입장에서만 생각하면 굳이 바로 서버에서 요청하면 되는걸 api를 왜 세팅해줘야하나 싶지만, 백엔드개발자와 협업하는 프로젝트라면 그리고 참여인원이 많아진다면 중복 코드가 발생될 수 있어 연습한다는 개념으로 봐주면 좋을 것 같다
그리고 가장 큰 이유는 보안,
api 사용시 서버에서 데이터 가져오는 과정에서 인증과 권한 검사를 서버에서 직접 처리가 가능하다, 그리고 클라이언트는 ui와 사용자 상호작용에 집중하고, 서버는 데이터 처리나 비즈니스 로직 담당할 수 있게 된다.
// Supabase에서 사용자 데이터 가져오기
const { data, error } = await supabase
.from("users")
.select("nickname, profile_image")
.eq("user_id", userId)
.single()
기존에 이런식으로 서버에 접근했었다면,
import { supabase } from "@/lib/supabase"
import { NextRequest, NextResponse } from "next/server"
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const userId = searchParams.get("userId")
if (!userId) {
return NextResponse.json({ error: "User ID is required" }, { status: 400 })
}
// Supabase에서 사용자 데이터 가져오기
const { data, error } = await supabase
.from("users")
.select("nickname, profile_image")
.eq("user_id", userId)
.single()
// Supabase에서 발생한 에러 처리
if (error) {
// Error 타입으로 명시적으로 처리
const errorMessage: string = error.message || "Failed to fetch user data"
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
// 데이터가 없는 경우 처리
if (!data) {
return NextResponse.json({ error: "No user data found" }, { status: 404 })
}
return NextResponse.json(data)
}
이런식으로 src/app/api/auth/user-data/route.ts 파일을 만들어서 여기에서 api호출을 하여서
// 사용자 프로필 정보를 가져오는 함수 예시
const fetchPostUserData = async (userId: string) => {
try {
const response = await fetch(`/api/auth/user-data?userId=${userId}`, {
method: "GET",
})
const data = await response.json()
if (response.ok && data) {
setPostNickname(data.nickname)
setPostProfileImage(data.profile_image)
} else {
throw new Error(data.error?.message || "Failed to fetch user data")
}
} catch (error: unknown) {
handleError(error) // 헬퍼 함수 호출
}
}
fetch를 통해 api호출을 해서 불러오게 되었다.
기존
const fetchPosts = async (
pageParam: number = 1,
userId?: string,
): Promise<FetchPostsResult> => {
let query = supabase
.from("posts")
.select(`*, users(user_id, nickname, profile_image)`, { count: "exact" })
.order("created_at", { ascending: false })
.range((pageParam - 1) * ROWS_PER_PAGE, pageParam * ROWS_PER_PAGE - 1)
if (userId) {
query = query.eq("user_id", userId)
}
console.log("123userId", userId)
const { data, error } = await query.returns<Post[]>()
if (error) {
console.error("Error fetching posts:", error.message)
throw new Error(error.message)
}
return {
data: data || [],
nextPage: data?.length === ROWS_PER_PAGE ? pageParam + 1 : undefined,
}
}
이 코드를 api를 활용해서 2개 코드로 분류하였는데,
// GET: 게시물 목록 가져오기 (userId가 없을 경우 모든 게시글)
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const pageParam = parseInt(searchParams.get("page") || "1");
const userId = searchParams.get("userId");
const ROWS_PER_PAGE = 10;
// 기본 쿼리 설정
let query = supabase
.from("posts")
.select("*, users(user_id, nickname, profile_image)", { count: "exact" })
.order("created_at", { ascending: false })
.range((pageParam - 1) * ROWS_PER_PAGE, pageParam * ROWS_PER_PAGE - 1);
// userId가 있을 경우에만 필터 추가
if (userId) {
query = query.eq("user_id", userId);
}
const { data, error } = await query;
console.log("data:", data); // 데이터 확인
console.log("error:", error); // 에러 확인
if (error) {
const errorMessage: string = error.message || "Failed to fetch posts";
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
return NextResponse.json(data || []); // data가 null일 경우 빈 배열 반환
}
api쪽
const fetchPosts = async (
pageParam: number = 1,
userId?: string,
): Promise<FetchPostsResult> => {
const response = await fetch(
`/api/posts?page=${pageParam}&userId=${userId}`,
{ method: "GET" },
)
console.log("response:", response)
console.log("userId", userId)
if (!response.ok) {
const errorData = await response.json()
console.error("Error fetching posts:", errorData.error)
throw new Error(errorData.error)
}
const data = await response.json()
return {
data: data || [],
nextPage: data.length === ROWS_PER_PAGE ? pageParam + 1 : undefined,
}
}
usePosts쪽
이렇게 구분지었는데,
실질적으로 유저 프로필아이디값이 있는 유저프로필에선 게시글들이 다 fetching되고있으나, userId가 undefined인 메인페이지에서는 fetch가 되지 않았다.
그래서 api쪽에서 console을 찍어보니,
error: {
code: '22P02',
details: null,
hint: null,
message: 'invalid input syntax for type uuid: "undefined"'
}
즉, userId는 string으로 타입선언을 해놓고 undefined로 결과괎이 나오다보니 타입에러에서 나오는 문제였다. 즉, 저 경우의 수도 조건문으로 없애줘야 유저아이디가 없어도 페칭이 되는 상황이여서,
// userId가 있을 경우에만 필터 추가
if (userId && userId !== "undefined") {
// userId가 있는 경우 필터 추가
query = query.eq("user_id", userId)
}
이렇게 구분을 지어줘서 해결할 수 있었다.
기존
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}`
}
코드로 잘 구현되던 코드가
import { supabase } from "@/lib/supabase"
import { NextResponse } from "next/server"
export async function POST(request: Request) {
const { fileName,file } = await request.json()
const { error: uploadError } = await supabase.storage
.from("images")
.upload(fileName,file)
if (uploadError) {
return NextResponse.json({ error: uploadError.message }, { status: 400 })
}
const imageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/${fileName}`
return NextResponse.json({ imageUrl })
}
api쪽을 기존 코드 형식과 같이 짜서 넣었더니 계속 500에러가 떴다.
그렇다면 서버에 문제가 있다는거여서,
찾아보니 file을 서버에 전송하는 형식에서 데이터에 file이 File객체로서 보내져야하는데 그부분이 오류가 났었고,
실제로 api쪽에 데이터를 보내주기 위해 FormData형식으로 처리해서 값을 보내줬는데, 실제 보내준 값은 fileName, file 두가지이며,
여기서
FormData는
웹 api로 HTML폼 데이터 전송을 쉽게 처리하기 위한 객체
키-쌍의 형태로 데이터를 저장하며, 파일과 같은 Blob 객체도 함께 전송할 수 있음
그렇게해서 클라이언트 코드에서
const formData = new FormData()
formData.append("fileName", fileName)
formData.append("file", file)
이런식으로 묶어서 body에 보내줬는데 여기서 fileName은 문자열 데이터, file은 file데이터라고 보내준 셈이다.
const uploadImage = async (file: File) => {
const fileName = `image-${Date.now()}.png`
// const fileData = await file.arrayBuffer()
const formData = new FormData()
formData.append("fileName", fileName)
formData.append("file", file)
const response = await fetch("/api/posts/image", {
method: "POST",
body: formData,
})
if (!response.ok) {
const error = await response.json()
toast({
title: "이미지 업로드 중 오류가 발생하였습니다.",
description: error.message,
})
return null
}
const { imageUrl } = await response.json()
return imageUrl
}
이렇게 클라이언트쪽을 수정해서 보내주니 해겷되었었다가 위에 적힌 buffer처리를 안하고 서버로 이미지파일을 전송하니 액박이 떴는데, buffer를 처리해주니 해결되었다.
여기서
buffer란
node.js의 전역 객체로, 이진 데이터를 다루기 위한 메모리 공간.
일반적으로 파일의 내용이나 네트워크 통신에서 바이너리 데이터를 처리하는데 사용
파일 업로드나 다운로드, 이미지 처리 등 다양한 상황에서 Buffer 사용
결국 FormData로 buffer처리해서 보내주니 이미지가 잘 생성되는걸 확인할 수 있었다.
로그인한 유저가 보고있는 유저페이지의 유저를 팔로우 했는지 체크해주는 isFollowing함수의 데이터 요청 코드를 api로 변환하는 과정에서 500에러가 떴다
좀 더 자세한 오류 확인을 위해
import { supabase } from "@/lib/supabase"
import { NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
const { follower_id, following_id } = await request.json()
if (!follower_id || !following_id) {
return NextResponse.json(
{ error: "Both follower_id and following_id are required" },
{ status: 400 },
)
}
try {
const { data, error } = await supabase
.from("follows")
.select("*")
.match({ follower_id, following_id })
.maybeSingle()
if (error) {
console.error("Supabase error:", error)
throw error // 에러를 던져서 catch 블록으로 이동
}
console.log("Request data:", { follower_id, following_id })
console.log("Database response:", { data, error })
return NextResponse.json({ isFollowing: !!data })
} catch (error) {
console.error("Error occurred:", error)
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 },
)
}
}
이렇게 세팅해보니
Supabase error: {
code: 'PGRST116',
details: 'The result contains 0 rows',
hint: null,
message: 'JSON object requested, multiple (or no) rows returned'
}
Error occurred: {
code: 'PGRST116',
details: 'The result contains 0 rows',
hint: null,
message: 'JSON object requested, multiple (or no) rows returned'
}
즉, json반환은 되는데 여러행이 반환되거나 없거나를 반환시킨다는 뜻인데, 내 api코드에선 .single()로 하나의 행만 나올 예상만하였기에 이런 에러가 발생하는것이다.
물론 하나일 경우도 맞지만, 팔로우를 안했을 경우 아예 어떠한 행도 즉, 어떠한 데이터도 반환되지 않을 것이기에, 이러한 서버쪽 에러인 500에러가 발생이 되었고,
위쪽 코드처럼 .maybeSingle()을 넣어줘서 데이터가 없는 경우에도 올바르게 처리할 수 있게 해결하였다.
대표라이브러리 2개를 비교해보니 react-loading-skeleton이 압도적으로 사용량이 높아서 이 걸로 진행해보겠다.
pnpm add react-loading-skeleton
으로 설치 후
직접 구현하는게 좋다고 해서..직접구현해보자
일단 스켈레톤 ui가 필요한 이유는 데이터가 로딩되는 동안 사용자에게 보여주는 플레이스 홀더이고, 컴포넌트나 페이지 구조를 미리 보여줘서 로딩중임을 알려주는 수단임
근데?? ui는 사실상 챗봇의 도움을 많이 받아 컨테이너 공간에 대한 이해도 없이 ui를 구현했기 때문에 다시 다시 react-loading-skeleton으로 구현해보겠다
이유는, 기본적인 스켈레통 로딩 컴포넌트를 제공하여 별도로 각 ui요소에 맞게 디자인할 필요가 없기 때문이다.
그 과정에서 아무리 스켈레톤을 적용해도 새로고침시 컴포넌트가 불투명하게라도 틀이 보이지 않았는데,
const {
data: postsData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading, // 초기 로딩 상태 추가
isError, // 에러 상태 추가
error, // 에러 정보 추가
} = useInfiniteQuery({
queryKey: ["posts", userId],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam, userId),
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 1,
})
기존 useInfiniteQuery로 게시글을 보여주기로할때 isLoading,isError, error에 대한 처리를 세팅을 안해놨기에, 아무리 수정해도 스켈레톤 ui를 구축해도 보여지지 않는 것이였다.
그 후 실제 posts게시글을 사용하는 홈페이지에서
if (isLoading) {
return (
<div className="bg-neutral-800 min-h-screen">
<Header />
<main className="my-5 max-w-[600px] mx-auto pt-16 pb-8 px-2">
<PostSkeleton />
<PostSkeleton />
<PostSkeleton />
</main>
</div>
)
}
if (isError) {
return <div>Error: {(error as Error).message}</div>
}
return (
<div className="bg-neutral-800 min-h-screen">
<Header />
<main className="my-5 max-w-[600px] mx-auto pt-16 pb-8 px-2">
{posts.map((post, index) => (
<article
key={post.id}
className="bg-neutral-900 text-white border-2 border-neutral-900 rounded-lg mb-6 shadow-sm"
ref={index === posts.length - 1 ? lastPostElementRef : null}
>
{/* Post content (same as before) */}
</article>
))}
{isFetchingNextPage && (
<>
<PostSkeleton />
<PostSkeleton />
<PostSkeleton />
</>
)}
</main>
</div>
)
}
그리고 해당 포스트 스켈레톤 컴포넌트
import Skeleton from "react-loading-skeleton"
import "react-loading-skeleton/dist/skeleton.css"
const PostSkeleton = () => (
<div className="bg-neutral-900 text-white border-2 border-neutral-900 rounded-lg mb-6 shadow-sm p-3">
<div className="flex items-center mb-3">
<Skeleton circle width={32} height={32} className="mr-3" />
<Skeleton width={100} />
</div>
<Skeleton height={300} className="mb-3" />
<Skeleton count={3} className="mb-2" />
</div>
)
export default PostSkeleton
이 두 컴포넌트를 합쳐서 결국, 첫 로딩때 스켈레톤이 보이고, 스크롤해서 다음 게시글을 불러오는 동안 스켈레톤이 보이게 구현하게 되었다.
이건 TruncatedText라는 컴포넌트를 만들어서 일정 텍스트 길이가 늘어나거나하면 더보기를 클릭해서 볼 수 있는 컴포넌트를 구현하였다
// app/home/_components/TruncatedText.tsx
import React, { useState } from "react"
interface TruncatedTextProps {
text: string
maxLength: number
maxLines?: number
}
const TruncatedText: React.FC<TruncatedTextProps> = ({
text,
maxLength,
maxLines = 3,
}) => {
const [isExpanded, setIsExpanded] = useState(false)
const truncatedText = isExpanded ? text : text.slice(0, maxLength)
return (
<div className="relative">
<div
className={`${
isExpanded ? "" : `line-clamp-${maxLines}`
} whitespace-pre-wrap break-words`}
>
{truncatedText}
{!isExpanded && text.length > maxLength && "..."}
</div>
{text.length > maxLength && (
<button
className="text-blue-400 hover:underline mt-1 text-sm"
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
aria-controls="expandable-text"
>
{isExpanded ? "접기" : "더 보기"}
</button>
)}
</div>
)
}
export default TruncatedText
그래서 이 컴포넌트를 활용하여 댓글텍스트가 일정숫자를 넘어서면 더보기로 볼 수 있게 구현하였는데,
{/* 댓글 내용 */}
<div className="flex justify-between items-start">
<TruncatedText
text={comment.content}
maxLength={20}
maxLines={2}
/>
이런식으로 기존 컴포넌트를 불러와서 조건만 넣어주면
이렇게 잘 표시가 된다.
그리고 기존에 게시글쪽 무한스크롤과 동일하게
// 무한 스크롤을 위한 observer 설정
const observer = useRef<IntersectionObserver | null>(null)
// 마지막 댓글을 감지하는 콜백 함수
const lastCommentElementRef = useCallback(
(node: HTMLDivElement | null) => {
if (isFetchingNextPage) return
if (observer.current) observer.current.disconnect()
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
loadMoreComments() // 추가 댓글 로드
}
})
if (node) observer.current.observe(node)
},
[isFetchingNextPage, hasNextPage, loadMoreComments],
)
해당 함수 적용해서 스크롤로 처리해주었다.
드디어 pnpm run build
pnpm run start
로 틀어졌다 ㅠㅠ
이제 라이트하우스를 비교해볼건데 run build / run start보다 실제 배포했을 때 비교하면 차이가 난다고해서, 배포를 시도했더니 위의 문제를 해결해서 성능측정을 시작한다..!
시크릿모드에서 페이지 하나하나 따주겠다.
이후 리펙토링 했더니
오히려 접근성이 더 감소한 모습
이후 리펙토링
변동 없음
성능
접근성
권장사항
검색엔진 최적화
리펙토링 후
성능 wow~~ 헤헿
여기는 팔로우 기능이 너무 늦게 에러를 잡게 되어서 before after 구분짓기가 어려움
리펙토링 진행하면서 에러 수정한 페이지임