본문 바로가기
프로젝트 회고

혼자서 Spring Batch 기반의 정산 배치 프로그램 구축하기

by woo00oo 2024. 5. 11.

취준생 시절에는 기술 블로그를 조금씩 운영해 왔지만, 취업 후 회사 업무에 치여.. 또 개인 개발 공부에만 집중하느라 그동안 내가 어떤 부분에서 성장해 왔는지 기록이 되어 있지 않았던 거 같다. '나중에 정리해야지', '머릿속에 가지고 있으면 되지' 등의 안일한 생각들을 버리고 당장 귀찮고 힘들더라도 의미 있는 프로젝트를 진행한 내용, 기술 지식을 습득한 내용을 정리하고자 한다.

 

그중에서도 오늘은 개별적으로 진행한 프로젝트, 혼자서 3~4달간 쉬지 않고 야근을 불사 지르며 진행했던 정산 배치 프로그램 구축 경험을 기록하고자 한다.


어떤 문제점들이 존재하였나?

사내에서 현재 내가 담당하고 있는 시스템은 실시간 결제 승인 서비스이다.

고객이 가맹점에서 파는 물건을 사기 위해 결제를 하면 고객의 돈은 결제사(매입사)에서 차감을 하고 PG사는 결제사에게 매입 요청을 하여 돈을 정산받게 된다. 그 돈을 다시 가맹점에게 돌려주기 위해서는 PG사에서는 가맹점 별로 정산 데이터를 가지고 있어야 한다.

 

기존에는 정산 데이터를 생성하는 로직이 실시간 결제 시점에서 처리되었다. 즉, 고객이 결제가 완료되면 정산 데이터를 생성하고 가맹점에게 결제가 완료되었다는 웹훅을 주게 된다. 아주 심플한 구조다.

 

하지만, 위 구조는 아래와 같은 문제점들을 야기한다.

  • 결제자에게는 불필요한 시간이다.. 결제자는 돈만 차감 되면 빠르게 주문 완료 페이지로 이동되어야 한다.
  • '결제'와 '정산' 두 도메인이 과연 결합되어 하나의 시스템에서 처리 되는 것이 맞나 싶었다.
  • 가장 최악의 경우다. 결제사와 데이터 정합성이 맞지 않게 처리되는 경우다. 이 경우 수동적으로 데이터를 밀어줘야 한다. 반복적이고 아주 귀찮은 일이다.

이런 문제들을 해결하기 위해 일 배치 프로세스로 변경하기로 마음을 먹었다.

 


개발 진행에 앞서 준비한 작업들

내가 담당하고 있는 시스템들은 주로 실시간성 프로그램들이다. 웹 서버나 데몬 서버, 노티 서버 등등..

그러기에 배치 프로그램에 대한 지식이 매우 떨어진 상태였다. Spring Batch에 대해서도 기본 개념들이 부족한 상태였다. 

따라서 스프링 배치를 학습하고 현재 정산 데이터 생성 로직을 분석하는 단계가 필요로 했다.

이 외에도, 사내 배치 프로그램 표준안 파악을 위해 배치 프로그램을 주로 담당하는 타 부서의 친한 동료분을 통해서 소스 코드를 받아 분석하고 서버 접근 계정을 신청하여 현재 사내에서의 배치 프로그램들이 어떤 식으로 동작하고 있는지 파악하였다.

 

한 번도 경험해보지 못한 배치 프로그램 개발이기에 혼자서 할 수 있을까라는 두려움이 있었지만 내가 학습한 내용들을 바로 실무에 적용할 수 있는 좋은 기회라 생각하고 약 1~2달간은 퇴근 후 회사에 남아 아래와 같은 작업을 진행하였다.

 

1. Spring Batch에 대한 전반적인 기술 습득

요즘에는 기술을 배우기 참 편리한 시대인 거 같다.

인프런 강의를 통해서 스프링 배치 핵심 도메인을 손쉽게 공부할 수 있었다. 내가 들었던 강의는 정수원 님의 스프링 배치 강의이다.

강의를 들으면서 오늘 습득한 기술들을 어떻게 활용할 수 있을까?라는 질문들을 머릿속으로 많이 하였다.

 

예를 들어, '스프링 배치 반복 및 오류 제어' 내용을 학습한 후 아래와 같은 방식으로 기술들을 접목하였다.

 

1. 배치 실행 시 예외가 발생하면 Skip & Retry 기능을 활용해 제어할 수 있겠구나?

 

2. 서비스 특성 상, 예외가 발생하는 상황은 주로 운영 P의 가맹점 설정 이슈로 예외가 발생할 텐데 Retry 보다는 Skip 기능이 유효할 거 같네?

 

3. Skip이 발생하면 설정 정보가 올바른지 확인을 위해 예외 발생한 거래 아이디를 슬랙 알람으로 전송해 주면 되나?

 

4. 슬랙 알림 전송 트리거를 걸기 위해서는 SkipListener를 활용하면 되겠구나?

 

5. 예외가 발생하여도 해당 Step은 종료할 필요가 없으니 AlwaysSkipItemSkipPolicy를 사용하면 되겠구나?

 

위와 같이, 강의의 한 챕터를 수강할 때마다 내가 만들고자 하는 정산 배치 프로그램에서 최적으로 활용할 수 있는 기술들을 뽑아 생각하고 또 생각하였던 거 같다.

 

2. 도메인 지식 습득

회사를 입사하고 나서 기술 지식 50% 도메인 지식 50% 라고 많이 느끼게 되는 거 같다.

내가 만들어야하는 프로그램에 대해서 도메인 지식이 없다면 아무리 좋은 기술 지식을 가지고 있더라도 사용자가 원하는 프로그램을 만들 수 없기 때문이다..

현재 사내의 정산 데이터 생성 로직에 대해 문서화가 따로 되어 있지 않아 열심히 Legacy 코드를 분석하였다.. 시간이 오래 걸리는 작업이었다. 위 코드가 과연 필요한 건가? 하드코딩은 또 뭐지.. 친절하지 않은 주석에 의존하며 요구사항에 맞는 프로그램을 만들고자 꼼꼼하게 Legacy 코드를 분석하였다.

 


본격적인 개발 시작

약 1~2달간의 사전 작업이 완료되고 본격적으로 AWS Code commit 및 스프링 부트 프로젝트 생성 후 개발을 시작하였다.

개발 과정 중 트러블 슈팅 했던 키워드는 아래와 같다.

  • ItemReader와 select 쿼리 튜닝
  • 팩토리 메소드 패턴
  • 테스트 코드
  • JobListener, StepExecutionListener, SkipListener

1. ItemReader와 select 쿼리 튜닝

배치 프로그램을 개발하면서 기술적 문제 해결의 첫 번째 관문이었다.

대량의 데이터들을 애플리케이션으로 올려 정산 데이터를 만들어야 했다.

밤 12시 경에 crontab 설정으로 인해 배치가 실행 되기에 전일자에 거래 건들을 조회하는 select 쿼리부터 작성하였다.

 

대략적인 쿼리 검색 조건은 다음과 같다.

전일자 거래 중 정상 성공건 이며, 결제사까지 매입을 요청한 거래 건 전부 조회 

 

간단한 검색 조건이기에 select 쿼리를 작성하는데 어려움이 없었다.

하지만 대량의 데이터가 존재하는 운영 환경에서 쿼리를 실행해보니 10s 이상 소요 되는 slow 쿼리가 발생하였다.

 

explain으로 통해 금방 해결 할 수 있었고, 항상 select 쿼리 작성 후 slow 쿼리가 발생하지 않게 explain을 실행하여 인덱스가 잘 타고 있는지, 개선할 수 있는 부분이 없는지 확인 하는 습관을 가져야 한다고 생각했다.

문제는 MySQL datetime 타입 컬럼의 조건 문에 date_format 함수를 사용하여 인덱스를 타고 있지 않았다.

Job 파라미터로는 yyMMdd 형식의 문자열이 넘어오기에 의식의 흐름대로 date_format 함수를 사용 해야지! 라고 생각했던 거 같다.

 

위 이슈를 애플리케이션 단에서 해결 할 수 있었는데, Job 파라미터로 넘어 온 문자열을 LocalDateTime 타입으로 변환 후 비교하도록 하니 인덱스가 정상적으로 타게 되어 약 0.4s 소요되는 쿼리로 성능을 개선할 수 있었다

 

두번째는, ItemReader 중 어떤 구현체를 사용할지에 대한 고민이였다.

JdbcCursorItemReader, JdbcPagingItemReader, JpaCursotrItemReader, JpaPagingItemReader 등등 여러 구현체들이 존재하는데 DB I/O의 성능 문제와 메모리 자원의 효율성 문제 등을 고려할 때 서비스 특성에 잘 맞는 기술은 뭘까? 라는 고민들을 많이 하였던 거 같다.

 

이 부분은 외부 레퍼런스를 활용하여 해결할 수 있었다. 인프콘이나 스프링 캠프 또는 타 회사 기술블로그에 정리가 잘 되어 있었다.

아래 글에서 언급된 것 처럼 대량 처리 시 사용해도 좋은 JdbcCursorItemReader로 배치 프로그램을 개발 하였다.

 

참고자료 : https://tech.kakaopay.com/post/ifkakao2022-batch-performance-read/

 

외부 레퍼런스를 활용하는 능력도 필요하지만, 여러 선택지가 존재할 경우 스스로 best practice를 판별하는 능력도 키워 나가야겠다라는 생각들도 함께 공존하게 되었던 거 같다.

주니어 개발자로써 성장하고 또 성장하기 위해 외부 컨퍼런스를 적극적으로 참여하자!

 

2. 팩토리 메소드 패턴

기존 정산 데이터 생성 시, 많은 분기 문들이 존재하여 코드 가독성을 떨어트렸다.

가맹점마다 요구사항이 각기각색이기에 아래 예시 코드 같이 if, else if 그 안에 if else if 문들이 많이 존재하였다..

if (일 정산일 경우..) {
  // 일 정산에 맞는 정산 마감일을 구한다..
  
  if (워크데이가 아닌 경우..) {
  
  } else if (워크데이인 경우..) {
  
  }

} else if(주 정산 일 경우..) {
  // 주 정산에 맞는 정산 마감일을 구한다..
  
  if (워크데이가 아닌 경우..) {
  
  } else if (워크데이인 경우..) {
  
  }

} else if (월 정산 일 경우..) {
  // 월 정산에 맞는 정산 마감임을 구한다..
  
  if (워크데이가 아닌 경우..) {
  
  } else if (워크데이인 경우..) {
  
  }

}

 

위 코드를 팩토리 메소드 패턴을 통해 소스 코드 품질을 향상 시킬 수 있었다.

백기선님의 GoF 디자인 패턴 강의가 문제 해결에 많은 도움을 받을 수 있었다.

 

팩토리 메소드 패턴을 이미 여러 블로그에서 자세히 설명 되어 있기에 자세한 내용은 생략하도록 하겠다.

대략적인 코드는 아래와 같다.

 

/**
 * 정산 타입에 따른 정산 마감일을 계산하는 메소드
 *
 * @param type 정산 타입
 * ...
 */
private LocalDate calculateSettlementDeadline(
	SettlementType type ...
) {
    // 정산 타입에 맞는 정산 마감일 계산기 구현 객체를 주입 받는다.
    DeadlineCalculator deadLineCalculator = deadlineCalculatorFactory.getDeadlineCalculator(type);
    
    // 정산 마감일을 계산한다.
    return deadLineCalculator.calculateDaedline(...);

}

 

위 코드 처럼 Factory 객체가 존재하여 정산 타입을 넘겨 주면 그에 해당하는 구현체 객체를 반환해준다.

그리고 '정산 마감일을 계산하는 행위'에 대해서 메소드를 호출한다.

클라이언트 객체에서는 정산 마감일을 구하는게 목적이지 요구사항에 따라 구체적인 로직은 알 필요가 없다고 생각하였으며, '정산 마감일을 구하라' 라는 행위에 대해서 추상화를 적용하였다.

 

3.  테스트 코드

업무를 하다보면 일정이 빠듯하여 프로덕션 코드만 작성하기도 바쁜데 언제 테스트코드를 작성하고 있어? 라는 생각이 들기 쉽다. 
근무하고 있는 회사 개발 문화 또한 테스트 코드를 강조하는 문화가 아니라 테스트 코드를 중요시 하지 않는다.

하지만 나는 테스트 코드를 꼭 작성해야한다고 생각했다. 이유는 아래와 같다.

 

  • 현재도 잘 돌아가고 있지만 성능 개선, 반복되는 일을 자동화를 위해 프로젝트를 시작하였다. 그런데 내가 만든 프로그램이 오히려 장애 포인트만 생겨 난다면 과연 동료들의 호응을 얻을 수 있을까? 테스트 코드가 있어야 안전하지 않을까?
  • 수수료율에 따라 BigDecimal 객체를 활용하여 정산 대금을 계산하는 로직,  여분일 타입에 따라 LocalDateTime 객체를 활용하여 송금 예정일과 정산 확정일을 구하는 로직 등 숫자와 날짜에 민감한 로직들이 많았다. 
  • 테스트 코드가 있어야 리팩토링시 등대 역할을 할 수 있다는 걸 박우빈님의 실용적인 테스트 가이드를 통해 영감을 얻을 수 있었다.

이에 따라, 테스트 코드 작성에 더 많은 시간을 투자 하였고,  여러 테스트 케이스를 테스트 코드에 녹아 내여 숫자와 날짜에 민감한 로직들이 오처리 되는 것을 사전에 방지할 수 있었다. 


여러 경우의 상황에서 내가 작성한 코드가 정상적인 기댓값을 리턴하는지 테스트 코드를 작성하고 수행하였다.

@DisplayName("여분일 타입이 비즈니스 데이인 경우의 송금 예정일 계산한다.")
@Test
void calculateRemittanceDate() {
	// given
    LocalDate settlementDeadline ... ;            // 정산 마감일
    Integer extraDay ... ;                        // 여분일
    String merchantId ... ; 			   // 가맹점 아이디
    LocalDate expected ... ;			   // 기댓값
    
    // when
    LocalDate result = calculator.calculateRemittanceDate(...);
    
    // then
    assertThat(result).isEqualTo(expected);

}

 

 

4. JobListener, StepExecutionListener, SkipListener

가맹점에게 정산 나가는 데이터는 중요하기에 한 건이라도 누락 된다면 정산 팀 업무에 혼선을 줄 수 있다.

이에 따라 정산 배치가 정상적으로 수행 되었는지 여부를 슬랙 알림을 받을 수 있도록 구현 하였다. 

이 또한 Spring Batch의 기본적인 지식을 학습하였기에 문제 상황을 손 쉽게 해결할 수 있었다고 생각한다.

정산 배치 수행 알림
정산 배치 수행 알림

 

대략적인 코드는 아래와 같다.

{
   // ms 단위의 실행 시간을 분 초로 계산하여 문자열로 반환
   long executionTime = jobExecution.getEndTime.getTime() - jobExecution.getStartTime().getTime(); 
   long minutes = (executionTime / (1000 * 60)) % 60;
   long seconds = (executionTime / 1000) % 60;
   실행 시간 : minutes + "분" + seconds + "초";
   
   // 성공 건수
   int successItemCount = jobExecution.getStepExecutions().stream()
           .mapToInt(StepExecution::getWriteCount)
           .sum();
           
    // 기처리건수
    // SkipListenr, StepExecutionListner를 구현한 커스텀 Listner 클래스에서 취합
    // StepExecution의 ExecutionContext에 DateIntegrityViolationException이 발생한 Item 갯수 Put
    int constraintViolationCount = jobExecution.getExecutionContext().getInt("constraintViolationCount");
           
    // 실패 건수
    int failItemCount = jobExecution.getStepExecutions().stream()
           .mapToInt(StepExecution::getSkipCount)
           .sum() - constraintViolationCount ;

}

개선 사항들

  • '결제'와 '정산'도메인의 철저한 분리
    • 두 도메인이 분리되어 문제 발생 시에도 영향도 최소화.
      ex) 이전에 정산 설정 정보를 담고 있는 테이블이 일괄 업데이트 되는 사고가 발생 하였다. 만약 배치 프로그램이 도입 전이라면 전 거래건에 대해서 데이터 보정이 필요한데 생각만 해도 끔찍하다.
  • 실시간 결제 승인 비즈니스 로직 단축 및 코드 품질 개선
    • 실시간 승인 시점 처리 되었던 약 10,000 라인 이상의 코드 제거, 8회의 DB 커넥션 사용 제거
    • 도메인 모델과 시퀀스 다이어그램 UML 문서화 (팀 컨플루언스 정리) 내가 작성한 코드도 레거시가 될터이니..
    • 약 34개의 테스트 코드 작성
  • 반복적인 단순 업무 제거 및 자동화
    • 하루 평균 약 정산 누락 5~10 건 -> 정산 누락 0건
    • 정산 누락이 발생하더라도, 배치 프로그램 재 실행하여 보정 처리

마치며

처음으로 Spring Batch를 활용해서 정산 배치 프로그램을 실무 환경에 적용 시키기까지 수 많은 노력들이 필요로 했다.

정산 데이터는 민감하기에 1원이라도 오차가 생기면 안된다. 운영 환경에 도입하기 전까지 테스트 테이블을 따로 만들어 배치 프로그램에서 생성된 정산 데이터가 기존 정산 데이터와 값이 일치한지 며칠간 모니터링 하였고, 해외 및 국내 결제 수단에 따라 수수료율이 잘 계산되는지 $0.1의 오차도 없는지 면밀히 검토 하였다.

 

그렇게 약 일주일간 운영환경에서 테스트 테이블로 운영 테스트를 진행한 후, 실시간 정산 데이터 생성 로직을 모두 제거하였다.
그리고 밤 열두시 배치가 실행한 후 슬랙 알림이 오기 전까지 기다렸다. 결과는 COMPLETED! 아주 뿌듯하였고 정산팀에서도 문제 없이 업무를 처리하실 수 있었다.

 

성공적인 프로젝트 성과로 인해 동료들의 단순 업무를 줄일 수 있었고, 코드 품질까지 개선할 수 있었다.

앞으로도 반복적인 업무를 자동화하고 비지니스 창출에 기여하는 개발자가 되기 위해 꾸준히 나아갈 것이다.