🎁 모든 강의 30% + 무료 강의 선물🎁

[인프런 워밍업 클럽 3기] 풀스택 스터디 3주차 미션 회고 발자국

[인프런 워밍업 클럽 3기] 풀스택 스터디 3주차 미션 회고 발자국

학습 내용 요약

인프런 워밍업 클럽 3기 풀스택 스터디 3주차에는 Supabase Database를 활용하여 페이지네이션을 다루는 방법을 학습하였습니다. 인프런에서 발자국을 작성할 때 강의를 보지 않고도 강의 내용을 파악할 수 있을 만큼 자세한 내용을 작성하는 건 지양해 달라고 가이드한 만큼 강의에 대한 필기는 최소화 하고 회고 위주로 적어보겠습니다.

 

Supabase Client Query Builder

let { data: movie, error } = await supabase
  .from('movie')  // 'movie' 테이블에서 데이터를 가져옴
  .select("*")     // 모든 컬럼을 선택

  // 필터 조건 (WHERE 절과 유사)
  .eq('column', 'Equal to')           // column이 'Equal to' 값과 같은 경우
  .gt('column', 'Greater than')       // column이 지정 값보다 큰 경우 (>)
  .lt('column', 'Less than')          // column이 지정 값보다 작은 경우 (<)
  .gte('column', 'Greater than or equal to')  // column이 지정 값보다 크거나 같은 경우 (≥)
  .lte('column', 'Less than or equal to')    // column이 지정 값보다 작거나 같은 경우 (≤)
  .like('column', '%CaseSensitive%')  // column이 특정 패턴과 일치하는 경우 (대소문자 구분, LIKE '%...%')
  .ilike('column', '%CaseInsensitive%')  // column이 특정 패턴과 일치하는 경우 (대소문자 구분 없음, ILIKE '%...%')
  .is('column', null)                 // column 값이 NULL인 경우
  .in('column', ['Array', 'Values'])  // column이 지정된 배열 값들 중 하나와 일치하는 경우 (IN 연산자)
  .neq('column', 'Not equal to')      // column이 지정된 값과 다른 경우 (!=)

  // 배열 관련 필터
  .contains('array_column', ['array', 'contains']) // array_column이 주어진 배열 요소를 모두 포함하는 경우
  .containedBy('array_column', ['contained', 'by']) // array_column이 지정된 배열에 완전히 포함되는 경우

  // 논리 연산자
  .not('column', 'like', 'Negate filter') // column이 'like' 조건을 만족하지 않는 경우 (NOT)
  .or('some_column.eq.Some value, other_column.eq.Other value')  
  // OR 연산자: some_column이 'Some value'이거나 other_column이 'Other value'인 경우

 

Supabase에서 Text와 Varchar 이해하기

  • 요약

    • 두 유형 모두 문자열을 저장하는 목적으로 사용됨

    • 저장 방식과 성능에 대한 차이가 있지만, Supabase는 단순성을 강조하므로, 특별한 이유가 없다면 기본적으로 text를 사용하는 것이 권장됨

       

  • Text

    • 긴 문자열을 저장하는 데 사용됨

    • 문자 개수에 대한 제한이 없으며, 길이를 예측하기 어려운 문자열에 적합함

  • Varchar

    • Variable Character Length(가변 길이 문자)의 약어

    • 최대 길이를 설정할 수 있음 → 데이터 일관성을 유지하는 데 유용하며, 특정 쿼리에서 성능이 약간 향상될 수 있음

     

     

페이지네이션 구현 방식: Offset vs Cursor

  • Offset Based Pagination

     

    • 동작 방식

      • OFFSETLIMIT을 사용해 특정 범위의 데이터를 가져옴

    • 장점

      • 특정 페이지로 바로 이동 가능 (예: 1페이지, 5페이지 등)

      • 직관적이고 구현이 간단함

    • 단점

      • 데이터가 많아질수록 OFFSET 성능 저하 (큰 OFFSET 값일수록 느려짐)

      • 데이터가 변경되면 순서가 달라질 수 있어 불안정함

  • Cursor Based Pagination

    • 동작 방식

      • 마지막 항목의 특정 필드(예: created_at 또는 id)를 커서로 사용해 이후 데이터를 가져옴

         

    • 장점

      • 성능이 우수함 (특히 큰 데이터셋에서 OFFSET 사용 없이 빠르게 조회 가능).

      • 데이터가 변경되더라도 안정적인 페이지네이션이 가능함.

    • 단점

      • 특정 페이지로 바로 이동이 어렵고, 이전 페이지로 돌아가는 것이 복잡할 수 있음.

      • 구현이 상대적으로 복잡함.

 

 

Netflix 클론 미션 회고

풀스택 스터디 3주차 미션은 강의에서 진행하는 Next.js와 Supabase Database를 활용한 Netflix 클론 앱에 찜 기능을 추가하는 것이었습니다. 진행했던 미션은 해당 링크에서 보실 수 있습니다.

 

미션 수행 내용

아래는 제가 수행한 미션에 대한 내용을 정리해보았습니다.

 

  • 영화 목록 조회 기능

    • 찜 리스트 조회 기능

    • 키워드 검색 기능

    • 무한 스크롤 지원

  • 영화 상세 정보 조회 기능

    • 동적 메타데이터 지원

    • SSR 지원

  • 영화 찜하기 기능

    • 낙관적 업데이트 지원

       

 

사용 기술

  • 프레임워크: Next.js v15, React v19

  • 데이터베이스: Supabase

  • 서버 상태관리: Tanstack Query v5

  • 클라이언트 상태관리: Zustand v5

  • 스타일 프레임워크: TailWindCSS v3, Mantine v7, Tabler Icons

  • 모노레포: Turbo Repo

  • 패키지 매니저: pnpm

 

페이지네이션

강의에서는 옵셋 기반으로 페이지네이션을 구현하였지만, 저는 커서 기반 페이지네이션이 데이터 추가 또는 삭제 시 페이지 별 인덱스가 꼬여 다른 페이지에 같은 데이터가 존재하거나 데이터를 건너뛸 수 있는 문제가 없기에 무한스크롤 기능에 조금 더 적합하다고 판단하여 커서 기반으로 페이지네이션을 구현하였습니다.

export interface SearchMoviesParams {
  keyword?: string
  cursor?: number | null
  size?: number
  like?: boolean
}

export interface SearchMovies {
  (params: SearchMoviesParams): Promise<{
    data: Movie[]
    nextCursor: number | null
    first: boolean
    last: boolean
  }>
}

export const searchMovies: SearchMovies = async ({
  cursor = null,
  keyword = '',
  like = false,
  size = 12,
}: SearchMoviesParams) => {
  const client = await createServerSupabaseClient()
  const first = !cursor

  let query = client.from('movie').select('*').order('id', { ascending: true })

  if (keyword) {
    query = query.ilike('title', `%${keyword}%`)
  }

  if (like === true) {
    query = query.eq('is_like', true)
  }

  if (cursor) {
    query = query.gt('id', cursor)
  }

  const { data, error } = await query.limit(size + 1)

  if (error) {
    console.log(error)
    return { data: [], nextCursor: null, first, last: true, error }
  }

  const hasNextPage = data.length > size
  const nextCursor = hasNextPage ? (data[size - 1]?.id ?? null) : null

  return {
    data: (
      data?.map((movie) => ({
        id: movie.id,
        title: movie.title,
        imageURL: movie.image_url,
        overview: movie.overview,
        popularity: movie.popularity,
        releaseDate: movie.release_date,
        voteAverage: movie.vote_average,
        isLike: movie.is_like,
      })) ?? []
    ).slice(0, size),
    nextCursor,
    first,
    last: !hasNextPage,
  }
}

 

찜 기능 테이블 스키마

아래는 3주차 미션인 찜 기능을 구현할 때 작성한 movie 테이블 스키마입니다.

CREATE TABLE movie (
    id SERIAL PRIMARY KEY,
    image_url TEXT NOT NULL,
    title TEXT NOT NULL,
    overview TEXT NOT NULL,
    vote_average FLOAT8 NOT NULL,
    popularity FLOAT8 NOT NULL,
    release_date DATE NOT NULL,
    is_like BOOLEAN NOT NULL DEFAULT FALSE
);

해당 프로젝트에서는 별도로 회원 관리를 하지 않기 때문에 간단하게 컬럼을 하나 추가하여 구현하였지만, 만약 회원이 존재하는 상황이라면 테이블을 따로 분리한 후 회원 id와 영화 id를 받아와서 왜래키(FK)로 관리하는 방식도 괜찮았을 것 같습니다.

 

 

후기

이번 주차에는 백엔드의 대표적인 작업 중 하나인 페이지네이션을 학습해 볼 수 있었습니다. 강의와는 다르게 Supabase를 사용한 커서 기반 페이지네이션을 구현해 보았는데, 공식 문서를 읽는 것이 쉽지 않았던 것 같습니다. 이전 주차들에서도 동일하게 Supabase 클라이언트에서 제공하는 쿼리 빌더 메서드들이 무슨 역할을 하는지 정리 해야겠다고 생각했는데 이번 주차에 드디어 하게 되었네요. 이제 다음 주차 미션인 인스타그램 클론까지 학습하면 간단한 MVP는 혼자서 구현해 볼 수 있을 것 같아서 기대됩니다.

 

긴글 읽어주셔서 감사합니다.

 

 

 

 

댓글을 작성해보세요.


채널톡 아이콘