프로젝트의 스케줄 작업은 전부 코드 한 줄에 기대고 있었다. if("1".equals(GkConstant.WAS_NUMBER)). WAS 두 대가 똑같은 @Scheduled 메서드를 갖고 있지만, VM 옵션의 시스템 프로퍼티가 1인 서버에서만 실제로 배치가 돈다.

인프라를 Autoscaling 구성으로 전환하는 일정이 잡히면서 이 한 줄이 문제가 됐다. 서버가 동적으로 생기고 사라지면 “1번 서버"라는 이름을 붙일 대상이 없다. 서버가 몇 대든 배치가 정확히 한 번 돌게 하려면, 실행 시점을 정하는 주체를 서버 밖으로 빼야 했다. 이 글에서는 그 판단과, EventBridge Scheduler와 Lambda로 트리거를 옮긴 과정을 정리한다.

1번 서버라는 기준

기존 환경은 이랬다.

  • Autoscaling이 적용되지 않은 WAS EC2 2대

  • Spring Framework 애플리케이션 안에서 @Scheduled로 스케줄 작업 실행

장기 미사용 관리자에게 알림 메일을 보내는 배치가 대표적이다.

@Scheduled(cron = "0 30 9 ? * *", zone = "GMT+9:00")
    public void notifyLongTermNoUseAdmin() {
        if("1".equals(GkConstant.WAS_NUMBER)) { // STG, PRD 의 1번 서버인 경우에만 Batch 실행
            try {
                List<LongTermNoUseAdminVO> notifyAdminDataList = longTermNoUseAdminSvc.getNotifyAdminDataList();

                for (LongTermNoUseAdminVO voNotifyAdmin : notifyAdminDataList) {
                    try {
                        List<String> mailReceiverIDList = new ArrayList<>(Arrays.asList(voNotifyAdmin.getAccountId())); // 메일 수신인 리스트에 장기 미사용자의 이메일 주소만 추출해 추가

                        // 대상자별 mail template parameter 가 다르기 때문에 for 문을 통해 개별 메일 발송
                        awssesClient.sendEmail(mailReceiverIDList, "[" + ospAppConfig.getGkServerType() + "]" + MailEnum.NOTIFICATION.getMailSubject(),
                                setMailHtmlBody(MailEnum.NOTIFICATION, voNotifyAdmin), null);

                        LOG.info("##### SUCCESS Sending Notification Email : {}, {}", voNotifyAdmin.getSaGuid(), voNotifyAdmin.getAccountId());
                    } catch (Exception e) {
                        e.printStackTrace();
                        LOG.info("##### FAIL Sending Notification Email : {}, {}", voNotifyAdmin.getSaGuid(), voNotifyAdmin.getAccountId());
                    }
                }
                LOG.info("##### SUCCESS notifyLongTermNoUseAdmin Batch");
            } catch (Exception e) {
                e.printStackTrace();
                LOG.info("##### FAIL notifyLongTermNoUseAdmin Batch");
            }
        }
    }

cron은 두 서버 모두에서 매일 09:30에 깨어나지만, 실제로 메일을 보내는 건 WAS_NUMBER가 1인 서버뿐이다. 서버 대수가 고정되어 있고 사람이 서버마다 번호를 붙여 두는 동안에는 문제가 없는 방식이다.

Autoscaling이 이 조건을 깨는 방식

Autoscaling 그룹의 인스턴스는 같은 Launch Template으로 찍혀 나온다. 번호를 어떻게 붙이느냐에 따라 결과는 둘 중 하나다.

  • 모든 인스턴스가 1을 갖는다. 서버가 세 대로 늘면 같은 메일이 세 번 나간다.

  • 아무 인스턴스도 1을 갖지 않거나, 1을 가진 인스턴스가 스케일 인으로 종료된다. 배치가 아예 돌지 않는다.

중복 실행이든 누락이든, 원인은 서버가 자기 자신이 실행 담당인지 스스로 판단한다는 데 있다. 서버 수가 고정일 때만 그 판단이 맞는다.

ShedLock 대신 트리거를 밖으로 뺀 이유

멀티 서버 환경에서 Spring 스케줄 중복 실행을 막는 흔한 방법은 ShedLock의 @SchedulerLock이다. 공유 저장소에 락을 걸어 한 서버만 실행하게 한다. 그런데 이 프로젝트는 Spring Framework 버전이 낮아서 이런 최신 라이브러리를 적용하기 어려웠다.

그래서 방향을 바꿨다. 서버들이 “누가 실행할지"를 합의하게 만드는 대신, 서버 밖에서 정해진 시각에 API를 한 번 호출한다.

  1. AWS EventBridge Scheduler가 매일 09:30에 Lambda를 호출한다.

  2. Lambda가 WAS 앞에 있는 Internal ALB의 DNS로 요청을 보낸다.

  3. 요청을 받은 애플리케이션 서버가 (수정된) 스케줄 로직을 실행한다.

flowchart LR
    S["EventBridge Scheduler<br/>매일 09:30"] -->|"{'key': 'admin/...'}"| L["Lambda<br/>gk-*-scheduling-lambda"]
    L -->|POST| A["Internal ALB"]
    A --> W1["WAS 1"]
    A -.-> W2["WAS 2"]
    A -.-> W3["WAS N"]

이 방식이 되는 이유는 ALB에 있다. ALB는 요청 하나를 등록된 타겟 중 한 대에만 전달한다. 서버가 두 대든 다섯 대든 Lambda 호출 한 번은 배치 실행 한 번이 된다. 서버는 더 이상 자기 번호를 알 필요가 없고, 스케일 인으로 어떤 서버가 사라져도 남은 서버 중 하나가 요청을 받는다.

Lambda 함수: 경로를 받아 ALB에 POST 하나

Python 코드

로컬에 lambda_function.py를 만들고 아래 코드를 넣었다. ALB 주소는 환경변수 ALB_URL에서 읽고, 이벤트에 key가 있으면 그 값을 경로로 붙인다.

import json
import requests
import os
  
def lambda_handler(event, context):
    url = os.environ['ALB_URL']
    print("url : ", url)
    
    print(event)
    if "key" in event :
        print(event["key"])
        url = url + event["key"]
    
    headers = { "Content-Type": "application/json", "Connection" : "keep-alive" }
    response = requests.post(url, headers=headers, verify=False) # health check 테스트시 주석처리
    
    # ------CONNECTION TEST CODE------
    # health check 테스트시 아래 블록 주석해제
    # response = requests.get(os.environ['ALB_URL'] + 'common/health', headers=headers, verify=False)
    # content = response.content.decode()
    # print("content:", content)
    # ------CONNECTION TEST CODE------
    
    if response.status_code == 200:
        return {
            'statusCode': 200,
            'body': json.dumps('API Success')
        }
    else:
        return {
            'statusCode': response.status_code,
            'body': json.dumps('API Fail')
        }

key 덕분에 Lambda 하나로 여러 배치를 부를 수 있다. 운영 로그를 보면 EventBridge 쪽에서 {'key': 'admin/deleteLongTermNoUseAdmin'}, {'key': 'admin/notifyLongTermNoUseAdmin'}처럼 배치마다 다른 경로를 넘긴다.

requests 라이브러리 패키징

Lambda의 Python 런타임에는 requests가 기본으로 들어 있지 않아서, 코드와 함께 압축해 올렸다.

  1. 코드가 있는 경로에 requirements.txt를 만든다.

  2. 내용은 한 줄이다.

requests==2.22.0

이어서 같은 경로에서 라이브러리를 현재 디렉토리에 설치하고, 전체를 zip으로 묶는다.

pip3 install -r requirements.txt -t .
zip -r scheduler.zip .

Lambda 생성과 설정

DEV 기준으로 gk-dev-scheduling-lambda를 만들었다.

설정값
런타임Python 3.9
아키텍처x86_64
VPC·서브넷WAS가 속한 private 서브넷 2개
보안그룹Gk_dev_lambda (dev ALB로 가는 443 포트 outbound 허용)

Internal ALB는 VPC 밖에서 닿지 않으니 Lambda도 같은 VPC의 private 서브넷에 넣어야 한다. 보안그룹은 양쪽을 다 열어야 한다. Lambda 쪽 Gk_dev_lambda에 443 outbound를, ALB 쪽 보안그룹에 Gk_dev_lambda로부터의 inbound를 허용한다.

생성이 끝나면 코드 탭에서 “.zip 파일"로 scheduler.zip을 업로드하면 소스와 라이브러리가 함께 올라간다.

마지막으로 [구성] 탭의 [환경 변수]에서 키 ALB_URL, 값 {Internal-ALB-DNS}로 호출할 로드밸런서 주소를 등록한다.

통신 테스트는 health check로

배치 API를 바로 호출하면 테스트 한 번에 실제 메일이 나갈 수 있으니, Lambda와 WAS 사이 연결은 health check 엔드포인트로 먼저 확인했다. 코드 안에 테스트용 블록을 주석으로 넣어 둔 이유다.

아래 화면의 번호 순서대로 진행한다.

  1. 1번 requests.post 줄을 주석 처리

  2. 2번 CONNECTION TEST CODE 블록의 주석 풀기

  3. 저장 후 3번 [Deploy]로 배포

  4. 배포가 끝나면 4번 [Test]

  5. health check 응답에 “up” → 통신 정상

✅
통신 테스트가 끝나면 반드시 코드를 원복한다. 1번 코드의 주석을 풀고, 2번 코드를 다시 주석 처리한 뒤, 저장(Ctrl + S)하고 [Deploy]로 배포한다.

09:30에 한 번씩 실행됐는지 로그로 확인

운영 환경에서는 두 군데 로그를 봤다.

먼저 Lambda 쪽이다. CloudWatch > 로그 그룹 > /aws/lambda/gk-prd-scheduling-lambda에 날마다 로그 스트림이 하나씩 쌓이고, 2023-08-31 스트림의 마지막 이벤트 시간은 09:30:40이다.

스트림 안을 보면 09:30:22에 admin/deleteLongTermNoUseAdmin, 09:30:40에 admin/notifyLongTermNoUseAdmin 경로로 각각 한 번씩 호출됐다.

WAS 쪽은 애플리케이션 로그 그룹에서 SUCCESS로 검색했다. deleteLongTermNoUseAdmin Batch와 notifyLongTermNoUseAdmin Batch의 성공 로그가 각각 한 줄씩 남아 있다.

아직 남은 문제

  1. Lambda는 API가 실패해도 정상 종료한다. 응답 코드가 200이 아니면 'API Fail'을 담은 dict를 반환할 뿐 예외를 던지지 않는다. Lambda 호출 자체는 성공으로 기록되니 EventBridge Scheduler의 재시도도 걸리지 않는다. 실패는 로그를 직접 봐야 알 수 있다.

  2. verify=False로 인증서 검증을 끄고 있다. 호출할 때마다 InsecureRequestWarning이 로그에 남는 것도 이 때문이다.

  3. 배치 API는 VPC 안에서 누구나 호출할 수 있는 엔드포인트다. 보안그룹으로 ALB 접근을 좁혀 두긴 했지만, 요청 자체를 검증하는 장치는 코드에 없다.

정리

  • Autoscaling에서는 “1번 서버"라는 기준이 성립하지 않는다. 같은 템플릿으로 찍힌 서버는 중복 실행하거나 아무도 실행하지 않는다.

  • 실행 시점을 서버 밖으로 빼면 중복 문제가 ALB의 라우팅으로 풀린다. EventBridge Scheduler가 Lambda를 부르고, Lambda가 Internal ALB에 요청 하나를 보내면 서버 한 대만 배치를 돈다.

  • 이벤트의 key를 경로로 붙이면 Lambda 하나로 여러 배치를 부를 수 있다.

  • 실패 감지와 재시도, 엔드포인트 보호는 아직 숙제다.

Spring 버전만 받쳐 줬다면 @SchedulerLock 한 줄로 끝났을 일이다. 대신 스케줄이 애플리케이션 밖으로 나오면서, 언제 어떤 배치가 호출됐는지가 Lambda 로그 스트림에 날짜별로 남는다.