이전 장에서는 URL 검색 매개변수 및 Next.js API를 사용하여 검색 및 페이지네이션을 구현했다. 송장 생성, 업데이트 및 삭제 기능을 추가하여 송장 페이지 작업을 계속해 보자.
이번 장에서 다룰 내용은 아래와 같다.
- React Server Actions가 무엇이며 이를 사용하여 데이터를 변경하는 방법.
- 양식 및 서버 컴포넌트로 작업하는 방법.
- 유형 유효성 검사를 포함하여 기본 formData 개체 작업에 대한 모범 사례.
- revalidatePath API를 사용하여 클라이언트 캐시를 재검증하는 방법.
- 특정 ID를 사용하여 동적 경로 세그먼트를 만드는 방법.
- 낙관적 업데이트를 위해 React의 useFormStatus 후크를 사용하는 방법.
서버 액션이란?
React Server Actions를 사용하면 서버에서 직접 비동기 코드를 실행할 수 있다. 데이터를 변경하기 위해 API 엔드포인트를 생성할 필요가 없다. 대신, 서버에서 실행되고 클라이언트 또는 서버 컴포넌트에서 호출될 수 있는 비동기 함수를 작성한다.
웹 애플리케이션은 다양한 위협에 취약할 수 있으므로 보안이 최우선이다. 서버 액션이 필요한 곳이 보안을 적용하기에 알맞은 곳이다. 서버 액션은 효과적인 보안 솔루션을 제공하여 다양한 유형의 공격으로부터 보호하고 데이터를 보호하며 승인된 액세스를 보장한다. 서버 액션은 POST 요청, 암호화된 폐쇄, 엄격한 입력 확인, 오류 메시지 해싱 및 호스트 제한과 같은 기술을 함께 작동하여 앱의 안전성을 크게 향상시킨다.
서버 액션과 함께 폼 사용
React에서는 <form> 엘리먼트의 action 속성을 사용하여 액션을 호출할 수 있다. 작업은 캡처된 데이터가 포함된 기본 FormData 개체를 자동으로 수신한다.
예를 들면:
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
서버 컴포넌트 내에서 서버 액션을 호출하면 클라이언트에서 JavaScript가 비활성화된 경우에도 양식이 작동한다.
서버 액션이 포함된 Next.js
서버 액션은 Next.js 캐싱과도 긴밀하게 통합된다. 서버 액션을 통해 양식이 제출되면 작업을 사용하여 데이터를 변경할 수 있을 뿐만 아니라 revalidatePath 및 revalidateTag와 같은 API를 사용하여 관련 캐시의 유효성을 다시 검사할 수도 있다.
송장 만들기
새 송장을 생성하기 위해 수행할 단계는 다음과 같다.
- 사용자의 입력을 캡처하는 폼을 만든다.
- 서버 액션을 만들고 폼에서 호출한다.
- 서버 액션 내에서 formData 개체에서 데이터를 추출한다.
- 데이터베이스에 삽입할 데이터를 검증하고 준비한다.
- 데이터를 삽입하고 오류를 처리한다.
- 캐시를 재검증하고 사용자를 송장 페이지로 다시 리디렉션한다.
1. 새 경로 및 양식 만들기
시작하려면 /invoices 폴더 내에서 page.tsx 파일을 사용하여 /create라는 새 경로 세그먼트를 추가한다.
이 경로를 사용하여 새 송장을 생성한다. page.tsx 파일 내에 다음 코드를 붙여넣은 후 잠시 지켜보자.
/dashboard/invoices/create/page.tsx
import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page() {
const customers = await fetchCustomers();
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Create Invoice',
href: '/dashboard/invoices/create',
active: true,
},
]}
/>
<Form customers={customers} />
</main>
);
}
여러분의 페이지는 고객을 가져와 <Form> 컴포넌트에 전달하는 서버 컴포넌트이다. 시간을 절약하기 위해 <Form> 컴포넌트가 이미 생성되어 있다.
<Form> 컴포넌트로 이동하면 다음과 같은 양식이 표시된다.
- 고객 목록이 포함된 <select>(드롭다운) 엘리먼트가 하나 있다.
- type="number"인 금액에 대해 하나의 <input> 엘리먼트가 있다.
- type="radio"인 상태에 대한 두 개의 <input> 엘리먼가 있다.
- type="submit"인 버튼이 하나 있다.
http://localhost:3000/dashboard/invoices/create에 다음 UI가 표시되어야 한다.
2. 서버 액션 생성
이제 양식이 제출될 때 호출될 서버 액션을 만들어 보자.
lib 디렉터리로 이동하여 actions.ts라는 새 파일을 만든다. 이 파일 상단에 'use server' 지시문(directive)을 추가하자.
'use server'을 추가하면 파일 내에서 내보낸 모든 기능을 서버 기능으로 표시한다. 그런 다음 이러한 서버 기능을 클라이언트 및 서버 컴포넌트로 가져올 수 있으므로 매우 다양하게 사용할 수 있다.
액션 내부에 "use server"을 추가하여 서버 컴포넌트 내에서 직접 서버 액션을 작성할 수도 있다. 하지만 이 과정에서는 모든 항목을 별도의 파일에 정리하여 보관하겠다.
/app/lib/action.ts
'use server';
export async function createInvoice(formData: FormData) {}
그런 다음 <Form> 컴포넌트의 actions.ts 파일에서 createInvoice를 가져온다. <form> 엘리먼트에 작업 속성을 추가하고 createInvoice 작업을 호출다.
/app/ui/invoices/create-form.tsx
'use client';
import { customerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice } from '@/app/lib/actions';
export default function Form({
customers,
}: {
customers: customerField[];
}) {
return (
<form action={createInvoice}>
// ...
)
}
팁: HTML에서는 URL을 action 속성에 전달한다. 이 URL은 폼 데이터를 제출해야 하는 대상(일반적으로 API 엔드포인트)이다.
그러나 React에서 action 속성은 특별한 prop으로 간주된다. 즉, React가 그 위에 액션을 호출할 수 있도록 빌드된다는 의미이다.
배후에서 서버 액션은 POST API 엔드포인트를 생성한다. 이것이 바로 서버 액션을 사용할 때 API 엔드포인트를 수동으로 생성할 필요가 없는 이유이다.
3. formData에서 데이터 추출
actions.ts 파일로 돌아가서 formData의 값을 추출해야 하며 사용할 수 있는 몇 가지 방법이 있다. 이 예에서는 .get(name) 메서드를 사용해 보겠다.
/app/lib/actions.ts
'use server';
export async function createInvoice(formData: FormData) {
const rawFormData = {
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
};
// Test it out:
console.log(rawFormData);
}
팁: 필드가 많은 폼으로 작업하는 경우 JavaScript의 Object.fromEntries()와 함께 Entries() 메서드를 사용하는 것이 좋다.
예를 들어:
const rawFormData = Object.fromEntries(formData.entries())
모든 것이 올바르게 연결되었는지 확인하려면 폼을 사용해 보자. 제출한 후에는 터미널에 로그인된 양식에 방금 입력한 데이터가 표시되어야 한다.
이제 데이터가 개체 형태이므로 작업하기가 훨씬 더 쉬워진다.
4. 데이터 검증 및 준비
폼 데이터를 데이터베이스로 보내기 전에 올바른 형식과 올바른 유형인지 확인하려고 한다. 과정 앞부분에서 배운 내용을 기억하면 송장 테이블에 다음 형식의 데이터가 필요하다는 것을 알 수 있다.
/app/lib/definitions.ts
export type Invoice = {
id: string; // Will be created on the database
customer_id: string;
amount: number; // Stored in cents
status: 'pending' | 'paid';
date: string;
};
지금까지는 양식에 customer_id, 금액, 상태만 표시되어 있다.
유형 검증 및 강제
폼의 데이터가 데이터베이스의 예상 유형과 일치하는지 확인하는 것이 중요하다. 예를 들어 작업 내에 console.log를 추가하는 경우:
console.log(typeof rawFormData.amount);
금액이 숫자가 아닌 문자열 유형임을 알 수 있다. 이는 type="number"인 입력 엘리먼트가 실제로 숫자가 아닌 문자열을 반환하기 때문이다!
유형 유효성 검사를 처리하기 위한 몇 가지 옵션이 있다. 유형을 수동으로 검증할 수 있지만 유형 검증 라이브러리를 사용하면 시간과 노력을 절약할 수 있다. 귀하의 예에서는 이 작업을 단순화할 수 있는 TypeScript 우선 유효성 검사 라이브러리인 Zod를 사용하겠다.
actions.ts 파일에서 Zod를 가져오고 양식 개체의 모양과 일치하는 스키마를 정의한다. 이 스키마는 데이터베이스에 저장하기 전에 formData의 유효성을 검사한다.
/app/lib/actions.ts
'use server';
import { z } from 'zod';
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
// ...
}
금액 필드는 해당 유형을 검증하는 동시에 문자열을 숫자로 강제(변경)하도록 특별히 설정된다.
그런 다음 rawFormData를 CreateInvoice에 전달하여 유형을 확인할 수 있다.
/app/lib/actions.ts
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
}
값을 센트 단위로 저장
일반적으로 JavaScript 부동 소수점 오류를 제거하고 정확성을 높이기 위해 데이터베이스에 금전적 가치를 센트 단위로 저장하는 것이 좋다.
금액을 센트로 변환해 보자.
/app/lib/actions.ts
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
}
새 날짜 만들기
마지막으로 송장 생성 날짜에 대해 "YYYY-MM-DD" 형식으로 새 날짜를 생성해 보자.
/app/lib/actions.ts
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
}
5. 데이터베이스에 데이터 삽입
이제 데이터베이스에 필요한 모든 값이 있으므로 SQL 쿼리를 생성하여 데이터베이스에 새 송장을 삽입하고 변수를 전달할 수 있다.
/app/lib/actions.ts
import { z } from 'zod';
import { sql } from '@vercel/postgres';
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
}
현재로서는 오류를 처리하지 않고잇다. 다음 장에서 오류 처리를 할 것이니, 지금은 다음 단계로 넘어가겠다.
6. 재검증 및 리디렉션
Next.js에는 한동안 사용자 브라우저에 경로 세그먼트를 저장하는 클라이언트 측 라우터 캐시가 있다. 프리패치와 함께 이 캐시를 사용하면 사용자는 서버에 대한 요청 수를 줄이면서 경로 사이를 빠르게 탐색할 수 있다.
송장 경로에 표시된 데이터를 업데이트하고 있으므로 이 캐시를 지우고 서버에 대한 새 요청을 트리거하려고 한다. Next.js의 revalidatePath 함수를 사용하여 이 작업을 수행할 수 있다.
/app/lib/actions.ts
'use server';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
// ...
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
revalidatePath('/dashboard/invoices');
}
데이터베이스가 업데이트되면 /dashboard/invoices 경로가 재검증되고 서버에서 새로운 데이터를 가져온다.
이 시점에서 사용자를 다시 /dashboard/invoices 페이지로 리디렉션한다. Next.js의 리디렉션 기능을 사용하여 이 작업을 수행할 수 있다.
/app/lib/actions.ts
'use server';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// ...
export async function createInvoice(formData: FormData) {
// ...
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
방금 첫 번째 서버 액션을 구현했다. 모든 것이 올바르게 작동하는지 새 송장을 추가하여 테스트해 보자.
- 제출 시 /dashboard/invoices 경로로 리디렉션되어야 한다.
- 표 상단에 새 인보이스가 표시다.
송장 업데이트
송장 양식 업데이트는 송장 양식 생성과 유사하지만, 데이터베이스의 기록을 업데이트하려면 송장 ID를 전달해야 한다는 점만 다르다. 송장 ID를 가져오고 전달하는 방법을 살펴보겠다.
송장을 업데이트하기 위해 수행할 단계는 다음과 같다.
- 송장 ID를 사용하여 새 동적 경로 세그먼트를 만든다.
- 페이지 매개변수에서 송장 ID를 읽는다.
- 데이터베이스에서 특정 송장을 가져온다.
- 송장 데이터로 양식을 미리 채운다.
- 데이터베이스의 송장 데이터를 업데이트한다.
1. 송장 ID를 사용하여 동적 경로 세그먼트를 생성.
Next.js를 사용하면 정확한 세그먼트 이름을 모르고 데이터를 기반으로 경로를 생성하려는 경우 동적 경로 세그먼트를 생성할 수 있다. 블로그 게시물 제목, 제품 페이지 등이 될 수 있다. 폴더 이름을 대괄호로 묶어 동적 경로 세그먼트를 만들 수 있다. 예를 들어 [id], [post] 또는 [slug] 같은 형태가 될 수 있다.
/invoices 폴더에서 [id]라는 새 동적 경로를 만든 다음 page.tsx 파일을 사용하여 edit라는 새 경로를 만든다. 파일 구조는 다음과 같다.
<Table> 컴포넌트에는 테이블 레코드에서 송장 ID를 받는 <UpdateInvoice /> 버튼이 있다.
/app/ui/invoices/table.tsx
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
return (
// ...
<td className="flex justify-end gap-2 whitespace-nowrap px-6 py-4 text-sm">
<UpdateInvoice id={invoice.id} />
<DeleteInvoice id={invoice.id} />
</td>
// ...
);
}
<UpdateInvoice /> 컴포넌로 이동하고 링크의 href를 업데이트하여 id prop을 수락하자. 템플릿 리터럴을 사용하여 동적 경로 세그먼트에 연결할 수 있다.
/app/ui/invoices/buttons.tsx
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
// ...
export function UpdateInvoice({ id }: { id: string }) {
return (
<Link
href={`/dashboard/invoices/${id}/edit`}
className="rounded-md border p-2 hover:bg-gray-100"
>
<PencilIcon className="w-5" />
</Link>
);
}
2. 페이지 매개변수에서 송장 ID 읽기.
<Page> 컴포넌트로 돌아가서 다음 코드를 붙여넣는다.
/app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page() {
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: 'Invoices', href: '/dashboard/invoices' },
{
label: 'Edit Invoice',
href: `/dashboard/invoices/${id}/edit`,
active: true,
},
]}
/>
<Form invoice={invoice} customers={customers} />
</main>
);
}
edit-form.tsx 파일에서 다른 양식을 가져오는 점을 제외하면 /create Invoice 페이지와 얼마나 유사한지 확인 할 수 있다. 이 양식은 고객 이름, 송장 금액 및 상태에 대한 defaultValue로 미리 채워져 있어야 한다. 양식 필드를 미리 채우려면 ID를 사용하여 특정 송장을 가져와야 한다.
searchParams 외에도 페이지 컴포넌트는 ID에 액세스하는 데 사용할 수 있는 params라는 속성도 허용한다. prop을 받으려면 <Page> 컴포넌트를 업데이트하자.
/app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
// ...
}
3. 특정 송장을 가져오기.
그 다음에:
- fetchInvoiceById라는 새 함수를 가져오고 ID를 인수로 전달한다.
- fetchCustomers를 가져와서 드롭다운에 대한 고객 이름을 가져온다.
Promise.all을 사용하여 송장과 고객을 동시에 가져올 수 있다.
/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
// ...
}
송장이 잠재적으로 정의되지 않을 수 있으므로 터미널의 송장 속성에 일시적인 TS 오류가 표시된다. 지금은 걱정할 필요 없다. 다음 장에서 오류 처리를 추가하면 문제가 해결될 것이다.
이제 모든 것이 올바르게 연결되었는지 테스트하자. 송장을 편집하려면 http://localhost:3000/dashboard/invoices를 방문하여 연필 아이콘을 클릭하자. 탐색 후에는 송장 세부정보가 미리 채워진 폼이 표시될 것이다.
URL은 다음과 같은 ID로 업데이트되어야 한다: http://localhost:3000/dashboard/invoice/uuid/edit
UUID와 자동 증가 키
키를 증가시키는 대신(예: 1, 2, 3 등) UUID를 사용한다. 이렇게 하면 URL이 길어진다. 그러나 UUID는 ID 충돌 위험을 제거하고 전역적으로 고유하며 열거 공격 위험을 줄여 대규모 데이터베이스에 이상적다.
그러나 더 깔끔한 URL을 선호한다면 자동 증가 키를 사용하는 것이 좋다.
4. ID를 서버 액션에 전달하기.
마지막으로 데이터베이스에서 올바른 레코드를 업데이트할 수 있도록 ID를 서버 액션에 전달하려고 한다. 다음과 같이 ID를 인수로 전달할 수 없다.
/app/ui/invoices/edit-form.tsx
// Passing an id as argument won't work
<form action={updateInvoice(id)}>
대신 JS 바인드를 사용하여 서버 액션에 ID를 전달할 수 있다. 이렇게 하면 서버 액션에 전달된 모든 값이 인코딩된다.
/app/ui/invoices/edit-form.tsx
// ...
import { updateInvoice } from '@/app/lib/actions';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
return (
<form action={updateInvoiceWithId}>
<input type="hidden" name="id" value={invoice.id} />
</form>
);
}
참고: 양식에 숨겨진 입력 필드를 사용하는 것도 가능하다(예: <input type="hidden" name="id" value={invoice.id} />). 그러나 값은 HTML 소스에 전체 텍스트로 표시되므로 ID와 같은 민감한 데이터에는 적합하지 않다.
그 다음 actions.ts 파일에서 updateInvoice라는 새 작업을 만든다.
/app/lib/actions.ts
// Use Zod to update the expected types
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
// ...
export async function updateInvoice(id: string, formData: FormData) {
const { customerId, amount, status } = UpdateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
createInvoice 액션과 유사하게 다음과 같은 일을 했다.
- formData에서 데이터를 추출.
- Zod를 사용하여 유형을 검증.
- 금액을 센트로 변환.
- 변수를 SQL 쿼리에 전달.
- revalidatePath를 호출하여 클라이언트 캐시를 지우고 새 서버 요청 생성.
- 사용자를 송장 페이지로 리디렉션하기 위해 리디렉션을 호출.
송장을 편집하여 테스트해보자. 양식을 제출한 후 청구서 페이지로 리디렉션되고 청구서가 업데이트되어야 한다.
송장 삭제
서버 액션을 사용하여 송장을 삭제하려면 <form> 엘리먼에 삭제 버튼을 래핑하고 바인드를 사용하여 서버 액션에 ID를 전달한다.
/app/ui/invoices/buttons.tsx
import { deleteInvoice } from '@/app/lib/actions';
// ...
export function DeleteInvoice({ id }: { id: string }) {
const deleteInvoiceWithId = deleteInvoice.bind(null, id);
return (
<form action={deleteInvoiceWithId}>
<button className="rounded-md border p-2 hover:bg-gray-100">
<span className="sr-only">Delete</span>
<TrashIcon className="w-4" />
</button>
</form>
);
}
actions.ts 파일 내에서 deleteInvoice라는 새 작업을 만든다.
/app/lib/actions.ts
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
이 작업은 /dashboard/invoices 경로에서 호출되므로 리디렉션을 호출할 필요가 없다. revalidatePath를 호출하면 새 서버 요청이 트리거되고 테이블이 다시 렌더링된다.
추가학습
이 장에서는 서버 액션을 사용하여 데이터를 변경하는 방법을 배웠다. 또한 revalidatePath API를 사용하여 Next.js 캐시의 유효성을 다시 검사하고 사용자를 새 페이지로 리디렉션하도록 리디렉션하는 방법도 배웠다.
추가 학습을 위해 서버 액션을 사용한 보안에 대한 자세한 내용을 읽어보자.
'Next.js 개발 가이드 > 06. Learn Next.js 공식 가이드' 카테고리의 다른 글
14. 접근성 강화 (0) | 2023.12.25 |
---|---|
13. 에러 처리 (0) | 2023.12.24 |
11. 검색, 페이지네이션 (0) | 2023.12.24 |
10. 부분 사전 렌더링(Partial Prerendering - Optional) (0) | 2023.12.23 |
09. 스트리밍 (0) | 2023.12.23 |