diff --git a/src/main/java/eatda/domain/cheer/Cheer.java b/src/main/java/eatda/domain/cheer/Cheer.java index 9cde5a3b..da91e069 100644 --- a/src/main/java/eatda/domain/cheer/Cheer.java +++ b/src/main/java/eatda/domain/cheer/Cheer.java @@ -49,6 +49,12 @@ public class Cheer extends AuditingEntity { @OneToMany(mappedBy = "cheer", cascade = CascadeType.ALL, orphanRemoval = true) private Set images = new HashSet<>(); + /* + CheerTags가 Embedded이기 때문에 BatchSize를 그대로 적용하지 못함. + 성능을 위해서는 Embedded 제거 후 직접 @OneToMany로 매핑 필요함. + 현재 데이터가 많지 않음으로 현상 유지하며 모니터링. + 추후 재설계 필요 + */ @Embedded private CheerTags cheerTags; diff --git a/src/main/java/eatda/domain/store/Store.java b/src/main/java/eatda/domain/store/Store.java index fa321ab9..98bf751b 100644 --- a/src/main/java/eatda/domain/store/Store.java +++ b/src/main/java/eatda/domain/store/Store.java @@ -57,6 +57,12 @@ public class Store extends AuditingEntity { @Embedded private Coordinates coordinates; + /* + 현재는 가게당 평균 응원 수가 5개 이하이므로 BatchSize=10이 적절함. + 데이터 증가를 고려하여 IN 쿼리 한 번당 최대 30개 Store의 Cheer를 로딩하도록 설정. + 향후 응원 수가 증가하거나 Store 리스트 조회 규모가 커질 경우 + 성능 모니터링 후 BatchSize 조정 및 Fetch 전략 재검토 필요. + */ @OneToMany(mappedBy = "store") private List cheers = new ArrayList<>(); diff --git a/src/main/java/eatda/repository/cheer/CheerRepository.java b/src/main/java/eatda/repository/cheer/CheerRepository.java index f1c9a5c5..7ac98dd1 100644 --- a/src/main/java/eatda/repository/cheer/CheerRepository.java +++ b/src/main/java/eatda/repository/cheer/CheerRepository.java @@ -8,19 +8,18 @@ import eatda.domain.store.StoreCategory; import jakarta.persistence.criteria.JoinType; import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; -import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.lang.Nullable; public interface CheerRepository extends JpaRepository { - @EntityGraph(attributePaths = {"member", "cheerTags.values"}) - List findAllByStoreOrderByCreatedAtDesc(Store store, PageRequest pageRequest); + Page findAllByStoreOrderByCreatedAtDesc(Store store, PageRequest pageRequest); - default List findAllByConditions(@Nullable StoreCategory category, + default Page findAllByConditions(@Nullable StoreCategory category, List cheerTagNames, List districts, Pageable pageable) { Specification spec = createSpecification(category, cheerTagNames, districts); @@ -36,7 +35,9 @@ private Specification createSpecification(@Nullable StoreCategory categor } if (!cheerTagNames.isEmpty()) { spec = spec.and(((root, query, cb) -> { - query.distinct(true); + if (query != null) { + query.distinct(true); + } return root.join("cheerTags").join("values", JoinType.LEFT) .get("name").in(cheerTagNames); })); @@ -47,8 +48,7 @@ private Specification createSpecification(@Nullable StoreCategory categor return spec; } - @EntityGraph(attributePaths = {"store", "member", "cheerTags.values"}) - List findAll(Specification specification, Pageable pageable); + Page findAll(Specification specification, Pageable pageable); int countByMember(Member member); diff --git a/src/main/java/eatda/repository/store/StoreRepository.java b/src/main/java/eatda/repository/store/StoreRepository.java index f1e87821..1fd34e62 100644 --- a/src/main/java/eatda/repository/store/StoreRepository.java +++ b/src/main/java/eatda/repository/store/StoreRepository.java @@ -8,9 +8,9 @@ import eatda.exception.BusinessException; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; -import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.lang.Nullable; @@ -33,7 +33,7 @@ default Store getById(Long id) { """) List findAllByCheeredMemberId(long memberId); - default List findAllByConditions(@Nullable StoreCategory category, + default Page findAllByConditions(@Nullable StoreCategory category, List cheerTagNames, List districts, Pageable pageable) { @@ -41,8 +41,7 @@ default List findAllByConditions(@Nullable StoreCategory category, return findAll(spec, pageable); } - @EntityGraph(attributePaths = {"cheers"}) - List findAll(Specification spec, Pageable pageable); + Page findAll(Specification spec, Pageable pageable); private Specification createSpecification(@Nullable StoreCategory category, List cheerTagNames, @@ -52,8 +51,12 @@ private Specification createSpecification(@Nullable StoreCategory categor spec = spec.and((root, query, cb) -> cb.equal(root.get("category"), category)); } if (!cheerTagNames.isEmpty()) { - spec = spec.and(((root, query, cb) -> - root.join("cheers").join("cheerTags").join("values").get("name").in(cheerTagNames))); + spec = spec.and(((root, query, cb) -> { + if (query != null) { + query.distinct(true); + } + return root.join("cheers").join("cheerTags").join("values").get("name").in(cheerTagNames); + })); } if (!districts.isEmpty()) { spec = spec.and((root, query, cb) -> root.get("district").in(districts)); diff --git a/src/main/java/eatda/repository/story/StoryRepository.java b/src/main/java/eatda/repository/story/StoryRepository.java index 20f4387a..1106785d 100644 --- a/src/main/java/eatda/repository/story/StoryRepository.java +++ b/src/main/java/eatda/repository/story/StoryRepository.java @@ -3,15 +3,12 @@ import eatda.domain.story.Story; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; public interface StoryRepository extends JpaRepository { - @EntityGraph(attributePaths = "images") Page findAllByOrderByCreatedAtDesc(Pageable pageable); Page findAllByMemberIdOrderByCreatedAtDesc(Long memberId, Pageable pageable); - @EntityGraph(attributePaths = {"member", "images"}) Page findAllByStoreKakaoIdOrderByCreatedAtDesc(String storeKakaoId, Pageable pageable); } diff --git a/src/main/java/eatda/service/cheer/CheerService.java b/src/main/java/eatda/service/cheer/CheerService.java index 7d2ee25d..ca910873 100644 --- a/src/main/java/eatda/service/cheer/CheerService.java +++ b/src/main/java/eatda/service/cheer/CheerService.java @@ -25,6 +25,7 @@ import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; @@ -115,12 +116,15 @@ private void saveCheerImages(Cheer cheer, @Transactional(readOnly = true) public CheersResponse getCheers(CheerSearchParameters parameters) { - List cheers = cheerRepository.findAllByConditions( + Page cheerPage = cheerRepository.findAllByConditions( parameters.getCategory(), parameters.getCheerTagNames(), parameters.getDistricts(), - PageRequest.of(parameters.getPage(), parameters.getSize(), Sort.by(Direction.DESC, "createdAt")) + PageRequest.of(parameters.getPage(), parameters.getSize(), + Sort.by(Direction.DESC, "createdAt")) ); + + List cheers = cheerPage.getContent(); return toCheersResponse(cheers); } @@ -140,11 +144,12 @@ private CheersResponse toCheersResponse(List cheers) { @Transactional(readOnly = true) public CheersInStoreResponse getCheersByStoreId(Long storeId, int page, int size) { Store store = storeRepository.getById(storeId); - List cheers = cheerRepository.findAllByStoreOrderByCreatedAtDesc(store, PageRequest.of(page, size)); + Page cheersPage = cheerRepository.findAllByStoreOrderByCreatedAtDesc(store, PageRequest.of(page, size)); - List cheersResponse = cheers.stream() + List cheersResponse = cheersPage.getContent().stream() .map(CheerInStoreResponse::new) - .toList(); // TODO N+1 문제 해결 + .toList(); + return new CheersInStoreResponse(cheersResponse); } } diff --git a/src/main/java/eatda/service/store/StoreService.java b/src/main/java/eatda/service/store/StoreService.java index d8609573..fafb627d 100644 --- a/src/main/java/eatda/service/store/StoreService.java +++ b/src/main/java/eatda/service/store/StoreService.java @@ -19,6 +19,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; @@ -45,7 +46,7 @@ public StoreResponse getStore(long storeId) { // TODO : N+1 문제 해결 @Transactional(readOnly = true) public StoresResponse getStores(StoreSearchParameters parameters) { - List stores = storeRepository.findAllByConditions( + Page stores = storeRepository.findAllByConditions( parameters.getCategory(), parameters.getCheerTagNames(), parameters.getDistricts(), diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 42208d9e..08b32a43 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -27,6 +27,9 @@ spring: jpa: hibernate: ddl-auto: validate + properties: + hibernate: + default_batch_fetch_size: 30 flyway: enabled: true diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 99fa13c9..50aed8c9 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -22,6 +22,7 @@ spring: max-file-size: 5MB max-request-size: 20MB + # BatchSize 미적용시를 비교하기 위해 local에는 BatchSize를 추가하지 않음 jpa: hibernate: ddl-auto: validate diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8927fd09..dc3294cb 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -27,6 +27,9 @@ spring: jpa: hibernate: ddl-auto: validate + properties: + hibernate: + default_batch_fetch_size: 30 flyway: enabled: true diff --git a/src/test/java/eatda/repository/cheer/CheerRepositoryTest.java b/src/test/java/eatda/repository/cheer/CheerRepositoryTest.java index 991b45e5..fd301991 100644 --- a/src/test/java/eatda/repository/cheer/CheerRepositoryTest.java +++ b/src/test/java/eatda/repository/cheer/CheerRepositoryTest.java @@ -13,6 +13,7 @@ import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; class CheerRepositoryTest extends BaseRepositoryTest { @@ -64,7 +65,7 @@ class FindAllByConditions { Cheer cheer2_2 = cheerGenerator.generateCommon(member2, store2); Cheer cheer3_2 = cheerGenerator.generateCommon(member2, store3); - List actual = cheerRepository.findAllByConditions( + Page actual = cheerRepository.findAllByConditions( StoreCategory.KOREAN, List.of(), List.of(), Pageable.unpaged()); assertThat(actual).map(Cheer::getId) @@ -90,7 +91,7 @@ class FindAllByConditions { cheerTagGenerator.generate(cheer2_2, List.of(CheerTagName.CLEAN_RESTROOM)); cheerTagGenerator.generate(cheer3_1, List.of(CheerTagName.ENERGETIC, CheerTagName.QUIET)); - List actual = cheerRepository.findAllByConditions(null, + Page actual = cheerRepository.findAllByConditions(null, List.of(CheerTagName.INSTAGRAMMABLE, CheerTagName.CLEAN_RESTROOM), List.of(), Pageable.unpaged()); assertThat(actual) @@ -110,7 +111,7 @@ class FindAllByConditions { Cheer cheer2_2 = cheerGenerator.generateCommon(member2, store2); Cheer cheer3_2 = cheerGenerator.generateCommon(member2, store3); - List actual = cheerRepository.findAllByConditions( + Page actual = cheerRepository.findAllByConditions( null, List.of(), List.of(District.GANGNAM), Pageable.unpaged()); assertThat(actual) @@ -145,7 +146,7 @@ class FindAllByConditions { cheerTagGenerator.generate(cheer4_2, List.of(CheerTagName.INSTAGRAMMABLE)); cheerTagGenerator.generate(cheer5_2, List.of(CheerTagName.CLEAN_RESTROOM, CheerTagName.ENERGETIC)); - List actual = cheerRepository.findAllByConditions(StoreCategory.KOREAN, + Page actual = cheerRepository.findAllByConditions(StoreCategory.KOREAN, List.of(CheerTagName.CLEAN_RESTROOM), List.of(District.GANGNAM), Pageable.unpaged()); assertThat(actual) @@ -169,7 +170,7 @@ class FindAllByConditions { cheerTagGenerator.generate(cheer2_1, List.of(CheerTagName.CLEAN_RESTROOM)); cheerTagGenerator.generate(cheer2_2, List.of(CheerTagName.CLEAN_RESTROOM)); - List actual = cheerRepository.findAllByConditions(null, List.of(), List.of(), Pageable.unpaged()); + Page actual = cheerRepository.findAllByConditions(null, List.of(), List.of(), Pageable.unpaged()); assertThat(actual) .map(Cheer::getId) diff --git a/src/test/java/eatda/repository/store/StoreRepositoryTest.java b/src/test/java/eatda/repository/store/StoreRepositoryTest.java index 43cf7aca..93ab512e 100644 --- a/src/test/java/eatda/repository/store/StoreRepositoryTest.java +++ b/src/test/java/eatda/repository/store/StoreRepositoryTest.java @@ -14,6 +14,7 @@ import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; class StoreRepositoryTest extends BaseRepositoryTest { @@ -61,7 +62,7 @@ class FindAllByConditions { Store store2 = storeGenerator.generate("1236", "서울시 강남구 역삼동 123-45", StoreCategory.WESTERN, startAt); Store store3 = storeGenerator.generate("1237", "서울시 강남구 역삼동 123-45", StoreCategory.KOREAN, startAt); - List actual = storeRepository.findAllByConditions( + Page actual = storeRepository.findAllByConditions( StoreCategory.KOREAN, List.of(), List.of(), Pageable.unpaged()); assertThat(actual).map(Store::getId) @@ -87,7 +88,7 @@ class FindAllByConditions { cheerTagGenerator.generate(cheer2_2, List.of(CheerTagName.CLEAN_RESTROOM)); cheerTagGenerator.generate(cheer3_1, List.of(CheerTagName.ENERGETIC, CheerTagName.QUIET)); - List actual = storeRepository.findAllByConditions(null, + Page actual = storeRepository.findAllByConditions(null, List.of(CheerTagName.INSTAGRAMMABLE, CheerTagName.CLEAN_RESTROOM), List.of(), Pageable.unpaged()); assertThat(actual).map(Store::getId) @@ -100,7 +101,7 @@ class FindAllByConditions { Store store2 = storeGenerator.generate("1236", "서울시 강남구 역삼동 123-45", District.GANGNAM); Store store3 = storeGenerator.generate("1237", "서울시 성북구 석관동 123-45", District.SEONGBUK); - List actual = storeRepository.findAllByConditions( + Page actual = storeRepository.findAllByConditions( null, List.of(), List.of(District.GANGNAM), Pageable.unpaged()); assertThat(actual).map(Store::getId) @@ -134,7 +135,7 @@ class FindAllByConditions { cheerTagGenerator.generate(cheer4_2, List.of(CheerTagName.INSTAGRAMMABLE)); cheerTagGenerator.generate(cheer5_2, List.of(CheerTagName.CLEAN_RESTROOM, CheerTagName.ENERGETIC)); - List actual = storeRepository.findAllByConditions(StoreCategory.KOREAN, + Page actual = storeRepository.findAllByConditions(StoreCategory.KOREAN, List.of(CheerTagName.CLEAN_RESTROOM), List.of(District.GANGNAM), Pageable.unpaged()); assertThat(actual).map(Store::getId) @@ -157,7 +158,7 @@ class FindAllByConditions { cheerTagGenerator.generate(cheer2_1, List.of(CheerTagName.CLEAN_RESTROOM)); cheerTagGenerator.generate(cheer2_2, List.of(CheerTagName.CLEAN_RESTROOM)); - List actual = storeRepository.findAllByConditions(null, List.of(), List.of(), Pageable.unpaged()); + Page actual = storeRepository.findAllByConditions(null, List.of(), List.of(), Pageable.unpaged()); assertThat(actual).map(Store::getId) .containsExactlyInAnyOrder(store1.getId(), store2.getId(), store3.getId()); diff --git a/terraform/common/waf/main.tf b/terraform/common/waf/main.tf index 7e68559e..af8906d5 100644 --- a/terraform/common/waf/main.tf +++ b/terraform/common/waf/main.tf @@ -85,14 +85,74 @@ resource "aws_wafv2_web_acl" "this" { statement { or_statement { statement { - size_constraint_statement { + byte_match_statement { + field_to_match { + single_header { + name = "origin" + } + } + search_string = "eatda.net" + positional_constraint = "ENDS_WITH" + text_transformation { + priority = 0 + type = "LOWERCASE" + } + } + } + statement { + byte_match_statement { + field_to_match { + single_header { + name = "referer" + } + } + search_string = "eatda.net" + positional_constraint = "CONTAINS" + text_transformation { + priority = 0 + type = "LOWERCASE" + } + } + } + statement { + byte_match_statement { + field_to_match { + single_header { + name = "origin" + } + } + search_string = "dev.eatda.net" + positional_constraint = "ENDS_WITH" + text_transformation { + priority = 0 + type = "LOWERCASE" + } + } + } + statement { + byte_match_statement { + field_to_match { + single_header { + name = "referer" + } + } + search_string = "dev.eatda.net" + positional_constraint = "CONTAINS" + text_transformation { + priority = 0 + type = "LOWERCASE" + } + } + } + statement { + byte_match_statement { field_to_match { single_header { name = "origin" } } - comparison_operator = "GT" - size = 0 + search_string = "http://localhost:3000" + positional_constraint = "EXACTLY" text_transformation { priority = 0 type = "NONE" @@ -100,14 +160,14 @@ resource "aws_wafv2_web_acl" "this" { } } statement { - size_constraint_statement { + byte_match_statement { field_to_match { single_header { name = "referer" } } - comparison_operator = "GT" - size = 0 + search_string = "http://localhost:3000/" + positional_constraint = "STARTS_WITH" text_transformation { priority = 0 type = "NONE" @@ -202,26 +262,6 @@ resource "aws_wafv2_web_acl" "this" { } } - # Bad Bot Protection - rule { - name = "AWS-Managed-Bot-Control-Rule-Set" - priority = 60 - override_action { - none {} - } - statement { - managed_rule_group_statement { - vendor_name = "AWS" - name = "AWSManagedRulesBotControlRuleSet" - } - } - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "aws-managed-bot-control" - sampled_requests_enabled = true - } - } - # Anonymous IP list rule { name = "AWS-Managed-Anonymous-IP-List"