프로젝트 : Foodies 앱 - 3

·2024년 3월 22일
0

NextJS

목록 보기
6/26
post-thumbnail

📌 어플리케이션 구현하기

📖 서버 행동 응답 및 useFormState 작업

  • 위처럼 하면 작성했던 모든 데이터가 지워진다.
  • 만약 작성했던 데이터를 남겨두고 싶다면..?
// lib/action.js
"use server";

import { redirect } from "next/navigation";
import { saveMeal } from "./meals";

function isInvalidText(text) {
  return !text || text.trim() === "";
}

// shareMeal(prevState, formData)로 변경
export async function shareMeal(prevState, formData) {
  const meal = {
    title: formData.get("title"),
    creator_email: formData.get("email"),
    summary: formData.get("summary"),
    image: formData.get("image"),
    instructions: formData.get("instructions"),
    creator: formData.get("name"),
  };

  if (
    isInvalidText(meal.title) ||
    isInvalidText(meal.summary) ||
    isInvalidText(meal.instructions) ||
    isInvalidText(meal.creator) ||
    isInvalidText(meal.creator_email) ||
    !meal.creator_email.instructions("@") ||
    !meal.image ||
    meal.image.size === 0
  ) {
    // 직렬화 가능한 것으로 리턴.
    return {
      message: "Invalid input.",
    };
  }

  await saveMeal(meal);
  redirect("/meals");
}

// app/meals/share/page.js
("use client");

import { useFormState } from "react-dom"; // 추가
import ImagePicker from "@/components/meals/image-picker";
import classes from "./page.module.css";
import { shareMeal } from "@/lib/action";
import MealsFormSubmit from "@/components/meals/meals-form-submit";

export default function ShareMealPage() {
  const [state, formAction] = useFormState(shareMeal, { message: null }); // 추가

  return (
    <>
      <header className={classes.header}>
        <h1>
          Share your <span className={classes.highlight}>favorite meal</span>
        </h1>
        <p>Or any other meal you feel needs sharing!</p>
      </header>
      <main className={classes.main}>
        <form className={classes.form} action={formAction}>
          <div className={classes.row}>
            <p>
              <label htmlFor="name">Your name</label>
              <input type="text" id="name" name="name" required />
            </p>
            <p>
              <label htmlFor="email">Your email</label>
              <input type="email" id="email" name="email" required />
            </p>
          </div>
          <p>
            <label htmlFor="title">Title</label>
            <input type="text" id="title" name="title" required />
          </p>
          <p>
            <label htmlFor="summary">Short Summary</label>
            <input type="text" id="summary" name="summary" required />
          </p>
          <p>
            <label htmlFor="instructions">Instructions</label>
            <textarea
              id="instructions"
              name="instructions"
              rows="10"
              required
            ></textarea>
          </p>
          <ImagePicker name="image" label="Your image" />
          {/* 추가 */}
          {state.message && <p>{state.message}</p>}
          <p className={classes.actions}>
            <MealsFormSubmit />
          </p>
        </form>
      </main>
    </>
  );
}
  • useFormState 훅은 리액트의 useState 훅과 약간 비슷하게 동작한다.
  • useFormState 훅은 Server Actions를 통해 제출될 form을 사용하는 페이지나 컴포넌트의 상태를 관리한다.
  • useFormState(arg1, arg2)
    1. arg1 : form이 제출될 때 동작하는 실제 Server Action
    2. arg2 : 컴포넌트의 초기 상태. Server Action이 동작하기 전이나 response가 돌아오기 전에 useFormState가 반환할 초기 값.
  • useFormState의 반환값은 useState처럼 두개의 요소로 되어있다.
    1. 해당 컴포넌트의 현재 상태 혹은 현재 response. 즉, Server Action으로부터 받은 가장 최근의 응답 또는 초기 상태.
    2. formAction : form의 action 속성에 값으로 설정


📖 NextJS 캐싱 구축 및 이해

  • 개발환경에서 배포환경으로 변경하기 위해 npm run build 실행 → npm start로 배포 서버 실행
  • 이제 개발 환경 서버가 아닌 배포 환경 서버로 열리게 된다.
  • NextJS는 꽤 공격적인 캐싱을 하고 배포 환경을 위해 앱을 준비할 때 그리고 동작할 때 거치는 추가 단계가 하나 있다.
  • 해당 단계는 개발 환경 서버에서 테스트할 때는 보지 못했던 것으로 npm run build를 실행하면 NextJS는 실제로 앱에서 사전 생성될 수 있는 모든 페이지를 모두 사전 렌더링하고 생성하여 기본적으로는 동적 웹페이지가 아니게 된다.

NextJS는 모든 페이지를 사전 렌더링 함으로써 배포된 직후부터 모든 페이지가 동작할 수 있게 한다. → 웹 사이트에 처음 방문해도 렌더링을 기다릴 필요 없이 즉시 완성된 페이지를 볼 수 있다. (빌드 프로세스에서 모든 데이터를 불러오고 렌더링한다.)

  • NextJS는 사전 렌더링된 페이지들을 캐싱하여 모든 방문자에게 제공한다. → 새롭게 데이터가 추가 되었을 때 반영되지 않을 수 있다는 단점이 존재.

📖 캐시 유효성 재확인 트리거

  • 새로운 음식 데이터를 등록할 때마다 NextJS에게 캐시의 전체나 일부를 비워야 한다고 명령할 필요가 있다.
  • revalidatePath('/meals') : NextJS가 특정 path(경로)에 속하는 캐시의 유효성 재검사를 하게 한다. → 기본값으로 설정한 path만 검사한다. 중첩된 path는 재검사 하지 않는다.
    • 만약 revalidatePath('/meals', 'page')을 사용한다면 두번째 인수의 기본값은 page로 이 path의 해당 페이지만 재검사하겠다는 뜻이 된다.
    • 만약 revalidatePath('/meals', 'layout')을 사용한다면 두번째 인수의 기본값은 layout으로 재검사되는 것은 layout이다. → 중첩된 페이지를 포함하므로 중첩된 모든 페이지를 재검사 한다.
  • 'revalidate'는 간단히 말해 NextJS가 해당 페이지에 연관된 캐시를 비우는 것을 의미한다.(ex. 캐싱된 페이지)

만약 모든 페이지의 캐시를 재검사 하고싶다면... revalidatePath('/', 'layout')


📖 로컬 Filesystem에 파일 저장 금지

  • 'The requested resource isn't a valid image for /images/dune-part-two.jpg received text/html; charset=utf-8' 라는 오류가 발생하면서 작성한 새로운 데이터에 사진이 뜨지 않는다.

  • 현재 public/images 폴더에 저장을 하고있다. 따라서 이미지가 보이지 않는다.

  • 개발 환경에서는 해당 폴더에 접근이 가능하지만, 배포 환경에서는 .next 폴더에 복사가 되고 .next 폴더를 사용하게 된다.

    🔗 Next.js | Static Assets in public

    위의 사진 처럼 Vercel blob(혹은 AWS S3)을 사용하는 것을 추천하고 있다.


📖 업로드된 이미지를 클라우드에 저장하기(AWS S3)

  • 업로드된 파일(또는 런타임에 생성된 기타 파일)을 로컬 파일 시스템에 저장하는 것은 이상적이지 않다. 대신 AWS S3 같은 클라우드 파일 저장소를 통해 파일을 저장하는 것이 좋다.
  • AWS S3는 환경설정에 따라 파일을 저장하고 제공할 수 있는 AWS 서비스이다.

💎 1. AWS 계정 만들기

💎 2. S3 버킷 생성 : S3 콘솔로 이동하여 버킷 생성

  • 버킷 : 파일을 저장할 수 있는 용기 (이미지를 포함한 모든 파일 저장 가능.)
  • 버킷은 전세계적으로 고유한 이름이어야한다. → zoekangdev-nextjs-demo-users-image 으로 설정

💎 3. 더미 이미지 파일 업로드

💎 4. 이미지를 제공할 버킷 환경 설정

  • 기본 설정 : lock down. 그 안의 파일이 보호되고 다른 사람이 접근 불가능.
  • 우리의 목적은 모든 사람이 이미지를 볼 수 있도록 하는 것이므로 permission(권한) 탭 → 공용 액세스 차단(block public access)을 편집 → 액세스 차단 체크박스 비활성화 → 저장

  • 버킷 정책 작성
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "PublicRead",
          "Effect": "Allow",
          "Principal": "*",
          "Action": ["s3:GetObject", "s3:GetObjectVersion"],
          "Resource": ["arn:aws:s3:::zoekangdev-nextjs-demo-users-image/*"]
        }
      ]
    }
  • 해당 버킷에는 공개적으로 공유하고 싶지 않은 파일은 추가해선 안된다.

💎 5. S3 이미지를 사용하기 위한 NextJS 코드 업데이트

  • public/images 폴더 삭제하여 public/ 만 남도록 한다.
  • .next 폴더 삭제
  • initdb.js 파일 업데이트하여 데이터베이스 편집 : image: '/images/burger.jpg'image: 'burger.jpg
  • components/meals/meal-item.js 편집
    <Image
      src={`https://zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com/${image}`}
      alt={title}
      fill
    />
  • app/meals/[mealSlug]/page.js 편집
    <Image
      src={`https://zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com/${meal.image}`}
      alt={meal.title}
      fill
    />
  • 기존의 데이터베이스인 meals.db 삭제 → node initdb.js 실행하여 데이터베이스 업그레이드
  • 다음과 같은 오류가 발생

    Invalid src prop (https://zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com/burger.jpg) on next/image, hostname "zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com" is not configured under images in your next.config.js See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host

💎 6. 이미지 소스로 S3 허용

  • 위의 오류는 NextJS가 기본적으로 <Image> 컴포넌트를 사용할 때 외부 URL을 허용하지 않기 때문에 발생하는 것이다.
  • 오류를 해결하기 위해 URL을 명시적으로 허용해야한다.
  • next.config.js 편집
    const nextConfig = {
      images: {
        remotePatterns: [
          {
            protocol: "https",
            hostname:
              "zoekangdev-nextjs-demo-users-image.s3.ap-northeast-2.amazonaws.com",
            port: "",
            pathname: "/**",
          },
        ],
      },
    };
  • remotePatternsconfig을 통해 특정 S3 URL을 이미지의 유효한 소스로 사용 가능하게 되었다.

💎 7. 업로드된 이미지를 S3에 저장

  • 유저가 생성한 이미지 데이터를 S3에 포워딩(forward). 이는 AWS에서 제공하는 패키지인 '@aws-sdk/client-s3'를 통해 가능하다.

  • 설치 : npm install @aws-sdk/client-s3

  • lib/meals.js 편집

    // 가장 상단에 추가
    import { S3 } from "@aws-sdk/client-s3";
    const s3 = new S3({
      region: "ap-northeast-2",
    });
  • saveMeal() 편집

    export async function saveMeal(meal) {
      meal.slug = slugify(meal.title, { lower: true });
      meal.instructions = xss(meal.instructions); // instructions 검열
    
      const extension = meal.image.name.split(".").pop(); // 마지막 요소. 즉 확장자 받음
      const fileName = `${meal.slug}.${extension}`;
    
      const bufferedImage = await meal.image.arrayBuffer(); // arrayBuffer함수가 프로미스를 반환 -> 버퍼로 변환됨.. 따라서 await 키워드 사용
    
      s3.putObject({
        Bucket: "zoekangdev-nextjs-demo-users-image",
        Key: fileName,
        Body: Buffer.from(bufferedImage),
        ContentType: meal.image.type,
      });
    
      meal.image = fileName; // 모든 이미지에 관한 요청은 자동적으로 public 폴더로 보내짐
    
      // 데이터베이스에 저장하기
      db.prepare(
        `
      INSERT INTO meals
       (title, summary, instructions, creator, creator_email, image, slug)
       VALUES (
         @title,
         @summary,
         @instructions,
         @creator,
         @creator_email,
         @image,
         @slug
       )
    `
      ).run(meal);
    }

💎 8. NextJS 백엔드 AWS 접근 권한 부여

  • 버킷의 내용을 모두에게 제공하도록 S3를 설정했으나 모든 사람이 버킷에 작성하거나 버킷 내용을 변경할 수 있도록 설정하지 않았고, 또 설정해서도 안된다. 그러나 현재 앱은 이것을 하려고 한다..
  • 앱에 권한을 부여하려면 앱에 대한 AWS 접근 키를 설정해야한다.
  • 루트 NextJS 프로젝트에 .env.local 파일을 추가 → NextJS에 의해 자동으로 읽히고 거기에 설정된 환경 변수를 앱의 백엔드 부분에서 사용할 수 있게 된다. 참고
  • .env.local 파일에 두 개의 키-값 쌍을 추가.
    AWS_ACCESS_KEY_ID=<your aws access key>
    AWS_SECRET_ACCESS_KEY=<your aws secret access key>
    • 해당 접근 코드는 AWS 콘솔 내부에서 얻을 수 있다. 계정 이름 클릭 후 '보안 자격 증명' 클릭
    • 접근 키 부분으로 스크롤하여 새로운 접근 키 생성. 해당 내용은 공유하면 안된다.


📖 정적 메타 데이터 추가

  • 페이지에 적용할 수 있는 메타데이터 추가
  • app/layout.js에 작성된 metadata에서 메타데이터 필드를 지정할 수 있다. 🔗 참고
  • metadata 상수는 다양한 메타데이터를 추가할 수 있게 해준다. ex. 검색 엔진 크롤러에 노출될 수 있게 하거나 페이지 링크를 X(트위터)나 페이스북에 공유할 때 보여준다.
  • layout에 작성한 메타데이터를 해당 데이터가 감싸고있는 모든 페이지에 자동으로 적용된다.
  • 만약 페이지에 metadata가 존재한다면 페이지의 metadata가 우선 적용된다.
// app/meals/page.js
export const metadata = {
  title: "All Meals",
  description: "Browse the delicious meals shared by our vibrant community.",
};

📖 동적 메타데이터 추가

  • 동적페이지에서는 metadata라는 이름의 변수(상수)를 export 하는게 아니라 generateMetadata라는 async 함수를 export 하여 메타데이터를 적용시킨다.
  • 만약 이 함수가 존재한다면 NextJS가 대신 실행시켜주며 반드시 이 함수에서 metadata 객체를 반환해야만 한다.
  • 해당 함수는 페이지 컴포넌트가 속성으로 받는 것과 동일한 데이터를 받는다.
// app/meals/[mealSlug]/page.js
export async function generateMetadata({ params }) {
  const meal = getMeal(params.mealSlug);

  if (!meal) {
    notFound();
  }

  return {
    title: meal.title,
    description: meal.summary,
  };
}

0개의 댓글

관련 채용 정보