메뉴 데이터 캐싱을 통한 성능 최적화
1. Redis를 활용한 메뉴 정보 캐싱
사용자 앱에서 메뉴 목록을 요청할 때마다 데이터베이스 조회가 발생하면, 트래픽 증가 시 DB 부하가 심화되어 응답 지연이 발생할 수 있다. 이를 해결하기 위해 Redis를 사용하여 자주 조회되는 메뉴 데이터를 캐싱하는 전략을 도입한다.
캐싱 전략 설계
- 카테고리별로 메뉴 목록을 별도의 키로 저장 (예:
menu_category_101) - 메뉴 정보 변경(등록/수정/삭제) 시 관련 캐시 무효화
Controller 레이어 - 캐시 조회 로직 추가
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/list")
@ApiOperation("카테고리별 메뉴 목록 조회")
public Result<List<DishVO>> listByCategory(Long categoryId) {
String key = "menu_category_" + categoryId;
// Redis에서 캐시된 데이터 조회
String cachedData = redisTemplate.opsForValue().get(key);
if (cachedData != null) {
List<DishVO> menuList = JsonUtil.toList(cachedData, DishVO.class);
return Result.success(menuList);
}
// 캐시 미존재 시 DB 조회 및 캐싱
Dish query = new Dish();
query.setCategoryId(categoryId);
query.setStatus(ENABLE);
List<DishVO> menuList = dishService.listWithFlavor(query);
redisTemplate.opsForValue().set(key, JsonUtil.toJson(menuList), Duration.ofMinutes(10));
return Result.success(menuList);
}
공통 캐시 제거 유틸리티 메서드
/**
* 와일드카드 패턴에 맞는 모든 캐시 항목 삭제
*/
private void invalidateCache(String pattern) {
Set<String> keys = redisTemplate.keys(pattern);
if (!CollectionUtils.isEmpty(keys)) {
redisTemplate.delete(keys);
}
}
데이터 변경 시 캐시 동기화 예시
@PostMapping
@ApiOperation("신규 메뉴 등록")
public Result addMenu(@RequestBody DishDTO dto) {
dishService.saveWithFlavor(dto);
// 해당 카테고리 캐시 삭제
invalidateCache("menu_category_" + dto.getCategoryId());
return Result.success();
}
@DeleteMapping
@ApiOperation("메뉴 일괄 삭제")
public Result batchDelete(@RequestParam List<Long> ids) {
dishService.deleteBatch(ids);
// 전체 메뉴 캐시 초기화
invalidateCache("menu_category_*");
return Result.success();
}
Spring Cache 어노테이션을 이용한 세트메뉴 캐싱
1. Spring Cache 개요
Spring에서 제공하는 선언적 캐싱 추상화 기능으로, 어노테이션만으로 캐시 논리를 구현할 수 있다. 백엔드로는 Redis, Caffeine 등 다양한 구현체를 사용 가능하다.
2. 주요 어노테이션
| 어노테이션 | 설명 |
|---|---|
| @EnableCaching | 프로젝트 전역 캐싱 기능 활성화 (부트스트랩 클래스에 적용) |
| @Cacheable | 메서드 실행 전 캐시 확인, 존재 시 반환, 없으면 실행 후 저장 |
| @CacheEvict | 지정된 캐시 항목 제거 |
| @CachePut | 메서드 결과를 강제로 캐시에 저장 |
3. 세트메뉴 캐싱 적용
부트스트랩 클래스에 @EnableCaching 추가 후, 다음과 같이 어노테이션을 적용:
@GetMapping("/list")
@ApiOperation("카테고리별 세트메뉴 조회")
@Cacheable(value = "setmeal", key = "#categoryId")
public Result<List<Setmeal>> getSetmeals(Long categoryId) {
Setmeal condition = new Setmeal();
condition.setCategoryId(categoryId);
condition.setStatus(ENABLE);
List<Setmeal> result = setmealService.list(condition);
return Result.success(result);
}
4. 데이터 변경 시 캐시 무효화
@PostMapping
@ApiOperation("세트메뉴 등록")
@CacheEvict(value = "setmeal", key = "#dto.categoryId")
public Result create(@RequestBody SetmealDTO dto) {
setmealService.saveWithDish(dto);
return Result.success();
}
@DeleteMapping
@ApiOperation("세트메뉴 일괄 삭제")
@CacheEvict(value = "setmeal", allEntries = true)
public Result delete(@RequestParam List<Long> ids) {
setmealService.deleteBatch(ids);
return Result.success();
}
장바구니 기능 구현
1. 요구사항
- 사용자는 메뉴 또는 세트메뉴를 장바구니에 담을 수 있음
- 동일 상품은 수량 증가로 처리 (중복 레코드 방지)
- 맛 선택이 필요한 메뉴는 옵션 포함 저장
2. 데이터 모델 (shopping_cart)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| user_id | bigint | 소유자 사용자 ID |
| dish_id | bigint | 선택한 메뉴 ID (null 가능) |
| setmeal_id | bigint | 선택한 세트메뉴 ID (null 가능) |
| number | int | 수량 |
| dish_flavor | varchar(50) | 맛 옵션 |
3. 핵심 비즈니스 로직
@Service
public class ShoppingCartServiceImpl implements ShoppingCartService {
@Override
public void addToCart(ShoppingCartDTO dto) {
ShoppingCart cartItem = new ShoppingCart();
BeanUtils.copyProperties(dto, cartItem);
cartItem.setUserId(BaseContext.getCurrentId());
// 중복 항목 존재 여부 확인
List<ShoppingCart> existing = shoppingCartMapper.findByCriteria(cartItem);
if (!existing.isEmpty()) {
// 수량 증가
ShoppingCart item = existing.get(0);
item.setNumber(item.getNumber() + 1);
shoppingCartMapper.updateQuantity(item);
} else {
// 신규 등록
enrichCartItem(cartItem, dto);
cartItem.setNumber(1);
cartItem.setCreateTime(LocalDateTime.now());
shoppingCartMapper.insert(cartItem);
}
}
private void enrichCartItem(ShoppingCart cartItem, ShoppingCartDTO dto) {
if (dto.getDishId() != null) {
Dish dish = dishMapper.getById(dto.getDishId());
cartItem.setName(dish.getName());
cartItem.setImage(dish.getImage());
cartItem.setAmount(dish.getPrice());
} else {
Setmeal meal = setmealMapper.getById(dto.getSetmealId());
cartItem.setName(meal.getName());
cartItem.setImage(meal.getImage());
cartItem.setAmount(meal.getPrice());
}
}
}
4. 추가 기능
- 장바구니 조회: 현재 사용자의 모든 항목 반환
- 전체 비우기: 사용자 ID 기준 모든 항목 삭제
- 단일 항목 감소: 수량 1 감소, 0이면 레코드 삭제
@DeleteMapping("/item")
public Result reduceItem(@RequestBody ShoppingCartDTO dto) {
ShoppingCart criteria = new ShoppingCart();
BeanUtils.copyProperties(dto, criteria);
criteria.setUserId(BaseContext.getCurrentId());
List<ShoppingCart> items = shoppingCartMapper.findByCriteria(criteria);
if (items.isEmpty()) return Result.success();
ShoppingCart item = items.get(0);
if (item.getNumber() <= 1) {
shoppingCartMapper.deleteById(item.getId());
} else {
item.setNumber(item.getNumber() - 1);
shoppingCartMapper.updateQuantity(item);
}
return Result.success();
}