![[인프런 워밍업 클럽 3기] 풀스택 스터디 3주차 미션 회고 발자국](https://cdn.inflearn.com/public/files/blogs/bbce7caa-425f-484e-8fa7-437c2f74414a/thumbnail.png)
[인프런 워밍업 클럽 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
동작 방식
OFFSET
과LIMIT
을 사용해 특정 범위의 데이터를 가져옴
장점
특정 페이지로 바로 이동 가능 (예: 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는 혼자서 구현해 볼 수 있을 것 같아서 기대됩니다.
긴글 읽어주셔서 감사합니다. ☺
댓글을 작성해보세요.