본문 바로가기
Insight/회고

[업무회고] 예약 모델 설계 & 배치로직 작성 디테일

by 싯벨트 2025. 2. 19.
728x90

예약 기능 개요

예약 모델을 구현한다고 했을 때, 일반적으로 유저와 파트너로 구성될 것입니다. 업체에 예약을 요청하고 서비스를 받는 고객 사이드, 에약을 확정하고 방문완료나 미방문 처리를 하는 파트너 사이드가 있습니다. 기능은 요청, 조회, 수정, 취소, 확정, 방문 혹은 미방문 처리가 있을 것이며, 도메인 규칙을 생각할 때는 예약의 상태값들을 잘 고려해야 합니다.

조금 더 기능을 추가하자면 방문에 대한 처리를 할 때 코멘트 남기기, 결제 및 포인트 사용에 대한 정보 연결하기, 유저가 탈퇴를 할 경우 모든 예약을 취소하기, 파트너가 방문 완료 처리를 일정 기간 동안 하지 않은 경우 자동으로 방문 완료 처리하기 등이 있을 수 있습니다.

앞서 언급했듯이 예약 도메인 규칙의 핵심은 상태값입니다. 각각 기능들이 어떤 상태일 때 가능하고, 어떤 상태일 때는 불가능한지 상태를 기준으로 정의해야 합니다. 또한, 유저와 파트너에게 보여주는 예약 목록은 어떤 기준으로 보여줄 지 생각할 때도 어떤 상태들을 묶어서 보여줘야 할 지 고민이 필요합니다.

도메인 모델

예약 도메인 다이어그램

Reservation 모델은 애그리게이트 루트입니다. 즉, 모든 변경은 루트를 통해서 이루어져야 합니다. 그래서 일대다로 연결된 결제내역를 수정하는 것도 예약 모델을 통해서 이루어져야 하며, 결제 실행과 취소라는 메서드는 이를 위해 구현되었습니다. 또한 다른 상태값으로 변경하는 로직도 예약 모델에 구현했는데, 이를 통해 코드의 응집도를 높이고, 도메인 모델의 의미를 강화했습니다.

상태값

먼저 어떤 상태값이 필요한지부터 생각해보겠습니다. 만약, 도메인의 주체인 유저의 관점에서 예약 플로우를 생각해보면 아래 6가지가 존재할 것입니다. 이러한 경우, 필요한 상태값은 요청, 확정, 취소, 방문, 미방문 5가지가 됩니다.

  1. 예약을 요청한다.
  2. 예약이 확정된다.
  3. 예약 날짜를 변경한다.
  4. 예약이 확정된다.
  5. 예약일에 방문한다.
  6. 에약일에 방문하지 않는다.

결제 내역

예약이 완료되었다는 것은 고객이 병원에 방문하여 진료를 받았거나 미방문했다는 것을 의미합니다. 정상적으로 방문을 한 경우를 생각해보면, 미리 결제한 충전금이든, 포인트든, 카드 또는 현금이든 어떤 형태로든 결제가 이루어졌을 것입니다.

그래서 결제에 대한 세부사항을 기록하는 결제 내역 모델을 만들었으며, 이는 예약이 선행되므로 구성 관계로 설정합니다. 실제 결제를 담당하는 모델은 따로 구성하여 충전 및 사용에 대한 책임을 부여하고, 예약의 결제 내역은 결제 정보를 참조하여 저장합니다.

유틸

유틸적으로 고민해야 할 기능에는 두 가지가 있습니다.

1. 회원 탈퇴한 유저들의 예약

탈퇴한 유저의 예약은 의미가 없기 때문에 삭제하면 되지 않을까 생각할 수도 있지만, 그렇게 구현할 경우 파트너 입장에서는 방금까지 예약 목록에서 보고 있던 데이터가 갑자기 사라져버리는 당황스러운 경험을 하게 됩니다. 그래서 탈퇴한 유저의 예약은 삭제하는 게 아니라 취소 처리를 하고, 파트너에게 취소에 대한 알림을 발송하고, 예약 목록에서 탈퇴한 유저의 예약임을 표기해주면 파트너가 충분히 이해할 수 있을 것입니다.

2. 완료 처리를 하지 않은 예약

파트너는 종종 방문 완료 처리를 까먹는 경우가 있을 수 있습니다. 이 경우, 배치로직을 통해 지정한 시간이 경과하면 예약 확정된 방문을 추출하여 방문 완료 처리를 할 수 있도록 구성했습니다. 이때, 여러 건을 처리하는 경우의 부하를 줄이기 위해 100건씩 끊어서 처리를 하고, 반복문을 통해 진행될 때 중간에 에러가 발생할 경우 해당 순서를 기록하여 후속처리를 할 수 있도록 구성했습니다.

상태값에 따른 목록 조회

도메인 규칙에 사용되는 경우 외에 유저와 파트너에게 제공하는 예약 리스트 조회에서도 상태값이 사용됩니다. 쿼리파람으로 상태값을 받을 때, 3가지 정도로 구현이 가능할 것 같습니다. 3번이 가장 합리적이라고 생각됩니다.

  1. 람다 핸들러에서 event의 multiValueQueryStringParameters 속성을 사용하여 필요한 값을 클라이언트가 호출하게 하는 것
  2. event의 queryStringParameters 속성에서 콤마 등의 구분자로 연결된 스트링으로 받을 상태를 연결하여 받고, 해당 스트링에 대한 처리를 하는 것
  3. event의 queryStringParameters 속성으로 예약의 상태값과 별개의 값을 받고, 이 값을 예약의 상태값과 연결하여 이것을 DB 조회 로직에 사용하는 것
    • 쿼리 파람에서 받을 상태값 - inactive
    • 실제로 매칭되는 상태값 - 취소, 미방문

배치로직 구현하기

배치로직 구성

로직은 두 단계로 구성됩니다. 1. 타겟 데이터를 찾는다. 2. 타겟 데이터의 상태값을 변경한다. 우리는 그 타겟 데이터가 몇 개인지 모릅니다. 어떤 날은 적고, 어떤 날은 많은 것입니다. 다만, 한번에 처리되는 값을 적절하게 나눌 수 있을 뿐입니다. 그래서 do…while 반복문을 활용하여 100개씩 데이터를 변경하도록 구성했습니다.

do…while 반복문은 do 블록 안의 코드를 최소 1번 실행하고, while 의 조건이 true이면 다시 반복, false이면 반복을 멈춥니다. 그래서 가져온 타겟 데이터가 100개인지 비교를 하면 100보다 작을 때 false를 반환하며 반복이 멈출 것입니다.

export class UtilService {
    constructor(@inject("DataSource") private datasource: DataSource) {}

    async setVisited(time: number, unit: string): Promise<void> {
        const limit = 100;
        let start = 0;
        let listLength: number;
        do {
            // 타겟 데이터 가져오기
            // 타겟 데이터 방문 완료 처리하기
		        
            start = start + limit;
            listLength = count;
        } while (listLength === limit);
    }
}

SAM 템플릿

해당 람다 함수는 크론식으로 설정된 시간에 스케줄러가 주기적으로 실행될 때 호출됩니다.

Resources:
  BatchSampleFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: sample-function
      CodeUri: batch/batch-sample
      Policies:
        - AWSLambdaRole
      Events:
        BatchSample:
          Type: ScheduleV2
          Properties:
            State: ENABLED
            Name: batch-sample
            ScheduleExpression: cron(0 15 * * ? *)
            ScheduleExpressionTimezone: UTC
            Input: '{"unit":"DAY", "value":-1}'
            RetryPolicy:
              MaximumRetryAttempts: 3
              MaximumEventAgeInSeconds: 300

크론식

cron 표현식은 cron(min hour day month week_day year)으로 구분됩니다. 애스터리스크(*)는 매번 실행된다는 것이고, 물음표(?)는 무관하게 실행된다는 것을 의미합니다. 위에 적힌 크론식 cron(0 15 * * ? *)은 UTC 기준 매일 오후 3시, 한국 시간 기준 매일 자정에 실행된다는 것을 의미합니다.

재시도 정책

RetryPolicy는 재시도 정책으로, 재시도 횟수와 이벤트의 최대 수명을 지정합니다. 기본으로 설정되는 값이 아니기 때문에 설정해두면 람다가 실행되지 않았을 때 자체적인 재시도를 구성할 수 있습니다.

Input

람다 이벤트에 전달할 이벤트입니다. 이렇게 인풋값을 지정해주면 기본적으로 핸들러 event가 가지고 있는 queryStringParameters, headers, body 같은 속성들은 사라지고, Input에 입력된 값이 전달됩니다.

배치로직 디테일

앞서 언급한 속성인 Input: '{"unit":"DAY", "value":-1}' 에 대한 얘기를 더 해보겠습니다. 물론 배치 로직의 시점과 대상이 정해진다면 하드코딩을 하여 배포하면 그만입니다. 그런데 테스트는 어떻게 할 수 있을까요? 상태를 변경하고 싶은 타겟이 하루가 지난 데이터가 아니라 1분 전의 데이터라면 실행이 난감해집니다. 배포를 다시하거나, DB에서 직접 배치 로직의 타겟이 될 수 있도록 하루 전으로 데이터를 변경해야 할 것입니다.

그래서 테스트를 하거나 파트너의 요청, 혹은 에러가 발생하여 로그에 찍힌 데이터부터 로직을 실행해야 하는 경우 등을 생각하여, 원하는 타겟을 자유롭게 지정할 수 있도록 유연하게 구성하는 게 좋습니다. 기본 값을 설정하되, unit과 value 입력을 변경하여 람다를 실행하여 타겟을 달리할 수 있게 하는 것이죠.