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 8 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
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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.QSpendingCustomCategory;
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;
Expand All @@ -22,6 +23,7 @@ public class SpendingSearchService {

private final QUser user = QUser.user;
private final QSpending spending = QSpending.spending;
private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory;

/**
* 사용자의 해당 년/월 지출 내역을 조회하는 메서드
Expand All @@ -32,10 +34,13 @@ public List<Spending> readSpendings(Long userId, int year, int month) {
.and(spending.spendAt.year().eq(year))
.and(spending.spendAt.month().eq(month));

QueryHandler queryHandler = query -> query.leftJoin(user).on(spending.user.eq(user));
QueryHandler queryHandler = query -> query
.leftJoin(spending.user, user)
.leftJoin(spending.spendingCustomCategory, spendingCustomCategory)
.fetchJoin();

Sort sort = Sort.by(Sort.Order.desc("spendAt"));

return spendingService.readSpendings(predicate, queryHandler, sort);
}
}
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,79 @@
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.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;

@Slf4j
@ExtendWith(MockitoExtension.class)
@ExternalApiIntegrationTest
class SpendingSearchServiceTest extends ExternalApiDBTestConfig {
@Autowired
private SpendingSearchService spendingSearchService;
@Autowired
private UserService userService;
@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 = spendingSearchService.readSpendings(user.getId(), LocalDate.now().getYear(), LocalDate.now().getMonthValue());
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());
}
}
psychology50 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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;
import java.util.concurrent.ThreadLocalRandom;

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 = getRandomCustomCategories(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> getRandomCustomCategories(User user, int capacity) {
List<SpendingCustomCategory> customCategories = new ArrayList<>(capacity);

for (int i = 0; i < capacity; i++) {
customCategories.add(SpendingCustomCategory.of(
getRandomCustomCategoryName(),
SpendingCategory.OTHER,
user
));
}
return customCategories;
}

private static String getRandomCustomCategoryName() {
List<String> customCategoryNames = List.of("은밀한 취미", "특이한 취미", "은밀한 지출", "특이한 지출", "은밀한 비용");
return customCategoryNames.get(ThreadLocalRandom.current().nextInt(0, customCategoryNames.size()));
}

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();
}
}
Loading