Next.js (7. Mutating Data) 정리
0. 데이터 변경
Next.js에서 데이터를 변경할 때는 React Server Functions를 사용한다.
Server Function은 서버에서 실행되는 비동기 함수로, 클라이언트에서 네트워크 요청을 통해 호출할 수 있다.
1. Server Function이란?
1-1. Server Function / Server Action 개념
Server Function은 서버에서 실행되는 비동기 함수다. 클라이언트에서 네트워크 요청을 통해 호출하기 때문에 반드시 async 함수여야 한다.
action 또는 데이터 변경 맥락에서는 Server Action이라고도 부른다. Server Action은 관례적으로 startTransition과 함께 사용하는 async 함수를 뜻한다.
아래 경우에는 startTransition이 자동으로 적용된다.
<form>의actionprop에 전달된 경우<button>의formActionprop에 전달된 경우
Action이 실행되면 Next.js는 단일 서버 왕복(server roundtrip)으로 업데이트된 UI와 새 데이터를 함께 반환할 수 있다. 내부적으로 Action은 POST 메서드를 사용하며, 오직 이 HTTP 메서드로만 호출된다.
보안 주의:
Server Function은 직접 POST 요청으로 접근이 가능하다.
애플리케이션 UI를 통해서만 호출되는 것이 아니므로, 모든 Server Function 내부에서 인증(authentication)과 인가(authorization)를 반드시 검증해야 한다.
Server Function vs Server Action:
Server Action은 Server Function의 특정 활용 방식(폼 제출 및 데이터 변경 처리)이다.
Server Function이 더 넓은 개념이다.
1-2. Server Function 정의하기 — "use server"
"use server"를 추가해 Server Function을 정의한다. 함수 본문 최상단에 추가하면 해당 함수만 Server Function이 되고, 파일 최상단에 추가하면 파일의 모든 export가 Server Function이 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// app/lib/actions.ts
import { auth } from '@/lib/auth'
export async function createPost(formData: FormData) {
'use server'
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
const title = formData.get('title')
const content = formData.get('content')
// 데이터 변경
// 캐시 재검증
}
export async function deletePost(formData: FormData) {
'use server'
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
const id = formData.get('id')
// 리소스 소유권 확인 후 삭제
// 데이터 변경
// 캐시 재검증
}
Server Component에서 인라인으로 정의하는 방법:
1
2
3
4
5
6
7
8
9
10
// app/page.tsx
export default function Page() {
async function createPost(formData: FormData) {
'use server'
// ...
}
return <></>
}
참고:
Server Component는 기본적으로 점진적 향상(Progressive Enhancement)을 지원한다.
JavaScript가 아직 로드되지 않았거나 비활성화된 상태에서도 Server Action을 호출하는 폼은 제출된다.
Client Component에서는 Server Function을 직접 정의할 수 없다.
대신 "use server"가 선언된 별도 파일에서 import해 사용한다.
1
2
3
4
5
// app/actions.ts
'use server'
export async function createPost() {}
1
2
3
4
5
6
7
8
9
// app/ui/button.tsx
'use client'
import { createPost } from '@/app/actions'
export function Button() {
return <button formAction={createPost}>Create</button>
}
Action을 Client Component의 prop으로 전달하는 것도 가능하다.
1
<ClientComponent updateItemAction={updateItem} />
1
2
3
4
5
6
7
8
9
10
11
// app/client-component.tsx
'use client'
export default function ClientComponent({
updateItemAction,
}: {
updateItemAction: (formData: FormData) => void
}) {
return <form action={updateItemAction}>{/* ... */}</form>
}
2. Server Function 호출하기
2-1. Form에서 호출
React는 HTML <form>을 확장해 action prop에 Server Function을 직접 전달할 수 있도록 지원한다.
폼에서 호출되면 함수는 자동으로 FormData 객체를 인자로 받는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// app/ui/form.tsx
import { createPost } from '@/app/actions'
export function Form() {
return (
<form action={createPost}>
<input type="text" name="title" />
<input type="text" name="content" />
<button type="submit">Create</button>
</form>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app/actions.ts
'use server'
import { auth } from '@/lib/auth'
export async function createPost(formData: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
const title = formData.get('title')
const content = formData.get('content')
// 데이터 변경
// 캐시 재검증
}
2-2. 이벤트 핸들러에서 호출
Client Component에서 onClick 같은 이벤트 핸들러를 통해 Server Function을 호출할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// app/like-button.tsx
'use client'
import { incrementLike } from './actions'
import { useState } from 'react'
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes)
return (
<>
<p>Total Likes: {likes}</p>
<button
onClick={async () => {
const updatedLikes = await incrementLike()
setLikes(updatedLikes)
}}
>
Like
</button>
</>
)
}
2-3. useEffect에서 호출
컴포넌트가 마운트되거나 의존성이 변경될 때 Server Function을 자동으로 실행하려면 useEffect를 사용한다.
앱 단축키(onKeyDown), 무한 스크롤(intersection observer), 조회수 업데이트 같은 경우에 유용하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// app/view-count.tsx
'use client'
import { incrementViews } from './actions'
import { useState, useEffect, useTransition } from 'react'
export default function ViewCount({ initialViews }: { initialViews: number }) {
const [views, setViews] = useState(initialViews)
const [isPending, startTransition] = useTransition()
useEffect(() => {
startTransition(async () => {
const updatedViews = await incrementViews()
setViews(updatedViews)
})
}, [])
return <p>Total Views: {views}</p>
}
3. 활용 패턴
3-1. 로딩 상태 표시 — useActionState
Server Function 실행 중 로딩 인디케이터를 표시하려면 useActionState 훅의 pending 값을 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/ui/button.tsx
'use client'
import { useActionState, startTransition } from 'react'
import { createPost } from '@/app/actions'
import { LoadingSpinner } from '@/app/ui/loading-spinner'
export function Button() {
const [state, action, pending] = useActionState(createPost, false)
return (
<button onClick={() => startTransition(action)}>
{pending ? <LoadingSpinner /> : 'Create Post'}
</button>
)
}
3-2. 데이터 새로고침 — refresh()
변경 후 현재 페이지를 최신 상태로 갱신하려면 next/cache의 refresh()를 호출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app/lib/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { refresh } from 'next/cache'
export async function updatePost(formData: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// 데이터 변경
refresh()
}
refresh()는 클라이언트 라우터를 갱신해 UI가 최신 상태를 반영하게 한다.
단, 태그가 지정된 데이터를 재검증하지는 않는다. 태그 기반 재검증이 필요하면revalidateTag를 사용한다.
3-3. 캐시 재검증 — revalidatePath / revalidateTag
데이터 변경 후 Next.js 캐시를 무효화하고 최신 데이터를 표시하려면 revalidatePath 또는 revalidateTag를 호출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app/lib/actions.ts
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
'use server'
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// 데이터 변경
revalidatePath('/posts')
}
| 함수 | 용도 |
|---|---|
revalidatePath('/posts') | 특정 경로의 캐시를 무효화 |
revalidateTag('tag-name') | 특정 태그가 붙은 캐시를 무효화 |
3-4. 변경 후 리다이렉트 — redirect()
데이터 변경 후 다른 페이지로 이동시키려면 next/navigation의 redirect()를 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app/lib/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
// 데이터 변경
revalidatePath('/posts')
redirect('/posts')
}
redirect()는 프레임워크가 처리하는 제어 흐름 예외를 던진다.
호출 이후의 코드는 실행되지 않으므로, 최신 데이터가 필요하다면revalidatePath/revalidateTag를 먼저 호출해야 한다.
3-5. 쿠키 처리 — cookies()
Server Action 안에서 next/headers의 cookies() API를 사용해 쿠키를 읽고, 설정하고, 삭제할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app/actions.ts
'use server'
import { cookies } from 'next/headers'
export async function exampleAction() {
const cookieStore = await cookies()
// 쿠키 읽기
cookieStore.get('name')?.value
// 쿠키 설정
cookieStore.set('name', 'Delba')
// 쿠키 삭제
cookieStore.delete('name')
}
Server Action에서 쿠키를 설정하거나 삭제하면 Next.js가 현재 페이지와 레이아웃을 서버에서 다시 렌더링해 UI가 새 쿠키 값을 즉시 반영한다. 리렌더링되는 컴포넌트의 클라이언트 상태는 유지되며, 의존성이 변경된 effect는 다시 실행된다.
