From 0b6ed3df73e66de3bbefb818674b4e150e656150 Mon Sep 17 00:00:00 2001 From: jjstyle Date: Wed, 25 Mar 2026 20:51:38 +0900 Subject: [PATCH] =?UTF-8?q?worker=20service=20=EB=A7=A4=EB=AC=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20redis=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EC=8B=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- SESSION_README.md | 360 ++++++++++++++++++ app/Commands/NaverWorker.php | 292 +++++++++----- app/Config/Session.php | 67 ++-- app/Controllers/KisoController.php | 88 +++-- app/Controllers/Login.php | 52 ++- app/Filters/AuthCheck.php | 29 +- app/Helpers/redis_helper.php | 82 ++++ app/Services/Handlers/TypeSHandler.php | 10 +- .../ParameterMapper/TypeSParameterMapper.php | 26 +- app/Views/pages/login.php | 33 ++ worker/api_receiver.php | 134 +++++-- 12 files changed, 976 insertions(+), 199 deletions(-) create mode 100644 SESSION_README.md create mode 100644 app/Helpers/redis_helper.php diff --git a/.gitignore b/.gitignore index b091baa..07accb1 100644 --- a/.gitignore +++ b/.gitignore @@ -175,4 +175,4 @@ _modules/* # 6. 기타 개인 설정 파일 (선택적) -.github/copilot-instructions.md \ No newline at end of file +.github/copilot-instructions.mdworker/fallback_queue/*.json diff --git a/SESSION_README.md b/SESSION_README.md new file mode 100644 index 0000000..034b681 --- /dev/null +++ b/SESSION_README.md @@ -0,0 +1,360 @@ +# Session 관리 가이드 + +## 개요 + +본 애플리케이션은 **Redis**를 기본 세션 저장소로 사용하며, Redis 장애 시 **Database(MariaDB)**로 자동 폴백하는 이중화 구조를 가지고 있습니다. + +## 아키텍처 + +### 세션 저장소 + +| 우선순위 | 핸들러 | 설명 | 성능 | +|---------|-------|------|------| +| 1순위 | **RedisHandler** | 메모리 기반 고속 세션 | ~0.03초 | +| 2순위 | **DatabaseHandler** | DB 기반 안정적 세션 | ~0.05초 (수동 전환 시) | + +### 구성 요소 + +``` +┌─────────────────────────────────────────┐ +│ 사용자 로그인 요청 │ +└──────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Session.php 생성자 │ +│ - Redis 연결 테스트 │ +│ - SESSION_FORCE_DATABASE 확인 │ +└──────────────┬──────────────────────────┘ + │ + ┌──────┴──────┐ + │ │ + Redis 정상? Redis 장애? + │ │ + ▼ ▼ + RedisHandler DatabaseHandler + (ci_sessions (ci_sessions + 테이블) 테이블) + │ │ + └──────┬──────┘ + ▼ + 세션 데이터 저장/조회 +``` + +## 설정 파일 + +### 1. Session.php (`src/app/Config/Session.php`) + +```php +public string $driver = RedisHandler::class; + +public function __construct() +{ + // 환경변수로 강제 Database 모드 설정 가능 + $forceDatabase = env('SESSION_FORCE_DATABASE', false); + + if ($this->driver === RedisHandler::class && !$forceDatabase) { + try { + // Redis 연결 테스트 (타임아웃: 0.5초) + $testRedis = get_redis_connection('session'); + + if (!$testRedis) { + throw new \Exception('Redis connection failed'); + } + + // Redis 정상 - Redis 사용 + $this->savePath = 'tcp://192.168.10.243:6379?database=0'; + + } catch (\Exception $e) { + // Redis 실패 - DatabaseHandler로 폴백 + $this->driver = DatabaseHandler::class; + $this->savePath = 'ci_sessions'; + } + } else { + // Database 모드 + $this->savePath = 'ci_sessions'; + } +} +``` + +### 2. 환경 설정 (`.env`) + +```bash +# Redis 세션 설정 +SESSION_REDIS_HOST = 192.168.10.243 +SESSION_REDIS_PORT = 6379 +SESSION_REDIS_DATABASE = 0 +#SESSION_REDIS_PASSWORD = + +# 세션 강제 Database 모드 (Redis 장애 시 true로 변경) +SESSION_FORCE_DATABASE = false +``` + +### 3. Redis Helper (`src/app/Helpers/redis_helper.php`) + +```php +function get_redis_connection(string $type = 'worker') +{ + $redis = new \Redis(); + + // 타임아웃 0.5초로 빠른 실패 감지 + $timeout = 0.5; + $success = $redis->connect($host, $port, $timeout); + + return $success ? $redis : false; +} +``` + +## 데이터베이스 테이블 + +### ci_sessions 테이블 구조 + +```sql +CREATE TABLE `ci_sessions` ( + `session_id` VARCHAR(40) NOT NULL DEFAULT '0', + `ip_address` VARCHAR(16) NOT NULL DEFAULT '0', + `user_agent` VARCHAR(120) NOT NULL, + `last_activity` INT UNSIGNED NOT NULL DEFAULT 0, + `user_data` VARCHAR(4000) NOT NULL, + PRIMARY KEY (`session_id`) +) ENGINE = MEMORY; + +CREATE INDEX `last_activity_idx` +ON `ci_sessions` (`last_activity` ASC); +``` + +**주요 특징:** +- **ENGINE = MEMORY**: 메모리 기반 테이블로 매우 빠른 읽기/쓰기 성능 +- **장점**: Redis와 비슷한 속도 (디스크 I/O 없음) +- **단점**: 서버 재시작 시 세션 데이터 소실 (일반적으로 문제없음 - 세션은 휘발성 데이터) +- **용량**: VARCHAR(4000)으로 충분한 세션 데이터 저장 가능 + +## 운영 가이드 + +### 1. Redis 장애 발생 시 + +#### 자동 폴백 (기본 동작) +- Redis 연결 실패 시 자동으로 DatabaseHandler로 전환 +- **단점**: 매 요청마다 Redis 연결 시도 → 타임아웃 대기 (약 0.5초 추가) +- 사용자는 약간 느린 응답 시간을 경험 + +#### 수동 전환 (권장) +Redis 장애 감지 즉시 다음 조치: + +```bash +# 1. .env 파일 수정 +vi /path/to/src/.env + +# SESSION_FORCE_DATABASE를 true로 변경 +SESSION_FORCE_DATABASE = true + +# 2. PHP-FPM 재시작 (선택사항, 다음 요청부터 자동 적용) +docker exec projects-admin_confirms kill -USR2 1 +``` + +**장점**: Redis 연결 시도 없이 즉시 Database 사용 → 빠른 응답 (0.05초) + +### 2. Redis 복구 후 + +```bash +# 1. .env 파일 원복 +SESSION_FORCE_DATABASE = false + +# 2. PHP-FPM 재시작 +docker exec projects-admin_confirms kill -USR2 1 +``` + +### 3. 모니터링 + +#### 로그 확인 +```bash +# 세션 관련 로그 확인 +docker exec projects-admin_confirms tail -f /var/www/html/writable/logs/log-$(date +%Y-%m-%d).log | grep -i session + +# Redis 폴백 경고 확인 +docker exec projects-admin_confirms grep "Redis unavailable" /var/www/html/writable/logs/log-$(date +%Y-%m-%d).log +``` + +#### 예상 로그 메시지 + +**Redis 정상:** +``` +DEBUG - Session: Class initialized using 'CodeIgniter\Session\Handlers\RedisHandler' driver. +``` + +**Redis 장애 (자동 폴백):** +``` +WARNING - Session: Redis unavailable (Redis connection failed), falling back to DatabaseHandler +DEBUG - Session: Class initialized using 'CodeIgniter\Session\Handlers\DatabaseHandler' driver. +``` + +**Database 강제 모드:** +``` +INFO - Session: Forced to use DatabaseHandler (SESSION_FORCE_DATABASE=true) +DEBUG - Session: Class initialized using 'CodeIgniter\Session\Handlers\DatabaseHandler' driver. +``` + +### 4. 성능 모니터링 + +```bash +# 로그인 응답 시간 측정 +for i in {1..5}; do + curl -X POST http://localtest2-admin.confirms.co.kr/login/chkLogin \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "user_id=test&user_pw=test" \ + -o /dev/null -s -w " Time: %{time_total}s\n" +done +``` + +**기대 성능:** +- Redis 정상: 0.03 ~ 0.05초 +- Database (수동 전환): 0.05 ~ 0.08초 +- 자동 폴백 (Redis 다운): 0.5 ~ 1.0초 + +## 사용자 알림 + +### 프론트엔드 경고 표시 + +Redis 장애 시 로그인 페이지에 자동으로 경고 배너가 표시됩니다: + +```javascript +// login.php의 JavaScript +function checkSystemStatus(responseData) { + if (responseData.system && responseData.system.redis_fallback) { + // 경고 메시지: "세션 서버(Redis) 장애로 임시 모드로 운영 중입니다." + showRedisWarning(responseData.system.warning_message); + } +} +``` + +**API 응답 예시:** +```json +{ + "code": "0", + "msg": "success", + "system": { + "redis_fallback": true, + "warning_message": "세션 서버(Redis) 장애로 임시 모드로 운영 중입니다. 시스템 관리자에게 문의하세요." + } +} +``` + +## 트러블슈팅 + +### 문제: 로그인이 느림 (2~3초) + +**원인**: Redis 다운 상태에서 자동 폴백 모드 사용 중 + +**해결**: +```bash +# SESSION_FORCE_DATABASE=true로 수동 전환 +echo "SESSION_FORCE_DATABASE = true" >> /path/to/src/.env +``` + +### 문제: 세션이 유지되지 않음 + +**원인**: ci_sessions 테이블이 없거나 권한 문제 + +**해결**: +```sql +-- 테이블 존재 확인 +SHOW TABLES LIKE 'ci_sessions'; + +-- 권한 확인 +SHOW GRANTS FOR 'confirms'@'%'; + +-- 테이블이 없는 경우 생성 +CREATE TABLE `ci_sessions` ( + `session_id` VARCHAR(40) NOT NULL DEFAULT '0', + `ip_address` VARCHAR(16) NOT NULL DEFAULT '0', + `user_agent` VARCHAR(120) NOT NULL, + `last_activity` INT UNSIGNED NOT NULL DEFAULT 0, + `user_data` VARCHAR(4000) NOT NULL, + PRIMARY KEY (`session_id`) +) ENGINE = MEMORY; + +CREATE INDEX `last_activity_idx` +ON `ci_sessions` (`last_activity` ASC); +``` + +### 문제: 서버 재시작 후 모든 세션이 사라짐 + +**원인**: ci_sessions가 MEMORY 엔진을 사용 (정상 동작) + +**설명**: +- MEMORY 엔진은 서버 재시작 시 데이터가 삭제됩니다 +- 세션은 일시적 데이터이므로 일반적으로 문제가 되지 않습니다 +- 사용자는 재로그인하면 됩니다 + +**영구 저장이 필요한 경우**: +```sql +-- InnoDB로 변경 (성능은 약간 느려짐) +ALTER TABLE ci_sessions ENGINE=InnoDB; +``` + +### 문제: Redis 연결 타임아웃이 너무 김 + +**원인**: redis_helper.php의 타임아웃 설정 + +**해결**: +```php +// src/app/Helpers/redis_helper.php +$timeout = 0.5; // 0.1 ~ 1.0 사이 값으로 조정 +$redis->connect($host, $port, $timeout); +``` + +## 관련 파일 + +| 파일 | 역할 | +|------|------| +| `src/app/Config/Session.php` | 세션 설정 및 폴백 로직 | +| `src/app/Helpers/redis_helper.php` | Redis 연결 헬퍼 함수 | +| `src/app/Controllers/Login.php` | 로그인 및 시스템 상태 체크 | +| `src/app/Views/pages/login.php` | 프론트엔드 경고 표시 | +| `src/.env` | 환경 설정 (Redis 연결 정보, 강제 모드) | + +## Worker 및 API (참고) + +### Worker (NaverWorker.php) +- Redis 큐 사용 (DB 9) +- Redis 장애 시 파일 기반 폴백 (`worker/fallback_queue/*.json`) + +### API (api_receiver.php, KisoController.php) +- Redis 큐 사용 (DB 9) +- Redis 장애 시 파일 기반 폴백 (`worker/fallback_queue/*.json`) + +## 추가 개선 사항 + +### 고려 중인 기능 + +1. **Redis Sentinel 도입** (고가용성) + - 자동 장애 조치 + - 마스터/슬레이브 구조 + +2. **APM 연동** + - New Relic, DataDog 등 + - 실시간 성능 모니터링 + +3. **헬스체크 엔드포인트** + ```php + // GET /health/session + { + "status": "ok", + "driver": "RedisHandler", + "redis_available": true, + "response_time_ms": 12 + } + ``` + +## 문의 + +세션 관련 문제 발생 시: +1. 로그 확인 (`/writable/logs/`) +2. Redis 서버 상태 확인 +3. ci_sessions 테이블 상태 확인 +4. 필요 시 수동 전환 수행 + +--- + +**Last Updated**: 2026-03-25 +**Version**: 1.0.0 diff --git a/app/Commands/NaverWorker.php b/app/Commands/NaverWorker.php index 0d7d274..9ab0dbb 100644 --- a/app/Commands/NaverWorker.php +++ b/app/Commands/NaverWorker.php @@ -23,136 +23,155 @@ class NaverWorker extends BaseCommand public function run(array $params) { - helper('log'); // 여기서 로드 완료! + helper(['log', 'redis']); // redis helper 추가 $this->db = \Config\Database::connect(); $logModel = model(NaverWorkerLogModel::class); $naverService = new \App\Services\NaverService(); // 서비스 생성 - $redis = new \Redis(); - try { - // 두 가지 환경 변수 형식 지원 (REDIS_HOST 또는 redis.default.host) - $this->redisHost = getenv('REDIS_HOST') ?: (getenv('redis.default.host') ?: '127.0.0.1'); - $this->redisPort = getenv('REDIS_PORT') ?: (getenv('redis.default.port') ?: 6379); - $this->redisDatabase = getenv('REDIS_DATABASE') ?: (getenv('redis.default.database') ?: 9); - - $redis->connect($this->redisHost, (int)$this->redisPort); - $redis->select((int)$this->redisDatabase); - CLI::write(CLI::color('🟢 Naver Worker running... (Redis: ' . $this->redisHost . ':' . $this->redisPort . ' DB:' . $this->redisDatabase . ')', 'green')); - } catch (\Exception $e) { - CLI::error("Redis 연결 불가: " . $e->getMessage()); - return; + // Redis 연결 (실패해도 계속 진행 - 파일 모드로 동작 가능) + $redis = get_redis_connection('worker'); + $config = get_redis_config('worker'); + + if ($redis) { + CLI::write(CLI::color('🟢 Naver Worker running... (Redis: ' . $config['host'] . ':' . $config['port'] . ' DB:' . $config['database'] . ')', 'green')); + } else { + CLI::write(CLI::color('⚠️ Naver Worker running in FILE-ONLY mode (Redis unavailable)', 'yellow')); } while (true) { - // Redis brPop with retry logic - $maxRetries = 3; - $retryCount = 0; - $result = null; - - while ($retryCount < $maxRetries) { - try { - $result = $redis->brPop(['naver:raw_queue'], 30); - break; // 성공하면 루프 탈출 - } catch (\RedisException $e) { - $retryCount++; - CLI::write(CLI::color("⚠️ Redis error (attempt {$retryCount}/{$maxRetries}): " . $e->getMessage(), 'yellow')); - - if ($retryCount >= $maxRetries) { - CLI::error("❌ Redis reconnection failed after {$maxRetries} attempts"); - break; - } - - // Redis 재연결 시도 + // Redis 또는 폴백 파일에서 데이터 읽기 + $rawData = null; + $source = 'redis'; // 데이터 소스 추적 + + // 1. Redis에서 데이터 읽기 시도 (Redis가 있을 경우만) + if ($redis) { + $maxRetries = 2; + $retryCount = 0; + + while ($retryCount < $maxRetries) { try { - CLI::write(CLI::color('🔄 Reconnecting to Redis...', 'yellow')); - $redis->close(); - $redis->connect($this->redisHost, (int)$this->redisPort); - $redis->select((int)$this->redisDatabase); - CLI::write(CLI::color('✅ Redis reconnected', 'green')); - } catch (\Exception $reconnectError) { - CLI::error("Redis reconnection error: " . $reconnectError->getMessage()); - sleep(2); // 재연결 실패 시 잠시 대기 + $result = $redis->brPop(['naver:raw_queue'], 5); // 5초 타임아웃 + if ($result) { + $rawData = $result[1]; + $source = 'redis'; + } + break; // 성공하면 루프 탈출 + } catch (\Exception $e) { + $retryCount++; + CLI::write(CLI::color("⚠️ Redis error (attempt {$retryCount}/{$maxRetries}): " . $e->getMessage(), 'yellow')); + + if ($retryCount >= $maxRetries) { + CLI::write(CLI::color("⚠️ Redis unavailable, switching to file mode", 'yellow')); + $redis = null; // Redis를 비활성화 + break; + } + + // Redis 재연결 시도 + try { + CLI::write(CLI::color('🔄 Reconnecting to Redis...', 'yellow')); + $redis->close(); + $redis = get_redis_connection('worker'); + if ($redis) { + CLI::write(CLI::color('✅ Redis reconnected', 'green')); + } + } catch (\Exception $reconnectError) { + CLI::error("Redis reconnection error: " . $reconnectError->getMessage()); + } } } } - if (!$result) { - // 데이터가 없어서 타임아웃 난 경우. - // 굳이 sleep 안 해도 바로 다음 brPop이 다시 30초 대기를 시작함. + // 2. Redis에서 데이터 없으면 폴백 파일 확인 + if (!$rawData) { + $rawData = $this->readFromFallbackFile(); + if ($rawData) { + $source = 'file'; + } + } + + // 3. 데이터 없으면 다음 루프 + if (!$rawData) { continue; } - if ($result) { - $rawData = $result[1]; + // 4. 데이터 소스 로깅 + CLI::write(CLI::color("📥 Data received from: " . strtoupper($source), 'cyan')); - // [1] 꺼내자마자 DB에 원문 저장 (2차 임시 저장) - 실패 시 재시도 - try { - $logId = $logModel->insert([ - 'raw_payload' => $rawData, - 'status' => 'INIT' - ]); - } catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) { - // MySQL gone away 에러 시 재연결 후 재시도 - if (strpos($e->getMessage(), 'MySQL server has gone away') !== false) { - CLI::write(CLI::color('⚠️ MySQL gone away, reconnecting...', 'yellow')); - $this->db->close(); - $this->db = \Config\Database::connect(); - $logModel = model(NaverWorkerLogModel::class); - - // 재시도 - $logId = $logModel->insert([ - 'raw_payload' => $rawData, - 'status' => 'INIT' - ]); - } else { - throw $e; // 다른 에러면 그대로 throw - } - } - - try { - $responseJson = json_decode($result[1], true); - $payload = $responseJson['request_data'] ?? []; - - if (empty($payload)) { - throw new \Exception("빈 페이로드 데이터"); - } - - // 서비스의 함수 하나로 모든 처리 완료 - $insertId = $naverService->processArticle($payload); - - // [3] 성공 시 로그 업데이트 (재연결 처리 포함) - $this->safeUpdateLog($logModel, $logId, [ - 'atcl_no' => $payload['articleNumber'] ?? null, - 'status' => 'SUCCESS', - 'target_db_id' => $insertId - ]); + // [1] 꺼내자마자 DB에 원문 저장 (2차 임시 저장) - 실패 시 재시도 + try { + $logId = $logModel->insert([ + 'raw_payload' => $rawData, + 'status' => 'INIT' + ]); + } catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) { + // MySQL gone away 에러 시 재연결 후 재시도 + if (strpos($e->getMessage(), 'MySQL server has gone away') !== false) { + CLI::write(CLI::color('⚠️ MySQL gone away, reconnecting...', 'yellow')); + $this->db->close(); + $this->db = \Config\Database::connect(); + $logModel = model(NaverWorkerLogModel::class); - CLI::write("✅ Success! DB ID: $insertId", 'cyan'); - - } catch (\Exception $e) { - CLI::error("❌ Task Failed: " . $e->getMessage()); - // 실패 로그는 여기서 남김 - // 1. DB 상태를 FAIL로 업데이트 (필수) (재연결 처리 포함) - $this->safeUpdateLog($logModel, $logId, [ - 'status' => 'FAIL', - 'error_msg' => $e->getMessage() + // 재시도 + $logId = $logModel->insert([ + 'raw_payload' => $rawData, + 'status' => 'INIT' ]); - - // 2. Redis 실패 큐에 백업 (선택 - 나중에 모아서 다시 던질 때 편함) - $redis->lPush('naver:failed_queue', $rawData); - helper('log'); - write_custom_log("FAILED_DATA | Error: " . $e->getMessage(), 'ERROR', 'failed'); - - // 루프 과부하 방지 (연속 에러 시) - sleep(1); + } else { + throw $e; // 다른 에러면 그대로 throw } } + + try { + $responseJson = json_decode($rawData, true); + $payload = $responseJson['request_data'] ?? []; + + if (empty($payload)) { + throw new \Exception("빈 페이로드 데이터"); + } + + // 서비스의 함수 하나로 모든 처리 완료 + $insertId = $naverService->processArticle($payload); + + // [3] 성공 시 로그 업데이트 (재연결 처리 포함) + $this->safeUpdateLog($logModel, $logId, [ + 'atcl_no' => $payload['articleNumber'] ?? null, + 'status' => 'SUCCESS', + 'target_db_id' => $insertId + ]); + + CLI::write("✅ Success! DB ID: $insertId | Source: $source", 'cyan'); + + } catch (\Exception $e) { + CLI::error("❌ Task Failed: " . $e->getMessage()); + // 실패 로그는 여기서 남김 + // 1. DB 상태를 FAIL로 업데이트 (필수) (재연결 처리 포함) + $this->safeUpdateLog($logModel, $logId, [ + 'status' => 'FAIL', + 'error_msg' => $e->getMessage() + ]); + + // 2. Redis 실패 큐에 백업 (선택 - Redis가 있을 경우만) + if ($redis) { + try { + $redis->lPush('naver:failed_queue', $rawData); + } catch (\Exception $redisEx) { + // Redis 실패 시에도 에러 처리하지 않음 (이미 DB에 FAIL 로그 남김) + CLI::write(CLI::color('⚠️ Failed to push to failed_queue: ' . $redisEx->getMessage(), 'yellow')); + } + } + + helper('log'); + write_custom_log("FAILED_DATA | Error: " . $e->getMessage(), 'ERROR', 'failed'); + + // 루프 과부하 방지 (연속 에러 시) + sleep(1); + } } - } +} /** * MySQL gone away 에러 발생 시 재연결 후 재시도하는 안전한 update @@ -176,5 +195,68 @@ class NaverWorker extends BaseCommand } } + /** + * 폴백 파일에서 데이터 읽기 (Redis 장애 시 파일에서 직접 처리) + * + * @return string|null JSON 데이터 또는 null + */ + protected function readFromFallbackFile() + { + $fallbackDir = ROOTPATH . 'worker/fallback_queue'; + + // 폴백 디렉토리가 없으면 null 반환 + if (!is_dir($fallbackDir)) { + return null; + } + + // 폴백 파일 목록 가져오기 (오래된 순서대로) + $files = glob($fallbackDir . '/*.json'); + + if (empty($files)) { + return null; + } + + sort($files); // 파일명(타임스탬프) 기준 정렬 + + // 가장 오래된 파일 하나 처리 + $filePath = $files[0]; + + try { + // 파일 락을 사용하여 읽기 (동시 접근 방지) + $fp = fopen($filePath, 'r'); + if (!$fp) { + CLI::write(CLI::color("⚠️ Failed to open fallback file: " . basename($filePath), 'yellow')); + return null; + } + + // 배타적 락 획득 시도 + if (!flock($fp, LOCK_EX | LOCK_NB)) { + // 락 획득 실패 (다른 프로세스가 처리 중) + fclose($fp); + return null; + } + + // 파일 내용 읽기 + $content = stream_get_contents($fp); + + // 파일 삭제 (처리 완료로 간주) + flock($fp, LOCK_UN); + fclose($fp); + unlink($filePath); + + CLI::write(CLI::color("📂 Processing fallback file: " . basename($filePath), 'green')); + + return $content; + + } catch (\Exception $e) { + CLI::write(CLI::color("❌ Error reading fallback file " . basename($filePath) . ": " . $e->getMessage(), 'red')); + if (isset($fp) && is_resource($fp)) { + flock($fp, LOCK_UN); + fclose($fp); + } + return null; + } + } + } \ No newline at end of file diff --git a/app/Config/Session.php b/app/Config/Session.php index 5ae25d5..375a49c 100644 --- a/app/Config/Session.php +++ b/app/Config/Session.php @@ -4,7 +4,7 @@ namespace Config; use CodeIgniter\Config\BaseConfig; use CodeIgniter\Session\Handlers\BaseHandler; -use CodeIgniter\Session\Handlers\FileHandler; +use CodeIgniter\Session\Handlers\DatabaseHandler; use CodeIgniter\Session\Handlers\RedisHandler; class Session extends BaseConfig @@ -15,14 +15,11 @@ class Session extends BaseConfig * -------------------------------------------------------------------------- * * The session storage driver to use: - * - `CodeIgniter\Session\Handlers\FileHandler` * - `CodeIgniter\Session\Handlers\DatabaseHandler` - * - `CodeIgniter\Session\Handlers\MemcachedHandler` * - `CodeIgniter\Session\Handlers\RedisHandler` * * @var class-string */ - // public string $driver = FileHandler::class; public string $driver = RedisHandler::class; /** @@ -51,43 +48,59 @@ class Session extends BaseConfig * * The location to save sessions to and is driver dependent. * - * For the 'files' driver, it's a path to a writable directory. - * WARNING: Only absolute paths are supported! - * * For the 'database' driver, it's a table name. - * Please read up the manual for the format with other session drivers. + * For Redis: tcp://host:port?database=n&password=xxx * * IMPORTANT: You are REQUIRED to set a valid save path! - * - * For Redis: tcp://host:port?database=n&password=xxx */ - // public string $savePath = WRITEPATH . 'session'; public string $savePath; public function __construct() { parent::__construct(); - // Redis 설정: .env 우선, 없으면 Docker 환경변수 사용 - if ($this->driver === RedisHandler::class) { - // .env 파일이 우선, 없으면 Docker compose 환경변수 사용 - $redisHost = env('SESSION_REDIS_HOST') ?: getenv('REDIS_HOST') ?: '127.0.0.1'; - $redisPort = env('SESSION_REDIS_PORT') ?: getenv('REDIS_PORT') ?: '6379'; - $redisDatabase = env('SESSION_REDIS_DATABASE') ?: getenv('REDIS_DATABASE') ?: '0'; - $redisPassword = env('SESSION_REDIS_PASSWORD') ?: getenv('REDIS_PASSWORD') ?: ''; + // 환경변수로 강제로 Database 모드 설정 가능 (Redis 장애 시 수동 전환) + $forceDatabase = env('SESSION_FORCE_DATABASE', false); - $this->savePath = sprintf( - 'tcp://%s:%s?database=%s', - $redisHost, - $redisPort, - $redisDatabase - ); + // Redis 설정: Redis 연결 실패 시 Database로 폴백 + if ($this->driver === RedisHandler::class && !$forceDatabase) { + helper('redis'); + + try { + // Redis 연결 테스트 + $testRedis = get_redis_connection('session'); + + if (!$testRedis) { + throw new \Exception('Redis connection failed'); + } + + // Redis 정상 - Redis 설정 사용 + $config = get_redis_config('session'); + + $this->savePath = sprintf( + 'tcp://%s:%s?database=%s', + $config['host'], + $config['port'], + $config['database'] + ); - if (!empty($redisPassword)) { - $this->savePath .= '&password=' . $redisPassword; + if (!empty($config['password'])) { + $this->savePath .= '&password=' . $config['password']; + } + + } catch (\Exception $e) { + // Redis 실패 - DatabaseHandler로 폴백 + log_message('warning', 'Session: Redis unavailable (' . $e->getMessage() . '), falling back to DatabaseHandler'); + $this->driver = DatabaseHandler::class; + $this->savePath = 'ci_sessions'; // 테이블 이름 } } else { - $this->savePath = WRITEPATH . 'session'; + // Database 강제 모드 또는 기본 Database 설정 + if ($forceDatabase && $this->driver === RedisHandler::class) { + log_message('info', 'Session: Forced to use DatabaseHandler (SESSION_FORCE_DATABASE=true)'); + $this->driver = DatabaseHandler::class; + } + $this->savePath = 'ci_sessions'; } } diff --git a/app/Controllers/KisoController.php b/app/Controllers/KisoController.php index d7af24a..bc0ccec 100644 --- a/app/Controllers/KisoController.php +++ b/app/Controllers/KisoController.php @@ -13,6 +13,23 @@ class KisoController extends BaseController /** @var ResponseInterface */ protected $response; + /** + * 네이버 검증 요청 API + * + * Required Parameters: + * - articleNumber: 기사 번호 + * - requestType: 요청 타입 (verify, check, validate) + * - requestDatetime: 요청 일시 (YYYY-MM-DD HH:MM:SS) + * + * Error Codes: + * - E001: 필수 파라미터 누락 + * - E002: requestType 값 오류 + * - E003: requestDatetime 형식 오류 + * - E005: HTTP 메소드 오류 + * - E999: Redis 연결 오류 + * + * @return ResponseInterface + */ public function vrfcReq() { // 1. 요청 방식에 따라 데이터 파싱 @@ -25,56 +42,79 @@ class KisoController extends BaseController } else { // 지원하지 않는 메소드 처리 (예: PUT, DELETE 등) return $this->response->setStatusCode(Response::HTTP_METHOD_NOT_ALLOWED) - ->setJSON(['resultCode' => 'E005', 'resultMessage' => 'Method not allowed. Use GET or POST.']); + ->setJSON(['code' => 'E005', 'message' => 'Method not allowed. Use GET or POST.']); } // 2. 필수 항목 검증 $requiredKeys = ['articleNumber', 'requestType', 'requestDatetime']; foreach ($requiredKeys as $key) { - // 파싱된 데이터($data) 내에 키가 없거나 값이 비어있는지 확인 - if (empty($data[$key])) { + // isset()과 trim()을 사용하여 '0' 값도 허용 + if (!isset($data[$key]) || trim((string)$data[$key]) === '') { return $this->response->setStatusCode(Response::HTTP_BAD_REQUEST) ->setJSON([ - 'resultCode' => 'E001', - 'resultMessage' => "Missing required parameter: {$key}" + 'code' => 'E001', + 'message' => "Missing required parameter: {$key}" ]); } } - - // 3. Redis 연결 및 예외 처리 - // 3. Redis 연결 및 직접 푸시 - try { - $redis = new \Redis(); - // Docker 환경이므로 host를 'redis'로 설정 - $success = $redis->connect('redis', 6379); - if (!$success) { + // 3. requestType 값 검증 + $validRequestTypes = ['verify', 'check', 'validate']; // 허용되는 requestType 값 + if (!in_array($data['requestType'], $validRequestTypes, true)) { + return $this->response->setStatusCode(Response::HTTP_BAD_REQUEST) + ->setJSON([ + 'code' => 'E002', + 'message' => "Invalid requestType. Allowed values: " . implode(', ', $validRequestTypes) + ]); + } + + // 4. requestDatetime 날짜 형식 검증 (Y-m-d H:i:s 형식) + $datetime = \DateTime::createFromFormat('Y-m-d H:i:s', $data['requestDatetime']); + if (!$datetime || $datetime->format('Y-m-d H:i:s') !== $data['requestDatetime']) { + return $this->response->setStatusCode(Response::HTTP_BAD_REQUEST) + ->setJSON([ + 'code' => 'E003', + 'message' => "Invalid requestDatetime format. Use: YYYY-MM-DD HH:MM:SS" + ]); + } + + // 5. Redis 연결 및 큐 저장 + helper('redis'); + try { + $redis = get_redis_connection('worker'); + + if (!$redis) { throw new \Exception('Redis connection failed'); } - $redis->select(10); // 10번 DB 선택 - // 데이터 준비 $data['retry_count'] = 0; + $data['received_at'] = date('Y-m-d H:i:s'); - // 리스트에 데이터 삽입 (이 명령어가 실행되어야 monitor에 LPUSH가 뜹니다) - $redis->lPush('naver:queue', json_encode($data)); + // 리스트에 데이터 삽입 + $pushResult = $redis->lPush('naver:queue', json_encode($data)); + + if (!$pushResult) { + throw new \Exception('Failed to push data to Redis queue'); + } + + // 성공 로그 기록 + log_message('info', "Request queued successfully - Article: {$data['articleNumber']}, Type: {$data['requestType']}"); } catch (\Exception $e) { log_message('error', 'Redis Push Error: ' . $e->getMessage()); return $this->response->setStatusCode(500)->setJSON([ - 'resultCode' => 'E999', - 'resultMessage' => 'Redis Connection Error' + 'code' => 'E999', + 'message' => 'Redis Connection Error' ]); } - // 4. 성공 응답 - return $this->response->setStatusCode(Response::HTTP_ACCEPTED) // 202 Accepted + // 6. 성공 응답 + return $this->response->setStatusCode(Response::HTTP_OK) // 200 OK ->setJSON([ - 'resultCode' => 'S000', - 'resultMessage' => 'Request successfully queued for processing', - 'articleNumber' => $data['articleNumber'] + 'code' => 'success', + 'message' => '' ]); } } \ No newline at end of file diff --git a/app/Controllers/Login.php b/app/Controllers/Login.php index 702c214..d34a208 100644 --- a/app/Controllers/Login.php +++ b/app/Controllers/Login.php @@ -3,6 +3,7 @@ namespace App\Controllers; use App\Controllers\BaseController; use App\Models\common\LoginModel; +use CodeIgniter\Session\Handlers\DatabaseHandler; class Login extends BaseController { @@ -13,6 +14,37 @@ class Login extends BaseController $this->loginModel = new LoginModel(); } + /** + * Redis 폴백 상태 체크 (세션이 DatabaseHandler로 동작 중인지) + */ + private function getSystemStatus(): array + { + $status = [ + 'redis_fallback' => false, + 'warning_message' => '' + ]; + + // Session 설정에서 현재 드라이버 확인 + $sessionConfig = config('Session'); + if ($sessionConfig->driver === DatabaseHandler::class) { + $status['redis_fallback'] = true; + $status['warning_message'] = '세션 서버(Redis) 장애로 임시 모드로 운영 중입니다. 시스템 관리자에게 문의하세요.'; + } + + return $status; + } + + /** + * JSON 응답에 시스템 상태 추가 + */ + private function jsonResponse(array $data): \CodeIgniter\HTTP\ResponseInterface + { + $systemStatus = $this->getSystemStatus(); + $data['system'] = $systemStatus; + + return $this->response->setJSON($data); + } + public function index(): string { $user_id = get_cookie('save_id'); @@ -59,7 +91,7 @@ class Login extends BaseController ]; if (!$this->validate($rules)) { - return $this->response->setJSON([ + return $this->jsonResponse([ 'code' => '1', 'errors' => $this->validator->getErrors() ]); @@ -82,7 +114,7 @@ class Login extends BaseController $this->loginModel->insertUserLog($logs); - return $this->response->setJSON([ + return $this->jsonResponse([ 'code' => '1', 'msg' => '존재하지 않는 아이디입니다.' ]); @@ -96,7 +128,7 @@ class Login extends BaseController $this->loginModel->insertUserLog($logs); - return $this->response->setJSON(body: [ + return $this->jsonResponse([ 'code' => '1', 'msg' => '잘못된 비밀번호 입니다.' ]); @@ -135,7 +167,7 @@ class Login extends BaseController $this->session->set($newdata); - return $this->response->setJSON([ + return $this->jsonResponse([ 'code' => '0', 'msg' => 'success' ]); @@ -148,7 +180,17 @@ class Login extends BaseController log_message('error', '[LOGIN ERROR] ' . $e->getMessage()); log_message('error', $e->getTraceAsString()); - return $this->response->setJSON([ + // 세션 관련 에러인지 확인 + $errorMessage = $e->getMessage(); + if (stripos($errorMessage, 'session') !== false || + stripos($errorMessage, 'redis') !== false) { + return $this->jsonResponse([ + 'code' => '8', + 'msg' => '세션 서비스 오류입니다. 시스템 관리자에게 문의해주세요.' + ]); + } + + return $this->jsonResponse([ 'code' => '9', 'msg' => '서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' ]); diff --git a/app/Filters/AuthCheck.php b/app/Filters/AuthCheck.php index 42fb98a..1f049fe 100644 --- a/app/Filters/AuthCheck.php +++ b/app/Filters/AuthCheck.php @@ -13,10 +13,31 @@ class AuthCheck implements FilterInterface $session = session(); log_message('debug', 'URI PATH: ' . service('uri')->getPath()); - // 로그인 체크 - if (!$session->get('logged_in')) { - // 로그인 안 되어 있으면 로그인 페이지로 - return redirect()->to('/login'); + try { + // 세션 읽기 시도 + $loggedIn = $session->get('logged_in'); + + // 로그인 체크 + if (!$loggedIn) { + // 로그인 안 되어 있으면 로그인 페이지로 + return redirect()->to('/login'); + } + } catch (\Exception $e) { + // 세션 오류 (Redis 다운 등) - 모든 예외 catch + log_message('error', '[SESSION ERROR in AuthCheck] ' . $e->getMessage()); + + // AJAX 요청인 경우 JSON 응답 + if ($request->isAJAX()) { + return service('response') + ->setStatusCode(503) + ->setJSON([ + 'code' => '8', + 'msg' => '세션 서비스 오류입니다. 페이지를 새로고침 해주세요. (Redis)' + ]); + } + + // 일반 요청인 경우 로그인 페이지로 리다이렉트 (에러 메시지 포함) + return redirect()->to('/login')->with('error', '세션 서비스 오류입니다. 시스템 관리자에게 문의해주세요.'); } } diff --git a/app/Helpers/redis_helper.php b/app/Helpers/redis_helper.php new file mode 100644 index 0000000..8cc34b9 --- /dev/null +++ b/app/Helpers/redis_helper.php @@ -0,0 +1,82 @@ +connect($host, (int)$port, $timeout); + + if (!$success) { + log_message('error', "Redis connection failed: {$host}:{$port}"); + return false; + } + + // 비밀번호 인증 (있는 경우) + if (!empty($password)) { + $redis->auth($password); + } + + // 데이터베이스 선택 + $redis->select((int)$database); + + return $redis; + + } catch (\RedisException $e) { + log_message('error', "Redis connection error: " . $e->getMessage()); + return false; + } + } +} + +if (!function_exists('get_redis_config')) { + /** + * Redis 설정 정보를 배열로 반환 + * + * @param string $type 'worker' 또는 'session' + * @return array Redis 설정 배열 + */ + function get_redis_config(string $type = 'worker'): array + { + if ($type === 'session') { + return [ + 'host' => env('SESSION_REDIS_HOST', '127.0.0.1'), + 'port' => env('SESSION_REDIS_PORT', '6379'), + 'database' => env('SESSION_REDIS_DATABASE', '0'), + 'password' => env('SESSION_REDIS_PASSWORD', ''), + ]; + } + + return [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DATABASE', '9'), + 'password' => env('REDIS_PASSWORD', ''), + ]; + } +} diff --git a/app/Services/Handlers/TypeSHandler.php b/app/Services/Handlers/TypeSHandler.php index 011b653..1f347d1 100644 --- a/app/Services/Handlers/TypeSHandler.php +++ b/app/Services/Handlers/TypeSHandler.php @@ -53,9 +53,17 @@ class TypeSHandler // 2. Result 데이터 저장 $resultData = $this->parameterMapper->mapResult($rcptSq, $rawData); + write_custom_log("Result Insert 데이터: " . json_encode($resultData, JSON_UNESCAPED_UNICODE), 'INFO', 'service'); + if (!$this->resultModel->insert($resultData)) { - throw new Exception("Result Insert 실패"); + $lastQuery = (string)$this->resultModel->getLastQuery(); + $errors = json_encode($this->resultModel->errors()); + write_custom_log("Result Insert 실패 | SQL: $lastQuery | Errors: $errors", 'ERROR', 'service'); + throw new Exception("Result Insert 실패: $errors"); } + + $insertedResultSql = (string)$this->resultModel->getLastQuery(); + write_custom_log("Result Insert 성공 | SQL: $insertedResultSql", 'INFO', 'service'); CLI::write(CLI::color('✅ Result 저장 성공', 'blue')); // 3. 트랜잭션 커밋 diff --git a/app/Services/ParameterMapper/TypeSParameterMapper.php b/app/Services/ParameterMapper/TypeSParameterMapper.php index 8e996f8..e0848b5 100644 --- a/app/Services/ParameterMapper/TypeSParameterMapper.php +++ b/app/Services/ParameterMapper/TypeSParameterMapper.php @@ -115,21 +115,39 @@ class TypeSParameterMapper extends BaseParameterMapper $charge = null; if ($sectorNumber) { $charge = $this->regionModel->getChargeByRegionCd($sectorNumber); + log_message('info', "[TypeSParameterMapper] 지역코드: {$sectorNumber}, 조회 결과: " . json_encode($charge, JSON_UNESCAPED_UNICODE)); } // 2. 기본 담당자 설정 (지역별 담당자가 없으면) $deptSq = $charge['dept_sq'] ?? '26'; $usrSq = $charge['usr_sq'] ?? '1'; + // 담당자 존재 여부 검증 (users 테이블 확인) + if ($usrSq && $usrSq !== '1') { + $db = \Config\Database::connect(); + $userExists = $db->table('users')->where('usr_sq', $usrSq)->countAllResults() > 0; + if (!$userExists) { + log_message('warning', "[TypeSParameterMapper] usr_sq={$usrSq}가 users 테이블에 없음. 기본값(1)으로 폴백"); + $usrSq = '1'; + $deptSq = '26'; + } + } + + log_message('info', "[TypeSParameterMapper] 기본 담당자 - dept_sq: {$deptSq}, usr_sq: {$usrSq}, VR검증: " . ($isVrVerification ? 'Y' : 'N')); + // 3. VR 검증인 경우 환경별 담당자로 덮어쓰기 if ($isVrVerification) { + log_message('info', "[TypeSParameterMapper] ENVIRONMENT 상수 값: " . ENVIRONMENT); + if (ENVIRONMENT === 'development') { $deptSq = '29'; - $usrSq = '472'; + $usrSq = '472'; // vradmin + log_message('info', "[TypeSParameterMapper] VR 검증 (DEV) - dept_sq: {$deptSq}, usr_sq: {$usrSq}"); } else { - // production - $deptSq = '33'; - $usrSq = '1993'; + // production - TODO: 실제 프로덕션 VR 담당자로 변경 필요 + $deptSq = '29'; + $usrSq = '472'; // 임시: vradmin (프로덕션 VR 담당자 확인 후 수정) + log_message('warning', "[TypeSParameterMapper] VR 검증 (PROD) - 프로덕션 VR 담당자 미설정, 임시 담당자 사용: usr_sq={$usrSq}"); } } diff --git a/app/Views/pages/login.php b/app/Views/pages/login.php index 4b35f01..dad72ca 100644 --- a/app/Views/pages/login.php +++ b/app/Views/pages/login.php @@ -69,6 +69,15 @@
+ + +
+ 시스템 알림 +

+ +
+
@@ -202,6 +211,27 @@ const tpl = document.querySelector('.my-loader-template'); var table; + // Redis 장애 경고 표시 함수 + function showRedisWarning(message) { + const warningDiv = $('#redis-warning'); + const messageEl = $('#redis-warning-message'); + + messageEl.text(message); + warningDiv.removeClass('d-none').addClass('show'); + + // 10초 후 자동 숨김 (선택사항) + setTimeout(function() { + warningDiv.fadeOut(); + }, 15000); + } + + // 시스템 상태 체크 함수 + function checkSystemStatus(responseData) { + if (responseData.system && responseData.system.redis_fallback) { + showRedisWarning(responseData.system.warning_message); + } + } + $(function () { $("#btnSearch").on("click", function () { @@ -231,6 +261,9 @@ console.log(xhr.responseText); }, success: function (result) { + // 시스템 상태 체크 (Redis 장애 여부) + checkSystemStatus(result); + if (result.code === "0") { location.href = '/' } else { diff --git a/worker/api_receiver.php b/worker/api_receiver.php index b55b053..3971a4b 100644 --- a/worker/api_receiver.php +++ b/worker/api_receiver.php @@ -4,6 +4,40 @@ * - 프레임워크를 로드하지 않아 매우 빠르고 안전함 * - 받은 데이터를 Redis 큐에 넣고 즉시 응답 테스트 */ + +// .env 파일 로드 함수 (프레임워크 미사용 환경) +function loadEnvFile($filePath) { + if (!file_exists($filePath)) { + return; + } + + $lines = file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + // 주석 제거 + if (strpos(trim($line), '#') === 0) { + continue; + } + + // KEY = VALUE 파싱 + if (strpos($line, '=') !== false) { + list($key, $value) = explode('=', $line, 2); + $key = trim($key); + $value = trim($value); + + // 따옴표 제거 + $value = trim($value, '"\''); + + // 환경변수 설정 (이미 있으면 덮어쓰지 않음) + if (!getenv($key)) { + putenv("$key=$value"); + } + } + } +} + +// .env 파일 로드 (한 단계 상위 디렉토리) +loadEnvFile(__DIR__ . '/../.env'); + /** * 날짜별 로그 기록 함수 * @param string $message 로그 내용 @@ -76,67 +110,111 @@ writeLog("REQUEST_INFO | " . json_encode($requestInfo, JSON_UNESCAPED_UNICODE | // ================================================================ // 2. 보안 키 체크 (URL 파라미터 key=값) -$configKey = "7EE868F4B36D36B3D86736828F4729EAC4992083"; // 실제 사용할 키값으로 변경하세요 +$configKey = getenv('API_RECEIVER_KEY') ?: "7EE868F4B36D36B3D86736828F4729EAC4992083"; $receivedKey = $_GET['key'] ?? ''; -$logDir = __DIR__ . '/logs/'; if ($receivedKey !== $configKey) { writeLog("SECURITY_FAIL | Invalid key: $receivedKey", 'WARNING'); http_response_code(403); echo apiResponse([ - 'code' => '-1', + 'code' => 'E403', 'message' => 'Unregistered key' ]); exit; } try { - // 3. 데이터 수신 (POST JSON 또는 GET 파라미터) + // 3. 데이터 수신 및 검증 (POST JSON) + if (empty($rawData)) { + throw new Exception("No data received"); + } + $data = json_decode($rawData, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception("Invalid JSON format: " . json_last_error_msg()); + } if (empty($data)) { throw new Exception("Empty data received"); } - // 4. Redis 연결 - $redis = new Redis(); - // Docker 서비스 이름인 'redis' 사용 - $redisHost = getenv('REDIS_HOST') ?: 'infra-redis'; // ✅ 가능 - $redisPort = getenv('REDIS_PORT') ?: 6379; - $success = $redis->connect($redisHost, $redisPort); - - if (!$success) { - throw new Exception("Could not connect to Redis"); - } - $redis->select(9); // 10번 DB 사용 - - // 5. 큐에 넣을 데이터 포맷팅 + // 4. 페이로드 준비 $payload = [ 'request_data' => $data, 'received_at' => date('Y-m-d H:i:s'), - 'client_ip' => $_SERVER['REMOTE_ADDR'] + 'client_ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' ]; - // 'naver:raw_queue'라는 이름의 리스트에 저장 - $redis->lPush('naver:raw_queue', json_encode($payload)); + // 5. Redis 연결 시도 및 폴백 처리 + $redisSuccess = false; + $redis = new Redis(); + + try { + // .env 환경변수에서 Redis 설정 읽기 + $redisHost = getenv('REDIS_HOST') ?: '127.0.0.1'; + $redisPort = getenv('REDIS_PORT') ?: 6379; + $redisDatabase = getenv('REDIS_DATABASE') ?: 9; + + $success = $redis->connect($redisHost, (int)$redisPort, 2.5); // 2.5초 타임아웃 + + if (!$success) { + throw new Exception("Could not connect to Redis at {$redisHost}:{$redisPort}"); + } - // 6. 네이버측에 성공 응답 (202 Accepted) - // 처리가 완료된 것은 아니지만, 접수는 완료되었음을 의미 + $redis->select((int)$redisDatabase); + + // 'naver:raw_queue'라는 이름의 리스트에 저장 + $pushResult = $redis->lPush('naver:raw_queue', json_encode($payload)); + + if (!$pushResult) { + throw new Exception("Failed to push data to Redis queue"); + } + + $redisSuccess = true; + writeLog("SUCCESS | Redis queue length: {$pushResult} | IP: " . ($payload['client_ip'] ?? 'unknown'), 'INFO'); + + } catch (Exception $redisError) { + // Redis 실패 시 파일 폴백 + writeLog("REDIS_FAIL | " . $redisError->getMessage() . " | Using file fallback", 'WARNING'); + + // 폴백 디렉토리 생성 및 권한 설정 + $fallbackDir = __DIR__ . '/fallback_queue'; + if (!is_dir($fallbackDir)) { + if (!mkdir($fallbackDir, 0777, true) && !is_dir($fallbackDir)) { + throw new Exception("Failed to create fallback directory"); + } + chmod($fallbackDir, 0777); + } + + // 파일로 저장 (타임스탬프 + 유니크ID) + $filename = $fallbackDir . '/' . date('YmdHis') . '_' . uniqid() . '.json'; + $writeResult = file_put_contents($filename, json_encode($payload), LOCK_EX); + + if ($writeResult === false) { + throw new Exception("Failed to write fallback file"); + } + + chmod($filename, 0666); + writeLog("FALLBACK_SUCCESS | File: " . basename($filename) . " | IP: " . ($payload['client_ip'] ?? 'unknown'), 'INFO'); + } + + // 6. 성공 응답 (Redis 또는 Fallback 성공) http_response_code(200); echo apiResponse([ - 'code' => 'success', - 'message' => '' + 'code' => 'success', + 'message' => '' ]); } catch (Exception $e) { - // 7. 장애 발생 시 로그 기록 (시스템 로그) - writeLog( 'Exception :' . apiResponse($data) , 'ERROR'); + // 7. 완전 장애 발생 시 (Redis도 실패, File도 실패) + writeLog('CRITICAL_ERROR: ' . $e->getMessage() . ' | Data: ' . substr($rawData, 0, 200), 'ERROR'); http_response_code(500); echo apiResponse([ - 'code' => '-1', - 'message' => $e->getMessage() + 'code' => 'E999', + 'message' => 'Internal server error' ]); } \ No newline at end of file