From e422545a3895506b3a4214eabaffaaf8978916ae Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Wed, 28 Oct 2026 01:40:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=EB=A9=94=EB=89=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20N+1=EC=9D=84=20LEFT=20JOIN=20=ED=95=9C=20=EB=B2=88?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B3=A0=20menus=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- nowait-domain/domain-core-rdb/build.gradle | 2 + .../domaincorerdb/menu/entity/Menu.java | 5 +- .../menu/repository/MenuRepository.java | 28 ++++++++ .../menu/repository/MenuRepositoryTest.java | 69 +++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 nowait-domain/domain-core-rdb/src/test/java/com/nowait/domaincorerdb/menu/repository/MenuRepositoryTest.java diff --git a/nowait-domain/domain-core-rdb/build.gradle b/nowait-domain/domain-core-rdb/build.gradle index 48bcad51..d277ef0c 100644 --- a/nowait-domain/domain-core-rdb/build.gradle +++ b/nowait-domain/domain-core-rdb/build.gradle @@ -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' } diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/Menu.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/Menu.java index c1297176..e4f3d6ab 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/Menu.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/entity/Menu.java @@ -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; @@ -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) diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuRepository.java index b785836c..8e07ebae 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuRepository.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/menu/repository/MenuRepository.java @@ -1,12 +1,19 @@ 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 { @@ -14,4 +21,25 @@ public interface MenuRepository extends JpaRepository { List findAllByStoreIdAndDeletedFalseOrderBySortOrder(Long storeId); Optional findByStoreIdAndIdAndDeletedFalse(Long storeId, Long menuId); Optional 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 findAllWithImageRowsByStoreId(@Param("storeId") Long storeId); + + /** + * 주점의 메뉴와 이미지를 쿼리 한 번으로 가져온다 (메뉴마다 이미지를 따로 조회하던 N+1 대체) + * sortOrder 순서를 유지하고, 이미지가 없는 메뉴는 빈 목록이다 + */ + default Map> 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()))); + } } diff --git a/nowait-domain/domain-core-rdb/src/test/java/com/nowait/domaincorerdb/menu/repository/MenuRepositoryTest.java b/nowait-domain/domain-core-rdb/src/test/java/com/nowait/domaincorerdb/menu/repository/MenuRepositoryTest.java new file mode 100644 index 00000000..7f9b2a60 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/test/java/com/nowait/domaincorerdb/menu/repository/MenuRepositoryTest.java @@ -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> 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()); + } +} From 534de1e5742552a66ded064e4393aa06c04339a8 Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Wed, 28 Oct 2026 01:40:12 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=A1=9C=EC=BB=AC=20=EC=BA=90=EC=8B=9C=20+=20Redis?= =?UTF-8?q?=20Pub/Sub=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../menu/service/MenuImageService.java | 7 ++ .../menu/service/MenuService.java | 26 ++++--- .../repository/DistributedCachedMenuDao.java | 42 +++++++++++ .../menu/service/MenuService.java | 44 ++--------- nowait-domain/domain-redis/build.gradle | 3 + .../cache/CacheInvalidationPublisher.java | 55 ++++++++++++++ .../common/cache/DistributedCachedDao.java | 75 +++++++++++++++++++ .../common/util/RedisKeyUtils.java | 7 ++ .../domaincoreredis/config/RedisConfig.java | 8 ++ .../cache/DistributedCachedDaoTest.java | 52 +++++++++++++ 10 files changed, 273 insertions(+), 46 deletions(-) create mode 100644 nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/repository/DistributedCachedMenuDao.java create mode 100644 nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/CacheInvalidationPublisher.java create mode 100644 nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDao.java create mode 100644 nowait-domain/domain-redis/src/test/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDaoTest.java diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuImageService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuImageService.java index b3726fe8..2b23b597 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuImageService.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuImageService.java @@ -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; @@ -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()) { @@ -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); @@ -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())); } } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuService.java index f69dbfa2..e986f9a8 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuService.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/menu/service/MenuService.java @@ -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; @@ -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) { @@ -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); } @@ -66,16 +70,9 @@ public MenuReadResponse getAllMenusByStoreId(Long storeId, MemberDetails memberD // 사용자 역할이 SUPER_ADMIN이거나, storeId가 일치하는지 확인 validateViewAuthorization(user, storeId); - List menus = menuRepository.findAllByStoreIdAndDeletedFalseOrderBySortOrder(storeId); - - List menuReadResponse = menus.stream() - .map(menu -> { - List images = menuImageRepository.findByMenu(menu); - List imageDto = images.stream() - .map(MenuImageUploadResponse::fromEntity) - .toList(); - return MenuReadDto.fromEntity(menu, imageDto); - }) + List menuReadResponse = menuRepository.findAllWithImagesByStoreId(storeId).entrySet().stream() + .map(e -> MenuReadDto.fromEntity(e.getKey(), + e.getValue().stream().map(MenuImageUploadResponse::fromEntity).toList())) .toList(); return MenuReadResponse.of(menuReadResponse); @@ -116,6 +113,7 @@ public MenuReadDto updateMenu(Long menuId, MenuUpdateRequest request, MemberDeta ); Menu saved = menuRepository.save(menu); + invalidateMenuCache(saved.getStoreId()); List images = menuImageRepository.findByMenu(saved); List imageDto = images.stream() @@ -163,6 +161,7 @@ public String updateMenuSortOrder(List requests, MemberDe menus.forEach(m -> m.updateSortOrder(idToSort.get(m.getId()))); menuRepository.saveAll(menus); + invalidateMenuCache(storeId); return "메뉴 순서가 성공적으로 업데이트되었습니다."; } @@ -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 + " 삭제되었습니다."; } @@ -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())))) { diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/repository/DistributedCachedMenuDao.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/repository/DistributedCachedMenuDao.java new file mode 100644 index 00000000..133e9fa2 --- /dev/null +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/repository/DistributedCachedMenuDao.java @@ -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> { + + 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 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(); + } +} diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java index 45aa92e5..3701598d 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/menu/service/MenuService.java @@ -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; @@ -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(); @@ -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 menus = menuRepository.findAllByStoreIdAndDeletedFalseOrderBySortOrder(storeId); - - List menuReadResponse = menus.stream() - .map(menu -> { - List images = menuImageRepository.findByMenu(menu); - List 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(); @@ -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 images = menuImageRepository.findByMenu(menu); - List imageDto = images.stream() - .map(MenuImageUploadResponse::fromEntity) - .toList(); - - return MenuReadDto.fromEntity(menu, imageDto); } } diff --git a/nowait-domain/domain-redis/build.gradle b/nowait-domain/domain-redis/build.gradle index 2beb40e2..3c03aef4 100644 --- a/nowait-domain/domain-redis/build.gradle +++ b/nowait-domain/domain-redis/build.gradle @@ -25,6 +25,8 @@ dependencies { implementation project(':nowait-event') implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.github.ben-manes.caffeine:caffeine' + implementation 'io.micrometer:micrometer-core' // SWAGGER implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' @@ -34,4 +36,5 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/CacheInvalidationPublisher.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/CacheInvalidationPublisher.java new file mode 100644 index 00000000..59855a7c --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/CacheInvalidationPublisher.java @@ -0,0 +1,55 @@ +package com.nowait.domaincoreredis.common.cache; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 캐시 무효화 메시지 발행 (INVALIDATE-{key}, CLEAR) + * 구독 측은 {@link DistributedCachedDao} + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CacheInvalidationPublisher { + + public static final String INVALIDATE_PREFIX = "INVALIDATE-"; + public static final String CLEAR = "CLEAR"; + + private final StringRedisTemplate redisTemplate; + + public void invalidate(String channel, String key) { + publishAfterCommit(channel, INVALIDATE_PREFIX + key); + } + + public void clear(String channel) { + publishAfterCommit(channel, CLEAR); + } + + // 커밋 전에 발행하면 구독 측이 evict 직후 커밋 전 값을 다시 읽어 TTL 동안 캐싱한다 + private void publishAfterCommit(String channel, String message) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + publish(channel, message); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + publish(channel, message); + } + }); + } + + private void publish(String channel, String message) { + try { + redisTemplate.convertAndSend(channel, message); + } catch (Exception e) { + // 발행 실패 시 TTL 만료 전까지 옛 값이 남는다 → redis-cli PUBLISH {channel} CLEAR 로 수동 무효화 + log.error("캐시 무효화 발행 실패 channel={}, message={}", channel, message, e); + } + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDao.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDao.java new file mode 100644 index 00000000..d87287b0 --- /dev/null +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDao.java @@ -0,0 +1,75 @@ +package com.nowait.domaincoreredis.common.cache; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.connection.SubscriptionListener; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; + +/** + * 로컬 캐시(Caffeine) + Redis Pub/Sub 키 무효화 전파 + * - 값은 공유하지 않고 키만 전파한다 (직렬화 불필요, 결과적 일관성) + * - put 은 의도적으로 제공하지 않는다. 갱신은 invalidate 후 get 으로 재로딩 + * - 키는 메시지에 그대로 싣기 위해 String 으로 제한한다 + */ +public abstract class DistributedCachedDao implements MessageListener, SubscriptionListener { + + private final Cache cache; + private final String channel; + private final CacheInvalidationPublisher publisher; + + protected DistributedCachedDao(String channel, Duration ttl, long maximumSize, + RedisMessageListenerContainer container, CacheInvalidationPublisher publisher, MeterRegistry meterRegistry) { + this.cache = Caffeine.newBuilder() + .expireAfterWrite(ttl) + .maximumSize(maximumSize) + .recordStats() + .build(); + this.channel = channel; + this.publisher = publisher; + // 히트율: cache_gets_total{cache=, result=hit|miss} + CaffeineCacheMetrics.monitor(meterRegistry, cache, channel); + container.addMessageListener(this, new ChannelTopic(channel)); + } + + protected abstract V load(String key); + + public V get(String key) { + return cache.get(key, this::load); + } + + public void invalidate(String key) { + cache.invalidate(key); + publisher.invalidate(channel, key); + } + + public void clearCache() { + cache.invalidateAll(); + publisher.clear(channel); + } + + @Override + public void onMessage(Message message, byte[] pattern) { + String body = new String(message.getBody(), StandardCharsets.UTF_8); + if (CacheInvalidationPublisher.CLEAR.equals(body)) { + cache.invalidateAll(); + } else if (body.startsWith(CacheInvalidationPublisher.INVALIDATE_PREFIX)) { + cache.invalidate(body.substring(CacheInvalidationPublisher.INVALIDATE_PREFIX.length())); + } + } + + // 최초 구독 + 연결 끊김 후 재구독 시 호출된다 → 끊긴 동안 놓친 무효화를 전체 비우기로 수습 + @Override + public void onChannelSubscribed(byte[] channel, long count) { + cache.invalidateAll(); + } +} diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java index 25467ea8..adad3e11 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/common/util/RedisKeyUtils.java @@ -23,6 +23,13 @@ public class RedisKeyUtils { private static final String WAITING_PARTYSIZE_KEY_PREFIX = "waiting:party:"; private static final String WAITING_STATUS_KEY_PREFIX = "waiting:status:"; + // Cache invalidation channels + private static final String MENU_CACHE_CHANNEL = "cache:menus"; + + public static String buildMenuCacheChannel() { + return MENU_CACHE_CHANNEL; + } + // Waiting User keys public static String buildUserHoldingKey(String userId) { return "waiting:user:{" + userId + "}:holding"; // ZSET(member=token, score=expireEpochMs) diff --git a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java index b0967e0f..07dd0147 100644 --- a/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java +++ b/nowait-domain/domain-redis/src/main/java/com/nowait/domaincoreredis/config/RedisConfig.java @@ -7,6 +7,7 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -45,4 +46,11 @@ public RedisTemplate redisTemplate() { return redisTemplate; } + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer() { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory()); + return container; + } } diff --git a/nowait-domain/domain-redis/src/test/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDaoTest.java b/nowait-domain/domain-redis/src/test/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDaoTest.java new file mode 100644 index 00000000..c0f78ace --- /dev/null +++ b/nowait-domain/domain-redis/src/test/java/com/nowait/domaincoreredis/common/cache/DistributedCachedDaoTest.java @@ -0,0 +1,52 @@ +package com.nowait.domaincoreredis.common.cache; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.DefaultMessage; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +class DistributedCachedDaoTest { + + static class CountingDao extends DistributedCachedDao { + int loads; + + CountingDao() { + super("cache:test", Duration.ofDays(1), 100, new RedisMessageListenerContainer(), null, + new SimpleMeterRegistry()); + } + + @Override + protected Integer load(String key) { + return ++loads; + } + } + + private static void receive(CountingDao dao, String body) { + dao.onMessage(new DefaultMessage("cache:test".getBytes(StandardCharsets.UTF_8), + body.getBytes(StandardCharsets.UTF_8)), null); + } + + @Test + void 무효화_메시지를_받으면_해당_키만_다시_로딩한다() { + CountingDao dao = new CountingDao(); + assertEquals(1, dao.get("1")); + assertEquals(2, dao.get("2")); + assertEquals(1, dao.get("1")); + + receive(dao, "INVALIDATE-1"); + assertEquals(3, dao.get("1")); + assertEquals(2, dao.get("2")); + + receive(dao, "CLEAR"); + assertEquals(4, dao.get("2")); + + dao.onChannelSubscribed("cache:test".getBytes(StandardCharsets.UTF_8), 1); + assertEquals(5, dao.get("2")); + } +} From 618347c89f8575ea3f62194e0d3f3442d031b5df Mon Sep 17 00:00:00 2001 From: Jihun Kim Date: Wed, 28 Oct 2026 01:40:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A9=94=EB=89=B4=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=84=B1=EB=8A=A5=20=EC=B8=A1=EC=A0=95=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=EC=99=80=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- performance/menu-cache/01-join.md | 110 +++++++++ performance/menu-cache/02-index.md | 85 +++++++ performance/menu-cache/03-cache.md | 148 ++++++++++++ performance/menu-cache/README.md | 118 ++++++++++ performance/menu-cache/digest.sql | 14 ++ performance/menu-cache/menu-load.js | 59 +++++ performance/menu-cache/probe.sh | 17 ++ .../menu-cache/results/2026-10-26-ec2.md | 149 +++++++++++++ .../menu-cache/results/2026-10-26-mac.md | 79 +++++++ performance/menu-cache/run.sh | 210 ++++++++++++++++++ performance/menu-cache/seed.sql | 25 +++ performance/menu-cache/verify.sh | 23 ++ 12 files changed, 1037 insertions(+) create mode 100644 performance/menu-cache/01-join.md create mode 100644 performance/menu-cache/02-index.md create mode 100644 performance/menu-cache/03-cache.md create mode 100644 performance/menu-cache/README.md create mode 100644 performance/menu-cache/digest.sql create mode 100644 performance/menu-cache/menu-load.js create mode 100755 performance/menu-cache/probe.sh create mode 100644 performance/menu-cache/results/2026-10-26-ec2.md create mode 100644 performance/menu-cache/results/2026-10-26-mac.md create mode 100755 performance/menu-cache/run.sh create mode 100644 performance/menu-cache/seed.sql create mode 100644 performance/menu-cache/verify.sh diff --git a/performance/menu-cache/01-join.md b/performance/menu-cache/01-join.md new file mode 100644 index 00000000..e0c3ca73 --- /dev/null +++ b/performance/menu-cache/01-join.md @@ -0,0 +1,110 @@ +# 1단계: 메뉴 목록 조회의 N+1 을 JOIN 한 번으로 + +실행 원본: [EC2](results/2026-10-26-ec2.md), [Mac](results/2026-10-26-mac.md) + +## 1. 질문 + +기존 손님 메뉴 목록 조회(user-api `MenuService.getAllMenusByStoreId`, 커밋 `d20c593`)는 메뉴마다 이미지를 따로 조회했다. admin-api 의 같은 이름 메서드도 같은 모양이다. + +```java +List menus = menuRepository.findAllByStoreIdAndDeletedFalseOrderBySortOrder(storeId); // 1번 +menus.stream().map(menu -> { + List images = menuImageRepository.findByMenu(menu); // 메뉴마다 1번 + ... +``` + +- 요청당 쿼리 = 주점 1 + 메뉴 1 + 이미지 N. 메뉴 20개면 22개 +- JPA 지연 로딩 N+1 이 아니라, 리포지토리를 루프에서 부르는 N+1 이다 +- 연관관계가 `MenuImage → Menu` 단방향 `@ManyToOne` 뿐이라 `Menu` 쪽에서 이미지를 fetch join 할 수 없다 + +질문 +- JOIN 한 번으로 바꿔도 응답이 기존과 같은가? (이미지 없는 메뉴, 이미지 여러 장, 정렬) +- 쿼리가 22개 → 2개가 되면 MySQL 한계가 얼마나 오르는가? + +## 2. 바꾼 것 + +`MenuRepository` 에 JOIN 쿼리와, 결과 행을 메뉴별로 묶는 default 메서드를 넣었다. user(`DistributedCachedMenuDao.load`)와 admin(`MenuService.getAllMenusByStoreId`) 둘 다 이것을 쓴다. + +```java +// 이미지가 아직 없는 메뉴도 보여야 하므로 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 findAllWithImageRowsByStoreId(@Param("storeId") Long storeId); + +default Map> 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()))); +} +``` + +- INNER JOIN 이 아니라 LEFT JOIN: admin 에서 메뉴 생성과 이미지 업로드가 따로라, 이미지가 아직 없는 메뉴가 있을 수 있다. INNER 면 손님 목록에서 사라진다 +- `LinkedHashMap` 으로 `sortOrder` 순서를 지키고, 이미지가 없는 메뉴는 빈 목록이 된다 +- `ORDER BY i.id`: 기존 `findByMenu` 가 돌려주던 이미지 순서(id 순)와 맞춘다 + +``` +기준 주점 SELECT → 메뉴 SELECT → 이미지 SELECT × 20 (+ 트랜잭션 설정 문장 약 5개) +1단계 주점 SELECT → 메뉴 LEFT JOIN 이미지 SELECT 1번 +``` + +이 단계의 user-api(`user-tobe.jar`)는 조회 트랜잭션도 없다(3단계에서 넣은 변경, [3단계 2절](03-cache.md#2-바꾼-것)). 기준 코드는 `@Transactional(readOnly = true)` 라 요청마다 `SET autocommit`, `SET SESSION TRANSACTION READ ONLY / READ WRITE`, `COMMIT` 이 더 나갔다. 이 단계의 차이에는 그것도 섞여 있다. 크기는 4절에서 나눠 본다. + +## 3. 실행 명령 + +```bash +performance/menu-cache/run.sh verify +RATES="100 200 400" performance/menu-cache/run.sh bench nplus1 +RATES="200 500 1000" performance/menu-cache/run.sh bench join # 캐시 끔(ttl 0s), 인덱스 지운 상태 +``` + +## 4. 관찰 결과 + +**응답은 기존과 같다.** 기존 jar 와 바꾼 jar 를 동시에 띄워 200개 주점 응답을 비교했다. user·admin 모두 200/200 같았다(EC2, Mac 둘 다). admin 목록 200회의 메뉴 관련 쿼리는 4,200(menus 200 + menu_images 4,000) → 200 이다. + +**요청당 쿼리 시간** (EC2, `performance_schema` 의 누적 실행 시간 ÷ 요청 수. 기준은 100 req/s, 1단계는 200 req/s) + +| 문장 | 기준 N+1 | 1단계 JOIN | +|---|---:|---:| +| 이미지 SELECT × 20 (`menu_images`) | 2.02ms | - | +| 메뉴 SELECT / 메뉴·이미지 JOIN (`menus`) | 1.88ms | 2.09ms | +| 주점 SELECT (`stores`) | 0.12ms | 0.14ms | +| 그 밖의 문장 (트랜잭션 설정 등) | 0.23ms | 0.03ms (admin 쪽) | +| 합계 | 4.25ms | 2.26ms | + +- 줄어든 약 2ms 의 대부분은 이미지 SELECT 20번(2.02ms)이다. 트랜잭션 설정 문장(요청당 약 5개)은 0.2ms 정도였다 +- `menus` 는 두 단계 모두 요청마다 약 4,000행을 읽었다(메뉴 테이블 전체 스캔). 이것이 2단계의 주제다 + +**같은 부하에서 (EC2)** + +| 목표 req/s | 단계 | 처리 req/s | p50 | p95 | p99 | 못 보낸 요청 | 요청당 쿼리 | MySQL CPU | user-api CPU | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 100 | N+1 | 100 | 9.3ms | 10.0ms | 11.4ms | 0 | 22.01 | 0.47 | 0.48 | +| 200 | N+1 | 187 | 1,492ms | 2,502ms | 3,157ms | 285 | 22.00 | 0.99 | 0.84 | +| 400 | N+1 | 187 | 10,625ms | 10,749ms | 11,704ms | 10,778 | 22.00 | 0.99 | 0.80 | +| 200 | JOIN | 200 | 3.5ms | 4.2ms | 4.7ms | 0 | 2.01 | 0.46 | 0.43 | +| 500 | JOIN | 499 | 74.2ms | 129.5ms | 160.6ms | 0 | 1.84 | 0.99 | 0.54 | +| 1000 | JOIN | 658 | 2,893ms | 3,170ms | 3,323ms | 18,517 | 1.62 | 0.99 | 0.66 | + +- N+1 은 200 req/s 에서 이미 무너졌다. MySQL 이 1코어를 다 쓰고 187 req/s 에서 멈췄다 +- JOIN 은 200 req/s 에서 MySQL 0.46코어로 여유가 있고, 500 에서 1코어가 찼다 +- p95 200ms 안에서 처리한 최대 부하: 100 → 500 +- JOIN 500·1000 은 캐시를 껐는데도 히트율이 16%·38% 로 찍혔다. TTL 0s 라도 같은 주점에 동시에 온 요청은 로딩 한 번을 나눠 받기 때문이다(Caffeine 키 단위 로딩, [3단계 4절](03-cache.md#4-관찰-결과)). 과부하 구간의 JOIN 수치는 그만큼 유리하다 + +Mac 에서도 순서는 같았다. N+1 은 200 req/s 를 넣어도 116 req/s, JOIN 은 200 req/s 를 p95 7.4ms 로 처리했다. + +## 5. 보장 범위와 해결하지 못한 문제 + +- 응답이 같다는 비교는 시드 데이터(이미지 0·1·2장) 기준이다. 삭제된 메뉴 제외는 `MenuRepositoryTest` 로만 확인했다 +- 이미지 순서를 위해 `ORDER BY i.id` 를 넣어 실행 계획에 `Using temporary; Using filesort` 가 남는다. 20행 정렬이라 무시했다 +- 이 단계의 차이에는 조회 트랜잭션 제거가 섞여 있다. 둘을 나눠 재지 않고, 쿼리 시간으로 크기만 나눠 봤다 +- 결과를 `Object[]` 행으로 받아 Java 에서 묶는다. `Menu` 에 `@OneToMany images` 를 두고 fetch join 하는 방법은 두 앱이 함께 쓰는 엔티티를 바꿔야 해서 택하지 않았다 + +## 6. 다음에 볼 것 + +- 남은 요청당 약 2ms 의 대부분이 `menus` 전체 스캔이다 → 2단계 인덱스 diff --git a/performance/menu-cache/02-index.md b/performance/menu-cache/02-index.md new file mode 100644 index 00000000..9e3a4ac3 --- /dev/null +++ b/performance/menu-cache/02-index.md @@ -0,0 +1,85 @@ +# 2단계: menus 에 (store_id, deleted, sort_order) 인덱스 + +실행 원본: [EC2](results/2026-10-26-ec2.md), [Mac](results/2026-10-26-mac.md) + +## 1. 질문 + +1단계 JOIN 쿼리는 요청마다 `menus` 를 약 4,000행 읽었다. `menus.store_id` 에 인덱스가 없었기 때문이다. + +- `Menu.storeId` 는 연관관계가 아니라 `Long` 컬럼이라 FK 가 없고, InnoDB 가 FK 때문에 자동으로 만드는 인덱스도 없다 +- 다른 쪽은 이미 인덱스가 있다: `menu_images.menu_id` 는 FK 인덱스, `stores.public_code` 는 unique 인덱스 + +질문 +- 인덱스로 요청당 읽는 행과 MySQL CPU 가 얼마나 줄고, 한계가 얼마나 오르는가? +- 운영 DB 에 별도 마이그레이션 없이 만들 수 있는가? + +## 2. 바꾼 것 + +```java +// 주점별 메뉴 조회(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")) +public class Menu extends BaseTimeEntity { +``` + +- 같다(`=`)로 거르는 컬럼 둘을 앞에, 정렬 컬럼을 뒤에 뒀다. `deleted` 는 값 종류가 적지만 앞에 같다 조건으로 있어야 `sort_order` 순서로 바로 읽힌다 +- 두 앱 모두 `ddl-auto: update` 라 배포 때 Hibernate 가 만든다. EC2·Mac 모두 앱 시작 후 생성된 것을 확인했다(`idx_menus_store_id_deleted_sort_order(store_id, deleted, sort_order)`) +- 측정에서는 `run.sh` 가 단계에 맞게 인덱스를 지우거나 다시 만든다(앱이 시작할 때 만들기 때문) + +**실행 계획** (Mac, 같은 시드 데이터) + +``` +EXPLAIN SELECT m.*, i.* FROM menus m LEFT JOIN menu_images i ON i.menu_id = m.id + WHERE m.store_id = 7 AND m.deleted = 0 ORDER BY m.sort_order, i.id + +인덱스 없음 m: type ALL, rows 3978, filtered 5.00, Using where; Using temporary; Using filesort + i: type ref, key FK(menu_id), rows 1 +인덱스 있음 m: type ref, key idx_menus_store_id_deleted_sort_order, ref const,const, rows 20, Using temporary; Using filesort + i: type ref, key FK(menu_id), rows 1 +``` + +`Using filesort` 는 두 번째 테이블 컬럼(`i.id`)으로도 정렬해서 남는다. 20행 정렬이다. + +## 3. 실행 명령 + +```bash +RATES="200 1000 2000 3000 4000" performance/menu-cache/run.sh bench join-idx # 캐시 끔(ttl 0s), 인덱스 있음 +``` + +## 4. 관찰 결과 + +**읽는 행이 4,042 → 62 로 줄었다 (EC2, 200 req/s)** + +| | 1단계 JOIN | 2단계 + 인덱스 | +|---|---:|---:| +| 요청당 읽은 행 | 4,042 | 62 | +| 요청당 쿼리 시간: `menus` (JOIN) | 2.09ms | 0.32ms | +| 요청당 쿼리 시간: `stores` | 0.14ms | 0.15ms | +| MySQL CPU | 0.46코어 | 0.11코어 | +| p95 | 4.2ms | 2.4ms | + +**속도별 (EC2)** + +| 목표 req/s | 처리 req/s | p50 | p95 | p99 | 못 보낸 요청 | 요청당 읽은 행 | MySQL CPU | user-api CPU | +|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 200 | 200 | 1.8ms | 2.4ms | 2.8ms | 0 | 62 | 0.11 | 0.41 | +| 1000 | 998 | 1.6ms | 1.8ms | 2.6ms | 0 | 62 | 0.50 | 0.92 | +| 2000 | 1,995 | 13.4ms | 57.6ms | 74.5ms | 0 | 56 | 0.99 | 2.00 | +| 3000 | 2,356 | 799ms | 937ms | 1,067ms | 36,353 | 44 | 0.99 | 2.21 | +| 4000 | 2,333 | 822ms | 976ms | 1,100ms | 97,755 | 44 | 0.99 | 2.25 | + +- p95 200ms 안에서 처리한 최대 부하: 500 → 2,000 (약 4배). 2,000 에서 MySQL 이 1코어를 다 썼고, 3,000 을 넣으면 2,356 에서 멈췄다 +- 요청당 MySQL CPU: 2.3ms (200 req/s) → 0.50ms (1,000 req/s) +- 3,000·4,000 에서 요청당 읽은 행이 44 로 줄어든 것은 동시에 온 같은 주점 요청이 로딩을 나눠 받아서다(히트율 29.7%·29.6%) + +Mac 에서도 한계가 비슷하게 올랐다. 1,000 req/s 를 p95 4.6ms 로 처리했고, 3,000 을 넣으면 2,205 에서 멈췄다. + +## 5. 보장 범위와 해결하지 못한 문제 + +- 인덱스 이름과 컬럼이 엔티티(`Menu`)와 `run.sh` 두 곳에 적혀 있다. 한쪽만 바꾸면 측정이 어긋난다 +- 운영 테이블에 인덱스를 만드는 동안의 잠금은 확인하지 않았다. MySQL 8 의 보조 인덱스 생성은 온라인 DDL 이고, `menus` 는 작은 테이블이다 +- `Using temporary; Using filesort` 가 남는다(2절) + +## 6. 다음에 볼 것 + +- 남은 요청당 쿼리 시간은 `menus` 0.32ms + `stores` 0.15ms 다. 거의 바뀌지 않는 같은 데이터를 요청마다 다시 읽는다 → 3단계 캐시 diff --git a/performance/menu-cache/03-cache.md b/performance/menu-cache/03-cache.md new file mode 100644 index 00000000..4927e3bb --- /dev/null +++ b/performance/menu-cache/03-cache.md @@ -0,0 +1,148 @@ +# 3단계: 로컬 캐시 + Redis Pub/Sub 무효화 + +실행 원본: [EC2](results/2026-10-26-ec2.md), [Mac](results/2026-10-26-mac.md) + +## 1. 질문 + +2단계 뒤에도 손님 메뉴 조회는 요청마다 DB 에 갔다(주점 SELECT + 메뉴·이미지 JOIN). 2,000 req/s 에서 MySQL 이 1코어를 다 썼다. 메뉴는 거의 바뀌지 않는데 같은 데이터를 매번 다시 읽는다. + +캐시를 붙이는 데 걸리는 제약 +- **쓰는 서버와 읽는 서버가 다르다.** 메뉴·품절은 admin-api 가 바꾸고 user-api 가 읽는다. user-api 에 로컬 캐시만 두면 admin-api 의 변경을 모른다 +- **품절은 바로 보여야 한다.** 짧은 TTL 은 히트율을 떨어뜨리고, 긴 TTL 은 품절된 메뉴를 그동안 보여 준다 +- **Redis 는 웨이팅 대기열과 함께 쓴다.** 조회마다 Redis 를 거치는 전역 캐시는 대기열과 자원을 나눠 쓴다 + +채널톡 기술 블로그의 Distributed Cache 도입기([1](https://tech.channel.io/kr/articles/tech-distributed-cache-1-67a392c5), [2](https://docs.channel.io/tech-blog/ko/articles/tech-distributed-cache-2-f77c0868), [3](https://docs.channel.io/tech-blog/ko/articles/tech-cache-rdb-4cc0bbf4))는 같은 문제를 로컬 캐시 + Redis Pub/Sub 키 무효화로 풀었다. 짧은 TTL 의 로컬 캐시와 Redis 전역 캐시(단일 노드 CPU 70%, 롤백)를 거친 뒤다. 그 구조를 그대로 가져왔다. + +질문 +- 캐시를 켜면 DB 쿼리와 MySQL CPU 가 얼마나 줄고, 한계가 얼마나 오르는가? +- 부하 중에 품절을 바꾸면 손님 응답에 바로 반영되는가? +- 무효화 직후 같은 주점 요청이 몰리면 DB 로 새는가? + +## 2. 바꾼 것 + +``` +[조회] 손님 → user-api → 로컬 캐시(Caffeine) 히트 → 응답 + └ 미스 → MySQL 에서 읽어 캐시에 넣음 (같은 키 동시 미스는 한 번만 읽음) + +[변경] 점주 → admin-api → MySQL 커밋 + └ 커밋 후 → Redis PUBLISH cache:menus "INVALIDATE-{storeId}" + └ 구독 중인 모든 user-api 가 그 주점 키만 지움 +``` + +| 파일 | 하는 일 | +|---|---| +| `domain-redis` `DistributedCachedDao` | Caffeine 캐시 + 채널 구독. `get(key)` = `cache.get(key, this::load)`, 메시지를 받으면 키를 지운다 | +| `domain-redis` `CacheInvalidationPublisher` | `INVALIDATE-{key}` / `CLEAR` 를 커밋 후 발행 | +| user-api `DistributedCachedMenuDao` | 키 = storeId, 값 = 주점 메뉴 목록. TTL 1일(`nowait.cache.menu.ttl`, 0s 면 끔), 최대 1,000개 | +| admin-api `MenuService`·`MenuImageService` | 무효화 7곳: 메뉴 생성·수정·순서 변경·삭제·품절 토글, 이미지 업로드·삭제 | + +- 채널톡 구현처럼 Redis 에는 데이터가 아니라 키만 보낸다. 캐시에 값을 넣는(put) 경로는 없고, 지운 뒤 다음 조회가 DB 에서 다시 읽는다 +- Redis 에는 메뉴가 바뀔 때만 메시지가 오간다. 조회 경로는 Redis 를 거치지 않는다 + +```java +public V get(String key) { + return cache.get(key, this::load); +} + +@Override +public void onMessage(Message message, byte[] pattern) { + String body = new String(message.getBody(), StandardCharsets.UTF_8); + if (CacheInvalidationPublisher.CLEAR.equals(body)) { + cache.invalidateAll(); + } else if (body.startsWith(CacheInvalidationPublisher.INVALIDATE_PREFIX)) { + cache.invalidate(body.substring(CacheInvalidationPublisher.INVALIDATE_PREFIX.length())); + } +} +``` + +**정합성을 지키는 장치 세 가지** + +1. **커밋 후에만 발행한다.** 커밋 전에 지우면, 그사이 들어온 조회가 커밋 전 값을 다시 읽어 TTL 동안 캐시한다. `TransactionSynchronization.afterCommit` 에서 발행한다 +2. **재구독하면 전부 비운다.** Pub/Sub 은 연결이 끊긴 동안의 메시지를 다시 주지 않는다. `onChannelSubscribed`(처음 구독할 때와 끊겼다 다시 구독할 때 모두 불린다)에서 `invalidateAll()` +3. **조회 트랜잭션을 없앴다.** user-api 조회에 `@Transactional(readOnly = true)` 가 있으면 주점 조회 때 잡힌 스냅샷(REPEATABLE READ) 안에서 캐시 미스 로딩이 일어난다. 무효화 직후에도 커밋 전 메뉴를 읽어 하루 동안 캐시할 수 있다. 쿼리마다 따로 커밋된 최신 값을 읽게 했다 + +AS-IS(2단계)와 TO-BE(3단계)는 같은 jar 에서 TTL 설정만 다르다(`0s` / `1d`). 두 단계의 차이는 캐시 하나다. + +## 3. 실행 명령 + +```bash +RATES="200 1000 2000 3000 4000" performance/menu-cache/run.sh bench join-idx # AS-IS: 캐시 끔 +RATES="200 1000 2000 4000 6000" performance/menu-cache/run.sh bench cache # TO-BE +performance/menu-cache/run.sh delay # 400 req/s 부하 중 품절 토글 30회 → 손님 응답 반영 확인 (probe.sh) +``` + +## 4. 관찰 결과 + +**DB 부하 (EC2, 1,000 req/s 60초)** + +| | AS-IS | TO-BE | 감소 | +|---|---:|---:|---:| +| 메뉴 쿼리 (`menus`, 메뉴·이미지 JOIN) | 59,808 | 123 | 99.8% | +| 주점 쿼리 (`stores`) | 59,982 | 59,960 | 0% (캐시 대상 아님) | +| RDB 쿼리 합계 | 119,790 | 60,083 | 49.8% (요청당 2 → 1) | +| 요청당 읽은 행 | 62 | 1 | 98% | +| 요청당 쿼리 시간 | 0.45ms | 0.13ms | 71% | +| MySQL CPU | 0.50코어 | 0.15코어 | 70% | +| 히트율 | - | 99.9% | | +| p95 | 1.8ms | 1.1ms | | + +- TO-BE 에 남은 메뉴 쿼리 123건은 초당 1건 품절 토글 때문이다. 점주 쪽 토글 처리가 메뉴를 읽은 약 60건과, 무효화된 키를 다시 채운 약 60건이다 +- RDB 쿼리 합계는 절반만 줄었다. 요청마다 나가는 주점 조회를 캐시하지 않았기 때문이다 + +**속도별 (EC2)** + +| 목표 req/s | AS-IS 처리 | AS-IS p95 | AS-IS MySQL CPU | TO-BE 처리 | TO-BE p95 | TO-BE MySQL CPU | TO-BE user-api CPU | +|---:|---:|---:|---:|---:|---:|---:|---:| +| 200 | 200 | 2.4ms | 0.11 | 200 | 1.6ms | 0.04 | 0.36 | +| 1000 | 998 | 1.8ms | 0.50 | 998 | 1.1ms | 0.15 | 0.69 | +| 2000 | 1,995 | 57.6ms | 0.99 | 1,996 | 1.3ms | 0.31 | 1.35 | +| 3000 | 2,356 | 937ms | 0.99 | - | - | - | - | +| 4000 | 2,333 | 976ms | 0.99 | 3,992 | 2.2ms | 0.59 | 2.62 | +| 6000 | - | - | - | 5,894 | 256ms | 0.88 | 3.89 | + +- p95 200ms 안에서 처리한 최대 부하: 2,000 → 4,000 (2배). 최대 처리량: 2,356 → 5,894 +- 같은 2,000 req/s 에서 p95 57.6ms → 1.3ms +- 요청당 MySQL CPU: 0.50ms → 0.15ms (1,000 req/s) +- AS-IS 는 과부하 구간에서 같은 주점 동시 요청이 로딩을 나눠 받아(2,000 에서 히트율 10.2%, 3,000 이상 약 30%) 실제보다 조금 유리하게 측정됐다. 캐시 효과는 표보다 조금 크다 + +**무효화 직후 몰려도 DB 로 새지 않았다** (EC2, 초당 1건 품절 토글, 60초 동안의 메뉴 쿼리) + +| 목표 req/s | 200 | 1,000 | 2,000 | 4,000 | 6,000 | +|---|---:|---:|---:|---:|---:| +| 메뉴 쿼리 | 123 | 123 | 122 | 122 | 123 | + +- 부하가 30배가 돼도 메뉴 쿼리 수가 그대로다. 토글은 인기 주점에 몰리게 보냈으니 무효화 직후 같은 주점 요청이 실제로 몰렸다. 그래도 무효화 1번에 다시 채우는 쿼리는 1번이다 +- `cache.get(key, loader)` 는 같은 키를 로딩하는 동안 다른 요청을 기다리게 하고 그 결과를 나눠 준다. 별도 락 없이 서버 한 대 안에서 미스가 하나로 모인다 + +**품절 반영** (EC2, 400 req/s 조회 부하 중 품절 토글 30회. 목록 80% · 단건 20%) + +| 토글 응답 p50 / p95 | 토글 응답 후 바뀐 값 확인 p50 / p95 / 최대 | 옛 값을 받은 조회 | +|---|---|---:| +| 17ms / 18ms | 9ms / 10ms / 11ms | 0 / 30회 | + +- 30번 모두 토글 응답 직후 첫 조회부터 바뀐 값이 나왔다. "바뀐 값 확인" 9ms 는 조회 한 번에 걸린 시간이다 +- 무효화는 커밋 후, HTTP 응답 전에 발행된다. 같은 서브넷의 Pub/Sub 전달이 응답보다 먼저 끝났다 + +**재구독** (Mac 로컬 E2E, 2026-10-26, 원본 기록 없음. 콘솔 출력으로만 확인) +- Redis 에서 `CLIENT KILL TYPE pubsub` 으로 구독 연결을 끊자 약 200ms 뒤 다시 구독했고(`PUBSUB NUMSUB cache:menus` = 1), 바로 다음 조회는 캐시 미스였다(전부 비워짐) + +**다음 한계**: 6,000 req/s 에서 user-api CPU 가 3.89/4 코어였다. MySQL 은 0.88코어로, 남은 쿼리는 주점 조회뿐이다. k6 인스턴스 CPU 는 재지 않아 못 보낸 요청 5,180건이 부하 발생기 쪽 한계인지는 가르지 못했다. + +## 5. 보장 범위와 해결하지 못한 문제 + +- 결과적 일관성이다. 커밋과 무효화 메시지 처리 사이(같은 서브넷에서 수 ms)에는 옛 값이 나갈 수 있다. 측정에서는 한 번도 보이지 않았다(4절) +- **무효화 메시지는 유실될 수 있다.** + - 연결이 끊긴 동안 놓친 메시지는 재구독 때 전부 비워 메운다 + - 커밋 때 Redis 가 죽어 발행 자체가 실패하면 옛 값이 TTL(1일)까지 남는다. 에러 로그를 남기고 `redis-cli PUBLISH cache:menus CLEAR` 로 수동 복구한다. 재시도·아웃박스는 넣지 않았다. TTL 을 없애지 않은 이유이기도 하다(채널톡도 같은 이유로 남겼다) +- **미스가 하나로 모이는 것은 서버 한 대 안에서다.** user-api 가 N대면 무효화 한 번에 DB 조회도 N번이다. 분산 락은 넣지 않았다(미스마다 Redis 왕복이 늘고, Redis 를 대기열과 함께 쓰는 문제가 돌아온다) +- **로딩하는 동안 같은 주점 요청은 기다린다.** 옛 값을 주면서 다시 읽는 방식(refresh)은 품절 직후 품절 전 메뉴를 보여 주므로 택하지 않았다. DB 가 느려지면 그 주점 요청도 함께 느려진다 +- `CLEAR` 나 배포 직후에는 모든 키가 비어 있다. 주점 200개면 서버마다 최대 200번 다시 읽는다 +- 주점 조회는 캐시하지 않았다. 캐시를 켠 뒤 MySQL 부하의 거의 전부다 +- user-api 한 대로만 쟀다. 여러 대에 무효화가 퍼지는 것과, 그때의 반영 시간은 재지 않았다 + +## 6. 다음에 볼 것 + +- 주점 조회 캐시: 같은 구조(키 = publicCode)로 요청당 남은 쿼리 1개를 없애기. 주점 정보 변경 지점에 무효화를 넣어야 한다 +- user-api 를 두 대 이상 띄워 무효화가 모든 서버에 퍼지는지, 반영 시간이 어떻게 되는지 +- 발행 실패 대비: 실패한 무효화를 재시도하거나, 커밋과 함께 남겨 두고(아웃박스) 나중에 다시 보내기 +- k6 인스턴스 CPU 를 함께 기록해, 6,000 req/s 의 한계가 앱 서버인지 부하 발생기인지 가르기 diff --git a/performance/menu-cache/README.md b/performance/menu-cache/README.md new file mode 100644 index 00000000..62227a36 --- /dev/null +++ b/performance/menu-cache/README.md @@ -0,0 +1,118 @@ +# 메뉴 목록 조회 성능: N+1 → JOIN → 인덱스 → 로컬 캐시 + Redis Pub/Sub 무효화 + +한 단계씩 바꾸고, 같은 조건에서 직접 잰 것만 적는다. 주제는 3단계(캐시)이고, 1·2단계는 3단계의 AS-IS 를 만든 과정이다. + +| 단계 | 주제 | 상태 | +|---|---|---| +| 1 | [N+1 을 JOIN 한 번으로](01-join.md) | 구현·측정 완료 | +| 2 | [menus 에 (store_id, deleted, sort_order) 인덱스](02-index.md) | 구현·측정 완료 | +| 3 | [로컬 캐시 + Redis Pub/Sub 무효화](03-cache.md) | 구현·측정 완료 | + +실행 원본: [EC2 측정 (2026-10-26)](results/2026-10-26-ec2.md), [Mac 측정 (2026-10-26)](results/2026-10-26-mac.md) + +## 한눈에 (EC2, 2026-10-26) + +| 단계 | 요청당 쿼리 | 요청당 읽은 행 | 요청당 MySQL CPU (잰 속도) | p95 200ms 안에서 처리한 최대 부하 | 그때 p95 | +|---|---:|---:|---|---:|---:| +| 기준: N+1 | 22 | 4,041 | 4.7ms (100 req/s) | 100 | 10.0ms | +| 1: JOIN | 2 | 4,042 | 2.3ms (200 req/s) | 500 | 129.5ms | +| 2: + 인덱스 | 2 | 62 | 0.50ms (1,000 req/s) | 2,000 | 57.6ms | +| 3: + 캐시 | 1 | 1 | 0.15ms (1,000 req/s) | 4,000 | 2.2ms | + +- 요청당 MySQL CPU 는 MySQL 이 포화되지 않은 속도에서 잰 값이다. 낮은 속도일수록 MySQL 의 기본 사용량이 섞여 조금 크게 나온다 +- 3단계에서 남은 쿼리 1개는 주점 조회(`stores`)다. 메뉴 쿼리는 1,000 req/s 60초 동안 59,808 → 123 (99.8% 감소) +- 3단계는 6,000 req/s 에서 user-api CPU 가 3.9/4 코어로 먼저 찼다. 한계가 DB 에서 앱 서버로 옮겨갔다 + +## 단계별 설정 + +같은 user-api jar 에서 설정만 바꾼 단계는 차이가 그 설정 하나다. + +| 단계 | `run.sh bench` 이름 | user-api jar | menus 인덱스 | 캐시 (`nowait.cache.menu.ttl`) | +|---|---|---|---|---| +| 기준 | `nplus1` | `user-asis.jar` (커밋 `d20c593`, 캐시 작업 전) | 없음 | 캐시 코드 없음 | +| 1 | `join` | `user-tobe.jar` | 없음 | `0s` (끔) | +| 2 | `join-idx` | `user-tobe.jar` | 있음 | `0s` (끔) | +| 3 | `cache` | `user-tobe.jar` | 있음 | `1d` (기본값) | + +- admin-api 는 모든 단계에서 `admin.jar`(작업 트리)를 쓴다. 손님 조회 경로에는 들어가지 않고, 품절 토글과 무효화 발행만 한다 +- 기준 단계의 user-api 는 조회에 `@Transactional(readOnly = true)` 가 붙어 있다. 1단계부터는 없다(3단계 2절). 그래서 기준 → 1단계 차이에는 트랜잭션 설정 문장도 섞여 있다(1단계 4절) + +## 공통 조건 + +**인스턴스** (쿠폰 발급 토이 프로젝트의 실험용 EC2 세 대를 빌려 썼다) + +| 이름 | 유형 | 하는 일 | +|---|---|---| +| db | c7g.large (2 vCPU, 4GiB) | MySQL 8.0.36·Redis 7.2 컨테이너 (각 1 CPU / 1GB 제한). 스키마는 따로 `nowait_lab` | +| app | c7g.xlarge (4 vCPU, 8GiB) | user-api(8081)·admin-api(8085). 둘 다 `-Xms256m -Xmx512m -XX:+UseG1GC` | +| k6 | c7g.large (2 vCPU, 4GiB) | 부하 발생 (k6 v2.1.0) | + +서울 리전, 세 대 같은 서브넷, Ubuntu 24.04 (Arm). 앱은 `user-local`·`admin-local` 프로필에 DB·Redis 주소만 바꾸고, SQL 로그를 끄고, Discord 알림 웹훅을 막아 띄운다. + +**데이터** (`seed.sql`): 주점 200 × 메뉴 20 = 메뉴 4,000건, 이미지 4,000장. 메뉴마다 이미지 1장인데, 2번 메뉴는 2장, 20번 메뉴는 0장이다(JOIN 결과를 메뉴별로 묶는지, 이미지 없는 메뉴가 빠지지 않는지 보려고). + +**트래픽** (`menu-load.js`) +- 손님 메뉴 목록 조회 `GET /v1/stores/{publicCode}/menus` 를 일정한 속도로(constant-arrival-rate) +- 주점은 앞 번호일수록 자주 고른다(`floor(200 × u²) + 1`). 인기 주점에 몰리는 모양 +- 동시에 점주 품절 토글 `PATCH /admin/menus/toggle-soldout/{id}` 초당 1건 (각 주점의 1번 메뉴는 반영 지연 측정용으로 빼 둔다) + +**순서**: 단계마다 user-api 를 새로 띄우고 품절을 초기화하고 인덱스를 단계에 맞춘다 → 초당 100건 20초 예열(버림) → 속도마다 60초씩 1회. + +**측정값** + +| 값 | 어디서 | +|---|---| +| 처리 req/s, p50·p95·p99, 실패, 못 보낸 요청 | k6 요약(조회 시나리오만). 처리 req/s 는 끝난 요청 수 ÷ 실행 시간(마무리 대기 포함) | +| 테이블별 쿼리 수·읽은 행·쿼리 시간 | `performance_schema.events_statements_summary_by_digest` (`digest.sql`). 속도마다 비운다. admin 쿼리(토글·로그인, 초당 1건 수준)가 섞인다. 메뉴·이미지 JOIN 쿼리는 `menus` 로 센다 | +| MySQL CPU | MySQL 컨테이너 cgroup `usage_usec` 증가량 ÷ 측정 시간 | +| user-api CPU | user-api JVM 누적 CPU 시간(`ps -o time`) 증가량 ÷ 측정 시간 | +| 히트율 | Micrometer `cache_gets_total{cache="cache:menus"}` 증가량 | + +**p95 200ms 안에서 처리한 최대 부하**: 처리 req/s ≥ 목표의 99%, 못 보낸 요청 0, p95 ≤ 200ms 를 모두 만족한 가장 높은 목표 속도. 잰 속도 간격이 넓어(예: 2,000 다음 3,000) 실제 한계는 이보다 높을 수 있다. + +## 정합성 확인 + +`run.sh verify` 가 기존 코드(user `user-asis.jar`, admin `admin-asis.jar`)와 바꾼 코드를 동시에 띄우고, 200개 주점 전부의 메뉴 목록 응답을 문자열로 비교한다. + +| 비교 | EC2 | Mac | +|---|---|---| +| user 메뉴 목록 | 같음 200 / 다름 0 | 같음 200 / 다름 0 | +| admin 메뉴 목록 | 같음 200 / 다름 0 | 같음 200 / 다름 0 | + +단위 테스트: `MenuRepositoryTest`(H2. JOIN 쿼리 1번, 정렬, 이미지 묶기, 삭제 메뉴·다른 주점 제외, 이미지 없는 메뉴), `DistributedCachedDaoTest`(키 무효화, CLEAR, 재구독 시 비우기). + +## 실행 방법 + +```bash +# jar 네 벌. 기존 코드는 캐시 작업 전 커밋에서 빌드한다 +# application-*.yml 은 git 에 없으므로 작업 트리에서 복사하고, +# admin 은 Mac 에서 빌드하면 storePayment 패키지 대소문자 충돌로 뜨지 않아 storepayment 패키지 수정 3파일도 복사한다 +git worktree add --detach /tmp/asis d20c593 +cp -R nowait-app-user-api/src/main/resources/. /tmp/asis/nowait-app-user-api/src/main/resources/ +cp -R nowait-app-admin-api/src/main/resources/. /tmp/asis/nowait-app-admin-api/src/main/resources/ +(cd /tmp/asis && ./gradlew :nowait-app-user-api:bootJar :nowait-app-admin-api:bootJar) # → user-asis.jar, admin-asis.jar +./gradlew :nowait-app-user-api:bootJar :nowait-app-admin-api:bootJar # → user-tobe.jar, admin.jar + +# EC2 (쿠폰 프로젝트의 build/ec2/hosts.env 를 읽는다) +JARS= performance/menu-cache/run.sh setup +performance/menu-cache/run.sh verify +RATES="100 200 400" performance/menu-cache/run.sh bench nplus1 +RATES="200 500 1000" performance/menu-cache/run.sh bench join +RATES="200 1000 2000 3000 4000" performance/menu-cache/run.sh bench join-idx +RATES="200 1000 2000 4000 6000" performance/menu-cache/run.sh bench cache +performance/menu-cache/run.sh delay +performance/menu-cache/run.sh cleanup # 앱 종료, nowait_lab 삭제 + +# Mac 한 대 (MySQL·Redis 는 도커, 각 1 CPU / 1GB) +LOCAL=1 JARS= performance/menu-cache/run.sh setup +LOCAL=1 RATES="200 500 1000" performance/menu-cache/run.sh bench nplus1 join join-idx cache +``` + +결과는 `performance/menu-cache/out/<단계>/` 에 쌓이고(git 에 올리지 않는다) `report.md` 로 요약된다. + +## 공통 한계 + +- 운영 트래픽이 아니라 실험 환경에서 잰 값이다. 데이터 규모(메뉴 4,000건, 주점당 20개)가 바뀌면 단계별 효과 크기도 바뀐다 +- 속도마다 1회만 쟀다(쿠폰 측정은 2회) +- k6 인스턴스 CPU 는 기록하지 않았다. 가장 높은 속도(6,000 req/s)의 "못 보낸 요청" 이 부하 발생기 한계인지 가르지 못한다 +- user-api 는 한 대로 쟀다. 여러 대에 무효화가 퍼지는 것은 재지 않았다 diff --git a/performance/menu-cache/digest.sql b/performance/menu-cache/digest.sql new file mode 100644 index 00000000..c603b4bc --- /dev/null +++ b/performance/menu-cache/digest.sql @@ -0,0 +1,14 @@ +-- 측정 구간 동안 테이블별 쿼리 수, 읽은 행 수, 누적 실행 시간(ms). admin(로그인·토글) 쿼리도 같은 스키마라 섞이지만 초당 1건 수준 +-- 메뉴·이미지 JOIN 쿼리는 FROM `menus` 로 시작하므로 menus 로 센다 +SELECT CASE + WHEN DIGEST_TEXT LIKE '%FROM `menu_images`%' THEN 'menu_images' + WHEN DIGEST_TEXT LIKE '%FROM `menus`%' THEN 'menus' + WHEN DIGEST_TEXT LIKE '%FROM `stores`%' THEN 'stores' + ELSE 'other' END AS tbl, + SUM(COUNT_STAR) AS calls, + SUM(SUM_ROWS_EXAMINED) AS rows_examined, + ROUND(SUM(SUM_TIMER_WAIT) / 1e9, 1) AS total_ms +FROM performance_schema.events_statements_summary_by_digest +WHERE SCHEMA_NAME = 'nowait_lab' +GROUP BY tbl +ORDER BY tbl; diff --git a/performance/menu-cache/menu-load.js b/performance/menu-cache/menu-load.js new file mode 100644 index 00000000..aabf31f9 --- /dev/null +++ b/performance/menu-cache/menu-load.js @@ -0,0 +1,59 @@ +// 손님 메뉴 조회(목록 LIST_RATIO, 나머지 단건) + 점주 품절 토글. 인기 주점에 몰리게 앞쪽 주점일수록 자주 고른다(u^2 분포) +import http from 'k6/http'; +import { check } from 'k6'; + +const USER = __ENV.USER_URL; +const ADMIN = __ENV.ADMIN_URL; +const STORES = Number(__ENV.STORES || 50); +const LIST_RATIO = Number(__ENV.LIST_RATIO || 1); +const MENUS_PER_STORE = 20; +const DURATION = __ENV.DURATION || '60s'; + +export const options = { + discardResponseBodies: true, + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)'], + scenarios: { + reads: { + executor: 'constant-arrival-rate', exec: 'read', rate: Number(__ENV.RATE), timeUnit: '1s', + duration: DURATION, preAllocatedVUs: 200, maxVUs: 2000, + }, + writes: { + executor: 'constant-arrival-rate', exec: 'write', rate: Number(__ENV.WRITE_RATE || 1), timeUnit: '1s', + duration: DURATION, preAllocatedVUs: 2, maxVUs: 10, + }, + }, + // 손님 조회만 따로 집계되게 하는 형식상 기준 (판정용 아님) + thresholds: { + 'http_req_duration{scenario:reads}': ['max>=0'], + 'http_reqs{scenario:reads}': ['count>=0'], + 'http_req_failed{scenario:reads}': ['rate<=1'], + }, +}; + +export function setup() { + const res = http.post(`${ADMIN}/admin/users/login`, + JSON.stringify({ email: 'load@test.com', password: 'loadPass1!' }), + { headers: { 'Content-Type': 'application/json' }, responseType: 'text' }); + return { token: res.json('response.body.accessToken') }; +} + +const pickStore = () => Math.floor(STORES * Math.random() ** 2) + 1; +const code = (store) => 'LOAD' + String(store).padStart(8, '0'); +const menuOf = (store, n) => (store - 1) * MENUS_PER_STORE + n; + +export function read() { + const store = pickStore(); + const res = Math.random() < LIST_RATIO + ? http.get(`${USER}/v1/stores/${code(store)}/menus`, { tags: { name: 'list' } }) + : http.get(`${USER}/v1/stores/${code(store)}/menus/${menuOf(store, Math.floor(Math.random() * MENUS_PER_STORE) + 1)}`, + { tags: { name: 'detail' } }); + check(res, { 'read 200': (r) => r.status === 200 }); +} + +// 각 주점의 1번 메뉴는 반영 지연 측정(probe.sh)용으로 남겨 둔다 +export function write(data) { + const menuId = menuOf(pickStore(), Math.floor(Math.random() * (MENUS_PER_STORE - 1)) + 2); + const res = http.patch(`${ADMIN}/admin/menus/toggle-soldout/${menuId}`, null, + { headers: { Authorization: `Bearer ${data.token}` }, tags: { name: 'toggle' } }); + check(res, { 'toggle 200': (r) => r.status === 200 }); +} diff --git a/performance/menu-cache/probe.sh b/performance/menu-cache/probe.sh new file mode 100755 index 00000000..79da6359 --- /dev/null +++ b/performance/menu-cache/probe.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# 품절 토글 → 손님 조회 반영까지. app 인스턴스에서 실행. 한 줄에: 토글 응답 ms, 토글 응답 후 반영 확인까지 ms, 그 사이 옛 값을 받은 조회 수 +U=localhost:8081/v1/stores/LOAD00000001/menus/1 +T=$(curl -s -X POST localhost:8085/admin/users/login -H 'Content-Type: application/json' \ + -d '{"email":"load@test.com","password":"loadPass1!"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["response"]["body"]["accessToken"])') +sold() { curl -s $U | grep -o '"isSoldOut":[a-z]*'; } +for i in $(seq ${N:-30}); do + b=$(sold) + t0=$(date +%s%3N) + curl -s -o /dev/null -X PATCH localhost:8085/admin/menus/toggle-soldout/1 -H "Authorization: Bearer $T" + t1=$(date +%s%3N) + n=0 + while [ "$(sold)" = "$b" ] && [ $n -lt 1000 ]; do n=$((n + 1)); done + t2=$(date +%s%3N) + echo "$((t1 - t0)) $((t2 - t1)) $n" + sleep 1 +done diff --git a/performance/menu-cache/results/2026-10-26-ec2.md b/performance/menu-cache/results/2026-10-26-ec2.md new file mode 100644 index 00000000..d7b21f78 --- /dev/null +++ b/performance/menu-cache/results/2026-10-26-ec2.md @@ -0,0 +1,149 @@ +# EC2 측정 원본 (2026-10-26 22:26 ~ 22:48) + +실행 조건 +- 인스턴스: db c7g.large, app c7g.xlarge, k6 c7g.large (서울, 같은 서브넷, Ubuntu 24.04 Arm). 쿠폰 프로젝트 실험용 인스턴스를 중지했다 다시 켠 뒤 썼다 +- MySQL 8.0.36 (1 CPU / 1GB), Redis 7.2 (1 CPU / 1GB). 쿠폰 프로젝트 컨테이너를 그대로 쓰고 스키마만 `nowait_lab` 으로 나눴다(측정 후 삭제) +- user-api·admin-api: `-Xms256m -Xmx512m -XX:+UseG1GC`, `user-local`·`admin-local` 프로필, SQL 로그 끔, Discord 웹훅 막음 +- 데이터: 주점 200, 메뉴 4,000, 이미지 4,000, 관리자 1 (`seed.sql`, 주점 수 `STORES=200`) +- 트래픽: 손님 메뉴 목록 조회만(`LIST_RATIO=1`) + 품절 토글 초당 1건(`WRITE_RATE=1`). 단계마다 초당 100건 20초 예열을 버리고, 속도마다 60초 1회 +- 표의 열: 처리 req/s·지연·실패·못 보낸 요청은 k6 조회 시나리오만. stores·menus·menu_images 는 그 테이블 쿼리 호출 수(`performance_schema` digest, admin 쿼리 포함). MySQL CPU 는 컨테이너 cgroup, user-api CPU 는 JVM 누적 CPU 시간에서 계산한 코어 수 + +## 정합성 (verify) + +`performance/menu-cache/run.sh verify` (기존 user :8081·admin :8086, 바꾼 user :8082·admin :8085) + +``` +user 메뉴 목록: 같음 200 / 다름 0 +admin 메뉴 목록: 같음 200 / 다름 0 +admin :8086 메뉴 목록 200 회 — menu_images 4000건 menus 200건 other 2208건 +admin :8085 메뉴 목록 200 회 — menus 200건 other 2208건 +``` + +`other` 2,208건(1회당 약 11개)은 인증 필터의 회원 조회와 트랜잭션 설정 문장이다. 두 버전이 같다. + +## 기준: N+1 (`user-asis.jar`, 인덱스 없음) + +`RATES="100 200 400" performance/menu-cache/run.sh bench nplus1` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 100 | 100 | 9.3 | 10.0 | 11.4 | 0.00% | 0 | - | 6001 | 6062 | 120020 | 22.01 | 4041 | 0.47 | 0.48 | +| 200 | 187 | 1492.1 | 2501.9 | 3157.2 | 0.00% | 285 | - | 11716 | 11777 | 234248 | 22.00 | 4041 | 0.99 | 0.84 | +| 400 | 187 | 10625.1 | 10749.1 | 11704.1 | 0.00% | 10778 | - | 13222 | 13283 | 264384 | 22.00 | 4041 | 0.99 | 0.80 | + +## 1단계: JOIN (`user-tobe.jar`, 캐시 ttl 0s, 인덱스 없음) + +`RATES="200 500 1000" performance/menu-cache/run.sh bench join` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 3.5 | 4.2 | 4.7 | 0.00% | 0 | 0.0% | 12001 | 12062 | 0 | 2.01 | 4042 | 0.46 | 0.43 | +| 500 | 499 | 74.2 | 129.5 | 160.6 | 0.00% | 0 | 16.0% | 29989 | 25248 | 0 | 1.84 | 3395 | 0.99 | 0.54 | +| 1000 | 658 | 2893.1 | 3169.5 | 3322.6 | 0.00% | 18517 | 38.4% | 41466 | 25627 | 0 | 1.62 | 2492 | 0.99 | 0.66 | + +## 2단계: JOIN + 인덱스 (`user-tobe.jar`, 캐시 ttl 0s, 인덱스 있음) + +`RATES="200 1000 2000 3000 4000" performance/menu-cache/run.sh bench join-idx` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 1.8 | 2.4 | 2.8 | 0.00% | 0 | 0.0% | 11999 | 12062 | 0 | 2.00 | 62 | 0.11 | 0.41 | +| 1000 | 998 | 1.6 | 1.8 | 2.6 | 0.00% | 0 | 0.4% | 59982 | 59808 | 0 | 2.00 | 62 | 0.50 | 0.92 | +| 2000 | 1995 | 13.4 | 57.6 | 74.5 | 0.00% | 0 | 10.2% | 119915 | 107749 | 0 | 1.90 | 56 | 0.99 | 2.00 | +| 3000 | 2356 | 799.0 | 937.0 | 1067.2 | 0.00% | 36353 | 29.7% | 143616 | 101090 | 0 | 1.70 | 44 | 0.99 | 2.21 | +| 4000 | 2333 | 821.6 | 975.9 | 1100.3 | 0.00% | 97755 | 29.6% | 142196 | 100222 | 0 | 1.70 | 44 | 0.99 | 2.25 | + +## 3단계: 캐시 (`user-tobe.jar`, 캐시 ttl 1d, 인덱스 있음) + +`RATES="200 1000 2000 4000 6000" performance/menu-cache/run.sh bench cache` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 1.1 | 1.6 | 1.9 | 0.00% | 0 | 99.5% | 12000 | 123 | 0 | 1.01 | 1 | 0.04 | 0.36 | +| 1000 | 998 | 1.0 | 1.1 | 1.4 | 0.00% | 0 | 99.9% | 59960 | 123 | 0 | 1.00 | 1 | 0.15 | 0.69 | +| 2000 | 1996 | 1.1 | 1.3 | 1.8 | 0.00% | 0 | 99.9% | 118113 | 122 | 0 | 0.99 | 1 | 0.31 | 1.35 | +| 4000 | 3992 | 1.2 | 2.2 | 4.0 | 0.00% | 0 | 100.0% | 239756 | 122 | 0 | 1.00 | 1 | 0.59 | 2.62 | +| 6000 | 5894 | 69.3 | 255.6 | 283.5 | 0.00% | 5180 | 100.0% | 354741 | 123 | 0 | 1.00 | 1 | 0.88 | 3.89 | + +## 테이블별 쿼리 (호출 수 / 읽은 행 / 누적 실행 시간 ms) + +`digest.sql`, 속도마다 비우고 60초 측정 뒤 읽음. 메뉴·이미지 JOIN 은 `menus` 로 센다. "그 밖" 은 트랜잭션 설정 문장(`SET autocommit`, `SET SESSION TRANSACTION ...`, `COMMIT`)과 admin 쪽 쿼리다. + +### 기준: N+1 + +| 목표 req/s | stores | menus | menu_images | 그 밖 (호출 / ms) | +|---:|---|---|---|---| +| 100 | 6,001 / 6,001 / 727 | 6,062 / 24,124,081 / 11,283 | 120,020 / 120,020 / 12,129 | 30,623 / 1,399 | +| 200 | 11,716 / 11,716 / 2,787 | 11,777 / 47,098,381 / 126,403 | 234,248 / 234,285 / 64,541 | 59,194 / 4,272 | +| 400 | 13,222 / 13,223 / 3,193 | 13,283 / 53,156,521 / 145,156 | 264,384 / 264,419 / 72,100 | 66,733 / 5,977 | + +### 1단계: JOIN + +| 목표 req/s | stores | menus | menu_images | 그 밖 (호출 / ms) | +|---:|---|---|---|---| +| 200 | 12,001 / 12,001 / 1,640 | 12,062 / 48,496,102 / 25,083 | - | 618 / 417 | +| 500 | 29,989 / 29,999 / 8,969 | 25,248 / 101,809,015 / 381,505 | - | 618 / 464 | +| 1,000 | 41,466 / 41,476 / 13,576 | 25,627 / 103,336,512 / 377,917 | - | 608 / 530 | + +### 2단계: JOIN + 인덱스 + +| 목표 req/s | stores | menus | menu_images | 그 밖 (호출 / ms) | +|---:|---|---|---|---| +| 200 | 11,999 / 12,001 / 1,758 | 12,062 / 732,061 / 3,810 | - | 618 / 398 | +| 1,000 | 59,982 / 59,994 / 7,801 | 59,808 / 3,646,458 / 18,708 | - | 618 / 424 | +| 2,000 | 119,915 / 119,976 / 23,663 | 107,749 / 6,570,187 / 69,006 | - | 608 / 481 | +| 3,000 | 143,616 / 143,634 / 31,632 | 101,090 / 6,163,867 / 70,295 | - | 618 / 1,647 | +| 4,000 | 142,196 / 142,235 / 29,993 | 100,222 / 6,110,491 / 67,125 | - | 609 / 459 | + +### 3단계: 캐시 + +| 목표 req/s | stores | menus | menu_images | 그 밖 (호출 / ms) | +|---:|---|---|---|---| +| 200 | 12,000 / 12,001 / 1,748 | 123 / 3,843 / 31 | - | 618 / 400 | +| 1,000 | 59,960 / 59,988 / 7,204 | 123 / 3,843 / 31 | - | 618 / 402 | +| 2,000 | 118,113 / 119,225 / 14,857 | 122 / 3,782 / 31 | - | 618 / 409 | +| 4,000 | 239,756 / 239,934 / 29,361 | 122 / 3,782 / 32 | - | 619 / 422 | +| 6,000 | 354,741 / 354,799 / 44,472 | 123 / 3,843 / 33 | - | 618 / 421 | + +- 기준의 "그 밖" 호출(요청당 약 5개)은 조회 `@Transactional(readOnly = true)` 의 설정 문장이다. 1단계부터는 admin 쪽 것만 남는다(60초에 약 618개) +- 3단계 `menus` 123건 ≈ 품절 토글 60번 × (admin 의 메뉴 조회 1 + 무효화 뒤 다시 채우기 1) + +## 품절 반영 지연 (delay) + +`performance/menu-cache/run.sh delay`: 캐시 단계 user-api 에 400 req/s 조회(목록 80%, 단건 20%)를 120초 걸고, 15초 뒤 app 인스턴스에서 `probe.sh` 를 돌렸다. 1번 주점 1번 메뉴(k6 토글 대상에서 뺀 메뉴)를 토글하고, 단건 조회를 반복해 바뀐 값이 나올 때까지 잰다. + +``` +토글 응답 ms 토글 응답 후 바뀐 값 확인까지 ms 그사이 옛 값을 받은 조회 수 +17 9 0 +18 9 0 +16 10 0 +16 9 0 +17 8 0 +16 9 0 +17 9 0 +16 9 0 +17 8 0 +16 8 0 +17 11 0 +16 9 0 +17 8 0 +17 8 0 +16 8 0 +16 8 0 +17 7 0 +17 9 0 +17 8 0 +17 8 0 +16 9 0 +16 9 0 +16 9 0 +17 8 0 +15 9 0 +16 9 0 +16 9 0 +17 7 0 +19 8 0 +16 8 0 +``` + +토글 30회 | 토글 응답 p50 17ms p95 18ms | 응답 후 반영 확인 p50 9ms p95 10ms 최대 11ms | 옛 값을 받은 조회 0회 diff --git a/performance/menu-cache/results/2026-10-26-mac.md b/performance/menu-cache/results/2026-10-26-mac.md new file mode 100644 index 00000000..9a6aeab9 --- /dev/null +++ b/performance/menu-cache/results/2026-10-26-mac.md @@ -0,0 +1,79 @@ +# Mac 측정 원본 (2026-10-26 21:51 ~ 22:13) + +EC2 가 꺼져 있어 먼저 잰 측정이다. 앱·k6·도커가 한 기계(Apple Silicon 8코어, 16GB)의 CPU 를 나눠 쓰므로 절대값보다 단계 간 차이를 본다. + +실행 조건 +- `LOCAL=1 performance/menu-cache/run.sh ...`. MySQL 8.0.36·Redis 7.2-alpine 은 도커(Docker Desktop, 각 `--cpus=1 -m 1g`), user-api·admin-api(JDK 17)와 k6 는 Mac 에서 +- 데이터·트래픽·예열·측정 시간은 EC2 와 같다(주점 200, 목록 조회만, 품절 토글 초당 1건, 예열 100 req/s 20초, 속도마다 60초 1회) +- 2단계·3단계는 200·500·1000 을 먼저 재고, 한계에 닿지 않아 2000·3000 을 따로 다시 쟀다(사이에 앱을 다시 띄웠다) + +## 정합성 (verify) + +``` +user 메뉴 목록: 같음 200 / 다름 0 +admin 메뉴 목록: 같음 200 / 다름 0 +admin :8086 메뉴 목록 200 회 — menus 200건 menu_images 4000건 other 2208건 +admin :8085 메뉴 목록 200 회 — menus 200건 other 2208건 +``` + +## 기준: N+1 + +`LOCAL=1 RATES="200 500 1000" performance/menu-cache/run.sh bench nplus1` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 116 | 11314.5 | 15897.1 | 17061.7 | 0.00% | 3397 | - | 8603 | 8664 | 171732 | 21.96 | 4041 | 0.94 | 0.64 | +| 500 | 104 | 14341.2 | 29772.4 | 30890.2 | 0.00% | 22284 | - | 7720 | 7781 | 154046 | 21.96 | 4041 | 0.94 | 0.51 | +| 1000 | 146 | 12559.0 | 16610.0 | 17431.5 | 0.00% | 49334 | - | 10666 | 10728 | 212876 | 21.96 | 4041 | 0.98 | 0.52 | + +## 1단계: JOIN + +`LOCAL=1 RATES="200 500 1000" performance/menu-cache/run.sh bench join` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 4.0 | 7.4 | 66.0 | 0.00% | 0 | 0.1% | 12000 | 12047 | 0 | 2.00 | 4037 | 0.41 | 0.47 | +| 500 | 456 | 1101.0 | 2856.8 | 3370.8 | 0.00% | 1269 | 25.8% | 28690 | 21364 | 0 | 1.74 | 2997 | 0.96 | 0.46 | +| 1000 | 430 | 4518.1 | 5281.3 | 5756.3 | 0.00% | 32329 | 28.1% | 27620 | 19953 | 0 | 1.72 | 2907 | 0.99 | 0.44 | + +## 2단계: JOIN + 인덱스 + +`LOCAL=1 RATES="200 500 1000" ... bench join-idx`, 이어서 `LOCAL=1 RATES="2000 3000" ... bench join-idx` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 200 | 3.3 | 6.6 | 13.8 | 0.00% | 0 | 0.0% | 11998 | 12057 | 0 | 2.00 | 62 | 0.21 | 0.47 | +| 500 | 499 | 2.0 | 3.9 | 9.2 | 0.00% | 0 | 0.1% | 29995 | 30031 | 0 | 2.00 | 62 | 0.29 | 0.50 | +| 1000 | 998 | 1.3 | 4.6 | 42.8 | 0.00% | 0 | 0.4% | 59996 | 59793 | 0 | 2.00 | 62 | 0.39 | 0.62 | +| 2000 | 1982 | 7.8 | 151.8 | 312.6 | 0.00% | 816 | 7.4% | 119097 | 110409 | 0 | 1.93 | 57 | 0.92 | 2.10 | +| 3000 | 2205 | 815.7 | 1176.8 | 1469.5 | 0.00% | 45659 | 20.4% | 134241 | 106967 | 0 | 1.80 | 50 | 0.98 | 1.74 | + +## 3단계: 캐시 + +`LOCAL=1 RATES="200 500 1000" ... bench cache`, 이어서 `LOCAL=1 RATES="2000 3000" ... bench cache` + +| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 200 | 199 | 2.3 | 5.2 | 12.5 | 0.00% | 0 | 99.5% | 11993 | 124 | 0 | 1.01 | 1 | 0.11 | 0.42 | +| 500 | 499 | 1.4 | 4.1 | 13.9 | 0.00% | 0 | 99.8% | 29973 | 122 | 0 | 1.00 | 1 | 0.19 | 0.37 | +| 1000 | 997 | 1.0 | 2.2 | 8.3 | 0.00% | 0 | 99.9% | 59961 | 122 | 0 | 1.00 | 1 | 0.24 | 0.45 | +| 2000 | 1996 | 0.6 | 2.5 | 11.7 | 0.00% | 0 | 99.9% | 119959 | 125 | 0 | 1.00 | 1 | 0.26 | 0.91 | +| 3000 | 2992 | 0.5 | 3.0 | 17.8 | 0.00% | 162 | 100.0% | 179802 | 121 | 0 | 1.00 | 1 | 0.35 | 0.89 | + +## EXPLAIN (메뉴·이미지 JOIN, 주점 7) + +``` +인덱스 있음 +id select_type table type possible_keys key key_len ref rows filtered Extra +1 SIMPLE m ref idx_menus_store_id_deleted_sort_order idx_menus_store_id_deleted_sort_order 9 const,const 20 100.00 Using temporary; Using filesort +1 SIMPLE i ref FKqpgn1rya57y74a1ncxnc476mj FKqpgn1rya57y74a1ncxnc476mj 8 nowait_lab.m.id 1 100.00 NULL + +인덱스 없음 +1 SIMPLE m ALL NULL NULL NULL NULL 3978 5.00 Using where; Using temporary; Using filesort +1 SIMPLE i ref FKqpgn1rya57y74a1ncxnc476mj FKqpgn1rya57y74a1ncxnc476mj 8 nowait_lab.m.id 1 100.00 NULL +``` + +## EC2 와 다른 점 + +- 순서와 한계의 자리는 EC2 와 같았다(N+1 < JOIN < 인덱스 < 캐시). 숫자는 EC2 가 더 좋았다: 같은 단계·같은 속도에서 p95 가 더 낮고, N+1 의 처리 상한이 116~146 → 187 +- Mac 은 앱·k6 가 MySQL 과 같은 기계에서 돌아, p99 가 들쭉날쭉했다(예: 인덱스 1000 req/s 의 p99 42.8ms) diff --git a/performance/menu-cache/run.sh b/performance/menu-cache/run.sh new file mode 100755 index 00000000..a27c0a54 --- /dev/null +++ b/performance/menu-cache/run.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# 메뉴 목록 조회 단계별 비교: 기존 N+1 → JOIN → JOIN+인덱스 → JOIN+인덱스+캐시. 결과는 performance/menu-cache/out/<이름>/ +# 기본은 쿠폰 프로젝트의 EC2 3대(db·app·k6, ~/Desktop/CouponToyProject/performance/ec2/README.md)를 Mac 에서 SSH 로만 다룬다 +# LOCAL=1 이면 Mac 한 대: MySQL·Redis 는 도커(EC2 db 처럼 각 1 CPU·1GB), 앱·k6 는 Mac. CPU 를 나눠 쓰니 절대값보다 단계 간 차이를 본다 +# JARS= run.sh setup jar·스크립트 올리기, 스키마·목업 데이터 만들기 +# run.sh verify 기존 코드와 JOIN 코드의 메뉴 목록 응답 비교(user·admin, 전 주점) + admin 목록 1회당 쿼리 수 +# run.sh bench <단계>... nplus1 | join | join-idx | cache. RATES 단계마다 측정 (손님 목록 조회 + 초당 WRITE_RATE 건 품절 토글) +# run.sh delay 부하 중 품절 토글 → 손님 조회 반영까지 걸린 시간 (cache) +# run.sh cleanup 앱 종료, nowait_lab 스키마 삭제 (LOCAL 은 컨테이너까지) +# STORES=200 RATES="200 500 1000" DURATION=60s WRITE_RATE=1 LIST_RATIO=1 DELAY_RATE=400 +set -u +cd "$(dirname "$0")/../.." +D=performance/menu-cache +STORES=${STORES:-200} +IDX=idx_menus_store_id_deleted_sort_order # Menu 엔티티의 @Index 와 같게 + +if [ "${LOCAL:-}" ]; then + WORK=$D/out/local; mkdir -p $WORK/logs + DB_PUB=local APP_PUB=local K6_PUB=local DB_PRIV=127.0.0.1 APP_PRIV=127.0.0.1 DB_PORT=13306 REDIS_PORT=16379 DB_PASSWORD=bench + MYSQL="docker exec -i -e MYSQL_PWD=$DB_PASSWORD nowait-bench-mysql mysql -uroot -h127.0.0.1" + on() { shift; (cd $WORK && bash -c "$*"); } + put() { shift; local f; for f in "${@:1:$#-1}"; do ln -sf "$(realpath "$f")" $WORK/; done; } + get() { cp $WORK/$2 $3; } + q() { $MYSQL -N nowait_lab -e "$1"; } + sqlf() { $MYSQL $1; } # 표준 입력의 SQL 실행 + mysql_usec() { docker exec nowait-bench-mysql cat /sys/fs/cgroup/cpu.stat | awk '$1=="usage_usec"{print $2}'; } +else + source ${HOSTS:-$HOME/Desktop/CouponToyProject/build/ec2/hosts.env} + DB_PORT=3306 REDIS_PORT=6379 + SSH_OPTS="-i $KEY -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR -o ControlMaster=auto -o ControlPath=$HOME/.ssh/cm-%r@%h:%p -o ControlPersist=10m" + on() { local h=$1; shift; ssh $SSH_OPTS ubuntu@$h "$@"; } + put() { local h=$1; shift; scp -q $SSH_OPTS "${@:1:$#-1}" ubuntu@$h:"${@: -1}"; } + get() { scp -q $SSH_OPTS ubuntu@$1:"$2" "$3"; } + q() { on $DB_PUB "mysql -N nowait_lab -e \"$1\""; } + sqlf() { on $DB_PUB "mysql $1"; } + mysql_usec() { on $DB_PUB 'cat /sys/fs/cgroup/system.slice/docker-$(docker inspect -f "{{.Id}}" coupon-toy-mysql).scope/cpu.stat' | awk '$1=="usage_usec"{print $2}'; } +fi +now() { python3 -c 'import time; print(time.time())'; } + +# 운영 시작 스크립트와 같은 힙. SQL 로그는 끄고, 에러 알림이 팀 Discord 로 가지 않게 웹훅을 막는다 +APP_ARGS="--spring.datasource.url='jdbc:mysql://$DB_PRIV:$DB_PORT/nowait_lab?createDatabaseIfNotExist=true&serverTimezone=Asia/Seoul' \ + --spring.datasource.username=root --spring.datasource.password=$DB_PASSWORD \ + --spring.data.redis.host=$DB_PRIV --spring.data.redis.port=$REDIS_PORT --spring.data.redis.password= \ + --spring.jpa.show-sql=false --spring.jpa.properties.hibernate.show_sql=false \ + --discord.webhook.user-url=http://127.0.0.1:9 --discord.webhook.admin-url=http://127.0.0.1:9" +start() { # start <포트> <프로필> [추가 인자...] + local jar=$1 port=$2 profile=$3; shift 3 + on $APP_PUB "nohup java -Xms256m -Xmx512m -XX:+UseG1GC -Duser.timezone=Asia/Seoul -jar $jar --server.port=$port \ + --spring.profiles.active=$profile $APP_ARGS $* > logs/${jar%.jar}.log 2>&1 < /dev/null &" + local i; for i in $(seq 1 120); do on $APP_PUB "curl -sf -m 2 localhost:$port/health-check" > /dev/null && return 0; sleep 1; done + echo "$jar did not start"; on $APP_PUB "tail -30 logs/${jar%.jar}.log"; return 1 +} +# pkill·pgrep 패턴은 [j] [.] 로 써서 그 명령을 돌리는 원격 셸 자신과는 맞지 않게 한다 +running() { on $APP_PUB "pgrep -f '[j]ava .*${1%.jar}[.]jar' > /dev/null"; } +stop() { on $APP_PUB "pkill -f '[j]ava .*${1%.jar}[.]jar'; for i in \$(seq 1 30); do pgrep -f '[j]ava .*${1%.jar}[.]jar' > /dev/null || exit 0; sleep 1; done"; } +cpu_time() { on $APP_PUB "ps -o time= -p \$(pgrep -f '[j]ava .*${1%.jar}[.]jar')" | tr -d ' '; } # 그 JVM 의 누적 CPU 시간 + +cache_stats() { # hitmiss (캐시 없는 기존 코드는 빈 값) + on $APP_PUB "curl -s localhost:8081/nowait/management/prometheus" | awk '/^cache_gets_total\{/ && /cache="cache:menus"/ { + if ($0 ~ /result="hit"/) h=$2; else if ($0 ~ /result="miss"/) m=$2 } END { printf "%s\t%s", h, m }' +} +index() { # index on|off 앱(ddl-auto)이 만든 인덱스를 단계에 맞게 지우거나 다시 만든다 + q "DROP INDEX $IDX ON menus" 2>/dev/null + [ $1 = off ] || q "CREATE INDEX $IDX ON menus (store_id, deleted, sort_order)" +} + +setup() { + set -e + local J=${JARS:?JARS=} + if [ "${LOCAL:-}" ]; then + docker rm -f nowait-bench-mysql nowait-bench-redis > /dev/null 2>&1 || true + docker run -d --name nowait-bench-mysql --cpus=1 -m 1g -e MYSQL_ROOT_PASSWORD=$DB_PASSWORD -p $DB_PORT:3306 mysql:8.0.36 > /dev/null + docker run -d --name nowait-bench-redis --cpus=1 -m 1g -p $REDIS_PORT:6379 redis:7.2-alpine > /dev/null + until $MYSQL -e 'SELECT 1' > /dev/null 2>&1; do sleep 2; done + else + on $APP_PUB "command -v java >/dev/null || { sudo apt-get update -q && sudo apt-get install -yq openjdk-17-jre-headless; }; mkdir -p logs" + fi + put $APP_PUB $J/user-asis.jar $J/user-tobe.jar $J/admin-asis.jar $J/admin.jar $D/probe.sh $D/verify.sh . + put $K6_PUB $D/menu-load.js . + # 스키마는 앱이 만든다(ddl-auto: update). 두 앱이 동시에 만들면 FK 이름이 겹치므로 차례로 띄운다 + stop user-asis.jar; stop user-tobe.jar; stop admin-asis.jar; stop admin.jar + start user-tobe.jar 8081 user-local + start admin.jar 8085 admin-local + { echo "SET @stores = $STORES;"; cat $D/seed.sql; } | sqlf nowait_lab + on $APP_PUB "curl -s -X POST localhost:8085/admin/users/signup -H 'Content-Type: application/json' \ + -d '{\"email\":\"load@test.com\",\"password\":\"loadPass1!\",\"nickname\":\"loadtester\",\"socialType\":\"LOCAL\"}'"; echo + q "UPDATE users SET role = 'SUPER_ADMIN' WHERE email = 'load@test.com'" # 모든 주점의 admin 메뉴 목록을 볼 수 있게 + echo "stores menus menu_images users: $(q "SELECT (SELECT COUNT(*) FROM stores), (SELECT COUNT(*) FROM menus), (SELECT COUNT(*) FROM menu_images), (SELECT COUNT(*) FROM users)")" + stop user-tobe.jar +} + +verify() { + L=$D/out/verify; mkdir -p $L + running admin.jar || start admin.jar 8085 admin-local || return 1 + stop user-asis.jar; stop user-tobe.jar; stop admin-asis.jar + start user-asis.jar 8081 user-local && start user-tobe.jar 8082 user-local && start admin-asis.jar 8086 admin-local || return 1 + on $APP_PUB "STORES=$STORES bash verify.sh diff" | tee $L/diff.txt + local port; for port in 8086 8085; do + q "TRUNCATE TABLE performance_schema.events_statements_summary_by_digest" + on $APP_PUB "STORES=$STORES bash verify.sh admin $port" + echo "admin :$port 메뉴 목록 $STORES 회 — $(sqlf "-N nowait_lab" < $D/digest.sql | awk '{printf "%s %s건 ", $1, $2}')" + done | tee $L/admin-queries.txt + stop user-asis.jar; stop user-tobe.jar; stop admin-asis.jar +} + +level() { # level <이름> <초당 요청> <시간> + local name=$1 rate=$2 dur=$3 + q "TRUNCATE TABLE performance_schema.events_statements_summary_by_digest" + local c0=$(cache_stats) m0=$(mysql_usec) p0=$(cpu_time $JAR) t0=$(now) + on $K6_PUB "env USER_URL=http://$APP_PRIV:8081 ADMIN_URL=http://$APP_PRIV:8085 STORES=$STORES RATE=$rate DURATION=$dur \ + WRITE_RATE=${WRITE_RATE:-1} LIST_RATIO=${LIST_RATIO:-1} k6 run --quiet --no-color --summary-export k6-$name.json menu-load.js" > $L/$name.log 2>&1 + local ec=$? t1=$(now) c1=$(cache_stats) m1=$(mysql_usec) p1=$(cpu_time $JAR) + sqlf "-N nowait_lab" < $D/digest.sql > $L/$name.digest + get $K6_PUB k6-$name.json $L/$name.json + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' $name $rate "$c0" "$c1" $((m1 - m0)) $(python3 -c "print($t1 - $t0)") $p0 $p1 >> $L/runs.tsv + echo "$name k6_exit=$ec" +} + +bench() { + local cfg rate + for cfg in "$@"; do + local ttl= idx=on; JAR=user-tobe.jar + case $cfg in + nplus1) JAR=user-asis.jar idx=off ;; + join) ttl=0s idx=off ;; + join-idx) ttl=0s ;; + cache) ;; + *) echo "모르는 단계: $cfg"; return 1 ;; + esac + L=$D/out/$cfg; mkdir -p $L; : > $L/runs.tsv + running admin.jar || start admin.jar 8085 admin-local || return 1 + stop user-asis.jar; stop user-tobe.jar + q "UPDATE menus SET is_sold_out = 0" + start $JAR 8081 user-local ${ttl:+--nowait.cache.menu.ttl=$ttl} || return 1 + index $idx + level warm-$cfg 100 20s # 예열 (보고서에서 뺀다) + for rate in ${RATES:-200 500 1000}; do level $cfg-$rate $rate ${DURATION:-60s}; done + stop $JAR + report $L | tee $L/report.md + done +} + +report() { + python3 - "$1" <<'EOF' +import json, sys +L = sys.argv[1] +def secs(t): # ps 의 time: [D-]HH:MM:SS (Linux), [H:]M:SS.ss (macOS) + d, _, hms = t.rpartition('-') + s = 0.0 + for x in hms.split(':'): + s = s * 60 + float(x) + return s + int(d or 0) * 86400 +print('| 목표 req/s | 처리 req/s | p50 ms | p95 ms | p99 ms | 실패 | 못 보낸 요청 | 히트율 | stores | menus | menu_images | 요청당 쿼리 | 요청당 읽은 행 | MySQL CPU(코어) | user-api CPU(코어) |') +print('|' + '---|' * 15) +for line in open(f'{L}/runs.tsv'): + name, rate, h0, m0, h1, m1, mus, el, p0, p1 = line.rstrip('\n').split('\t') + if name.startswith('warm'): + continue + k = json.load(open(f'{L}/{name}.json'))['metrics'] + d, n = k['http_req_duration{scenario:reads}'], k['http_reqs{scenario:reads}'] + failed = k['http_req_failed{scenario:reads}'].get('value', 0) + dropped = k.get('dropped_iterations', {}).get('count', 0) + q = {t: (int(c), int(r)) for t, c, r, _ in (l.split('\t') for l in open(f'{L}/{name}.digest') if l.strip())} + hit, miss = float(h1 or 0) - float(h0 or 0), float(m1 or 0) - float(m0 or 0) + hr = f'{hit / (hit + miss) * 100:.1f}%' if hit + miss else '-' + calls = {t: q.get(t, (0, 0))[0] for t in ('stores', 'menus', 'menu_images')} + rows = sum(q.get(t, (0, 0))[1] for t in ('stores', 'menus', 'menu_images')) + el = float(el) + print(f"| {rate} | {n['rate']:.0f} | {d['med']:.1f} | {d['p(95)']:.1f} | {d['p(99)']:.1f} | {failed * 100:.2f}% | {dropped:.0f} " + f"| {hr} | {calls['stores']} | {calls['menus']} | {calls['menu_images']} | {sum(calls.values()) / n['count']:.2f} " + f"| {rows / n['count']:.0f} | {int(mus) / 1e6 / el:.2f} | {(secs(p1) - secs(p0)) / el:.2f} |") +EOF +} + +delay() { + L=$D/out/delay; mkdir -p $L + running admin.jar || start admin.jar 8085 admin-local || return 1 + stop user-asis.jar + running user-tobe.jar || start user-tobe.jar 8081 user-local || return 1 + index on + on $K6_PUB "nohup env USER_URL=http://$APP_PRIV:8081 ADMIN_URL=http://$APP_PRIV:8085 STORES=$STORES RATE=${DELAY_RATE:-400} DURATION=120s \ + WRITE_RATE=1 LIST_RATIO=${LIST_RATIO:-0.8} k6 run --quiet --no-color menu-load.js > delay-k6.log 2>&1 < /dev/null &" + sleep 15 + on $APP_PUB "N=${N:-30} bash probe.sh" | tee $L/probe.txt + on $K6_PUB "while pgrep -x k6 > /dev/null; do sleep 1; done" + python3 - $L/probe.txt <<'EOF' | tee $L/report.md +import statistics, sys +rows = [list(map(int, l.split())) for l in open(sys.argv[1]) if l.strip()] +toggle, reflect, stale = zip(*rows) +p = lambda xs, q: sorted(xs)[min(len(xs) - 1, int(len(xs) * q))] +print(f'토글 {len(rows)}회 | 토글 응답 p50 {p(toggle, .5)}ms p95 {p(toggle, .95)}ms ' + f'| 응답 후 반영 확인 p50 {p(reflect, .5)}ms p95 {p(reflect, .95)}ms 최대 {max(reflect)}ms ' + f'| 옛 값을 받은 조회 {sum(stale)}회') +EOF +} + +cleanup() { + stop user-asis.jar; stop user-tobe.jar; stop admin-asis.jar; stop admin.jar + if [ "${LOCAL:-}" ]; then docker rm -f nowait-bench-mysql nowait-bench-redis > /dev/null; else q "DROP DATABASE IF EXISTS nowait_lab"; fi +} + +case ${1:-} in + setup) setup ;; + verify) verify ;; + bench) shift; bench "$@" ;; + delay) delay ;; + report) report "$2" ;; + cleanup) cleanup ;; + *) sed -n 2,10p "$0"; exit 1 ;; +esac diff --git a/performance/menu-cache/seed.sql b/performance/menu-cache/seed.sql new file mode 100644 index 00000000..cb3dbf81 --- /dev/null +++ b/performance/menu-cache/seed.sql @@ -0,0 +1,25 @@ +-- 목업: 학과 5, 주점 @stores, 주점당 메뉴 20. 메뉴 id = (store_id - 1) * 20 + n, 공개 코드 = LOAD + 8자리 store_id +-- 이미지: 메뉴당 1장, 단 2번 메뉴는 2장·20번 메뉴는 없음 (JOIN 결과를 메뉴별로 묶는지, 이미지 없는 메뉴가 빠지지 않는지 확인용) +-- 실행 전에 SET @stores = <주점 수>; +SET SESSION cte_max_recursion_depth = 1000000; +DELETE FROM menu_images; +DELETE FROM menus; +DELETE FROM stores; +DELETE FROM departments; + +INSERT INTO departments (id, name) +WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 5) +SELECT i, CONCAT('dept-', i) FROM n; + +INSERT INTO stores (store_id, created_at, deleted, department_id, is_active, name, public_code, location, description) +WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < @stores) +SELECT i, NOW(), 0, i % 5 + 1, 1, CONCAT('store-', i), CONCAT('LOAD', LPAD(i, 8, '0')), 'location', 'description' FROM n; + +INSERT INTO menus (id, created_at, admin_display_name, deleted, description, is_sold_out, name, price, sort_order, store_id) +WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < @stores * 20) +SELECT i, NOW(), CONCAT('menu-', i), 0, 'description', 0, CONCAT('menu-', i), 1000 * ((i - 1) % 20 + 1), (i - 1) % 20 + 1, (i - 1) DIV 20 + 1 FROM n; + +INSERT INTO menu_images (id, created_at, file_key, image_url, menu_id) +SELECT id, NOW(), CONCAT('menu/', id), CONCAT('https://img.example.com/menu/', id, '.png'), id FROM menus WHERE sort_order <> 20 +UNION ALL +SELECT id + @stores * 20, NOW(), CONCAT('menu/', id, '-2'), CONCAT('https://img.example.com/menu/', id, '-2.png'), id FROM menus WHERE sort_order = 2; diff --git a/performance/menu-cache/verify.sh b/performance/menu-cache/verify.sh new file mode 100644 index 00000000..72eb4e57 --- /dev/null +++ b/performance/menu-cache/verify.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# app 인스턴스에서 실행 +# diff 기존(user :8081, admin :8086)과 JOIN(user :8082, admin :8085)의 메뉴 목록 응답을 전 주점 비교 +# admin <포트> 그 admin 으로 전 주점 메뉴 목록을 한 번씩 조회 (쿼리 수 집계용) +T=$(curl -s -X POST localhost:8085/admin/users/login -H 'Content-Type: application/json' \ + -d '{"email":"load@test.com","password":"loadPass1!"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["response"]["body"]["accessToken"])') +user() { curl -s localhost:$1/v1/stores/LOAD$(printf %08d $2)/menus; } +admin() { curl -s localhost:$1/admin/menus/all-menus/stores/$2 -H "Authorization: Bearer $T"; } +case $1 in + diff) + for kind in user admin; do + if [ $kind = user ]; then old=8081 new=8082; else old=8086 new=8085; fi + same=0 diff=0 + for s in $(seq $STORES); do + a=$($kind $old $s) b=$($kind $new $s) + if [ "$a" = "$b" ] && [[ $a == *'"menuId"'* ]]; then same=$((same + 1)); continue; fi + diff=$((diff + 1)) + [ $diff = 1 ] && printf '%s 주점 %s 다름\n기존: %.600s\nJOIN: %.600s\n' $kind $s "$a" "$b" + done + echo "$kind 메뉴 목록: 같음 $same / 다름 $diff" + done ;; + admin) for s in $(seq $STORES); do admin $2 $s > /dev/null; done ;; +esac