1.시스템&인프라/redis

4편. Redis Hash 실습: 프로필, 세션, 재고, 사용자 설정

쿼드큐브 2026. 6. 26. 17:30
반응형
반응형

 

4편. Redis Hash 실습: 프로필, 세션, 재고, 사용자 설정

 

📚 목차
1. 사용자 프로필 캐시 저장하기
2. 로그인 세션 저장하기
3. 상품 재고 상태 캐싱하기
4. 사용자 설정 정보 관리하기

 

 

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

 

Redis Hash는 하나의 Redis key 안에 여러 개의 field-value를 저장하는 자료구조입니다.
String이 하나의 key에 하나의 문자열 값을 저장한다면, Hash는 하나의 key 안에 객체처럼 여러 필드를 저장할 수 있습니다.

String 예시

cache:user:1
→ '{"id":1,"email":"kim@example.com","name":"Kim","point":100}'
Hash 예시

hash:user-profile:1
→ id        = "1"
→ email     = "kim@example.com"
→ name      = "Kim"
→ point     = "100"
→ status    = "ACTIVE"
→ createdAt = "2026-06-17T00:00:00.000Z"
→ updatedAt = "2026-06-17T00:00:00.000Z"

Hash를 사용하면 객체 전체를 JSON 문자열로 다시 저장하지 않고, 필요한 필드만 읽거나 수정할 수 있습니다.

 

1. 사용자 프로필 캐시 저장하기

사용자 프로필은 여러 API에서 자주 조회되는 데이터입니다.

예를 들어 다음과 같은 화면이나 기능에서 반복 조회될 수 있습니다.

- 마이페이지
- 게시글 작성자 정보
- 댓글 작성자 정보
- 사용자 포인트 표시
- 사용자 상태 확인

 

Redis Hash는 사용자 프로필을 필드 단위로 저장합니다.

hash:user-profile:1
  id        → "1"
  email     → "kim@example.com"
  name      → "Kim"
  point     → "100"
  status    → "ACTIVE"
  createdAt → "..."
  updatedAt → "..."

이렇게 저장하면 name, status, point 같은 특정 필드를 다루기 쉬워집니다.

 

✔️ 사용하는 Redis Key

사용자 프로필 Hash key는 기존 RedisKey 유틸을 그대로 사용합니다.

RedisKey.hash.userProfile(userId)

실제 key는 다음과 같습니다.

hash:user-profile:1
hash:user-profile:2

 

✔️ Hash 캐시 조회

사용자 프로필 조회의 핵심 메서드는 getUserProfile()입니다.

// src/services/user-hash.service.ts

/**
 * 사용자 프로필 조회
 *
 * 1. Redis Hash 조회
 * 2. 캐시가 있으면 Redis 데이터 반환
 * 3. 캐시가 없으면 DB 조회
 * 4. DB 조회 결과를 Redis Hash에 저장
 *
 * 실습 포인트:
 * JSON 캐시와 달리 Redis Hash는 필요한 필드만 따로 읽거나 갱신하는 흐름을 만들 수 있습니다.
 */
async getUserProfile(userId: number): Promise<UserProfileOutput> {
  // Cache hit: Redis Hash에 데이터가 있으면 DB를 조회하지 않고 바로 반환합니다.
  const cachedProfile = await this.getUserProfileFromHash(userId);

  if (cachedProfile) {
    return cachedProfile;
  }

  // Cache miss: Redis Hash에 없을 때만 DB를 조회합니다.
  const dbProfile = await this.getUserProfileFromDatabase(userId);

  // DB 조회 결과를 다음 요청에서 재사용할 수 있도록 Hash에 저장합니다.
  await this.saveUserProfileToHash(dbProfile);

  return dbProfile;
}
String 캐시
→ 객체 전체를 JSON 문자열 하나로 저장

Hash 캐시
→ 객체를 field-value 단위로 나누어 저장

 

✔️ Hash 캐시 저장

/**
 * 사용자 프로필을 Redis Hash에 저장
 *
 * 1. userId로 Redis Hash key를 만듭니다.
 * 2. UserProfileOutput 필드를 Redis Hash 필드로 저장합니다.
 * 3. TTL을 설정해 오래된 캐시가 무기한 남지 않게 합니다.
 *
 * 실습 포인트:
 * Redis Hash는 객체 전체를 JSON 문자열로 저장하지 않고, 필드별로 나누어 저장할 수 있습니다.
 */
async saveUserProfileToHash(user: UserProfileOutput, ttlSeconds = 300): Promise<void> {
  // 사용자 프로필 Hash key입니다.
  // 예: hash:user-profile:1
  const key = RedisKey.hash.userProfile(user.id);

  // Redis Hash의 field/value는 문자열 기반으로 다루는 것이 안전합니다.
  await redis.hSet(key, {
    id: String(user.id),
    email: user.email,
    name: user.name,
    point: String(user.point),
    status: user.status,
    createdAt: user.createdAt,
    updatedAt: user.updatedAt,
  });

  // TTL 300초가 기본값입니다.
  // 시간이 지나면 Redis가 key를 자동 삭제합니다.
  await redis.expire(key, ttlSeconds);
}

 

✔️ 사용자 수정 후 Hash 갱신

사용자 프로필을 수정할 때는 DB를 먼저 수정합니다.
그다음 수정된 DB 결과를 Redis Hash에 다시 저장합니다.

 

이 방식은 Redis Hash를 최신 상태로 바로 맞추는 방식입니다.

DB update
  ↓
DB 결과를 UserProfileOutput으로 변환
  ↓
Redis Hash 갱신
/**
 * 사용자 프로필 수정
 *
 * 1. userId에 해당하는 사용자의 name/status를 수정합니다.
 * 2. undefined가 아닌 필드만 update data에 포함합니다.
 * 3. 수정된 사용자 정보를 UserProfileOutput 형태로 변환합니다.
 * 4. Redis Hash를 최신 데이터로 다시 저장합니다.
 *
 * 실습 포인트:
 * DB를 먼저 수정한 뒤 Redis Hash를 최신 데이터로 다시 저장합니다.
 * 이 방식은 write-through에 가까운 흐름입니다.
 *
 * 실무에서는 DB 갱신 후 Redis Hash를 삭제하는 방식도 자주 사용합니다.
 * 캐시를 직접 갱신하는 것보다 단순하고, 다음 조회 때 DB 기준 최신 값을 다시 캐싱하므로 더 안전합니다.
 */
async updateUserProfile(
  userId: number,
  input: UpdateUserProfileInput,
): Promise<UserProfileOutput> {
  // prisma.user.update()는 대상 사용자가 없으면 null을 반환하지 않고
  // P2025 예외를 던집니다.
  const user = await prisma.user.update({
    where: {
      id: userId,
    },
    data: {
      // 값이 undefined인 필드는 업데이트하지 않습니다.
      // 예: name만 들어오면 status는 기존 값을 유지합니다.
      ...(input.name !== undefined && { name: input.name }),
      ...(input.status !== undefined && { status: input.status }),
    },
    select: UserProfileSelect,
  });

  const output = toUserProfileOutput(user);

  // DB 업데이트 후 Redis Hash도 같은 값으로 갱신합니다.
  await this.saveUserProfileToHash(output);

  return output;
}

 

실무에서는 두 가지 방식 중 하나를 선택합니다.

방식 1. DB 수정 후 Redis Hash 갱신
- 장점: 다음 조회부터 바로 Redis 사용 가능
- 단점: 캐시 갱신 코드가 복잡해질 수 있음

방식 2. DB 수정 후 Redis Hash 삭제
- 장점: 단순하고 안전함
- 단점: 다음 조회 때 다시 DB 조회 필요

 

2. 로그인 세션 저장하기

로그인 세션은 Redis Hash와 잘 어울리는 대표적인 데이터입니다.
세션에는 보통 다음과 같은 필드가 들어갑니다.

- sessionId
- userId
- email
- role
- issuedAt
- expiresAt
- userAgent
- ip

이런 데이터는 하나의 객체이지만, 필드 단위로 조회하거나 갱신할 일이 있습니다.

 

예를 들어 다음과 같은 처리가 가능합니다.

- 세션의 userId만 조회
- 세션 만료 시간만 확인
- 마지막 접근 시간만 갱신
- 세션 전체 삭제

 

✔️ 사용하는 Redis Key

RedisKey.hash.userSession(sessionId)

실제 key는 다음과 같습니다.

hash:session:session-abc-123

 

✔️ 세션 생성

세션 저장은 hSet()으로 처리합니다.

// src/services/session-hash.service.ts

/**
 * 로그인 세션 생성
 *
 * 1. sessionId로 Redis Hash key를 만듭니다.
 * 2. 현재 시간을 기준으로 발급 시간과 만료 시간을 계산합니다.
 * 3. 세션 정보를 Redis Hash 필드로 저장합니다.
 * 4. TTL을 설정해 만료 시간이 지나면 세션이 자동 삭제되게 합니다.
 *
 * 실습 포인트:
 * Redis Hash는 세션 정보를 필드별로 저장할 수 있어 userId 같은 특정 값만 따로 조회하기 쉽습니다.
 */
async createSession(input: CreateSessionInput, ttlSeconds = 60 * 60): Promise<SessionOutput> {
  // 로그인 세션 Hash key입니다.
  // 예: hash:session:abc123
  const key = RedisKey.hash.userSession(input.sessionId);

  // issuedAt/expiresAt은 서버 시간을 기준으로 계산합니다.
  const now = new Date();
  const expiresAt = new Date(now.getTime() + ttlSeconds * 1000);

  const session: SessionOutput = {
    sessionId: input.sessionId,
    userId: input.userId,
    email: input.email,
    role: input.role,
    issuedAt: now.toISOString(),
    expiresAt: expiresAt.toISOString(),
    lastAccessedAt: now.toISOString(),
    userAgent: input.userAgent ?? '',
    ip: input.ip ?? '',
  };

  // Redis Hash의 field/value는 문자열 기반으로 다루는 것이 안전합니다.
  await redis.hSet(key, {
    sessionId: session.sessionId,
    userId: String(session.userId),
    email: session.email,
    role: session.role,
    issuedAt: session.issuedAt,
    expiresAt: session.expiresAt,
    lastAccessedAt: session.lastAccessedAt,
    userAgent: session.userAgent,
    ip: session.ip,
  });

  // TTL 기본값은 1시간입니다.
  // 시간이 지나면 Redis가 세션 key를 자동 삭제합니다.
  await redis.expire(key, ttlSeconds);

  return sess
}

 

저장 결과는 다음과 같습니다.

hash:session:session-abc-123
sessionId      = "session-abc-123"
userId         = "1"
email          = "kim@example.com"
role           = "USER"
issuedAt       = "2026-06-17T00:00:00.000Z"
expiresAt      = "2026-06-17T01:00:00.000Z"
lastAccessedAt = "2026-06-17T00:00:00.000Z"
userAgent      = "Chrome"
ip             = "127.0.0.1"

 

세션은 영구 데이터가 아니므로 TTL을 설정합니다.

await redis.expire(key, ttlSeconds);

 

✔️ 특정 필드만 조회하기

Hash의 장점은 특정 필드만 조회할 수 있다는 것입니다.

/**
 * 세션의 사용자 ID만 조회
 *
 * 1. sessionId로 Redis Hash key를 만듭니다.
 * 2. hGet으로 userId 필드만 조회합니다.
 * 3. 값이 있으면 숫자로 변환하고, 없으면 null을 반환합니다.
 *
 * 실습 포인트:
 * Redis Hash는 전체 객체를 읽지 않고 특정 field만 조회할 수 있습니다.
 */
async getSessionUserId(sessionId: string): Promise<number | null> {
  const key = RedisKey.hash.userSession(sessionId);
  const userId = await redis.hGet(key, 'userId');

  return userId ? Number(userId) : null;
}

 

세션 전체를 가져오지 않고 userId 필드만 조회합니다.

HGET hash:session:session-abc-123 userId
→ "1"

 

이런 방식은 인증 미들웨어에서 유용합니다.

요청 쿠키에서 sessionId 확인
  ↓
Redis Hash에서 userId만 조회
  ↓
userId가 있으면 인증된 사용자로 처리

 

✔️ 특정 필드만 갱신하기

Hash 방식은 특정 필드만 갱신하면 됩니다.

/**
 * 마지막 접근 시간 갱신
 *
 * 1. sessionId로 Redis Hash key를 만듭니다.
 * 2. 현재 시간을 ISO 문자열로 만듭니다.
 * 3. lastAccessedAt 필드만 갱신합니다.
 *
 * 실습 포인트:
 * 세션 전체를 다시 저장하지 않고 필요한 field만 수정할 수 있습니다.
 */
async touchSession(sessionId: string): Promise<void> {
  const key = RedisKey.hash.userSession(sessionId);

  await redis.hSet(key, {
    lastAccessedAt: new Date().toISOString(),
  });
}
반응형

 

3. 상품 재고 상태 캐싱하기

예를 들어 상품 상세 페이지에서는 다음 정보를 반복 조회할 수 있습니다.

- 현재 재고 수량
- 판매 상태
- 예약 재고
- 마지막 갱신 시간

이 데이터는 필드 단위로 갱신될 가능성이 높습니다.

stock만 변경
status만 변경
reservedStock만 변경

따라서 Redis Hash로 저장하면 필드 단위 수정 흐름을 자연스럽게 만들 수 있습니다.

 

✔️ 사용하는 Redis Key

RedisKey.hash.productStock(productId)

 

실제 key는 다음과 같습니다.

hash:product-stock:1
hash:product-stock:2

 

✔️ 재고상태 생성

// src/services/product-hash.service.ts

/**
 * 상품 생성
 *
 * 1. 전달받은 name/stock/status로 DB에 상품을 생성합니다.
 * 2. ProductStockSelect로 필요한 필드만 다시 가져옵니다.
 * 3. Prisma 결과를 ProductStockOutput 형태로 변환합니다.
 * 4. 생성된 상품 재고 상태를 Redis Hash에 저장합니다.
 *
 * 실습 포인트:
 * DB 저장 결과를 기준으로 Redis Hash 캐시를 만들어 조회 흐름에서 재사용합니다.
 */
async createProduct(input: CreateProductInput): Promise<ProductStockOutput> {
  const product = await prisma.product.create({
    data: {
      name: input.name,
      stock: input.stock,
      status: input.status ?? 'ON_SALE',
    },
    select: ProductStockSelect,
  });

  const output = toProductStockOutput(product);

  // DB 생성 결과를 기준으로 Redis Hash를 저장합니다.
  await this.saveProductStockToHash(output);

  return output;
}

/**
 * 상품 재고 상태를 Redis Hash에 저장
 *
 * 1. productId로 Redis Hash key를 만듭니다.
 * 2. ProductStockOutput 필드를 Redis Hash 필드로 저장합니다.
 * 3. TTL을 설정해 오래된 캐시가 무기한 남지 않게 합니다.
 *
 * 실습 포인트:
 * Redis Hash는 stock/reservedStock/availableStock처럼 관련 있는 값을 필드별로 저장할 수 있습니다.
 */
async saveProductStockToHash(product: ProductStockOutput, ttlSeconds = 300): Promise<void> {
  // 상품 재고 Hash key입니다.
  // 예: hash:product-stock:1
  const key = RedisKey.hash.productStock(product.productId);

  // Redis Hash의 field/value는 문자열 기반으로 다루는 것이 안전합니다.
  await redis.hSet(key, {
    productId: String(product.productId),
    name: product.name,
    stock: String(product.stock),
    reservedStock: String(product.reservedStock),
    availableStock: String(product.availableStock),
    status: product.status,
    updatedAt: product.updatedAt,
  });

  // TTL 300초가 기본값입니다.
  // 시간이 지나면 Redis가 key를 자동 삭제합니다.
  await redis.expire(key, ttlSeconds);
}

 

Redis에는 다음과 같이 저장됩니다.

hash:product-stock:1
productId      = "1"
name           = "기계식 키보드"
stock          = "100"
reservedStock  = "3"
availableStock = "97"
status         = "ON_SALE"
updatedAt      = "2026-06-17T00:00:00.000Z"

 

✔️ 예약 재고를 Hash에서 관리하기

/**
 * 예약 재고 증가
 *
 * 1. 현재 상품 재고 상태를 조회합니다.
 * 2. reservedStock을 전달받은 수량만큼 증가시킵니다.
 * 3. availableStock을 stock - reservedStock으로 다시 계산합니다.
 * 4. 변경된 재고 상태를 Redis Hash에 저장합니다.
 *
 * 실습 포인트:
 * 실제 주문 확정 전 임시 예약 수량을 Redis Hash에서 관리합니다.
 * 이 메서드는 DB 재고를 직접 바꾸지 않고 Redis Hash의 예약 상태만 갱신합니다.
 *
 * 참고:
 * 실무에서는 초과 예약 검증과 동시성 제어가 필요하지만, 
 * 여기서는 Redis Hash 흐름을 이해하기 위해 단순하게 구성합니다.
 */
async increaseReservedStock(productId: number, quantity: number): Promise<ProductStockOutput> {
  const current = await this.getProductStock(productId);

  const nextReservedStock = current.reservedStock + quantity;
  const nextAvailableStock = current.stock - nextReservedStock;

  const updated: ProductStockOutput = {
    ...current,
    reservedStock: nextReservedStock,
    availableStock: nextAvailableStock,
    updatedAt: new Date().toISOString(),
  };

  // 예약 재고는 Redis Hash에만 반영합니다.
  await this.saveProductStockToHash(updated);

  return updated;
}

흐름은 다음과 같습니다.

현재 상품 재고 Hash 조회
  ↓
reservedStock 증가
  ↓
availableStock 재계산
  ↓
Redis Hash 다시 저장

실제 주문 시스템에서는 재고 정확성이 매우 중요하므로 DB 트랜잭션, 락, 재고 차감 정책이 함께 필요합니다.
이번 실습에서는 Redis Hash의 필드 기반 구조를 이해하기 위한 단순 예제로 보면 됩니다.

 

4. 사용자 설정 정보 관리하기

예를 들어 다음과 같은 설정값이 있을 수 있습니다.

- theme
- language
- emailNotification
- smsNotification
- marketingAgreed

이 데이터는 필드 단위로 자주 수정됩니다.

테마만 변경
언어만 변경
이메일 알림 여부만 변경

따라서 JSON 문자열 전체를 다시 저장하는 것보다 Hash로 관리하는 것이 자연스럽습니다.

 

✔️ 사용하는 Redis Key

RedisKey.hash.userSetting(userId)

 

실제 key는 다음과 같습니다.

hash:user-setting:1
hash:user-setting:2

 

✔️ 사용자 설정 저장

// src/services/user-setting-hash.service.ts

/**
 * 사용자 설정 전체를 Redis Hash에 저장합니다.
 *
 * 1. userId로 사용자 설정 key를 만듭니다.
 * 2. UserSettingOutput의 각 항목을 저장합니다.
 * 3. boolean 값은 저장하기 전에 문자열로 변환합니다.
 *
 * 실습 포인트:
 * Redis Hash는 사용자 설정처럼 관련 있는 여러 값을 하나의 key 아래 field별로 저장할 수 있습니다.
 */
async saveUserSettingToHash(setting: UserSettingOutput): Promise<void> {
  const key = RedisKey.hash.userSetting(setting.userId);

  await redis.hSet(key, {
    theme: setting.theme,
    language: setting.language,
    emailNotification: String(setting.emailNotification),
    smsNotification: String(setting.smsNotification),
    marketingAgreed: String(setting.marketingAgreed),
    updatedAt: setting.updatedAt,
  });
}

사용자 설정은 Redis Hash에 다음처럼 저장됩니다.

hash:user-setting:1
theme             = "light"
language          = "ko"
emailNotification = "true"
smsNotification   = "false"
marketingAgreed   = "false"
updatedAt         = "2026-06-17T00:00:00.000Z"

 

Boolean 값은 Redis에 문자열로 저장합니다.

emailNotification: String(setting.emailNotification),
smsNotification: String(setting.smsNotification),
marketingAgreed: String(setting.marketingAgreed),

 

조회할 때는 다시 boolean으로 변환합니다.

function parseBoolean(value: string | undefined): boolean {
  return value === 'true';
}

 

✔️ 일부 필드만 수정하기

/**
 * 사용자 설정 일부를 수정합니다.
 *
 * 1. getUserSetting으로 사용자 설정이 없으면 기본 설정을 먼저 생성합니다.
 * 2. undefined가 아닌 입력 항목만 수정 대상에 포함합니다.
 * 3. updatedAt은 현재 시간으로 갱신합니다.
 * 4. hSet으로 전달된 항목만 Redis Hash에 반영합니다.
 * 5. 갱신된 사용자 설정 전체를 다시 조회해 반환합니다.
 *
 * 실습 포인트:
 * Redis Hash는 전체 객체를 다시 저장하지 않아도 필요한 field만 부분 수정할 수 있습니다.
 */
async updateUserSetting(
  userId: number,
  input: UpdateUserSettingInput,
): Promise<UserSettingOutput> {
  // key가 없을 때 일부 항목만 저장되는 일을 막기 위해 기본 설정 전체를 먼저 보장합니다.
  // 설정 Hash가 없거나 불완전할 수 있으므로,
  // 업데이트 전에 기본 설정 필드 전체가 존재하도록 보장한다.
  await this.getUserSetting(userId);

  const key = RedisKey.hash.userSetting(userId);

  const fieldsToUpdate: Record<string, string> = {
    updatedAt: new Date().toISOString(),
  };

  if (input.theme !== undefined) {
    fieldsToUpdate.theme = input.theme;
  }
  if (input.language !== undefined) {
    fieldsToUpdate.language = input.language;
  }
  if (input.emailNotification !== undefined) {
    fieldsToUpdate.emailNotification = String(input.emailNotification);
  }
  if (input.smsNotification !== undefined) {
    fieldsToUpdate.smsNotification = String(input.smsNotification);
  }
  if (input.marketingAgreed !== undefined) {
    fieldsToUpdate.marketingAgreed = String(input.marketingAgreed);
  }
  await redis.hSet(key, fieldsToUpdate);
  return this.getUserSetting(userId);
}

전달된 필드만 fieldsToUpdate에 담습니다.

그다음 hSet()으로 해당 필드만 갱신합니다.

 

예를 들어 테마만 변경하면 다음과 같은 동작이 됩니다.

변경 요청:
{
  "theme": "dark"
}

Redis 반영:
HSET hash:user-setting:1 theme "dark" updatedAt "..."

 

 


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

반응형

 

반응형