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

Firebase, Upstash, SvelteKit으로 만드는 오픈소스 Jira 클론 완전 가이드

이 글에서는 Upstash, SvelteKit, Firebase Storage를 활용해 Jira 칸반 보드의 오픈소스 대안을 직접 구축한 과정을 소개합니다. 인증, 데이터 CRUD, 속도 제한(Rate Limiting), 파일 업로드까지 실제 서비스에 필요한 핵심 기능을 단계별로 살펴보겠습니다.

Firebase, Upstash, SvelteKit으로 만드는 오픈소스 Jira 클론 완전 가이드

사용할 기술 스택

  • SvelteKit — UI 및 API 라우트
  • Upstash — 데이터 CRUD 작업
  • Tailwind CSS — 스타일링
  • Firebase Storage — 이미지, PDF 등 에셋 저장
  • Auth.js의 SvelteKit Auth — 사용자 인증

사전 준비물

  • 데이터베이스 생성을 위한 Upstash 계정
  • 스토리지 컨테이너 생성을 위한 Firebase 계정
  • OAuth 자격 증명 발급을 위한 Google OAuth 2.0 설정

Upstash Redis 설정하기

Upstash 계정을 만들고 로그인한 뒤, Redis 탭으로 이동해 데이터베이스를 생성합니다.

Firebase, Upstash, SvelteKit으로 만드는 오픈소스 Jira 클론 완전 가이드

Firebase, Upstash, SvelteKit으로 만드는 오픈소스 Jira 클론 완전 가이드

데이터베이스 생성이 끝나면 Details 탭으로 이동합니다. 화면을 아래로 스크롤해 'Connect your database' 섹션을 찾은 후, 내용을 복사해 안전한 곳에 보관해 두세요.

Firebase, Upstash, SvelteKit으로 만드는 오픈소스 Jira 클론 완전 가이드

이어서 REST API 섹션까지 스크롤한 뒤 .env 버튼을 클릭하고, 표시되는 내용을 복사해 함께 보관합니다.

Firebase, Upstash, SvelteKit으로 만드는 오픈소스 Jira 클론 완전 가이드

프로젝트 설정하기

앱 저장소를 클론한 뒤 이 튜토리얼을 따라가면 프로젝트의 모든 내용을 익힐 수 있습니다. 다음 명령어로 프로젝트를 가져오세요:

git clone https://github.com/rishi-raj-jain/jira-sveltekit-firebase-storage-upstash-starter
cd jira-sveltekit-firebase-storage-upstash-starter
npm install

저장소를 클론한 후에는 .env 파일을 생성하고, 앞서 저장해 둔 값들을 입력합니다.

.env 파일은 대략 다음과 같은 형태입니다:

# .env
 
# Obtained from Google OAuth 2.0 setup
# https://support.google.com/cloud/answer/6158849?hl=en
GOOGLE_ID="..."
GOOGLE_SECRET="..."
 
# SvelteKit Auth
AUTH_SECRET="..." # A random 32 char string
AUTH_TRUST_HOST=true
 
# Obtained from Upstash as from the steps done above
UPSTASH_REDIS_REST_URL="your_upstash_redis_rest__url_from_above"
UPSTASH_REDIS_REST_TOKEN="your_upstash_redis_rest__token_from_above"
// firebase-adminsdk.json
// with the firebase config obtained from your firebase project
// Read more about firebase config
// https://firebase.google.com/docs/web/learn-more#config-object
 
{
 "type": "...",
 "project_id": "...",
 "private_key_id": "...",
 "private_key": "...",
 "client_email": "...",
 "client_id": "...",
 "auth_uri": "...",
 "token_uri": "...",
 "auth_provider_x509_cert_url": "...",
 "client_x509_cert_url": "...",
 "universe_domain": "...",
 "storageBucket": "..."
}

여기까지 완료했다면 다음 명령어로 로컬 개발 환경을 실행할 수 있습니다:

npm run dev

저장소 구조 살펴보기

아래는 프로젝트의 메인 폴더 구조입니다. 빨간색으로 표시된 파일들은 이 글에서 설명할 CRUD 작업, SvelteKit Auth, 파일 업로드 핸들러 관련 파일들이며, 각각이 참조되는 위치와 함께 정리되어 있습니다.

Firebase, Upstash, SvelteKit으로 만드는 오픈소스 Jira 클론 완전 가이드

사용자 인증으로 SvelteKit 엣지 함수 보호하기

Auth.js 팀의 훌륭한 덕분에 SvelteKit에서 인증을 구현하는 일이 매우 매끄러워졌습니다. 이 프로젝트는 다음과 같은 방식으로 인증을 처리합니다:

Google OAuth 2.0 기반 전체 페이지 권한 검증

SvelteKit의 서버 훅(Server Hooks)을 활용하면 모든 페이지로 들어오는 요청에 대해 인증을 강제할 수 있습니다:

// File: @/hooks.server.ts
 
import Google from "@auth/core/providers/google";
import { SvelteKitAuth } from "@auth/sveltekit";
import type { Handle } from "@sveltejs/kit";
import { GOOGLE_ID, GOOGLE_SECRET } from "$env/static/private";
 
// Read more on
// https://kit.svelte.dev/docs/hooks#server-hooks-handle
export const handle = SvelteKitAuth({
 // @ts-ignore
 providers: [Google({ clientId: GOOGLE_ID, clientSecret: GOOGLE_SECRET })],
}) satisfies Handle;

서버 로컬(Server Locals)을 활용한 엣지 함수 권한 검증

SvelteKit의 Server Locals를 사용하면 서버 사이드에서만 동작하는 어떤 로직에서든 사용자 인증 여부를 선택적으로 확인할 수 있습니다. 아래는 새 이슈를 생성할 때 사용자 인증 여부를 검증하는 예시입니다:

import { json } from '@sveltejs/kit'
import { isAuth } from '@/lib/auth'
import type { RequestEvent } from './$types'
import { getTask, getTasks } from '@/lib/issues'
import type { LayoutServerLoadEvent } from '../routes/$types'
import type { RequestEvent, ServerLoadEvent } from '@sveltejs/kit'
 
// Get user session if available in event locals
const isAuth = async (event: LayoutServerLoadEvent | ServerLoadEvent | RequestEvent) => {
 const session = await event.locals.getSession()
 if (session?.user?.image) {
 return { session }
 }
 return false
}
 
export async function GET(event: RequestEvent) {
 // If user is not authenticated throw a 403
 if (!(await isAuth(event))) {
 return new Response(undefined, {
 status: 403
 })
 }
 const url = event.url
 const idSearchParam = url.searchParams.get('id')
 if (idSearchParam) {
 const res = await getTask(idSearchParam)
 return json(res)
 } else if (url.searchParams.get('all')) {
 const res = await getTasks()
 return json(res)
 }
 return new Response(JSON.stringify({ code: 0, error: 'Invalid Request.' }), {
 status: 400,
 headers: {
 'content-type': 'application/json'
 }
 })
}

Upstash Redis로 구현하는 이슈 CRUD 작업

이 섹션에서는 칸반 보드의 각 이슈 데이터를 조회하고, 수정하고, 삭제하는 과정을 깊이 있게 다룹니다. 데이터를 가져오고, 화면에 표시하고, 갱신하는 모든 과정에서 Upstash DB(@upstash/redis 라이브러리 경유)를 지속적으로 사용합니다.

getTask: 이슈 데이터 조회 함수

getTask 함수는 고유한 id를 키로 삼아 Upstash의 hget을 호출함으로써 해당 이슈 데이터를 조회하는 API 요청을 보냅니다. 만약 이슈가 존재하지 않거나 오류가 발생하면 { code: 0 } 객체를 반환하도록 설계되어 있으며, 이를 통해 SvelteKit의 다이내믹 라우트에서 자동으로 404(이슈 없음) 페이지로 리디렉션됩니다.

type Task = { [key: string]: any } | null;
 
// Get Issue Data
// File: @/lib/issues/get.ts
export async function getTask(id: string) {
 try {
 const redis = (await import("../upstash/setup")).default;
 const task: Task = await redis.hget("issues", id);
 if (!task) {
 return {
 code: 0,
 error: "No such issue found.",
 };
 }
 return { ...task, code: 1 };
 } catch (e: any) {
 const error = e.message || e.toString();
 console.log(error);
 return {
 code: 0,
 error,
 };
 }
}

나머지 CRUD 연산도 같은 패턴으로 구성되어 있습니다:

// Create Issue
// File: @/lib/issues/create.ts
export async function createTask(info: any) {
 try {
 const redis = (await import("../upstash/setup")).default;
 const id =
 Math.random().toString().slice(2) + new Date().getUTCMilliseconds();
 await redis.hset("issues", { [id]: info });
 return { code: 1, id, message: "Issue Created Succesfully ✅" };
 } catch (e: any) {
 const error = e.message || e.toString();
 console.log(error);
 return {
 code: 0,
 error,
 };
 }
}
// Delete Issue
// File: @/lib/issues/delete.ts
export async function deleteTask(id: string) {
 try {
 const redis = (await import("../upstash/setup")).default;
 await redis.hdel("issues", id);
 return { code: 1, message: "Deleted Succesfully!" };
 } catch (e: any) {
 const error = e.message || e.toString();
 console.log(error);
 return {
 code: 0,
 error,
 };
 }
}
// Update Issue Data
// File: @/lib/issues/update.ts
export async function updateTask(info: any, id: string) {
 try {
 const redis = (await import("../upstash/setup")).default;
 if (id) {
 const task = await redis.hget("issues", id);
 if (task) {
 await redis.hset("issues", { [id]: info });
 return { code: 1, message: "Updated Successfully" };
 }
 }
 return {
 code: 0,
 error: "No such issue was found.",
 };
 } catch (e: any) {
 const error = e.message || e.toString();
 console.log(error);
 return {
 code: 0,
 error,
 };
 }
}

요청 속도 제한(Rate Limiting)

엣지에서 속도 제한을 구현하기 위해 Upstash Redis 데이터베이스 클라이언트와 @upstash/ratelimit이라는 레이트 리미터 라이브러리를 사용합니다.

// Reference Function to ratelimiting
// File: @/lib/upstash/ratelimit.ts
import { Ratelimit } from "@upstash/ratelimit";
 
import redis from "./setup";
 
export const ratelimit = {
 upload: new Ratelimit({
 redis,
 limiter: Ratelimit.slidingWindow(2, "60s"),
 }),
 issues: new Ratelimit({
 redis,
 limiter: Ratelimit.slidingWindow(5, "60s"),
 }),
};

속도 제한을 적용해 다음 두 가지를 달성할 수 있었습니다:

A. 분당 사용자별 이슈 생성 횟수 제한

레이트 리밋을 통해 인증된 사용자당 분당 최대 5개의 이슈만 생성할 수 있도록 제한했습니다. 이 제한은 인증된 사용자의 이메일 주소를 기준으로 적용됩니다.

// File: @/routes/api/issue/+server.ts
// Issue Creation POST API SvelteKit Handler
import { ratelimit } from "@/lib/upstash/ratelimit";
 
export async function POST(event: RequestEvent) {
 const user = await isAuth(event);
 if (!user) {
 return new Response(undefined, {
 status: 403,
 });
 }
 if (user.session.user?.email) {
 // Look at the user email of authenticated user at edge
 // Rate limit 5 issues creation per minute
 const result = await ratelimit.issues.limit(user.session.user.email);
 if (!result.success) {
 return new Response(
 JSON.stringify({
 code: 0,
 error: `You can't create more than 5 issues per minute.`,
 }),
 {
 status: 403,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 const { info } = await event.request.json();
 const res = await createTask(info);
 return json(res);
 }
 return new Response(undefined, {
 status: 403,
 });
}

B. 분당 이슈별 사용자별 파일 업로드 횟수 제한

레이트 리밋을 통해 인증된 사용자가 하나의 작업(task)당 분당 최대 2개의 파일만 업로드할 수 있도록 제한했습니다. 이 제한은 인증된 사용자의 이메일과 작업 ID를 조합해 적용됩니다. 업로드가 성공적으로 완료되면 파일 URL을 이슈 데이터에 추가한 뒤 Upstash DB에서 해당 작업 정보를 갱신합니다.

// File: @/routes/api/content/+server.ts
// File Upload POST API SvelteKit Handler
import { ratelimit } from "@/lib/upstash/ratelimit";
 
export async function POST(event: RequestEvent) {
 // User Authentication Code
 if (user.session.user?.email) {
 // Validate User, Task ID and if a file is uploaded
 // Look at the user email of authenticated user and task's ID at edge
 // Rate limit 2 uploads per minute
 const result = await ratelimit.upload.limit(
 `${user.session.user.email}_${taskID}`,
 );
 if (!result.success) {
 return new Response(
 JSON.stringify({
 code: 0,
 error: `You can't upload more than 2 files per issue per minute.`,
 }),
 {
 status: 403,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 // File upload code
 // Continue reading the blog to see how
 // file uploads are being taken care of
 }
 return new Response(undefined, {
 status: 403,
 });
}

Firebase Storage로 파일 업로드·다운로드 처리하기

이 섹션에서는 이슈에 첨부되는 파일들의 업로드와 다운로드를 SvelteKit 엣지에서 안전하게, 그리고 인증된 상태로 처리하는 방법을 자세히 살펴봅니다. 파일을 업로드하고 가져올 때는 Firebase(v9) Storage를 활용합니다.

왜 Cloudflare R2가 아닌가?

Cloudflare R2 무료 스토리지 플랜의 장점을 찬양하는 커뮤니티 반응을 많이 봐 왔지만, 저를 망설이게 한 것은 시스템을 체험해 보기도 전에 신용카드를 등록해야 한다는 점이었습니다. 그래서 다른 스토리지 솔루션을 알아본 끝에 Firebase Storage를 선택했습니다. Firebase는 5GB의 무료 스토리지를 제공하고, 용량을 초과하더라도 아무런 통보 없이 카드에 결제되는 것이 아니라 서비스가 중단될 뿐이기 때문입니다.

Firebase Storage에 파일을 업로드하는 SvelteKit 엣지 함수

다음 엣지 함수는 POST 요청 이벤트를 감시하고, 사용자가 인증되어 있다면 이벤트의 formData에서 taskIDfile을 추출합니다. 이후 파일 크기가 5MB 미만일 때만 진행할지 여부를 판단합니다. 모든 사전 조건이 충족되면 고유 ID를 생성하고, 파일이 업로드될 고유 폴더에 대한 Firebase 참조(ref)를 만듭니다. 파일이 Firebase에 업로드되는 즉시 해당 파일에 접근할 수 있는 URL을 반환받고, 이 고유 URL을 이슈 데이터의 files 키에 추가합니다.

// File: @/routes/api/content/+server.ts
// File Upload POST API SvelteKit Handler
import { initializeApp } from "firebase/app";
import { getDownloadURL, getStorage, ref, uploadBytes } from "firebase/storage";
 
import fireBaseConfig from "../../../../firebase-adminsdk.json";
 
export async function POST(event: RequestEvent) {
 // User Authentication Code
 if (user.session.user?.email) {
 const app = initializeApp(fireBaseConfig);
 const storage = getStorage(app);
 const data = await event.request.formData();
 const taskID = data.get("taskID");
 const file = data.get("file");
 
 // ...Validate User, Task ID and if a file is uploaded
 // ...Rate Limiting Code
 
 // File Size Restriction(s)
 if (file.size > 5 * 1024 * 1024) {
 return new Response(
 JSON.stringify({
 code: 0,
 error: "File size exceeds the limit of 5 MB.",
 }),
 {
 status: 400,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 
 // Start File Upload Code
 try {
 // Create a unique ID
 const fileId = uuidv4();
 // If uploaded is not a File type
 if (!(file instanceof File)) return;
 // Create a ref to firebase storage
 const storageRef = ref(storage, `uploads/${fileId}/${file.name}`);
 // Obtain the arrayBuffer of the file uploaded
 const fileBuffer = await file.arrayBuffer();
 // Upload file to Firebase Storage in bytes using Uint8Array
 const { metadata } = await uploadBytes(
 storageRef,
 new Uint8Array(fileBuffer),
 );
 const { fullPath } = metadata;
 // No fullPath is received, the API errored out
 if (!fullPath) {
 return new Response(
 JSON.stringify({
 code: 0,
 error: `<span>There was some error while uploading the file.</span> <span class="mt-1 text-xs text-gray-500">Report an issue with the current URL that you are on and with the code XXX.</span>`,
 }),
 {
 status: 403,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 // If a file is uploaded successfully, append the file to list of attachments to the issue's data
 const { code, ...taskValues } = await getTask(taskID);
 if (code === 1) {
 if (taskValues) {
 if (taskValues.hasOwnProperty("files")) {
 taskValues["files"].push(
 `https://storage.googleapis.com/${storageRef.bucket}/${storageRef.fullPath}`,
 );
 } else {
 taskValues["files"] = [
 `https://storage.googleapis.com/${storageRef.bucket}/${storageRef.fullPath}`,
 ];
 }
 }
 // Update the task's data in Upstash
 await updateTask(taskValues, taskID);
 }
 return json({
 code: 1,
 message: "Uploaded Successfully",
 });
 } catch (error) {
 return new Response(
 JSON.stringify({ code: 0, error: error.message || error.toString() }),
 {
 status: 403,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 }
 return new Response(undefined, {
 status: 403,
 });
}

Firebase Storage에서 파일 공개 URL을 가져오는 SvelteKit 엣지 함수

앞서 Firebase가 반환한 고유 URL을 이슈의 files 키에 추가했다는 점을 기억하실 겁니다. 원본 파일을 가져오기 위해 SvelteKit 엣지 함수로 GET 요청을 보낼 때 이 고유 URL이 image 파라미터로 전달됩니다. 여기서는 Firebase 라이브러리의 getDownloadURL 함수를 사용해 원본 미디어의 공개 URL을 얻습니다.

// File: @/routes/api/content/+server.ts
// File Upload GET API SvelteKit Handler
import { initializeApp } from "firebase/app";
import { getDownloadURL, getStorage, ref, uploadBytes } from "firebase/storage";
 
import fireBaseConfig from "../../../../firebase-adminsdk.json";
 
export async function GET(event: RequestEvent) {
 if (!(await isAuth(event))) {
 return new Response(undefined, {
 status: 403,
 });
 }
 const url = event.url;
 const image = url.searchParams.get("image");
 if (image) {
 try {
 const app = initializeApp(fireBaseConfig);
 const storage = getStorage(app);
 const fileRef = ref(storage, image);
 const imagePublicURL = await getDownloadURL(fileRef);
 return json({ code: 1, image: imagePublicURL });
 } catch (error) {
 return new Response(
 JSON.stringify({ code: 0, error: error.message || error.toString() }),
 {
 status: 500,
 headers: {
 "content-type": "application/json",
 },
 },
 );
 }
 }
 return new Response(JSON.stringify({ code: 0, error: "Invalid Request." }), {
 status: 400,
 headers: {
 "content-type": "application/json",
 },
 });
}

짐작하셨겠지만 업로드되는 미디어는 여러 종류일 수 있습니다. 이미지와 영상 같은 단순한 경우를 처리하기 위해 프론트엔드에 다음과 같은 조건문을 추가했습니다:

<!-- File: @/routes/issue/[slug]/+page.svelte -->
 
{#each fieldFiles as file}
<div class="mt-8 w-full border border-white/25 p-3">
 {#if /\.(mp4|mov|mkv)/i.test(file)}
 <video class="h-auto w-full" src="{file}" controls>
 <track kind="captions" />
 </video>
 {:else}
 <img alt="{file}" src="{file}" class="h-auto w-full" />
 {/if}
</div>
{/each}

그런데 왜 굳이 오픈소스 Jira 대안일까?

비싼 유료 솔루션을 구매하는 대신 Jira 칸반 보드의 오픈소스 대안을 선택해야 하는 이유는 다음과 같습니다:

  • 상당한 비용 절감: 오픈소스 대안의 가장 큰 장점은 비용 절감입니다. Jira 같은 유료 칸반 보드 솔루션과 달리, SvelteKit, TailwindCSS, Firebase Storage, Upstash의 서버리스 DB, 레이트 리밋으로 구축된 오픈소스 대안은 라이선스 비용 없이 사용할 수 있습니다.
  • 무제한 커스터마이징: 오픈소스 대안은 코드베이스를 완전히 통제할 수 있으므로, 필요에 따라 칸반 보드를 자유롭게 수정할 수 있습니다. 이런 유연성은 커스터마이징 옵션이 제한적인 유료 솔루션에서는 얻기 어렵습니다.
  • 손쉬운 통합: API의 힘을 빌려 칸반 보드를 프로젝트 관리 시스템, 버전 관리 도구, 알림 서비스 등과 연동할 수 있습니다. 또한 프로젝트가 오픈소스이기 때문에 개발자가 기능을 확장하거나 특정 요구사항에 맞춘 플러그인·통합 기능을 직접 만들 수도 있습니다.

마치며

결론적으로 이 프로젝트를 통해 세분화된 레이트 리밋 구현, CRUD 데이터 연산 처리, Firebase Storage API를 활용한 파일 업로드·조회 구현 등을 경험할 수 있었고, 이 모든 것이 Upstash의 @upstash/redis 라이브러리를 활용해 엣지에서 처리되었습니다!