diff --git a/Backend/build.gradle b/Backend/build.gradle index 62f6a1a1..e26817e1 100644 --- a/Backend/build.gradle +++ b/Backend/build.gradle @@ -28,7 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0' compileOnly 'org.projectlombok:lombok' // runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' @@ -44,6 +44,12 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' +// implementation 'org.springframework.boot:spring-boot-starter-actuator' +// implementation 'io.micrometer:micrometer-registry-prometheus' + + implementation 'com.github.ben-manes.caffeine:caffeine' + implementation 'org.springframework.boot:spring-boot-starter-cache' + } tasks.named('test') { diff --git a/Backend/src/main/java/com/luckyseven/backend/BackendApplication.java b/Backend/src/main/java/com/luckyseven/backend/BackendApplication.java index ac8cb765..7b928f89 100644 --- a/Backend/src/main/java/com/luckyseven/backend/BackendApplication.java +++ b/Backend/src/main/java/com/luckyseven/backend/BackendApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication @EnableJpaAuditing +@EnableCaching public class BackendApplication { public static void main(String[] args) { diff --git a/Backend/src/main/java/com/luckyseven/backend/core/CacheConfig.java b/Backend/src/main/java/com/luckyseven/backend/core/CacheConfig.java new file mode 100644 index 00000000..b38d8f12 --- /dev/null +++ b/Backend/src/main/java/com/luckyseven/backend/core/CacheConfig.java @@ -0,0 +1,26 @@ +package com.luckyseven.backend.core; + +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.concurrent.TimeUnit; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cm = new CaffeineCacheManager("recentExpenses"); + cm.setCaffeine( + Caffeine.newBuilder() + .maximumSize(10_000) // 최대 엔트리 수 + .expireAfterWrite(5, TimeUnit.MINUTES) // TTL + .recordStats() + ); + return cm; + } +} diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/budget/entity/Budget.java b/Backend/src/main/java/com/luckyseven/backend/domain/budget/entity/Budget.java index 14d07fdd..e4736cba 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/budget/entity/Budget.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/budget/entity/Budget.java @@ -1,8 +1,11 @@ package com.luckyseven.backend.domain.budget.entity; +import static com.luckyseven.backend.sharedkernel.exception.ExceptionCode.INSUFFICIENT_BALANCE; + import com.luckyseven.backend.domain.budget.dto.BudgetUpdateRequest; import com.luckyseven.backend.domain.team.entity.Team; import com.luckyseven.backend.sharedkernel.entity.BaseEntity; +import com.luckyseven.backend.sharedkernel.exception.CustomLogicException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -143,9 +146,27 @@ public Budget setTeam(Team team) { return this; } - public void updateBalance(BigDecimal balance) { - if (balance != null) { - this.balance = balance; + public void debitKrw(BigDecimal krwAmount) { + validateSufficientBalance(krwAmount, balance); + balance = balance.subtract(krwAmount); + } + + public void debitForeign(BigDecimal foreignAmount) { + BigDecimal krwAmount = foreignAmount.multiply(avgExchangeRate); + validateSufficientBalance(krwAmount, balance); + validateSufficientBalance(foreignAmount, foreignBalance); + balance = balance.subtract(krwAmount); + foreignBalance = foreignBalance.subtract(foreignAmount); + } + + public void creditKrw(BigDecimal krwAmount) { + balance = balance.add(krwAmount); + } + + private void validateSufficientBalance(BigDecimal amount, BigDecimal current) { + if (current.compareTo(amount) < 0) { + throw new CustomLogicException(INSUFFICIENT_BALANCE); } } + } diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/expense/mapper/ExpenseMapper.java b/Backend/src/main/java/com/luckyseven/backend/domain/expense/mapper/ExpenseMapper.java index 24d28121..5d332ce8 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/expense/mapper/ExpenseMapper.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/expense/mapper/ExpenseMapper.java @@ -61,8 +61,7 @@ public static ExpenseResponse toExpenseResponse(Expense expense) { .build(); } - public static PageResponse toPageResponse(Page expensePage) { - Page responsePage = expensePage.map(ExpenseMapper::toExpenseResponse); - return PageResponse.fromPage(responsePage); + public static PageResponse toPageResponse(Page page) { + return PageResponse.fromPage(page); } } diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/expense/repository/ExpenseRepository.java b/Backend/src/main/java/com/luckyseven/backend/domain/expense/repository/ExpenseRepository.java index b0bcbe30..f58cdd41 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/expense/repository/ExpenseRepository.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/expense/repository/ExpenseRepository.java @@ -1,5 +1,6 @@ package com.luckyseven.backend.domain.expense.repository; +import com.luckyseven.backend.domain.expense.dto.ExpenseResponse; import com.luckyseven.backend.domain.expense.entity.Expense; import java.util.Optional; import org.springframework.data.domain.Page; @@ -13,6 +14,30 @@ public interface ExpenseRepository extends JpaRepository { @EntityGraph(attributePaths = {"payer"}) Page findByTeamId(Long teamId, Pageable pageable); + // TODO: 쿼리 최적화 + @EntityGraph(attributePaths = "payer") + @Query( + value = """ + select new com.luckyseven.backend.domain.expense.dto.ExpenseResponse( + e.id, + e.description, + e.amount, + e.category, + p.id, + p.nickname, + e.createdAt, + e.updatedAt, + e.paymentMethod + ) + from Expense e + join e.payer p + where e.team.id = :teamId + """, + countQuery = "select count(e) from Expense e where e.team.id = :teamId" + ) + Page findResponsesByTeamId(Long teamId, Pageable pageable); + + @Query(""" select e from Expense e join fetch e.payer p @@ -20,6 +45,7 @@ public interface ExpenseRepository extends JpaRepository { """) Optional findByIdWithPayer(Long expenseId); + @Query(""" select e from Expense e join fetch e.team t diff --git a/Backend/src/main/java/com/luckyseven/backend/domain/expense/service/ExpenseService.java b/Backend/src/main/java/com/luckyseven/backend/domain/expense/service/ExpenseService.java index b03795b4..c477861c 100644 --- a/Backend/src/main/java/com/luckyseven/backend/domain/expense/service/ExpenseService.java +++ b/Backend/src/main/java/com/luckyseven/backend/domain/expense/service/ExpenseService.java @@ -1,7 +1,6 @@ package com.luckyseven.backend.domain.expense.service; import static com.luckyseven.backend.sharedkernel.exception.ExceptionCode.EXPENSE_NOT_FOUND; -import static com.luckyseven.backend.sharedkernel.exception.ExceptionCode.INSUFFICIENT_BALANCE; import static com.luckyseven.backend.sharedkernel.exception.ExceptionCode.TEAM_NOT_FOUND; import com.luckyseven.backend.domain.budget.entity.Budget; @@ -19,137 +18,123 @@ import com.luckyseven.backend.domain.settlements.app.SettlementService; import com.luckyseven.backend.domain.team.entity.Team; import com.luckyseven.backend.domain.team.repository.TeamRepository; +import com.luckyseven.backend.sharedkernel.cache.CacheEvictService; import com.luckyseven.backend.sharedkernel.dto.PageResponse; import com.luckyseven.backend.sharedkernel.exception.CustomLogicException; import java.math.BigDecimal; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service -@Slf4j @RequiredArgsConstructor +@CacheConfig(cacheNames = "recentExpenses") public class ExpenseService { private final ExpenseRepository expenseRepository; private final TeamRepository teamRepository; private final MemberService memberService; private final SettlementService settlementService; + private final CacheEvictService cacheEvictService; @Transactional public CreateExpenseResponse saveExpense(Long teamId, ExpenseRequest request) { - - Team team = findTeamWithBudgetOrThrow(teamId); - - Member payer = findPayerOrThrow(request.payerId()); - + Team team = findTeamOrThrow(teamId); + Member payer = memberService.findMemberOrThrow(request.payerId()); Budget budget = team.getBudget(); if (request.paymentMethod() == PaymentMethod.CASH) { - BigDecimal foreignAmount = request.amount(); - BigDecimal KRWAmount = foreignAmount.multiply(budget.getAvgExchangeRate()); - validateSufficientBudget(KRWAmount, budget.getBalance()); - validateSufficientBudget(foreignAmount, budget.getForeignBalance()); - budget.updateBalance(budget.getBalance().subtract(KRWAmount)); - budget.setForeignBalance(budget.getForeignBalance().subtract(foreignAmount)); - - } else if (request.paymentMethod() == PaymentMethod.CARD) { - validateSufficientBudget(request.amount(), budget.getBalance()); - budget.updateBalance(budget.getBalance().subtract(request.amount())); + // 외화 결제: 외화 및 원화 잔액 차감 + budget.debitForeign(request.amount()); + } else { + // 카드 결제: 원화 잔액 차감 + budget.debitKrw(request.amount()); } Expense expense = ExpenseMapper.fromExpenseRequest(request, team, payer); Expense saved = expenseRepository.save(expense); - // TODO: 낙관적 락(Lock) 적용 검토 - - createAllSettlements(request, payer, saved); - + settlementService.createAllSettlements(request, payer, saved); + evictRecentExpensesForTeam(teamId); return ExpenseMapper.toCreateExpenseResponse(saved, budget); } - @Transactional(readOnly = true) public ExpenseResponse getExpense(Long expenseId) { - Expense expense = findExpenseOrThrow(expenseId); + Expense expense = findExpenseOrThrowWithPayer(expenseId); return ExpenseMapper.toExpenseResponse(expense); } @Transactional(readOnly = true) + @Cacheable(key = "'team:' + #teamId + ':page:' + #pageable.pageNumber + ':size:' + #pageable.pageSize") public PageResponse getListExpense(Long teamId, Pageable pageable) { - validateTeamExists(teamId); - - Page expensePage = expenseRepository.findByTeamId(teamId, pageable); - - return ExpenseMapper.toPageResponse(expensePage); + Page page = expenseRepository.findResponsesByTeamId(teamId, pageable); + return ExpenseMapper.toPageResponse(page); } - @Transactional public CreateExpenseResponse updateExpense(Long expenseId, ExpenseUpdateRequest request) { - Expense expense = findExpenseWithTeamBudget(expenseId); + Expense expense = findExpenseOrThrow(expenseId); BigDecimal originalAmount = expense.getAmount(); BigDecimal newAmount = request.amount(); + BigDecimal delta = newAmount.subtract(originalAmount); Budget budget = expense.getTeam().getBudget(); - BigDecimal delta = newAmount.subtract(originalAmount); if (delta.compareTo(BigDecimal.ZERO) > 0) { - validateSufficientBudget(delta, budget.getBalance()); + // 금액 증가 시 원화 잔액 차감 + budget.debitKrw(delta); + } else if (delta.compareTo(BigDecimal.ZERO) < 0) { + // 금액 감소 시 줄어든 만큼 원화 환불 + budget.creditKrw(delta.abs()); } expense.update(request.description(), newAmount, request.category()); - budget.updateBalance(budget.getBalance().subtract(delta)); - + evictRecentExpensesForTeam(expense.getTeam().getId()); return ExpenseMapper.toCreateExpenseResponse(expense, budget); } @Transactional public ExpenseBalanceResponse deleteExpense(Long expenseId) { - Expense expense = findExpenseWithTeamBudget(expenseId); + Expense expense = findExpenseOrThrow(expenseId); + Long teamId = expense.getTeam().getId(); Budget budget = expense.getTeam().getBudget(); - budget.updateBalance(budget.getBalance().add(expense.getAmount())); + // 지출 삭제 시 원화 환불 + budget.creditKrw(expense.getAmount()); expenseRepository.delete(expense); + evictRecentExpensesForTeam(teamId); return ExpenseMapper.toExpenseBalanceResponse(budget); } - private void createAllSettlements(ExpenseRequest request, Member payer, Expense expense) { - settlementService.createAllSettlements(request, payer, expense); + private void evictRecentExpensesForTeam(Long teamId) { + cacheEvictService.evictByPrefix("recentExpenses", "team:" + teamId + ":"); } - private void validateTeamExists(Long teamId) { - if (!teamRepository.existsById(teamId)) { - throw new CustomLogicException(TEAM_NOT_FOUND); - } + private Expense findExpenseOrThrowWithPayer(Long expenseId) { + return expenseRepository.findByIdWithPayer(expenseId) + .orElseThrow(() -> new CustomLogicException(EXPENSE_NOT_FOUND)); } - private Expense findExpenseWithTeamBudget(Long expenseId) { + private Expense findExpenseOrThrow(Long expenseId) { return expenseRepository.findWithTeamAndBudgetById(expenseId) .orElseThrow(() -> new CustomLogicException(EXPENSE_NOT_FOUND)); } - private Team findTeamWithBudgetOrThrow(Long teamId) { - return teamRepository.findTeamWithBudget(teamId) - .orElseThrow(() -> new CustomLogicException(TEAM_NOT_FOUND)); - } - - private Member findPayerOrThrow(Long memberId) { - return memberService.findMemberOrThrow(memberId); + private void validateTeamExists(Long teamId) { + if (!teamRepository.existsById(teamId)) { + throw new CustomLogicException(TEAM_NOT_FOUND); + } } - public Expense findExpenseOrThrow(Long expenseId) { - return expenseRepository.findByIdWithPayer(expenseId) - .orElseThrow(() -> new CustomLogicException(EXPENSE_NOT_FOUND)); + private Team findTeamOrThrow(Long teamId) { + return teamRepository.findTeamWithBudget(teamId) + .orElseThrow(() -> new CustomLogicException(TEAM_NOT_FOUND)); } - private void validateSufficientBudget(BigDecimal amount, BigDecimal balance) { - if (balance.compareTo(amount) < 0) { - throw new CustomLogicException(INSUFFICIENT_BALANCE); - } - } } diff --git a/Backend/src/main/java/com/luckyseven/backend/sharedkernel/cache/CacheEvictService.java b/Backend/src/main/java/com/luckyseven/backend/sharedkernel/cache/CacheEvictService.java new file mode 100644 index 00000000..721358eb --- /dev/null +++ b/Backend/src/main/java/com/luckyseven/backend/sharedkernel/cache/CacheEvictService.java @@ -0,0 +1,43 @@ +package com.luckyseven.backend.sharedkernel.cache; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class CacheEvictService { + + private final CacheManager cacheManager; + + public void evictByPrefix(String cacheName, String prefix) { + Cache cache = cacheManager.getCache(cacheName); + if (cache == null) { + log.warn("캐시 '{}'를 찾을 수 없습니다. 무효화 작업을 건너뜁니다.", cacheName); + return; + } + + if (cache instanceof CaffeineCache caffeineCache) { + com.github.benmanes.caffeine.cache.Cache nativeCache = + caffeineCache.getNativeCache(); + + List keysToRemove = nativeCache.asMap().keySet().stream() + .map(Object::toString) + .filter(key -> key.startsWith(prefix)) + .toList(); + keysToRemove.forEach(nativeCache::invalidate); + + log.info("Caffeine 캐시 '{}'에서 '{}'로 시작하는 {}건 무효화 완료.", + cacheName, prefix, keysToRemove.size()); + return; + } + + cache.clear(); + log.info("Caffeine 외 캐시 구현체라 전체 clear() 호출 – 네임스페이스='{}'", cacheName); + } +} diff --git a/Backend/src/test/java/com/luckyseven/backend/domain/expense/service/ExpenseServiceTest.java b/Backend/src/test/java/com/luckyseven/backend/domain/expense/service/ExpenseServiceTest.java index 5afbf3a7..46ec9590 100644 --- a/Backend/src/test/java/com/luckyseven/backend/domain/expense/service/ExpenseServiceTest.java +++ b/Backend/src/test/java/com/luckyseven/backend/domain/expense/service/ExpenseServiceTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import com.luckyseven.backend.domain.budget.entity.Budget; +import com.luckyseven.backend.domain.budget.entity.CurrencyCode; import com.luckyseven.backend.domain.expense.dto.CreateExpenseResponse; import com.luckyseven.backend.domain.expense.dto.ExpenseBalanceResponse; import com.luckyseven.backend.domain.expense.dto.ExpenseRequest; @@ -16,6 +17,7 @@ import com.luckyseven.backend.domain.expense.entity.Expense; import com.luckyseven.backend.domain.expense.enums.ExpenseCategory; import com.luckyseven.backend.domain.expense.enums.PaymentMethod; +import com.luckyseven.backend.domain.expense.mapper.ExpenseMapper; import com.luckyseven.backend.domain.expense.repository.ExpenseRepository; import com.luckyseven.backend.domain.member.entity.Member; import com.luckyseven.backend.domain.member.service.MemberService; @@ -23,6 +25,7 @@ import com.luckyseven.backend.domain.settlements.dao.SettlementRepository; import com.luckyseven.backend.domain.team.entity.Team; import com.luckyseven.backend.domain.team.repository.TeamRepository; +import com.luckyseven.backend.sharedkernel.cache.CacheEvictService; import com.luckyseven.backend.sharedkernel.dto.PageResponse; import com.luckyseven.backend.sharedkernel.exception.CustomLogicException; import com.luckyseven.backend.sharedkernel.exception.ExceptionCode; @@ -58,6 +61,9 @@ class ExpenseServiceTest { @Mock private SettlementService settlementService; + @Mock + private CacheEvictService cacheEvictService; + @Mock private SettlementRepository settlementRepository; @@ -74,7 +80,9 @@ class ExpenseServiceTest { void setUp() { budget = Budget.builder() .balance(new BigDecimal("100000.00")) - .foreignBalance(new BigDecimal("50.00")) + .foreignBalance(new BigDecimal("100000.00")) + .foreignCurrency(CurrencyCode.USD) + .avgExchangeRate(new BigDecimal("1.00")) .build(); payer = Member.builder() .email("dldldldl@naver.com") @@ -119,7 +127,7 @@ void success() { .payerId(1L) .settlerId(List.of(10L, 20L)) .description("럭키비키즈 점심 식사") - .amount(new BigDecimal("50000.00")) + .amount(new BigDecimal("10000.00")) .category(ExpenseCategory.MEAL) .paymentMethod(PaymentMethod.CASH) .build(); @@ -425,9 +433,11 @@ class GetListExpenseTests { void success() { // given Pageable pageable = PageRequest.of(0, 10); + + // 1) Expense 엔티티 생성 List expenses = List.of( Expense.builder() - .description("럭키비키즈 미국에서 점심 식사") + .description("점심 식사") .amount(new BigDecimal("10000.00")) .category(ExpenseCategory.MEAL) .paymentMethod(PaymentMethod.CASH) @@ -435,7 +445,7 @@ void success() { .team(team) .build(), Expense.builder() - .description("럭키비키즈 미국에서 저녁 식사") + .description("저녁 식사") .amount(new BigDecimal("15000.00")) .category(ExpenseCategory.MEAL) .paymentMethod(PaymentMethod.CARD) @@ -443,23 +453,26 @@ void success() { .team(team) .build() ); - Page page = new PageImpl<>(expenses, pageable, 2); + + List responseDtos = expenses.stream() + .map(ExpenseMapper::toExpenseResponse) + .toList(); + Page page = new PageImpl<>(responseDtos, pageable, responseDtos.size()); when(teamRepository.existsById(1L)).thenReturn(true); - when(expenseRepository.findByTeamId(1L, pageable)).thenReturn(page); + when(expenseRepository.findResponsesByTeamId(1L, pageable)).thenReturn(page); - // when - PageResponse response = expenseService.getListExpense(1L, pageable); + PageResponse result = expenseService.getListExpense(1L, pageable); // then - assertThat(response.getContent()).hasSize(2); - assertThat(response.getPage()).isEqualTo(0); - assertThat(response.getSize()).isEqualTo(10); - assertThat(response.getTotalElements()).isEqualTo(2); - assertThat(response.getTotalPages()).isEqualTo(1); - - ExpenseResponse first = response.getContent().getFirst(); - assertThat(first.description()).isEqualTo("럭키비키즈 미국에서 점심 식사"); + assertThat(result.getContent()).hasSize(2); + assertThat(result.getPage()).isEqualTo(0); + assertThat(result.getSize()).isEqualTo(10); + assertThat(result.getTotalElements()).isEqualTo(2); + assertThat(result.getTotalPages()).isEqualTo(1); + + ExpenseResponse first = result.getContent().get(0); + assertThat(first.description()).isEqualTo("점심 식사"); assertThat(first.amount()).isEqualByComparingTo(new BigDecimal("10000.00")); }