[#1 - Server Action]
Server Action(서버 액션)
서버 액션이란 브라우저 측에서 호출할 수 있는 서버에서 실행되는 비동기 함수를 의미한다. 서버 액션을 활용하면 별도의 API 호출 없이도 간단한 함수 하나만으로 NextJS 서버측에서 실행되는 함수를 직접 호출이 가능해지는 것이다.
서버 액션은 우리가 form 양식을 사용할 때 유용하게 사용할 수 있다. 우리가 간단한 form 양식을 통해 서버측에 데이터를 전달하거나 전달한 데이터를 받아서 사용하기에 굉장히 편리한 방법이다. 세부적으로 살펴보자.
form action
우리가 사용하는 form 태그에는 기본적으로 action이라는 속성이 존재한다. 이 속성은 form 태그 내부의 요소들에 입력된 값들을 처리하는 방식을 의미한다. 따라서 데이터를 처리하는 로직, 즉 서버측에 데이터를 전송하는 로직을 만든 후 form 태그의 action 속성에 부여해주면 form 태그 내부의 데이터들은 formData 형식으로 서버측으로 전달된다.
"use server"
우리는 이전에 Client Component로 사용하고자 하는 컴포넌트 파일의 최상단에 "use client"라는 지시자를 적어주어야 한다는 점을 공부했다. 마찬가지로 Server Action을 적용하고자 하는 함수 로직에는 "use server"라는 지시자를 붙여주어야 NextJS가 Server Action으로 인식하고 그에 맞게 동작할 수 있게 된다.
formData: FormData
우리가 form 양식을 통해 전달한 데이터들은 formData 형식으로 담겨져 서버측에 전달된다고 앞서 살펴보았다. formData 내부에 어떤 형식으로 데이터가 포함되고 해당 데이터들을 어떻게 가져와서 사용하는지에 대해서는 뒤에 살펴볼 예정이다. 참고해야할 점은 TSX를 사용하는 경우 formData의 타입을 지정해주어야 하는데 타입은 간단하게 JS에서 제공해주는 FormData라는 타입을 지정해주면 된다.
아래 코드 예시를 살펴보면서 Server Action은 어떻게 구성되어 있으며 사용 방법과 데이터 전달 및 호출 구조를 살펴보도록 하자.
export default function Page(){
const getData = async (formData: FormData) => {
"use server"
const name = formData.get("name");
const content = formData.get("content");
}
return (
<form action={getData}>
<input name="name" placeholder="Your Name" />
<input name="content" placeholder="Content" />
<button type="submit">Submit</button>
</form>
)
}
코드를 살펴보면 Page 컴포넌트 내부에 getData라는 서버 액션을 취하는 함수가 존재한다. 이어서 Page 컴포넌트가 반환하는 UI는 input 태그와 button 태그로 구성되어 있는 form 양식이다. 따라서 form 양식의 input 태그에 값을 입력하고 submit 타입의 버튼을 눌러 양식을 제출하면 getData가 Server Action으로서 실행되며 formData에는 사용자가 input 태그에 입력한 값들이 담겨서 전송되게 된다.
또한 formData.get() 메서드를 통해 formData 내부의 원하는 키워드를 입력하여 데이터 값을 받아오는 동작도 가능하다. 주의해야할 부분은 Server Action은 이름에도 나와있듯이 서버측에서 실행되는 함수이기 때문에 콘솔창에 데이터를 입력해보아도 브라우저에는 출력되지 않는다는 점을 참고해야 한다. 실제로 데이터를 입력해보고 받아오는 과정을 살펴보자.
각각의 사진은 Server Action 구현 로직과 Server Action을 통해 받아온 formData의 모습이다. 이와 같이 Server Action을 사용하면 별도의 API를 생성할 필요없이 NextJS 서버측과 간단한 함수 하나만으로 쉽게 데이터 통신이 가능하다는 장점이 있다.
추가적으로 타입 관련해서 참고할 점이 있다. formData.get 메서드를 통해 받아온 데이터를 변수에 담아서 타입 추론 결과를 살펴보면 const name: FormDataEntryValue | null 과 같이 FormDataEntryValue와 null의 Union Type으로 추론이 되는 것을 볼 수 있다. 이 때 FormDataEntryValue는 string이나 file 타입을 의미한다. 따라서 우리가 받아오는 데이터들의 타입은 일반적으로 string 타입일 것이기 때문에 데이터를 담아놓은 변수에 문자형태로 담아주기 위해 toString 메서드를 사용하거나 String()을 활용하여 문자열로 바꾸어주는 것이 필요하다.
정리하자면 Server Action은 보다 간결한 코드로 구현이 가능하다는 장점과 Client Component로 컴포넌트를 생성하여 별도의 API 호출을 할 필요없이 서버측에서만 실행을 하여 보안상으로도 안전하고 편리하게 사용할 수 있다는 장점이 있기에 사용한다.
revalidatePath
앞서 살펴 본 Server Action으로 동작하는 로직 내에는 revalidatePath라는 속성을 추가할 수 있다. 이 속성은 서버 액션을 통해 업데이트 된 요소들을 즉각적으로 최신화하여 브라우저를 통해 출력해주는 역할을 한다.
간단한 상황을 예시로 들어보자.
게시글에 댓글을 달 수 있는 기능을 서버 액션을 통해 구현해 두었다고 가정하자. 폼 양식을 통해 댓글을 달고 서버 액션을 통해 formData를 NextJS 서버 측에 전송하였고 그 값을 받아와서 게시글의 하단에 출력되도록 동작해야 한다. 하지만 데이터 베이스에는 올바르게 등록되었지만 전송한 댓글이 실시간으로 화면에 업데이트 되지 않는 상황이 발생하였다. 이 때 사용하는 속성이 revalidatePath 속성이다.
revalidatePath 속성의 정확한 역할은 인자로 주어진 경로의 페이지를 재검증하는 것이다. 이 속성은 인자를 받는데 인자값은 재검증하고자 하는 즉, 데이터 업데이트를 위해 다시 렌더링 해야하는 페이지의 경로이다. 조금 자세하게 설명을 보태자면 서버 액션에서 최초로 데이터 전송을 위해 호출한 API 주소에 인자값으로 주어진 parameter 값을 추가하여 해당 페이지를 재검증하기 위해 재렌더링을 실행하는 것이다.
속성에 대해서는 알아보았으니 주의해야 할 점에 대해서 알아보자.
가장 주의해야 할 부분은 바로 캐싱 무효화이다. 우리는 이전에 Data Caching, Request Memoization, Full Route Cache 등 프로젝트의 빌드 과정에서 호출한 데이터 값이나 캐싱 되어있던 데이터들로 완성된 페이지 자체를 캐싱해두는 등 다양한 캐싱 방법에 대해서 공부한 바 있다. 하지만 서버 액션의 revalidatePath 속성을 통해서 페이지 재검증을 하게 되면 기존에 저장되어 있던 모든 캐시 값들은 무효화 처리되어 사라지게 된다. 주의해야 할 점인 부분치고는 어떻게 보면 당연한 이야기이기도 하다. 캐싱 되어있는 데이터를 사용하면 페이지의 렌더링이 빠르다는 장점이 있지만 데이터가 업데이트 되었는데 캐싱된 데이터를 사용한다는 것은 아무 의미가 없다. 따라서 캐싱 데이터를 최신화 해주어야 하기 때문에 기존의 캐싱 데이터들을 삭제하고 새롭게 캐싱하게 된다는 것은 당연하다고 볼 수 있다.
그에 더해 추가적으로 주의해야 할 점이 있다. 중간 정리를 한 번 하자면 초기 프로젝트 빌드 시에 저장되어있던 Full Route Cache, Data Cache 등 해당 페이지의 캐시 값들은 서버 액션의 revalidatePath를 통해 페이지 재검증이 실행되면 모두 초기화 된다. 이 때 재검증을 위해 서버 측에 새로 데이터를 요청하여 받아오는데 그 데이터를 Data Cache에 새롭게 저장하게 된다. 여기서 주의해야 할 점이 나오는데 Data Cache 값은 초기 재검증 시에 새롭게 캐싱되지만 Full Route Cache 즉, 페이지 전체는 캐싱되지 않는다는 점을 주의해야 한다. 초기 재검증이 실행되고 난 직후에는 Data Cache만 실행되고 Full Route Cache는 재검증이 일어나고 이후에 다시 해당 페이지에 접속 요청이 들어왔을 경우 Full Route Cache가 실행되어 페이지 전체를 캐싱할 수 있게 된다. revalidatePath 속성에 대한 설명을 들었으니 코드 구성이 어떻게 되어있는지 살펴보도록 하자. 서버 액션이 취해지는 로직 내에 속성만 넣어주면 된다.
위의 코드를 버면 try-catch 문 내부의 마지막에 revalidatePath 속성이 추가된 것을 볼 수 있다.
이 때 나는 재검증하는 방식에 대해 의문점을 가졌다. 초기에 서버 액션을 통해 데이터 요청을 보낸 API 주소 값에 revalidatePath 속성에 인자로 주어진 경로를 추가한 페이지를 외부 API 요청을 하여 재검증하는 방식인걸까? 잘못 생각하던 내용을 정리하자면 다음과 같다.
서버 액션을 통한 최초 요청을 보낸 API : "URL/review"
revalidatePath를 통해 재검증을 하고자 하는 페이지의 API : "URL/review/book/${bookId}"
하지만 틀렸다. revalidatePath의 인자로 주어진 값을 더하여 외부 API로 요청을 보내는 것이 아닌 revalidatePath는 주어진 경로에 포함된 캐시 값을 초기화하는 캐시 무효화 기능만 갖고 있는 것이었다. 따라서 revalidatePath는 캐시만 지워주는 역할을하고 해당 경로에 캐싱 된 데이터 값이 없다는 것은 NextJS가 자체적으로 인식하여 캐시 무효화가 진행된 후 다음 요청이 발생했을 때 기존에 요청 보냈던 경로로 다시 fetch 요청을 보내는 것이다.
다양한 재검증 방식
앞 단락에서 revalidatePath를 통해 특정 주소에 해당하는 페이지 재검증에 대해서 알아보았다. 하지만 공식 문서에도 나와있듯이 revalidatePath는 1-2개의 인자를 받는다고 나와있다. 첫번째 인자는 재검증을 위한 페이지의 경로를 넣고 선택 사항인 두번째 인자에는 선택적인 속성값을 넣어주면 되는데, 재검증의 방식에는 크게 5가지로 나뉘어져있다. 5가지를 차례로 살펴보자.
1. 특정 주소에 해당하는 페이지만 재검증
- 우리가 앞 단락에서 살펴본 방식이 바로 특정 주소 페이지 재검증 방식이다. 이 방식은 하나의 인자를 받으며 인자값은 재검증을 하고자 하는 페이지의 경로를 넣어주면 된다. 사용 방법은 다음과 같다.
revalidatePath(`/book/${bookId}`);
2. 특정 경로의 모든 동적 페이지를 재검증
- 이 방식은 경로를 입력한다기보다 프로젝트 내에서 동적 경로를 갖는 모든 페이지 즉, 동적 경로에 해당하는 폴더를 넣어준다고 이해하는 것이 좋다. 첫번째 인자로 재검증을 하고자 하는 동적 경로에 대한 폴더를 넣어주고 두번째 인자로 "page"라는 값을 넣어주면 첫번째 인자로 구성된 동적 경로로 접근 가능한 모든 동적 페이지들이 모두 재검증이 된다. 사용 방법은 다음과 같다.
revalidatePath("/book/[id]","page");
3. 특정 레이아웃을 갖는 모든 페이지를 재검증
- 말 그대로 특정 레이아웃을 갖고 있는 모든 하위 페이지들을 모두 재검증하는 방식이다. App Router 버전에서 layout 파일을 page 파일과 동일 경로에 위치시키면 해당 페이지와 하위 경로의 페이지들은 모두 같은 레이아웃 파일이 적용된다는 점을 우리는 알고 있다. 따라서 특정 레이아웃을 갖고 있는 모든 페이지들을 재검증하기 위해 사용하는 방식이며 첫번째 인자로는 위의 동적 페이지 재검증 방식과 비슷하게 특정 레이아웃을 갖고 있는 폴더명을 넣어주고 두번째 인자로는 "layout"이라는 값을 넣어주면 된다. 사용 방법은 다음과 같다.
revalidatePath("/(Home)","layout");
4. 모든 데이터 재검증
- 위의 특정 레이아웃 재검증 방식의 확장판 느낌으로 프로젝트 내의 모든 데이터를 재검증하고자 하는 경우에는 첫번째 인자로 전체 레이아웃에 해당하는 index 경로를 넣어주고 두번째 인자로 "layout" 값을 넣어주면 된다. 사용 방법은 다음과 같다.
revalidatePath("/","layout");
5. 태그 기준, 데이터 캐시 재검증
- 이 방식은 앞서 Data Cache에서 다루었던 fetch method의 캐싱 설정 값 중에 하나인 {next : {tag : ['a']}} 설정값에 해당하는 재검증 방식이다. Data Cache를 설정하는 과정에서 태그 값을 설정하여 특정 데이터 캐시값을 명시해주는 방법이 있었는데 그 태그 값을 그대로 가져와서 첫번째 인자값으로 넣어주는 것이다. 여기서 주의해야할 점은 태그 기준으로 재검증을 요청할 때에는 revalidatePath가 아닌 revalidateTag로 변경하여 사용하여야 한다. 사용 방법을 Data Cache 로직과 함께 살펴보자.
// fetch Logic
async function getData({params}:{params:string}) {
const response = await fetch(`URL/${id}`,{next: {tags: [`review-${id}`]}});
if(!response.ok) return;
const data: Data = await response.json();
}
// revalidateTag
"use server"
import { revalidatePath } from "next/cache";
export default async function createReviewAction(formData:FormData){
try {
const response = await fetch("URL/review",{
method: "POST",
body: JSON.stringify({id})
})
revalidateTag(`review-${id}`);
} catch (error) {
console.error(error);
return;
}
}
위와 같이 Data Cache에 설정해두었던 tag 값을 그대로 가져와서 revalidateTag의 인자로 넣어주면 해당 데이터만 재검증을 요청할 수가 있게 된다. 이 방식을 사용하면 1번 방식의 전체 페이지의 캐싱값을 모두 무효화 시키는 것이 아닌 특정 데이터의 캐시만 무효화를 시킬 수 있기에 더 효율적이고 경제적으로 재검증을 할 수 있다.
Server Action in Client Component
서버 액션이 동작하는 컴포넌트에서 UX를 개선하기 위해서 해당 컴포넌트를 Client Component로 전환하여 사용자의 동작에 맞게 예외 처리나 피드백을 해주는 작업을 처리할 수 있다. 그러기 위해서는 서버 액션이 동작하며 Server Component로 동작하던 컴포넌트르 Client Component로 전환한 뒤 뒤에 나오는 훅을 사용해서 개선해보자.
useActionState
React 19부터 추가된 Hook으로 form 양식의 상태값을 다양한 방식으로 다룰 수 있도록 도와주는 훅이다. 이 훅은 2-3개의 인자를 받고 3개의 값을 배열 형태로 반환해준다. 첫번째 인자 값은 Server Action 함수를 받고 두번째 인자 값으로는 상태의 초기값을 받는다. 이어서 반환값은 각각 상태값 state, formAction 함수, 로딩 상태를 의미하는 isPending으로 구성된다. 사용하는 방식을 간단하게 코드로 살펴보면 다음과 같다.
const [state, formAction, isPending] = useActionState(ServerAction, null);
현재 Server Action 함수를 사용하고 있는 form 양식의 form 태그의 action 속성값을 기존의 Server Action 함수가 아닌 useActionState로부터 받은 formAction 값을 넣어주어야 한다. 그렇게 되면 form 양식이 제출되었을 때 useActionState 훅이 자동적으로 Server Action을 동작하게 하여 자동으로 상태값 state와 현재 상태인 isPending 값까지 관리를 해주게 된다.
state
useActionState로 인해 Server Action이 실행되고 난 후 반환값을 state 값으로 저장하게 되는 것이므로 Server Action 함수에서 반환값을 어떻게 구성하느냐에 따라서 로직이 달라지게 된다. 여기서는 간단하게 반환받은 state 값을 데이터 호출의 성공 여부인 boolean의 status 값과 에러 문구가 문자열 형태로 포함된 error 값을 객체 형태로 설정해두겠다.
따라서 서버 액션 함수 내에서 데이터 호출의 성공 여부에 따라서 state의 값을 {status: true, error: ""}이거나 {status: false, error: "데이터 호출에 실패했습니다"} 등으로 반환받게 하여 state의 값을 이용하여 서버 액션 함수가 아닌 useActionState 훅을 사용하고 있는 컴포넌트 내에서 예를 들어 useEffect를 사용하여 state의 error 문구를 alert으로 출력해준다던지 별도의 에러 문구를 발생시켜주는 방식으로 사용이 가능하다.
isPending
isPending은 현재 서버 액션의 동작 상태에 대한 값이 들어간다. 현재 서버 액션을 통해 form 양식이 제출중이라면 false 값을 반환하고, 동작이 완료되었다면 성공의 유뮤와는 상관없이 true 값이 반환된다. 따라서 isPending 값을 통해서 사용자의 버튼 중복 클릭, 양식 제출 중 중복 전송 방지 등 예외 처리를 보다 쉽게 해 줄 수 있다는 장점이 있다.
대표적인 방법으로는 HTML 태그 내에 disabled 속성의 값에 isPending 값을 넣어 양식이 제출중일 경우에는 요소들의 모든 동작을 막아주는 방법이 있다. 또한 isPending 값이 true인 경우에 로딩 화면이나 아이콘 같은 것을 출력해주는 것도 UX적인 측면으로 많은 개선이 된다.
결론적으로 사용자가 입력하는 값에 따라서 다양한 상황이 발생하는 Form 양식을 사용하는 경우에는 중복 제출 방지, 에러 핸들링 등 다양한 요구 사항을 충족하기 위해 useActionState 훅을 적극적으로 사용하는 것을 권장한다. 하지만 현재 우리가 사용하고 있는 NextJS 15 버전과 React 19 버전은 둘 다 매우 최신 버전이기 때문에 약간의 오류가 조금씩 발생한다고 한다. 따라서 주의하여 사용하면 될 듯하다.
'NextJS' 카테고리의 다른 글
NextJS[NextJS v.13~] - (6) Parallel Route (2) | 2024.11.29 |
---|---|
NextJS[NextJS v.13~] - (4) Streaming (4) | 2024.11.01 |
NextJS[NextJS v.13~] - (3) Page Caching (3) | 2024.10.11 |
NextJS[NextJS v.13~] - (2) Data Fetching on App Router (1) | 2024.10.05 |
[NextJS v.13~] - (1) App Router (1) | 2024.09.26 |