diff --git a/src/main/java/woojooin/planitbatch/batch/job/BatchConfig.java b/src/main/java/woojooin/planitbatch/batch/job/BatchConfig.java index f74ce8e..61dc638 100644 --- a/src/main/java/woojooin/planitbatch/batch/job/BatchConfig.java +++ b/src/main/java/woojooin/planitbatch/batch/job/BatchConfig.java @@ -10,13 +10,16 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import lombok.RequiredArgsConstructor; + @Configuration @EnableBatchProcessing +@RequiredArgsConstructor public class BatchConfig { - @Autowired - private JobBuilderFactory jobs; - @Autowired - private StepBuilderFactory steps; + + private final JobBuilderFactory jobs; + + private final StepBuilderFactory steps; @Bean public Step sampleStep() { diff --git a/src/main/java/woojooin/planitbatch/batch/job/IsaTaxSavingJobConfig.java b/src/main/java/woojooin/planitbatch/batch/job/IsaTaxSavingJobConfig.java new file mode 100644 index 0000000..605b503 --- /dev/null +++ b/src/main/java/woojooin/planitbatch/batch/job/IsaTaxSavingJobConfig.java @@ -0,0 +1,45 @@ +package woojooin.planitbatch.batch.job; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobBuilderFactory; +import org.springframework.batch.core.configuration.annotation.StepBuilderFactory; +import org.springframework.batch.item.ItemReader; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.RequiredArgsConstructor; +import woojooin.planitbatch.batch.processor.IsaTaxSavingProcessor; +import woojooin.planitbatch.batch.reader.IsaTaxSavingReader; +import woojooin.planitbatch.batch.writer.IsaTaxSavingWriter; +import woojooin.planitbatch.domain.dto.UserProductQuarterData; +import woojooin.planitbatch.domain.vo.IsaTaxSavingHistoryVo; + +@Configuration +@RequiredArgsConstructor +public class IsaTaxSavingJobConfig { + + private final JobBuilderFactory jobBuilderFactory; + private final StepBuilderFactory stepBuilderFactory; + private final IsaTaxSavingReader reader; + private final IsaTaxSavingProcessor processor; + private final IsaTaxSavingWriter writer; + + @Bean + public Job isaTaxSavingJob(@Qualifier("isaTaxReader") ItemReader reader) { + Step isaTaxSavingStep = stepBuilderFactory.get("isaTaxSavingStep") + .chunk(100) + .reader(reader) + .processor(processor) + .writer(writer) + .build(); + + return jobBuilderFactory.get("isaTaxSavingJob") + .start(isaTaxSavingStep) + .build(); + } + + + +} \ No newline at end of file diff --git a/src/main/java/woojooin/planitbatch/batch/processor/IsaTaxSavingProcessor.java b/src/main/java/woojooin/planitbatch/batch/processor/IsaTaxSavingProcessor.java new file mode 100644 index 0000000..28ce045 --- /dev/null +++ b/src/main/java/woojooin/planitbatch/batch/processor/IsaTaxSavingProcessor.java @@ -0,0 +1,31 @@ +package woojooin.planitbatch.batch.processor; + +import java.math.BigDecimal; + +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import woojooin.planitbatch.domain.dto.UserProductQuarterData; +import woojooin.planitbatch.domain.vo.IsaTaxSavingHistoryVo; +import woojooin.planitbatch.global.util.IsaTaxCalculator; + +@Component +@RequiredArgsConstructor +@Slf4j +public class IsaTaxSavingProcessor implements ItemProcessor { + @Override + public IsaTaxSavingHistoryVo process(UserProductQuarterData item) throws Exception { + Long memberId = item.getMemberId(); + String quarter = item.getQuarter(); + // "general" 타입 고정, 필요시 변경 가능 + String userType = "general"; + + if (item.getTotalProfit() == null) { + item.setTotalProfit(BigDecimal.ZERO); + } + + return IsaTaxCalculator.calculateIsaTaxSavingHistoryVo(memberId, quarter, item.getTotalProfit(), userType); + } +} diff --git a/src/main/java/woojooin/planitbatch/batch/reader/IsaTaxSavingReader.java b/src/main/java/woojooin/planitbatch/batch/reader/IsaTaxSavingReader.java new file mode 100644 index 0000000..5e3c7bd --- /dev/null +++ b/src/main/java/woojooin/planitbatch/batch/reader/IsaTaxSavingReader.java @@ -0,0 +1,30 @@ +package woojooin.planitbatch.batch.reader; + +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.batch.MyBatisPagingItemReader; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import woojooin.planitbatch.domain.dto.UserProductQuarterData; + +@Configuration +@RequiredArgsConstructor +@Slf4j +public class IsaTaxSavingReader { + + private final SqlSessionFactory sqlSessionFactory; + + @Bean(name = "isaTaxReader") + @StepScope + public MyBatisPagingItemReader isaTaxReader() { + + MyBatisPagingItemReader reader = new MyBatisPagingItemReader<>(); + reader.setSqlSessionFactory(sqlSessionFactory); + reader.setQueryId("woojooin.planitbatch.domain.mapper.IsaTaxSavingMapper.selectIsaProductProfitByMember"); + reader.setPageSize(100); + return reader; + } +} \ No newline at end of file diff --git a/src/main/java/woojooin/planitbatch/batch/scheduler/IsaTaxSavingJobBatchScheduler.java b/src/main/java/woojooin/planitbatch/batch/scheduler/IsaTaxSavingJobBatchScheduler.java new file mode 100644 index 0000000..9de63e9 --- /dev/null +++ b/src/main/java/woojooin/planitbatch/batch/scheduler/IsaTaxSavingJobBatchScheduler.java @@ -0,0 +1,47 @@ +package woojooin.planitbatch.batch.scheduler; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class IsaTaxSavingJobBatchScheduler { + + private final JobLauncher jobLauncher; + private final JobExplorer jobExplorer; + private final Job isaTaxSavingJob; + + @Scheduled(cron = "0 13 11 * * *") // 매일 2시 20분에 실행 + public void runIsaTaxSavingJob() { + String jobName = isaTaxSavingJob.getName(); + + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) // 매 실행마다 달라지는 값 + .toJobParameters(); + + try { + // 현재 실행 중인 동일 Job이 있는지 확인 (jobName 기준) + if (!jobExplorer.findRunningJobExecutions(jobName).isEmpty()) { + log.warn("[스케줄러] Job '{}'이(가) 이미 실행 중입니다. 실행을 건너뜁니다.", jobName); + return; + } + + log.info("[스케줄러] Job '{}' 실행을 시작합니다.", jobName); + JobExecution jobExecution = jobLauncher.run(isaTaxSavingJob, jobParameters); + log.info("[스케줄러] Job '{}' 실행 상태: {}", jobName, jobExecution.getStatus()); + + } catch (Exception e) { + log.error("[스케줄러] Job '{}' 실행 중 예외가 발생했습니다.", jobName, e); + } + } +} diff --git a/src/main/java/woojooin/planitbatch/batch/writer/IsaTaxSavingWriter.java b/src/main/java/woojooin/planitbatch/batch/writer/IsaTaxSavingWriter.java new file mode 100644 index 0000000..bb05abf --- /dev/null +++ b/src/main/java/woojooin/planitbatch/batch/writer/IsaTaxSavingWriter.java @@ -0,0 +1,38 @@ +package woojooin.planitbatch.batch.writer; + +import java.util.List; + +import org.apache.ibatis.session.ExecutorType; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import woojooin.planitbatch.domain.mapper.IsaTaxSavingMapper; +import woojooin.planitbatch.domain.vo.IsaTaxSavingHistoryVo; + +@Component +@RequiredArgsConstructor +@Slf4j +public class IsaTaxSavingWriter implements ItemWriter { + + private final SqlSessionFactory sqlSessionFactory; + + @Override + public void write(List items) throws Exception { + + try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) { + IsaTaxSavingMapper mapper = sqlSession.getMapper(IsaTaxSavingMapper.class); + for (IsaTaxSavingHistoryVo item : items) { + mapper.upsertIsaTaxSavingHistory(item); + } + sqlSession.commit(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + +} diff --git a/src/main/java/woojooin/planitbatch/domain/dto/UserProductQuarterData.java b/src/main/java/woojooin/planitbatch/domain/dto/UserProductQuarterData.java new file mode 100644 index 0000000..64d0dfc --- /dev/null +++ b/src/main/java/woojooin/planitbatch/domain/dto/UserProductQuarterData.java @@ -0,0 +1,12 @@ +package woojooin.planitbatch.domain.dto; + + +import java.math.BigDecimal; +import lombok.Data; + +@Data +public class UserProductQuarterData { + private Long memberId; + private String quarter; + private BigDecimal totalProfit; +} diff --git a/src/main/java/woojooin/planitbatch/domain/mapper/IsaTaxSavingMapper.java b/src/main/java/woojooin/planitbatch/domain/mapper/IsaTaxSavingMapper.java new file mode 100644 index 0000000..8e36541 --- /dev/null +++ b/src/main/java/woojooin/planitbatch/domain/mapper/IsaTaxSavingMapper.java @@ -0,0 +1,14 @@ +package woojooin.planitbatch.domain.mapper; + +import java.util.List; + + +import woojooin.planitbatch.domain.dto.UserProductQuarterData; +import woojooin.planitbatch.domain.vo.IsaTaxSavingHistoryVo; + +public interface IsaTaxSavingMapper { + List selectIsaProductProfitByMember(); + + int upsertIsaTaxSavingHistory(IsaTaxSavingHistoryVo vo); + +} diff --git a/src/main/java/woojooin/planitbatch/domain/vo/IsaTaxSavingHistoryVo.java b/src/main/java/woojooin/planitbatch/domain/vo/IsaTaxSavingHistoryVo.java new file mode 100644 index 0000000..b9df1cb --- /dev/null +++ b/src/main/java/woojooin/planitbatch/domain/vo/IsaTaxSavingHistoryVo.java @@ -0,0 +1,20 @@ +package woojooin.planitbatch.domain.vo; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Data; + +@Data +public class IsaTaxSavingHistoryVo { + + private Long memberId; + private String quarter; // 예: "2024-Q1" + private Long isaProfit; // ISA 수익 + private Long generalTax; // 일반계좌였다면 냈을 세금 = isaProfit * 0.154 + private Long taxSaved; // 절세 금액 = generalTax - 0 = generalTax + +} + diff --git a/src/main/java/woojooin/planitbatch/global/config/DatabaseConfig.java b/src/main/java/woojooin/planitbatch/global/config/DatabaseConfig.java index c818735..9b2b7b2 100644 --- a/src/main/java/woojooin/planitbatch/global/config/DatabaseConfig.java +++ b/src/main/java/woojooin/planitbatch/global/config/DatabaseConfig.java @@ -28,124 +28,123 @@ import com.zaxxer.hikari.HikariDataSource; @Configuration -@EnableBatchProcessing @PropertySource("classpath:application.properties") -@MapperScan(basePackages = {"woojooin.planitbatch.domain.mapper", "woojooin.planitbatch.domain.product.mapper", - "woojooin.planitbatch.domain.rebalance.mapper"}) +@MapperScan(basePackages = { + "woojooin.planitbatch.domain.mapper", + "woojooin.planitbatch.domain.product.mapper", + "woojooin.planitbatch.domain.rebalance.mapper" +}) public class DatabaseConfig implements BatchConfigurer { - @Value("${jdbc.driver}") - private String driverClassName; - - @Value("${jdbc.url}") - private String url; - - @Value("${jdbc.username}") - private String username; - - @Value("${jdbc.password}") - private String password; - - @Value("${batch.jdbc.driver}") - private String batchDriverClassName; - - @Value("${batch.jdbc.url}") - private String batchUrl; - - @Value("${batch.jdbc.username}") - private String batchUsername; - - @Value("${batch.jdbc.password}") - private String batchPassword; - - @Bean - @Primary - public DataSource dataSource() { - HikariConfig config = new HikariConfig(); - config.setDriverClassName(driverClassName); - config.setJdbcUrl(url); - config.setUsername(username); - config.setPassword(password); - config.setMaximumPoolSize(10); - - config.setConnectionTimeout(20000); - config.setIdleTimeout(300000); - config.setMaxLifetime(1200000); - config.setLeakDetectionThreshold(15000); - - return new HikariDataSource(config); - } - - @Bean("batchDataSource") - public DataSource batchDataSource() { - HikariConfig config = new HikariConfig(); - config.setDriverClassName(batchDriverClassName); - config.setJdbcUrl(batchUrl); - config.setUsername(batchUsername); - config.setPassword(batchPassword); - config.setMaximumPoolSize(5); - - config.setConnectionTimeout(20000); - config.setIdleTimeout(300000); - config.setMaxLifetime(1200000); - config.setLeakDetectionThreshold(15000); - - return new HikariDataSource(config); - } - - @Bean - public SqlSessionFactory sqlSessionFactory() throws Exception { - SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean(); - - sessionFactory.setDataSource(dataSource()); - - sessionFactory.setConfigLocation(new ClassPathResource("mybatis-config.xml")); - sessionFactory.setMapperLocations( - new PathMatchingResourcePatternResolver() - .getResources("classpath:mapper/*.xml") - ); - - return sessionFactory.getObject(); - } - - @Bean - public SqlSessionTemplate sqlSessionTemplate() throws Exception { - return new SqlSessionTemplate(sqlSessionFactory()); - } - - @Bean("batchTransactionManager") - public PlatformTransactionManager batchTransactionManager() { - return new DataSourceTransactionManager(batchDataSource()); - } - - @Override - public JobRepository getJobRepository() throws Exception { - JobRepositoryFactoryBean factory = new JobRepositoryFactoryBean(); - factory.setDataSource(batchDataSource()); - factory.setTransactionManager(batchTransactionManager()); - factory.afterPropertiesSet(); - return factory.getObject(); - } - - @Override - public PlatformTransactionManager getTransactionManager() throws Exception { - return batchTransactionManager(); - } - - @Override - public JobLauncher getJobLauncher() throws Exception { - SimpleJobLauncher jobLauncher = new SimpleJobLauncher(); - jobLauncher.setJobRepository(getJobRepository()); - jobLauncher.afterPropertiesSet(); - return jobLauncher; - } - - @Override - public JobExplorer getJobExplorer() throws Exception { - JobExplorerFactoryBean jobExplorerFactoryBean = new JobExplorerFactoryBean(); - jobExplorerFactoryBean.setDataSource(batchDataSource()); - jobExplorerFactoryBean.afterPropertiesSet(); - return jobExplorerFactoryBean.getObject(); - } - -} \ No newline at end of file + @Value("${jdbc.driver}") + private String driverClassName; + + @Value("${jdbc.url}") + private String url; + + @Value("${jdbc.username}") + private String username; + + @Value("${jdbc.password}") + private String password; + + @Value("${batch.jdbc.driver}") + private String batchDriverClassName; + + @Value("${batch.jdbc.url}") + private String batchUrl; + + @Value("${batch.jdbc.username}") + private String batchUsername; + + @Value("${batch.jdbc.password}") + private String batchPassword; + + @Bean + @Primary + public DataSource dataSource() { + HikariConfig config = new HikariConfig(); + config.setDriverClassName(driverClassName); + config.setJdbcUrl(url); + config.setUsername(username); + config.setPassword(password); + config.setMaximumPoolSize(10); + + config.setConnectionTimeout(20000); + config.setIdleTimeout(300000); + config.setMaxLifetime(1200000); + config.setLeakDetectionThreshold(15000); + + return new HikariDataSource(config); + } + + @Bean("batchDataSource") + public DataSource batchDataSource() { + HikariConfig config = new HikariConfig(); + config.setDriverClassName(batchDriverClassName); + config.setJdbcUrl(batchUrl); + config.setUsername(batchUsername); + config.setPassword(batchPassword); + config.setMaximumPoolSize(5); + + config.setConnectionTimeout(20000); + config.setIdleTimeout(300000); + config.setMaxLifetime(1200000); + config.setLeakDetectionThreshold(15000); + + return new HikariDataSource(config); + } + + @Bean + public SqlSessionFactory sqlSessionFactory() throws Exception { + SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean(); + sessionFactory.setDataSource(dataSource()); + sessionFactory.setConfigLocation(new ClassPathResource("mybatis-config.xml")); + sessionFactory.setMapperLocations( + new PathMatchingResourcePatternResolver() + .getResources("classpath:mapper/*.xml") + ); + return sessionFactory.getObject(); + } + + @Bean + public SqlSessionTemplate sqlSessionTemplate() throws Exception { + return new SqlSessionTemplate(sqlSessionFactory()); + } + + @Bean("batchTransactionManager") + public PlatformTransactionManager batchTransactionManager() { + return new DataSourceTransactionManager(batchDataSource()); + } + + @Override + public JobRepository getJobRepository() throws Exception { + JobRepositoryFactoryBean factory = new JobRepositoryFactoryBean(); + factory.setDataSource(batchDataSource()); + factory.setTransactionManager(batchTransactionManager()); + factory.setIsolationLevelForCreate("ISOLATION_READ_COMMITTED"); // 격리 수준 설정 + factory.afterPropertiesSet(); + return factory.getObject(); + } + + @Override + public PlatformTransactionManager getTransactionManager() throws Exception { + return batchTransactionManager(); + } + + @Override + public JobLauncher getJobLauncher() throws Exception { + SimpleJobLauncher jobLauncher = new SimpleJobLauncher(); + jobLauncher.setJobRepository(getJobRepository()); + jobLauncher.afterPropertiesSet(); + return jobLauncher; + } + + @Override + public JobExplorer getJobExplorer() throws Exception { + JobExplorerFactoryBean jobExplorerFactoryBean = new JobExplorerFactoryBean(); + jobExplorerFactoryBean.setDataSource(batchDataSource()); + jobExplorerFactoryBean.afterPropertiesSet(); + return jobExplorerFactoryBean.getObject(); + } +} diff --git a/src/main/java/woojooin/planitbatch/global/util/DateUtils.java b/src/main/java/woojooin/planitbatch/global/util/DateUtils.java new file mode 100644 index 0000000..c113a5b --- /dev/null +++ b/src/main/java/woojooin/planitbatch/global/util/DateUtils.java @@ -0,0 +1,21 @@ +package woojooin.planitbatch.global.util; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; + +public class DateUtils { + + //분기 계산 + public static String getCurrentQuarter() { + LocalDate now = LocalDate.now(); + int quarter = (now.getMonthValue() - 1) / 3 + 1; + return now.getYear() + "-Q" + quarter; + } + + public static String getQuarter(LocalDate date) { + int quarter = (date.getMonthValue() - 1) / 3 + 1; + return date.getYear() + "-Q" + quarter; + } + +} diff --git a/src/main/java/woojooin/planitbatch/global/util/IsaTaxCalculator.java b/src/main/java/woojooin/planitbatch/global/util/IsaTaxCalculator.java new file mode 100644 index 0000000..f8947d7 --- /dev/null +++ b/src/main/java/woojooin/planitbatch/global/util/IsaTaxCalculator.java @@ -0,0 +1,53 @@ +package woojooin.planitbatch.global.util; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import woojooin.planitbatch.domain.vo.IsaTaxSavingHistoryVo; + +public class IsaTaxCalculator { + + /** + * ISA 절세 효과를 계산해서 IsaTaxSavingHistoryVo에 맞는 값을 세팅해 반환한다. + * + * @param memberId 회원 ID + * @param quarter 분기 (예: "2024-Q1") + * @param totalProfit ISA 계좌 전체 수익 + * @param userType "general" 또는 "preferential" + * @return IsaTaxSavingHistoryVo (isaProfit, generalTax, taxSaved 세팅 완료) + */ + public static IsaTaxSavingHistoryVo calculateIsaTaxSavingHistoryVo(Long memberId, String quarter, BigDecimal totalProfit, String userType) { + BigDecimal isaLimit = userType.equalsIgnoreCase("preferential") + ? new BigDecimal("4000000") + : new BigDecimal("2000000"); + + BigDecimal generalTaxRate = new BigDecimal("0.154"); // 일반 계좌 세율: 15.4% + BigDecimal isaOverLimitTaxRate = new BigDecimal("0.099"); // ISA 초과분 분리과세: 9.9% + + // 일반 계좌 기준 세금 (isaProfit * generalTaxRate) + BigDecimal taxIfGeneralAccount = totalProfit.multiply(generalTaxRate); + + BigDecimal taxFreeAmount; // ISA 한도 내 금액 + BigDecimal taxableExcessAmount = BigDecimal.ZERO; + BigDecimal actualIsaTax = BigDecimal.ZERO; + + if (totalProfit.compareTo(isaLimit) <= 0) { + taxFreeAmount = totalProfit; + } else { + taxFreeAmount = isaLimit; + taxableExcessAmount = totalProfit.subtract(isaLimit); + actualIsaTax = taxableExcessAmount.multiply(isaOverLimitTaxRate); + } + + // 절세 금액 = 일반 계좌 세금 - ISA 세금 + BigDecimal totalTaxSaved = taxIfGeneralAccount.subtract(actualIsaTax); + + IsaTaxSavingHistoryVo vo = new IsaTaxSavingHistoryVo(); + vo.setMemberId(memberId); + vo.setQuarter(quarter); + vo.setIsaProfit(totalProfit.setScale(0, RoundingMode.DOWN).longValue()); // 소수점 버림 후 Long 변환 + vo.setGeneralTax(taxIfGeneralAccount.setScale(0, RoundingMode.DOWN).longValue()); + vo.setTaxSaved(totalTaxSaved.setScale(0, RoundingMode.DOWN).longValue()); + + return vo; + } +} diff --git a/src/main/resources/mapper/IsaTaxSavingMapper.xml b/src/main/resources/mapper/IsaTaxSavingMapper.xml new file mode 100644 index 0000000..672a525 --- /dev/null +++ b/src/main/resources/mapper/IsaTaxSavingMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + + INSERT INTO isa_tax_saving_history (member_id, quarter, isa_profit, general_tax, tax_saved) + VALUES (#{memberId}, #{quarter}, #{isaProfit}, #{generalTax}, #{taxSaved}) + ON DUPLICATE KEY UPDATE isa_profit = VALUES(isa_profit), + general_tax = VALUES(general_tax), + tax_saved = VALUES(tax_saved) + + + + +