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
2 changes: 2 additions & 0 deletions services/account-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ dependencies {
testImplementation "org.springframework:spring-test:${springVersion}"
testImplementation "org.springframework:spring-tx:${springVersion}"
testImplementation 'org.hamcrest:hamcrest:2.2'
testImplementation 'org.mockito:mockito-core:4.5.1'
testImplementation 'org.mockito:mockito-junit-jupiter:4.5.1'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.ntropy.account.service;

import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

/**
* local/test 환경에서 가상 금융 데이터셋 기능을 활성화하면 애플리케이션 기동 중 FIN-005
* 가상 계좌·거래를 멱등 생성한다({@link VirtualFinancialDataService#generate()}).
* user-service의 {@code VirtualUserSeedInitializer}가 먼저 USERS에 가상회원을 시딩해야
* {@link VirtualFinancialDataService#generate()}가 조회할 대상이 생긴다. 모든 일반 singleton의
* 초기화 콜백이 끝난 뒤 호출되는 {@link SmartInitializingSingleton}을 사용해 회원 시딩 이후에
* 실행하되, account-service 단독 컨텍스트에서는 user-service 빈을 강제하지 않는다.
*/
@Slf4j
@Component
public class VirtualFinancialDataSeedInitializer implements SmartInitializingSingleton {

private final VirtualFinancialDataService virtualFinancialDataService;
private final boolean virtualTestEnabled;

public VirtualFinancialDataSeedInitializer(
VirtualFinancialDataService virtualFinancialDataService,
@Value("${virtual-test.enabled:false}") boolean virtualTestEnabled
) {
this.virtualFinancialDataService = virtualFinancialDataService;
this.virtualTestEnabled = virtualTestEnabled;
}

@Override
public void afterSingletonsInstantiated() {
if (!virtualTestEnabled) {
return;
}
VirtualFinancialDataService.GenerationSummary summary = virtualFinancialDataService.generate();
log.info(
"가상 금융 데이터셋 시딩 완료: users={}, accounts={}, transactions={}",
summary.users(), summary.accounts(), summary.transactions()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.time.YearMonth;
import java.util.List;
import java.util.Locale;
import java.util.function.BiFunction;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -21,6 +22,10 @@
import com.ntropy.account.mapper.AccountMapper;
import com.ntropy.account.mapper.AccountTransactionMapper;
import com.ntropy.account.service.VirtualFinancialTransactionGenerator.GeneratedTransactions;
import com.ntropy.common.client.VirtualUserQueryClient;
import com.ntropy.common.dto.user.VirtualDatasetContext;
import com.ntropy.common.dto.user.VirtualUserDataset;
import com.ntropy.common.dto.user.VirtualUserIdentity;

import lombok.RequiredArgsConstructor;

Expand All @@ -33,7 +38,6 @@ public class VirtualFinancialDataService {
public static final int ACCOUNTS_PER_USER = 2;
public static final int EXPECTED_TRANSACTION_COUNT = 15_000;

private static final long LOGICAL_USER_ID_BASE = 9_000_046_000L;
private static final String DATASET_VERSION = "FIN-005-v1";
private static final String CURRENCY_KRW = "KRW";
private static final int TRANSACTION_BATCH_SIZE = 500;
Expand All @@ -43,33 +47,55 @@ public class VirtualFinancialDataService {
private final AccountTransactionMapper accountTransactionMapper;
private final VirtualFinancialTransactionGenerator transactionGenerator;
private final Clock clock;
private final VirtualUserQueryClient virtualUserQueryClient;

/** user-service가 시딩한 실제 가상회원 목록을 조회해 이 서비스의 생성기를 호출하는 조합 지점. */
@Transactional
public GenerationSummary generate() {
LocalDate referenceDate = LocalDate.now(clock);
VirtualUserDataset dataset = virtualUserQueryClient.findSeededVirtualUsers();
return generateForUsers(dataset.users(), dataset.context());
}

/** 실제 USERS.user_id 목록과 실행 컨텍스트를 받아 가상 금융 데이터셋을 멱등하게 생성한다. */
@Transactional
public GenerationSummary generateForUsers(List<VirtualUserIdentity> users, VirtualDatasetContext context) {
if (users == null || users.isEmpty()) {
throw new IllegalArgumentException("가상회원 목록이 필요합니다");
}
if (context == null) {
throw new IllegalArgumentException("데이터셋 실행 컨텍스트가 필요합니다");
}
LocalDate referenceDate = context.referenceDate();
int totalUsers = users.size();
int generatedAccounts = 0;
int generatedTransactions = 0;
PersonalBank[] banks = PersonalBank.values();

for (int userOrdinal = 1; userOrdinal <= USER_COUNT; userOrdinal++) {
Long userId = LOGICAL_USER_ID_BASE + userOrdinal;
PersonalBank bank = banks[(userOrdinal - 1) % banks.length];
UserGenerationResult result = generateForUser(userId, bank, userOrdinal, false, referenceDate);
for (VirtualUserIdentity identity : users) {
int userOrdinal = identity.ordinal();
PersonalBank bank = bankFor(userOrdinal, context.randomSeed(), banks);
UserGenerationResult result = generateAccountsAndTransactions(
identity.userId(), bank, userOrdinal, totalUsers, referenceDate,
(ordinaryAccount, secondaryAccount) -> transactionGenerator.generate(
referenceDate, userOrdinal, context.randomSeed(), bank,
ordinaryAccount, secondaryAccount
)
);
generatedAccounts += result.accounts();
generatedTransactions += result.transactions();
}

// 지난 2개월은 사용자당 200건 고정이고, 현재 월은 기준일까지만 생성돼 사용자당 0~100건이다.
int minimumExpected = USER_COUNT * VirtualFinancialTransactionGenerator.TRANSACTIONS_PER_USER_PER_MONTH * 2;
int maximumExpected = USER_COUNT * VirtualFinancialTransactionGenerator.TRANSACTIONS_PER_USER_PER_MONTH * 3;
int minimumExpected = totalUsers * VirtualFinancialTransactionGenerator.TRANSACTIONS_PER_USER_PER_MONTH * 2;
int maximumExpected = totalUsers * VirtualFinancialTransactionGenerator.TRANSACTIONS_PER_USER_PER_MONTH * 3;
if (generatedTransactions < minimumExpected || generatedTransactions > maximumExpected) {
throw new IllegalStateException(
"가상 거래 총 건수 불일치: expected=[" + minimumExpected + "," + maximumExpected + "]"
+ ", actual=" + generatedTransactions
);
}
return new GenerationSummary(
USER_COUNT, generatedAccounts, VirtualFinancialTransactionGenerator.INCOME_COUNTERPARTY_COUNT,
totalUsers, generatedAccounts, VirtualFinancialTransactionGenerator.INCOME_COUNTERPARTY_COUNT,
generatedTransactions,
YearMonth.from(referenceDate).minusMonths(2).atDay(1),
referenceDate
Expand All @@ -87,28 +113,32 @@ public GenerationSummary generateForUser(Long userId, PersonalBank bank) {
}
LocalDate referenceDate = LocalDate.now(clock);
int ordinal = stableUserOrdinal(userId);
UserGenerationResult result = generateForUser(userId, bank, ordinal, true, referenceDate);
UserGenerationResult result = generateAccountsAndTransactions(
userId, bank, ordinal, USER_COUNT, referenceDate,
(ordinaryAccount, secondaryAccount) -> transactionGenerator.generateForUser(
referenceDate, userId, bank, ordinaryAccount, secondaryAccount
)
);
return new GenerationSummary(
1, result.accounts(), result.incomeCounterparties(), result.transactions(),
YearMonth.from(referenceDate).minusMonths(2).atDay(1),
referenceDate
);
}

private UserGenerationResult generateForUser(Long userId, PersonalBank bank, int userOrdinal,
boolean useUserIdProfile, LocalDate referenceDate) {
private UserGenerationResult generateAccountsAndTransactions(
Long userId, PersonalBank bank, int userOrdinal, int totalUsers, LocalDate referenceDate,
BiFunction<Account, Account, GeneratedTransactions> transactionsSupplier) {
CodefConnection connection = virtualConnectionService.getOrCreateConnection(userId);
virtualConnectionService.registerInstitution(connection, bank.getOrganizationCode());

Account ordinaryAccount = saveAccount(
buildOrdinaryAccount(userOrdinal, userId, connection, bank, referenceDate)
);
Account secondaryAccount = saveAccount(
buildSecondaryAccount(userOrdinal, userId, connection, bank, referenceDate)
buildSecondaryAccount(userOrdinal, totalUsers, userId, connection, bank, referenceDate)
);
GeneratedTransactions generated = useUserIdProfile
? transactionGenerator.generateForUser(referenceDate, userId, bank, ordinaryAccount, secondaryAccount)
: transactionGenerator.generate(referenceDate, userOrdinal, bank, ordinaryAccount, secondaryAccount);
GeneratedTransactions generated = transactionsSupplier.apply(ordinaryAccount, secondaryAccount);

ordinaryAccount.setBalance(generated.finalBalances().get(ordinaryAccount.getId()));
secondaryAccount.setBalance(generated.finalBalances().get(secondaryAccount.getId()));
Expand All @@ -123,6 +153,12 @@ private UserGenerationResult generateForUser(Long userId, PersonalBank bank, int
return new UserGenerationResult(2, generated.transactions().size(), generated.userIncomeCounterpartyCount());
}

/** 은행 배정: 순번을 randomSeed만큼 순환시켜, 데이터셋 버전(랜덤시드)이 바뀌면 배정도 함께 바뀌도록 한다. */
private static PersonalBank bankFor(int userOrdinal, long randomSeed, PersonalBank[] banks) {
int index = (int) Math.floorMod((userOrdinal - 1L) + randomSeed, (long) banks.length);
return banks[index];
}

private static int stableUserOrdinal(Long userId) {
int profileCount = VirtualFinancialTransactionGenerator.ConsumerProfile.values().length;
int hash = Long.hashCode(userId);
Expand Down Expand Up @@ -155,11 +191,11 @@ private static Account buildOrdinaryAccount(int userOrdinal, Long userId,
return account;
}

private static Account buildSecondaryAccount(int userOrdinal, Long userId,
private static Account buildSecondaryAccount(int userOrdinal, int totalUsers, Long userId,
CodefConnection connection, PersonalBank bank,
LocalDate referenceDate) {
Account account = baseAccount(userOrdinal, userId, connection, bank, 2, referenceDate);
boolean installment = userOrdinal <= USER_COUNT / 2;
boolean installment = userOrdinal <= totalUsers / 2;
account.setAccountGroup(installment ? AccountGroup.DEPOSIT_TRUST : AccountGroup.LOAN);
account.setDepositTypeCode(installment ? "12" : "40");
account.setAccountName(bank.getDisplayName() + (installment ? " 가상 적금" : " 가상 대출"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,15 @@ public class VirtualFinancialTransactionGenerator {

public GeneratedTransactions generate(LocalDate referenceDate, int userOrdinal, PersonalBank bank,
Account ordinaryAccount, Account secondaryAccount) {
return generate(referenceDate, userOrdinal, 0L, bank, ordinaryAccount, secondaryAccount);
}

/** 공용 데이터셋 seed와 사용자 순번으로 소비 프로필을 결정해 거래를 생성한다. */
public GeneratedTransactions generate(LocalDate referenceDate, int userOrdinal, long randomSeed,
PersonalBank bank, Account ordinaryAccount, Account secondaryAccount) {
return generate(
referenceDate, userOrdinal, consumerProfileFor(userOrdinal), bank, ordinaryAccount, secondaryAccount
referenceDate, userOrdinal, consumerProfileFor(userOrdinal, randomSeed),
bank, ordinaryAccount, secondaryAccount
);
}

Expand All @@ -168,7 +175,7 @@ private GeneratedTransactions generate(LocalDate referenceDate, int userOrdinal,
if (referenceDate == null) {
throw new IllegalArgumentException("기준일이 필요합니다");
}
if (userOrdinal < 1 || userOrdinal > VirtualFinancialDataService.USER_COUNT) {
if (userOrdinal < 1) {
throw new IllegalArgumentException("가상 사용자 순번이 범위를 벗어났습니다: " + userOrdinal);
}

Expand Down Expand Up @@ -681,8 +688,13 @@ private static boolean isTransfer(PlannedTransaction plan) {
}

static ConsumerProfile consumerProfileFor(int userOrdinal) {
return consumerProfileFor(userOrdinal, 0L);
}

static ConsumerProfile consumerProfileFor(int userOrdinal, long randomSeed) {
ConsumerProfile[] profiles = ConsumerProfile.values();
return profiles[Math.floorMod(userOrdinal - 1, profiles.length)];
int index = (int) Math.floorMod((userOrdinal - 1L) + randomSeed, (long) profiles.length);
return profiles[index];
}

static ConsumerProfile consumerProfileForUser(Long userId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import com.ntropy.account.service.VirtualFinancialDataService;
import com.ntropy.account.service.VirtualFinancialDataService.GenerationSummary;
import com.ntropy.account.service.VirtualFinancialTransactionGenerator;
import com.ntropy.common.client.VirtualUserQueryClient;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

Expand Down Expand Up @@ -171,16 +172,27 @@ Clock clock() {
return Clock.systemDefaultZone();
}

@Bean
VirtualUserQueryClient virtualUserQueryClient() {
// 이 테스트는 generateForUser(userId, bank)만 사용하므로 실제로 호출되지 않는다.
return () -> {
throw new UnsupportedOperationException(
"이 수동 검증 테스트는 배치 시딩 경로를 사용하지 않습니다"
);
};
}

@Bean
VirtualFinancialDataService virtualFinancialDataService(
VirtualConnectionService connectionService,
AccountMapper accountMapper,
AccountTransactionMapper transactionMapper,
VirtualFinancialTransactionGenerator generator,
Clock clock
Clock clock,
VirtualUserQueryClient virtualUserQueryClient
) {
return new VirtualFinancialDataService(
connectionService, accountMapper, transactionMapper, generator, clock
connectionService, accountMapper, transactionMapper, generator, clock, virtualUserQueryClient
);
}

Expand Down
Loading
Loading