AWS 기술 블로그

AWS Instance Scheduler로 공휴일에 유휴 자원 관리하기

배경

AWS 솔루션의 Instance SchedulerAmazon Elastic Compute Cloud(Amazon EC2) 및 Amazon Relational Database Service(Amazon RDS) 인스턴스를 비롯한 다양한 AWS 서비스의 자동 시작 및 중지 기능을 제공합니다. 이 솔루션을 통해 불필요한 리소스 사용을 줄이고 필요한 시점에 리소스를 효율적으로 활용하여 운영 비용을 절감하는 데 도움이 됩니다. 예를 들어, 회사에서 인스턴스 스케줄러를 사용하여 매일 업무 시간 외에 인스턴스를 자동으로 중지 할 수 있습니다. 모든 인스턴스를 최대 사용률로 실행한 상태로 두면 이 솔루션은 정규 업무 시간(주간 사용률이 168시간에서 50시간으로 단축됨)에만 필요한 인스턴스에 대해 최대 70%의 비용 절감 효과를 얻을 수 있습니다.

그러나, AWS Instance Scheduler는 기본적으로 요일, 월, 시간 기반의 스케줄링만 지원합니다. 이에 따라 공휴일을 자동으로 인식하거나 처리하는 기능이 없어 대한민국 공휴일과 같은 지역 특수성을 반영하기 어렵습니다. 현재는 매년/매월 공휴일을 수동으로 확인하고 사용자가 설정할 수 있으나, 인적 오류의 발생 가능성이 있고 추가적인 운영 리소스의 소비로 이어집니다.

본 블로그에서는 AWS 솔루션의 인스턴스 스케줄러를 기반으로 한 새로운 접근 방식을 소개합니다. 인스턴스 스케줄러의 기존 기능에 대한민국 *공공데이터포털로부터 조회한 매월 공휴일 시작과 종료 정보를 동적으로 반영하는 유연한 아키텍처 디자인을 더하여 이러한 문제를 해결하고자 합니다.

* 대한민국 공공데이터포털은 공공기관이 생성 또는 취득하여 관리하고 있는 공공데이터를 한 곳에서 제공하는 통합 창구입니다.

시나리오

  1. A회사에서 소규모 사내 업무용 시스템 및 개발환경의 경우, 현재 인스턴스 스케줄러가 주말 운영은 자동으로 최적화하나, 공휴일에는 평일 일정으로 운영되어 불필요한 자원이 낭비되고 있습니다. 이는 연간 운영비용 증가로 직결되며, 리소스 활용 효율성 측면에서 개선이 필요한 상황입니다.
  2. K씨는 현재 공휴일 일정을 Instance Scheduler에 사용자가 수동으로 입력해야 하는 비효율적인 프로세스가 존재함을 인식하고 있습니다. 수동 작업으로 인한 인적 오류 발생 가능성이 상시 존재하며, 이는 서비스 중단이나 불필요한 비용 발생으로 이어질 수 있는 잠재적 리스크 요인입니다. 또한 운영팀의 불필요한 공수 소요로 인해 핵심 업무에 대한 집중도 저하가 발생할 수 있는 점에 대해 개선하고 싶습니다.

솔루션 아키텍처 및 개요

본 포스팅에서는 AWS Instance Scheduler의 한계점을 극복하고 한국의 공휴일을 자동으로 관리하는 솔루션 아키텍처를 소개합니다.

시작하기에 앞서 AWS의 Instance Scheduler에서 사용되는 주요 용어들을 살펴보겠습니다:

  • Schedule: 인스턴스의 시작/중지 일정을 정의하는 기본 단위입니다. 하나 이상의 Period를 포함할 수 있습니다.
  • Period: Schedule 내에서 특정 시간대의 실행 규칙을 정의하는 요소입니다. 예를 들어, 평일용 Period와 공휴일용 Period를 별도로 설정할 수 있습니다.
  • monthdays: Period 내에서 해당 규칙이 적용될 날짜를 지정하는 속성입니다. 예를 들어, 공휴일이 1일과 15일인 경우 monthdays는 [1, 15]로 설정됩니다.
  • begintime/endtime: Period 내에서 인스턴스가 실행될 시작 시간과 중지 시간을 정의합니다.

이러한 구성 요소들을 활용하여 본 솔루션은 공휴일에 대한 자동화된 스케줄 관리를 구현합니다.

이 솔루션의 전체적인 아키텍처는 Instance Scheduler가 동작하는 계정에서 공휴일 관리를 위한 AWS Lambda 함수들이 구성되며 공휴일 정보 수집이 이루어집니다. Instance Scheduler는 Amazon DynamoDB의 ConfigTable을 참조하여 스케줄과 Period 정보를 관리하며, 이를 기반으로 인스턴스의 시작/중지를 제어할 수 있는 기반 데이터가 됩니다.

매월 1일 00:00(KST)에 Amazon EventBridge Rule이 MonthlyHolidayCheck Lambda 함수를 트리거하여 공공데이터포털 API를 통해 해당 월의 공휴일 정보를 조회합니다. 조회된 공휴일 정보를 기반으로 DynamoDB의 공휴일 Period를 업데이트하고, 각 공휴일에 대한 EventBridge 규칙을 생성합니다. 이 규칙들은 공휴일 당일 00:00와 23:59에 UpdateScheduleForHoliday Lambda 함수를 트리거하도록 설정됩니다.

UpdateScheduleForHoliday Lambda 함수는 공휴일 시작 시점(00:00)에 스케줄의 평일 Period를 공휴일 Period로 교체하고, 종료 시점(23:59)에 다시 평일 Period로 복구합니다. 이러한 Period 교체 방식을 통해 하나의 스케줄에 여러 Period가 존재할 때 발생할 수 있는 충돌을 방지하고, 공휴일과 평일의 스케줄을 명확하게 구분하여 관리할 수 있습니다.

모든 Lambda 함수와 EventBridge는 IAM Role을 통해 필요한 권한을 부여받으며, Lambda함수의 실행기록들은 CloudWatch Logs를 통해 로그를 기록하고 모니터링됩니다.

이 솔루션의 핵심 워크플로우는 다음과 같습니다:

  1. 매월 1일 00:00(KST) 기준으로 공공데이터포털 API를 통해 해당 월의 공휴일 정보를 자동으로 조회합니다.
  2. 공휴일 존재 여부에 따라 다음과 같이 처리됩니다:
    • 공휴일이 있는 경우: 공휴일 Period의 monthdays를 해당 공휴일 날짜로 업데이트
    • 공휴일이 없는 경우: 스케줄에서 공휴일 Period를 제거하고 평일 Period만 유지
  3. 공휴일 당일 처리:
    • 공휴일 시작(00:00): 스케줄의 평일 Period를 공휴일 Period로 교체
    • 공휴일 종료(23:59): 공휴일 Period를 평일 Period로 복구

이러한 방식으로 하나의 스케줄에 여러 Period가 존재할 때 발생할 수 있는 충돌을 방지하고, 공휴일과 평일의 스케줄을 명확하게 구분하여 관리할 수 있습니다. 전체 프로세스는 완전 자동화되어 있어 운영자의 수동 개입이 필요 없으며, 안정적이고 효율적인 인스턴스 관리가 가능합니다. 보다 구체적인 흐름도는 아래와 같습니다.

사전 준비 사항(Prerequisites)

공휴일 자동 관리 솔루션을 사용하려면, 다음과 같은 사항이 사전에 준비되어야 합니다.

  • 본 솔루션을 구현하기 위해서는 기존 AWS Instance Scheduler 구성 및 scheduler-cli 설치가 기본 전제가 됩니다. AWS Instance Scheduler 솔루션을 빠르게 배포하기 위한 AWS CloudFormation 템플릿을 다운로드할 수 있습니다. scheduler-cli의 설치/사용 방법에 대해서는 구현 안내서를 참조하시기 바랍니다.
  • 이후 AWS Instance Scheduler가 이미 설치 된 상태에서 아래의 리소스들에 대한 자원 확인이 필요합니다.
    • DynamoDB 콘솔의 Tables 목록에서 [Instance Scheduler명]-ConfigTable-* 형식의 테이블 ARN을 확인 후 이후 과정에서 사용할 수 있도록 별도로 메모
    • KMS 콘솔의 Customer Managed Key 목록에서 [Instance Scheduler명]-instance-scheduler-encryption-key 에 대한 ARN을 확인 후 이후 과정에서 사용할 수 있도록 별도로 메모
  • 공공데이터포털 한국천문연구원 특일 정보(https://www.data.go.kr/data/15012690/openapi.do#tab_layer_detail_function) 웹 페이지에서 활용 신청 버튼을 통해 API 키를 발급 받습니다. (별도 로그인 필요)

구성 단계

단계 1: Lambda 및 EventBridge 동작을 위한 Role 생성

단계 1에서는 AWS Lambda와 EventBridge 서비스를 연동하기 위한 IAM Role 생성 과정을 안내합니다. 생성되는 Role에는 Lambda 기본 실행 권한과 함께, EventBridge 규칙 관리, DynamoDB 항목 조작, KMS 키 사용, 그리고 Lambda 함수 호출 권한이 포함됩니다. 특히 이 Role은 Lambda와 EventBridge 서비스 모두에 대한 신뢰 관계를 가지며, 공휴일 일정 업데이트를 위한 자동화된 워크플로우를 지원하는 것이 주요 목적입니다.

따라서 본 솔루션을 위한 새로운 IAM 역할에서는 아래와 같은 필수 권한들이 필요합니다.

  • Policy :
    • AWS Policy : AWSLambdaBasicExecutionRole
    • EventBridge 관련 권한 (PutRule, PutTargets, List*, DeleteRule, RemoveTargets)
    • DynamoDB 관련 권한 (GetItem, PutItem)
      • Resource : 사전준비 과정에서 확인한 DynamoDB테이블 ARN
    • KMS 관련 권한 (Decrypt, GenerateDataKey)
      • Resource : 사전준비 과정에서 확인한 KMS Key ARN
    • IAM PassRole 권한
    • Lambda 함수 호출 권한
      • Resource : “arn:aws:lambda:ap-northeast-2:[Account-ID]:function:UpdateScheduleForHoliday”
  • 신뢰 관계(Trust Relationships) : events.amazonaws.com, lambda.amazonaws.com

단계 2: Lambda 함수 생성(1) – 공휴일 당일 자동 트리거

단계 2에서는 공휴일에 자동으로 트리거되는 Lambda 함수: UpdateScheduleForHoliday 의 생성 과정과 구현 코드를 안내합니다. 이 Lambda 함수는 DynamoDB에 저장된 스케줄 정보를 공휴일에 맞춰 자동으로 업데이트하는 역할을 수행합니다.

함수는 일반 기간과 공휴일 기간의 스케줄을 전환하며, 각 상태 변경에 대한 로깅을 제공합니다. 이전 단계에서 생성한 IAM Role을 활용하여 필요한 권한을 얻으며, 전체적으로 공휴일 기간 동안의 자동화된 리소스 관리를 위한 핵심 구성 요소입니다.

DynamoDB에 저장된 스케줄을 조회하여 공휴일 시작/종료 상태에 따라 기존 Period을 새로운 Period로 교체하고 업데이트하는 기능을 수행하기 위하여 코드를 아래와 같이 구성합니다.

import boto3
import json
import logging

# 로깅 설정
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def update_schedule(table, schedule_name: str, holiday_period: str, normal_period: str, hol_state: str) -> dict:
    """스케줄 업데이트 함수"""
    try:
        response = table.get_item(
            Key={
                'name': schedule_name,
                'type': 'schedule'
            }
        )
        
        if 'Item' not in response:
            raise Exception(f"Schedule {schedule_name} not found")
            
        schedule = response['Item']
        current_periods = set(schedule.get('periods', set()))
        
        if hol_state == 'start':
            # 공휴일 시작: normal_period 제거 후 holiday_period 추가
            current_periods.discard(normal_period)
            current_periods.add(holiday_period)
            logger.info(f"Holiday start: Replacing {normal_period} with {holiday_period}")
        elif hol_state == 'finish':
            # 공휴일 종료: holiday_period 제거 후 normal_period 추가
            current_periods.discard(holiday_period)
            current_periods.add(normal_period)
            logger.info(f"Holiday finish: Replacing {holiday_period} with {normal_period}")
        
        schedule['periods'] = current_periods
        table.put_item(Item=schedule)
        logger.info(f"Updated schedule {schedule_name} with periods: {current_periods}")
        
        return schedule
        
    except Exception as e:
        logger.error(f"Error updating schedule: {str(e)}")
        raise

def lambda_handler(event, context):
    try:
        # 필수 파라미터 검증
        required_params = ['schedule_name', 'holiday_period', 'normal_period', 'dynamodb_table', 'hol_state']
        for param in required_params:
            if param not in event:
                raise ValueError(f"Missing required parameter: {param}")
        
        schedule_name = event['schedule_name']
        holiday_period = event['holiday_period']
        normal_period = event['normal_period']
        dynamodb_table = event['dynamodb_table']
        hol_state = event['hol_state']
        
        logger.info(f"Received parameters: schedule_name={schedule_name}, "
                   f"holiday_period={holiday_period}, normal_period={normal_period}, "
                   f"hol_state={hol_state}")
        
        dynamodb = boto3.resource('dynamodb')
        table = dynamodb.Table(dynamodb_table)
        
        updated_schedule = update_schedule(table, schedule_name, holiday_period, normal_period, hol_state)
        
        return {
            'statusCode': 200,
            'body': json.dumps({
                'message': f"Successfully updated schedule {schedule_name}",
                'schedule_name': schedule_name,
                'periods': list(updated_schedule['periods']),
                'state': hol_state
            })
        }
        
    except Exception as e:
        error_message = str(e)
        logger.error(f"Error: {error_message}")
        return {
            'statusCode': 500,
            'body': json.dumps({
                'error': error_message,
                'schedule_name': event.get('schedule_name'),
                'state': event.get('hol_state')
            })
        }

단계 3: Lambda 함수 생성(2) – MonthlyHolidayCheck

단계 3에서는 매월 초에 자동으로 실행되는 Lambda 함수: MonthlyHolidayCheck 의 생성 과정과 상세 구현 코드를 안내합니다. 이 함수 역시 이전 단계에서 생성한 IAM Role을 활용하여 필요한 권한을 얻으며, Lambda 함수는 공공데이터포털 API를 통해 해당 월의 공휴일 정보를 조회하고, EventBridge 규칙을 생성하여 공휴일에 맞춰 자동으로 스케줄을 관리합니다.

해당 코드는 HolidayConfig, EventBridgeManager, HolidayManager 세 개의 주요 클래스로 구성되어 있으며, 공휴일 시작과 종료 시점에 맞춰 DynamoDB의 스케줄 정보를 자동으로 업데이트합니다. 특히 한국 시간(KST)을 기준으로 공휴일 전환을 처리하며, 대체공휴일도 함께 관리할 수 있는 유연한 구조를 가지고 있습니다.

import boto3
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime
import json
import logging
import os
from datetime import datetime, timedelta
from typing import List, Dict, Set, Any

# 로깅 설정
logger = logging.getLogger()
logger.setLevel(logging.INFO)

class HolidayConfig:
    """공휴일 설정 관련 상수"""
    API_KEY = "사전준비 사항” 에서 발급 받은 공공데이터포털 API 키(Encrypted) 입력"
    BASE_URL = "http://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService/getRestDeInfo"
    UPDATE_LAMBDA_ARN = "단계 2 에서 생성한 UpdateScheduleForHoliday Lambda 함수의 ARN 입력"
    RULE_PREFIX = "holiday-scheduler-"
 
    # 환경 변수에서 설정값 가져오기
    IAM_ROLE_ARN = os.environ.get('IAM_ROLE_ARN')
    HOL_PERIOD_NAME = os.environ.get('HOL_PERIOD_NAME')
    NORMAL_PERIOD_NAME = os.environ.get('NORMAL_PERIOD_NAME')
    SCHEDULE_NAME = os.environ.get('SCHEDULE_NAME')
    DYNAMODB_TABLE = os.environ.get('DYNAMODB_TABLE')

class EventBridgeManager:
    """EventBridge 규칙 관리 클래스"""
    def __init__(self):
        self.client = boto3.client('events')

    def delete_existing_rules(self) -> None:
        """기존 holiday-scheduler 규칙 삭제"""
        try:
            paginator = self.client.get_paginator('list_rules')
            for page in paginator.paginate(NamePrefix=HolidayConfig.RULE_PREFIX):
                for rule in page['Rules']:
                    rule_name = rule['Name']
                    self._delete_rule(rule_name)
        except Exception as e:
            logger.error(f"Error deleting existing rules: {str(e)}")
            raise

    def _delete_rule(self, rule_name: str) -> None:
        """개별 규칙 삭제"""
        try:
            # 타겟 제거
            targets = self.client.list_targets_by_rule(Rule=rule_name)
            if targets['Targets']:
                target_ids = [t['Id'] for t in targets['Targets']]
                self.client.remove_targets(Rule=rule_name, Ids=target_ids)
                logger.info(f"Removed targets for rule: {rule_name}")
            
            # 규칙 삭제
            self.client.delete_rule(Name=rule_name)
            logger.info(f"Deleted rule: {rule_name}")
        except Exception as e:
            logger.error(f"Error deleting rule {rule_name}: {str(e)}")
            raise

    def create_holiday_rule(self, holiday: Dict[str, str]) -> List[Dict[str, str]]:
        """공휴일 시작과 종료 규칙 생성 (KST 기준)"""
        try:
            holiday_date = holiday['date']
            holiday_name = holiday['name']
            rules = []

            # KST 00:00는 전날 UTC 15:00
            start_date = datetime.strptime(holiday_date, '%Y-%m-%d')
            start_utc = start_date - timedelta(hours=9)  # UTC로 변환
            
            # KST 23:59는 당일 UTC 14:59
            end_date = datetime.strptime(holiday_date, '%Y-%m-%d')
            end_utc = end_date + timedelta(hours=15)  # UTC로 변환

            # 공휴일 시작 규칙 (KST 00:00 = 전날 UTC 15:00)
            start_rule_name = f"{HolidayConfig.RULE_PREFIX}{holiday_date}-start"
            start_rule = self.client.put_rule(
                Name=start_rule_name,
                ScheduleExpression=f"cron(0 15 {start_utc.day} {start_utc.month} ? {start_utc.year})",
                State='ENABLED',
                Description=f"Holiday start for {holiday_name} on {holiday_date} KST"
            )
            logger.info(f"Created start rule: {json.dumps(start_rule)}")

            # 공휴일 종료 규칙 (KST 23:59 = UTC 14:59)
            end_rule_name = f"{HolidayConfig.RULE_PREFIX}{holiday_date}-finish"
            end_rule = self.client.put_rule(
                Name=end_rule_name,
                ScheduleExpression=f"cron(59 14 {end_utc.day} {end_utc.month} ? {end_utc.year})",
                State='ENABLED',
                Description=f"Holiday end for {holiday_name} on {holiday_date} KST"
            )
            logger.info(f"Created end rule: {json.dumps(end_rule)}")

            # 시작 규칙 타겟 설정
            self._set_rule_target(start_rule_name, holiday_date, 'start')
            # 종료 규칙 타겟 설정
            self._set_rule_target(end_rule_name, holiday_date, 'finish')

            rules.extend([
                {
                    'date': holiday_date,
                    'name': f"{holiday_name}-start",
                    'rule': start_rule_name
                },
                {
                    'date': holiday_date,
                    'name': f"{holiday_name}-finish",
                    'rule': end_rule_name
                }
            ])
            return rules

        except Exception as e:
            logger.error(f"Error creating rules for {holiday_date}: {str(e)}")
            raise

    def _create_cron_expression(self, date: str) -> str:
        """날짜로부터 cron 표현식 생성"""
        year, month, day = date.split('-')
        return f"cron(0 0 {day} {month} ? {year})"

    def _set_rule_target(self, rule_name: str, holiday_date: str, hol_state: str) -> None:
        """규칙에 Lambda 타겟 설정"""
        lambda_input = {
            'schedule_name': HolidayConfig.SCHEDULE_NAME,
            'holiday_period': HolidayConfig.HOL_PERIOD_NAME,
            'normal_period': HolidayConfig.NORMAL_PERIOD_NAME,
            'dynamodb_table': HolidayConfig.DYNAMODB_TABLE,
            'hol_state': hol_state
        }

        target_response = self.client.put_targets(
            Rule=rule_name,
            Targets=[{
                'Id': f"UpdateSchedule-{holiday_date}-{hol_state}",
                'Arn': HolidayConfig.UPDATE_LAMBDA_ARN,
                'Input': json.dumps(lambda_input),
                'RoleArn': HolidayConfig.IAM_ROLE_ARN  # IAM 역할 추가
            }]
        )
        logger.info(f"Set EventBridge target: {json.dumps(target_response)}")


class HolidayManager:
    """공휴일 정보 관리 클래스"""
    def __init__(self):
        self.dynamodb = boto3.resource('dynamodb')

    def get_holiday_info(self, year: int, month: int, specific_days: List[int]) -> List[Dict[str, str]]:
        """공휴일 정보 조회"""
        holidays = []
        formatted_month = str(month).zfill(2)

        if not specific_days:
            holidays.extend(self._get_api_holidays(year, formatted_month))

        holidays.extend(self._create_custom_holidays(year, month, specific_days))
        return holidays

    def _get_api_holidays(self, year: str, month: str) -> List[Dict[str, str]]:
        """API에서 공휴일 정보 조회"""
        try:
            params = f"?serviceKey={HolidayConfig.API_KEY}&pageNo=1&numOfRows=10&solYear={year}&solMonth={month}"
            url = f"{HolidayConfig.BASE_URL}{params}"
            
            with urllib.request.urlopen(url) as response:
                return self._parse_holiday_response(response.read())
        except Exception as e:
            logger.error(f"Error fetching holiday data: {str(e)}")
            raise

    def _parse_holiday_response(self, xml_data: bytes) -> List[Dict[str, str]]:
        """XML 응답 파싱"""
        holidays = []
        root = ET.fromstring(xml_data)
        
        items = root.findall('.//item')
        for item in items:
            if item.find('isHoliday').text == 'Y':
                date = item.find('locdate').text
                formatted_date = f"{date[:4]}-{date[4:6]}-{date[6:]}"
                holidays.append({
                    'date': formatted_date,
                    'name': item.find('dateName').text
                })
        
        return holidays

    def _create_custom_holidays(self, year: int, month: int, days: List[int]) -> List[Dict[str, str]]:
        """공휴일 문자열 포맷팅"""
        return [{
            'date': f"{year}-{str(month).zfill(2)}-{str(day).zfill(2)}",
            'name': f"Custom Holiday {year}-{month}-{day}"
        } for day in days]

    def update_period(self, holidays: List[Dict[str, str]]) -> Set[str]:
        """Period 업데이트"""
        try:
            table = self.dynamodb.Table(HolidayConfig.DYNAMODB_TABLE)
            
            current_period = self._get_or_create_period(table)
            days = set([str(int(h['date'].split('-')[2])) for h in holidays])
            
            current_period['monthdays'] = days
            table.put_item(Item=current_period)
            
            logger.info(f"Updated {HolidayConfig.HOL_PERIOD_NAME} period with days: {days}")
            return days
        except Exception as e:
            logger.error(f"Error updating period: {str(e)}")
            raise

    def update_schedule_periods(self, has_holidays: bool) -> None:
        """스케줄의 periods 업데이트 - 공휴일이 없을 때만 수행"""
        # 공휴일이 있는 경우에는 아무 작업도 하지 않음
        if has_holidays:
            logger.info(f"Holidays found - no schedule update needed")
            return

        try:
            table = self.dynamodb.Table(HolidayConfig.DYNAMODB_TABLE)
            
            # 스케줄 조회
            response = table.get_item(
                Key={
                    'name': HolidayConfig.SCHEDULE_NAME,
                    'type': 'schedule'
                }
            )
            
            if 'Item' not in response:
                logger.error(f"Schedule {HolidayConfig.SCHEDULE_NAME} not found")
                return
            
            schedule = response['Item']
            current_periods = set(schedule.get('periods', set()))
            
            # 공휴일이 없을 때만 holiday period 제거하고 normal period 추가
            current_periods.discard(HolidayConfig.HOL_PERIOD_NAME)
            current_periods.add(HolidayConfig.NORMAL_PERIOD_NAME)
            
            schedule['periods'] = current_periods
            table.put_item(Item=schedule)
            
            logger.info(f"Updated schedule {HolidayConfig.SCHEDULE_NAME} with periods: {current_periods}")
            
        except Exception as e:
            logger.error(f"Error updating schedule: {str(e)}")
            raise


    def _get_or_create_period(self, table: Any) -> Dict[str, Any]:
        """기존 period 조회 또는 새로 생성"""
        response = table.get_item(
            Key={
                'name': HolidayConfig.HOL_PERIOD_NAME,
                'type': 'period'
            }
        )
        
        return response.get('Item', {
            'name': HolidayConfig.HOL_PERIOD_NAME,
            'type': 'period',
            'description': f'Holiday Period ({HolidayConfig.HOL_PERIOD_NAME})',
            'begintime': '09:00',
            'endtime': '13:59',
            'monthdays': set()
        })

def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    try:
        # Lambda 환경 변수 로깅
        logger.info(f"Using holiday period name: {HolidayConfig.HOL_PERIOD_NAME}")
        logger.info(f"Using normal period name: {HolidayConfig.NORMAL_PERIOD_NAME}")
        logger.info(f"Using schedule name: {HolidayConfig.SCHEDULE_NAME}")
        
        # 날짜 파라미터 처리
        test_date = event.get('test_date')
        specific_days = event.get('specific_days', [])
        
        if test_date:
            year, month = map(int, test_date.split('-'))
        else:
            now = datetime.now()
            year, month = now.year, now.month
        
        logger.info(f"Processing holidays for {year}-{month}")

        # 매니저 인스턴스 생성
        event_manager = EventBridgeManager()
        holiday_manager = HolidayManager()

        # 기존 규칙 삭제
        event_manager.delete_existing_rules()

        # 공휴일 정보 조회
        holidays = holiday_manager.get_holiday_info(year, month, specific_days)
        
        if not holidays:
            # 공휴일이 없을 때만 스케줄 처리
            logger.info(f"No holidays found for {year}-{month}")
            holiday_manager.update_schedule_periods(has_holidays=False)
            return {
                'statusCode': 200,
                'body': json.dumps({
                    'message': f"No holidays found for {year}-{month}. Updated schedule with normal period.",
                    'schedule_name': HolidayConfig.SCHEDULE_NAME,
                    'normal_period': HolidayConfig.NORMAL_PERIOD_NAME
                })
            }

        # Period 업데이트 (monthdays만 업데이트)
        updated_days = holiday_manager.update_period(holidays)

        # 새 규칙 생성
        created_rules = [
            event_manager.create_holiday_rule(holiday)
            for holiday in holidays
        ]

        result = {
            'statusCode': 200,
            'body': json.dumps({
                'message': "Successfully processed holidays",
                'schedule_name': HolidayConfig.SCHEDULE_NAME,
                'period_name': HolidayConfig.HOL_PERIOD_NAME,
                'year': year,
                'month': str(month).zfill(2),
                'specific_days': specific_days,
                'updated_period_days': list(updated_days),
                'created_rules': created_rules
            })
        }
        
        logger.info(f"Final result: {json.dumps(result)}")
        return result

    except Exception as e:
        error_response = {
            'statusCode': 500,
            'body': json.dumps({'error': str(e)})
        }
        logger.error(f"General error: {str(e)}")
        return error_response

단계 4: MonthlyHolidayCheck 함수 관련 구성 설정

단계 4에서는 이전 단계에서 생성한 MonthlyHolidayCheck 함수의 상세 구성과 환경 변수 설정, 그리고 자동 실행을 위한 EventBridge 트리거 설정 방법을 안내합니다. MonthlyHolidayCheck 함수는 7초의 타임아웃을 가지며, DynamoDB 테이블 정보, 휴일/평일 Period 명, IAM Role ARN, Schedule 명 등의 주요 환경 변수들을 포함합니다. 특히 EventBridge Rule을 통해 매월 1일 한국 시간 0시 0분(UTC 15:00)에 자동으로 실행되도록 설정되어 있어, 월간 공휴일 정보를 자동으로 관리할 수 있습니다.

(1) 일반 구성(General Configuration) 설정

  1. AWS Lambda 콘솔 → 함수(Functions) → MonthlyHolidayCheck 함수 선택
  2. 구성(Configuration) 탭 선택 후 일반 구성(General Configuration)을 선택 후 timeout 값을 7초로 설정

MonthlyHolidayCheck 함수의 timeout 값을 7초로 설정

설정 값 확인

(2) 환경 변수(Environment Variables) 설정

환경 변수(Environment Variables) 선택 후 아래 5개의 환경 변수 추가(Add Environment Variable)

(1) DYNAMODB_TABLE: 사전 준비 사항 1-1에서 확인한 DynamoDB Table ARN
(2) HOL_PERIOD_NAME: 기존에 사용 중인 휴일 전용 Period 명(* 사용중인 공휴일 Period가 없을 경우 본 환경변수로 지정한 Period 명으로 자동 생성)
(3) IAM_ROLE_ARN: 단계 1에서 생성한 IAM Role ARN
(4) NORMAL_PERIOD_NAME: 기존에 사용중인 평일 전용 Period 명
(5) SCHEDULE_NAME: 기존에 사용중인 Schedule 명

(3) 트리거(Trigger)용 EventBridge Rule 생성

MonthlyHolidayCheck Lambda 함수에 EventBridge 트리거를 추가하여 ‘Monthly-Holiday-Check’라는 규칙을 생성하고, cron 표현식, cron(0 15 L * ? *) 을 통해 매월 1일 0시 0분(한국시간)에 자동으로 함수가 실행되도록 설정합니다.

여기까지 전체적인 구성이 완료되었습니다.

테스트 단계

이번 단계에서는 생성된 리소스들의 실제 동작을 검증하는 테스트 과정을 수행합니다. 특히 매월 자동으로 트리거되는 MonthlyHolidayCheck 함수의 정상 작동 여부를 확인하는 것이 중요합니다.

(1) 공휴일 정보 가져오는 MonthlyHolidayCheck 함수 테스트

MonthlyHolidayCheck 함수 테스트는 다음과 같은 순서로 진행됩니다:

  1. AWS Lambda 콘솔에 접속하여 MonthlyHolidayCheck 함수로 이동합니다.
  2. 함수의 Test 탭을 선택하고 테스트 이벤트를 구성합니다. 이때 Event JSON에 테스트하고자 하는 연월을 “test_date”: “YYYY-MM” 형식으로 입력합니다. 예를 들어 2025년 5월을 테스트하려면 “test_date”: “2025-05″로 입력합니다.
  3. AWS EventBridge 콘솔로 이동하여 “holiday-scheduler-“로 시작하는 규칙들이 생성되었는지 확인합니다. 이 규칙들은 해당 월의 각 공휴일에 대해 자동으로 생성되어야 합니다.
  4. 생성된 EventBridge 규칙들이 올바른 일정과 대상을 가지고 있는지 검증합니다. 각 공휴일의 시작(00:00)과 종료(23:59) 시점에 대한 규칙이 정확히 설정되어 있어야 합니다.

이러한 테스트를 통해 MonthlyHolidayCheck 함수가 공휴일 정보를 정확히 가져오고, 이를 바탕으로 적절한 EventBridge 규칙들을 생성하는지 확인할 수 있습니다.

(2) 공휴일에 자동으로 수행되는 UpdateScheduleForHoliday 함수 테스트

다음으로는 공휴일에 자동으로 실행되는 UpdateScheduleForHoliday 함수의 테스트를 수행합니다. 이 함수는 공휴일 시작과 종료 시점에 스케줄의 Period를 적절히 전환하는 역할을 담당합니다.

UpdateScheduleForHoliday 함수 테스트는 다음과 같은 순서로 진행됩니다:

  1. AWS EventBridge 콘솔에 접속하여 “holiday-scheduler-*-start”로 시작하는 규칙을 찾아 선택합니다.
  2. 선택한 규칙의 Target 섹션에서 Inputs > Constant를 클릭하여 설정된 매개변수 값을 확인하고 복사합니다. 이 매개변수에는 schedule_name, holiday_period, normal_period, dynamodb_table, hol_state 등의 정보가 포함되어 있습니다.
  3. AWS Lambda 콘솔에서 UpdateScheduleForHoliday 함수로 이동한 후, Test 탭을 선택합니다. 새로운 테스트 이벤트를 생성하고, Event JSON 영역에 이전 단계에서 복사한 매개변수 값을 붙여넣습니다. (아래는 예시 입니다.)
    {
       "schedule_name" : "test",
       "holiday_period" : "KoreanHolidays",
       "normal_period" : "office-hours",
       "dynamodb_table" : "scheduler123-ConfigTable-***",
       "hol_state" : "start"
    }
  4. 그 후 해당 스케쥴에서 Period 명이 변경되었는지 확인합니다.

결론

이번 포스팅에서는 AWS Instance Scheduler의 한계점을 해결하여 한국의 공휴일을 자동으로 관리할 수 있는 비용 효율적인 서버리스 솔루션을 제안해 드렸습니다. 본 솔루션은 공공데이터포털 API를 활용한 자동화된 공휴일 정보 수집과 AWS Lambda 및 EventBridge를 통한 효율적인 스케줄 관리 시스템을 제공합니다.

본 솔루션 도입을 통해 기업은 운영팀의 수동적인 공휴일 일정 관리 업무를 완전히 제거할 수 있습니다. 이는 단순한 업무 효율화를 넘어 인적 오류로 인한 잠재적 리스크를 제거하여 서비스 안정성을 크게 향상시키며, 운영팀의 핵심 업무 생산성을 극대화할 수 있는 기회를 제공합니다.

또한 비용 측면에서도 효율적인 효과를 기대할 수 있습니다. 공휴일의 불필요한 리소스 운영을 자동으로 최적화함으로써 정규 업무 시간 기준 최대 70%의 운영 비용 절감이 가능하며, 서버리스 아키텍처 기반으로 구현되고 특정 시점에만 단발성으로 리소스가 사용되기에 솔루션 자체의 추가 운영 비용은 월 $1 이하로 예상됩니다.
(AWS Lambda 함수: 128MB 사양 기준 0.0000021 USD/초, EventBridge 월 $1 미만 예상)

이러한 자동화된 리소스 관리를 통해 운영 효율성을 극대화하고, 동시에 비용 최적화를 실현할 수 있는 균형 잡힌 해결책이 되시기를 바랍니다.

Ion Kim

Ion Kim

김이온 Technical Account Manager는 클라우드 엔지니어 경험을 바탕으로 Enterprise On-Ramp 고객이 AWS 워크로드를 안정적으로 운영할 수 있도록 기술 지원을 포함한 다양한 정보와 서비스를 제공하고 있습니다.

SangKyu Jang

SangKyu Jang

장상규 Cloud Support Engineer는 EC2 SME (Subject Matter Expert)로 활동하며 현재 EC2, EBS, ElastiCache 등 Linux연관 서비스의 기술 지원 업무를 수행하고 있습니다.