Skip to content

Commit

Permalink
Api: ✏️ 월별 지출내역 조회시 발생하는 N+1 문제 개선 (#110)
Browse files Browse the repository at this point in the history
* test: 커스텀 카테고리를 가지는 spendingfixture를 만들기위한 customcategoryfixture 작성

* test: 커스텀 카테고리를 가지는 spending 조회시 lazy loading 확인용 테스트 작성

* test: fetch 테스트를 위한 spendingfixture 추가작성

* test: 테스트용 customcategory 벌크 삽입연산 메소드 작성 및 customcategory spending 생성 메소드 변경

* test: searchspendings customcategory fetch 테스트 작성

* fix: 월별 지출내역 조회시 N+1 문제 개선을 위한 fetchjoin 사용

* test: 테스트 assertion 수정

* test: 랜덤한 커스텀 카테고리가 아닌 동일한 커스텀 카테고리 사용

* refactor: searchspendingservice 제거를 위한 spendingcustomrepository 연월별 조회 메서드 작성

* test: desc sorted 검증 추가
  • Loading branch information
asn6878 authored Jun 23, 2024
1 parent 3453d88 commit 6458798
Show file tree
Hide file tree
Showing 11 changed files with 205 additions and 60 deletions.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,7 +25,6 @@
@RequiredArgsConstructor
public class SpendingUseCase {
private final SpendingSaveService spendingSaveService;
private final SpendingSearchService spendingSearchService;
private final SpendingUpdateService spendingUpdateService;
private final SpendingService spendingService;

Expand All @@ -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<Spending> spendings = spendingSearchService.readSpendings(userId, year, month);
List<Spending> spendings = spendingService.readSpendings(userId, year, month).orElseThrow(
() -> new SpendingErrorException(SpendingErrorCode.NOT_FOUND_SPENDING)
);

return SpendingMapper.toSpendingSearchResMonth(spendings, year, month);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Spending> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<SpendingCustomCategory> 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<SpendingCustomCategory> getCustomCategories(User user, int capacity) {
List<SpendingCustomCategory> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Spending> 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);
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<TotalSpendingAmount> findTotalSpendingAmountByUserId(Long userId, int year, int month);

Optional<List<Spending>> findByYearAndMonth(Long userId, int year, int month);
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<TotalSpendingAmount> findTotalSpendingAmountByUserId(Long userId, int year, int month) {
Expand All @@ -37,4 +44,21 @@ public Optional<TotalSpendingAmount> findTotalSpendingAmountByUserId(Long userId

return Optional.ofNullable(result);
}

@Override
public Optional<List<Spending>> findByYearAndMonth(Long userId, int year, int month) {
Sort sort = Sort.by(Sort.Order.desc("spendAt"));
List<OrderSpecifier<?>> orderSpecifiers = QueryDslUtil.getOrderSpecifier(sort);

List<Spending> 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ public Optional<TotalSpendingAmount> readTotalSpendingAmountByUserId(Long userId
}

@Transactional(readOnly = true)
public List<Spending> readSpendings(Predicate predicate, QueryHandler queryHandler, Sort sort) {
return spendingRepository.findList(predicate, queryHandler, sort);
public Optional<List<Spending>> readSpendings(Long userId, int year, int month) {
return spendingRepository.findByYearAndMonth(userId, year, month);
}

@Transactional(readOnly = true)
Expand Down
Loading

0 comments on commit 6458798

Please sign in to comment.