Application Design

사례로 배워보는 디자인패턴 #5 - 추상화를 통한 파티션 테이블 자동 갱신하기 (1)

멋진그이름 2024. 7. 3. 18:28

<현황>

- 다양한 도메인에서 대량의 데이터 분석이 매일 DataLake에서 이루어지고 있음

- 각 도메인별 데이터 분석결과물은 유형에 따라서 일/주/월 간격으로 생성이 됨 ( Partition by date )

- 이 결과물은 RDB에 적재되고 Application은 이를 참조하여 다시 다양한 외부 데이터 / 메타 등과 결합하고 비지니스 로직을 처리

- 최종 데이터 컨텐츠를 서비스형태로 사용자에게 제공함

 

<해결해야하는 점>

1. 각 도메인별로 생성되는 주기가 다르기 때문에 서비스에서 일괄로 최종 적재일자를 파악하기 어려움 (Latest Partition)

2. Application에서 스케쥴링을 통하여 적재일자 갱신작업을 수행하고 있으나 이때 에러가 발생할 경우 다음 스케쥴까지 기다려야 함

3. API / DB 호출은 속도가 느리기 때문에 최대한 캐시를 활용해야 하지만 1,2번과 맞물려 캐시 갱신타이밍을 잡기 어려움

4. 각 서비스 별로 Latest Partition을 관리하다 보니 코드의 복잡성이 증가하고 테스트하기도 어려움

 

<개요>

  1. 실제 쿼리를 수행하는 Repository를 멤버로 가지며 각 테이블의 파티션 일자를 관리하는 Wrapper Class 정의
  2. Wrapper Class에 대해서Abstract형태로 메소드를 추상화하고 각 테이블별로 최신파티션일자를 관리하도록함
    1. AbstractPartitionedRepository
      1. setLatestPartition / getLatestPartition: 구현체는 도메인별 테이블마다 특성을 반영하여 작성
    2. PartitionSyncService
      1. AbstractPartitionedRepository타입에 대해서 Bean주입을 받아서 List형태로 관리 하도록 함(dependency injection)
      2. 도메인별 구현없이 AbstractPartitionedRepository 타입에 대해서는 통합으로 동기화 작업 수행

 

<AS-IS>

- 각 테이블에 대한 Repository 클래스 와 최신 파티션 일자 저장/갱신 로직 을 구현하고 있는 Biz Service

- Service Layer에서 주기적으로 소유하고 있는 Repository의 메소드를 호출하여 최종 적재일자를 가져오는 형태

@Service
@Slf4j
public class BizAService {

  private MonthlyMarketShareRepository monthlyMarketShareRepository;
  private MonthlyUsageStatRepository monthlyUsageStatRepository;
  private MonthlyPointRepository monthlyPointRepository;
  private MonthlyDropoffRepository monthlyDropoffRepository;
  private MonthlyDropoffCombRepository monthlyDropoffCombRepository;
  
  private String monthlyMarketShareLatestPartition;
  private String monthlyUsageLatestPartition;
  private String monthlyPointLatestPartition;
  private String monthlyDropoffLatestPartition;
  private String monthlyDropoffCombLatestPartition;

  @Scheduled(cron = "0 */20 * * * *")
  private void setLatestPartition() {
    monthlyMarketShareLatestPartition = monthlyMarketShareRepository.findFirst...().getExecYm();
    monthlyUsageLatestPartition = monthlyUsageStatRepository.findFirst...().getExecYm();
    monthlyPointLatestPartition = monthlyPointRepository.findFirst...().getExecYm();
    monthlyDropoffLatestPartition = monthlyDropoffRepository.findFirst...().getExecYm();
    monthlyDropoffCombLatestPartition = monthlyDropoffCombRepository.findFirst...().getExecYm();
  }

- 저장되어 있는 최종 파티션 일자를 DB 조회시 조회조건으로 사용

public MarketStatResponse getMarketShareStat(String districtCode) {
    List<MonthlyMarketShare> monthlyMarketShareList = monthlyMarketShareRepository.findistrictCode, monthlyMarketShareLatestPartition);

 

<TO-BE>

1. 최종 파티션 관리 및 일자값 저장을 위한 추상화 클래스 

- getLatestPartition을 위한 Implementation은 각 도메인 테이블별로 수행

public abstract class AbstractPartitionedRepository {
    
    protected PartitionExecDt partitionExecDt;

    public abstract PartitionExecDt getLatestPartition();

    public void setLatestPartition(){
        this.partitionExecDt = getLatestPartition();
    }

    public PartitionExecDt getPartitionExecDt(){
        return this.partitionExecDt;
    }
}

 

2. 최종 파티션 일자에 대한 테이블 별 상세구현을 Repository와 함께 Wrapper로 처리하면 Service Layer에서는 더이상 파티션 조건을 신경쓰지 않아도 됨

public class MonthlyMarketShareRepositoryWrapper extends AbstractPartitionedRepository {

    MonthlyMarketShareRepository monthlyMarketShareRepository;

    @Override
    public PartitionExecDt getLatestPartition() {
        MonthlyMarketShare monthlyMarketShare = this.monthlyMarketShareRepository.findFirstByExecYmNotNullOrderByExecYmDesc();
        return PartitionExecDt.builder()
                .execDt(monthlyMarketShare.getExecYm())
                .build();
    }
	
    // Service Layer에서는 필요한 값만 전달하고 파티션조건에 대한 처리는 Wrapper에서 담당한다.
    public List<MonthlyMarketShare> findAllBySigCodeAndExecYm(String districtCode){
        return this.monthlyMarketShareRepository.findAllBySigCodeAndExecYm(districtCode, this.partitionExecDt.getExecDt());
    }

 

3. Biz Serivce에서는 Logic구현에 대해서만 처리하도록 하며, 스케쥴링을 통한 동기화 작업에 대해서는 공통 관심사로 분리하여 통합

@Service
@Slf4j
public class BizAService {

  private MonthlyMarketShareRepositoryWrapper monthlyMarketShareRepositoryWrapper;
  private MonthlyUsageStatRepositoryWrapper monthlyUsageStatRepositoryWrapper;
  private MonthlyPointRepositoryWrapper monthlyPointRepositoryWrapper;
  private MonthlyDropoffRepositoryWrapper monthlyDropoffRepositoryWrapper;
  private MonthlyDropoffCombRepositoryWrapper monthlyDropoffCombRepositoryWrapper;
public class PartitionSyncService {

    @Autowired
    List<AbstractPartitionedRepository> repositoryList;

    @Autowired
    public PartitionSyncService(List<AbstractPartitionedRepository> repositoryList){
        this.repositoryList = repositoryList;
    }

    @Scheduled(fixedDelay = 1000 * 60 * 10)
    public void setPartition(){
        for(AbstractPartitionedRepository abstractPartitionedRepository : repositoryList){
            abstractPartitionedRepository.setLatestPartition();
        }
    }

 

<테스트 코드 비교>

AS-IS : 각 서비스에 대해서 파티션 일자 세팅 및 Repository 조회조건에서도 동일하게 테스트

@BeforeEach
  public void init(){
    MockitoAnnotations.initMocks(this);
    ReflectionTestUtils.setField(bizService, "monthlyMarketShareLatestPartition", "202406");
    ...
    ...
    ...
  }

  @Test
  void testGetMarketShareStat() {
  // Repository 호출시 파티션 조건명시 필요
    given(monthlyMarketShareRepository.findAllBySigCodeAndExecYm("0000000000", "202406"))
      .willReturn(Lists.newArrayList(kickboardTestData.monthlyMarketShare));
    ...
    ...
    ...

    MarketStatResponse expected = bizTestData.getMarketStatResponse();
    MarketStatResponse actual = bizService.getMarketShareStat("0000000000");

TO-BE : given형태로 mock객체에 파티션일자 세팅, Repository 조회조건에서는 더이상 관심사가 아님.

@Test
  void testGetMarketShareStat() {
    // ServiceLayer의 관심사가 아님
    given(monthlyMarketShareRepositoryWrapper.getLatestPartition())
            .willReturn(PartitionExecDt.builder().execDt("202406").build());
    // Repository 호출시 파티션 조건 필요없음
    given(monthlyMarketShareRepositoryWrapper.findAllBySigCodeAndExecYm("0000000000"))
      .willReturn(Lists.newArrayList(kickboardTestData.monthlyMarketShare));
    ...
    ...

    MarketStatResponse expected = bizTestData.getMarketStatResponse();
    MarketStatResponse actual = bizService.getMarketShareStat("0000000000");

 

<효과>

  • Service Layer에서 적재 파티션 일자에 대한 관심사 분리 ( Repository Layer )
  • Repository가 추가될 때마다 동기화에 대한 작업을 신경쓰지 않아도 됨
  • Abstract타입 일괄호출을 통한 수동반영 가능
  • 개발 및 테스트 용이성
  • 캐시 Point 설정 용이성

 

- 해당 리팩토링을 통해서 Biz 관심사와 공통 관심사를 분리하여 코드를 보다 간결하게 만들 수 있었다.

- 다음 글에서는 이 작업을 통해서 관심사가 깔끔하게 분리되었을때 Cache에 대한 적용 (Key값, 갱신주기 등) 이 더 효율적으로 진행될 수 있음을 살펴본다.