원글: Building an Image Gallery with Next.js, Supabase, and Tailwind CSS (Lee Robinson / 2022년 3월 28일)

 

* 오역 있을 수 있습니다. 댓글이나 이메일로 말씀 부탁드립니다.

 

이미지 갤러리 스타일링

Tailwind가 구성되었으니 이제 이미지 갤러리를 스타일링할 컴포넌트를 생성할 수 있습니다.

우리 애플리케이션의 진입점인 pages/index.tsx 안에서, 이미지를 위한 컨테이너가 필요하니 CSS Grid를 사용합시다. (원래 작성되어있던 코드는 모두 지워주세요!)

뷰포트에 따라 조정되는 padding과 그리드 간격(grid spacing)도 추가합시다.

// pages/index.tsx

export default function Gallery() {
  return (
    <div className="max-w-2xl mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:max-w-7xl lg:px-8">
      <div className="grid grid-cols-1 gap-y-10 sm:grid-cols-2 gap-x-6 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
        {/* 이미지들이 들어갈 자리 */}
      </div>
    </div>
  )
}
...일단 저(=블로그 주인)는 이전까지 Tailwind CSS를 사용해본 적이 없기 때문에, 이런 클래스 이름이 생소하기도 하고 대충 어떤 스타일을 적용하는 건지는 알겠는데 정확히 뭔지는 모르겠어서 Tailwind CSS 문서를 좀 찾아봤습니다.

예를 들어 max-w-2xl의 경우에는 이 페이지에 나와있듯이 max-width: 42rem;를 정의한 것입니다. 초심자 입장에선 사람들이 이걸 다 외우고 쓰는 걸까..? 싶은 생각이 들긴 하지만 자주 쓰다보면 주로 쓰는 유틸리티들은 자연스레 외워질 것 같긴 합니다. 그 밖에 sm:, lg: 등은 브레이크 포인트를 나타내고, x는 좌우, y는 위아래를 나타냅니다. 패딩을 예로 들자면 px, py 이런 식으로 좌우 또는 상하 패딩을 동일하게 줄 수도 있지만 pt(top), pr(right), pb(bottom), pl(left) 이런 식으로 각각의 여백을 개별적으로 줄 수도 있습니다.

(제가 덧붙이는 말은 반말로 쓰겠다고 해놓고 다 쓰고 보니 존댓말로 썼네요..?)

 

다음으로, 개별 이미지를 위한 컴포넌트를 만들어봅시다. 이미지를 표시함과 동시에 추가적인 메타데이터도 표시하고 싶습니다. 이 예제에서 저는 트위터 이름과 핸들을 추가하려고 합니다. 또한 각각의 이미지에는 해당 원본 트윗으로 이동하는 링크를 걸어둘 것입니다.

 

트위터 이름과 핸들 (이미지 출처: https://sproutsocial.com/insights/twitter-handle/ )

 

이제 플레이스홀더 데이터를 이용하여 새로운 컴포넌트를 만들어볼까요?

 

// pages/index.tsx
// (Gallery 윗부분에 이 코드를 추가해주세요.)

function Image() {
  return (
    <a href="#" className="group">
      <div className="w-full aspect-w-1 aspect-h-1 bg-gray-200 rounded-lg overflow-hidden xl:aspect-w-7 xl:aspect-h-8">
        <img alt="" src="https://bit.ly/placeholder-img" className="group-hover:opacity-75" />
      </div>
      <h3 className="mt-4 text-sm text-gray-700">홍길동</h3>
      <p className="mt-1 text-lg font-medium text-gray-900">@hgd</p>
    </a>
  )
}
참고로, div, h3, p 등의 블록 요소(block elements)들은 원래는 인라인 요소(inline elements) 안에 둘 수 없지만, a 태그의 경우 인라인 요소임에도 불구하고 예외적으로 블록 요소를 감쌀 수 있다.

[참고]
- https://makandracards.com/makandra/43549-it-s-ok-to-put-block-elements-inside-an-a-tag
- https://stackoverflow.com/questions/3092610/div-inside-link-a-href-tag

 

위 코드에서 Tailwind CSS의 group modifier(그룹 수식자?)를 사용한 것을 보실 수 있는데요, 이를 사용하면 호버되는 영역을 텍스트 등을 포함한 전체 컴포넌트로 잡을 수 있습니다. 그리고 이렇게 함으로써 이미지의 투명도를 조절할 수 있는 것이구요.

위 링크된 문서 내용에 따르면, group modifier는 상위 요소의 상태에 따라 하위 요소를 스타일링 해야 하는 경우에 사용한다.

사용 방법은 다음과 같다.

1. 우선 부모 요소에 group이라는 클래스 네임을 부여한다.
2. 이 부모 요소의 상태에 따라 스타일링을 할 대상 요소에 group-* 수식자와 함께 클래스 네임을 지정한다.

예를 들어 부모 요소가 호버됐을 때 대상 요소의 텍스트 색상을 흰색으로 바꾸고 싶다면, 대상 요소에 다음과 같이 클래스 네임을 부여하는 식이다.

group-hover:text-white

 

또한 이미지가 1:1 정사각형 비율을 유지하도록 할 건데요, 이를 위해선 Tailwind CSS의 플러그인이 필요합니다. 터미널에 아래 명령어를 입력하여 플러그인을 설치해주세요.

npm i -D @tailwindcss/aspect-ratio

설치가 완료되었다면, 루트 디렉토리에 있는 tailwind.config.js 파일의 plugins 부분을 다음과 같이 수정해주세요.

// tailwind.config.js

// * 기존
plugins: [],

// * 방금 설치한 플러그인을 추가해주세요.
plugins: [require('@tailwindcss/aspect-ratio')],

 

이미지 최적화하기

Core Web Vitals를 달성하기 위해서 이미지 최적화를 하려고 합니다.

Core Web Vitals는 ‘웹에서 우수한 사용자 경험을 제공하는 데 필수적인 품질 신호에 대한 통합 지침을 제공하기 위한 구글의 이니셔티브’인 Web Vitals의 서브셋(부분 집합)으로서, 다음의 세 가지 지표로 구성되어 있다.

1. LCP (Largest Contentful Paint) : 로딩 성능을 측정
2. FID (First Input Delay) : 상호 작용을 측정
3. CLS (Cumulative Layout Shift) : 시각적 안정성을 측정

 

Next.js는 HTML img 요소를 모던 웹에 맞게 확장한 Image 컴포넌트를 제공하는데요, 이 컴포넌트는 몇 가지 특징을 가지고 있습니다. :

  • 빠른 페이지 로드 속도
    • 이미지는 뷰포트 범위에 들어올 때만 로드 되기 때문에, 뷰포트 밖의 이미지는 로드되지 않아 전체적인 페이지 로드 속도가 빠릅니다.
  • 시각적 안정성
CLS란?

[정의]
- CLS는 한마디로 말하면 ‘페이지 콘텐츠의 예기치 않은 이동' 현상을 수량화한 매트릭이다. CLS는 시각적 안정성을 측정하는 지표다. CLS가 낮을수록 우수한 사용자 경험을 제공한다.

[원인]
- 이러한 예기치 않은 이동은 보통 DOM 요소가 기존 콘텐츠가 존재하던 공간에 동적으로 추가되기 때문에 발생한다. (예: 인터넷 광고 배너)

['페이지 콘텐츠의 예기치 않은 이동' 사례]
- 예를 들어.. 어떤 페이지에 진입 하자마자 이목을 끄는 어떤 링크를 발견하고 클릭하는 순간..! 클릭이 되기 직전에 그 자리에 갑자기 이상한 광고 배너가 뜨고, 내가 누르려던 링크는 그 아래로 밀려버려서 결국에는 원치 않는 광고 페이지로 이동해 본 경험이 있다면 그 상황이 얼마나 열 받는지 알 것이다. 이처럼 사용자가 예상하지 못한 레이아웃 이동은 사용자에게 좋지 못한 경험을 제공한다.

[개선 방안]
- 반대로 말하면, 사용자가 예상한 레이아웃 이동은 나쁘지 않다. 사용자 상호 작용으로 인한 레이아웃 이동(예: 사용자가 '더보기' 버튼을 클릭해서 해당 영역이 늘어나는 경우)은 사용자가 레이아웃 이동에 대한 원인을 파악할 수 있는 한 괜찮다. 그리고 만약 사용자 상호 작용 과정에 네트워크 요청이 수행된다면, 사용자가 '지금 로딩 중이고, 곧 콘텐츠가 표시될 것'임을 충분히 예상할 수 있도록 콘텐츠가 들어올 공간을 미리 만들어두고 로딩 스피너를 표시하는 것이 좋다. (참고: https://web.dev/i18n/ko/cls/#vs)
- 다음과 같이, 요소에 CSS transform 속성을 적용시켜 레이아웃 이동을 방지하는 방법도 있다.
  - 요소 크기를 변경하려면 요소의 width, height를 바꾸는 대신 요소에 transform: scale() 사용하기!
  - 요소 위치를 변경하려면 top, right, bottom, left를 바꾸는 대신 transform: translate() 사용하기!

 

  • 향상된 성능
    • Next.js의 Image는 파일 크기를 줄이기 위해 WebP 형식과 같은 모던 이미지 형식을 사용하며, 디바이스 크기에 따라 더 작인 이미지를 제공합니다.

이제 next/image로 부터 Image를 불러오고 이를 기존에 만들어 둔 Image 컴포넌트 안에서 사용할 건데요, 이름이 충돌되므로 우리가 만든 Image 컴포넌트의 이름은 BlurImage로 변경해줍니다. BlurImage 컴포넌트(구 Image 컴포넌트)는 다음과 같이 수정해주세요. :

// pages/index.tsx

// img 태그를 Image로 교체해주고, 여기에 layout과 objectFit만 추가해주면 됨.
// (나머지는 기존과 동일)

import Image from "next/image"

function BlurImage() {
  return (
    <a href="#" className="group">
      <div className="w-full aspect-w-1 aspect-h-1 bg-gray-200 rounded-lg overflow-hidden xl:aspect-w-7 xl:aspect-h-8">
        <Image
          alt=""
          src="https://bit.ly/placeholder-img"
          layout="fill"
          objectFit="cover"
          className="group-hover:opacity-75"
        />
      </div>
      <h3 className="mt-4 text-sm text-gray-700">홍길동</h3>
      <p className="mt-1 text-lg font-medium text-gray-900">@hgd</p>
    </a>
  )
}

이미지 크기를 적절하게 조정하기 위해서 Imagelayout 프로퍼티와 objectFit 프로퍼티를 지정해줬습니다.

 

Next.js의 Image 컴포넌트로 이미지를 최적화하기 위해선 '허용 도메인 리스트'에 해당 이미지를 가져온 도메인을 명시적으로 추가해줘야 합니다. 이를 위해 next.config.js의 Next.js 관련 설정 중 images.domains에 원하는 도메인을 추가해주면, 해당 도메인으로부터 가져온 이미지를 최적화할 수 있습니다. (우리 프로젝트의 경우 bit.ly를 추가해주면 되겠죠?)

// next.config.js

/** @type {import('next').NextConfig} */
module.exports = {
  reactStrictMode: true,
  images: {
    domains: ['bit.ly'],
  },
}

 

(추가 안해주면 이렇게 에러가 난다!)

 

 

블러 플레이스홀더

더 나은 사용자 경험을 위해, 이미지가 로딩 중일 때 블러 처리된 이미지를 보여주려고 합니다. Image 컴포넌트는 로컬에 존재하는 이미지 파일에 대해선 이러한 기능을 제공하지만(참고), 이 프로젝트에선 로컬 이미지가 아닌 Supabase로부터 가져오는 이미지를 사용합니다. 따라서 우리는 Image 컴포넌트의 props 중 onLoadingComplete와 CSS를 통해 블러 플레이스홀더를 직접 만들 것입니다. Image 컴포넌트의 onLoadingComplete는 해당 이미지가 완전히 로드되면 호출될 콜백 함수입니다.

 

다음과 같이 pages/index.tsx 파일을 수정해주세요.

// pages/index.tsx

import { useState } from 'react';

import Image from "next/image"

/**
 * 배열 안에 존재할 수 있는 falsy한 값들을 제거하여 배열을 믿을 수 있는 상태로 만들기 위해 사용
 * 참고: https://velog.io/@yongbum/filter-boolean
*/
function cn(...classes: string[]) {
  return classes.filter(Boolean).join(' ');
}

function BlurImage() {
  const [isLoading, setLoading] = useState(true);

  return (
    <a href="#" className="group">
      <div className="w-full aspect-w-1 aspect-h-1 bg-gray-200 rounded-lg overflow-hidden xl:aspect-w-7 xl:aspect-h-8">
        <Image
          alt=""
          src="https://bit.ly/placeholder-img"
          layout="fill"
          objectFit="cover"
          className={cn(
            'group-hover:opacity-75 duration-700 ease-in-out',
            isLoading
              ? 'grayscale blur-2xl scale-110'
              : 'grayscale-0 blur-0 scale-100'
          )}
          onLoadingComplete={() => setLoading(false)}
        />
      </div>
      <h3 className="mt-4 text-sm text-gray-700">홍길동</h3>
      <p className="mt-1 text-lg font-medium text-gray-900">@hgd</p>
    </a>
  )
}

export default function Gallery() {
  return (
    <div className="bg-slate-100 max-w-2xl mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:max-w-7xl lg:px-8">
      <div className="grid grid-cols-1 gap-y-10 sm:grid-cols-2 gap-x-6 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
        <BlurImage />
      </div>
    </div>
  )
}

이제 테스트용 이미지를 Supabase로부터 가져올 실제 이미지로 바꿔봅시다!

 

// 다음편: Supabase 세팅하기

 

복사했습니다!