Project Detail
Monkey Travel
Monkey Travel — Commerce Platform
해외 호텔 공급사들의 방대한 데이터를 처리하는 병렬 파이프라인을 구축하고, 운영 리소스를 획기적으로 줄여 '기술이 비즈니스 이익을 만드는' 사례를 증명했습니다.
- 배치 수행 시간 23일 → 3.8일 (83% 단축)
- 글로벌 OTA 데이터 ETL 완전 자동화
- 운영 인력 비용 절감 및 업무 효율 극대화
글로벌 OTA(Agoda, Expedia, Hotelbeds, FITRuums 등)로부터 전 세계 호텔 데이터를 수집/적재하는 파이프라인을 설계하여 데이터 갱신 속도를 23일에서 3.8일로 단축(83% 성능 향상)했습니다. 수작업에 의존하던 운영 프로세스를 100% 자동화하여 비용 절감과 비즈니스 확장성을 확보했습니다.
OTA 데이터 파이프라인
Agoda, Expedia, Hotelbeds, FITRuums 배치를 Airflow와 PHP 워커로 자동화하고, 실패 시 재시도, 재개 지점을 관리합니다.
데이터 정규화 & 관리 UX
대륙-국가-도시-호텔-객실 계층을 정규화하고 어드민에서 신규 데이터 생성, 검증 플로우를 개선했습니다.
운영 효율화 & 피드백
현지 운영팀이 업데이트된 재고를 즉시 파악할 수 있도록 알림, 하이라이트를 제공하고, 배치 상태를 대시보드로 가시화했습니다.
프로젝트 개요
주요 특징
- 호텔 데이터 공급업체 OTA(Agoda, Expedia, Hotelbeds, Fitruums)로부터 배치 프로세스를 통해 데이터를 수집, 가공하여 DB에 영속화
- Airflow를 활용한 호텔 데이터 ETL 프로세스 스케쥴링 및 자동화
- Redis와 OpenSearch를 이용한 빠른 데이터 조회
- 기존의 몽키트래블 어드민 보다 나은 UI/UX를 제공하여 관리 환경 개선
- 업데이트된 호텔 혹은 객실에 대해 현지 어드민에게 피드백 제공
주요 책임 및 성과
- 대륙, 국가, 도시, 도시 내 지역, 호텔, 객실, 시설, 어메니티, 액티비티 등 다양한 데이터 모델 설계 및 정규화
- OTA 벤더별 1차 메타데이터 테이블, 2차 합성 테이블, 3차 최종 마이그레이션 테이블 구성 등 테이블 설계
- OTA 벤더별 API 분석, 활용 방안, 배치 구동 절차, 시퀀스 플로우 등 문서 작성
- 호텔 데이터 공급업체 OTA(Agoda, Expedia, Fitruums, Hotelbeds)로 부터 전 세계 호텔 데이터 수집/가공/적재하는 ETL PHP 배치 프로세스 개발 및 Airflow DAG 작성을 통한 일간/주간 전 세계 호텔 데이터 적재 배치 프로세스 자동화
- Agoda 배치 실행 시간 83% 단축 (기존 23일 → 3.8일): Airflow Task 병렬 분할 처리 도입
- Expedia 배치 실행 시간 75% 단축 (기존 2일 → 0.5일)
- 새로운 호텔 공급업체 Hotelbeds, FITRuums ETL 설계 및 구현
- 호텔, 객실, 호텔 상세 정보 생성/관리를 위한 백오피스 비즈니스 개발
- 퍼블리싱 된 프론트오피스 비즈니스 로직 개발
기술 스택
- Backend: PHP 8.3, Smarty, Python
- Database: MariaDB, Redis, OpenSearch
- Infra: Docker, Amazon EC2
- Batch Scheduling: Airflow
담당 역할
배운 점
배치 프로세스 완전 자동화(+에러에 대한 내성)에 대한 경험
병목을 발생시키는 또 다른 케이스의 발견 및 적절한 대응
병렬 분할 처리 방식
-
전체 호텔의 마지막 ID를 조회합니다.
-
마지막 ID를 기준으로 분할 요인 수(split_number)를 기준으로 나누어 구간을 구합니다.
1# last_record_id 를 split_number 로 범위 분할 함수 2def split_into_ranges(last_record_id, split_number): 3 split_count = last_record_id // split_number 4 ranges = [] 5 for i in range(split_number): 6 start_id = i * split_count if i == 0 else ranges[-1]['end_id'] 7 end_id = (i + 1) * split_count if i < split_number - 1 else last_record_id 8 ranges.append({'start_id': start_id, 'end_id': end_id}) 9 return ranges 10 11# 생성된 태스크 예시 12# split_number = 10 13# Last Hotel Id = 1,000,000 14# 생성된 각 구간 => 15# [ 16# {start_id: 0, end_id: 100000}, 17# {start_id: 100000, end_id: 200000}, 18# ..., 19# {start_id: 900000, end_id: 1000000} 20# ]
- 각 구간별로 태스크를 생성해 병렬로 실행합니다. 각 태스크는 Agoda API를 호출해 호텔 데이터를 수집하고, 이를 DB에 적재하는 작업을 수행합니다.
1# 호텔 디테일 데이터 업데이트 병렬 수행 2hotel_detail_tasks = extract_and_load_hotel_details.partial( 3 batchRunId=batch_run_id, 4 batchRecordId=batch_record_id, 5 currTaskStep=curr_task_step 6).expand(task_params=hotel_detail_task_params) 7 8# Step 3: 호텔 디테일 데이터 업데이트 병렬 수행 9@task(on_failure_callback=stop_dag, trigger_rule='none_failed') 10def extract_and_load_hotel_details(task_params, batchRunId, batchRecordId, currTaskStep): 11 if currTaskStep > CURRENT_STEP_NUMBER: 12 logging.info(f"Step {CURRENT_STEP_NUMBER} 이미 완료되었으므로 스킵합니다.") 13 return [] 14 15 try: 16 ...생략... 17 18 # 호텔 디테일 데이터 업데이트 호출 19 call_endpoint( 20 supplier='otaBatchAgoda', 21 batch_type=endpoint, 22 request_body={ 23 'id': task_status_id, # 현재 실행 중인 태스크의 레코드 ID를 함께 전달 24 'language': language, 25 'start_id': start_id, 26 'end_id': end_id, 27 }, 28 ) 29 30 # 태스크 상태 업데이트: 성공 31 update_task_status(task_status_id, TASK_STATUS_SUCCESS) 32 33 logging.info(f"[Step 3] 호텔 디테일 데이터 업데이트 병렬 수행 단계를 완료했습니다.") 34 except Exception as e: 35 # 태스크 상태 업데이트: 실패 36 update_task_status(task_status_id, TASK_STATUS_FAILED, str(e)) 37 logging.error(f"[Step 3] 에러 발생. 메시지: {str(e)}") 38 raise
1while (true) { 2 if ($currentCursor > $endRecordId) { 3 $this->logger->info("[SUCCESS] 업데이트 처리가 필요한 호텔이 존재하지 않습니다. Hotel Full Info(FEED_ID:19) 적재 배치를 정상적으로 종료합니다. 진행된 배치 범위({$startRecordId}~{$endRecordId})"); 4 return; 5 } 6 7 // [A] 임계값 단위로 현재 태스크 레코드의 task_param의 start_id 업데이트 8 if ($taskUpdateCounter++ >= $this->TASK_WEEKLY_HOTEL_DETAILS_UPDATE_COUNT_THRESHOLD) { // start_id 업데이트 9 $taskParams['hotel_id_range']['start_id'] = $currentCursor; 10 $this->otaBatch->updateTaskParams($taskRecordId, json_encode($taskParams)); 11 $taskUpdateCounter = 0; 12 } 13 14 $hotelsArray = $this->findIdAndHotelIdByIdGreaterThan($currentCursor, $languageCode, $this->HOTEL_CHUNK_SIZE); 15 16 if (empty($hotelsArray)) { 17 $this->logger->debug("[SUCCESS] 업데이트 처리가 필요한 호텔 목록이 존재하지 않습니다. Hotel Full Info(FEED_ID:19) 적재 배치를 정상적으로 종료합니다."); 18 return; 19 } 20 21 foreach ($hotelsArray as $hotel) { 22 $hotelFullInfo = BatchAgodaHotel::fromHotelFullInformationJsonForUpdate( 23 $this->callContentAPI(ContentAPIRequest::toHotelInfoQueryParams($this->FEED_ID_HOTEL_FULL_INFO, $languageCode, $hotel['hotelId'])), 24 $hotel['id']); 25 26 $this->arrayUpdater( 27 $this->TABLE_PRODUCT_HOTEL_SUPPLIER_AGODA_HOTEL . $languageCode->label() . '.hotel', 28 [$this->convertObjectToArray($hotelFullInfo)]); 29 30 $this->logger->info("Hotel ID({$hotel['hotelId']}) Hotel Full Info 조회 API 호출 및 DB 적재 완료."); 31 32 // 짧은 간격으로 잦은 API 호출을 방지하기 위해 대기 33 sleep($this->API_CALL_INTERVAL); 34 35 unset($hotelFullInfo); 36 } 37 38 $this->logger->debug('Current Cursor: ' . $currentCursor . ' | Next Cursor: ' . end($hotelsArray)['hotelId']); 39 // 현재 hotelId 배치 커서를 다음 포인트로 이동 40 $currentCursor = end($hotelsArray)['id']; 41 42 unset($hotelsArray); 43 } 44}
DAG 실행 상태 관리 테이블
1create table dag_execution_status 2( 3 ...생략... 4 otaId TINYINT(1) UNSIGNED NOT NULL COMMENT 'OTA ID', 5 batchType ENUM('DAILY', 'WEEKLY') NOT NULL COMMENT '배치 타입 (일간/ 주간)', 6 batchRunStatus ENUM('STANDBY', 'RUNNING', 'SUCCESS', 'FAILED') DEFAULT 'STANDBY' NOT NULL COMMENT '배치 진행 상태', 7 currTaskStep INT DEFAULT 0 NOT NULL COMMENT '현재 진행 중인 태스크 단계', 8 taskParams LONGTEXT DEFAULT '{}' NOT NULL COMMENT '전체 배치에 필요한 공통 파라미터', 9 errorMessage TEXT DEFAULT '' NOT NULL COMMENT '에러 내용', 10 lastHeartbeat DATETIME NULL COMMENT '마지막 하트비트 시간', 11 ...생략... 12); 13 14 15create table dag_task_execution_status 16( 17 ...생략... 18 dagExecutionStatusId int NOT NULL COMMENT '참조하는 배치 실행 상태 ID', 19 taskName VARCHAR(255) NOT NULL COMMENT '태스크 이름', 20 language VARCHAR(10) NOT NULL COMMENT '언어 코드 (예: en, ko)', 21 taskStatus ENUM ('QUEUED', 'RUNNING', 'SUCCESS', 'FAILED', 'SKIPPED') DEFAULT 'QUEUED' NOT NULL COMMENT '태스크 상태', 22 startId BIGINT NULL COMMENT '처리 시작 ID', 23 endId BIGINT NULL COMMENT '처리 종료 ID', 24 taskParams LONGTEXT DEFAULT '{}' NOT NULL COMMENT '태스크에 전달된 파라미터 목록', 25 errorMessage TEXT DEFAULT '' NOT NULL COMMENT '에러 내용', 26 ...생략... 27);
다양한 일간 배치 케이스의 대응
1create table ota_daily_batch_execution_status 2( 3 ... 생략... 4 5 mTypeId TINYINT UNSIGNED 6 NOT NULL COMMENT '검색 타입 (1: 업데이트 된 호텔 목록, 2: 새로 활성화 된 호텔 목록, 3: 비활성화/ 폐쇄 된 호텔 목록)', 7 mDate DATETIME 8 NOT NULL COMMENT '검색 기준 일시', 9 status ENUM ('EXTRACTING', 'EXTRACTED', 'TRANSFORMING', 'TRANSFORMED', 'LOADING', 'LOADED', 'SUCCESS', 'FAILED') DEFAULT 'EXTRACTING' 10 NOT NULL COMMENT 'BATCH 진행 상태', 11 hotelIds LONGTEXT DEFAULT '[]' 12 NOT NULL COMMENT '업데이트가 필요한 호텔 ID 목록' 13 CHECK (JSON_VALID(`hotelIds`)), 14 updatedIds LONGTEXT DEFAULT '[]' 15 NOT NULL COMMENT '업데이트에 성공한 호텔 ID 목록 (전 세계 대상)' 16 CHECK (JSON_VALID(`updatedIds`)), 17 failedId BIGINT 18 NULL COMMENT '업데이트에 실패한 호텔 ID 목록', 19 failedMessage TEXT DEFAULT '' 20 NOT NULL, 21 isNotiPublished TINYINT(1) UNSIGNED DEFAULT 0 22 NOT NULL, 23 lastJobStep TINYINT(2) UNSIGNED DEFAULT 0 24 NOT NULL COMMENT '배치 실행 단계 (0. NONE (시작 전 단계), 1. ORIGIN_FULL_INFO, 2. ORIGIN_OTHER_INFO, 3. ORIGIN_LOCAL_INFO, 4. ORIGIN_FACILITY_INFO, 5. SUPPLIER_MASTER, 6. SUPPLIER_FACILITY, 7. SUPPLIER_ROOM, 8. MASTER_HOTEL, 9. MASTER_FACILITY, 10. MASTER_ROOM)', 25 syncDate DATETIME 26 NULL COMMENT 'Supplier Master -> Origin Master 테이블로의 동기화 일시' 27 28 ... 생략... 29) comment 'OTA 벤더 중립적인 일간 배치 테이블. 업데이트/활성화/비활성화 된 호텔 Ids 저장';
테이블 필드 설명
-
mTypeId: 업데이트/활성화/비활성화 유형 중 어느 유형으로 배치가 진행 중인지 알기 위한 타입 정보입니다. OTA 벤더들은 공통적으로 이 세 가지 타입의 일간 데이터를 제공합니다.
-
mDate: 검색 대상 날짜 구간을 의미합니다.
-
status: 배치 진행 상태를 의미합니다. 관리 주체는 Airflow DAG 입니다.
-
hotelIds: 배치 EXTRACT 단계에서 업데이트가 필요한 호텔 ID 목록을 저장합니다.
-
updatedIds: 업데이트를 정상적으로 마친 호텔 ID 목록을 저장합니다.
-
failedId: 업데이트에 실패한 호텔 ID를 저장합니다.
-
failedMessage: 업데이트를 실패한 이유를 저장합니다.
-
isNotiPublished: 몽키트래블 어드민에게 알림이 전송됐는지에 관한 여부입니다. 어드민 페이지의 호텔 리스트에서 업데이트 된 호텔에 하이라이트 표기하고, 텔레그램으로 업데이트 알림을 보냅니다.
-
lastJobStep: 마지막으로 수행된 Step이 어디인지 알기 위한 기록입니다. 문제가 발생해 배치가 종료된 경우 이 Step 부터 진행하도록 해 시간적, 비용적 리소스를 줄입니다.
-
syncDate: 어드민 페이지에서 실제로 어드민이 업데이트를 진행한 날짜입니다. 최종 업데이트 결정권은 몽키트래블 어드민이 가지며, 어느 어드민이 업데이트를 수행했는지 알 수 있습니다.
Agoda 일간 호텔 ID 적재 배치 로직
1/** 2 * [Extract] FEED_ID 32 mtypeid(1 or 2 or 3): 업데이트 된 호텔 목록 조회 & 적재 3 * 4 * @param int $mtypeid 5 * @param string $mdate 6 * @return mixed|void 7 * @throws \Exception 8 */ 9public function extractUpdatedHotelIdsJob(int $mtypeid, string $mdate) 10{ 11 try { 12 $this->logger->info("FEED_ID 32 mtypeid(1 or 2 or 3): 업데이트 된 호텔 목록 조회 & 적재 작업을 시작합니다. 파라미터 (mtypeid: $mtypeid, mdate: $mdate)"); 13 14 /* 15 * [A] 16 * 초입 단계에서 ota_daily_batch_execution_status 테이블에 17 * 입력으로 들어온 mtypeid, mdate가 일치하는 레코드가 존재하고 & 18 * status가 RUNNING 인 경우 Extract 프로세스 조기 종료 19 */ 20 $existingRunningRecord = $this->findTodayRunningRecordByMtypeid($mtypeid); 21 22 // RUNNING 레코드가 있는 경우 배치 프로세스 조기 정상 종료 23 if (!empty($existingRunningRecord)) { 24 $this->logger->info(BatchErrorCode::AGODA_DAILY_IS_RUNNING->getMessage("ko")); 25 return [ 26 "result" => false, 27 "data" => ["code" => BatchErrorCode::AGODA_DAILY_IS_RUNNING], 28 "errorMsg" => BatchErrorCode::AGODA_DAILY_IS_RUNNING->getMessage(), 29 ]; 30 } 31 32 $this->logger->info('$existingRecord: ' . json_encode($existingRunningRecord)); 33 34 /* 35 * [B] 36 * FEED_ID 32 API는 페이지네이션 방식으로 데이터를 조회한다. 37 * 따라서 다음의 초기 값을 가지고 순회를 진행하다 currentPage가 totalPage에 도달한 경우 다음 페이지가 없는 것으로 간주하고 루프를 종료한다. 38 * - currentPage = 1 39 * - total = 2 40 * 41 * FEED_ID 32 응답 데이터 中 page 데이터 형식 예시 42 * "page": { 43 * "id": 1, 44 * "total": 1 45 * } 46 */ 47 $tobeUpdatedHotelIdsArray = []; // 호텔 IDs 누적 저장 48 $record = null; 49 while ($this->currentPage <= $this->totalPage) { 50 // Content API(FEED_ID: 32) 요청 51 $record = $this->updatedHotelIdsReader($mtypeid, $mdate, $tobeUpdatedHotelIdsArray); 52 53 $tobeUpdatedHotelIdsArray = $record->hotelIds; 54 } 55 56 if (!isset($record)) { 57 $message = "[SUCCESS] 업데이트가 필요한 호텔 ID 목록이 없습니다. 배치를 정상적으로 종료합니다."; 58 $this->logger->info($message); 59 return [ 60 "result" => true, 61 "errorMsg" => $message, 62 ]; 63 } 64 65 // DB.product_hotel_supplier.ota_daily_batch_execution_status 테이블에 적재 66 $saveRecordId = $this->insert($record); 67 $this->logger->info("[SUCCESS] 업데이트가 필요한 호텔 ID 목록 적재 배치 수행을 마쳤습니다. 배치를 정상적으로 종료합니다. 파라미터(mtypeid: {$mtypeid}, mdate: {$mdate})"); 68 69 return [ 70 "result" => true, 71 "data" => ["saveRecordId" => $saveRecordId] 72 ]; 73 } catch (\Exception $e) { 74 $this->logger->info("[FAILED] 에러가 발생했습니다. 에러 메시지: " . $e->getMessage()); 75 throw $e; 76 } 77}
호텔 상세 정보 업데이트 배치
1/** 2 * FEED_ID(32) Load 작업 마친 후 업데이트가 필요한 호텔 ID 목록 기반 호텔 상세 정보 업데이트 Job 3 * 4 * ** Agoda Affiliate API 단건 조회, 단건 적재 & 단건 마이그레이션 ** 5 * 1. [Extract] hotelIds 를 기준으로 순회하며 Feed 19, Feed 10, Feed 31, Feed 14 조회 6 * 2. [Transform & Load] hotel, room, facility 마이그레이션 수행 7 */ 8public function updateTobeUpdatedHotelsJob(int $tobeUpdateRowId, LanguageCode $languageCode) 9{ 10 try { 11 /* 12 * 1. ota_daily_batch_execution_status에서 id & status(EXTRACTED)를 기준으로 업데이트 하고자 하는 호텔 id 목록 조회 13 * 2. 추출된 hotelIds 목록을 기반으로 맨 앞 요소부터 업데이트 수행 14 * 3. (FEED_ID 19) Hotel Full Info 조회 & 적재 15 * 4. (FEED_ID 10) Hotel Other Info 조회 & 적재 16 * 5. (FEED_ID 31) Hotel Local Info 조회 & 적재 17 * 6. (FEED_ID 14) Hotel Facility Info per roomtype 조회 & 적재 18 * 7. Origin -> Supplier Master 마이그레이션 19 * 8. Origin -> Supplier Facility 마이그레이션 20 * 9. Origin -> Supplier Room 마이그레이션 21 */ 22 23 // 마지막 배치 수행 Job name 24 $lastJobStep = AgodaLastJobStep::NONE; 25 // 업데이트 대상 OTA 타입 26 $updateTargetOtaType = OtaType::AGODA; 27 28 // DB에 업데이트 완료된 호텧 IDs 저장 용도 29 $updatedHotelIds = []; 30 31 /** [1] */ 32 $tobeUpdatedHotelsArray = $this->findTobeUpdatedByIdAndStatus($tobeUpdateRowId, BatchProcessType::EXTRACTED); 33 if (empty($tobeUpdatedHotelsArray)) { 34 $msg = "[FAIL] 해당 ota_daily_batch_execution_status recordId: {$tobeUpdateRowId}, status: EXTRACTED 와 일치하는 레코드가 존재하지 않습니다. 배치 프로세스를 종료합니다. LOAD 절차를 수행하기에 앞서 배치 상태는 반드시 EXTRACTED 상태여야 합니다."; 35 $this->logger->info($msg); 36 return [ 37 "result" => false, 38 "errorMsg" => $msg 39 ]; 40 } 41 // hotelIds string -> array 42 $tobeUpdatedHotelsArray['hotelIds'] = json_decode($tobeUpdatedHotelsArray['hotelIds'] ?? [], true); 43 44 /** [2] */ 45 while (true) { 46 // 맨 앞 요소 추출 47 $hotelId = array_shift($tobeUpdatedHotelsArray['hotelIds']); 48 49 // 배치 종료 도달점 체크 50 if (empty($hotelId)) { 51 $message = "[SUCCESS] 업데이트 처리가 필요한 Agoda 호텔 및 객실 업데이트 배치 프로세스를 완료했습니다. 배치 프로세스를 종료합니다. 저장한 agoda_tobe_update_hotel id($tobeUpdateRowId), language({$languageCode->label()}), mtypeid({$tobeUpdatedHotelsArray['mtypeid']}), mdate({$tobeUpdatedHotelsArray['mdate']})"; 52 $this->logger->info($message); 53 54 // ota_daily_batch_execution_status (status -> SUCCESS), 마지막 배치 Job Step, 업데이트된 호텔 IDs 업데이트 55 $this->updateTobeUpdatedById( 56 $this->convertObjectToArray( 57 BatchSupplierAgodaTobeUpdateHotel::ofSuccess( 58 $tobeUpdateRowId, 59 $updatedHotelIds, 60 BatchProcessType::SUCCESS, 61 $lastJobStep))); 62 63 return [ 64 "result" => true, 65 "message" => $message 66 ]; 67 } 68 69 // 기존 레코드 조회 70 $updateTargetHotelRecordId = $this->findIdAndHotelIdAndCountryIdByHotelId($hotelId, $languageCode)['id'] ?? null; 71 72 // 가져온 호텔 id 목록을 기준으로 Agoda Affiliate FEED_ID 19 호출 73 // 업데이트 수행 전 ota_daily_batch_execution_status (lastJobStep -> [1. SUPP_FULL_INFO]) 로 업데이트 74 $lastJobStep = AgodaLastJobStep::ORIGIN_FULL_INFO; 75 76 /** [3] FEED_ID(19) Hotel Full Info */ 77 $hotelFullInfoJsonStr = $this->callContentAPI(ContentAPIRequest::toHotelInfoQueryParams($this->FEED_ID_HOTEL_FULL_INFO, $languageCode, $hotelId)); 78 $hotelFullInfo = BatchAgodaHotel::fromHotelFullInformationJson($hotelFullInfoJsonStr); 79 $this->upsert($this->TABLE_PRODUCT_HOTEL_SUPPLIER_AGODA_HOTEL . $languageCode->label() . '.hotel', $hotelFullInfo ? [$this->convertObjectToArray($hotelFullInfo)] : []); 80 81 /** [4] FEED_ID(10) Hotel Other Info */ 82 $lastJobStep = AgodaLastJobStep::ORIGIN_OTHER_INFO; 83 $hotelOtherInfo = BatchAgodaHotel::fromHotelOtherInformationJsonForUpdate( 84 $this->callContentAPI(ContentAPIRequest::toHotelInfoQueryParams($this->FEED_ID_HOTEL_OTHER_INFO, $languageCode, $hotelId)), 85 $updateTargetHotelRecordId); 86 $this->upsert($this->TABLE_PRODUCT_HOTEL_SUPPLIER_AGODA_HOTEL . $languageCode->label() . '.hotel', $hotelOtherInfo ? [$this->convertObjectToArray($hotelOtherInfo)] : []); 87 88 ... 생략 ... 89 } 90 } catch (\Exception $e) { 91 // 배치 실패 지점(hotelId), lastBatchStep 로깅 92 $message = "[FAIL] $lastJobStep->value 단계에서 예외가 발생했습니다. hotelId($hotelId), ERROR MESSAGE: {$e->getMessage()}. 배치 프로세스를 종료합니다."; 93 $this->logger->error($message); 94 95 // ota_daily_batch_execution_status status를 FAILED 로 변경 96 $this->updateTobeUpdatedById( 97 $this->convertObjectToArray( 98 BatchSupplierAgodaTobeUpdateHotel::ofFailure( 99 $tobeUpdateRowId, 100 $updatedHotelIds, 101 $hotelId, 102 $message, 103 BatchProcessType::FAILED, 104 $lastJobStep))); 105 106 return [ 107 "result" => false, 108 "errorMsg" => $message 109 ]; 110 } 111}
예외 케이스에 대한 대응
호텔 도메인에 대한 이해
Tech Stack
OTA 자동화 파이프라인과 레거시 어드민을 현대화하기 위해 PHP 워커, Airflow DAG, Redis/OpenSearch 조합을 적용했습니다.
PIPELINE
Data Pipeline & Batch Automation
Airflow DAG와 PHP 워커를 결합해 OTA 데이터를 병렬로 수집, 정제하고 Failover 지점을 관리했습니다.
BACKOFFICE
Admin Experience & Domain Modeling
정규화된 호텔/객실 모델과 어드민 UI 흐름을 정의해 신규 데이터 생성과 검수 속도를 높였습니다.
OPS
Operations & Monitoring
텔레그램 알림, 운영 대시보드, 배치 모니터링을 통해 현지 운영팀이 즉시 대응하도록 가시성을 마련했습니다.