이 글에서는 서버리스(Serverless) 기반의 Next.js TODO 애플리케이션을 만들어 보겠습니다. 목표는 최대한 미니멀하게 만드는 것입니다. 데이터베이스 연결 없이, Next.js 외에는 어떠한 추가 의존성도 없이, 심지어 버튼조차 없이 말이죠. 미니멀리즘은 멋지고 깔끔합니다. 그리고 게으른 개발자에게는 이보다 더 매력적인 선택이 없습니다 :)
왜 데이터베이스 연결을 피해야 할까?
Next.js는 프론트엔드 개발자가 풀스택 애플리케이션을 개발할 수 있도록 지원하는 현대적인 프레임워크입니다. 특히 서버리스 함수(Serverless Functions)는 Next.js 개발자의 백엔드 개발을 크게 단순화해 주는 핵심 요소입니다.
하지만 서버리스 함수는 본질적으로 상태를 저장하지 않는(stateless) 구조이기 때문에 데이터베이스 연결과는 궁합이 좋지 않습니다. 요청마다 연결을 새로 맺고 끊어야 하다 보니 커넥션 누수나 성능 저하 같은 문제가 발생하기 쉽습니다. 실제로 서버리스 환경에서 데이터베이스 연결로 인한 문제 사례가 여러 곳에서 보고된 바 있습니다.
해답은 REST
REST는 세션 정보 없이 클라이언트와 서버가 통신할 수 있는 아키텍처 스타일입니다. 이러한 무상태(stateless) 특성과 단순함 덕분에 REST는 서버리스 환경에 가장 적합한 통신 방식이라 할 수 있습니다. 이번 프로젝트에서는 REST 방식으로 Upstash Redis에 접근하겠습니다.
프로젝트 스택
- 프론트엔드: Next.js
- 백엔드: Vercel Functions(서버리스 함수)
- 데이터베이스: REST API 기반의 Upstash Redis
데모 확인: https://nextjs-todo-zeta.vercel.app/
소스 코드: https://github.com/upstash/examples/tree/master/nextjs-todo
프로젝트 셋업
먼저 Next.js 앱을 생성합니다:
npx create-next-app
그다음 AWS-US-EAST-1 리전에 Upstash Redis 데이터베이스를 생성하고, REST URL과 토큰을 복사해 둡니다.
이 프로젝트는 3개의 API 엔드포인트를 가진 단일 페이지 애플리케이션입니다:
pages/api/list.js: TODO 항목 목록 조회pages/api/add.js: TODO 항목 추가pages/api/remove.js: TODO 항목 삭제
코드 작성하기
pages/api/list.js 파일을 아래와 같이 작성합니다:
export default async (req, res) => {
const token = "REPLACE_YOUR_TOKEN";
const url = "https://REPLACE_YOUR_ENDPOINT/lrange/todo/0/100?_token=" + token;
return fetch(url)
.then((r) => r.json())
.then((data) => {
let result = JSON.stringify(data.result);
return res.status(200).json(result);
});
};
pages/api/add.js 파일을 아래와 같이 작성합니다:
export default async (req, res) => {
if (!req.query.todo) {
return res.status(400).send("todo parameter required.");
}
let todo = encodeURI(req.query.todo);
const token = "REPLACE_YOUR_TOKEN";
const url =
"https://REPLACE_YOUR_ENDPOINT/lpush/todo/" + todo + "?_token=" + token;
return fetch(url)
.then((r) => r.json())
.then((data) => {
let result = JSON.stringify(data.result);
return res.status(200).json(result);
});
};
pages/api/remove.js 파일을 아래와 같이 작성합니다:
export default async (req, res) => {
if (!req.query.todo) {
return res.status(400).send("todo parameter required.");
}
let todo = encodeURI(req.query.todo);
const token = "REPLACE_YOUR_TOKEN";
const url =
"https://REPLACE_YOUR_ENDPOINT/lrem/todo/1/" + todo + "?_token=" + token;
return fetch(url)
.then((r) => r.json())
.then((data) => {
let result = JSON.stringify(data.result);
return res.status(200).json(result);
});
};
pages/index.js 파일을 아래와 같이 수정합니다:
import Head from "next/head";
import Image from "next/image";
import styles from "../styles/Home.module.css";
import { useEffect, useState } from "react";
export default function Home() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [todo, setTodo] = useState("");
let changeHandler = (event) => {
setTodo(event.target.value);
};
let addTodo = (event) => {
setLoading(true);
event.preventDefault();
fetch("/api/add?todo=" + todo)
.then((res) => res.json())
.then((data) => {
loadTodos();
});
};
let removeTodo = (rtodo) => {
setLoading(true);
fetch("/api/remove?todo=" + rtodo)
.then((res) => res.json())
.then((data) => {
loadTodos();
});
};
let loadTodos = () => {
console.log("load todos");
fetch("/api/list")
.then((res) => res.json())
.then((data) => {
setData(data);
setLoading(false);
});
};
useEffect(() => {
setLoading(true);
loadTodos();
}, []);
if (!data) return "Loading...";
return (
<div className={styles.container}>
<Head>
<title>Next.js TODO APP</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<div className={styles.grid}>
<h1 className={styles.title}>
TODO App with{" "}
<a href="https://blog.upstash.com/nextjs-todo">Next.js!</a>
<br />
<br />
</h1>
{loading ? (
<a href="#" className={styles.card}>
<img src="/loader.gif" />
</a>
) : (
<form className={styles.cardForm} onSubmit={addTodo}>
<input
className={styles.cardInput}
type="text"
name="todo"
onChange={changeHandler}
placeholder="Enter your exciting TODO item!"
/>
</form>
)}
{data.map((item) => (
<a
href="#"
onClick={() => removeTodo(item)}
className={styles.card}
>
<p>{item}</p>
</a>
))}
</div>
</main>
<footer className={styles.footer}>
<a
href="https://blog.upstash.com/nextjs-todo"
target="_blank"
rel="noopener noreferrer"
>
Powered by{" "}
<span className={styles.logo}>
<Image src="/logo.png" alt="Upstash Logo" width={87} height={25} />
</span>
</a>
</footer>
</div>
);
}
보시다시피 React 훅(Hooks)을 사용하는 기본적인 애플리케이션입니다. API와 상호작용하는 메서드는 총 3개로, 각각 addTodo(추가), removeTodo(삭제), loadTodos(목록 조회)입니다. 입력창에 할 일을 입력하고 엔터를 누르면 추가되며, 목록의 항목을 클릭하면 해당 항목이 삭제되는 구조입니다.
마지막으로 styles/Home.module.css 파일을 여기 예제 코드에 맞게 수정하면 됩니다.
실행 및 배포
npm run dev 명령으로 프로젝트를 로컬에서 실행해 봅니다. 문제가 없다면 프로젝트 폴더에서 vercel 명령을 실행해 배포할 수 있습니다. Vercel은 API 함수들을 자동으로 서버리스 함수로 변환해 줍니다. Vercel 함수의 기본 리전이 US-EAST-1이기 때문에, 앞서 데이터베이스도 같은 리전에 생성한 것입니다.
참고 사항
- 데이터베이스 토큰은 코드에 직접 넣기보다 Vercel 환경 변수(Environment Variable)로 관리하는 것이 안전합니다.
- 성능을 위해서는 서버리스 함수와 Redis 데이터베이스를 동일한 리전에 배치하는 것이 가장 좋습니다.
- REST API 대신 Redis 클라이언트를 사용할 수도 있습니다. 하지만 앞서 언급했듯이 데이터베이스 연결은 서버리스 함수 내에서 문제를 일으킬 수 있습니다. 참고로 Upstash REST API와 네이티브 API 사이에 유의미한 성능 차이는 관찰되지 않았습니다.