콤퓨우터/프로그래밍 공부

Next.js Full Course 필기 (4)

파란화면 2024. 3. 17. 16:54
반응형

Fireship.io의 "Next.js Full Course" 강의를 듣고 필기했던 내용입니다.


Follow 기능 만들기

데이터베이스 스키마 수정

Follows 모델:

  • followerId 피팔로우자의ID
  • followingId 팔로우하는 자의 ID
  • @@id([followerId, followingId]): userA_userB 같은 식으로 만들어지는 해당 팔로우에 대한 고유ID
      followedBy    Follows[] @relation("following")
      following     Follows[] @relation("follower")
    }
    
    model Follows {
      follower    User @relation("follower", fields: [followerId], references: [id])
      followerId  String
      following   User @relation("following", fields: [followingId], references: [id])
      followingId String
    
      @@id([followerId, followingId])
    }

 

Follow API Route

POST(팔로우 시)와 DELETE(언팔로우 시) 함수를 export한다.

export async function POST(req:Request) {
    const session = await getServerSession(authOptions);
    const currentUserEmail = session?.user?.email!;
    const {targetUserId} = await req.json();

    //authOptions를 수정해서 jwt에 userId를 저장하게 하는 식으로 구현하면
    //이 부분의 쿼리를 절약할수있을수도있나? 다음기회에 알아보자
    const currentUserId = await prisma.user.findUnique({where:{email:currentUserEmail}})
        .then((user) => user?.id!);

    const record = await prisma.follows.create({
        data: {
            followerId: currentUserId,
            followingId: targetUserId
        }
    });
    return NextResponse.json(record);
}

DELETE:


export async function DELETE(req:NextRequest) {
    const session = await getServerSession(authOptions);
    const currentUserEmail = session?.user?.email!;
    const targetUserId = req.nextUrl.searchParams.get('targetUserId');

    const currentUserId = await prisma.user.findUnique({where:{email:currentUserEmail}})
        .then((user) => user?.id!);

    const record = await prisma.follows.delete({
        where: {
            followerId_followingId: {
                followerId: currentUserId,
                followingId: targetUserId!
            }
        }
    });
    return NextResponse.json(record);
}

User Inteface 만들기

  1. 현재 사용자가 해당 사용자를 팔로우하는지 체크하여 팔로우/언팔로우 버튼을 표시
  2. 버튼을 클릭하는 즉시 (서버의 응답을 기다리지 않고) 팔로우 버튼이 언팔로우 버튼으로 변경

사실 컴포넌트는 /app/components가 아니라 /components에 왔어야 하는 건데 지금까지 잘못 쓰고 있었다
뭐 일단 넘어갑시다

/components/FollowButton/ 하위에 FollowButton 서버 컴포넌트와 FollowClient 클라이언트 컴포넌트를 만든다
현재 유저를 보고 해당 유저가 지금 보이는 유저를 팔로우하는지 확인하는 부분은 서버 컴포넌트로 구현합니다
targetUserId를 Prop으로 받는 async 컴포넌트 FollowButton을 export하고
팔로우 여부를 구한 뒤 Child Component인 FollowClient에 해당 데이터를 넘겨준다

interface Props {
    targetUserId: string;
}

export default async function FollowButton({targetUserId}: Props) {
    const session = await getServerSession(authOptions);    

    const currentUserId = await prisma.user
        .findFirst({where: {email: session?.user?.email!}})
        .then((user) => user?.id!);

    const isFollowing = await prisma.follows.findFirst({
        where: {followerId: currentUserId, followingId: targetUserId}
    });

    return(<FollowClient targetUserId={targetUserId} isFollowing={!!isFollowing}) />;
}

!!isFollowing을 하여 boolean타입으로 변환하는 효과를 준다

DELETE (언팔로우)도 비슷한 느낌이지만, 대신 이 친구는 API endpoint가 DELETE /api/follow?targetuserId=targetUserId 같은 식으로 될 것이므로,

const targetUserId = req.nextUrl.searchParams.get('targetUserId');

를 이용한다.

/components/FollowButtons/FollowClient.tsx:
간단하게 버튼을 구현하면 이렇게 된다.

"use client";
interface Props{
    targetUserId: string,
    isFollowing: boolean
}

export default function FollowClient({targetUserId, isFollowing}: Props) {
    if(isFollowing) {
        return (
            <button onClick={unfollow}>
                {'Unfollow'}
            </button>
        );
    } else {
        return (
            <button onClick={follow}>
                {'follow'}
            </button>
        );
    }
}

하지만 이러면 눌렀을때 바로 버튼이 바뀌어야한다는 요구조건을 만족시키지 못한다.
이것을 위해 Next Router와 React의 useTransition() 훅(로딩상태를 확인하는 훅)을 이용한다. 또 useState() 훅도 이용하여 서버에서 값을 받아오는 중인지도 저장하도록하자.

  • useState() 훅 - useState() 훅을 사용하면 Stateful한 Value를 만들어, 변경될 때마다 여기에 의존하는 컴포넌트들이 자동으로 재렌더링되게할수있다.
    • 이 배열의 첫 번째는 UI에서 사용할 실제 값으로, 두 번째는 함수로 구성
export default function FollowClient({targetUserId, isFollowing}: Props) {
    const router = useRouter();
    const [isPending, startTransition] = useTransition();
    const [isFetching, setIsFetching] = useState(false);
    const isMutating = isFetching || isPending;

사용자가 버튼을 클릭했을 때 사용되는 event handler를 만든다.

startTransition()을 이용하면 현재 있는 라우트를 위해 서버에 새로운 요청을 만들 수 있다. 타겟 사용자가 현재 사용자를 팔로우하고 있는지에 대해 확인하여 페이지의 상태를 변화시키지 않고서도 seamlessly하게.

    const follow = async() => {
        setIsFetching(true);
        const res = await fetch('/api/follow', {
            method: "POST",
            body: JSON.stringify({targetUserId}),
            headers: {
                'Content-Type': 'application/json'
            }
        });
        setIsFetching(false);
        console.log(res);
        startTransition(() => {
            router.refresh();
        });
    };

업데이트 중일 때에는 버튼 표시를 다음과 같이...

    if(isFollowing) {
        return (
            <button onClick={unfollow}>
                {!isMutating ? 'Unfollow' : '...'}
            </button>
        );

이제 이렇게 만든 버튼 컴포넌트를 /app/users/[id]/page.tsx 같은 데 넣어주면 된다.

Next.js Server Actions

클라이언트 컴포넌트에 서버에서 구동되는 하나의 함수를 만들 수 있는 기능

이것을 사용하면, 굳이 form을 API 엔드포인트를 만들어서 처리하지 않아도, 마치 원시 고대 PHP처럼 한 파일에서 폼과 폼 처리용 코드가 같이 있게 만들 수 있다.

<?php
    echo 'Hello, ' + $_POST['name'];
?>
<form method="post">
    <input type="text" id="name" name="name"/>
    <input type="submit" />
</form>

(원시고대 PHP의 상상도, 요즘은 이렇게 안 합니다... 보통?)

하지만 옛날 PHP와 다른 점은, Next.js의 Actions는 페이지를 새로고침하지 않고 그냥 페이지 재렌더링하기 때문에 페이지 이동이 발생하지 않아 UX상에서 큰 차이를 보인다.

예제를 보자.
/app/dogs/[id]/edit/page.tsx:

export default async function EditPage({
    params,
}: {
    params: {id: string};
}) {
    const dog = await prisma.dog.findFirst({where: {id: params.id}});

    async function updateDog(formData: FormData) {
        "use server"; //Server-Side Endpoint
        await prisma.dog.update({where: {id: dog.id}, data: 
            {
                name: formData.get("title"),
                breed: formData.get("breed")
            }
        });
        revalidatePath(`/dogs/${params.id}/edit`);
    }
    return (
        <div className={styles.card}>
            <div className={styles.cardBody}>
                <h2>Edit {dog?.name}</h2>
                <form action={updateDog}>
                    <label>Name</label>
                    <input name="title" type="text" defaultValue={dog?.name} />
                    <label>Breed</label>
                    <input name="breed" type="text" defaultValue={dog?.breed} />
                    <button type="submit">save</button>
                </form>
            </div>
        </div>
    );
}

먼저 return 부분 JSX의 "form"을 보자.
onClick 이벤트가 아니라 <form action={updateDog}>을 쓰고 있는 것을 볼 수 있다. 이것은 해당 폼을 처리할 서버 사이드 함수의 이름이다.

이 함수는 "use server" directive를 맨 처음에 선언한다. 그러면 이 함수는 자동적으로 서버측 엔드포인트가 된다.

<form action={updateDog}>에서 보낸 폼은 자동으로 /dogs/[id]/edit로 가는 POST 요청이 된다.

이 POST 요청을 받는 것이 "use server"; 로 선언된 함수이다. 필요할 경우 이 함수에서 헤더, 쿠키 등에도 접근할 수 있다.

async function updateDog(formData: FormData) {
    "use server"; //Server-Side Endpoint
    await prisma.dog.update({where: {id: dog.id}, data: 
        {
            name: formData.get("title"),
            breed: formData.get("breed")
        }
    });
    revalidatePath(`/dogs/${params.id}/edit`);
}

prisma.dog.update() 를 통해 DB의 Dog 테이블을 업데이트해준 뒤, next-cache의 revalidatePath() 함수를 불러, 캐시를 버리고 (변경된 DB의 데이터를 이용해) 서버 컴포넌트를 재렌더링하도록 할 수 있다.

formAction

JSX의 <button>에 formAction 프로퍼티를 넣어서 아까와 다른 server function에서 해당 폼을 처리하도록 만들 수 있다.

예를 들어 "저장" 버튼 옆에 "저장하고 돌아가기" 같은 버튼을 만든다고 해 보자.

<button type="submit">save</button>
<button formAction={updateDogAndBack}>save and go back</button>

이후 새로운 서버 함수를 만들어준다.

async function updateDogAndBack(formData: FormData) {
    "use server"; //Server-Side Endpoint
    await prisma.dog.update({where: {id: dog.id}, data: 
        {
            name: formData.get("title"),
            breed: formData.get("breed")
        }
    });
    redirect(`/dogs/${params.id}`);
}

이렇게 하면 "save and go back" 버튼을 눌렀을 때 폼을 updateDogAndBack() 함수에서 처리하고, 그 결과 폼의 값으로 DB를 업데이트한 후 사용자는 /dogs/[id] 페이지로 이동된다.

외부에 있어도 된다

서버 액션이 꼭 서버 컴포넌트 안에 있어야 하는 것은 아니다. 외부 파일로 빼서 export 해 줄 수도 있다.

클라이언트 컴포넌트에서 서버 액션 사용

Actions 관련 프로퍼티는 폼 안에 있는 elements에서만 적용된다.
하지만 클라이언트 컴포넌트에서 서버 액션을 트리거할 수 없는 것은 아니다.

예를 들어
/app/dogs/[id]/actions.ts:

"use sever";

export async function like(dogid: string) {
    await prisma.update.likes({
        where: {id: dogid},
        data: {
            likes: {increment: 1}
        }
    });
    revalidatePath(`/dogs/${dogid}`);
}

export async function dislike(dogid: string) {
...

가 있다고 하자.

그리고, 클라이언트 컴포넌트 Likes.tsx은 useTransition() 훅을 이용해 블로킹 없이 상태 업데이트를 처리한다. 서버 액션을 실행한 뒤 next.js router가 서버 컴포넌트를 리로딩하고, 상태를 업데이트하는 모든 작업을 한번에 할 수 있다는 것이다.

"use client";
import {like, dislike} from './actions';

export default function Likes({id}: any) {
    let [isPending, startTransition] = useTransition();

    return(
        <div>
            <button onClick={() => startTransition(() => like(id))}>like</button>
            <button onClick={() => startTransition(() => dislike(id))}>dislike</button>
        </div>
    );
}

이제 이 버튼을 클릭하면 저 서버 액션 함수가 호출되는 것은 물론, 클라이언트 컴포넌트를 포함한 서버 컴포넌트까지 (이 경우 like 카운터) 전부 갱신되는 것을 확인할 수 있다.

구버전에서는 useEffect()로 삽질했어야 했을 것을 이제 서버 액션을 통해 거의 모두 해결할 수 있다.

Optimistic Update

현재 구현에서는 서버에서 값을 받아와야 비로소 카운터가 업데이트된다. Optimistic Update를 통해 일단 버튼을 누르면 카운터를 바꾸고, 서버에서 응답이 오면 그때 다시 카운터를 서버의 값으로 동기화하도록 해 보자.

이를 위해 체-신 useOptimistic() 훅을 이용한다.

"use client";

"use client";
import {like, dislike} from './actions';

export default function Likes({LikeCount, id}: any) {
    const [optimisticLikes, addOptimisticLike] = useOptimistic(
        {likeCount, sending: false},
        (state, newLikeCount) => ({
            ...state,
            likeCount: newLikeCount,
            sending: true,
        })
    );

    return(
        <div>
            <div>
            OptimisticLikes: {optimisticLikes.likeCount}
            {optimisticLikes.sending? "Sending": ""}
            <button onClick={async () => addOptimisticLike(optimisticLikes.likeCount + 1)}>like</button>
            <button onClick={async () => addOptimisticLike(optimisticLikes.likeCount - 1)}>dislike</button>
        </div>
    );
}
반응형