Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import com.nowait.domaincorerdb.menu.exception.MenuNotFoundException;
import com.nowait.domaincorerdb.menu.repository.MenuImageRepository;
import com.nowait.domaincorerdb.menu.repository.MenuRepository;
import com.nowait.domaincoreredis.common.cache.CacheInvalidationPublisher;
import com.nowait.domaincoreredis.common.util.RedisKeyUtils;
import com.nowait.infraaws.aws.s3.S3Service;

import lombok.RequiredArgsConstructor;
Expand All @@ -25,6 +27,8 @@ public class MenuImageService {
private final MenuRepository menuRepository;
private final MenuImageRepository menuImageRepository;
private final S3Service s3Service;
private final CacheInvalidationPublisher cacheInvalidationPublisher;

@Transactional
public MenuImageUploadResponse save(Long menuId, MultipartFile file) {
if (file == null || file.isEmpty()) {
Expand Down Expand Up @@ -52,6 +56,7 @@ public MenuImageUploadResponse save(Long menuId, MultipartFile file) {
.build();

menuImageRepository.save(menuImage);
cacheInvalidationPublisher.invalidate(RedisKeyUtils.buildMenuCacheChannel(), String.valueOf(menu.getStoreId()));

// 응답 생성
return MenuImageUploadResponse.fromEntity(menuImage);
Expand All @@ -64,5 +69,7 @@ public void delete(Long id) {

s3Service.delete(menuImage.getFileKey());
menuImageRepository.delete(menuImage);
cacheInvalidationPublisher.invalidate(RedisKeyUtils.buildMenuCacheChannel(),
String.valueOf(menuImage.getMenu().getStoreId()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
import com.nowait.domaincorerdb.user.entity.User;
import com.nowait.domaincorerdb.user.exception.UserNotFoundException;
import com.nowait.domaincorerdb.user.repository.UserRepository;
import com.nowait.domaincoreredis.common.cache.CacheInvalidationPublisher;
import com.nowait.domaincoreredis.common.util.RedisKeyUtils;

import lombok.RequiredArgsConstructor;

Expand All @@ -44,6 +46,7 @@ public class MenuService {
private final MenuRepository menuRepository;
private final MenuImageRepository menuImageRepository;
private final UserRepository userRepository;
private final CacheInvalidationPublisher cacheInvalidationPublisher;

@Transactional
public MenuCreateResponse createMenu(MenuCreateRequest request, MemberDetails memberDetails) {
Expand All @@ -55,6 +58,7 @@ public MenuCreateResponse createMenu(MenuCreateRequest request, MemberDetails me
// 메뉴 생성 로직
Menu toSave = request.toEntity();
Menu saved = menuRepository.save(toSave);
invalidateMenuCache(saved.getStoreId());

return MenuCreateResponse.fromEntity(saved);
}
Expand All @@ -66,16 +70,9 @@ public MenuReadResponse getAllMenusByStoreId(Long storeId, MemberDetails memberD
// 사용자 역할이 SUPER_ADMIN이거나, storeId가 일치하는지 확인
validateViewAuthorization(user, storeId);

List<Menu> menus = menuRepository.findAllByStoreIdAndDeletedFalseOrderBySortOrder(storeId);

List<MenuReadDto> menuReadResponse = menus.stream()
.map(menu -> {
List<MenuImage> images = menuImageRepository.findByMenu(menu);
List<MenuImageUploadResponse> imageDto = images.stream()
.map(MenuImageUploadResponse::fromEntity)
.toList();
return MenuReadDto.fromEntity(menu, imageDto);
})
List<MenuReadDto> menuReadResponse = menuRepository.findAllWithImagesByStoreId(storeId).entrySet().stream()
.map(e -> MenuReadDto.fromEntity(e.getKey(),
e.getValue().stream().map(MenuImageUploadResponse::fromEntity).toList()))
.toList();

return MenuReadResponse.of(menuReadResponse);
Expand Down Expand Up @@ -116,6 +113,7 @@ public MenuReadDto updateMenu(Long menuId, MenuUpdateRequest request, MemberDeta
);

Menu saved = menuRepository.save(menu);
invalidateMenuCache(saved.getStoreId());

List<MenuImage> images = menuImageRepository.findByMenu(saved);
List<MenuImageUploadResponse> imageDto = images.stream()
Expand Down Expand Up @@ -163,6 +161,7 @@ public String updateMenuSortOrder(List<MenuSortUpdateRequest> requests, MemberDe
menus.forEach(m -> m.updateSortOrder(idToSort.get(m.getId())));

menuRepository.saveAll(menus);
invalidateMenuCache(storeId);

return "메뉴 순서가 성공적으로 업데이트되었습니다.";
}
Expand All @@ -175,6 +174,7 @@ public String deleteMenu(Long menuId, MemberDetails memberDetails) {
validateDeleteAuthorization(user, menu.getStoreId());
menu.markAsDeleted();
menuRepository.save(menu);
invalidateMenuCache(menu.getStoreId());

return "Menu with ID " + menuId + " 삭제되었습니다.";
}
Expand All @@ -186,10 +186,16 @@ public Boolean toggleSoldOut(Long menuId) {

menu.toggleSoldOut();
menuRepository.save(menu);
invalidateMenuCache(menu.getStoreId());
return menu.getIsSoldOut();
}


// user-api 의 DistributedCachedMenuDao 무효화 (커밋 후 발행)
private void invalidateMenuCache(Long storeId) {
cacheInvalidationPublisher.invalidate(RedisKeyUtils.buildMenuCacheChannel(), String.valueOf(storeId));
}

private void validateViewAuthorization(User user, Long storeId) {
if (!(Role.SUPER_ADMIN.equals(user.getRole())
|| (Role.MANAGER.equals(user.getRole()) && storeId.equals(user.getStoreId())))) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.nowait.applicationuser.menu.repository;

import java.time.Duration;
import java.util.List;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;

import com.nowait.applicationuser.menu.dto.MenuImageUploadResponse;
import com.nowait.applicationuser.menu.dto.MenuReadDto;
import com.nowait.domaincorerdb.menu.repository.MenuRepository;
import com.nowait.domaincoreredis.common.cache.CacheInvalidationPublisher;
import com.nowait.domaincoreredis.common.cache.DistributedCachedDao;
import com.nowait.domaincoreredis.common.util.RedisKeyUtils;

import io.micrometer.core.instrument.MeterRegistry;

/**
* 주점별 메뉴 목록 캐시 (key: storeId)
* 메뉴/메뉴 이미지 변경은 admin-api 가 커밋 후 storeId 로 무효화 메시지를 발행한다
*/
@Component
public class DistributedCachedMenuDao extends DistributedCachedDao<List<MenuReadDto>> {

private final MenuRepository menuRepository;

// ttl 을 0s 로 주면 매 조회가 DB 로 간다 (캐시 끄기: 비교 측정, 장애 시 차단)
public DistributedCachedMenuDao(MenuRepository menuRepository, @Value("${nowait.cache.menu.ttl:1d}") Duration ttl,
RedisMessageListenerContainer container, CacheInvalidationPublisher publisher, MeterRegistry meterRegistry) {
super(RedisKeyUtils.buildMenuCacheChannel(), ttl, 1_000, container, publisher, meterRegistry);
this.menuRepository = menuRepository;
}

@Override
protected List<MenuReadDto> load(String storeId) {
return menuRepository.findAllWithImagesByStoreId(Long.valueOf(storeId)).entrySet().stream()
.map(e -> MenuReadDto.fromEntity(e.getKey(),
e.getValue().stream().map(MenuImageUploadResponse::fromEntity).toList()))
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
package com.nowait.applicationuser.menu.service;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.nowait.applicationuser.menu.dto.MenuImageUploadResponse;
import com.nowait.applicationuser.menu.dto.MenuReadDto;
import com.nowait.applicationuser.menu.dto.MenuReadResponse;
import com.nowait.domaincorerdb.menu.entity.Menu;
import com.nowait.domaincorerdb.menu.entity.MenuImage;
import com.nowait.applicationuser.menu.repository.DistributedCachedMenuDao;
import com.nowait.domaincorerdb.menu.exception.MenuNotFoundException;
import com.nowait.domaincorerdb.menu.exception.MenuParamEmptyException;
import com.nowait.domaincorerdb.menu.repository.MenuImageRepository;
import com.nowait.domaincorerdb.menu.repository.MenuRepository;
import com.nowait.domaincorerdb.store.entity.Store;
import com.nowait.domaincorerdb.store.exception.StoreNotFoundException;
import com.nowait.domaincorerdb.store.repository.StoreRepository;
Expand All @@ -24,12 +17,11 @@
@RequiredArgsConstructor
public class MenuService {

private final MenuRepository menuRepository;
private final MenuImageRepository menuImageRepository;
private final StoreRepository storeRepository;
private final DistributedCachedMenuDao menuDao;


@Transactional(readOnly = true)
// @Transactional 을 걸지 않는다: 주점 조회로 잡힌 스냅샷 안에서 캐시 미스 로딩이 일어나면
// 무효화 직후에도 커밋 전 메뉴가 TTL 동안 캐싱될 수 있다
public MenuReadResponse getAllMenusByStoreId(String publicCode) {
if (publicCode == null) {
throw new MenuParamEmptyException();
Expand All @@ -38,24 +30,9 @@ public MenuReadResponse getAllMenusByStoreId(String publicCode) {
Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode)
.orElseThrow(StoreNotFoundException::new);

String storeName = store.getName();
Long storeId = store.getStoreId();
List<Menu> menus = menuRepository.findAllByStoreIdAndDeletedFalseOrderBySortOrder(storeId);

List<MenuReadDto> menuReadResponse = menus.stream()
.map(menu -> {
List<MenuImage> images = menuImageRepository.findByMenu(menu);
List<MenuImageUploadResponse> imageDto = images.stream()
.map(MenuImageUploadResponse::fromEntity)
.toList();
return MenuReadDto.fromEntity(menu, imageDto);
})
.toList();

return MenuReadResponse.of(storeName, menuReadResponse);
return MenuReadResponse.of(store.getName(), menuDao.get(String.valueOf(store.getStoreId())));
}

@Transactional(readOnly = true)
public MenuReadDto getMenuById(String publicCode, Long menuId) {
if (publicCode == null || menuId == null) {
throw new MenuParamEmptyException();
Expand All @@ -64,14 +41,9 @@ public MenuReadDto getMenuById(String publicCode, Long menuId) {
Store store = storeRepository.findByPublicCodeAndDeletedFalse(publicCode)
.orElseThrow(StoreNotFoundException::new);

Menu menu = menuRepository.findByStoreIdAndIdAndDeletedFalse(store.getStoreId(), menuId)
return menuDao.get(String.valueOf(store.getStoreId())).stream()
.filter(menu -> menu.getMenuId().equals(menuId))
.findFirst()
.orElseThrow(MenuNotFoundException::new);

List<MenuImage> images = menuImageRepository.findByMenu(menu);
List<MenuImageUploadResponse> imageDto = images.stream()
.map(MenuImageUploadResponse::fromEntity)
.toList();

return MenuReadDto.fromEntity(menu, imageDto);
}
}
2 changes: 2 additions & 0 deletions nowait-domain/domain-core-rdb/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,6 @@ dependencies {

testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testRuntimeOnly 'com.h2database:h2'
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
Expand All @@ -17,7 +18,9 @@
import lombok.experimental.SuperBuilder;

@Entity
@Table(name = "menus")
// 주점별 메뉴 조회(WHERE store_id = ? AND deleted = ? ORDER BY sort_order)가 전체 스캔·filesort 없이 끝나게 한다
@Table(name = "menus", indexes = @Index(name = "idx_menus_store_id_deleted_sort_order",
columnList = "store_id, deleted, sort_order"))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
package com.nowait.domaincorerdb.menu.repository;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import com.nowait.domaincorerdb.menu.entity.Menu;
import com.nowait.domaincorerdb.menu.entity.MenuImage;

@Repository
public interface MenuRepository extends JpaRepository<Menu, Long> {
List<Menu> findAllByStoreIdAndDeletedFalse(Long storeId);
List<Menu> findAllByStoreIdAndDeletedFalseOrderBySortOrder(Long storeId);
Optional<Menu> findByStoreIdAndIdAndDeletedFalse(Long storeId, Long menuId);
Optional<Menu> findByIdAndDeletedFalse(Long menuId);

// 이미지가 아직 없는 메뉴도 보여야 하므로 LEFT JOIN (행: [Menu, MenuImage 또는 null])
@Query(value = """
SELECT m, i
FROM Menu m
LEFT JOIN MenuImage i ON i.menu = m
WHERE m.storeId = :storeId
AND m.deleted = false
ORDER BY m.sortOrder, i.id
""")
List<Object[]> findAllWithImageRowsByStoreId(@Param("storeId") Long storeId);

/**
* 주점의 메뉴와 이미지를 쿼리 한 번으로 가져온다 (메뉴마다 이미지를 따로 조회하던 N+1 대체)
* sortOrder 순서를 유지하고, 이미지가 없는 메뉴는 빈 목록이다
*/
default Map<Menu, List<MenuImage>> findAllWithImagesByStoreId(Long storeId) {
return findAllWithImageRowsByStoreId(storeId).stream()
.collect(Collectors.groupingBy(row -> (Menu)row[0], LinkedHashMap::new,
Collectors.flatMapping(row -> Stream.ofNullable((MenuImage)row[1]), Collectors.toList())));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.nowait.domaincorerdb.menu.repository;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;
import java.util.Map;

import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

import com.nowait.domaincorerdb.menu.entity.Menu;
import com.nowait.domaincorerdb.menu.entity.MenuImage;

@DataJpaTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
class MenuRepositoryTest {

@SpringBootConfiguration
@EntityScan(basePackageClasses = Menu.class)
@EnableJpaRepositories(basePackageClasses = MenuRepository.class)
static class Config {
}

@Autowired
MenuRepository menuRepository;

@Autowired
TestEntityManager em;

@Test
void 메뉴와_이미지를_쿼리_한번에_메뉴별로_묶는다() {
Menu second = menu(1L, 2L, false);
Menu first = menu(1L, 1L, false);
Menu noImage = menu(1L, 3L, false);
menu(1L, 4L, true);
menu(2L, 1L, false);
MenuImage a = image(first);
MenuImage b = image(second);
MenuImage c = image(second);
em.flush();
em.clear();
Statistics stats = em.getEntityManager().getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics();
stats.clear();

Map<Menu, List<MenuImage>> result = menuRepository.findAllWithImagesByStoreId(1L);

assertThat(stats.getPrepareStatementCount()).isEqualTo(1);
// sortOrder 순서, 삭제된 메뉴·다른 주점 제외, 이미지 없는 메뉴는 빈 목록
assertThat(result.keySet()).extracting(Menu::getId)
.containsExactly(first.getId(), second.getId(), noImage.getId());
assertThat(result.values()).extracting(images -> images.stream().map(MenuImage::getId).toList())
.containsExactly(List.of(a.getId()), List.of(b.getId(), c.getId()), List.of());
}

private Menu menu(Long storeId, Long sortOrder, boolean deleted) {
return em.persist(Menu.builder().storeId(storeId).name("menu").isSoldOut(false).sortOrder(sortOrder)
.deleted(deleted).build());
}

private MenuImage image(Menu menu) {
return em.persist(MenuImage.builder().menu(menu).imageUrl("url").fileKey("key").build());
}
}
Loading
Loading