스프링 배치 예, 적금 데이터 동기화 시리즈
2022.09.07 - [프로그래밍/java] - [Srping Batch] 스프링 배치를 이용하여 예,적금 데이터 동기화하기 1편
2022.09.09 - [프로그래밍/java] - [Srping Batch] 스프링 배치를 이용하여 예,적금 데이터 동기화하기 2편
2022.09.10 - [프로그래밍/java] - [Srping Batch] 스프링 배치를 이용하여 예,적금 데이터 동기화하기 3편
[현재글] 2022.09.12 - [프로그래밍/java] - [Srping Batch] 스프링 배치를 이용하여 예,적금 데이터 동기화하기 4편
안녕하세요!
오늘은 스프링 배치를 이용하여 예,적금 데이터 동기화하기 4편입니다.
이번글에서는 3편에서 예금을 동기화 했던것 처럼 적금목록을 동기화 하려고합니다.
기본적인 플로우는 3편 예금 동기화와 거의 비슷해서 따라오는데 무리가 없으실거라고 생각됩니다.
금융감독원 OPEN API 요청, 결과값부터 확인해봅시다.
http://finlife.fss.or.kr/PageLink.do?link=openapi/detail03&menuId=2000127
결과값은 다음과 같습니다.
정기예금 데이터와 마찬가지로 Options와 일대다 관계가 형성되기 때문에 2개의 entity를 작성하여 처리하였습니다.
Saving, SavingOption 2개의 Entity로 나누었는데요
각각 적금상품정보(Saving), 각 적금상품의 상세 옵션정보(Saving)으로 나누었습니다.
그럼 차례대로 확인해보록 합시다.
Saving
@Getter
@Entity
@NoArgsConstructor
@IdClass(SavingPK.class)
@Table(name = "tb_saving")
public class Saving extends BaseTimeEntity{
@Id//금융상품코드
@Column(length = 50)
private String finPrdtCd;
@Id//금융회사코드
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name ="finCoNo", columnDefinition="VARCHAR(20)", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Bank bank;
@Column//최고한도
private Long maxLimit;
@Column(columnDefinition = "varchar(2000)")//우대조건
private String spclCnd;
@Column(columnDefinition = "varchar(2000)")//만기 후 이자율
private String mtrtInt;
@Column//가입대상
private String joinMember;
@Column//가입방법
private String joinWay;
@Column//가입제한 EX) 1:제한없음, 2:서민전용, 3일부제한
private String joinDeny;
@Column//금융회사명
private String korCoNm;
@Column//금융상품명
private String finPrdtNm;
@Column//기타 유의사항
private String etcNote;
@Column//공시 제출일[YYYYMM]
private String dclsMonth;
@Column//금융회사 제출일 [YYYYMMDDHH24MI]
private String finCoSubmDay;
@Column
private String dclsStrtDay;
@Column
private String dclsEndDay;
@Column
private int enable;
}
saving entity는 다음과 같이 코드를 작성하였는데, bank entity와 일대다 관계이며 fin_co_no, fin_prdt_cd 2개의 복합키를 PK로 설정하였습니다. 그렇기 때문에 @IdClass어노테이션을 설정하였습니다.
SavingPK
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@NoArgsConstructor
@AllArgsConstructor
public class SavingPK implements Serializable {
private static final long serialVersionUID = -4052438448409315836L;
@EqualsAndHashCode.Include
private String bank; //finCoNo
@EqualsAndHashCode.Include
private String finPrdtCd;
}
복합키를 사용하기 위해선 Serializable을 상속받아 Equals와 HashCode를 구현해주어야 합니다.
일일이 구현하기 귀찮기 때문에 Lombok에서 제공하는 어노테이션을 이용하여 작성하였습니다.
다음으로 각 적금의 옵션정보를 담는 entity인 savingOption entity를 확인해봅시다.
SavingOption
@Getter
@Builder
@AllArgsConstructor
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "tb_deposit_option")
public class DepositOption extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long depositOptionNo;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumns(value = {
@JoinColumn(name = "finPrdtCd",
referencedColumnName = "finPrdtCd"),
@JoinColumn(name = "finCoNo",
referencedColumnName = "finCoNo")
}, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
private Deposit deposit;
@Column //저축 금리 [소수점 2자리]
private double intrRate2;
@Column //최고 우대금리[소수점 2자리]
private double intrRate;
@Column(length = 20) //저축 금리 유형명
private String intrRateTypeNm;
@Column(length = 10) //저축 기간[단위: 개월]
private String saveTrm;
@Column(length = 4) //저축 금리 유형
private String intrRateType;
@Column
private String dclsMonth;
}
각 API 결과값에서 필요한 항목들만 추가하여 작성하였습니다.
saving과 일대다 관계를 맺고 있기 때문에 조인 컬럼으로 finPrdtCd, finCoNo을 함께 작성해 주었습니다.
이제 API 결과값을 매핑해 줄 각각의 DTO를 작성해보도록 하겠습니다.
SavingDto
@Data
public class DepositDto {
private String finCoSubmDay;
private String dclsStrtDay;
private String dclsEndDay;
private long maxLimit;
private String etcNote;
private String joinMember;
private String joinDeny;
private String spclCnd;
private String mtrtInt;
private String joinWay;
private String finPrdtNm;
private String korCoNm;
private String finPrdtCd;
private String finCoNo;
private String dclsMonth;
private List<DepositOptionDto> options;
public Deposit toEntity(Bank bank){
return Deposit.builder()
.finPrdtCd(finPrdtCd)
.bank(bank)
.finCoSubmDay(finCoSubmDay)
.dclsStrtDay(dclsStrtDay)
.dclsEndDay(dclsEndDay)
.maxLimit(maxLimit)
.etcNote(etcNote)
.joinMember(joinMember)
.joinDeny(joinDeny)
.spclCnd(spclCnd)
.mtrtInt(mtrtInt)
.joinWay(joinWay)
.finPrdtNm(finPrdtNm)
.korCoNm(korCoNm)
.dclsMonth(dclsMonth)
.build();
}
@Getter
@Setter
public static class Result {
private List<DepositOptionDto> optionList;
private List<Baselist> baseList;
@JsonProperty("err_msg")
private String errMsg;
@JsonProperty("err_cd")
private String errCd;
@JsonProperty("now_page_no")
private String nowPageNo;
@JsonProperty("max_page_no")
private String maxPageNo;
@JsonProperty("total_count")
private String totalCount;
@JsonProperty("prdt_div")
private String prdtDiv;
}
@Getter
@Setter
public static class Baselist {
@JsonProperty("fin_co_subm_day")
private String finCoSubmDay;
@JsonProperty("dcls_strt_day")
private String dclsStrtDay;
@JsonProperty("dcls_end_day")
private String dclsEndDay;
@JsonProperty("max_limit")
private long maxLimit;
@JsonProperty("etc_note")
private String etcNote;
@JsonProperty("join_member")
private String joinMember;
@JsonProperty("join_deny")
private String joinDeny;
@JsonProperty("spcl_cnd")
private String spclCnd;
@JsonProperty("mtrt_int")
private String mtrtInt;
@JsonProperty("join_way")
private String joinWay;
@JsonProperty("fin_prdt_nm")
private String finPrdtNm;
@JsonProperty("kor_co_nm")
private String korCoNm;
@JsonProperty("fin_prdt_cd")
private String finPrdtCd;
@JsonProperty("fin_co_no")
private String finCoNo;
@JsonProperty("dcls_month")
private String dclsMonth;
public boolean isDepositOption(DepositOptionDto depositOptionDto){
if(finCoNo.equals(depositOptionDto.getFinCoNo()) && finPrdtCd.equals(depositOptionDto.getFinPrdtCd())){
return true;
}
return false;
}
}
@Data
public static class ResponseDepositApi {
private Result result;
public boolean requestSuccess(){
if(result != null && result.getErrCd().equals("000")){
return true;
}
return false;
}
public boolean isOverLastPage(){
if(requestSuccess() && Integer.parseInt(result.getMaxPageNo()) < Integer.parseInt(result.getNowPageNo())){
return true;
}
return false;
}
}
}
API 결과에 따라 매핑해주고 있으며 나머지 메서드는 API 호출에 사용할 기능들을 작성하였습니다.
DepositOptionDto
@Setter
@Getter
public class DepositOptionDto {
@JsonProperty("intr_rate2")
private double intrRate2;
@JsonProperty("intr_rate")
private double intrRate;
@JsonProperty("save_trm")
private String saveTrm;
@JsonProperty("intr_rate_type_nm")
private String intrRateTypeNm;
@JsonProperty("intr_rate_type")
private String intrRateType;
@JsonProperty("fin_prdt_cd")
private String finPrdtCd;
@JsonProperty("fin_co_no")
private String finCoNo;
@JsonProperty("dcls_month")
private String dclsMonth;
public DepositOption toEntity(Deposit deposit){
return DepositOption.builder()
.deposit(deposit)
.intrRate(intrRate)
.intrRate2(intrRate2)
.saveTrm(saveTrm)
.intrRateTypeNm(intrRateTypeNm)
.intrRateType(intrRateType)
.dclsMonth(dclsMonth).build();
}
}
각각의 Dto는 Entity로 만들어주는 toEntity 메서드를 이용하여 Entity변환을 할 수 있게 작성하였습니다.
이제 본격적으로 JOB을 작성해봅시다.
savingSyncJob
@Bean
public Job savingSyncJob(){
return jobBuilderFactory.get("savingSyncJob")
.incrementer(new RunIdIncrementer())
.start(savingInitStep()) /* 기존 적금목록들의 사용여부를 0으로 바꿔주는 Step */
.next(savingSyncStep()) /* 금융감독원 OPEN API를 이용하여 동기화하는 Step */
.build();
}
기존 bankSyncJob, depositSyncJob과 매우 유사합니다.
첫번째 Step인 savingInitStep은 DB에 등록되어 있는 saving테이블의 enable 컬럼을 0으로 바꿔주며
saving_option테이블을 truncate로 비우는 작업이 포함되어 있습니다.
savingInitStep
@Bean
public Step savingInitStep(){
return stepBuilderFactory.get("savingInitStep")
.tasklet((contribution, chunkContext) -> {
savingRepository.updateAllSavingEnable(0);
savingOptionRepository.truncateSavingOption();
return RepeatStatus.FINISHED;
}).build();
}
updateAllSavingEnable: saving테이블의 enable 컬럼을 0으로 바꿔주는 메서드
truncateSavingOption: saving_option 테이블을 truncate로 초기화 해주는 메서드
두가지 메서드는 기존 예금목록 동기화 글에서 다루는 메서드와 동일하므로 코드는 생략하도록 하겠습니다.
다음으로 savingSyncStep입니다.
savingSyncStep
@Bean
public Step savingSyncStep() {
return stepBuilderFactory.get("savingSyncStep")
.<List<SavingDto>, List<SavingDto>>chunk(1)
.reader(savingItemReader())
.writer(compositeSavingItemWriter())
.build();
}
reader에서 List<savingDto> 형태로 데이터를 읽으며 writer에 List<SavingDto>로 전달하는 Chunk기반 Step입니다.
기존 예금 동기화와 마찬가지로 Writer는 CompositeItemWriter과 JdbcBatchItemWriter를 함께 사용하여 작성할 예정입니다.
Reader부터 확인해보도록합시다.
savingItemReader
@Bean
public ItemReader<List<SavingDto>> savingItemReader() {
return new CustomSavingItemReader(webClient, modelMapper);
}
이번에도 구현체를 사용하지 않고 직접 구현하도록 작성하였습니다.
CustomSavingItemReader
public class CustomSavingItemReader implements ItemReader<List<SavingDto>> {
public CustomSavingItemReader(WebClient webClient, ModelMapper modelMapper) {
this.webClient = webClient;
this.modelMapper = modelMapper;
}
private final WebClient webClient;
private final ModelMapper modelMapper;
@Value(value = "${api.fss.host}")
private String fssHost;
@Value(value = "${api.fss.saving.path}")
private String savingPath;
@Value(value = "${api.fss.authKey}")
private String authKey;
private int currentPage = 1;
private List<String> topFinGrpNoList = new ArrayList<>(Arrays.asList("020000", "030300"));
private int currentGrpNo = 0;
@Override
public List<SavingDto> read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException {
SavingDto.ResponseSavingApi result = getSavingList(currentPage, topFinGrpNoList.get(currentGrpNo));
/* 정상 호출이 실패한 경우 break */
if (!result.requestSuccess()) {
throw new Exception("");
}
if (result.isOverLastPage() && currentGrpNo == 0) {
currentGrpNo++;
currentPage = 0;
} else if (result.isOverLastPage() && currentGrpNo == 1) {
return null;
}
/* 다음페이지로 셋팅한다. */
currentPage++;
return result.getResult().getBaseList().stream().map(savingInfo -> {
List<SavingOptionDto> savingOptionDtos = new ArrayList<>();
result.getResult().getOptionList().stream().forEach(savingOptionDto -> {
if (savingInfo.isSavingOption(savingOptionDto)) {
savingOptionDtos.add(savingOptionDto);
}
});
SavingDto savingDto = modelMapper.map(savingInfo, SavingDto.class);
savingDto.setOptions(savingOptionDtos);
return savingDto;
}).collect(Collectors.toList());
}
public SavingDto.ResponseSavingApi getSavingList(int currentPage, String topFinGrpNo) {
return webClient.get()
.uri(uriBuilder -> uriBuilder.scheme("https")
.host(fssHost)
.path(savingPath)
.queryParam("auth", authKey)
.queryParam("topFinGrpNo", topFinGrpNo)
.queryParam("pageNo", currentPage)
.build())
.retrieve()
.onStatus(HttpStatus::isError, clientResponse -> null)
.bodyToMono(SavingDto.ResponseSavingApi.class)
.flux()
.toStream()
.findFirst().orElse(null);
}
}
예금동기화와 비슷하게 각 적금 상품마다 옵션이 존재하기 때문에 옵션을 담아주는 부분이 있으며
JdbcBatchItemWriter을 이용하여 저장을 진행 합니다.
compositeSavingItemWriter
@Bean
public CompositeItemWriter compositeSavingItemWriter() {
List<ItemWriter> delegates = new ArrayList<>(2);
delegates.add(new CustomSavingJdbcItemWriter(dataSource, new JdbcBatchItemWriter()));
delegates.add(new CustomSavingOptionJdbcItemWriter(dataSource, new JdbcBatchItemWriter()));
CompositeItemWriter compositeItemWriter = new CompositeItemWriter();
compositeItemWriter.setDelegates(delegates);
return compositeItemWriter;
}
CompositeItemWriter를 사용한 이유는 읽어드린 값들이 Saving, SavingOption 두가지로 나뉘는데 이것들을 각각 writer를 이용하여 2개를 한꺼번에 저장하기 위해 사용하였습니다.
jpa가 아닌 jdbc를 이용한 것은 배치성 insert를 더욱 용이하게 사용하기 위해 채택하였습니다.
이제 Saving, SavingOption 각각을 구현한 CustomSavingJdbcItemWriter, CustomSavingOptionJdbcItemWriter를 확인해봅시다.
CustomSavingJdbcItemWriter
public class CustomSavingJdbcItemWriter implements ItemWriter<List<SavingDto>> {
private final DataSource dataSource;
private final JdbcBatchItemWriter<SavingDto> jdbcBatchItemWriter;
public CustomSavingJdbcItemWriter(DataSource dataSource, JdbcBatchItemWriter jdbcBatchItemWriter){
this.dataSource = dataSource;
this.jdbcBatchItemWriter = jdbcBatchItemWriter;
}
@Override
public void write(List<? extends List<SavingDto>> items) throws Exception {
List<SavingDto> savingDtos = items.stream().flatMap(Collection::stream).collect(Collectors.toList());
String sql = "INSERT INTO tb_saving (fin_co_no, fin_prdt_cd, dcls_end_day, dcls_month, dcls_strt_day, enable, etc_note, fin_co_subm_day, fin_prdt_nm, " +
"join_deny, join_member, join_way, kor_co_nm, max_limit, mtrt_int, spcl_cnd, created_date, last_modified_date) values " +
"(:finCoNo, :finPrdtCd, :dclsEndDay, :dclsMonth, :dclsStrtDay, " +
"1, :etcNote, :finCoSubmDay, :finPrdtNm, :joinDeny, :joinMember, :joinWay, :korCoNm, :maxLimit, :mtrtInt, :spclCnd, now(), now()) " +
"ON DUPLICATE KEY UPDATE " +
"dcls_end_day = :dclsEndDay," +
"dcls_month = :dclsMonth, " +
"dcls_strt_day = :dclsStrtDay, " +
"enable = 1, " +
"etc_note = :etcNote, " +
"fin_co_subm_day = :finCoSubmDay, " +
"fin_prdt_nm = :finPrdtNm, " +
"join_deny = :joinDeny, " +
"join_member = :joinMember, " +
"join_way = :joinWay, " +
"kor_co_nm = :korCoNm, " +
"max_limit = :maxLimit, " +
"mtrt_int = :mtrtInt, " +
"spcl_cnd = :spclCnd, " +
"last_modified_date = now()";
jdbcBatchItemWriter.setDataSource(dataSource);
jdbcBatchItemWriter.setJdbcTemplate(new NamedParameterJdbcTemplate(dataSource));
jdbcBatchItemWriter.setSql(sql);
jdbcBatchItemWriter.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider());
jdbcBatchItemWriter.afterPropertiesSet();
jdbcBatchItemWriter.write(savingDtos);
}
}
기본적으론 INSERT를 진행하지만 해당 PK가 존재하는 경우 업데이트 하는 형태로 구현하였습니다.
다음으로 CustomSavingOptionJdbcItemWriter입니다.
CustomSavingOptionJdbcItemWriter
public class CustomSavingOptionJdbcItemWriter implements ItemWriter<List<SavingDto>> {
private final DataSource dataSource;
private final JdbcBatchItemWriter jdbcBatchItemWriter;
public CustomSavingOptionJdbcItemWriter(DataSource dataSource, JdbcBatchItemWriter jdbcBatchItemWriter){
this.dataSource = dataSource;
this.jdbcBatchItemWriter = jdbcBatchItemWriter;
}
@Override
public void write(List<? extends List<SavingDto>> items) throws Exception {
List<SavingOptionDto> savingOptionDtos = items.stream().flatMap(Collection::stream).map(SavingDto::getOptions)
.flatMap(Collection::stream).collect(Collectors.toList());
String sql = "INSERT INTO tb_saving_option " +
"(fin_co_no, fin_prdt_cd, save_trm, intr_rate_type_nm, intr_rate_type, intr_rate2, intr_rate, dcls_month, rsrv_type, rsrv_type_nm, created_Date, last_modified_date)" +
" values (:finCoNo, :finPrdtCd, :saveTrm, :intrRateTypeNm, :intrRateType, :intrRate2, :intrRate, :dclsMonth, :rsrvType, :rsrvTypeNm, now(), now())";
jdbcBatchItemWriter.setDataSource(dataSource);
jdbcBatchItemWriter.setJdbcTemplate(new NamedParameterJdbcTemplate(dataSource));
jdbcBatchItemWriter.setSql(sql);
jdbcBatchItemWriter.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider());
jdbcBatchItemWriter.afterPropertiesSet();
jdbcBatchItemWriter.write(savingOptionDtos);
}
}
SavingOptionm의 경우 truncate로 테이블을 비워주기 때문에 따로 업데이트를 진행하지 않고 INSERT로만 진행합니다.
이제 적금 batch JOB을 완성하였으니 직접 실행해보도록합시다.
다음과 같이 파라미터를 넘겨주고 실행하면
동기화 되는 모습을 볼 수 있습니다.
지금까지 금융회사, 예,적금 정보를 데이터베이스에 저장하여 동기화하는 작업을 진행하였는데
앞으로는 이데이터들을 이용하여 여러분이 직접 화면단을 구성해보시거나 또는 쿼츠나 스케줄러를 이용하여 주기적으로
동기화한 데이터로 사용자에게 알람을 보내거나 하는 무궁무진한 토이프로젝트를 작성해보실 수 있을 것 같아요.
저는 이러한 데이터를 리액트와 스프링(코틀린)을 이용하여 토이프로젝트성으로 만들었습니다.
아래 사이트가 토이 프로젝트 url입니다.
참고하셔서 구현해보는것도 좋을 것 같습니다!
지금까지 여러글들을 읽어주셔서 감사합니다.
'프로그래밍 > java' 카테고리의 다른 글
[Srping] JDBC Template 정리 (1) | 2022.12.18 |
---|---|
[Srping Batch] 스프링 배치를 이용하여 예,적금 데이터 동기화하기 3편 (0) | 2022.09.10 |
[Srping Batch] 스프링 배치를 이용하여 예,적금 데이터 동기화하기 2편 (0) | 2022.09.09 |
[Srping Batch] 스프링 배치를 이용하여 예,적금 데이터 동기화하기 1편 (6) | 2022.09.07 |
[Spring Boot] JSP 사용시 다국어 적용하기 (0) | 2022.05.02 |
댓글