우리는 전날에 고민한 프로젝트 구조를 다시 한번 회의를 하고 멀티 모듈을 사용하기로 결정
중복 객체들을 코어 모듈에 몰아넣고, 이를 활용하여 빌드 및 배포 자동화하기 위해서 사용.
우리 프로젝트에서 이러한 구조이다
common
모듈에 공통으로 사용되는 dto
,entity
,repository
를 넣어놓고 필요한 곳에서 주입받아서 쓰는 구조
settings.gradle
에서 각가 모듈들을 include 해준다
그리고 공통 .yml 파일을 생성해서 관리
이런 식으로 멀티 모듈 환경을 구성하고
스프링 배치는 크게 하나의 Job
에서 reader
,processor
,writer
로 동작하는 원리이다
먼저 필요한 데이터를 읽고 그 다음 처리하고 그 내용을 저장하는 원리이다
우리는 프로젝트에서 코인 거래에 대한 수익률을 정산하고 그 수익률에 대한 월별 랭킹을 만드는 것을 구상했다.
정산 작업은 성능이 좋으면 당연히 좋겠지만 일단, 젤 먼저 생각해야할 점은 정확성이라고 판단했다
그래서 Spring Batch를 활용한 정산 작업의 정확성을 우선으로 하는 Reader 설계와 성능 최적화 방안을 고민하는 해보았다. 이 과정에서 성능 문제를 대비하고 최적화를 고려하여 프로젝트의 안정성을 높이고자 했다.
정산 작업에서는 정확성이 무엇보다 중요하다. 데이터가 정밀하게 처리되지 않으면, 최종 계산에 오류가 발생할 수 있다. 이를 위해 유저 정보를 기준으로 관련된 지갑과 거래 내역을 모두 조인하여 데이터를 가져오는 방식으로 Reader를 설계했다. 각 유저별로 필요한 모든 데이터를 한 번에 불러옴으로써 데이터 일관성을 확보하고 정산 과정에서의 오류를 방지하고자 했다.
데이터 처리 구조에서는 각 유저별 지갑 및 거래 내역을 한 번에 조인하여 가져오는 방식을 사용했다. 이렇게 하면 한 유저와 관련된 데이터를 종합적으로 다룰 수 있어 정확한 정산 처리가 가능하다. 이 방법은 데이터가 한 곳에서 모여 처리되므로 정확성을 높이는 데 효과적이다.
초기 설계대로 진행했을 때 성능 저하 가능성도 고려해야 했다. 만약 처리 속도가 지나치게 느려지는 문제가 발생하면, 병렬 처리 구조로 개선하는 방안도 고려할 예정이다. 이를 위해서는 병렬 처리에 따른 동기화 문제, 그리고 최적의 Chunk 크기 설정 등도 함께 고민해야 한다.
성능 문제를 해결할 필요가 있다면, ThreadPoolTaskExecutor
와 같은 Spring의 병렬 처리 도구를 사용해 성능을 개선할 계획이다. 하지만 이러한 최적화는 정밀한 모니터링과 분석을 통해 이루어져야 하며, 정확성을 우선 확보한 후에 적용하는 것이 필요하다. 데이터의 정확성이 무엇보다 중요한 정산 작업에서는 성능 최적화도 조심스럽게 접근해야 한다.
@Bean
public ItemProcessor<User, Ranking> middleProcessor() {
return new ItemProcessor<User, Ranking>() {
@Override
public Ranking process(User item) throws Exception {
if (rankingRepository.existsByUserEmail(item.getEmail())) {
return null; // 중복된 경우 null 반환하여 Writer에서 무시됨
}
List<Trade> tradeList = item.getTradeList();
double otherPriceForBtc=0.0;
double otherPriceForEth=0.0;
List<Trade> otherTradeList = new ArrayList<>();
for(Trade t : tradeList){
if(t.getCrypto().getSymbol().equals("btc")&&t.getTradeFor().equals(TradeFor.OTHER)){//btc eth cryptoSymbol뭐로했냐에따라 변경해야댐
otherPriceForBtc += t.getTotalPrice()*0.1;
} else if (t.getCrypto().getSymbol().equals("eth")&&t.getTradeFor().equals(TradeFor.OTHER)) {
otherPriceForEth += t.getTotalPrice()*0.1;
}
}
처음에는 Batch config
에 processor
에서 하는 각 유저의 코인의 수익률을 계산하는 로직을 작성했다.
여기서 유저가 랭킹 테이블에 중복해서 2번씩 들어가는 문제가 발생해서 이 문제를 고치면서 구조를 리펙토링 해보았다
초기 코드 작성 후 배치 작업 중 발생한 다양한 오류들을 해결하기 위해 코드를 리팩토링했다. 첫 번째로, 지갑 기록 필터링 로직을 명확하게 개선하고, 중복 데이터 방지 로직을 추가하여 안정성을 높였다.
1) 지갑 기록 필터링 메서드 분리 및 개선
문제 상황
초기 코드에서는 특정 달의 지갑 기록을 필터링하는 로직이 불명확하여 과거 및 현재 지갑 기록이 제대로 분리되지 않는 문제가 있었다. 예를 들어, 특정 날짜 범위 내에서 가장 최근의 WalletHistory
기록을 가져와야 함에도 불구하고 정확한 조건이 설정되지 않았다.
해결 과정
두 가지 별도의 필터링 메서드 findClosestLastMonthWallet
와 findClosestThisMonthWallet
을 분리하여 각각의 날짜 범위에 맞는 최신 지갑 기록을 가져올 수 있도록 개선했다. 이를 통해 지난달과 이번 달의 최신 기록을 명확하게 구분할 수 있었다.
적용 코드
private WalletHistory findClosestLastMonthWallet(List<WalletHistory> walletHistoryList, String cryptoSymbol) {
LocalDate firstDayOfLastMonth = LocalDate.now().minusMonths(1).withDayOfMonth(1);
return walletHistoryList.stream()
.filter(w -> w.getCryptoSymbol().equals(cryptoSymbol))
.filter(w -> w.getModifiedAt().toLocalDate().isBefore(firstDayOfLastMonth))
.max(Comparator.comparing(WalletHistory::getModifiedAt))
.orElse(null);
}
private WalletHistory findClosestThisMonthWallet(List<WalletHistory> walletHistoryList, String cryptoSymbol) {
LocalDate firstDayOfCurrentMonth = LocalDate.now().withDayOfMonth(1);
return walletHistoryList.stream()
.filter(w -> w.getCryptoSymbol().equals(cryptoSymbol))
.filter(w -> w.getModifiedAt().toLocalDate().isBefore(firstDayOfCurrentMonth))
.max(Comparator.comparing(WalletHistory::getModifiedAt))
.orElse(null);
}
그리고 따로 서비스 클래스를 만들어서 코인 수익률 계산을 구현했다
@Component
public class RankingCalculationService {
// 특정 암호화폐의 수익률을 계산하여 반환하는 메서드
public double calculateYield(User user, String cryptoSymbol) throws RuntimeException {
// 다른 사용자를 위한 거래 가격을 계산
double otherPriceForCrypto = calculateOtherPrice(user.getTradeList(), cryptoSymbol);
// 한 달 전 마지막 지갑 기록과 현재 지갑 기록을 가져옴
WalletHistory lastMonthWallet = findClosestLastMonthWallet(user.getWalletHistoryList(), cryptoSymbol);
WalletHistory nowWallet = findClosestThisMonthWallet(user.getWalletHistoryList(), cryptoSymbol);
// 한 달 전 또는 현재 지갑 기록이 없는 경우 예외를 발생시킴
if (lastMonthWallet == null || nowWallet == null) {
throw new RuntimeException(String.format("한달전 %s 지갑의 기록이 없습니다.", cryptoSymbol));
}
// 수익률을 계산하여 반환
return calculateYieldPercentage(lastMonthWallet, nowWallet, otherPriceForCrypto);
}
2) 중복 방지 로직 개선
문제 상황
배치 작업이 반복 실행되면서 동일한 유저 이메일이 Ranking
테이블에 중복 저장되어 데이터의 무결성이 깨졌다. 이로 인해 동일한 유저의 데이터가 여러 번 랭킹에 포함되는 문제가 발생했다.
해결 과정
ExecutionContext
를 사용해 중복 이메일을 체크하고 이미 저장된 이메일에 대해서는 수익률 계산을 건너뛰도록 수정했다. 이를 통해 중복 데이터가 저장되지 않도록 방지했다.
적용 코드
@Component
@StepScope
public class RankingProcessor implements ItemProcessor<User, Ranking>, StepExecutionListener {
private final RankingRepository rankingRepository;
private ExecutionContext executionContext;
@Override
public Ranking process(User user) {
String userEmail = user.getEmail();
if (executionContext.containsKey(userEmail) || rankingRepository.existsByUserEmail(userEmail)) {
return null; // 중복 방지
}
// 수익률 계산 및 Ranking 생성
double btcYield = rankingCalculationService.calculateYield(user, "btc");
double ethYield = rankingCalculationService.calculateYield(user, "eth");
executionContext.put(userEmail, true);
return new Ranking(userEmail, btcYield, ethYield);
}
}
그리고 이런 과정을 구현하는데 몇가지 문제가 발생하였다
1) 지갑 기록이 없을 때 예외 발생
에러 메시지
RuntimeException: 한달전 btc 지갑의 기록이 없습니다.
문제 상황
특정 월의 지갑 기록이 누락되어 수익률 계산이 불가능한 경우 예외가 발생했다. 이는 WalletHistory
필터링이 정확하게 이루어지지 않아 적절한 데이터가 조회되지 않은 결과였다.
해결 과정
findClosestLastMonthWallet
과 findClosestThisMonthWallet
메서드를 개선하여 한 달 전과 이번 달의 지갑 기록이 명확하게 구분되도록 했다. 또한, 각 WalletHistory
가 필터링 범위 내에 있는지 여부를 콘솔에 출력하여 디버깅에 도움을 주었다.
적용 코드
public double calculateYield(User user, String cryptoSymbol) throws RuntimeException {
double otherPriceForCrypto = calculateOtherPrice(user.getTradeList(), cryptoSymbol);
WalletHistory lastMonthWallet = findClosestLastMonthWallet(user.getWalletHistoryList(), cryptoSymbol);
WalletHistory nowWallet = findClosestThisMonthWallet(user.getWalletHistoryList(), cryptoSymbol);
if (lastMonthWallet == null || nowWallet == null) {
throw new RuntimeException(String.format("한달전 %s 지갑의 기록이 없습니다.", cryptoSymbol));
}
return calculateYieldPercentage(lastMonthWallet, nowWallet, otherPriceForCrypto);
}
2) 수익률 계산 오류
문제 상황
btc
의 수익률이 음수로 계산되고 eth
의 수익률이 0으로 나오는 오류가 발생했다. 이는 WalletHistory
필터링이 정확하지 않거나 calculateOtherPrice
메서드의 금액 계산이 잘못된 경우에 발생할 수 있었다.
해결 과정
findClosestLastMonthWallet
과 findClosestThisMonthWallet
메서드가 정확한 지갑 기록을 반환하도록 개선하고 calculateOtherPrice
메서드도 다른 사용자와의 거래 금액을 반영하도록 수정했다. 이를 통해 수익률 계산이 정확하게 이루어질 수 있었다.
적용 코드
private double calculateYieldPercentage(WalletHistory lastMonthWallet, WalletHistory nowWallet, double otherPrice) {
double lastTotal = lastMonthWallet.getCash() + lastMonthWallet.getAmount() * lastMonthWallet.getCryptoPrice();
double nowTotal = nowWallet.getCash() + nowWallet.getAmount() * nowWallet.getCryptoPrice();
return ((nowTotal - lastTotal - otherPrice) / lastTotal) * 100;
}
이번 프로젝트에서는 배치 작업의 안정성과 정확성을 높이기 위해 다양한 시도를 거쳤다. 지갑 기록 필터링과 중복 데이터 방지 로직을 개선하며 배치 작업의 구조를 더욱 견고하게 만들 수 있었다. 특히, 코드 리팩토링을 통해 반복되는 문제를 해결하고 유지보수성을 높이는 방법을 체득하는 값진 경험이 되었다.
이제 각 유저의 수익률을 랭킹 repository에 저장까지는 되었으니 이제 수익률대로 랭킹 순위를 구성해야한다