Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Api: ✏️ 월별 지출내역 조회시 발생하는 N+1 문제 개선 #110

Merged
merged 12 commits into from
Jun 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

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]))
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
.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
Loading