1차 프로젝트에서 구현했던 장바구니 단순 CRUD에 Redis를 적용해서 빠른 조회가 가능하도록 리팩토링을 진행했습니다. 이 과정을 기록하고자 합니다.
들어가기에 앞서 1차 프로젝트 주제는 음식 주문 관리 플랫폼으로 "배민 서비스"와 비슷하게 동작한다고 생각하면됩니다.
장바구니에는 하나의 가게에 대해서만 메뉴 아이템을 담을 수 있습니다. 이미 다른 가게의 메뉴가 담겨져 있고 새로 추가하려고하는 메뉴가 다른 가게라면 replace true/false 여부로 장바구니의 내용을 변경할 수 있습니다. 그리고, 이미 장바구니에 존재한 메뉴 아이템을 또 담으면 그 만큼 수량을 증가시킵니다.
RedisTemplate Hash 방식으로 장바구니 정보 redis에 저장
장바구니처럼 부분 수정이 빈번하게 일어나는 데이터에는 Hash 방식이 성능상 좋다고 한다.
Redis Hash는 하나의 키 아래에 여러 개의 필드(Field)와 값(Value) 쌍을 저장하는 구조이다. Redis Hash는 내부적으로 해시 테이블로 구현되어 있어, 특정 필드에 접근하거나 갱신하는 연산이 O(1)의 시간 복잡도를 가진다.
- 만약 String 타입이었다면 : 장바구니 전체 목록을 Redis String 타입에 하나의 거대한 JSON 문자열로 저장했다면, 수량 하나를 변경할 때마다 전체 JSON을 덮어씌워야 한다.
- 하지만 Hash 타입이라면 : 변경된 아이템 정보만 생성하거나 수정해서 HSET 만 추가하면 된다.
이를 위해, 2개의 정보를 redis에 저장해야한다.
- cart:meta
- cart:meta 키는 장바구니 아이템 목록이 저장된 메인키 cart:user:{id}와 쌍을 이루는 보조 키
- 가게 ID, 가게 이름, Cart ID 저장
- String 구조
- 장바구니에 어떤 가게의 메뉴아이템이 담겨져 있는지 알고 있어야 해서 필요한 정보이다.
- cart:user
- cart:user 키는 장바구니 아이템 목록
- menu ID, RedisCartItem 객체를 Value로 저장
- Hash 구조
만약 사용자 ID가 1 이고, 장바구니에 아이템이 두 개 있을 때, 예) 짬뽕 2개, 탕수육 1개
| Redis Key | Field | Value(Json) |
| cart:meta:1 | String | {"storeId": "s1 ...", "storeName": "중국집 만리장성", "cartId": "c1..."} |
| Redis Key | Field | Value(Json) |
| cart:user:1 | e705... (짬뽕 ID) | {"menuName": "짬뽕", "unitPrice": 6000, "quantity": 2, ...} |
| cart:user:1 | a3f2... (탕수육 ID) | {"menuName": "탕수육", "unitPrice": 20000, "quantity": 1, ...} |
이렇게 저장된다.
수정된 코드
RedisConfig.class
- Redis Hash 및 Value 저장을 위한 Spring RedisTemplate 빈
- Key와 HashKey는 **StringRedisSerializer**로 설정하여 가독성을 높였고, Value와 HashValue는 GenericJackson2JsonRedisSerializer로 설정하여 자바 객체(DTO)를 JSON 형태로 Redis에 직렬화/역직렬화
@Bean
public RedisTemplate<String, Object> cartHashRedisTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// Key/HashKey 직렬화 (cart:user:1, uuid)
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value/HashValue 직렬화 (RedisCartItem 객체)
// Hash Value를 Object로 설정하여 RedisCartItem을 저장합니다.
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
RedisCartMeta.class
- 장바구니 전체의 메타 정보 (가게 ID, 가게 이름, Cart DB ID)를 담는 DTO
@Builder
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RedisCartMeta implements Serializable {
private static final long serialVersionUID = 3L;
private UUID storeId;
private String storeName;
private UUID cartId;
}
RedisCartItem.class
- 장바구니 내 개별 아이템 정보 (메뉴 ID, 수량, 가격)를 담는 DTO
@Builder
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class RedisCartItem implements Serializable {
private static final long serialVersionUID = 2L;
private UUID menuItemId;
private String menuName;
private BigInteger unitPrice;
private int quantity;
public static RedisCartItem fromEntity(CartItem ci) {
return new RedisCartItem(
ci.getMenuItem().getId(),
ci.getMenuItem().getName(),
ci.getMenuItem().getPrice(),
ci.getQuantity()
);
}
}
RedisKeys.class
- Redis 키 패턴 (cart:user:%d, cart:meta:%d)과 TTL 상수를 중앙 관리하는 클래스
public final class RedisKeys {
public static final long CART_TTL_MINUTES = 30;
// 키 패턴 정의
private static final String CART_KEY_FORMAT = "cart:user:%d";
private static final String CART_META_KEY_FORMAT = "cart:meta:%d";
private RedisKeys() {
}
public static String getCartKey(Long userId) {
return String.format(CART_KEY_FORMAT, userId);
}
public static String getCartMetaKey(Long userId) {
return String.format(CART_META_KEY_FORMAT, userId);
}
}
Service에서 CRUD
Read
- Read-Through 전략
- Redis Hash와 Meta 정보가 모두 있으면 캐시 히트로 응답하고, 하나라도 없으면 DB에서 조회 후 Redis에 다시 저장(Cache Warming)하여 다음 요청에 대비한다.
@Override
@Transactional(readOnly = true)
public CartResponseDto getCart(UserAuth userAuth) {
Long userId = userAuth.getId();
String cartKey = RedisKeys.getCartKey(userId);
String cartMetaKey = RedisKeys.getCartMetaKey(userId);
// Redis Hash에서 모든 항목 조회 (HGETALL)
Map<Object, Object> hashEntries = cartHashRedisTemplate.opsForHash().entries(cartKey);
if (!hashEntries.isEmpty()) {
// 메타 정보 조회
RedisCartMeta meta = (RedisCartMeta) cartHashRedisTemplate.opsForValue()
.get(cartMetaKey);
log.info("meta: {}", meta);
// 메타 정보도 있다면 완전한 캐시 히트
if (meta != null) {
List<RedisCartItem> redisItems = hashEntries.values().stream()
.filter(o -> o instanceof RedisCartItem)
.map(o -> (RedisCartItem) o)
.collect(Collectors.toList());
return CartResponseDto.fromRedisItems(meta, redisItems);
}
}
// 캐시 미스 (Cache Miss) 또는 Redis에 데이터가 없을 경우 DB 조회 (Read-Through)
Cart dbCart = findByUserId(userId);
// DB 데이터로 Redis Hash를 채움 (Cache Warming)
Map<String, RedisCartItem> redisMap = new HashMap<>();
dbCart.getItems().forEach(ci -> {
RedisCartItem redisItem = RedisCartItem.fromEntity(ci);
redisMap.put(ci.getMenuItem().getId().toString(), redisItem);
});
if (!redisMap.isEmpty()) {
cartHashRedisTemplate.opsForHash().putAll(cartKey, redisMap); // 새로 갱신
// 메타 정보 저장 (누락된 로직 추가)
RedisCartMeta meta = new RedisCartMeta(dbCart.getStore().getId(),
dbCart.getStore().getName(),
dbCart.getId());
cartHashRedisTemplate.opsForValue().set(cartMetaKey, meta);
// TTL 설정
cartHashRedisTemplate.expire(cartKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
cartHashRedisTemplate.expire(cartMetaKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
}
return CartResponseDto.from(dbCart);
}
Create
- Write-Through 전략
- DB에 아이템을 추가/갱신한 후, Redis Hash에 해당 아이템만 HSET으로 갱신하고, 메타 정보도 SET하여 TTL을 연장한다.
- replace=true 시에는 DB 저장 전에 기존 Redis 키를 삭제하여 캐시를 초기화한다.
@Override
@Transactional
public CartResponseDto addItem(UserAuth userAuth, AddCartItemRequestDto req, boolean replace) {
User user = userService.findByUser(userAuth);
String cartKey = RedisKeys.getCartKey(user.getId());
String cartMetaKey = RedisKeys.getCartMetaKey(user.getId());
MenuItem menu = menuItemService.findById(req.getMenuItemId());
if (menu.getIsSoldout()) {
throw new MenuItemSoldOutException();
}
UUID menuId = menu.getId();
// 장바구니가 없는 경우 새 생성
Cart cart = cartRepository.findByUserId(user.getId())
.orElse(Cart.of(user, menu.getStore()));
// 장바구니가 있고 다른 가게인 경우
if (cart.getStore() != null && !cart.getStore().getId().equals(menu.getStore().getId())) {
if (replace) {
cart.clear();
cart.setStore(menu.getStore());
cartHashRedisTemplate.delete(cartKey);
cartHashRedisTemplate.delete(cartMetaKey);
} else {
throw new CartStoreConflictException();
}
}
// 이미 존재하면 수량 합산, 없으면 새 아이템 추가
cart.addItem(menu, req.getQuantity());
Cart savedCart = cartRepository.save(cart);
// 장바구니 아이템 Entity에서 Redis용 스냅샷 DTO 생성
CartItem addedItem = savedCart.getItems().stream()
.filter(ci -> ci.getMenuItem().getId().equals(menuId))
.findFirst().orElseThrow(CartItemNotFoundException::new);
RedisCartItem redisItem = RedisCartItem.fromEntity(addedItem);
// Hash 구조에 해당 필드만 갱신
cartHashRedisTemplate.opsForHash().put(cartKey, menuId.toString(), redisItem);
// 메타 정보 저장 (Write-Through)
RedisCartMeta meta = new RedisCartMeta(savedCart.getStore().getId(),
savedCart.getStore().getName(),
savedCart.getId());
cartHashRedisTemplate.opsForValue().set(cartMetaKey, meta);
cartHashRedisTemplate.expire(cartKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
cartHashRedisTemplate.expire(cartMetaKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
return CartResponseDto.from(savedCart);
}
Update
- Write-Through 전략
- DB에서 수량을 변경 후 저장하고, Redis Hash에서 해당 메뉴 ID의 Field만 HSET으로 덮어쓴다. ⭐️
- Redis의 정보를 모두 수정할 필요 없이 해당 메뉴 Field만 수정하는 것이 장점.
- 매번 TTL을 갱신하여 장바구니 만료 시간을 연장한다.
@Override
@Transactional
public CartResponseDto updateItemQuantity(UserAuth userAuth, UUID menuItemId, int quantity) {
Long userId = userAuth.getId();
String cartKey = RedisKeys.getCartKey(userId);
String cartMetaKey = RedisKeys.getCartMetaKey(userId);
Cart cart = findByUserId(userId);
CartItem item = getCartItem(menuItemId, cart);
if (quantity <= 0) {
throw new InvalidCartQuantityException();
}
item.updateQuantity(quantity);
Cart saved = cartRepository.save(cart);
CartItem updatedItem = getCartItem(menuItemId, saved);
RedisCartItem redisItem = RedisCartItem.fromEntity(updatedItem);
cartHashRedisTemplate.opsForHash().put(cartKey, menuItemId.toString(), redisItem);
cartHashRedisTemplate.expire(cartKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
cartHashRedisTemplate.expire(cartMetaKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
return CartResponseDto.from(saved);
}
Delete
deleteItem()
- Write-Through 전략
- DB에서 아이템을 삭제하고, 장바구니가 비지 않았다면 Redis Hash에서 해당 메뉴 ID의 Field만 HDEL로 빠르게 제거한다.
- TTL도 연장
deleteCart()
- Cache Evict 전략
- DB에서 Cart 엔티티를 삭제한 후, cart:user와 cart:meta 두 키를 Redis에서 즉시 삭제하여 캐시를 무효화한다.
@Override
@Transactional
public void deleteItem(UserAuth userAuth, UUID menuItemId) {
Long userId = userAuth.getId();
String cartKey = RedisKeys.getCartKey(userId);
String cartMetaKey = RedisKeys.getCartMetaKey(userId);
Cart cart = findByUserId(userId);
CartItem item = getCartItem(menuItemId, cart);
cart.removeItem(item.getId());
if (cart.getItems().isEmpty()) { // 장바구니에 메뉴가 없으면 장바구니 삭제
cartRepository.delete(cart);
cartHashRedisTemplate.delete(cartKey);
cartHashRedisTemplate.delete(cartMetaKey);
} else {
cartRepository.save(cart);
cartHashRedisTemplate.opsForHash().delete(cartKey, menuItemId.toString());
cartHashRedisTemplate.expire(cartKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
cartHashRedisTemplate.expire(cartMetaKey, RedisKeys.CART_TTL_MINUTES, TimeUnit.MINUTES);
}
}
@Override
@Transactional
public void deleteCart(UserAuth userAuth) {
Long userId = userAuth.getId();
String cartKey = RedisKeys.getCartKey(userId);
String cartMetaKey = RedisKeys.getCartMetaKey(userId);
Cart cart = findByUserId(userId);
cartRepository.delete(cart);
cartHashRedisTemplate.delete(cartKey);
cartHashRedisTemplate.delete(cartMetaKey);
}
요청 시간 결과
조회
- 처음 조회(183ms)

- 40ms

처음에만 hibernate 하여 db에서 정보를 조회하지만, 이후에는 Redis 캐시에서 계속 값을 가지고 온다.


redis에도 meta정보와 user 정보가 잘 들어간 것을 확인할 수 있다.
'Redis' 카테고리의 다른 글
| [redis] 캐싱 개념과 전략 (0) | 2025.11.03 |
|---|---|
| Docker compose로 Redis Intellij에서 연결하기 (0) | 2025.10.28 |