Computer >> 컴퓨터 >  >> 프로그래밍 >> Redis

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

이 글에서는 Next.js API 라우트와 Upstash Redis를 활용해 최소한의 코드로 완전히 작동하는 인증 기반 REST API 서비스를 만들어 보겠습니다. Upstash Redis는 데이터 저장소이자 캐시 시스템으로, 데이터뿐 아니라 사용자 인증 정보와 JWT 처리에도 활용됩니다. 이 프로젝트는 프론트엔드 없이 오직 다양한 클라이언트에서 호출할 수 있는 API만 제공한다는 점을 미리 말씀드립니다.

사전 준비 사항

이 튜토리얼을 따라 하려면 다음이 필요합니다.

  • Upstash 계정 — 무료 계정으로 시작할 수 있습니다.
  • Redis에 대한 기본적인 이해
  • Next.js API 라우트에 대한 기본 지식
  • 인증(Authentication)과 권한 부여(Authorization) 워크플로우에 대한 기본 개념
  • HTTP 요청을 테스트할 수 있는 도구 (Postman 등)

Upstash Redis란?

Upstash는 Redis 기반의 서버리스 인메모리 클라우드 데이터베이스입니다. 우리는 여기에 API가 제공할 데이터를 저장하고, 사용자 정보와 토큰도 함께 관리하게 됩니다. 서버리스 방식이기 때문에 별도의 서버 운영 부담 없이 빠르고 확장성 있는 스토리지를 사용할 수 있다는 점이 큰 장점입니다.

만들 것은 무엇인가?

클라이언트 애플리케이션이 데이터를 요청할 수 있는 REST API 서비스(여기서는 영화 목록)를 코딩합니다. 엔드포인트는 JWT(JSON Web Token)로 보호하고, 토큰을 발급받기 위한 로그인 API와 리프레시 토큰(Refresh Token) 워크플로우까지 구현합니다.

클라이언트 개발에는 집중하지 않습니다. '오피니언이 없는(unopinionated)' 서비스를 만들기 때문입니다. 대신 서비스 명세를 제공하여 누구나 원하는 방식으로 클라이언트를 만들 수 있도록 합니다.

저장소 및 데모

따라 하면서 진행하려면 프로젝트 저장소를 클론하는 것이 좋습니다.

소스 코드는 GitHub에서 확인할 수 있습니다.

다음 URL에서 데모를 직접 체험해 볼 수도 있습니다.

https://upstash-dwov9jbiq-popland.vercel.app/api/auth/signin

서비스에 연결하려면 username(me@home.org)과 password(password)를 담아 POST 요청을 보내면 됩니다. 아래는 Postman을 사용한 예시입니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

Redis 데이터베이스 설정하기

먼저 Upstash Redis에 가입해야 합니다(테스트 용도라면 무료 플랜으로 충분합니다). 가입 후 콘솔에 로그인하면 새 데이터베이스를 생성할 수 있습니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

“Create database” 버튼을 누르고, 이름을 MovieManager로 지정한 뒤 Global 옵션으로 설정합니다.

이제 Upstash CLI를 사용해 더미 데이터를 추가해 보겠습니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

영화 데이터는 Redis 해시(기본적으로 객체 형태)로 HMSET 명령어를 통해 추가합니다.

 hmset movie:'Dr. Strangelove' director 'Stanley Kubrick' year 1964
 hmset movie:'2001: A Space Odyssey' director 'Stanley Kubrick' year 1968
 hmset movie:'Pulp Fiction' director 'Quentin Tarantino' year 1994
 hmset movie:'Django Unchained' director 'Quentin Tarantino' year 2012

데이터에 접근할 권한을 가진 사용자도 역시 해시 형태로 추가합니다.

 hmset user:'me@home.org' password $2b$10$zctxUVDyy3jzvSp68oKpMOnkyra4R.NzOFVh9aii3Y43X7XtetoyK level 0

참고: 비밀번호는 bcrypt로 암호화되어 있습니다(평문은 password). 일반적으로 API에 접근하려는 사용자는 웹사이트를 통해 회원가입하지만, 이 예제에서는 회원가입 엔드포인트를 따로 제공하지 않습니다.

Upstash CLI에서 입력한 모든 명령은 OK 응답을 반환해야 합니다. 모두 정상적으로 완료되었다면 Data Browser에서 Hash 탭을 선택해 방금 삽입한 데이터 목록을 확인할 수 있습니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

인증·권한 부여 워크플로우

앞서 언급했듯이 우리의 엔드포인트는 공개되어 있지 않으므로, 사용자를 인증하고 권한을 검증하는 메커니즘이 필요합니다. 인증은 로그인 엔드포인트를 통해 처리하고, 권한 검증은 보호된 엔드포인트에 요청과 함께 Authorization 헤더를 전달하는 방식으로 구현합니다. 전체 흐름은 다음과 같습니다.

  • 사용자가 username과 password를 POST 방식으로 사인인 엔드포인트에 요청합니다.
  • 서버는 사용자를 검증하고, 유효한 사용자라면 JWT와 리프레시 토큰을 생성해 반환합니다. 이때 리프레시 토큰은 Upstash Redis에도 저장됩니다.
  • 클라이언트는 토큰을 받아 자신의 방식대로 저장합니다(저장 위치와 방법은 클라이언트의 책임입니다).
  • 클라이언트가 보호된 엔드포인트를 요청할 때 헤더에 JWT를 담아 전송합니다.
  • 서버는 JWT를 검증하고, 유효하다면 요청한 데이터를 반환합니다.
  • JWT가 만료되었거나 곧 만료될 예정이라면, 클라이언트는 재로그인 없이 리프레시 토큰을 특정 엔드포인트로 전송해 새 JWT를 발급받을 수 있습니다.
  • 서버는 리프레시 토큰을 검증한 뒤, 문제가 없으면 새 JWT와 새 리프레시 토큰을 발급해 클라이언트에 반환하고, 새 리프레시 토큰을 다시 저장합니다.

JWT와 리프레시 토큰은 동일한 포맷을 사용하고 거의 같은 정보를 담지만, 서로 다른 시크릿 키(.env 파일에서 설정)로 서명되며 만료 시간도 다릅니다. JWT는 세션 중 가장 많이 사용되는 토큰이므로 탈취 위험을 줄이기 위해 짧게 설정하고, 리프레시 토큰은 상대적으로 길게 유지합니다. 두 토큰의 유효 기간은 필요한 보안 수준에 따라 조절하면 됩니다. 일반적으로 JWT는 1시간 이내, 리프레시 토큰은 한 달 정도로 설정합니다. 두 토큰이 모두 만료되면 사용자는 다시 로그인해야 합니다.

프로젝트 초기 설정

Upstash Redis 데이터베이스 설정이 끝났다면 프로젝트를 초기화할 차례입니다. 먼저 새 Next.js 프로젝트를 생성합니다.

 npx create-next-app upstash-jwt

생성된 upstash-jwt 폴더로 이동해 필요한 모듈을 설치합니다.

 npm i bcrypt jsonwebtoken @upstash/redis

키 값을 저장할 .env.local 파일을 만들고 아래 내용을 올바르게 채워 넣습니다.

 SECRET_TOKEN=
 SECRET_RTOKEN=
 UPSTASH_REDIS_REST_URL=
 UPSTASH_REDIS_REST_TOKEN=

JWT 생성에 사용할 SECRET_TOKEN과 SECRET_RTOKEN을 직접 만들어야 합니다. 이 키들은 반드시 비밀로 유지해야 하며, 추측하기 어려운 무작위 문자열이어야 합니다. 64bit Hex 문자열을 활용하는 것이 좋습니다.

UPSTASH_REDIS_REST_URL과 UPSTASH_REDIS_REST_TOKEN은 Upstash 콘솔의 Details 탭에 있는 Rest API 섹션에서 확인할 수 있습니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

이제 엔드포인트 설계를 살펴보겠습니다.

POST /auth/signin
사용자를 로그인시키는 엔드포인트입니다. JSON 객체 형태로 email과 password({"email":"email", "password": "password"})를 전달받으며, 사용자 정보와 JWT, 리프레시 토큰이 담긴 JSON 객체를 반환합니다.

GET /movies/
영화 목록을 JSON 객체로 반환합니다. 헤더에 다음 형식의 유효한 JWT가 필요합니다.
Authorization: Bearer xxx

GET /movies/$ID
id가 $ID인 영화의 상세 정보를 반환합니다.

POST /auth/refresh
새 JWT를 생성해 반환합니다. refreshToken 파라미터로 리프레시 토큰을 전달해야 합니다.

API 라우트 코드 작성하기

먼저 사인인 엔드포인트부터 시작합니다. pages/api/auth/signin.js 파일을 다음과 같이 생성합니다.

import bcrypt from "bcrypt";
 
import {
 addToList,
 generateAccessToken,
 generateRefreshToken,
 redis,
} from "../../../utils";
 
export default async (req, res) => {
 if (req.method === "GET") {
 res.status(405).send("Not Allowed");
 } else {
 console.log(req.body.user);
 try {
 const user = await redis.hgetall(`user:${req.body.user}`);
 if (user) {
 const validPassword = bcrypt.compare(req.body.password, user.password);
 if (validPassword) {
 const token = generateAccessToken(req.body.user, user.level);
 const refreshToken = generateRefreshToken(req.body.user, user.level);
 const refresh = await addToList(req.body.user, refreshToken);
 const content = {
 user: req.body.user,
 level: user.level,
 };
 res.status(200).json({
 message: "Logged in",
 content: content,
 JWT: token,
 refresh: refreshToken,
 });
 } else {
 res.status(400).json({ error: "Invalid Password" });
 }
 } else {
 res.status(401).json({ error: "User not found" });
 }
 } catch (error) {
 res.status(500).send("Internal Server Error");
 }
 }
};

사인인 엔드포인트는 userpassword라는 두 파라미터를 담은 POST 요청만 허용합니다. 우선 다음 코드로 해당 사용자가 Redis 데이터베이스에 존재하는지 확인합니다.

 const user = await redis.hgetall(`user:${req.body.user}`);

사용자가 존재하면 암호화된 비밀번호를 비교합니다.

 const validPassword = bcrypt.compare(req.body.password, user.password);

비밀번호가 일치하면 사용자가 인증된 것으로 간주하고, JWT와 리프레시 토큰을 반환하며 리프레시 토큰은 Redis에 저장합니다.

이 과정에서는 utils.js라는 외부 파일에 정의된 함수들을 사용합니다. 반환된 토큰을 저장하고, 필요할 때 권한 확인에 사용하며, 만료 시 갱신하는 것은 클라이언트의 책임입니다.

utils.js에는 토큰을 생성하는 generateAccessToken, 리프레시 토큰을 생성하는 generateRefreshToken, 그리고 리프레시 토큰을 Redis에 저장하는 addToList 함수가 들어갑니다. 이 파일에는 Redis 연결, 토큰 검증 및 갱신 등 나머지 유틸리티 함수와 참조들도 함께 관리합니다.

import { Redis } from "@upstash/redis";
import jwt from "jsonwebtoken";
 
export const redis = new Redis({
 url: process.env.UPSTASH_REDIS_REST_URL,
 token: process.env.UPSTASH_REDIS_REST_TOKEN,
});
export function generateAccessToken(username, email, level) {
 return jwt.sign(
 { user: username, email: email, level: level },
 process.env.SECRET_TOKEN,
 {
 expiresIn: "1h",
 },
 );
}
 
export function generateRefreshToken(username, email, level) {
 return jwt.sign(
 { user: username, email: email, level: level },
 process.env.SECRET_RTOKEN,
 {
 expiresIn: "30d",
 },
 );
}
 
export async function addToList(user, refresher) {
 try {
 await redis.hset("refresh:" + user, { refresh: refresher });
 } catch (error) {
 console.log(error);
 }
}
 
export async function tokenRefresh(refreshtoken, res) {
 var decoded = "";
 try {
 decoded = jwt.verify(refreshtoken, process.env.SECRET_RTOKEN);
 } catch (error) {
 return res.status(401).send("Can't refresh. Invalid Token");
 }
 if (decoded) {
 try {
 const rtoken = await redis.hget("refresh:" + decoded.user, "refresh");
 console.log(rtoken);
 if (rtoken !== refreshtoken) {
 return res.status(401).send("Can't refresh. Invalid Token");
 } else {
 const user = await redis.hgetall(`user:${decoded.user}`);
 console.log(user);
 const token = generateAccessToken(decoded.user, user.level);
 const refreshToken = generateRefreshToken(decoded.user, user.level);
 
 const refresh = await addToList(decoded.user, refreshToken);
 
 const content = {
 user: decoded.user,
 level: user.level,
 };
 return {
 message: "Token Refreshed",
 content: content,
 JWT: token,
 refresh: refreshToken,
 };
 }
 } catch (error) {
 console.log(error);
 }
 }
}
 
export async function verifyToken(token, res) {
 try {
 const decoded = jwt.verify(token, process.env.SECRET_TOKEN);
 return decoded;
 } catch (err) {
 return res.status(405).send("Token is invalid");
 }
}

이제 Postman 같은 도구로 로그인 과정을 테스트할 수 있습니다. https://localhost:3000/api/auth/signin으로 POST 요청을 보내고 username(me@home.org)과 password(password)를 전달하면, 사용자 정보와 함께 JWT와 리프레시 토큰이 담긴 JSON 객체를 응답으로 받게 됩니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

모든 것이 정상적이라면 Redis 데이터베이스에 새로 생성된 리프레시 토큰에 대한 Hash 항목이 추가된 것을 확인할 수 있습니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

다음으로 토큰 갱신 라우트인 refresh.js를 작성해 인증 프로세스를 완성합니다.

import { redis, tokenRefresh } from "../../../utils";
 
export default async (req, res) => {
 if (req.method === "GET") {
 res.status(405).send("Not Allowed");
 } else {
 console.log(req.body.refresh);
 const refresp = await tokenRefresh(req.body.refresh, res);
 res.status(200).json(refresp);
 }
};

이 라우트는 utils.jstokenRefresh 함수를 사용합니다. 함수는 먼저 토큰이 유효하고 디코딩 가능한지 확인한 뒤, Redis에서 해당 사용자의 리프레시 토큰(앞서 addToList로 저장한 값)을 조회합니다. 모든 검증이 통과하면 새 JWT와 새 리프레시 토큰을 생성하고(Redis에 다시 저장), 결과를 클라이언트에 반환합니다.

Postman 같은 도구로 https://localhost:3000/api/auth/refresh에 리프레시 토큰을 파라미터로 전달해 이 엔드포인트를 테스트해 볼 수 있습니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

이제 가상의 클라이언트는 로그인과 토큰 갱신이 가능해졌습니다. 그럼 이 토큰을 어떻게 활용해 인증된 요청을 보내는지 살펴보겠습니다.

영화 목록 조회와 영화 상세 조회를 위해 새 API 라우트 api/movies/[[...id]].js를 생성합니다.

import { redis, verifyToken } from "../../../utils";
 
export default async (req, res) => {
 var id;
 console.log(req.query);
 if (req.query.id) {
 id = req.query.id[0];
 }
 
 var decoded = "";
 const authHeader = req.headers["authorization"];
 const token = authHeader && authHeader.split(" ")[1];
 if (!token) {
 return res.status(403).send("A token is required for authentication");
 } else {
 decoded = await verifyToken(token, res);
 }
 if (decoded) {
 if (id) {
 try {
 const result = await redis.hgetall(id);
 console.log(result);
 return res.status(200).json(result);
 } catch (error) {
 return res.status(500).send("Internal Server Error");
 }
 } else {
 try {
 const result = await redis.scan(0, { match: "movie:*" });
 return res.status(200).json(result);
 } catch (error) {
 return res.status(500).send("Internal Server Error");
 }
 }
 }
};

utils.jsverifyToken 함수를 활용하면 유효한 토큰을 제공한 사용자에게만 API 엔드포인트 접근을 허용할 수 있습니다. 여기서는 두 가지 샘플 쿼리를 만들었습니다. 첫 번째는 영화 목록을 가져오는 쿼리입니다.

 const result = await redis.scan(0, { match: 'movie:*' });

두 번째는 URL의 id 파라미터를 기반으로 단일 영화의 상세 정보를 가져오는 쿼리입니다.

const result = await redis.hgetall(id);

두 요청 모두 verifyToken을 통한 사용자 검증에 의존하지만, 상황에 따라 조합을 바꿀 수도 있습니다. 예를 들어 목록은 공개하고 상세 정보만 보호할 수 있습니다. 또한 사용자(그리고 토큰)에 level 값이 저장되어 있으므로, 다단계 권한 체계도 구현할 수 있습니다.

실제로 영화 목록을 조회해 보겠습니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

그리고 단일 영화의 상세 정보도 확인해 봅니다.

Next.js API 라우트와 Upstash Redis로 안전하고 빠른 인증 REST API 구축하기

클라이언트 관점에서 보기

앞서 말씀드렸듯이 우리는 서버 쪽에만 집중했습니다. 이것이 API의 본질입니다. API는 추상적이어야 하며 웹사이트가 아닙니다. 클라이언트가 어떤 프로그래밍 언어와 라이브러리를 사용해 데이터를 요청할지는 전적으로 클라이언트 개발자의 선택입니다. 우리는 엔드포인트 목록과 각 엔드포인트가 기대하는 입력값, 그리고 반환하는 결과만 명확히 제공하면 됩니다. 데이터 처리 방식이나 토큰 갱신 타이밍 같은 전략 역시 클라이언트의 몫입니다.

다음 단계는?

지금까지 만든 것은 보호된 API 워크플로우의 기본 예제일 뿐입니다. 여기서부터는 개선의 여지가 무궁무진합니다. Redis 데이터 저장 방식 최적화, 사용자 데이터를 별도의 Redis 인스턴스에 분리해 로그인 보안 강화, 요청 데이터 사전 검증 로직 추가, 엔드포인트 확장, GraphQL 형식 응답 지원, API용 클라이언트 제작, 시간당 최대 호출 수 제한, 레벨 기반 접근 제한 등 확장과 개선은 끝이 없습니다!