728x90

과거에 next.js의 튜토리얼을 진행하면서 얕게 streaming에 대해 학습한 적이 있습니다. 그 당시에 깊게 이해하지 못한 부분에 대해서 다시 한번 정리하고자 포스팅을 작성하게 되었습니다.

 

전통적인 SSR의 단점

SSR은 서버에서 완성된 HTML을 클라이언트에 전송해줍니다. SSR이 진행되는 과정은 아래와 같습니다.

1. 먼저, 특정 페이지에 필요한 모든 데이터가 서버에서 가져와집니다.
2. 그런 다음, 서버에서 해당 페이지의 HTML을 렌더링합니다.
3. 이후, 페이지의 HTML, CSS, 그리고 JavaScript가 클라이언트로 전송됩니다.
4. 전송된 HTML과 CSS를 사용하여 비상호작용 UI가 화면에 표시됩니다.
5. 마지막으로, React가 UI를 하이드레이션(hydration) 하여 상호작용할 수 있도록 만듭니다.

그렇기에 한 페이지 내에서도 모든 구역이 동시에 작업이 완료되진 않습니다. 하지만 우린 완성된 HTML을 받기 때문에, 하나라도 오래 걸리는 작업이 생기면 HTML을 받지 못하는 문제점을 겪게 됩니다.

출처 : https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#what-is-streaming

 

 

이를 보완해주는 것이 Next.js의 Streaming 기능입니다.

 

Streaming이란?

Next.js에서는 Streaming을 아래와 같이 정의합니다

Streaming 데이터를 전송하는 기술로, 하나의 라우트를 더 작은 "청크(chunks)"로 분할하여 서버에서 클라이언트로 준비되는 대로 점진적으로 스트리밍할 수 있도록 합니다.
Streaming is a data transfer technique that allows you to break down a route into smaller "chunks" and progressively stream them from the server to the client as they become ready.

 

 

그 결과  Time To First Byte (TTFB) , First Contentful Paint (FCP) ,Time to Interactive (TTI) 를 향상 시킬 수 있습니다.

 

상호작용 시작 시간  |  Lighthouse  |  Chrome for Developers

Lighthouse의 상호작용 시작 시간 측정항목과 이 측정항목을 측정하고 최적화하는 방법을 알아보세요.

developer.chrome.com

 

Streaming 사용방법

Streaming은 크게 2가지의 경우로 사용법이 구분됩니다.

1. 페이지단위로 사용하기

2. 컴퍼넌트단위로 사용하기

 

1. 페이지 단위로 사용하기

export default function Loading() {
  // You can add any UI inside Loading, including a Skeleton.
  return <LoadingSkeleton />
}

 

Lodaing.tsx 파일은 React Suspense를 기반으로 하는 Next.js의 특별한 파일로, 페이지 콘텐츠가 로드되는 동안 대신 표시할 Fallback UI를 생성할 수 있도록 해줍니다. 보통 Layout.tsx에 위치합니다.

 

2. 컴퍼넌트 단위로 사용하기

import { Suspense } from 'react'
import { PostFeed, Weather } from './Components'
 
export default function Posts() {
  return (
    <section>
      <Suspense fallback={<p>Loading feed...</p>}>
        <PostFeed />
      </Suspense>
      <Suspense fallback={<p>Loading weather...</p>}>
        <Weather />
      </Suspense>
    </section>
  )
}

 

이 경우에는 react에서 제공하는 Suspense를 활용하여 Streaming을 사용할 수 있습니다.

 

 

 

참고 링크

 

https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#what-is-streaming

 

Routing: Loading UI and Streaming | Next.js

Built on top of Suspense, Loading UI allows you to create a fallback for specific route segments, and automatically stream content as it becomes ready.

nextjs.org

https://nextjs.org/learn/dashboard-app/streaming

 

App Router: Streaming | Next.js

Improve your application's loading experience with streaming and loading skeletons.

nextjs.org

 

 

 

 

728x90

들어가며

Next.js의 next/image 컴퍼넌트는 굉장히 많은 역할을 해줍니다.  보통  Image를 사용할 떄는 아래와 같이 width, height을지정해주고 사용합니다. 이후 화면이나 외부 컨테이너가 작아짐에 따라서 이미지의 크기가 줄어듭니다.

import Image from 'next/image'
 
export default function Page() {
  return (
    <Image
      src="/profile.png"
      width={500}
      height={500}
      alt="Picture of the author"
    />
  )
}

 

하지만 이번 프로젝트에서는 이미지의 크기가 외부 영역에 영향을 받게 되었습니다. 

이때 사용할 수 있는 속성이 fill입니다.

 

Next/Image Fill속성 사용하기

정확하게 크기를 정의할 수는 없지만, 외부 영역 기준으로 가득차게 할 때는 fill을 사용할 수 있습니다.

공식문서  Responsive Image with Fill  

import Image from 'next/image'
 
export default function Page({ photoUrl }) {
  return (
    <div style={{ position: 'relative', width: '300px', height: '500px' }}>
      <Image
        src={photoUrl}
        alt="Picture of the author"
        sizes="300px"
        fill
        style={{
          objectFit: 'contain',
        }}
      />
    </div>
  )
}

 

예시를 봤을 때, fill를 사용할때는  3가지를 고려해야 합니다.

1. fill을 사용하면 Image컴퍼넌트가 absolute 포지션이기에 외부가 absolute, fiexd, relative중 하나여야 한다.

// 공식문서 내부의 : packages\next\src\client\image-component.tsx

	if (img.parentElement) {
          const { position } = window.getComputedStyle(img.parentElement)
          const valid = ['absolute', 'fixed', 'relative']
          if (!valid.includes(position)) {
            warnOnce(
              `Image with src "${origSrc}" has "fill" and parent element with invalid "position". Provided "${position}" should be one of ${valid
                .map(String)
                .join(',')}.`
            )
          }
        }

 

2. sizes를 사용하여 최적화를 해야한다.

(사용하지 않으면 아래의 경고 문구가 나옵니다.) 

                `Image with src "${origSrc}" has "fill" but is missing "sizes" prop. Please add it to improve page performance. Read more: https://nextjs.org/docs/api-reference/next/image#sizes`
 

 

이전 포스트를 통해서 next.js의 image 가 html의 img태그를 사용하고 있음을 알 수 있었고, srcset과 preload를 사용하여 최적화를 시도하고 있음을 확인했습니다.

mdn 에서 img태그의 sizes를 확인해보면 뷰포인트 크기를 활용하여 반응형으로 소스크기를 적용할 수 있습니다.

width가 정해지지 않은 경우에는 sizes옵션을 주어서 viewport에 따라서 소스의 크기를 결정할 수 있고, 최적화가 가능합니다. 

 

저는 화면이 1000px보다 작은 경우에는 400px, 클 경우 200px로 설정하였습니다.

	 <Image
          src={src}
          alt={alt}
          fill
          sizes="(max-width: 1000px) 400px, 200px"
  	  />

 

 

3. style에서 objectFit 속성 사용

fill을 사용하더라도 이미지의 비율이 유지되기를 생각할 것입니다. 이떄 사용할 수 있는 것이 objectFit이고 이는 12버전까지 next/image에 포함되어  있는 기능이었습니다.

 

 

하지만 현재에는 deprecated 됐으며 공식문서 예시와 같이 css에서 관리해야 합니다.

 

https://nextjs.org/docs/pages/api-reference/components/image-legacy#comparison

 

 

next.js에서  style이나 className등을 사용한 css의 영역을 지지하여  관심사가 분리되었기 때문입니다.

 

 

 

참고 문헌

공식 문서 Image 컴퍼넌트 : https://nextjs.org/docs/app/api-reference/components/image

 

Components: <Image> | Next.js

Optimize Images in your Next.js Application using the built-in `next/image` Component.

nextjs.org

 

 

mdn img태그 

https://developer.mozilla.org/ko/docs/Web/HTML/Element/img

 

<img>: 이미지 삽입 요소 - HTML: Hypertext Markup Language | MDN

HTML <img> 요소는 문서에 이미지를 넣습니다.

developer.mozilla.org

https://nextjs.org/docs/pages/api-reference/components/image-legacy

 

Components: <Image> (Legacy) | Next.js

Backwards compatible Image Optimization with the Legacy Image component.

nextjs.org

 

728x90

들어가며

Next.js에서 Image를 제공하는데, 이를 통해서 이미지에 대한 최적화 작업을 간단히 할 수 있습니다. 

 Next.js 공식문서에서는  크기 최적화, 시각 안정성, 빠른 페이지 리로드, 자산의 유연성 4가지 측면에서 장점을 제시합니다. 

  • Size Optimization: Automatically serve correctly sized images for each device, using modern image formats like WebP and AVIF.
  • Visual Stability: Prevent layout shift automatically when images are loading.
  • Faster Page Loads: Images are only loaded when they enter the viewport using native browser lazy loading, with optional blur-up placeholders.
  • Asset Flexibility: On-demand image resizing, even for images stored on remote servers

하지만 개발에서 은탄환은 없다고 많이 말합니다. 따라서 어떤 기능을 어떻게 제공하는지에 대해서 알 필요성을 느꼈고, 이를 위해서 소스코드를 분석을 하게 되었습니다.

 

소스코드 위치 추적

 리액트 기반의 UI 라이브러리나 react/drei 등 코드를 본적이 종종 있었는데,  개인적으로 파일이 너무 많고 복잡해서 파악하는데 조금 더 어려웠습니다. VS-CODE의 도움을 받고자 클론을 받고 파일을 경로를 추적했습니다.

 

우리가 사용하고 있는 next/image는 image-external에서 가져오는 군요

import type { ImageConfigComplete, ImageLoaderProps } from './image-config'
import type { ImageProps, ImageLoader, StaticImageData } from './get-img-props'

import { getImgProps } from './get-img-props'
import { Image } from '../../client/image-component'

// @ts-ignore - This is replaced by webpack alias
import defaultLoader from 'next/dist/shared/lib/image-loader'

/**
 * For more advanced use cases, you can call `getImageProps()`
 * to get the props that would be passed to the underlying `<img>` element,
 * and instead pass to them to another component, style, canvas, etc.
 *
 * Read more: [Next.js docs: `getImageProps`](https://nextjs.org/docs/app/api-reference/components/image#getimageprops)
 */
export function getImageProps(imgProps: ImageProps) {
  const { props } = getImgProps(imgProps, {
    defaultLoader,
    // This is replaced by webpack define plugin
    imgConf: process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete,
  })
  // Normally we don't care about undefined props because we pass to JSX,
  // but this exported function could be used by the end user for anything
  // so we delete undefined props to clean it up a little.
  for (const [key, value] of Object.entries(props)) {
    if (value === undefined) {
      delete props[key as keyof typeof props]
    }
  }
  return { props }
}

export default Image

export type { ImageProps, ImageLoaderProps, ImageLoader, StaticImageData }

우리가 사용하고 있는 next/image는 image-external에서 가져오는 군요

Image 이외에도 getImageProps와  ImageLoader등 몇 가지를 추가적으로 export하고 있네요.

 

사실 Image에 대해서 처음 관심을 갖게 된것은 wanted에서 멘토님이 defaultLoader와 관련된 설명을 해주시면서 분석해야겠다는 생각을 하게 되었습니다. Next.js에서는 defaultLoader를 사용하여 서버에서 캐싱을 해주는데, 이와 관련된 작업이 서버에 부담이 크다는 discussion을 보여주면서 설명했던 내용입니다.

next.js image

 

이때 defaultLoader의 props 타입을 보면 next.js에서가 어떤식으로 srcset을 관리하는지 유추할 수 있었습니다.

흔히 next.js에서는 webp로 관리한다고 하고 경로가 /_next/image~~ 와 같이 나오는 이유도 유추가 가능하네요.

function defaultLoader({
  config,
  src,
  width,
  quality,
}: ImageLoaderPropsWithConfig): string {
	// ..생략
}

export type ImageLoaderPropsWithConfig = ImageLoaderProps & {
  config: Readonly<ImageConfig>
}

export const imageConfigDefault: ImageConfigComplete = {
  deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  path: '/_next/image',
  loader: 'default',
  loaderFile: '',
  domains: [],
  disableStaticImages: false,
  minimumCacheTTL: 60,
  formats: ['image/webp'],
  dangerouslyAllowSVG: false,
  contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`,
  contentDispositionType: 'attachment',
  remotePatterns: [],
  unoptimized: false,
}

 

 

 

소스코드 분석

이제 이미지 컴퍼넌트에 대해서 좀 더 집중해보려고 합니다. 경로는 packages/next/src/client/image-component.tsx 입니다

파일 자체는 타입이나 export하는 과정만 50줄에 이르고  코드만 430줄이 넘더군요.. 이를 topdown 방식으로 분석해보려고 합니다.

 Image 

export const Image = forwardRef<HTMLImageElement | null, ImageProps>(
  (props, forwardedRef) => {
    const pagesRouter = useContext(RouterContext)
    // We're in the app directory if there is no pages router.
    const isAppRouter = !pagesRouter

    const configContext = useContext(ImageConfigContext)
    const config = useMemo(() => {
      const c = configEnv || configContext || imageConfigDefault
      const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
      const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
      return { ...c, allSizes, deviceSizes }
    }, [configContext])

    const { onLoad, onLoadingComplete } = props
    const onLoadRef = useRef(onLoad)

    useEffect(() => {
      onLoadRef.current = onLoad
    }, [onLoad])

    const onLoadingCompleteRef = useRef(onLoadingComplete)

    useEffect(() => {
      onLoadingCompleteRef.current = onLoadingComplete
    }, [onLoadingComplete])

    const [blurComplete, setBlurComplete] = useState(false)
    const [showAltText, setShowAltText] = useState(false)

    const { props: imgAttributes, meta: imgMeta } = getImgProps(props, {
      defaultLoader,
      imgConf: config,
      blurComplete,
      showAltText,
    })

    return (
      <>
        {
          <ImageElement
            {...imgAttributes}
            unoptimized={imgMeta.unoptimized}
            placeholder={imgMeta.placeholder}
            fill={imgMeta.fill}
            onLoadRef={onLoadRef}
            onLoadingCompleteRef={onLoadingCompleteRef}
            setBlurComplete={setBlurComplete}
            setShowAltText={setShowAltText}
            sizesInput={props.sizes}
            ref={forwardedRef}
          />
        }
        {imgMeta.priority ? (
          <ImagePreload
            isAppRouter={isAppRouter}
            imgAttributes={imgAttributes}
          />
        ) : null}
      </>
    )
  }
)

 

우선 useContext를 통해서 페이지 라우터여부, imageConfig 설정을 받아옵니다.

이후 환경변수 ,context, 기본설정 순으로 존재 여부 확인 후 적용할 config를 결정합니다.

그리고 props중에 유저가 설정한 onLoaded와 onLoadedComplete를 구조분해할당합니다.

 

이후 아래와 같이 getImgProps 함수에 할당해 줍니다.  ( getImageProps 에도 사용되는 공용함수)

    const { props: imgAttributes, meta: imgMeta } = getImgProps(props, {
      defaultLoader,
      imgConf: config,
      blurComplete,
      showAltText,
    })

 

네이밍에서도 알 수 있듯이 이미지 등록에 필요한 과정들과 이를 바탕으로 next.js의 이미지 컴퍼넌트에 사용할 props를 반환해 줍니다.

 

이후  이미지 요소와 preLoad요소를 반환된 설정에 맞춰서  보여줍니다.

따라서 어떤 요소를 반환하는 함수인지에 대해서 먼저 확인해보려고 합니다.

 

getImgProps

400줄에 달하는 코드라서 한번에 다 보여주기 보다는 부분 부분 잘라서 설명할려고 합니다.( 내부에 사용하는 함수까지 합치면 600줄 )
코드 전문은 아래에서 확인해보실 수 있습니다.

https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/get-img-props.ts

 

next.js/packages/next/src/shared/lib/get-img-props.ts at canary · vercel/next.js

The React Framework. Contribute to vercel/next.js development by creating an account on GitHub.

github.com

 

우선 매개변수와 반환값의 타입부터 확인하겠습니다

  const { props: imgAttributes, meta: imgMeta } = getImgProps(props, {
      defaultLoader,
      imgConf: config,
      blurComplete,
      showAltText,
    })

Image 컴퍼넌트에서는 위와 같이 getImgProps 함수를 사용했었습니다. img 태그에 사용할 설정과 image를 어떻게 상태관리할지에 대해서 2가지가 나옵니다. 그리고 반환값으로 

이중  onLoadingComplete, layout,  objectFit,  objectPosition, lazyBoundary,  lazyRoot는 더 이상 사용하지 않아서 봤더니 deprecated됐다고 하네요.

  {
    src,
    sizes,
    unoptimized = false,
    priority = false,
    loading,
    className,
    quality,
    width,
    height,
    fill = false,
    style,
    overrideSrc,
    onLoad,
    onLoadingComplete,
    placeholder = 'empty',
    blurDataURL,
    fetchPriority,
    layout,
    objectFit,
    objectPosition,
    lazyBoundary,
    lazyRoot,
    ...rest
  }: ImageProps,
  _state: {
    defaultLoader: ImageLoaderWithConfig
    imgConf: ImageConfigComplete
    showAltText?: boolean
    blurComplete?: boolean
  }
): {
  props: ImgProps
  meta: {
    unoptimized: boolean
    priority: boolean
    placeholder: NonNullable<ImageProps['placeholder']>
    fill: boolean
  }
} {

 

 

이후 img태그에 직접사용할 요소는 props로 그리고 meta데이터를 반환해줍니다.

다양한 조건문을 통한 에러검증이 내부에 존재하는데, 예외처리 및 props를 활용하여 img의 크기 등이 결정되면은

generateImgAttrs함수가 실행됩니다.

  const imgAttributes = generateImgAttrs({
    config,
    src,
    unoptimized,
    width: widthInt,
    quality: qualityInt,
    sizes,
    loader,
  })

 

function generateImgAttrs({
  config,
  src,
  unoptimized,
  width,
  quality,
  sizes,
  loader,
}: GenImgAttrsData): GenImgAttrsResult {
  if (unoptimized) {
    return { src, srcSet: undefined, sizes: undefined }
  }

  const { widths, kind } = getWidths(config, width, sizes)
  const last = widths.length - 1

  return {
    sizes: !sizes && kind === 'w' ? '100vw' : sizes,
    srcSet: widths
      .map(
        (w, i) =>
          `${loader({ config, src, quality, width: w })} ${
            kind === 'w' ? w : i + 1
          }${kind}`
      )
      .join(', '),

    // It's intended to keep `src` the last attribute because React updates
    // attributes in order. If we keep `src` the first one, Safari will
    // immediately start to fetch `src`, before `sizes` and `srcSet` are even
    // updated by React. That causes multiple unnecessary requests if `srcSet`
    // and `sizes` are defined.
    // This bug cannot be reproduced in Chrome or Firefox.
    src: loader({ config, src, quality, width: widths[last] }),
  }
}

해당 함수를 통해서 sizes와 srcSet, src가 결정됩니다.

srcSet 코드를 보면 loader를 활용하여 반환된 이름이 나오고 아래와 같이 이름이 나온 이유가 유추가능합니다.

next.js image

 

마지막으로 반환되는 내용들은 아래와 같습니다. imgAttributes에서 sizes, srcSet, src를 반환해줬는데 해당 값을 props에 다시 할당해줬네요. 

  const props: ImgProps = {
    ...rest,
    loading: isLazy ? 'lazy' : loading,
    fetchPriority,
    width: widthInt,
    height: heightInt,
    decoding: 'async',
    className,
    style: { ...imgStyle, ...placeholderStyle },
    sizes: imgAttributes.sizes,
    srcSet: imgAttributes.srcSet,
    src: overrideSrc || imgAttributes.src,
  }
  const meta = { unoptimized, priority, placeholder, fill }
  return { props, meta }
}

 

 

const ImageElement = forwardRef<HTMLImageElement | null, ImageElementProps>(
  (
    {
      src,
      srcSet,
      sizes,
      height,
      width,
      decoding,
      className,
      style,
      fetchPriority,
      placeholder,
      loading,
      unoptimized,
      fill,
      onLoadRef,
      onLoadingCompleteRef,
      setBlurComplete,
      setShowAltText,
      sizesInput,
      onLoad,
      onError,
      ...rest
    },
    forwardedRef
  ) => {
    return (
      <img
        {...rest}
        {...getDynamicProps(fetchPriority)}
        // It's intended to keep `loading` before `src` because React updates
        // props in order which causes Safari/Firefox to not lazy load properly.
        // See https://github.com/facebook/react/issues/25883
        loading={loading}
        width={width}
        height={height}
        decoding={decoding}
        data-nimg={fill ? 'fill' : '1'}
        className={className}
        style={style}
        // It's intended to keep `src` the last attribute because React updates
        // attributes in order. If we keep `src` the first one, Safari will
        // immediately start to fetch `src`, before `sizes` and `srcSet` are even
        // updated by React. That causes multiple unnecessary requests if `srcSet`
        // and `sizes` are defined.
        // This bug cannot be reproduced in Chrome or Firefox.
        sizes={sizes}
        srcSet={srcSet}
        src={src}
        ref={useCallback(
          (img: ImgElementWithDataProp | null) => {
            if (forwardedRef) {
              if (typeof forwardedRef === 'function') forwardedRef(img)
              else if (typeof forwardedRef === 'object') {
                // @ts-ignore - .current is read only it's usually assigned by react internally
                forwardedRef.current = img
              }
            }
            if (!img) {
              return
            }
            if (onError) {
              // If the image has an error before react hydrates, then the error is lost.
              // The workaround is to wait until the image is mounted which is after hydration,
              // then we set the src again to trigger the error handler (if there was an error).
              // eslint-disable-next-line no-self-assign
              img.src = img.src
            }
            if (process.env.NODE_ENV !== 'production') {
              if (!src) {
                console.error(`Image is missing required "src" property:`, img)
              }
              if (img.getAttribute('alt') === null) {
                console.error(
                  `Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.`
                )
              }
            }
            if (img.complete) {
              handleLoading(
                img,
                placeholder,
                onLoadRef,
                onLoadingCompleteRef,
                setBlurComplete,
                unoptimized,
                sizesInput
              )
            }
          },
          [
            src,
            placeholder,
            onLoadRef,
            onLoadingCompleteRef,
            setBlurComplete,
            onError,
            unoptimized,
            sizesInput,
            forwardedRef,
          ]
        )}
        onLoad={(event) => {
          const img = event.currentTarget as ImgElementWithDataProp
          handleLoading(
            img,
            placeholder,
            onLoadRef,
            onLoadingCompleteRef,
            setBlurComplete,
            unoptimized,
            sizesInput
          )
        }}
        onError={(event) => {
          // if the real image fails to load, this will ensure "alt" is visible
          setShowAltText(true)
          if (placeholder !== 'empty') {
            // If the real image fails to load, this will still remove the placeholder.
            setBlurComplete(true)
          }
          if (onError) {
            onError(event)
          }
        }}
      />
    )
  }
)

 

defaultLoader의 코드를 자세히 분석하진 않을거지만 로더에 사용되는 Config값을 확인하면  Next.js  Size Optimization에 대해서 유추가능합니다. 왜 /_next/image라는 경로가 나오고, 디바이스 사이즈들은 어떤걸 다루는 지, 이미지 포멧 등등..) 

export const imageConfigDefault: ImageConfigComplete = {
  deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  path: '/_next/image',
  loader: 'default',
  loaderFile: '',
  domains: [],
  disableStaticImages: false,
  minimumCacheTTL: 60,
  formats: ['image/webp'],
  dangerouslyAllowSVG: false,
  contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`,
  contentDispositionType: 'attachment',
  remotePatterns: [],
  unoptimized: false,
}

 

Image의 JSX요소인 ImageElement와 ImagePreload

return (
      <>
        {
          <ImageElement
            {...imgAttributes}
            unoptimized={imgMeta.unoptimized}
            placeholder={imgMeta.placeholder}
            fill={imgMeta.fill}
            onLoadRef={onLoadRef}
            onLoadingCompleteRef={onLoadingCompleteRef}
            setBlurComplete={setBlurComplete}
            setShowAltText={setShowAltText}
            sizesInput={props.sizes}
            ref={forwardedRef}
          />
        }
        {imgMeta.priority ? (
          <ImagePreload
            isAppRouter={isAppRouter}
            imgAttributes={imgAttributes}
          />
        ) : null}
      </>
    )
  }

 

ImagePreload

앞서 meta의 반환값으로 getImgProps함수가 meta를 반환하고 있고, 이를 Image 컴퍼넌트에서는 imgMeta에 할당하여 상용합니다. 이중에서 priority가 체크된 이미지의 경우 Preload가 일어납니다. 이때 Approuter에서는 일어나고, ReactDom의 preload함수를 사용하고 pageRouter에서는 Head와 link태그를 활용해서 preload를 합니다.

function ImagePreload({
  isAppRouter,
  imgAttributes,
}: {
  isAppRouter: boolean
  imgAttributes: ImgProps
}) {
  const opts = {
    as: 'image',
    imageSrcSet: imgAttributes.srcSet,
    imageSizes: imgAttributes.sizes,
    crossOrigin: imgAttributes.crossOrigin,
    referrerPolicy: imgAttributes.referrerPolicy,
    ...getDynamicProps(imgAttributes.fetchPriority),
  }

  if (isAppRouter && ReactDOM.preload) {
    // See https://github.com/facebook/react/pull/26940
    ReactDOM.preload(
      imgAttributes.src,
      // @ts-expect-error TODO: upgrade to `@types/react-dom@18.3.x`
      opts
    )
    return null
  }

  return (
    <Head>
      <link
        key={
          '__nimg-' +
          imgAttributes.src +
          imgAttributes.srcSet +
          imgAttributes.sizes
        }
        rel="preload"
        // Note how we omit the `href` attribute, as it would only be relevant
        // for browsers that do not support `imagesrcset`, and in those cases
        // it would cause the incorrect image to be preloaded.
        //
        // https://html.spec.whatwg.org/multipage/semantics.html#attr-link-imagesrcset
        href={imgAttributes.srcSet ? undefined : imgAttributes.src}
        {...opts}
      />
    </Head>
  )
}

 

 

ImageElement

 

const ImageElement = forwardRef<HTMLImageElement | null, ImageElementProps>(
  (
    {
      src,
      srcSet,
      sizes,
      height,
      width,
      decoding,
      className,
      style,
      fetchPriority,
      placeholder,
      loading,
      unoptimized,
      fill,
      onLoadRef,
      onLoadingCompleteRef,
      setBlurComplete,
      setShowAltText,
      sizesInput,
      onLoad,
      onError,
      ...rest
    },
    forwardedRef
  ) => {
    return (
      <img
        {...rest}
        {...getDynamicProps(fetchPriority)}
        // It's intended to keep `loading` before `src` because React updates
        // props in order which causes Safari/Firefox to not lazy load properly.
        // See https://github.com/facebook/react/issues/25883
        loading={loading}
        width={width}
        height={height}
        decoding={decoding}
        data-nimg={fill ? 'fill' : '1'}
        className={className}
        style={style}
        // It's intended to keep `src` the last attribute because React updates
        // attributes in order. If we keep `src` the first one, Safari will
        // immediately start to fetch `src`, before `sizes` and `srcSet` are even
        // updated by React. That causes multiple unnecessary requests if `srcSet`
        // and `sizes` are defined.
        // This bug cannot be reproduced in Chrome or Firefox.
        sizes={sizes}
        srcSet={srcSet}
        src={src}
        ref={useCallback(
          (img: ImgElementWithDataProp | null) => {
            if (forwardedRef) {
              if (typeof forwardedRef === 'function') forwardedRef(img)
              else if (typeof forwardedRef === 'object') {
                // @ts-ignore - .current is read only it's usually assigned by react internally
                forwardedRef.current = img
              }
            }
            if (!img) {
              return
            }
            if (onError) {
              // If the image has an error before react hydrates, then the error is lost.
              // The workaround is to wait until the image is mounted which is after hydration,
              // then we set the src again to trigger the error handler (if there was an error).
              // eslint-disable-next-line no-self-assign
              img.src = img.src
            }
            if (process.env.NODE_ENV !== 'production') {
              if (!src) {
                console.error(`Image is missing required "src" property:`, img)
              }
              if (img.getAttribute('alt') === null) {
                console.error(
                  `Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.`
                )
              }
            }
            if (img.complete) {
              handleLoading(
                img,
                placeholder,
                onLoadRef,
                onLoadingCompleteRef,
                setBlurComplete,
                unoptimized,
                sizesInput
              )
            }
          },
          [
            src,
            placeholder,
            onLoadRef,
            onLoadingCompleteRef,
            setBlurComplete,
            onError,
            unoptimized,
            sizesInput,
            forwardedRef,
          ]
        )}
        onLoad={(event) => {
          const img = event.currentTarget as ImgElementWithDataProp
          handleLoading(
            img,
            placeholder,
            onLoadRef,
            onLoadingCompleteRef,
            setBlurComplete,
            unoptimized,
            sizesInput
          )
        }}
        onError={(event) => {
          // if the real image fails to load, this will ensure "alt" is visible
          setShowAltText(true)
          if (placeholder !== 'empty') {
            // If the real image fails to load, this will still remove the placeholder.
            setBlurComplete(true)
          }
          if (onError) {
            onError(event)
          }
        }}
      />
    )
  }
)

그리고 ref에서 loading을 통해 이미지 로드 완료시 블러처리 이벤트 전파 관리등의 추가작업을 실행하고 있습니다.

이때 getImgProps함수를 통해 반환했던 width값등이 지정되기 때문에 layoutShift가 방지가 됩니다.

https://web.dev/articles/optimize-cls?hl=ko

 

레이아웃 변경 누적 최적화  |  Articles  |  web.dev

레이아웃 변경 횟수 (CLS)는 사용자가 페이지 콘텐츠의 갑작스러운 변화를 경험하는 빈도를 정량화하는 측정항목입니다. 이 가이드에서는 크기 또는 동적 콘텐츠가 없는 이미지 및 iframe과 같이 C

web.dev

 

Asset Flexibility에 대해서는 찾아보지 못했지만, Next.js에서 어떻게 Size Optimization, Visual Stability, Faster Page Loads 를 하려고 했는지는 직접 확인할 수 있었습니다.

 

느낀점

1. 다양한 이슈를 확인하고 에러처리를 볼 수 있어서 좋았다.

대표적인게 getDynamicProps함수인데, next.js의 19버전과 이전 버전에서 옵션의 네이밍 컨벤션이 달라진걸 처리하는 함수이다.

function getDynamicProps(
  fetchPriority?: string
): Record<string, string | undefined> {
  if (Boolean(use)) {
    // In React 19.0.0 or newer, we must use camelCase
    // prop to avoid "Warning: Invalid DOM property".
    // See https://github.com/facebook/react/pull/25927
    return { fetchPriority }
  }
  // In React 18.2.0 or older, we must use lowercase prop
  // to avoid "Warning: Invalid DOM property".
  return { fetchpriority: fetchPriority }
}

 

2. Image컴퍼넌트와 img태그에 대한 학습

img.complete등 생각보다 모르는 내용들이 있었고, Image컴퍼넌트에 대해서도 몰라서 사용하지 않았던 내용들이 있었느데, 소스 코드를 보면서 알 수 있어서 좋았습니다.  개발 실력을 기르려면 오픈소스 코드를 많이 참고하라는 이유를 느낄 수 있었습니다.

 

3. 컴퍼넌트 분리 기준에 대한 호기심

 구성된 코드가 제 생각보다 굉장히 복잡했습니다. 그런데 생각보다 컴퍼넌트가 간략하게 분리되어있어서 놀랐습니다.

error처리가 함수로 분리된게 아닌 직접 적혀있었는데, 이게 라이브러리라서 이런건지 어떤 기준으로 분리를 한건지 개인적으로 호기심이 생겼습니다.

 

4. 네이밍이 좋아서 코드가 잘 읽힌다.

네이밍이 굉장히 좋다는 생각이 드는 부분도 있었고 getImageProps와 getImgProps처럼 애매한 네이밍도 보였다고 생각했느데, 나름의 합리성이 느껴지고 덕분에 코드가 읽기 더 수월했습니다. 좋은 변수명이 항상 어려운데 이런걸 보다 보면 늘지 않을까 라는 생각이 들었습니다.

 

5. 정리하기엔 너무 방대한 코드

연계되어있는 기능들이 너무 많아서 한 블로그 포스팅에 정리하기엔 너무 많았습니다. 간략하게 다루면서 생략된 내용들이 조금 아쉬운 거 같습니다.

 

 

 

참고한 링크

https://developer.mozilla.org/ko/docs/Web/HTML/Element/img

 

<img>: 이미지 삽입 요소 - HTML: Hypertext Markup Language | MDN

HTML <img> 요소는 문서에 이미지를 넣습니다.

developer.mozilla.org

 

https://github.com/vercel/next.js/tree/canary

 

GitHub - vercel/next.js: The React Framework

The React Framework. Contribute to vercel/next.js development by creating an account on GitHub.

github.com

https://nextjs.org/docs/app/api-reference/components/image

 

Components: <Image> | Next.js

Optimize Images in your Next.js Application using the built-in `next/image` Component.

nextjs.org

https://web.dev/articles/optimize-cls?hl=ko

 

레이아웃 변경 누적 최적화  |  Articles  |  web.dev

레이아웃 변경 횟수 (CLS)는 사용자가 페이지 콘텐츠의 갑작스러운 변화를 경험하는 빈도를 정량화하는 측정항목입니다. 이 가이드에서는 크기 또는 동적 콘텐츠가 없는 이미지 및 iframe과 같이 C

web.dev

 

728x90

Next.js에서는 auth.js ( 구 Next.auth)를 통해서 유저 인증(authentication )및 권한 부여(authorization)를 손쉽게 구현 할 수 있습니다.

 

Oauth를 활용하여 유저 인증을 하는 방법은 앞선 게시글에서 다루었고, 권한 설정에 대해서 다루어 볼려고 합니다.

https://ungumungum.tistory.com/110

 

Auth.js로 Oauth 구현하기 (예시 : google)

NextAuth가  다양한 프레임워크를 지원하기 위해서 확장되면서 Auth.js로 바꼈습니다.그러면서 사용법도 살짝 바꼈고 공식문서를 보고 학습한 부분을 포스팅합니다. 설치방법1. Auth.js설치npm install

ungumungum.tistory.com

 

 

방법 1. session을 통해서 다루기

우선 auth.ts 파일에서 유저 정보를 얻을 수 있는 auth함수를 반환하고 있습니다.

//auth.ts
import NextAuth, { NextAuthConfig } from 'next-auth';
import Google from 'next-auth/providers/google';

export const authConfig = {
  theme: { logo: 'https://authjs.dev/img/logo-sm.png' },
  providers: [Google],
} satisfies NextAuthConfig;

export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);

 

이를 활용하여 sesion 정보의 유무를 확인할 수 있고, 만약 로그인 되어있지 않다면 원하는 주소로 redirect 시킬 수 있습니다. 

import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function Mypage() {
  const session = await auth();
  if (!session) return redirect('/');
  return <main>로그인해야 볼 수 있는 페이지</main>;
}

 

다만 이렇게 할 경우 모든 페이지에 대해서 동일한 코드가 반복되어야 합니다. 또한 관심사가 분리되지 않아서, 어떤 페이지가 권한이 필요한지 한눈에 파악하기가 힘듭니다.

 

방법 2.  config에서 callback 설정하기

Next.js의 공식문서 tutorial에서는 아래와 같은 코드를 통해  접근권한을 관리합니다.

import type { NextAuthConfig } from 'next-auth';

export const authConfig = {
// authorized에서 false가 반환될 때 이동할 로그인 페이지(signIn)
  pages: {
    signIn: '/login',
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
    // user가 있으면 로그인 없으면 로그인 되지 않은 상태
      const isLoggedIn = !!auth?.user;
      // url의 경로가 /dashboard로 시작하는지
      const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
      
      // dashboard에서는 권한이 필요하므로, 비로그인시 login페이지로 이동
      if (isOnDashboard) {
        if (isLoggedIn) return true;
        return false; // Redirect unauthenticated users to login page
      } else if (isLoggedIn) {
      // 만약 로그인 된 유저라면 홈에서 origin과 더해서 /dashboard로 이동
        return Response.redirect(new URL('/dashboard', nextUrl));
      }
      return true;
    },
  },
  providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;

config설정의 callbacks를 통하여서 권한 확인할 수 있는데,  이중 authorized는 next.js의 middleware와 함께 사용해서 

true를 반환하면, 권한 인증, false를 반환하면 권한이 없다는 것이다.

 

저는 위 코드를 기반으로 mypage에 접속햇는데 비로그인시 /로 이동하도록 하였습니다.

export const authConfig = {
  pages: {
    signIn: '/',
  },
  theme: { logo: 'https://authjs.dev/img/logo-sm.png' },
  callbacks: {
     authorized({ auth, request: { nextUrl } }) {
       const isLoggedIn = !!auth?.user;
       const protectedPath = ['/mypage'];
       const isProtected = protectedPath.includes(nextUrl.pathname);
       if (isProtected) {
         if (isLoggedIn) return true;
         return Response.redirect(new URL('/', nextUrl));
       }
       return true;
     },
   },
  providers: [Google],
} satisfies NextAuthConfig;

 

이제 위 코드를 적용하기 위해서는 middleware에 등록해야 합니다.

https://authjs.dev/reference/nextjs#authorized

 

미들웨어 등록

//middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth';

export default NextAuth(authConfig).auth;

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

 

next.js에서는 middleware 파일에서 미들웨어등록을 할 수 있습니다.

이때 config에 matcher를 통해서 언제 미들웨어를 사용할 지 결정할 수 있는데,

정규식의 부정형 전방 탐색을 활용하여

 

  1. api
  2. _next/static
  3. _next/image
  4. .png로 끝나는 문자열

을 포함한 경우에는 middleware가 실행되지 않도록 하였습니다.
( next.js에서는  불필요한 요청을 줄이기 위해서 cache가 진행되는데, _next/static , _next/image는 각각 cache된 파일들의 경로입니다.)

Middleware allows you to run code before a request is completed. Then, based on the incoming request, you can modify the response by rewriting, redirecting, modifying the request or response headers, or responding directly.
// https://nextjs.org/docs/pages/building-your-application/routing/middleware

 

이렇게 미들웨어를 사용하는 경우에는 특정페이지 요청전에 미들웨어가 실행되어 불필요한 요청을 막을 수 있습니다.

 

next.js에서 파일의 실행순서는 아래와 같습니다.

  1. headers from next.config.js
  2. redirects from next.config.js
  3. Middleware (rewrites, redirects, etc.)
  4. beforeFiles (rewrites) from next.config.js
  5. Filesystem routes (public/, _next/static/, pages/, app/, etc.)
  6. afterFiles (rewrites) from next.config.js
  7. Dynamic Routes (/blog/[slug])
  8. fallback (rewrites) from next.config.js

 

참고 문헌

https://nextjs.org/docs/pages/building-your-application/routing/middleware

 

Routing: Middleware | Next.js

Learn how to use Middleware to run code before a request is completed.

nextjs.org

 


https://authjs.dev/reference/nextjs#callbacks

 

 

Auth.js | Nextjs

Authentication for the Web

authjs.dev

https://authjs.dev/reference/nextjs#authorized

 

Auth.js | Nextjs

Authentication for the Web

authjs.dev

 

728x90

NextAuth가  다양한 프레임워크를 지원하기 위해서 확장되면서 Auth.js로 바꼈습니다.

그러면서 사용법도 살짝 바꼈고 공식문서를 보고 학습한 부분을 포스팅합니다.

 

설치방법

1. Auth.js설치

npm install next-auth@beta


beta를 생략해도 됩니다.

 

이후 .env파일을 만든후에  암호화에 사용할 AUTH_SECRET을 만듭니다.

npx auth secret


을 실행하고 env파일에 넣어주면 됩니다.

 

2. Oauth 등록

이후 google-Oauth를 사용해야하니 계정을 만들고 등록합니다.

Oauth에 대한 자세한 설명한 아래 링크 참조하시면 좋습니다.

https://goldenrabbit.co.kr/2023/08/07/oauth%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-%EA%B5%AC%EA%B8%80-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%9D%B8%EC%A6%9D%ED%95%98%EA%B8%B0-1%ED%8E%B8/

 

OAuth를 사용한 구글 로그인 인증하기 1편 - OAuth 소개와 준비하기 - 골든래빗

'[Node.js] 자바스크립트 비동기 개념에 익숙해지기'는 총 3편에 걸쳐서 콜백 함수, 프로미스, async await 구문을 소개할 예정입니다. 1편에서는 자바스크립트 비동기 개념을 이해하고, 콜백 함수 예

goldenrabbit.co.kr

계정 생성과정까지는 동일한데, 리디랙션 URI만 아래와 같이 수정합니다

[origin]/api/auth/callback/google

http://localhost:3000/api/auth/callback/google


생성된 환경변수는 아래와 같은 환경변수로 해야하지 추가 작업이 줍니다.(아니면 일일이 사용할 떄마다 등록)
AUTH_GOOGLE_ID={CLIENT_ID}
AUTH_GOOGLE_SECRET={CLIENT_SECRET}

 
이후 루트경로에 provider를 등록합니다.

import NextAuth, { NextAuthConfig } from 'next-auth';
import Google from 'next-auth/providers/google';

export const config = {
  theme: { logo: 'https://authjs.dev/img/logo-sm.png' },
  providers: [Google],
} satisfies NextAuthConfig;

export const { handlers, signIn, signOut, auth } = NextAuth(config);

마지막으로 로그인 버튼에 아래와 같이 서버액션으로 사용하면 끝납니다.

import { signIn } from "@/auth.ts"
 
export function SignIn() {
  return (
    <form
      action={async () => {
        "use server"
        await signIn("google")
      }}
    >
      <button type="submit">Signin with Google</button>
    </form>
  )
}


이떄 서버액션을 사용하는 이유는 서버에서 작업을 하는 함수기에 보안적인 측면에서 장점이 있기 때문입니다.
https://nextjs.org/learn/dashboard-app/mutating-data

 

Learn Next.js: Mutating Data | Next.js

Mutate data using React Server Actions, and revalidate the Next.js cache.

nextjs.org


https://next-auth-example.vercel.app/

 

NextAuth.js Example

 

next-auth-example.vercel.app

을 통해서 다양한 Oauth및 로그인 방법에 대한 예시를 확인할 수 있습니다.

 

트러블 슈팅
사용할 수 없는 메서드라고 에러가 뜸

이떄 우리가 만든 auth.ts 말고도 next/auth에서 자체적으로 사용가능한 signOut함수가 있는데, 해당 함수가 import되면서 에러가 발생함. 이를 수정

참고문헌

https://authjs.dev/getting-started/installation

 

Auth.js | Installation

Authentication for the Web

authjs.dev

https://authjs.dev/getting-started/authentication/oauth

 

OAuth Providers

Authentication for the Web

authjs.dev

 

728x90

 

웹 바이탈은 웹에서 우수한 사용자 환경을 제공하는 데 필수적인 웹페이지 품질 신호에 관한 통합 가이드를 제공하기 위한 Google 이니셔티브이다.

현재는 아래 3가지를 중요 평가 지표로 사용한다.

출처 : Web vitals

 

이를 next.js의 useReportWebVitals 훅을 사용하면 콘솔에서 우리 웹의 WebVitals를 측정할 수 있다. 

import { useReportWebVitals } from 'next/web-vitals'
 
function MyApp({ Component, pageProps }) {
  useReportWebVitals((metric) => {
    console.log(metric)
  })
 
  return <Component {...pageProps} />
}

공식 문서의 사용법은 위와 같고,  내 프로젝트에 적용한 모습은 아래와 같다.

export default function App({
  Component,
  pageProps: { dehydratedState, session, ...pageProps },
}: AppProps) {
  useReportWebVitals((metric) => {
    console.log(metric)
  })

 

이후 웹 페이지에 접속하면, 아래와 같이 주석에서 확인할 수 있다.

그리고 rating을 통해서 good을 얻으면 괜찮다는 것이다.

 

해당 훅에서는 아래 타입에서 확인할 수 있듯이  FCP, LCP ,CLS, FID, TTFB ,INP를 지원한다.

  1. FCP (First Contentful Paint):
    • 첫 번째 콘텐츠가 화면에 그려지기 시작하는 시점
    • 사용자가 페이지를 로드했을 때 실제로 눈에 보이는 첫 번째 콘텐츠가 얼마나 빨리 표시되는지를 측정
  2. LCP (Largest Contentful Paint):
    • 페이지에서 가장 큰 콘텐츠 요소가 화면에 완전히 그려지는 시점
    • 사용자가 페이지에서 가장 중요한 콘텐츠를 볼 수 있는 시간을 측정하여 페이지의 시각적 로딩 성능을 평가
  3. CLS (Cumulative Layout Shift):
    • 페이지의 레이아웃이 얼마나 불안정한지를 측정. (랜더링 도중에 화면이 얼마나 밀리는 지)
    • 사용자가 페이지를 로드할 때 콘텐츠의 위치가 얼마나 자주 변경되는지를 측정하여 불안정한 레이아웃을 감지
  4. FID (First Input Delay):
    • 사용자가 페이지와 상호작용하려고 할 때 브라우저가 해당 입력에 대해 얼마나 빨리 반응하는지
    • 페이지가 사용자의 입력에 얼마나 빠르게 반응하는지를 측정하여 사용자 경험을 평가
  5. TTFB (Time to First Byte):
    • 사용자가 페이지를 요청한 후 서버가 첫 번째 바이트를 응답하는 데 걸리는 시간
    • 서버 응답 시간을 측정하여 웹 페이지의 초기 로딩 성능을 평가
  6. INP (Input Timing):
    • 사용자 상호작용에 대한 성능을 나타내는 지표
    • 사용자가 페이지와 상호작용하고 실제로 응답이 일어나는 시점을 측정하여 사용자 경험을 평가
export type NextWebVitalsMetric = {
  id: string
  startTime: number
  value: number
} & (
  | {
      label: 'web-vital'
      name: 'FCP' | 'LCP' | 'CLS' | 'FID' | 'TTFB' | 'INP'
    }
  | {
      label: 'custom'
      name:
        | 'Next.js-hydration'
        | 'Next.js-route-change-to-render'
        | 'Next.js-render'
    }
)

 https://github.com/vercel/next.js/blob/442378d21dd56d6e769863eb8c2cb521a463a2e0/packages/next/shared/lib/utils.ts#L43

 

 

웹성능은  LightHouse를 통해서도 측정할 수 있다. 

LIghtHouse에서는 FID 대신 TBT를 측정한다.


TBT는 사용자와 페이지가 상호작용한 경우 해당 요청을 처리하는 시간을 의미하는데,  상호작용에 대한 응답이 50ms가 넘어가면, 그때부턴 사용자의 다음 상호작용을 차단하는 긴 작업으로 인지한다

 

 

 

 

https://nextjs.org/docs/pages/api-reference/functions/use-report-web-vitals

 

 

Functions: useReportWebVitals | Next.js

useReportWebVitals

nextjs.org

https://web.dev/articles/vitals?hl=ko

 

Web Vitals  |  Articles  |  web.dev

건전한 사이트를 위한 필수 측정항목

web.dev

https://web.dev/articles/tbt?hl=ko

 

총 차단 시간 (TBT)  |  Articles  |  web.dev

이 게시물에서는 총 차단 시간 (TBT) 측정항목을 소개하고 이를 측정하는 방법을 설명합니다.

web.dev

 

728x90

 

Next.js-14 튜토리얼 따라가기 -1 (1~4) 라우팅 및 기본 세팅 폰트 이미지

 

Next.js-14 튜토리얼 따라가기 -1

next.js 14버전이 나왔다. 물론 난 처음이다. 이력서 제출하려고 하는데, next.js 쓰는 회사가 생각보다 너무 많아서 튜토리얼이라도 우선적으로 학습하려고 한다.. 튜토리얼 따라가기 -2 링크 : 5~8장

ungumungum.tistory.com

Next.js-14 튜토리얼 따라가기 -2 (5~8장)  페이지 이동  정적 및 동적 랜더링

 

Next.js-14 튜토리얼 따라가기 -2 (5~8장)

1~4장을 통해서 간단하게 next에서는 UI. 이미지 . 라우팅. 폰트를 어떻게 구현 및 처리했는지를 확인했다. 5장 Navigating Between Pages 5장에서는 link, navigation 원리 , usePathname() 훅 , 에 대해서 알아볼 것

ungumungum.tistory.com

Next.js-14 튜토리얼 따라가기 -3 (9장~ 12장) CRUD하기

 

Next.js-14 튜토리얼 따라가기 -3 (9장~ 12장)

Next.js-14 튜토리얼 따라가기 -2 (5~8장) Next.js-14 튜토리얼 따라가기 -2 (5~8장) 1~4장을 통해서 간단하게 next에서는 UI. 이미지 . 라우팅. 폰트를 어떻게 구현 및 처리했는지를 확인했다. 5장 Navigating Betw

ungumungum.tistory.com

 

 

13장 Handling Error

1. 에러가 났을 때. error.tsx를 사용해 루트요소에서 에러를 잡고, 어떻게 fallback 처리하는지

2. 404로 not found 에러가 났을 때 처리하는 법을 배울 차례입니다.

 

1. 에러 처리하기

 

에러 처리하기 위해서 12장에서 작성했던 cud 에서 try catch 처리를 먼저 해야합니다.

'use server';

import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

const InvoiceSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});

const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });
const UpdateInvoice = InvoiceSchema.omit({ date: true, id: true });

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];
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    return {
      message: 'Database Error: Failed to Create Invoice.',
    };
  }

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

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;

  try {
    await sql`
        UPDATE invoices
        SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
        WHERE id = ${id}
      `;
  } catch (error) {
    return { message: 'Database Error: Failed to Update Invoice.' };
  }

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

export async function deleteInvoice(id: string) {
  try {
    await sql`DELETE FROM invoices WHERE id = ${id}`;
    revalidatePath('/dashboard/invoices');
    return { message: 'Deleted Invoice.' };
  } catch (error) {
    return { message: 'Database Error: Failed to Delete Invoice.' };
  }
}

 

 

하지만 에러를 아래와 같이 처리한다면, 에러가 떴을 때 페이지 진행을 할 수 없어진다. 이게  error.tsx 파일이 생긴 이유다.

 

export async function deleteInvoice(id: string) {
  throw new Error('Failed to Delete Invoice');

  try {
    await sql`DELETE FROM invoices WHERE id = ${id}`;
    revalidatePath('/dashboard/invoices');
    return { message: 'Deleted Invoice.' };
  } catch (error) {
    return { message: 'Database Error: Failed to Delete Invoice.' };
  }
}

 

 

이제 error.tsx 파일을 /dashboard/invoices 에추가해보자

'use client';
 
import { useEffect } from 'react';
 
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Optionally log the error to an error reporting service
    console.error(error);
  }, [error]);
 
  return (
    <main className="flex h-full flex-col items-center justify-center">
      <h2 className="text-center">Something went wrong!</h2>
      <button
        className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
        onClick={
          // Attempt to recover by trying to re-render the invoices route
          () => reset()
        }
      >
        Try again
      </button>
    </main>
  );
}

이후 에러가 뜬다면 아래와 같이 보일 것이다.

 

이때 error와 reset을 위 코드에서 확인할 수 있는데

error : 자바스크립트 error객체의 instance이다.

reset : 에러 경계를 리샛시켜주는 버튼으로,  해당 루트요소를 리랜더링 시켜준다.

 

 

2. No-Found 처리하기

 

존재하지 않는 아이디로 접속하면 아래와 같은 가짜 화면을 보개 될 것이다.(값이 없는)

 

import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
import { notFound } from 'next/navigation';

export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);

  if (!invoice) {
    notFound();
  }
  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Edit Invoice',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      {invoice && <Form invoice={invoice} customers={customers} />}
    </main>
  );
}

위 코드를 추가하면 이제 페이지가 없을 때 에러가 생기기에 error.tsx가 랜더링된다.

 

이때 생긴 에러는 notfound 에러이므로 not-found.tsx를 아래와 같은 폴더 위치에 추가하면 notfound에러처리가 완료된다.

 

 

낫파운드는 error.tsx보다 우선시 랜더링된다.

 

 

 

14장 Improving Accessibility

배울내용은 아래와 같습니다

1. 넥스트에서  eslint-plugin-jsx-a11y 를 활용해 접근성 향상시키기

2. 서버사이드에서  폼 유효성 검사

3. 리엑트에서 useFormState 훅을 통해서 에러처리하고  유저한테 보여주는 법

 

 

1. 넥스트에서  eslint-plugin-jsx-a11y 를 활용해 접근성 향상시키기

접근성이란 ?? keyboard navigation, semantic HTML, images, colors, videos, etc. 등 다양하게 있는데,

모든 사람이 앱을 쓸 수 있게 디자인하고 실행하는 것을 말합니다. (https://web.dev/learn/accessibility/)

 

넥스트에서는   eslint-plugin-jsx-a11y  를 통해서 사전에 이미지에 alt가 없다던가,, 등등 경고를 해줍니다.

"scripts": {
    "build": "next build",
    "dev": "next dev",
    "seed": "node -r dotenv/config ./scripts/seed.js",
    "start": "next start",
    "lint": "next lint"
},

 

현재는 경고가 없지만, 만약 alt를 지운다면??

 

추가적으로 접근성을 향상시키는 대표적인 방법은

시멘틱 태그, 라벨링,  포커스시 경계선 표시등이 있습니다.

 

2. 서버사이드에서 폼 유효성 검사

현재 예시에서는 별다른 조건 없이 폼을 제출가능하게 했는데, 그러면 에러가 발생한다. 이때 required  어트리뷰트를 통해서, 이를 예방할 수 있습니다.

없을 경우 위와 같이 에러가 뜨지만 required가 있으면 아래와 같이 브라우저에서 제출을 막아준다.

하지만 서버에서 유효성 검사를 해야지 아래 3가지 효과를 얻을 수 있다.

1. 디비에 데이터 전송전에 형식을 예측 가능함

2. 클라이언트 사이드 유효성 검사를 무시한 유저를 대비할 수 있음

3. 유효한 정보라는 하나의 신뢰성을 얻음.

 

예제가 많고,, 가독성 차원에서 EXAMPLE 사진을 그대로 가져오겠습니다.. ( __ )

 

우선 useFormState 훅을 임포트하고,   return 값으로 state,dispatch를 가져와 줍니다. (useReducer와 비슷한 모양)

이때 2번쨰 매개변수인 초기값은 할당하고 싶은 것 할당하면 됩니다. 

예제에서는 에러시 뜰 메세지를 null로 errors를 빈 객체로 초기값을 할당했네요

이후 action의 InvoiceSchema에 에러시 나올 문구들을 추가해줍니다.

이후  createInvioce함수에  usesFormState 에서 전달하는 값을 받을 수 있게 매개변수 preState를 추가해줍니다.

export async function createInvoice(prevState: State, formData: FormData) {
  // Validate form using Zod
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // If form validation fails, return errors early. Otherwise, continue.
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
 
  // Prepare data for insertion into the database
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  // Insert data into the database
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    // If a database error occurs, return a more specific error.
    return {
      message: 'Database Error: Failed to Create Invoice.',
    };
  }
 
  // Revalidate the cache for the invoices page and redirect the user.
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

 

마지막으로 zod로 유효성검사를 한 내용에서 성공 및 실패시 결과값을 추출합니다.

<form action={dispatch}>
  <div className="rounded-md bg-gray-50 p-4 md:p-6">
    {/* Customer Name */}
    <div className="mb-4">
      <label htmlFor="customer" className="mb-2 block text-sm font-medium">
        Choose customer
      </label>
      <div className="relative">
        <select
          id="customer"
          name="customerId"
          className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
          defaultValue=""
          aria-describedby="customer-error"
        >
          <option value="" disabled>
            Select a customer
          </option>
          {customerNames.map((name) => (
            <option key={name.id} value={name.id}>
              {name.name}
            </option>
          ))}
        </select>
        <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
      </div>
      {state.errors?.customerId ? (
        <div
          id="customer-error"
          aria-live="polite"
          className="mt-2 text-sm text-red-500"
        >
          {state.errors.customerId.map((error: string) => (
            <p key={error}>{error}</p>
          ))}
        </div>
      ) : null}
    </div>
    // ...
  </div>
</form>

이후 create-form 에서 에러시 에러문구가 나오도록 조건문을 설정합니다.

 

여기서 추가된 것은 select에 aria-describedby ,  에러메시지가 뜨는 div에 id와 aria-live 입니다.

  • aria-describedby="customer-error":  이 어트리뷰트를 통해서 error 메세지 컨테이너와 셀렉트간의 연관성을 설립합니다.  아이디가 customer-error라고 된  컨테이너는 이 select요소를 의미합니다.  유저가 셀렉트 박스와 상호작용할 때, 에러가 발생한다면, 스크린 리더가 이 메시지를 읽습니다.
  • id="customer-error": 위에서 설정한 아이디입니다.
  • aria-live="polite": The screen reader should politely notify the user when the error is updated. When the content changes (e.g. when a user corrects an error), the screen reader will announce these changes, but only when the user is idle so as not to interrupt them.

aria-live는 적용 미적용 차이를 비시각장애인은 인지하기 힘든데, 해당 태그를 설정해야지 시각장애인들이 에러가 났따는 사실을 인지할 수 있다고 하네요.

 

https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions

 

ARIA live regions - Accessibility | MDN

Using JavaScript, it is possible to dynamically change parts of a page without requiring the entire page to reload — for instance, to update a list of search results on the fly, or to display a discreet alert or notification which does not require user i

developer.mozilla.org

 

 

https://bcp0109.tistory.com/348

 

웹 접근성과 WAI-ARIA

Overview HTML 페이지를 만들 때 고려해야 하는 것 중 하나가 웹 접근성입니다. 웹 접근성이란 시각장애인들이 웹 페이지를 원활하게 이용할 수 있도록 알려주는 가이드라인이라고 생각하면 됩니다

bcp0109.tistory.com

동일한 aria-live
위에서부터 off, assertive, polite

 

최종적으로 제가 작성한 코드는 (예제 고객 이름만 예씨로 들어줍니다)

'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';
import { useFormState } from 'react-dom';

export default function Form({ customers }: { customers: CustomerField[] }) {
  const initialState = { message: null, errors: {} };

  const [state, dispatch] = useFormState(createInvoice, initialState);

  return (
    <form action={dispatch}>
      <div
        className="rounded-md bg-gray-50 p-4 md:p-6"
        aria-describedby="invoice-error"
      >
        {/* Customer Name */}
        <div className="mb-4">
          <label htmlFor="customer" className="mb-2 block text-sm font-medium">
            Choose customer
          </label>
          <div className="relative">
            <select
              id="customer"
              name="customerId"
              className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
              defaultValue=""
              aria-describedby="customer-error"
            >
              <option value="" disabled>
                Select a customer
              </option>
              {customers.map((customer) => (
                <option key={customer.id} value={customer.id}>
                  {customer.name}
                </option>
              ))}
            </select>
            <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
          </div>
          {state.errors?.customerId ? (
            <div
              id="customer-error"
              aria-live="polite"
              className="mt-2 text-sm text-red-500"
            >
              {state.errors.customerId.map((error: string) => (
                <p key={error}>{error}</p>
              ))}
            </div>
          ) : null}
        </div>

        {/* Invoice Amount */}
        <div className="mb-4">
          <label htmlFor="amount" className="mb-2 block text-sm font-medium">
            Choose an amount
          </label>
          <div className="relative mt-2 rounded-md">
            <div className="relative">
              <input
                id="amount"
                name="amount"
                type="number"
                step="0.01"
                placeholder="Enter USD amount"
                className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
                aria-describedby="amount-error"
              />
              <CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
            {state.errors?.amount ? (
              <div
                id="amount-error"
                aria-live="polite"
                className="mt-2 text-sm text-red-500"
              >
                {state.errors.amount.map((error: string) => (
                  <p key={error}>{error}</p>
                ))}
              </div>
            ) : null}
          </div>
        </div>

        {/* Invoice Status */}
        <fieldset>
          <legend className="mb-2 block text-sm font-medium">
            Set the invoice status
          </legend>
          <div
            className="rounded-md border border-gray-200 bg-white px-[14px] py-3"
            aria-describedby="status-error"
          >
            <div className="flex gap-4">
              <div className="flex items-center">
                <input
                  id="pending"
                  name="status"
                  type="radio"
                  value="pending"
                  className="h-4 w-4 border-gray-300 bg-gray-100 text-gray-600 focus:ring-2 focus:ring-gray-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-gray-600"
                />
                <label
                  htmlFor="pending"
                  className="ml-2 flex items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300"
                >
                  Pending <ClockIcon className="h-4 w-4" />
                </label>
              </div>
              <div className="flex items-center">
                <input
                  id="paid"
                  name="status"
                  type="radio"
                  value="paid"
                  className="h-4 w-4 border-gray-300 bg-gray-100 text-gray-600 focus:ring-2 focus:ring-gray-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-gray-600"
                />
                <label
                  htmlFor="paid"
                  className="ml-2 flex items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white dark:text-gray-300"
                >
                  Paid <CheckIcon className="h-4 w-4" />
                </label>
              </div>
            </div>
          </div>
          {state.errors?.status ? (
            <div
              id="status-error"
              aria-live="polite"
              className="mt-2 text-sm text-red-500"
            >
              {state.errors.status.map((error: string) => (
                <p key={error}>{error}</p>
              ))}
            </div>
          ) : null}
        </fieldset>
        {state.message ? (
          <div
            id="invoice-error"
            aria-live="polite"
            className="mt-2 text-sm text-red-500"
          >
            <p>{state.message}</p>
          </div>
        ) : null}
      </div>
      <div className="mt-6 flex justify-end gap-4">
        <Link
          href="/dashboard/invoices"
          className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
        >
          Cancel
        </Link>
        <Button type="submit">Create Invoice</Button>
      </div>
    </form>
  );
}

 

마찬가지로 수정도 하면 됩니다!

수정은 값 뺴고는 비울 수가 없어서 amount만  에러 처리했습니다.

 

15장 Adding Authentication

배울 내용!

1 .인증이 무엇이고 권한과 차이
2. next에서는 인증과 권한을 어떻게 다루는지  미들웨어로 유저 리다이렉트 시키는 법

3.  진행중 상태와 에러를  `useFormStatus` and `useFormState`  을 써서 관리하기

 

 

1 .인증이 무엇이고 권한과의 차이

 

인증은  시스템이 유저가 누군지 아는 것입니다.

권한은 인증 다음 단계로 해당 유저가 어디까지 접근 가능한지를 결정합니다.

 

이를 구현하기 위해 우선 로그인 페이지를 만들겠습니다.

import AcmeLogo from '@/app/ui/acme-logo';
import LoginForm from '@/app/ui/login-form';
 
export default function LoginPage() {
  return (
    <main className="flex items-center justify-center md:h-screen">
      <div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
        <div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
          <div className="w-32 text-white md:w-36">
            <AcmeLogo />
          </div>
        </div>
        <LoginForm />
      </div>
    </main>
  );
}

2 . next에서는 인증과 권한을 어떻게 다루는지  미들웨어로 유저 리다이렉트 시키는 법

 

이제 NextAuth.js 를 추가해서 유저 인증을 진행할 것 입니다.

npm install next-auth@beta bcrypt

 

next-auth@beta는 14버전과 호완되는 NextAuth.js 이고 bcrypt는 유저 정보를 암호화해주는 기능을 가진 라이브러리입니다.

 

이후  .env에  시크릿키와 URL을 설정해줍니다. (아래 시크릿키는  명령어 openssl rand -base64 32 로 만든 예시입니다)

AUTH_SECRET=VqFXv3rlxMfIQBVHe2vg5AfnM/dPzeEjARj17uD7Aus=
AUTH_URL=http://localhost:3000/api/auth

 

다음으로 NextAuth의 구성을 설정해주는  auth.config.ts 파일을 root 경로에 설정해줍니다.

import type { NextAuthConfig } from 'next-auth';
 
export const authConfig = {
  pages: {
    signIn: '/login',
  },
};

페이지를 통해서 sign-in, sign-out, and error pages 경로를 설정할 수 있는데, 추가 설정을 하지 않으면 우리가 설정하지 않은 default 경로를 next.js가 설정한다.

 

이제 해당 파일을 활용해서 미들웨어를 이용한 리다이랙트를 구현할 수 있다.

 

import type { NextAuthConfig } from 'next-auth';
 
export const authConfig = {
  providers: [],
  pages: {
    signIn: '/login',
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
      if (isOnDashboard) {
        if (isLoggedIn) return true;
        return false; // Redirect unauthenticated users to login page
      } else if (isLoggedIn) {
        return Response.redirect(new URL('/dashboard', nextUrl));
      }
      return true;
    },
  },
} satisfies NextAuthConfig;

authorized 의 콜백은 요청이 Next.js 의 미들웨어를 거쳐서 페이지에 접속할 때, 해당 요청이 권한이 있는지를 확인한다.

요청이 완료되기전에 호출되며, auth와 request 프로퍼티를 객체로 받는다. auth 프로퍼티는 유저 세션을 가지고 있꼬, request 프로퍼티는 다가오는 요청을 담고 있다.

 

이제 middleware.ts 파일을 만들고 위 코드를 import 하면 된다.

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';

export default NextAuth(authConfig).auth;

export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.png).*)'],
};

이제 NextAuth.js 를 authConfig 객체를 활용해서 시작했고, 미들웨어에서 matcher 옵션을 사용해서 어떤 경로에서 운영되야 할 지를 구체화 했다. 이렇게 미들웨어를 사용하면  루트가 미들웨어에 의해 검증되기 전에는 랜더링 조차 되지 않아서, 보안과 성능 모두 향상된다.

 

이제 bcrypt 로 암호화해서 보안을 향상시켜야하는데, bcrypt는 node.js API라서 next.js 의 미들웨어에서는 사용이 불가능하다. 따라서 bcrypt를 임포트할 auth.ts 파일을 만들어야 한다.

 

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
});

 

이후 next.auth의 옵션인 providers  을 추가하는데, 이는 구글이나 깃헙 같은 다른 로그인 리스트를 담는 배열인데, 

이 예제에서는 Credentials authentication만 다룬다. (일반로그인, 이메일로그인 ,OAUTH 3가지 중 일반로그인)

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [Credentials({})],
});

 

크래댄셜을 추가하고

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);
      },
    }),
  ],
});

내부에 이메일과 비밀번호 유효성 검사 코드를 추가한다. (zod활용)

 

import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { authConfig } from './auth.config';
import { sql } from '@vercel/postgres';
import { z } from 'zod';
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';
 
async function getUser(email: string): Promise<User | undefined> {
  try {
    const user = await sql<User>`SELECT * from USERS where email=${email}`;
    return user.rows[0];
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw new Error('Failed to fetch user.');
  }
}
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);
 
        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUser(email);
          if (!user) return null;
        }
 
        return null;
      },
    }),
  ],
});

이후 getUser를 통해서 DB에 있는 이메일과 일치한 유저 정보를 가져온다

 

import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { authConfig } from './auth.config';
import { sql } from '@vercel/postgres';
import { z } from 'zod';
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';

async function getUser(email: string): Promise<User | undefined> {
  try {
    const user = await sql<User>`SELECT * from USERS where email=${email}`;
    return user.rows[0];
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw new Error('Failed to fetch user.');
  }
}

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);
        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUser(email);
          if (!user) return null;
          const passwordsMatch = await bcrypt.compare(password, user.password);

          if (passwordsMatch) return user;
        }

        console.log('Invalid credentials');
        return null;
      },
    }),
  ],
});

마지막으로 bcrypt를 통해 가져온 비밀번호를 비교한다.

이후 존재하면 user정보주고 아니면 null을 주어서 로그인 방지한다.

 

 

이후 action.ts 파일에  authenticate 라는 함수를 만들고 auth.js에 있는 signin 을 임포트한다. 이를 통해서 로그인폼에 로그인 로직을 연결 할 것이다.

 

export async function authenticate(
  prevState: string | undefined,
  formData: FormData
) {
  try {
    await signIn('credentials', Object.fromEntries(formData));
  } catch (error) {
    if ((error as Error).message.includes('CredentialsSignin')) {
      return 'CredentialSignin';
    }
    throw error;
  }
}

CredentialSiginin 이 발생하면, 이를 리턴해서 적절한 에러 메시지를 보여줄 수 있다.

끝으로 로그인 폼 컴퍼넌트에서 useFormState를 사용하여 서버액션을 일으키고 에러를 다룰 수 잇따. 그리고 useFormStatus로 전송 중 상태를 다룰 수 있다.

 

'use client';

import { lusitana } from '@/app/ui/fonts';
import {
  AtSymbolIcon,
  KeyIcon,
  ExclamationCircleIcon,
} from '@heroicons/react/24/outline';
import { ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from './button';
import { useFormState, useFormStatus } from 'react-dom';
import { authenticate } from '@/app/lib/actions';

export default function LoginForm() {
  const [code, action] = useFormState(authenticate, undefined);

  return (
    <form action={action} className="space-y-3">
      <div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
        <h1 className={`${lusitana.className} mb-3 text-2xl`}>
          Please log in to continue.
        </h1>
        <div className="w-full">
          <div>
            <label
              className="mb-3 mt-5 block text-xs font-medium text-gray-900"
              htmlFor="email"
            >
              Email
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="email"
                type="email"
                name="email"
                placeholder="Enter your email address"
                required
              />
              <AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
          <div className="mt-4">
            <label
              className="mb-3 mt-5 block text-xs font-medium text-gray-900"
              htmlFor="password"
            >
              Password
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="password"
                type="password"
                name="password"
                placeholder="Enter password"
                required
                minLength={6}
              />
              <KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
        </div>
        <LoginButton />
        <div className="flex h-8 items-end space-x-1">
          {code === 'CredentialSignin' && (
            <>
              <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
              <p aria-live="polite" className="text-sm text-red-500">
                Invalid credentials
              </p>
            </>
          )}
        </div>
      </div>
    </form>
  );
}

function LoginButton() {
  const { pending } = useFormStatus();

  return (
    <Button className="mt-4 w-full" aria-disabled={pending}>
      Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
    </Button>
  );
}

 

 

사이드네비바의 form에도 action을 추가하여 로그아웃도 구현하면 된다.

<form
          action={async () => {
            'use server';
            await signOut();
          }}
        >
          <button className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
            <PowerIcon className="w-6" />
            <div className="hidden md:block">Sign Out</div>
          </button>
        </form>

 

16장 Adding Metadata

Metadata는  SEO 와 공유성에서 중요한 요소다. 이를 추가하는 법을 배운다.!

 

 

Metadata 란?

 

메타데이터는 화면에 보이지 않는 html에 내장된 정보다. 주로 <head> 에 있으며,  SEO나 다른 시스템들이 웹사이트 정보를 자세히 알 때 필요하다.

 

Metadata는 왜 중요한가?

Metadata는 SEO를 향상시키는데 중요한 역할을 한다.(접근 가능성과 이해력을 높인다)

적절한 메타데이터는 서치 엔진이 웹페이지를 효율적으로 찾을 수 있게 하고, 검색 결과 순위를 향상시켜준다. 추가적으로 Open Graph 같은 메타데이터는 소셜 미디어에 공유된 링크 외향을 향상시키고, 컨텐츠가 더 매력있고 유익하게 한다.

 

메타데이터의 종류

더 다양한 종류가 있지만 일반적으로 쓰는 것만 설명하겠습니다.

Title Metadata: 브라우저 텝 제목으로, SEO가 어떤 사이트인지 아는데 도움준다.

<title>Page Title</title>

Description Metadata: 간단한 내용을 요약해주면 종종 SEO의 결과로 노출된다.

<meta name="description" content="A brief description of the page content." />

Keyword Metadata: 검색 엔진이 탐색할 때 도움을 주는 키워드 들이다.

<meta name="keywords" content="keyword1, keyword2, keyword3" />

Open Graph Metadata: 소셜 미디어에 공유될 떄, 제목, 설명, 내용이다.

<meta property="og:title" content="Title Here" /><meta property="og:description" content="Description Here" /><meta property="og:image" content="image_url_here" />

Favicon Metadata: 브라우저 주소쪽에 등록되는 페비콘 이미지

<link rel="icon" href="path/to/favicon.ico" />

 

Metadata 추가하는법

메타데이터를 next에서는 두 가지 방법으로 추가 가능하다.

  • Config-based: ayout.js or page.js file에서 static metadata object  또는 generateMetadata function 을 export하기 File-based: 특별한 파일명 활용
    • favicon.ico, apple-icon.jpg, and icon.jpg: Utilized for favicons and icons
    • opengraph-image.jpg and twitter-image.jpg: Employed for social media images
    • robots.txt: Provides instructions for search engine crawling
    • sitemap.xml: Offers information about the website's structure

두 가지 방법 중 어떤 걸 써도 next.js가 알아서 <head>에 추가해준다.

 

Favicon and Open Graph Image

 

Learn Next.js: Adding Metadata | Next.js

Learn how to add metadata to your Next.js application.

nextjs.org

 

public에 있던 파일을 app으로 이동시키면 favicon과 open graph image가 등록된다. (개발자 도구로 확인할 수 있음)

 

Page title and descriptons

import '@/app/ui/global.css';
import { inter } from '@/app/ui/fonts';
import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Acme Dashboard',
  description: 'The official Next.js Course Dashboard, built with App Router.',
  metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};

어떠한 layout.js 든 상관없이 metadata object  로 적용 가능하다.

특정 페이지에서 metadata를 다르게 하고 싶으면 해당 page.tsx에서 등록하면 된다.

import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Invoices | Acme Dashboard',
};

 

부모의 메타데이터를 override하는 것을 확인할 수 있다.

 

하지만 더 좋은 방법은  title.template를 사용하는 것이다. (유지보수성 향상)

import { Metadata } from 'next';
 
export const metadata: Metadata = {
  title: {
    template: '%s | Acme Dashboard',
    default: 'Acme Dashboard',
  },
  description: 'The official Next.js Learn Dashboard built with App Router.',
  metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};

 

루트 레이아웃에 이렇게 작성하고,

page.tsx 를 수정하면 위와 똑같은 title 임을 확인할 수 있다.

export const metadata: Metadata = {
  title: 'Invoices ',
};

 

이상 끝~

728x90

 

Next.js-14 튜토리얼 따라가기 -2 (5~8장)

 

Next.js-14 튜토리얼 따라가기 -2 (5~8장)

1~4장을 통해서 간단하게 next에서는 UI. 이미지 . 라우팅. 폰트를 어떻게 구현 및 처리했는지를 확인했다. 5장 Navigating Between Pages 5장에서는 link, navigation 원리 , usePathname() 훅 , 에 대해서 알아볼 것

ungumungum.tistory.com

5~8 장에서 동적랜더링에 대해 배웠는데, 이때 랜더링이 가장 느린 페이지를 기준으로 된다고 배웠다.

이제 이를 해결하여 UX를 향상하는 법을 배울 차례다.

 

 

9장 Streaming

9장에서 배울 내용은

1. 스트리밍이 뭐고 언제 쓸지

2. 스트리밍을 loading.tsx와 Suspense로 어떻게 구현할지

3.  로딩 스캘래톤이 뭔지

4. 루트 그룹이 무엇이고, 언제 쓸지

5. Suspense boundaries를 앱 어디에 위치시킬지

 

 

1. 스트리밍이란?

스트림은 데이터 전송 기술 중 하나로, route를 좀 더 작은 덩어리로 쪼갠 후에 각각의 덩어리들이 준비가 완료되면 점진적으로 서버에서 클라이언트로 전송하는 것이다.

이를 이용하면  동적 랜더링에서 겪은 가장 느린 요청 때문에 페이지 로딩이 밀리는 현상을 해결할 수 있다.

 

https://nextjs.org/learn/dashboard-app/streaming

 

스트리밍은 두 가지 방법을 통해 구현할 수 있다.

1. loading.tsx 

2. Suspense로 된 컴퍼넌트

 

 

#1 loading.tsx 처리하는 법은 간단하다

dashboard 폴더에 해당 파일을 만들면 이번엔 바로 이동하고, 대신 3초 동안 loading이라고 뜬다.

사이드바는 정적이기에 바로 나오고, 우측에 있는 동적인 부분만 로딩처리된다.

export default function Loading() {
  return <div>Loading...</div>;
}

이 경우 로딩을 기다리지 않고 페이지 이동도 가능하다. (  interruptable navigation)

이후 단순하게 로딩처리했던 컴퍼넌트를 스켈레톤 이미지로 교체하면 ,  일반적인 사이트들처럼 UX를 향상할 수 있다.

하지만 이 스켈레톤 이미지를 dashboard 폴더에서 그냥 처리하면, 하위에 있는 다른 라우트에도 영향을 준다.

우린 invoices나 Customers 말고 Home에서만 적용시키고 싶다. (index역할)

이를 사용하는 방법이  소괄호 ( ) 이다.  소괄호 안에 내용은 url path에 포함되지 않아서 /dashboard/(overview)/page.tsx 는 /dashboard.와 같아진다.

 

#2 각각의 컴퍼넌트를 Suspense를 통해 세분화해서 랜더링 하기

이 방식을 사용할 경우 로딩처리는 되었지만 Revenue , Card,  latestInvoices 3가지 요청이 하나의 로딩을 통해서 처리가 된다. 이를 React Suspense를 사용하면  세분화해서 특정 컴퍼넌트를 랜더링 시킬 수 있다.

 

현재 Revenue 요청에 딜레이를 걸었기 때문에 해당 컴퍼넌트만 따로 Suspense처리하면 된다.

이후 매개변수로 Data를 받아오던 Revenue 컴퍼넌트를 직접 데이터 요청하는 것으로 수정하면 된다.

     <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';

// This component is representational only.
// For data visualization UI, check out:
// https://www.tremor.so/
// https://www.chartjs.org/
// https://airbnb.io/visx/

export default async function RevenueChart() {
  const revenue = await fetchRevenue();

 

하지만 카드의 경우에는 각각을 Suspense 처리하면 갑자기 튀어나오는 듯이 느껴질 수 있다.

따라서 그룹화하여서 Suspense 처리하는 것이 좋다.

 

Suspense 영역은 3가지로 보통 구분을 한다

1. 유저가 어떻게 페이지를 경험하면 좋겠다고 생각하는지

2. 너가 어떤 컨텐츠를 우선시 여기는지

3. 컴퍼넌트가 대이터 요청에 의존하는지

 

그리고 이를 좀 더 세분화하려면 data fetching을 최대한 하단 컴퍼넌트에서 하는 게 좋다.

 

10장 Partial Prerendering

Next 14에 도입된 실험적인 기능이라, 스킵해도 된다고 한다.

현재 넥스트에서 noStore()를 사용하면서 Dynamic인지 Static인지 구분하고 있다. 대부분의 사이트들 또한 특정 부분은  정적이고 어떤 부분은 동적일 것이다.

 

 

프리랜더링을 사용하면 3가지 효과를 얻을 수 있다.

우선 프리랜더링에서 정적루트쉘이 제공돼서 로드를 빠르게 할 것이다.

해당 쉘은 홀(구덩이)들을 남기는데, 여기에 동적 컨텐츠가 비동기적으로 로드된다.

비동기적 홀들은 평행하게 로드되고, 전체적인 로드타임을 감소시킨다.

 

프리랜더링은 리엑트의 ConCurrent API와 Suspense를 사용하여 작동한다.

폴백은 초기 정적 파일에  정적 내용과 같이 존재하고, 정적인 부분은 Pre-Rendering 되고 동적인 부분은 유저가 해당 라우트 접속까지 지연된다.

이후 서스팬스로 동적인 부분과 정적인 부분을 구분하면 되고, next가 알아서 코드 수정없이 동적인부분과 정적인 부분을 구분해 준다.

 

위 택스트는 1~10장 내용을 요약해 놓은 것이다.

 

11장 Adding Search and Pagination

이제 Next.js APIs인  searchParams, usePathname, and useRouter를 써서 검색을 구현할 것이다.

 

 

import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';

export default async function Page() {
  return (
    <div className="w-full">
      <div className="flex w-full items-center justify-between">
        <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>
      {/*  <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense> */}
      <div className="mt-5 flex w-full justify-center">
        {/* <Pagination totalPages={totalPages} /> */}
      </div>
    </div>
  );
}

 

3가지 컴퍼넌트로 구성되어 있는데

1. Search : 특정 송장 검색

2.  Pagination : 페이지네이션

3. Table : 송장들을 나열함.

유저가 검색하면 url이 변경되고, 서버로부터 새 데이터를 받은 후 리랜더링 될 것이다.

 

검색은 URL에 검색 내용이 담기는 방식으로 되어 있다.  이는 클라이언트 state로 관리하는 방식과는 다른데 3가지 장점이 있다.

 

  • Bookmarkable and Shareable URLs:
    검색 파라미터가 URL에 있어서, 유저가 현재 페이지를 내용과 함께 즐겨찾기 할 수 있다.
  • Server-Side Rendering and Initial Load 
    초기 상태를 랜더링 할 때, URL 파라미터가 직접적으로 이용되어 서버랜더링을 다루기 쉬워진다.
  • Analytics and Tracking 
    별 다른 클라이언트 로직없이도, 유저가 어떤 식으로 검색하는지 분석하기 쉬워진다.

 

이를 위해서 3가지 훅을 사용해서 검색을 구현할 수 있다

  • useSearchParams 
     URL에 접근하기 위해 쓰며 /dashboard/invoices?page=1&query=pending 을 만들려면  
      {page: '1', query: 'pending'}을 하면 된다.
  • usePathname
    현재 URL을 읽을 수 있다.  /dashboard/invoices 이면 usePathname 훅은 '/dashboard/invoices''/dashboard/invoices'. 을 반환한다.
  • useRouter
     클라이언트 컴퍼넌트가 내비게이션 할 때 사용함

실행 순서는 4가지로 구성된다

  1. 유저의 인풋을 캡처한다.
  2. URL을 검색 매개변수로 업데이트한다.
  3. URL을 인풋과 동기화한다.
  4. 검색 쿼리바탕으로 데이터를 업데이트한다.

 

이를 위해서 input이 있는 컴퍼넌트가 'use client'를 통해 훅과 이벤트가 사용 가능한 클라이언트 컴퍼넌트로 되어있는 걸 볼 수 있다.

 

1. 검색함수 추가해서 입력값 들어오는지 확인함

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';

export default function Search({ placeholder }: { placeholder: string }) {
  function handleSearch(term: string) {
    console.log(term);
  }

  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

 

2-1.  useSearchParams 훅으로 검색 시  params를 세팅함.

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();

  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
  }

URLSearchParams 은  URL쿼리 파라미터를 조절하는 WEB API이다.

이때 비어있을 때는 쿼리문 자체 없애준다.

 

2-2.  useRouter훅으로 세팅한 params를 교체해 줌

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);

    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    console.log(params, term);
    replace(`${pathname}?${params.toString()}`);
  }

 

 

이때 next의 client-side navigation에 의해 페이지가 리로딩이 일어나지는 않는다.

 

3. 인풋 내용을 URL과 동기화함

  <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
        defaultValue={searchParams.get('query')?.toString()}
      />

초기값을 Param에서  얻도록 해서 URL로 페이지 이동시에도 INPUT창과 동시화를 시켜준다.

STATE를 따로 쓰고 있지 않아서, Value보단 초깃값만 defaultValue로 설정하는 게 좋다.

 

4. 테이블 업데이트 하기

https://nextjs.org/docs/app/api-reference/file-conventions/page

 

File Conventions: page.js | Next.js

API reference for the page.js file.

nextjs.org

 

export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;

 

우선 route에서 serachParams를 매개변수로 받는다.

이후 serach에 매개변수로 넘겨준다.

 

import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { Suspense } from 'react';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';

export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;

  return (
    <div className="w-full">
      <div className="flex w-full items-center justify-between">
        <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>
      <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense>
      <div className="mt-5 flex w-full justify-center">
        {/* <Pagination totalPages={totalPages} /> */}
      </div>
    </div>
  );
}

이후 테이블에서 해당 매개변수를 받아서 데이터 요청을 한다.

import Image from 'next/image';
import { UpdateInvoice, DeleteInvoice } from '@/app/ui/invoices/buttons';
import InvoiceStatus from '@/app/ui/invoices/status';
import { formatDateToLocal, formatCurrency } from '@/app/lib/utils';
import { fetchFilteredInvoices } from '@/app/lib/data';

export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);

 

const ITEMS_PER_PAGE = 6;
export async function fetchFilteredInvoices(
  query: string,
  currentPage: number
) {
  noStore();

  const offset = (currentPage - 1) * ITEMS_PER_PAGE;

  try {
    const invoices = await sql<InvoicesTable>`
      SELECT
        invoices.id,
        invoices.amount,
        invoices.date,
        invoices.status,
        customers.name,
        customers.email,
        customers.image_url
      FROM invoices
      JOIN customers ON invoices.customer_id = customers.id
      WHERE
        customers.name ILIKE ${`%${query}%`} OR
        customers.email ILIKE ${`%${query}%`} OR
        invoices.amount::text ILIKE ${`%${query}%`} OR
        invoices.date::text ILIKE ${`%${query}%`} OR
        invoices.status ILIKE ${`%${query}%`}
      ORDER BY invoices.date DESC
      LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset}
    `;

    return invoices.rows;
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch invoices.');
  }
}

 useSearchParams() hook vs. the searchParams prop
클라이언트 컴퍼넌트에서는 훅을 사용하고, 아닌 경우 props로 route나 매개변수를 통해 전달받는다.

 

이후 페이지 이동을 너무 자주 하면 성능에 무리가 가니깐 debouce를 걸어준다.

예제에서는 쉽게 구현하기 위해 use-Debounce훅을 인스톨했다.

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  const handleSearch = useDebouncedCallback((term) => {
    const params = new URLSearchParams(searchParams);

    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }, 300);

 

 

마지막으로 페이지네이션까지  ( 당연히 클라이언트 컴퍼넌트다 , 따라 useSerachParams,)

'use client';

import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';

export default function Pagination({ totalPages }: { totalPages: number }) {
  // NOTE: comment in this code when you get to this point in the course
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;
  const allPages = generatePagination(currentPage, totalPages);
  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };

createPageURL에서 query문에다가 pageNumber를 더해주어서 새로운 url을 만들어준다.

 

그리고 검색창에 초기 page를 1로 설정해 준다.

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const pathname = usePathname();
  const { replace } = useRouter();

  const handleSearch = useDebouncedCallback((term) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');

    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }, 300);

 

12장 Mutating Data

아직은 CRUD 중에서 데이터를 읽기만 했다. 따라서 12장에서 배울 내용은

1. React Server Actions가 무엇이고, 데이터 변경할 때 어떻게 쓰는지

2. 서버 컴포넌트와 폼에서 어떻게 작동하는지

3. 폼데이터 사용 예제 (타입 유효성 포함)

4. revalidatePath APi를 써서 revalidate 하기 ( ISR) 

5. 특정 아이디 값을 이용해 동적 라우트 어떻게 하는지

6. useFormStatus 훅으로 업데이트 최적화를 어떻게 하는지

 

 

우선 ServerAction이 무엇일까?

Server Actions는 비동기적 코드를 서버에서 직접적으로 쓰게 하는 것이다.

즉. 클라이언트나 서버 컴퍼넌트에서 비동기적 함수를 일으킬 수 있게 해 준다.

이러한 요청은 보안이 중요한데,  POST requests(포스트 요청), encrypted closures(암호화된 클로져), strict input checks(엄격한 입력검사), error message hashing(에러 메시지 헤싱), and host restrictions(호스트 제한) 기능을 통해서 Server Actions는 보안을 신경 쓴다.

 

서버 컴퍼넌트와 폼에서 어떻게 작동하는지

// 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>;
}

 

form에서 서버컴퍼넌트를 사용해서  formData를 만들 수 있는데, 이러면 클라이언트 자바스크립트가 꺼져 있어도 폼데이터가 생긴다.

 

Server Actions는 넥스트의 캐싱과 깊게 통합되어 있다. Server Action을 통해 Form이 전송되면 데이터 변경뿐만 아니라,

캐싱을 revalidatePath and revalidateTag. 을 통해서 초기화시킬 수 있다.  (데이터 최신화)

 

새 송장은 아래 순서로 이루어진다.

  1. 유저 인풋을 입력받는 폼 만든다
  2. 폼에서 Server Action을 일으킨다.
  3. 서버액션의 Form Data에서 데이터를 추출한다
  4. 디비에 입력할 데이터를 확인하고, 유효성 검증한다.
  5. 데이터 입력하고 에러 처리한다
  6. 캐시를 초기화하고 유저를 invoices 페이지로 이동시킨다.

이를 코드로 확인하면 아래 순서대로다

1. 우선 Create라는 새로운 Route를 폴더 생성을 통해 만든다

https://nextjs.org/learn/dashboard-app/mutating-data#1-create-a-new-route-and-form

 

 

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>
  );
}

 

 

2. 폼에 사용할 Server Action을 만든다.

/app/lib/actions.ts 경로에 아래 파일을 만든다. 이때 use server를 통해서 클라이언트 컴포넌트나 서버 컴퍼넌트에서 사용해도 서버 함수로 작동할 수 있도록 한다. (서버 컴퍼넌트인 경우 굳이 분리 안 해도 상관은 없다)

'use server';

export async function createInvoice(formData: FormData) {}

 

'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}>
      <div className="rounded-md bg-gray-50 p-4 md:p-6">
        {/* Customer Name */}
        <div className="mb-4">
          <label htmlFor="customer" className="mb-2 block text-sm font-medium">
            Choose customer
          </label>
          <div className="relative">
            <select
              id="customer"
              name="customerId"
              className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
              defaultValue=""
            >

이때 form을 사용하는데 전송할 URL을 설정하지 않았다. 이는 React는 일반 HTML과 다르게 특별한 props 취급되어 모든 action을 전달받을 수 있다. 따라서 별다른 url 설정을 하지 않아도, Server Action이 작동한다.  (설정한다면 API Endpoint를 사용할 수 있다)

 

3. 폼데이터에서 데이터 추출

'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);
}

 

필드가 많다면 아래 식으로 확인

const rawFormData = Object.fromEntries(formData.entries())

 

 

이후 액션을 일으키면 콘솔이 찍히는 걸 확인할 수 있다 ( 서버 컴퍼넌트라서 터미널에 나옴)

 

4.  데이터 유효성 검증

이후 액션을 일으키면 콘솔이 찍히는 걸 확인할 수 있다 ( 서버 컴퍼넌트라서 터미널에 나옴)

하지만 type of를 통해서 input의 amount를 확인하면 number 타입으로 입력받았지만 전송되면서 string으로 변환된 걸 확인할 수 있다. 이때 우리는 초기 설정한 타입에 맞게 확인 및 변환할 필요가 있다.

 

export type Invoice = {
  id: string; // Will be created on the database
  customer_id: string;
  amount: number; // Stored in cents
  status: 'pending' | 'paid';
  date: string;
};

 

이를 간단하게 하기 위해 zod라이브러리를 사용했고 코드는 아래와 같다.

'use server';

import { z } from 'zod';

const InvoiceSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});

const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });

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];

}

( 센트이기에 100을 곱하여 타입을 넘버로 변환, 입력받지 않은 날짜를 설정.)

 

5. 디비에 데이터 삽입

 

이후 sql문으로 DB에 해당 값을 넣어주면 된다.

'use server';

import { z } from 'zod';
import { sql } from '@vercel/postgres';

const InvoiceSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});

const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });

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. 데이터가 추가 됐으니 관련 페이지를 revalidate 해줌

 

'use server';

import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

const InvoiceSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});

const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });

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');
  redirect('/dashboard/invoices');
}

추가된 내용은 /dashboard/invoices 에서 확인하니 해당 url을 revalidate 함

그리고 redirect 해주면 됩니다.

 

 

 

이제 수정과 삭제를 통해서

 특정 아이디 값을 이용해 동적 라우트 어떻게 하는지에 대해서 알아볼 차례다.

업데이트는 아래 순서로 이루어진다.

  1. 송장아이디로 동적 루트를 만든다
  2. 파람으로 송장 아이디를 읽는다.
  3. 특정 송장을 아이디로 패칭 한다
  4. 수정 내용을 미리 채운다
  5. 디비 업데이트를 한다

1. 송장 아이디로 동적 루트 만들기

넥스트에서는 동적 루트들을 대괄호로 만들 수 있다.

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. 페이지 파람으로 송장 값 읽어오기 + 3. 특정 송장 정보 얻기

 

invoices/[id]/edit/page.tsx 에서는  create와 비슷하지만, 고객이름, 송장 값, 상태는 미리 받아와야 한다. 이를 id 값으로 요청할 수 있다. 

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(),
  ]);
  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Edit Invoice',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      {invoice && <Form invoice={invoice} customers={customers} />}
    </main>
  );
}

 

4. 서버 액션에 invoice id전달하기

 

// ...
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>
  );
}

 

 

서버액션에서는 매개변수로 id값을 전달할 수 없고 자바스크립트 바인딩을 이용해야 한다.

이때 updateInvoice는 create와 유사하다.

// Use Zod to update the expected types
const UpdateInvoice = InvoiceSchema.omit({ date: true, id: 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');
}
const InvoiceSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});

const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });
const UpdateInvoice = InvoiceSchema.omit({ date: true, id: true });

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');
  redirect('/dashboard/invoices');
}

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');
}

 

날짜는 업데이트하지 않고, 나머지로직은 비슷하게 진행이 된다.

 

 

삭제 시에도 비슷하게  아이디를 바인딩해서 삭제하면 된다..

 

 

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>
  );
}

 

action.ts의 함

const UpdateInvoice = FormSchema.omit({ date: true, id: true });
// ...
 
export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');
}

삭제는 invoices에서 진행했기에 redirect는 필요 없다.

 

 

튜토리얼 설명서와 다르게 optimistic updates는 없다..  해당 내용은 성공 여부와 상관없이 낙천적으로 성공했다 치고 업데이트하는 것이다.

https://www.youtube.com/watch?v=BsPpIjaKyWQ

링크 참조해도 좋을 거 같다.

 

Next.js-14 튜토리얼 따라가기 -4 (13장~ 16장)

 

728x90

1~4장을 통해서  간단하게 next에서는 UI. 이미지 . 라우팅. 폰트를 어떻게  구현 및 처리했는지를 확인했다.

 

 

5장 Navigating Between Pages

 

5장에서는 link, navigation 원리 , usePathname() 훅 ,  에 대해서 알아볼 것이다.

 

링크란 무엇이고 장점 

import Link from 'next/link';
import NavLinks from '@/app/ui/dashboard/nav-links';
import AcmeLogo from '@/app/ui/acme-logo';
import { PowerIcon } from '@heroicons/react/24/outline';

export default function SideNav() {
  return (
    <div className="flex h-full flex-col px-3 py-4 md:px-2">
      <Link
        className="mb-2 flex h-20 items-end justify-start rounded-md bg-blue-600 p-4 md:h-40"
        href="/"
      >
        <div className="w-32 text-white md:w-40">
          <AcmeLogo />
        </div>
      </Link>
      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
        <NavLinks />
        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
        <form>
          <button className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
            <PowerIcon className="w-6" />
            <div className="hidden md:block">Sign Out</div>
          </button>
        </form>
      </div>
    </div>
  );
}
import {
  UserGroupIcon,
  HomeIcon,
  DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';

// Map of links to display in the side navigation.
// Depending on the size of the application, this would be stored in a database.
const links = [
  { name: 'Home', href: '/dashboard', icon: HomeIcon },
  {
    name: 'Invoices',
    href: '/dashboard/invoices',
    icon: DocumentDuplicateIcon,
  },
  { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon },
];

export default function NavLinks() {
  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon;
        return (
          <a
            key={link.name}
            href={link.href}
            className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3"
          >
            <LinkIcon className="w-6" />
            <p className="hidden md:block">{link.name}</p>
          </a>
        );
      })}
    </>
  );
}

 

현재 사이드바에서 페이지 이동을 구현했는데, 이때 Link 내부에서 a 태그를 써서페이지 이동시마다 화면 전체가 새로고침 된다.이를 a태그 대신  Link를 쓰면 client-side-navitaion 이 가능하게 함

import {
  UserGroupIcon,
  HomeIcon,
  DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';

import Link from 'next/link';

// Map of links to display in the side navigation.
// Depending on the size of the application, this would be stored in a database.
const links = [
  { name: 'Home', href: '/dashboard', icon: HomeIcon },
  {
    name: 'Invoices',
    href: '/dashboard/invoices',
    icon: DocumentDuplicateIcon,
  },
  { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon },
];

export default function NavLinks() {
  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon;
        return (
          <Link
            key={link.name}
            href={link.href}
            className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3"
          >
            <LinkIcon className="w-6" />
            <p className="hidden md:block">{link.name}</p>
          </Link>
        );
      })}
    </>
  );
}

( 페이지 상단의 새로고침을 보면 이전 a태그와 다르게 새로고침 버튼이 활성화 되지 않음을 확인할 수 있다.)

이럴 경우 부분적 리랜더링은 되도 새로고침은 없어진다.

 

넥스트의 페이지 이동 원리(Automatic code-splitting and prefetching)

 

spa와 다르게  넥스트는 루트 요소별로 페이지를 나눈다 ( spa 에서는 처음에 한번에 다 로드함) 즉 페이지가 독립되어 있다. 이후 연결된 루트들을 prefatching 한다.  따라서 페이지 이동시 즉각적으로 되도록 한다.

 

 

활성화된 루트를 usePathName 훅을 통해 보여주기

 

보통  네비게이션 바의 활성화 여부를 통해서 현재 페이지를 유저에게 알려준다. 이를 next.js에서는 usePathName 훅을 통해서 현재 route를 알고  이를 바탕으로 네비바 활성화를 할 수 있다. 이때 네비바는 client 컴퍼넌트 처리해준다!

 

 

'use client';

import {
  UserGroupIcon,
  HomeIcon,
  DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import clsx from 'clsx';

// Map of links to display in the side navigation.
// Depending on the size of the application, this would be stored in a database.
const links = [
  { name: 'Home', href: '/dashboard', icon: HomeIcon },
  {
    name: 'Invoices',
    href: '/dashboard/invoices',
    icon: DocumentDuplicateIcon,
  },
  { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon },
];

export default function NavLinks() {
  const pathname = usePathname();

  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon;
        return (
          <Link
            key={link.name}
            href={link.href}
            className={clsx(
              'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
              {
                'bg-sky-100 text-blue-600': pathname === link.href,
              }
            )}
          >
            <LinkIcon className="w-6" />
            <p className="hidden md:block">{link.name}</p>
          </Link>
        );
      })}
    </>
  );
}

 

상단에 'use client' 를 통해서 리엑트의 클라이언트 컴퍼넌트로 변경해 주고, clsx라이브러리를 활용해서 

pathname과 link.href가 같을 떄 'bg-sky-100 text-blue-600' 이 적용되도록 하였다.

 

 

6장  Setting Up Your Database

PostgreSQL을 사용해서 DB세팅을 할 거다.  관련 내용이 필요없다면 스킵해도 된다고 하네요~

설명서보고 따라하는거라, 직접 보고 하는게 더 좋을 거 같아요

https://nextjs.org/learn/dashboard-app/setting-up-your-database

 

Learn Next.js: Setting Up Your Database | Next.js

Setup a database for your application and seed it with initial data.

nextjs.org

 

(my sql을 얕게 나마 봐서 코드 모양이 익숙하네요..)

 

7장  Fetching Data

 

7장을 마치면 위 이미지처럼 됩니다.

 

7장에서는

1. 어떻게 데이터를 가져올 지

2. 서버 컴퍼넌트가 어떻게 백엔드 리소스를 안전하게 접근할 수 있도록 할 지

3.  워터 폴이 무엇인지

4. 워터폴을 해결하려면 평행하게 데이터를 요청해야하는데, 이를 자바스크립트 패턴에서 어떻게 하는지를 배웁니다.

 

 

우선 데이터를 가져오는 건 크게 2가지로 나뉘어 집니다.

1. API LAYER

API LAYER는  3rd party 서비스를 사용할 때,  client에서 데이터 요청할 떄, 디비 secrets를 숨기기 위해서 사용할 수 있습니다. 낵스트에서는 API endpoints를 Route Handlers를 이용해서 만들 수 있습니다. 

Route.js라는 파일을 사용하면 되는데, 이는 page.tsx와 같이 공존할 수는 없습니다. 자세한 사용법은  https://nextjs.org/docs/app/building-your-application/routing/route-handlers

 

Routing: Route Handlers | Next.js

Create custom request handlers for a given route using the Web's Request and Response APIs.

nextjs.org

2. Database queries

풀스텍 앱인 경 백엔드 데이터 요청해야할 텐데, 이때 관계형 디비인 Postgresql 과 같은 경 SQL문 ORM과 Prisma로 백엔드와 상호작용 할 수 있습니다.

API endpoints를 만드는데, 디비와 상호작용해야할 경우, 서버컴퍼넌트 사용하는 경우에는 API LAYER를 만들지 않고도 database secrets를 클라이언트한테 노출시키지 않을 수 있습ㄴ디ㅏ.

이외의 방법은 아래 링크에 있다고 하네요.

https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating

 

 Using Server Components to fetch data

1. 서버 컴퍼넌트를 사용해서 패치할 경우, 비싼 데이터와 로직 서버에서 유지하고 , 클라이언트에는 결과물만 보내면 됩니다.

2. 서버 컴퍼넌트가 비동기를 지원해 데이터 요청시 비동기작업이 더 간단해 집니다.

3. 서버 컴퍼넌트에서 패칭 요청하기에, 추가적인 API LAYER가 필요없습니다.

 

6장에서 vercel의 postgres에 DB를 seeding하였는데, 이를   Vercel Postgres SDK 를 통해 접근할 겁니다.

data.ts에서  sql을 임포트하고  , 아래에 각각의 함수들에서 sql문을 통해서 데이터를 가져오는 것을 확인할 수 있습니다.

//data.ts

import { sql } from '@vercel/postgres';
/*
.....
*/
// Fetch the last 5 invoices, sorted by date
const data = await sql<LatestInvoiceRaw>`
  SELECT invoices.amount, customers.name, customers.image_url, customers.email
  FROM invoices
  JOIN customers ON invoices.customer_id = customers.id
  ORDER BY invoices.date DESC
  LIMIT 5`;

 

 

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();

 

 

마지막으로 카드 컴퍼넌트를 랜더링하면 문제점을 2가지를 확인할 수 있다.

1. waterfall 이 생김

2.  next에서는 static generation을 하는데 이럴 경우 db가 바꿨는데 해결되지 않음(8장에서 설명)

 

waterfall이란 무엇일까?

워터폴은 폭포수처럼 이전 요청이 완료되어야 다음 데이터를 요청하는 모양을 말한다.

이게 절대적으로 나쁜건 아닌데, 이걸 의도적으로 관리할 줄 알아야 한다.

 

이떄 사용하는게 Promise.all() 과 Promise.allSettled()이다

export async function fetchCardData() {
  try {
    const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
    const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
    const invoiceStatusPromise = sql`SELECT
         SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
         SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
         FROM invoices`;
 
    const data = await Promise.all([
      invoiceCountPromise,
      customerCountPromise,
      invoiceStatusPromise,
    ]);
    // ...
  }
}

 

이 패턴을 사용하면

  • Start executing all data fetches at the same time, which can lead to performance gains.
  • Use a native JavaScript pattern that can be applied to any library or framework.

동시에 요청해서 성능향상 노릴 수 있고, 자바스크립트 패턴이라 어떠한 라이브러리 또는 프레임워크에도 적용이 가능하다.

단점은 특정 요청이 다른 요청에 비해서 다를 때 문제가 생길 수 있다는 점이다.

 

8장 Static and Dynamic Rendering

 

1.8장에서는 static rendering이 뭐고, 이게 어떻게 너의 앱 성능을 올리는지
2. dynamic rendering은 뭐고 언제 쓰는지
3. dashboard를 동적이게 하는 다른 방법들은 무엇이 있는지
4.  데이터 요청 시간에 대한 한계
을 배웁니다.
 
 
Static Rendering (정적 랜더링)은 데이터 요청과 리랜더링을 서버가 빌드 될 때 혹은 재유효화(revalidation)될 때 일어납니다. 그 결과물들은  CDN을 통해서 분배되거나 캐시됩니다.
 

정적 랜더링의 3가지 장점은

1. 빠른 랜더링 가능 (미리랜더링된 내용들이 캐쉬되어 있음)

2. 서버 부하 감소 ( 이미 만들어져 있기에 매요청시 새로 만들 필요가 없음)

3. SEO ( 미리랜더링되서  SEO가 크롤링하기 좋음)

 

하지만 데이터가 자주 교체가 되는 대시보드앱에선 적합하지 않음( 블로그나 홍보물 같이 데이터가 고정될 떄는 유리함)

이와 반대되는 개념이

 

Dynamic Rendering (동적 렌더링)

컨텐츠가 사용자가 매번 요청할 떄마다 바뀐다. 이럴 경우 여러 장점이 있는데 크게

1. 실시간 데이터

2.유저 특화된 내용

3. 요청시에만 얻을 수 있는 정보 사용 (쿠키 URL 파라미터등)

 

 

이제 우리가 만들려는 대쉬보드는 동적 렌더링에 적합함으로  NEXT.JS의 API인 unstable_noStore  을 사용할 것이다.

import { unstable_noStore as noStore } from 'next/cache';


export async function fetchRevenue() {
  // Add noStore() here prevent the response from being cached.
  // This is equivalent to in fetch(..., {cache: 'no-store'}).
  noStore();

각각의 함수에 noStore를 선언하면 된다. 이럴 경우 캐쉬를 하지 않게 하여 동적 랜더링을 하게 한다.

 

이후  한 요청에서 대해서 일부러 3초간의 딜레이를 부여한다.

export async function fetchRevenue() {
  // Add noStore() here prevent the response from being cached.
  // This is equivalent to in fetch(..., {cache: 'no-store'}).
  noStore();

  try {
    // Artificially delay a reponse for demo purposes.
    // Don't do this in real life :)

    console.log('Fetching revenue data...');
    await new Promise((resolve) => setTimeout(resolve, 3000));

    const data = await sql<Revenue>`SELECT * FROM revenue`;

    console.log('Data fetch complete after 3 seconds.');

    return data.rows;
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch revenue data.');
  }
}

이럴 경우 페이지 이동이 최소 3초 이후에 된다는 사실을 알 수 있다. 

이는 동적 랜더링의 경우 가장 느린 요청을 기준으로 완료가 되기 떄문이다.

 

Next.js-14 튜토리얼 따라가기 -3 (9장~ 12장)

 

Next.js-14 튜토리얼 따라가기 -3 (9장~ 12장)

Next.js-14 튜토리얼 따라가기 -2 (5~8장) Next.js-14 튜토리얼 따라가기 -2 (5~8장) 1~4장을 통해서 간단하게 next에서는 UI. 이미지 . 라우팅. 폰트를 어떻게 구현 및 처리했는지를 확인했다. 5장 Navigating Betw

ungumungum.tistory.com

 

728x90

next.js 14버전이 나왔다.  물론 난 처음이다.  이력서 제출하려고 하는데, next.js 쓰는 회사가 생각보다 너무 많아서 튜토리얼이라도 우선적으로 학습하려고 한다..

튜토리얼 따라가기 -2 링크 : 5~8장

 

https://nextjs.org/learn/dashboard-app/getting-started

 

Learn Next.js: Getting Started | Next.js

Create a new Next.js application using the dashboard starter example and explore the project.

nextjs.org

이거 보면서 한번 실습해보기로 했다. 정신이 오락가락 하나봐요. 존댄말과 혼잣말이 번갈아서 나오네요.
총 16단계로 구성되어 있고 각 단계별로 확인해보려고 합니다. 1에서는 우선 4단계까지만..!

 

1단계 

npx create-next-app@latest nextjs-dashboard --use-npm --example "https://github.com/vercel/next-learn/tree/main/dashboard/starter-example"

설치하면 이런 폴더구조가 나오고, 각각 폴더에 대한 설명을 해준다.

모던 웹 환경을 경험시켜주기 위해서 이미 작성된 코드에 타입을 썼다고 한다.

 

 

설치 잘하고 npm run dev까지 하면 2장으로 가라고 한다 ( 여기서도 vite처럼 start 대신 run dev네요)

 

 

2단계

이 못난이를 이제 css 적용해서 꾸며주겠다고 한다.

 

이때 global.css를 통하여서 css를 적용할 수 있는데,  어떤 컴퍼넌트든 적용해도 되지만, layout.tsx하는게 좋다 왜냐면 해당 컴퍼넌트가 RootLayout인 탑레벨 컴퍼넌트이기 떄문이다.

// 임포트 추가, 타이틀 적용시 타이틀도 됨
import '@/app/ui/global.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <title>타이틀 바꾸기</title>
      <body>{children}</body>
    </html>
  );
}

import를 하면 css가 바뀐다. (title 추가도 layout.tsx에서 가능하다)

css는 tailwind를 사용해서 만들어주는데, 처음 create할 떄 tailwind 사용여부를 물어본다.

page.tsx에 className 을 사용해 tailwind를 적용한 걸 확인할 수 있고, sass나 다른 css-in-js도 사용 가능하다.

 

3단계

 

폰트를 적용시켜야한다고 한다!  폰트 최적화가 되지 않으면 구글에서 도입한 

Cumulative Layout Shift   라는 사이트 평가 지표중 하나에 걸린다. next에서는  next/font 모듈로 최적화 시켜준다.

 

import { Inter } from 'next/font/google';

export const inter = Inter({ subsets: ['latin'] });

이렇게 쓰면 된다. 이후 적용 시키고자 하는 폰트를 layout에 적용하면 끝!

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  );
}

 

lusitana 폰트도  마찬가지로 적용하면 됩니다.

import { Inter, Lusitana } from 'next/font/google';
export const inter = Inter({ subsets: ['latin'] });

export const lusitana = Lusitana({
  weight: ['400', '700'],
  subsets: ['latin'],
});

 

 

 

이미지 최적화도 해준다고 하네요.. 와 미쳤따~ 이래서 넥스트넥스트,,라고 하구나..
반응형, 이미지 로딩중 레이아웃 이동막고, 반응형해주고, 레이지 로딩에, 포멧 최적화까지,,

  • Preventing layout shift automatically when images are loading.
  • Resizing images to avoid shipping large images to devices with a smaller viewport.
  • Lazy loading images by default (images load as they enter the viewport).
  • Serving images in modern formats, like WebP and AVIF, when the browser supports it.

이걸 next/Image 모듈이 해준다고 합니다.

 

오호라!...

           <Image
            src="/hero-desktop.png"
            width={1000}
            height={760}
            className="hidden md:block"
            alt="Screenshots of the dashboard project showing desktop and mobile versions"
          />

          <Image
            src="/hero-mobile.png"
            width={560}
            height={620}
            className="block md:hidden"
            alt="Screenshot of the dashboard project showing mobile version"
          />

 

클래스 이름에 있는 block hidden을 통해서 반응형에서 어떤 이미지가 보여질 지 결정합니다. hidden md:block에서는 데스크탑 크기에서만, 아래는 반대로 모바일에서만 보입니다.

 

4단계

 

1. 파일시스템 라우팅을 써서 dashboard 루트들을 만들기

2. 루트 요소?를 만들 때, 폴더와 각 파일의 역할 이해하기

3. 여러페이지에서 공유될 nested layout (중첩 레이아웃) 만들기.

4. colocation이 뭔지, 부분적 랜더링, 그리고 루트 레이아웃이 먼지 이해하기

 

읽을 때, file-system routing,  colocation , partial rendering , root layout 을 제가 모르겠구나 라는 생각이 들었습니다.  nested layout 은  공유되는 layout이라는 뜻이라고 설명이 되어있고, 나머지는 변수명을 통해 추측은 되는데 한번 직접 확인해 봐야겠네요..


우선 nested routing(중첩 라우팅)에 대해서 설명해주네요

 

Next.js uses file-system routing where folders are used to create nested routes. Each folder represents a route segment that maps to a URL segment.

 

오호 next.js에서는 각각의 폴더가 url에 나타날 루트 역할을 한다고 하네요!

 

이떄 page.tsx는 리액트 컴퍼넌트를 export하는 특별한 넥스트파일입니다.

즉 /app/page.tsx 이 있기에  루트 url에서 저희가 화면을 보고 있는 거죠..!  
(500에러때문에  diagram이 지금 안 보이네요 ㅠㅠ)

 

따라서  이렇게 dashboard 폴 더만들고 page.tsx 만들면 !  해당 페이지 접속이 됩니다.

 

아니? 그러면 ui 랑 lib폴더도 접속되겠네,.? 물론 page.tsx파일을 만들면 가능하지만,  page라는 파일을 가지지 않은 폴더는 접속되지 않습니다. 따라서 해당 폴더들이 app폴더 안에서 colocate 가능하죠!

 

 

요약하면

next.js가 가진 특수한 파일시스템은 각각의 폴더를 url처럼 인식할 수 있는데, 이때 유저에게 보여주는 페이지(react component)는 page.tsx라는 파일이 해준다. 이런 파일이 없는 경우 유저에게 보여지지 않기 때문에, ui와 lib같은 폴더들도 app 폴더 내부에 공존 가능하다.!

 

 

 

이제 , partial rendering , root layout 만 이해하면 되겠네요

import SideNav from '@/app/ui/dashboard/sidenav';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
      <div className="w-full flex-none md:w-64">
        <SideNav />
      </div>
      <div className="flex-grow p-6 md:overflow-y-auto md:p-12">{children}</div>
    </div>
  );
}

 

 

next.js 에서는 layout.tsx라는 파일을 통해서 여러 페이지에서 공유될  UI를 생성할 수 있습니다.

이때 layout의 children 요소는 하위 layout이나 page가 적용 될 수 있습니다.

현재 dashboard의 layout은 루트 layout 안에 있기에 상단 제목이 타이틀 바꾸기 적용된 것을 확인할 수 있고, 하위 page들에서도 사이드바가 적용된 것이 확인됩니다.

이때 layout의 장점으로는 다른 페이지 이동시에 공통으로 사용되는 레이아웃 내의 sidebar 컴퍼넌트는 리랜더링 되지 않고 page.tsx만 랜더링이 되는데  이를 partial rendering 이라고 하빈다.

 

그리고 /app/layout.tsx 는 모든 페이지에 적용 되는 root layout 입니다.

 

 

Next.js-14 튜토리얼 따라가기 -2 (5~8장)

 

Next.js-14 튜토리얼 따라가기 -2 (5~8장)

1~4장을 통해서 간단하게 next에서는 UI. 이미지 . 라우팅. 폰트를 어떻게 구현 및 처리했는지를 확인했다. 5장 Navigating Between Pages 5장에서는 link, navigation 원리 , usePathname() 훅 , 에 대해서 알아볼 것

ungumungum.tistory.com

Next.js-14 튜토리얼 따라가기 -3 (9장~ 12장)

 

Next.js-14 튜토리얼 따라가기 -3 (9장~ 12장)

Next.js-14 튜토리얼 따라가기 -2 (5~8장) Next.js-14 튜토리얼 따라가기 -2 (5~8장) 1~4장을 통해서 간단하게 next에서는 UI. 이미지 . 라우팅. 폰트를 어떻게 구현 및 처리했는지를 확인했다. 5장 Navigating Betw

ungumungum.tistory.com

Next.js-14 튜토리얼 따라가기 -4 (13장~ 16장)

 

+ Recent posts