32장. 데이터 저장하고 불러오기
이제부터 진짜 재미가 시작된다.
지금까지 우리는 화면을 만들고, 입력 폼을 만들고, 목록과 상세 화면도 구성했다. 그런데 화면만 그럴듯하다고 서비스가 완성된 것은 아니다. 브라우저를 새로고침하는 순간 방금 입력한 글이 사라진다면, 그건 멋진 서비스라기보다 잘 만든 모형에 가깝다.
풀스택의 짜릿함은 바로 여기서 온다. 내가 입력한 내용이 어딘가에 실제로 저장되고, 다시 들어왔을 때 그 정보가 그대로 불러와지는 순간 말이다. 그때 비로소 우리는 "아, 내가 진짜 서비스를 만들고 있구나"라는 감각을 얻는다.
이번 장에서는 Supabase를 이용해 데이터를 저장하고, 다시 읽어오는 가장 기본적인 흐름을 만들어 보겠다. 아직 로그인과 권한은 붙이지 않는다. 지금은 우선 저장된다, 다시 보인다, 이 두 감각을 몸에 익히는 것이 먼저다.
![[6부] 데이터 저장하고 불러오기](https://images.tilnote.io/pages/fca77e47-0cb2-43b8-828b-67e151c70ac3.webp)
![[6부] 데이터 저장하고 불러오기](https://images.tilnote.io/pages/6b3acc03-071c-4722-91a3-a149dddc0705.webp)
![[6부] 데이터 저장하고 불러오기](https://images.tilnote.io/pages/e4156efa-a3f0-4c72-bb3b-48933035f5e4.webp)
이번 장에서 만들 작은 흐름
이번 장에서는 아주 작은 게시판의 핵심만 만든다고 생각하면 된다.
사용자가 제목과 내용을 입력한다. 저장 버튼을 누른다. 그러면 서버가 Supabase에 데이터를 넣는다. 그리고 목록을 다시 불러와 화면에 보여 준다. 구조는 단순하지만, 이 안에 풀스택의 핵심이 거의 다 들어 있다.
흐름을 짧게 쓰면 이렇다.
화면에서 사용자가 입력한다.
브라우저가 서버에 저장 요청을 보낸다.
서버가 Supabase 테이블에 데이터를 저장한다.
서버가 저장 결과를 돌려준다.
화면이 다시 목록을 요청한다.
서버가 Supabase에서 데이터를 읽어 온다.
화면에 최신 목록이 보인다.
이 과정을 한 번만 제대로 이해하면, 나중에 댓글 저장, 프로필 수정, 할 일 추가, 문의 접수 같은 기능도 거의 같은 방식으로 확장할 수 있다.
Supabase를 이번 장의 데이터 창고로 쓰자
Supabase는 PostgreSQL 데이터베이스를 바탕으로, 웹 서비스에 필요한 데이터 저장 기능을 비교적 쉽게 붙일 수 있게 해 준다. Next.js 프로젝트에서는 보통 프로젝트 URL과 공개 키를 환경변수로 넣고 클라이언트를 만든 뒤, 테이블에 select, insert, update, delete 같은 작업을 요청하는 방식으로 사용한다.1
초보자에게 Supabase가 좋은 이유는 두 가지다.
첫째, 데이터베이스를 클릭 몇 번과 SQL 몇 줄로 만들 수 있다. 둘째, 자바스크립트 코드에서 비교적 읽기 쉬운 문장으로 데이터를 다룰 수 있다. posts 테이블에서 읽어 오고, posts 테이블에 넣는 흐름이 눈에 바로 들어온다.
이번 장에서는 posts라는 아주 단순한 테이블을 기준으로 설명하겠다.
먼저 저장할 그릇을 만든다
데이터를 저장하려면 먼저 그릇이 있어야 한다. Supabase 대시보드의 SQL Editor에서 아래 SQL을 실행해 보자.
create table public.posts (
id bigint generated always as identity primary key,
title text not null,
content text not null,
created_at timestamptz default now()
);
alter table public.posts enable row level security;
create policy "Anyone can read posts"
on public.posts
for select
to anon
using (true);
create policy "Anyone can insert posts"
on public.posts
for insert
to anon
with check (true);하나씩 보면 어렵지 않다. id는 글 번호다. 자동으로 늘어난다. title은 제목, content는 본문, created_at은 작성 시간이다.
여기서 낯선 것은 RLS일 수 있다. RLS는 누가 어떤 데이터를 읽고 쓸 수 있는지 정하는 규칙이라고 생각하면 된다. Supabase에서는 RLS를 켜 둔 뒤 정책을 만들지 않으면 접근이 막히는 쪽으로 이해하는 것이 안전하다.2 그래서 지금은 연습을 위해 "누구나 읽을 수 있음", "누구나 쓸 수 있음" 정책을 아주 단순하게 열어 둔다.
물론 실제 서비스에서는 이 상태로 오래 가면 안 된다. 다음 장들에서 로그인과 권한을 붙이면서, 누구 글인지 구분하고 남의 글을 함부로 건드리지 못하게 막을 것이다. 지금은 저장과 조회의 감각을 먼저 익히기 위한 연습 단계라고 생각하면 된다.
Next.js에서 Supabase 연결 준비하기
이제 Next.js 프로젝트에서 Supabase와 연결할 통로를 만든다.
먼저 환경변수를 준비하자.
NEXT_PUBLIC_SUPABASE_URL=https://내-프로젝트-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=내-공개-key그다음 lib/supabase.ts 파일을 만든다.
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)이 파일은 매번 같은 연결 코드를 반복해서 쓰지 않기 위한 공용 창구다. 앞으로 데이터를 읽거나 저장할 때 이 supabase를 불러다 쓰면 된다.
여기서 잠깐 감각을 잡아 두자. 우리는 지금 데이터베이스에 직접 손을 뻗는 것이 아니라, Supabase 클라이언트라는 리모컨을 하나 쥐게 된 셈이다. 이 리모컨으로 "posts 테이블에서 가져와", "posts 테이블에 넣어" 같은 요청을 보내게 된다.
먼저 읽기부터 만들어 보자
저장은 멋있어 보이지만, 사실 제일 먼저 확인하기 좋은 것은 읽기다. 데이터베이스에 연결이 잘 되었는지, 테이블 이름이 맞는지, 정책이 열려 있는지 가장 빨리 확인할 수 있기 때문이다.
app/api/posts/route.ts 파일을 만들고 이렇게 작성해 보자.
import { NextResponse } from 'next/server'
import { supabase } from '@/lib/supabase'
export async function GET() {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json(
{ message: error.message },
{ status: 500 }
)
}
return NextResponse.json(data)
}이 코드는 서버가 /api/posts로 들어온 GET 요청을 받았을 때, posts 테이블의 모든 행을 최신순으로 읽어 와서 JSON으로 돌려주는 역할을 한다.
여기서 중요한 줄은 이 부분이다.
.from('posts')
.select('*')
.order('created_at', { ascending: false })뜻은 아주 단순하다.
posts 테이블에서, 모든 컬럼을, 작성 시간 내림차순으로 가져오라는 뜻이다. 코드가 영어처럼 읽히기 때문에 초보자도 비교적 빨리 익숙해질 수 있다.
이제 저장을 붙이자
읽기가 됐다면 이제 저장이다. 같은 파일에 POST 처리도 추가해 보자.
import { NextResponse } from 'next/server'
import { supabase } from '@/lib/supabase'
export async function GET() {
const { data, error } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json(
{ message: error.message },
{ status: 500 }
)
}
return NextResponse.json(data)
}
export async function POST(request: Request) {
const body = await request.json()
const title = body.title?.trim()
const content = body.content?.trim()
if (!title || !content) {
return NextResponse.json(
{ message: '제목과 내용을 입력해 주세요.' },
{ status: 400 }
)
}
const { data, error } = await supabase
.from('posts')
.insert({ title, content })
.select()
if (error) {
return NextResponse.json(
{ message: error.message },
{ status: 500 }
)
}
return NextResponse.json(data?.[0], { status: 201 })
}여기서 핵심은 insert다. Supabase에서는 insert()로 데이터를 넣고, 방금 만들어진 행을 바로 돌려받고 싶다면 뒤에 .select()를 이어 붙일 수 있다.3
이 부분이 처음엔 꽤 신기하다. "저장만 하는 거 아닌가요?" 싶지만, 실제 서비스에서는 방금 저장된 글의 id, created_at, 가공된 값 등을 바로 받아야 할 일이 많다. 그래서 insert 뒤에 select를 붙이는 패턴은 아주 자주 쓰인다.
다만 한 가지 주의할 점이 있다. 삽입 후 생성된 행을 응답으로 돌려받으려면, 단순히 INSERT만 되는 것이 아니라 SELECT도 가능해야 하는 경우가 있다.4 그래서 아까 RLS 정책에서 읽기와 쓰기를 둘 다 열어 둔 것이다.
화면에서 목록을 불러오고 저장해 보자
이제 사용자가 직접 만질 수 있는 화면을 하나 붙여 보자. 예를 들어 app/posts/page.tsx를 이렇게 만들 수 있다.
'use client'
import { useEffect, useState } from 'react'
type Post = {
id: number
title: string
content: string
created_at: string
}
export default function PostsPage() {
const [posts, setPosts] = useState<Post[]>([])
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [submitting, setSubmitting] = useState(false)
async function loadPosts() {
const response = await fetch('/api/posts')
const data = await response.json()
setPosts(data)
}
useEffect(() => {
loadPosts()
}, [])
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSubmitting(true)
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, content }),
})
if (response.ok) {
setTitle('')
setContent('')
await loadPosts()
}
setSubmitting(false)
}
return (
<main>
<h1>글 목록</h1>
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="내용"
/>
<button type="submit" disabled={submitting}>
{submitting ? '저장 중...' : '글 저장'}
</button>
</form>
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</li>
))}
</ul>
</main>
)
}이제 흐름이 훨씬 선명해진다.
페이지가 처음 열리면 loadPosts()가 실행된다. 그러면 /api/posts에 GET 요청을 보낸다. 서버는 Supabase에서 글 목록을 읽어 온다. 그리고 그 데이터를 화면에 그린다.
사용자가 폼을 작성하고 저장 버튼을 누르면 handleSubmit()이 실행된다. 그러면 /api/posts에 POST 요청이 간다. 서버는 Supabase에 글을 저장한다. 저장이 끝나면 다시 loadPosts()를 호출해 최신 목록을 받아 온다.
이렇게 한 번 저장하고 다시 불러오는 흐름을 경험하면, 웹 서비스가 갑자기 납작한 화면이 아니라 살아 있는 시스템처럼 느껴진다.
왜 저장 후 다시 불러오는 방식이 좋은가
초보자는 종종 이런 고민을 한다.
"저장에 성공했으면 화면 상태에 그냥 끼워 넣으면 되지 않나요?"
맞다. 그렇게 할 수도 있다. 실제로 더 빠르게 보이게 만들 때 자주 쓰는 방법이다. 하지만 처음에는 저장 후 다시 불러오기가 훨씬 안전하다.
왜냐하면 지금은 우리가 먼저 익혀야 할 것이 있기 때문이다. 바로 화면의 진실은 데이터베이스에서 온다는 감각이다.
화면 상태만 믿고 있으면, 눈앞에서는 저장된 것처럼 보이는데 실제로는 저장이 실패했을 수도 있다. 반대로 저장은 됐는데 화면이 갱신되지 않아서 안 된 것처럼 보일 수도 있다. 초보 단계에서는 이런 착시가 자주 생긴다.
그래서 처음에는 이렇게 생각하면 좋다.
저장했다. 다시 읽어 왔다. 그 결과가 보인다. 그러면 진짜다.
이 방식은 조금 덜 화려해 보여도, 풀스택의 기본기를 익히기에는 아주 좋다.
AI에게 맡길 때도 이 단위로 잘게 요청하자
이 장의 작업은 AI에게 맡기기에도 딱 좋은 크기다. 다만 한 번에 "게시판 전체 만들어 줘"라고 하지 말고, 작업 단위를 잘게 나누는 것이 좋다.
예를 들어 이렇게 요청할 수 있다.
posts테이블용 GET route를 만들어 줘.제목과 내용 검증이 들어간 POST route를 만들어 줘.
글 작성 폼과 목록 화면을 한 페이지에 붙여 줘.
저장 성공 후 목록을 다시 불러오도록 바꿔 줘.
이렇게 나누면 결과를 검토하기 쉽고, 어디서 틀렸는지도 빨리 찾을 수 있다. 특히 Supabase 연동에서는 테이블 이름, 컬럼 이름, RLS 정책 같은 작은 불일치가 자주 문제를 만든다. 그래서 AI가 만들어 준 코드도 "흐름이 맞는가"를 눈으로 확인해야 한다.
자주 막히는 지점은 늘 비슷하다
이 장에서 막히는 이유는 대개 문법보다 연결 문제다. 특히 아래 네 가지를 가장 먼저 의심하면 된다.
환경변수 이름이 틀렸거나 값이 빠졌다.
테이블 이름이나 컬럼 이름이 코드와 Supabase에서 다르다.
RLS는 켰는데 정책을 만들지 않았다.
저장은 됐지만
.select()가 없어 방금 만든 데이터를 바로 받지 못했다.
여기서 특히 RLS는 초보자를 가장 자주 멈추게 한다. 코드는 멀쩡해 보이는데 데이터가 안 보이거나 저장이 안 되면, 일단 정책부터 확인하는 습관을 들이면 좋다.
이 장에서 꼭 기억할 감각
이번 장의 핵심은 코드 몇 줄 자체가 아니다. 더 중요한 것은 이 한 문장이다.
화면의 정보는 서버를 거쳐 데이터베이스에 저장되고, 다시 그 데이터베이스에서 읽혀 와야 진짜 서비스가 된다.
이 감각을 이해하면 앞으로 배우게 될 회원가입, 로그인, 글 수정, 삭제, 권한 처리도 훨씬 쉽게 받아들일 수 있다. 결국 다 같은 흐름 위에 서 있기 때문이다.
회원가입은 사용자 정보를 저장하는 일이다. 로그인은 저장된 사용자 정보를 확인하는 일이다. 글 수정은 기존 데이터를 찾아 바꾸는 일이다. 삭제는 특정 데이터를 지우는 일이다. 권한 처리는 누가 그 일을 할 수 있는지 제한하는 일이다.
그러니 지금 이 장은 작아 보여도 아주 중요하다. 여기서 "저장된다", "다시 보인다"를 손에 익히면, 뒤에 나오는 기능들은 같은 골격 위에 살을 붙이는 작업이 된다.
이 장의 핵심 정리
화면만 있는 웹 페이지와 실제 서비스의 차이는 데이터가 남느냐에 있다. 사용자의 입력이 서버를 통해 Supabase에 저장되고, 다시 읽혀 와서 화면에 보일 때 비로소 풀스택 서비스다운 모습이 시작된다.
Supabase에서는 from('테이블명').select()로 데이터를 읽고, insert()로 데이터를 저장할 수 있다. 저장 직후 생성된 행을 받고 싶다면 .select()를 이어 붙이는 패턴을 기억해 두면 좋다.
또한 RLS를 켰다면 정책을 반드시 함께 생각해야 한다. 읽기 정책과 쓰기 정책이 맞지 않으면 코드는 멀쩡해 보여도 데이터가 오가지 않을 수 있다.
지금은 연습을 위해 단순한 공개 읽기와 공개 쓰기를 사용했다. 다음 장부터는 여기에 회원가입과 로그인을 붙여, "누가 저장했는가"까지 구분되는 진짜 서비스의 모양으로 나아가 보겠다.
참고
2How to Insert Data into a Supabase Table
3JavaScript API Reference | Supabase Docs
4INSERT into the table and return id of the record Supabase JS
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.