1.시스템&인프라/redis

2편. Redis 실습 프로젝트 구성하기: Prisma, SQLite, Redis, Vitest

쿼드큐브 2026. 6. 22. 15:18
반응형
반응형

 

2편. Redis 실습 프로젝트 구성하기: Prisma, SQLite, Redis, Vitest

 

📚 목차
1. Redis 실습 프로젝트 목표와 전체 구조 설계하기
2. Prisma 7과 SQLite로 스키마 구성하기
3. Redis 연결 코드와 공통 라이브러리 구성하기
4. Vitest 기반 테스트 환경 구성하기

 

redis 실습 프로젝트 구성
redis 실습 프로젝트 구성

 

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/redis-examples

 

1. Redis 실습 프로젝트 목표와 전체 구조 설계하기

Redis의 여러 자료구조를 단순 명령어 수준에서만 확인하지 않고, 실제 백엔드 서비스 코드에서 어떻게 활용할 수 있는지 확인해 보겠습니다.

String      → 사용자 조회 캐싱, 인증 코드, 조회수 카운터, Rate Limiting
Hash        → 사용자 프로필, 세션, 상품 재고, 사용자 설정
List        → 최근 본 게시글, 최근 검색어, 간단한 작업 큐, 로그 버퍼
Set         → 좋아요, 일일 방문자, 온라인 사용자, 중복 요청 방지
Sorted Set  → 인기 게시글, 검색어 순위, 사용자 포인트, 우선순위 큐
Stream      → 주문 이벤트, 알림 이벤트 큐, 이메일 작업 큐, 감사 로그
Pub/Sub     → 실시간 알림, 캐시 무효화, 채팅 브로드캐스트, 관리자 공지

 

🔷 루트 프로젝트 안에 독립 하위 프로젝트로 구성하기

기존 루트 프로젝트 안에 redis-examples를 별도 하위 프로젝트로 구성합니다.

nodejs-practice-lab/
├─ fastify-api-rest/
├─ redis-examples/
│  ├─ package.json
│  ├─ tsconfig.json
│  ├─ prisma.config.ts
│  ├─ vitest.config.ts
│  ├─ prisma/
│  ├─ src/
│  └─ tests/
├─ package.json
└─ tsconfig.json

redis-examples는 루트 프로젝트 안에 있지만, 자체 package.json, tsconfig.json, prisma.config.ts, vitest.config.ts를 가지는 독립 실습 프로젝트로 관리합니다.

 

🔷 workspace에 redis-examples 등록하기

루트 package.json에는 redis-examples를 workspace로 등록합니다.

{
  "name": "nodejs-practice-lab",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "workspaces": [
    "fastify-api-rest",
    "redis-examples"
  ]
}

이렇게 하면 루트 프로젝트에서 여러 하위 Node.js 프로젝트를 함께 관리할 수 있습니다.

 

🔷 실습 프로젝트 구조

redis-examples/
├─ prisma/
│  ├─ migrations/
│  ├─ dev.db
│  └─ schema.prisma
├─ src/
│  ├─ generated/
│  │  └─ prisma/
│  ├─ lib/
│  │  ├─ prisma.ts
│  │  └─ redis.ts
│  ├─ redis/
│  │  ├─ redis-key.ts
│  │  └─ cache.service.ts
│  ├─ services/
│  │  ├─ user.service.ts
│  │  ├─ post.service.ts
│  │  ├─ product.service.ts
│  │  ├─ order.service.ts
│  │  ├─ search.service.ts
│  │  ├─ session.service.ts
│  │  ├─ notification.service.ts
│  │  └─ rate-limit.service.ts
│  └─ scripts/
│     └─ seed.ts
├─ tests/
│  ├─ setup.ts
│  └─ services/
│     ├─ user.service.test.ts
│     ├─ post.service.test.ts
│     ├─ product.service.test.ts
│     └─ redis-connection.test.ts
├─ .env
├─ package.json
├─ prisma.config.ts
├─ tsconfig.json
└─ vitest.config.ts
src/lib/prisma.ts          → Prisma Client 공통 연결
src/lib/redis.ts           → Redis Client 공통 연결
src/redis/redis-key.ts     → Redis key 규칙 관리
src/redis/cache.service.ts → JSON 캐시 공통 처리
src/services/              → 실무 시나리오별 Service Layer
tests/                     → Service 단위 테스트

 

2. Prisma 7과 SQLite로 스키마 구성하기

🔷 실습용 패키지 설치하기

redis-examples 하위 프로젝트에서 필요한 패키지를 설치합니다.

# Prisma 기본 패키지
npm install @prisma/client@7.2.0
npm install -D prisma@7.2.0

# SQLite Adapter 패키지
npm install @prisma/adapter-better-sqlite3@7.2.0 better-sqlite3@12.10.0
npm install -D @types/better-sqlite3@7.6.13

# Redis Client
npm install redis@6.0.0

# 환경 변수
npm install dotenv@17.4.2

# 테스트
npm install -D vitest@4.0.17 @vitest/coverage-v8@4.0.17
nodejs-practice-lab@1.0.0 D:\NodejsDevelope\workspace\nodejs-practice-lab
└─┬ redis-examples@1.0.0 -> .\redis-examples
  ├── @prisma/adapter-better-sqlite3@7.2.0
  ├── @prisma/client@7.2.0
  ├── @types/better-sqlite3@7.6.13
  ├── @vitest/coverage-v8@4.0.17
  ├── better-sqlite3@12.10.0
  ├── dotenv@17.4.2
  ├── prisma@7.2.0
  ├── redis@6.0.0
  └── vitest@4.0.17

 

🔷 .env 설정하기

REDIS_URL=redis://:mypassword@127.0.0.1:6379

# Prisma에서 SQLite 상대 경로는 보통 prisma/schema.prisma 기준으로 해석됩니다.
DATABASE_URL="file:./dev.db"

 

🔷 prisma.config.ts 작성하기

import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';

export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: {
    path: 'prisma/migrations',
  },
  datasource: {
    url: env('DATABASE_URL'),
  },
});

 

🔷 Prisma Schema 작성 : prisma/schema.prisma

// =========================
// Prisma Generator
// =========================
// Prisma Client 생성 설정
// - Prisma 7부터 provider는 "prisma-client" 사용 (-js 제거)
// - output을 지정하지 않으면 monorepo / tsconfig path 환경에서 import 꼬일 수 있음
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "sqlite"
}

// 사용자 정보 모델
// Redis String: 사용자 조회 캐싱
// Redis Hash: 사용자 프로필, 세션, 사용자 설정
// Redis Sorted Set: 사용자 포인트 랭킹 실습에 사용
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  point     Int      @default(0)
  status    String   @default("ACTIVE")
  createdAt DateTime @default(now())

  updatedAt DateTime @updatedAt

  // 사용자가 작성한 게시글 목록
  posts Post[]

  // 사용자의 주문 목록
  orders Order[]
}

// 게시글 모델
// Redis String: 조회수 카운터
// Redis List: 최근 본 게시글
// Redis Set: 좋아요 사용자 목록
// Redis Sorted Set: 인기 게시글 랭킹 실습에 사용
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  authorId  Int
  status    String   @default("DRAFT")
  viewCount Int      @default(0)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 게시글 작성자과의 관계 설정
  author User @relation(fields: [authorId], references: [id])
}

// 상품 모델
// Redis Hash: 상품 재고 상태 캐싱 실습에 사용
model Product {
  id        Int      @id @default(autoincrement())
  name      String
  stock     Int      @default(0)
  status    String   @default("ON_SALE")
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

// 주문 모델
// Redis Stream: 주문 생성 이벤트 실습에 사용
model Order {
  id         Int      @id @default(autoincrement())
  userId     Int
  status     String   @default("CREATED")
  totalPrice Int      @default(0)
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  // 주문한 사용자
  user User @relation(fields: [userId], references: [id])
}

// 감사 로그 모델
// Redis Stream: 감사 로그 이벤트 실습
// DB 저장 방식과 Redis Stream 저장 방식을 비교할 때 사용
model AuditLog {
  id        Int      @id @default(autoincrement())
  action    String
  target    String
  message   String
  createdAt DateTime @default(now())
}

 

🔷 데이터베이스 생성 및 Prisma Client 생성

# schema.prisma 파일의 내용대로 데이터베이스 테이블 생성
npx prisma migrate dev --name init
Loaded Prisma config from prisma.config.ts.
Prisma schema loaded from prisma\schema.prisma.
Datasource "db": SQLite database "dev.db" at "file:./dev.db"
# 클라이언트 코드를 생성
npx prisma generate

# 실행 후 다음 경로에 Prisma Client 코드가 생성됩니다.
# src/generated/prisma/

반응형

 

3. Redis 연결 코드와 공통 라이브러리 구성하기

🔷 Prisma Client 공통 파일 작성하기 : src/lib/prisma.ts

import 'dotenv/config';
import { PrismaClient } from '../generated/prisma/client';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';

// .env 파일에 정의된 DATABASE_URL 값을 읽어옵니다.
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
  throw new Error('DATABASE_URL is not defined in .env file');
}

// Prisma에 전달할 Better SQLite3 어댑터 인스턴스를 생성합니다.
const adapter = new PrismaBetterSqlite3({
  url: connectionString,
});

// Prisma 클라이언트를 생성하여 애플리케이션에서 재사용할 수 있도록 export 합니다.
// 다른 모듈에서 import { prisma } from './lib/prisma' 형태로 사용합니다.
export const prisma = new PrismaClient({
  adapter,
});

이 파일은 모든 Service에서 공통으로 사용하는 Prisma Client입니다.

 

🔷 Redis Client 공통 파일 작성하기 : src/lib/redis.ts

import 'dotenv/config';
import { createClient } from 'redis';

// 환경 변수에서 Redis 연결 URL을 읽어옵니다.
const redisUrl = process.env.REDIS_URL;

// REDIS_URL이 정의되어 있지 않으면 애플리케이션 실행을 중단합니다.
if (!redisUrl) {
  throw new Error('REDIS_URL is not defined');
}

// Redis 클라이언트를 생성합니다. URL을 통해 Redis 서버에 연결하도록 설정합니다.
export const redis = createClient({
  url: redisUrl,
});

// Redis 클라이언트에서 발생하는 에러를 콘솔에 출력합니다.
redis.on('error', (error) => {
  console.error('[Redis Error]', error);
});

// Redis 연결을 초기화하는 함수입니다.
// 이미 연결되어 있지 않으면 connect()를 호출하여 서버에 연결합니다.
export async function connectRedis() {
  if (!redis.isOpen) {
    await redis.connect();
  }

  return redis;
}

// Redis 연결을 종료하는 함수입니다.
// 연결이 열려 있는 경우 quit()를 호출하여 안전하게 연결을 끊습니다.
export async function disconnectRedis() {
  if (redis.isOpen) {
    await redis.quit();
  }
}

 

🔷 RedisKey 유틸 파일 만들기 : src/redis/redis-key.ts

// src/redis/redis-key.ts

/**
 * Redis Key 규칙을 한 곳에서 관리하는 유틸입니다.
 *
 * 기본 규칙:
 * - cache:*   : JSON 캐시
 * - string:*  : Redis String 실습
 * - hash:*    : Redis Hash 실습
 * - list:*    : Redis List 실습
 * - set:*     : Redis Set 실습
 * - zset:*    : Redis Sorted Set 실습
 * - stream:*  : Redis Stream 실습
 * - channel:* : Redis Pub/Sub 실습
 */
export const RedisKey = {
  cache: {
    user: (userId: number) => `cache:user:${userId}`, // 사용자 단건 조회 캐시
  },

  string: {
    authCode: (email: string) => `string:auth-code:${email}`, // 이메일 인증 코드
    rateLimit: (key: string) => `string:rate-limit:${key}`, // 요청 횟수 제한
    postViewCount: (postId: number) => `string:post-view-count:${postId}`, // 게시글 조회수 카운터
  },

  hash: {
    userProfile: (userId: number) => `hash:user-profile:${userId}`, // 사용자 프로필 캐시
    userSession: (sessionId: string) => `hash:session:${sessionId}`, // 로그인 세션 정보
    userSetting: (userId: number) => `hash:user-setting:${userId}`, // 사용자 설정 정보
    productStock: (productId: number) => `hash:product-stock:${productId}`, // 상품 재고 상태
  },

  list: {
    postRecentViews: (userId: number) => `list:user:${userId}:recent-posts`, // 최근 본 게시글 목록
    searchRecent: (userId: number) => `list:user:${userId}:recent-searches`, // 최근 검색어 목록
    simpleJobQueue: () => `list:simple-job-queue`, // 간단한 작업 큐
    logBuffer: () => `list:log-buffer`, // 최근 로그 버퍼
  },

  set: {
    postLikes: (postId: number) => `set:post-likes:${postId}`, // 게시글 좋아요 사용자 목록
    dailyVisitors: (date: string) => `set:daily-visitors:${date}`, // 일일 방문자 중복 제거
    onlineUsers: () => `set:online-users`, // 현재 온라인 사용자 목록
    duplicateRequest: (requestId: string) => `set:duplicate-request:${requestId}`, // 중복 요청 방지
  },

  zset: {
    postRanking: () => `zset:post-ranking`, // 인기 게시글 랭킹
    searchRanking: () => `zset:search-ranking`, // 인기 검색어 순위
    userPointRanking: () => `zset:user-point-ranking`, // 사용자 포인트 랭킹
    priorityQueue: () => `zset:priority-queue`, // 우선순위 큐
  },

  stream: {
    orders: () => `stream:orders`, // 주문 이벤트 스트림
    notifications: () => `stream:notifications`, // 알림 이벤트 큐
    emails: () => `stream:emails`, // 이메일 작업 큐
    auditLogs: () => `stream:audit-logs`, // 감사 로그 스트림
  },

  channel: {
    notification: () => `channel:notification`, // 실시간 알림 채널
    cacheInvalidation: () => `channel:cache-invalidation`, // 캐시 무효화 채널
    chat: (roomId: string) => `channel:chat:${roomId}`, // 채팅방 메시지 채널
    adminNotice: () => `channel:admin-notice`, // 관리자 공지 채널
  },
} as const;

 

🔷 CacheService 기본 구조 만들기 : src/redis/cache.service.ts

// src/redis/cache.service.ts

import { redis } from '../lib/redis.js';

/**
 * Redis String 기반 JSON 캐시를 다루는 공통 서비스입니다.
 *
 * Redis의 String 자료구조는 문자열만 저장할 수 있으므로,
 * 객체 데이터는 JSON.stringify()로 문자열 변환 후 저장하고
 * 조회할 때는 JSON.parse()로 다시 객체로 변환합니다.
 *
 * 사용 예:
 * - 사용자 조회 결과 캐싱
 * - 게시글 상세 조회 캐싱
 * - 상품 상세 정보 캐싱
 */
export class CacheService {
  /**
   * Redis에서 JSON 문자열을 조회한 뒤 객체로 변환합니다.
   *
   * @param key Redis key
   * @returns 캐시가 있으면 객체, 없으면 null
   */
  async getJson<T>(key: string): Promise<T | null> {
    const cached = await redis.get(key);

    if (!cached) {
      return null;
    }

    try {
      return JSON.parse(cached) as T;
    } catch {
      await this.deleteCache(key);
      return null;
    }
  }

  /**
   * 객체 데이터를 JSON 문자열로 변환하여 Redis에 저장합니다.
   *
   * TTL을 함께 설정하여 캐시가 일정 시간이 지나면
   * 자동으로 만료되도록 합니다.
   *
   * @param key Redis key
   * @param value 저장할 객체 데이터
   * @param ttlSeconds 캐시 만료 시간, 초 단위
   */
  async setJson<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
    if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
      throw new Error('ttlSeconds must be a positive integer');
    }

    const serializedValue = JSON.stringify(value);

    await redis.set(key, serializedValue, {
      EX: ttlSeconds,
    });
  }

  /**
   * Redis key를 삭제합니다.
   *
   * 주로 DB 데이터가 수정되거나 삭제되었을 때
   * 기존 캐시를 무효화하기 위해 사용합니다.
   *
   * @param key 삭제할 Redis key
   */
  async deleteCache(key: string): Promise<void> {
    await redis.del(key);
  }

  /**
   * Redis key가 존재하는지 확인합니다.
   *
   * @param key 확인할 Redis key
   * @returns key가 존재하면 true, 없으면 false
   */
  async exists(key: string): Promise<boolean> {
    const result = await redis.exists(key);
    return result === 1;
  }
}

 

4. Vitest 기반 테스트 환경 구성하기

🔷 vitest.config.ts 작성하기

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // describe/it/expect를 테스트 파일에서 바로 사용할 수 있게 합니다.
    globals: true,

    // Fastify/Prisma 서버 코드를 테스트하므로 브라우저 대신 Node 런타임을 사용합니다.
    environment: 'node',

    // 기능별 테스트를 tests 아래에 모으는 현재 디렉터리 규칙입니다.
    include: ['tests/**/*.test.ts'],

    // DB 통합 테스트가 포함되어 있어 기본값보다 여유 있게 둡니다.
    testTimeout: 10_000,

    // 테스트 실행 전에 setup.ts 파일을 먼저 실행하여 테스트 환경을 초기화합니다.
    // 또는 각 테스트 파일이 실행되기 전에 setup.ts에서 필요한 초기화 작업을 수행할 수 있습니다.
    // setupFiles: ['./test/setup.ts'],

    // 6. 커버리지 설정
    coverage: {
      // 테스트 실행 시 커버리지 수집을 활성화합니다. 
      enabled: true, 
      // V8 엔진의 native coverage API를 사용하는 공급자 (빠르고 정확함)
      provider: 'v8', 
      // 생성할 커버리지 리포트 형식 (터미널 출력, JSON, 브라우저 HTML 등)
      reporter: ['text', 'json', 'html'],
      // 커버리지 리포트가 저장될 디렉터리 경로
      reportsDirectory: './coverage', 

      // 커버리지 측정 대상 파일을 지정합니다.
      // 보통 테스트 대상 소스코드를 포함하는 경로를 지정합니다.
      include: ['src/**/*.ts'],
      // 커버리지에서 제외할 파일 목록입니다.
      // 서버 엔트리포인트, 타입 정의(d.ts), 테스트 파일 등은 일반적으로 제외합니다.
      exclude: [
        '**/*.d.ts', // 타입 선언 파일은 실행 코드가 아니므로 제외
        '**/*.test.ts', // 테스트 자체는 커버리지 측정 대상이 아님
      ],
    },
  },
});

 

🔷 테스트 공통 setup 작성하기 : tests/setup.ts

import { afterAll, beforeEach } from 'vitest';
import { prisma } from '../src/lib/prisma.js';
import { connectRedis, disconnectRedis } from '../src/lib/redis.js';

beforeEach(async () => {
  await prisma.auditLog.deleteMany();
  await prisma.order.deleteMany();
  await prisma.post.deleteMany();
  await prisma.product.deleteMany();
  await prisma.user.deleteMany();

  const redis = await connectRedis();
  await redis.flushDb();
});

afterAll(async () => {
  await prisma.$disconnect();
  await disconnectRedis();
});

 

🔷 기본 Service 테스트 작성하기 : tests/services/redis-connection.test.ts

import { describe, expect, it } from 'vitest';
import { redis } from '../../src/lib/redis.js';
import '../setup.js';

describe('Redis Connection', () => {
  it('Redis에 값을 저장하고 조회할 수 있다', async () => {
    await redis.set('greeting', 'Hello, Redis!');

    const value = await redis.get('greeting');

    expect(value).toBe('Hello, Redis!');
  });
});

 

🔷테스트 실행

redis-examples> npx vitest .\tests\services\redis-connections.test.ts

 DEV  v4.0.17 D:/NodejsDevelope/workspace/nodejs-practice-lab/redis-examples
      Coverage enabled with v8

 ✓ tests/services/redis-connections.test.ts (1 test) 116ms
   ✓ Redis Connection (1)
     ✓ Redis에 값을 저장하고 조회할 수 있다 109ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  13:05:03
   Duration  1.45s (transform 136ms, setup 0ms, import 688ms, tests 116ms, environment 0ms)

 

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형