diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java deleted file mode 100644 index 19b7cd9be..000000000 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchService.java +++ /dev/null @@ -1,41 +0,0 @@ -package kr.co.pennyway.api.apis.ledger.service; - -import com.querydsl.core.types.Predicate; -import kr.co.pennyway.domain.common.repository.QueryHandler; -import kr.co.pennyway.domain.domains.spending.domain.QSpending; -import kr.co.pennyway.domain.domains.spending.domain.Spending; -import kr.co.pennyway.domain.domains.spending.service.SpendingService; -import kr.co.pennyway.domain.domains.user.domain.QUser; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Slf4j -@Service -@RequiredArgsConstructor -public class SpendingSearchService { - private final SpendingService spendingService; - - private final QUser user = QUser.user; - private final QSpending spending = QSpending.spending; - - /** - * 사용자의 해당 년/월 지출 내역을 조회하는 메서드 - */ - @Transactional(readOnly = true) - public List readSpendings(Long userId, int year, int month) { - Predicate predicate = spending.user.id.eq(userId) - .and(spending.spendAt.year().eq(year)) - .and(spending.spendAt.month().eq(month)); - - QueryHandler queryHandler = query -> query.leftJoin(user).on(spending.user.eq(user)); - - Sort sort = Sort.by(Sort.Order.desc("spendAt")); - - return spendingService.readSpendings(predicate, queryHandler, sort); - } -} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java index 14ef1995b..315c10db8 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/ledger/usecase/SpendingUseCase.java @@ -4,7 +4,6 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingSearchRes; import kr.co.pennyway.api.apis.ledger.mapper.SpendingMapper; import kr.co.pennyway.api.apis.ledger.service.SpendingSaveService; -import kr.co.pennyway.api.apis.ledger.service.SpendingSearchService; import kr.co.pennyway.api.apis.ledger.service.SpendingUpdateService; import kr.co.pennyway.common.annotation.UseCase; import kr.co.pennyway.domain.domains.spending.domain.Spending; @@ -26,7 +25,6 @@ @RequiredArgsConstructor public class SpendingUseCase { private final SpendingSaveService spendingSaveService; - private final SpendingSearchService spendingSearchService; private final SpendingUpdateService spendingUpdateService; private final SpendingService spendingService; @@ -45,7 +43,9 @@ public SpendingSearchRes.Individual createSpending(Long userId, SpendingReq requ @Transactional(readOnly = true) public SpendingSearchRes.Month getSpendingsAtYearAndMonth(Long userId, int year, int month) { - List spendings = spendingSearchService.readSpendings(userId, year, month); + List spendings = spendingService.readSpendings(userId, year, month).orElseThrow( + () -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING) + ); return SpendingMapper.toSpendingSearchResMonth(spendings, year, month); } diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java index f07bb14dc..6e95243bd 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/SpendingControllerIntegrationTest.java @@ -133,7 +133,7 @@ class GetSpendingListAtYearAndMonth { void getSpendingListAtYearAndMonthSuccess() throws Exception { // given User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); - SpendingFixture.bulkInsertSpending(user, 150, jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 150, false, jdbcTemplate); // when long before = System.currentTimeMillis(); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java index bdc2cf446..b3c987598 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/integration/TargetAmountIntegrationTest.java @@ -114,7 +114,7 @@ class GetTargetAmountAndTotalSpending { void getTargetAmountAndTotalSpending() throws Exception { // given User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2), jdbcTemplate); - SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, false, jdbcTemplate); TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); // when @@ -156,7 +156,7 @@ class GetTargetAmountsAndTotalSpendings { void getTargetAmountsAndTotalSpendings() throws Exception { // given User user = createUserWithCreatedAt(LocalDateTime.now().minusYears(2).plusMonths(2), jdbcTemplate); - SpendingFixture.bulkInsertSpending(user, 300, jdbcTemplate); + SpendingFixture.bulkInsertSpending(user, 300, false, jdbcTemplate); TargetAmountFixture.bulkInsertTargetAmount(user, jdbcTemplate); // when diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java new file mode 100644 index 000000000..53269ea84 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/ledger/service/SpendingSearchServiceTest.java @@ -0,0 +1,86 @@ +package kr.co.pennyway.api.apis.ledger.service; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.api.config.fixture.SpendingFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.service.SpendingService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.engine.spi.SessionImplementor; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.IntStream; + +@Slf4j +@ExtendWith(MockitoExtension.class) +@ExternalApiIntegrationTest +class SpendingSearchServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + @Autowired + private SpendingService spendingService; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @PersistenceContext + private EntityManager entityManager; + private Statistics statistics; + + + @BeforeEach + public void setUp() { + SessionFactoryImplementor sessionFactory = (SessionFactoryImplementor) ((SessionImplementor) entityManager.getDelegate()).getSessionFactory(); + statistics = sessionFactory.getStatistics(); + statistics.setStatisticsEnabled(true); + } + + @AfterEach + public void tearDown() { + statistics.clear(); + } + + @Test + @Transactional + @DisplayName("커스텀 카테고리 지출 내역을 기간별 조회시 카테고리를 바로 fetch 한다.") + void testReadSpendingsLazyLoading() { + // given + User user = userService.createUser(UserFixture.GENERAL_USER.toUser()); + SpendingFixture.bulkInsertSpending(user, 100, true, jdbcTemplate); + + // when + List spendings = spendingService.readSpendings(user.getId(), LocalDate.now().getYear(), LocalDate.now().getMonthValue()).orElseThrow(); + + int size = spendings.size(); + for (Spending spending : spendings) { + log.info("지출내역 id : {} 커스텀 카테고리 id : {} 커스텀 카테고리 name : {}", + spending.getId(), + spending.getSpendingCustomCategory().getId(), + spending.getSpendingCustomCategory().getName() + ); + } + + // then + log.info("쿼리문 실행 횟수: {}", statistics.getPrepareStatementCount()); + log.info("readSpendings로 조회해온 지출 내역 개수: {}", size); + + Assertions.assertEquals(2, statistics.getPrepareStatementCount()); + + boolean isSortedDescending = IntStream.range(0, spendings.size() - 1) + .allMatch(i -> !spendings.get(i).getSpendAt().isBefore(spendings.get(i + 1).getSpendAt())); + Assertions.assertTrue(isSortedDescending); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java new file mode 100644 index 000000000..24eadb0a3 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingCustomCategoryFixture.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.api.config.fixture; + +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public enum SpendingCustomCategoryFixture { + GENERAL_SPENDING_CUSTOM_CATEGORY("커스텀 지출 내역 카테고리", SpendingCategory.FOOD); + + private final String name; + private final SpendingCategory icon; + + SpendingCustomCategoryFixture(String name, SpendingCategory icon) { + this.name = name; + this.icon = icon; + } + + public static void bulkInsertCustomCategory(User user, int capacity, NamedParameterJdbcTemplate jdbcTemplate) { + Collection customCategories = getCustomCategories(user, capacity); + + String sql = String.format(""" + INSERT INTO `%s` (name, icon, user_id, created_at, updated_at, deleted_at) + VALUES (:name, 1, :user.id, NOW(), NOW(), null) + """, "spending_custom_category"); + SqlParameterSource[] params = customCategories.stream() + .map(BeanPropertySqlParameterSource::new) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + private static List getCustomCategories(User user, int capacity) { + List customCategories = new ArrayList<>(capacity); + + for (int i = 0; i < capacity; i++) { + customCategories.add(SpendingCustomCategoryFixture.GENERAL_SPENDING_CUSTOM_CATEGORY.toCustomSpendingCategory(user)); + } + return customCategories; + } + + public SpendingCustomCategory toCustomSpendingCategory(User user) { + return SpendingCustomCategory.of(name, icon, user); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java index 185084a1f..2a2a2266b 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/config/fixture/SpendingFixture.java @@ -2,6 +2,7 @@ import kr.co.pennyway.api.apis.ledger.dto.SpendingReq; import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; import kr.co.pennyway.domain.domains.user.domain.User; import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; @@ -16,35 +17,44 @@ import java.util.concurrent.ThreadLocalRandom; public enum SpendingFixture { - GENERAL_SPENDING(10000, SpendingCategory.FOOD, LocalDateTime.now(), "카페인 수혈", "아메리카노 1잔", UserFixture.GENERAL_USER.toUser()); + GENERAL_SPENDING(10000, SpendingCategory.FOOD, LocalDateTime.now(), "카페인 수혈", "아메리카노 1잔"), + CUSTOM_CATEGORY_SPENDING(10000, SpendingCategory.CUSTOM, LocalDateTime.now(), "커스텀 카페인 수혈", "아메리카노 1잔"); private final int amount; private final SpendingCategory category; private final LocalDateTime spendAt; private final String accountName; private final String memo; - private final User user; - SpendingFixture(int amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, User user) { + + SpendingFixture(int amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo) { this.amount = amount; this.category = category; this.spendAt = spendAt; this.accountName = accountName; this.memo = memo; - this.user = user; } public static SpendingReq toSpendingReq(User user) { return new SpendingReq(10000, -1L, SpendingCategory.FOOD, LocalDate.now(), "카페인 수혈", "아메리카노 1잔"); } - public static void bulkInsertSpending(User user, int capacity, NamedParameterJdbcTemplate jdbcTemplate) { + public static void bulkInsertSpending(User user, int capacity, boolean isCustom, NamedParameterJdbcTemplate jdbcTemplate) { Collection spendings = getRandomSpendings(user, capacity); + String sql; + if (isCustom) { + SpendingCustomCategoryFixture.bulkInsertCustomCategory(user, capacity, jdbcTemplate); + sql = String.format(""" + INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) + VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, 1 + FLOOR(RAND() * %d), NOW(), NOW(), null) + """, "spending", capacity); + } else { + sql = String.format(""" + INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) + VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, null, NOW(), NOW(), null) + """, "spending"); + } - String sql = String.format(""" - INSERT INTO `%s` (amount, category, spend_at, account_name, memo, user_id, spending_custom_category_id, created_at, updated_at, deleted_at) - VALUES (:amount, 1+FLOOR(RAND()*11), :spendAt, :accountName, :memo, :user.id, null, NOW(), NOW(), null) - """, "spending"); SqlParameterSource[] params = spendings.stream() .map(BeanPropertySqlParameterSource::new) .toArray(SqlParameterSource[]::new); @@ -96,4 +106,16 @@ public Spending toSpending(User user) { .user(user) .build(); } + + public Spending toCustomCategorySpending(User user, SpendingCustomCategory customCategory) { + return Spending.builder() + .amount(amount) + .category(category) + .spendAt(spendAt) + .accountName(accountName) + .memo(memo) + .user(user) + .spendingCustomCategory(customCategory) + .build(); + } } \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java index 8687e8589..bc11d7bec 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java @@ -1,9 +1,13 @@ package kr.co.pennyway.domain.domains.spending.repository; +import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import java.util.List; import java.util.Optional; public interface SpendingCustomRepository { Optional findTotalSpendingAmountByUserId(Long userId, int year, int month); + + Optional> findByYearAndMonth(Long userId, int year, int month); } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java index 7b6ed3ade..004752e0a 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -1,13 +1,19 @@ package kr.co.pennyway.domain.domains.spending.repository; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.common.util.QueryDslUtil; import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.domain.Spending; import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -17,6 +23,7 @@ public class SpendingCustomRepositoryImpl implements SpendingCustomRepository { private final QUser user = QUser.user; private final QSpending spending = QSpending.spending; + private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory; @Override public Optional findTotalSpendingAmountByUserId(Long userId, int year, int month) { @@ -37,4 +44,21 @@ public Optional findTotalSpendingAmountByUserId(Long userId return Optional.ofNullable(result); } + + @Override + public Optional> findByYearAndMonth(Long userId, int year, int month) { + Sort sort = Sort.by(Sort.Order.desc("spendAt")); + List> orderSpecifiers = QueryDslUtil.getOrderSpecifier(sort); + + List result = queryFactory.selectFrom(spending) + .leftJoin(spending.user, user) + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .where(spending.spendAt.year().eq(year) + .and(spending.spendAt.month().eq(month))) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + + return Optional.ofNullable(result); + } + } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java index 8e7d83044..76f490766 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -45,8 +45,8 @@ public Optional readTotalSpendingAmountByUserId(Long userId } @Transactional(readOnly = true) - public List readSpendings(Predicate predicate, QueryHandler queryHandler, Sort sort) { - return spendingRepository.findList(predicate, queryHandler, sort); + public Optional> readSpendings(Long userId, int year, int month) { + return spendingRepository.findByYearAndMonth(userId, year, month); } @Transactional(readOnly = true) diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index 462eec76f..d18e61916 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -26,7 +26,7 @@ spring: open-in-view: false generate-ddl: false hibernate: - ddl-auto: none + ddl-auto: update show-sql: true properties: hibernate: @@ -57,7 +57,7 @@ spring: generate-ddl: false hibernate: ddl-auto: none - show-sql: false + show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect