<현황>

- 다양한 도메인에서 대량의 데이터 분석이 매일 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값, 갱신주기 등) 이 더 효율적으로 진행될 수 있음을 살펴본다.

<개요>

- 최근 S3를 File,정적데이터 제공등의 목적으로 사용중인데 max-age 헤더에 대한 내용이 궁금하여 상세한 내용을 파악해 보았다. (HTTP 완벽 가이드 중 일부 내용 정리)

- HTTP 프로토콜은 통신의 많은 부분을 차지하고 있으며 OSI 7 Layer상 최상단에 위치한다.

 

- 즉, 해당 계층을 잘 활용하면 실제 사용자에게 전달되는 데이터를 컨트롤 할 수 있으며

 특히 캐시를 잘 활용하면 응답시간을 상당히 개선할 수 있다.

(다만 브라우저나 클라이언트등에서 일으키는 강제 Refresh에 대해서도 고려할 필요가 있다.)

 

<내용>

1. Cache-Control 헤어

- 클라이언트는 Cache-Control 요청헤더를 사용하여 만료제약을 조정할 수 있다.

Cache-Control: max-stale
Cache-Control: max-stale=<s>
캐시는 신선하지 않은 문서라도 자유롭게 제공할 수 있다.
<s>가 지정되면, 클라이언트는 만료시간이 <s>만큼 지난 문서도 받아들인다.
완화
Cache-Control: min-fresh=<s> 클라이언트는 지금으로부터 적어도 <s>초 후까지 신선한 문서만을 받아들인다. 엄격
Cache-Control: max-age=<s> 캐시는 <s>초보다 오랫동안 캐시된 문서를 반환할 수 없다.
나이가 유효기간을 넘어서게 되는 max-stale지시어가 함께 설정되지 않는 이상 더엄격하게 만든다.
엄격
Cache-Control: no-cache-Pragma:no-cache 이 클라이언트는 캐시된 리소스는 재검사하기 전에는 받아들이지 않을 것이다. 엄격
Cache-Control: no-store 이 캐시는 저장소에서 문서의 흔적을 최대한 빨리 삭제해야 한다.
그 문서에는 민감한 정보가 포함되어 있기 때문이다.
엄격
Cache-Control: only-if-cached 클라이언트는 캐시에 들어있는 사본만을 원한다.  

* 이는 완벽한 시스템이 아니다.

* 유효기간을 먼 미래로 설정한다면, 어떤 변경도 캐시에 반영되지 않을 것이다. 

* 유효기간을 사용조차 하지 않아서 문서가 얼마나 오래 신선할 것인지 캐시가 알기 어려운 경우도 많다.

* 이는 DNS와 같은 많은 인터넷 프로토콜에서 사용되는 "ttl"의 기법의 한 형식이다.

다행히 HTTP에는 DNS와 달리 클라이언트가 만료일을 덮어쓰고 강제로 재로딩할 수 있는 메커니즘이 있다.

 

2. 나이와 신선도 계산

- 캐시된 문서가 제공되기에 충분히 신선한지 알려면 두 가지값을 계산할 필요가 있다.

- 바로 캐시된 사본의 나이와 신선도의 수명이다.

- 충분히 신선한가?

 $나이 < $신선도 수명

 

다음 사항이 주 고려사항이다.

- 캐시는 문서응답이 어디에서 왔는지 알 수 없기 때문에 헤더를 통해서 계산해야 한다.

- 신선도 수명은 해당 문서의 나이가 신선도 수명을 넘었다면 제공하기에 충분하지 않다고 판단하는 것으로 문서의 유효기간 뿐만 아니라 영향을 주는 클라이언트의 모든 요청을 고려해야 한다. (e.g 네트워크 지연) 

 

A. 겉보기 나이는 Date헤더에 기반한다.

$겉보기_나이 = max(0, $응답을 받은 시각 - $Date_헤더값)

$문서가_캐시에_도착했을때의_나이 = $겉보기 나이

 

- 모든 컴퓨터가 똑같이 정확한 시간을 갖고 있다면 단순히 현재시간 - 문서를 보낸 시간으로 계산할 수 있다.

- 하지만 모든 시계는 동기화되지 않으며 심지어 오차가 심할 경우에는 음수가 되기도 한다. max(0, )처리가 필요한 이유

- 이러한 문제를 클럭 스큐라고 한다. 

 

B. 점층적 나이

- 그래서 우리는 이에 대한 대응방법으로 프락시나 캐시를 통과할때마다 Age헤더에 상대적인 나이를 누적해서 더하도록 한다.

- 이 방법은 서버간의 시간비교나 종단 시간비교가 필요없기 때문에 유용하다. (내부시계를 사용하여 체류시간 계산)

- 문서가 각 어플리케이션에 머무른시간과 네트워크 사이를 이동한 시간만큼 Age헤더의값을 늘려야 한다.

- 비 HTTP/1.1 장치의 경우 헤더를 고치거나 삭제하기 때문에 유의해야 하며, 따라서 Age 헤더는 상대나이에 대한 모자란 추정값의 상태로 본다.

 

$보정된_겉보기_나이 = max($겉보기_나이, $Age헤더값)

$문서가_캐시에_도착했을때의_나이 = $보정된_겉보기_나이

 

*신선한 컨텐츠를 얻는 것이 목적이기 때문에 max를 이용해서 보수적으로 계산한다.

 

C. 네트워크 지연에 대한 보상

- 트랜잭션은 느려질 수 있다. (캐시의 주된 동기)

- 매우 느린 네트워크, 과부하 서버, 트리팩등의 발생은 문서의 나이 추정에 대한 추가 계산이 필요하다.

- Date헤더는 언제 문서가 원 서버를 떠났는지 나타내주고 ( *프락시/캐시는 절대 이 헤더를 수정해서는 안된다), 캐시로 옮겨가는 중 얼만큼 시간이 걸렸는지 말해주지 않는다.

- 서버 <> 캐시 왕복지연 시간을 계산하는 것은 상대적으로 쉽다. (왜나하면 요청시각과 도착시간을 알고 있으니까)

 

$겉보기_나이 = max(0, $응답을 받은 시각 - $Date_헤더값)

$보정된_겉보기_나이 = max($겉보기_나이, $Age헤더값)

$응답_지연_추정값 = ($응답을_받은_시각 - $요청을_보낸_시각)

$문서가_캐시에_도착했을때의_나이 = $보정된_겉보기_나이 + $응답_지연_추정값

 

D. 최종 나이계산

- 이 응답이 캐시에 한번 저장되면, 나이를 더 먹게 된다.

- 그 문서의 현재 나이를 계산하기 위해서 그 문서가 캐시에 얼마나 오랫동안 머물렀는지 알아야 한다.

 

$나이 = $문서가_캐시에_도착했을때의_나이 + $사본이_얼마나_오래_우리의_캐시에_있었는지

  캐시된 문서의 나이
서버   요청네트워크지연 서버가 처리하는 시간 응답네트워크지연      
캐시 요청한_시각       응답을_받은_시각 캐시에 체류한 시간 현재_시각
클라이언트             클라이언트가_요청한_시각

 

<정리>

- HTTP에서는 문서의 나이와 신선도를 계산하여 캐시를 제공한다.

- 신선도 수명은 서버와 클라이언트의 제약조건에 의존한다.

- 인터넷의 특성상 클럭스큐와 네트워크 지연이 발생하며 문서의 나이를 계산할때 이를 고려한 방법들이 존재한다.

- 다음 글에서는 신선도를 계산하는 알고리즘에 대해서 정리한다.

+ Recent posts