Skip to content
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
8 changes: 7 additions & 1 deletion Backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions Backend/src/main/java/com/luckyseven/backend/core/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ public static ExpenseResponse toExpenseResponse(Expense expense) {
.build();
}

public static PageResponse<ExpenseResponse> toPageResponse(Page<Expense> expensePage) {
Page<ExpenseResponse> responsePage = expensePage.map(ExpenseMapper::toExpenseResponse);
return PageResponse.fromPage(responsePage);
public static PageResponse<ExpenseResponse> toPageResponse(Page<ExpenseResponse> page) {
return PageResponse.fromPage(page);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,13 +14,38 @@ public interface ExpenseRepository extends JpaRepository<Expense, Long> {
@EntityGraph(attributePaths = {"payer"})
Page<Expense> 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<ExpenseResponse> findResponsesByTeamId(Long teamId, Pageable pageable);


@Query("""
select e from Expense e
join fetch e.payer p
where e.id = :expenseId
""")
Optional<Expense> findByIdWithPayer(Long expenseId);


@Query("""
select e from Expense e
join fetch e.team t
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<ExpenseResponse> getListExpense(Long teamId, Pageable pageable) {

validateTeamExists(teamId);

Page<Expense> expensePage = expenseRepository.findByTeamId(teamId, pageable);

return ExpenseMapper.toPageResponse(expensePage);
Page<ExpenseResponse> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Object, Object> nativeCache =
caffeineCache.getNativeCache();

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