worker service 매물 등록 및 redis 다운시 처리

This commit is contained in:
2026-03-25 20:51:38 +09:00
parent a170fdc774
commit 0b6ed3df73
12 changed files with 976 additions and 199 deletions

2
.gitignore vendored
View File

@@ -175,4 +175,4 @@ _modules/*
# 6. 기타 개인 설정 파일 (선택적) # 6. 기타 개인 설정 파일 (선택적)
.github/copilot-instructions.md .github/copilot-instructions.mdworker/fallback_queue/*.json

360
SESSION_README.md Normal file
View File

@@ -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

View File

@@ -23,136 +23,155 @@ class NaverWorker extends BaseCommand
public function run(array $params) public function run(array $params)
{ {
helper('log'); // 여기서 로드 완료! helper(['log', 'redis']); // redis helper 추가
$this->db = \Config\Database::connect(); $this->db = \Config\Database::connect();
$logModel = model(NaverWorkerLogModel::class); $logModel = model(NaverWorkerLogModel::class);
$naverService = new \App\Services\NaverService(); // 서비스 생성 $naverService = new \App\Services\NaverService(); // 서비스 생성
$redis = new \Redis(); // Redis 연결 (실패해도 계속 진행 - 파일 모드로 동작 가능)
try { $redis = get_redis_connection('worker');
// 두 가지 환경 변수 형식 지원 (REDIS_HOST 또는 redis.default.host) $config = get_redis_config('worker');
$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); if ($redis) {
$redis->select((int)$this->redisDatabase); CLI::write(CLI::color('🟢 Naver Worker running... (Redis: ' . $config['host'] . ':' . $config['port'] . ' DB:' . $config['database'] . ')', 'green'));
CLI::write(CLI::color('🟢 Naver Worker running... (Redis: ' . $this->redisHost . ':' . $this->redisPort . ' DB:' . $this->redisDatabase . ')', 'green')); } else {
} catch (\Exception $e) { CLI::write(CLI::color('⚠️ Naver Worker running in FILE-ONLY mode (Redis unavailable)', 'yellow'));
CLI::error("Redis 연결 불가: " . $e->getMessage());
return;
} }
while (true) { while (true) {
// Redis brPop with retry logic // Redis 또는 폴백 파일에서 데이터 읽기
$maxRetries = 3; $rawData = null;
$retryCount = 0; $source = 'redis'; // 데이터 소스 추적
$result = null;
while ($retryCount < $maxRetries) { // 1. Redis에서 데이터 읽기 시도 (Redis가 있을 경우만)
try { if ($redis) {
$result = $redis->brPop(['naver:raw_queue'], 30); $maxRetries = 2;
break; // 성공하면 루프 탈출 $retryCount = 0;
} catch (\RedisException $e) {
$retryCount++;
CLI::write(CLI::color("⚠️ Redis error (attempt {$retryCount}/{$maxRetries}): " . $e->getMessage(), 'yellow'));
if ($retryCount >= $maxRetries) { while ($retryCount < $maxRetries) {
CLI::error("❌ Redis reconnection failed after {$maxRetries} attempts");
break;
}
// Redis 재연결 시도
try { try {
CLI::write(CLI::color('🔄 Reconnecting to Redis...', 'yellow')); $result = $redis->brPop(['naver:raw_queue'], 5); // 5초 타임아웃
$redis->close(); if ($result) {
$redis->connect($this->redisHost, (int)$this->redisPort); $rawData = $result[1];
$redis->select((int)$this->redisDatabase); $source = 'redis';
CLI::write(CLI::color('✅ Redis reconnected', 'green')); }
} catch (\Exception $reconnectError) { break; // 성공하면 루프 탈출
CLI::error("Redis reconnection error: " . $reconnectError->getMessage()); } catch (\Exception $e) {
sleep(2); // 재연결 실패 시 잠시 대기 $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) { // 2. Redis에서 데이터 없으면 폴백 파일 확인
// 데이터가 없어서 타임아웃 난 경우. if (!$rawData) {
// 굳이 sleep 안 해도 바로 다음 brPop이 다시 30초 대기를 시작함. $rawData = $this->readFromFallbackFile();
if ($rawData) {
$source = 'file';
}
}
// 3. 데이터 없으면 다음 루프
if (!$rawData) {
continue; continue;
} }
if ($result) { // 4. 데이터 소스 로깅
$rawData = $result[1]; CLI::write(CLI::color("📥 Data received from: " . strtoupper($source), 'cyan'));
// [1] 꺼내자마자 DB에 원문 저장 (2차 임시 저장) - 실패 시 재시도 // [1] 꺼내자마자 DB에 원문 저장 (2차 임시 저장) - 실패 시 재시도
try { try {
$logId = $logModel->insert([ $logId = $logModel->insert([
'raw_payload' => $rawData, 'raw_payload' => $rawData,
'status' => 'INIT' 'status' => 'INIT'
]); ]);
} catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) { } catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) {
// MySQL gone away 에러 시 재연결 후 재시도 // MySQL gone away 에러 시 재연결 후 재시도
if (strpos($e->getMessage(), 'MySQL server has gone away') !== false) { if (strpos($e->getMessage(), 'MySQL server has gone away') !== false) {
CLI::write(CLI::color('⚠️ MySQL gone away, reconnecting...', 'yellow')); CLI::write(CLI::color('⚠️ MySQL gone away, reconnecting...', 'yellow'));
$this->db->close(); $this->db->close();
$this->db = \Config\Database::connect(); $this->db = \Config\Database::connect();
$logModel = model(NaverWorkerLogModel::class); $logModel = model(NaverWorkerLogModel::class);
// 재시도 // 재시도
$logId = $logModel->insert([ $logId = $logModel->insert([
'raw_payload' => $rawData, 'raw_payload' => $rawData,
'status' => 'INIT' '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
]); ]);
} else {
CLI::write("✅ Success! DB ID: $insertId", 'cyan'); throw $e; // 다른 에러면 그대로 throw
} 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->lPush('naver:failed_queue', $rawData);
helper('log');
write_custom_log("FAILED_DATA | Error: " . $e->getMessage(), 'ERROR', 'failed');
// 루프 과부하 방지 (연속 에러 시)
sleep(1);
} }
} }
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 * 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;
}
}
} }

View File

@@ -4,7 +4,7 @@ namespace Config;
use CodeIgniter\Config\BaseConfig; use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Session\Handlers\BaseHandler; use CodeIgniter\Session\Handlers\BaseHandler;
use CodeIgniter\Session\Handlers\FileHandler; use CodeIgniter\Session\Handlers\DatabaseHandler;
use CodeIgniter\Session\Handlers\RedisHandler; use CodeIgniter\Session\Handlers\RedisHandler;
class Session extends BaseConfig class Session extends BaseConfig
@@ -15,14 +15,11 @@ class Session extends BaseConfig
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
* *
* The session storage driver to use: * The session storage driver to use:
* - `CodeIgniter\Session\Handlers\FileHandler`
* - `CodeIgniter\Session\Handlers\DatabaseHandler` * - `CodeIgniter\Session\Handlers\DatabaseHandler`
* - `CodeIgniter\Session\Handlers\MemcachedHandler`
* - `CodeIgniter\Session\Handlers\RedisHandler` * - `CodeIgniter\Session\Handlers\RedisHandler`
* *
* @var class-string<BaseHandler> * @var class-string<BaseHandler>
*/ */
// public string $driver = FileHandler::class;
public string $driver = RedisHandler::class; public string $driver = RedisHandler::class;
/** /**
@@ -51,43 +48,59 @@ class Session extends BaseConfig
* *
* The location to save sessions to and is driver dependent. * 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. * 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! * 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 string $savePath;
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();
// Redis 설정: .env 우선, 없으면 Docker 환경변수 사용 // 환경변수로 강제로 Database 모드 설정 가능 (Redis 장애 시 수동 전환)
if ($this->driver === RedisHandler::class) { $forceDatabase = env('SESSION_FORCE_DATABASE', false);
// .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') ?: '';
$this->savePath = sprintf( // Redis 설정: Redis 연결 실패 시 Database로 폴백
'tcp://%s:%s?database=%s', if ($this->driver === RedisHandler::class && !$forceDatabase) {
$redisHost, helper('redis');
$redisPort,
$redisDatabase
);
if (!empty($redisPassword)) { try {
$this->savePath .= '&password=' . $redisPassword; // 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($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 { } 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';
} }
} }

View File

@@ -13,6 +13,23 @@ class KisoController extends BaseController
/** @var ResponseInterface */ /** @var ResponseInterface */
protected $response; 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() public function vrfcReq()
{ {
// 1. 요청 방식에 따라 데이터 파싱 // 1. 요청 방식에 따라 데이터 파싱
@@ -25,56 +42,79 @@ class KisoController extends BaseController
} else { } else {
// 지원하지 않는 메소드 처리 (예: PUT, DELETE 등) // 지원하지 않는 메소드 처리 (예: PUT, DELETE 등)
return $this->response->setStatusCode(Response::HTTP_METHOD_NOT_ALLOWED) 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. 필수 항목 검증 // 2. 필수 항목 검증
$requiredKeys = ['articleNumber', 'requestType', 'requestDatetime']; $requiredKeys = ['articleNumber', 'requestType', 'requestDatetime'];
foreach ($requiredKeys as $key) { foreach ($requiredKeys as $key) {
// 파싱된 데이터($data) 내에 키가 없거나 값이 비어있는지 확인 // isset()과 trim()을 사용하여 '0' 값도 허용
if (empty($data[$key])) { if (!isset($data[$key]) || trim((string)$data[$key]) === '') {
return $this->response->setStatusCode(Response::HTTP_BAD_REQUEST) return $this->response->setStatusCode(Response::HTTP_BAD_REQUEST)
->setJSON([ ->setJSON([
'resultCode' => 'E001', 'code' => 'E001',
'resultMessage' => "Missing required parameter: {$key}" 'message' => "Missing required parameter: {$key}"
]); ]);
} }
} }
// 3. Redis 연결 및 예외 처리 // 3. requestType 값 검증
// 3. Redis 연결 및 직접 푸시 $validRequestTypes = ['verify', 'check', 'validate']; // 허용되는 requestType 값
try { if (!in_array($data['requestType'], $validRequestTypes, true)) {
$redis = new \Redis(); return $this->response->setStatusCode(Response::HTTP_BAD_REQUEST)
// Docker 환경이므로 host를 'redis'로 설정 ->setJSON([
$success = $redis->connect('redis', 6379); 'code' => 'E002',
'message' => "Invalid requestType. Allowed values: " . implode(', ', $validRequestTypes)
]);
}
if (!$success) { // 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'); throw new \Exception('Redis connection failed');
} }
$redis->select(10); // 10번 DB 선택
// 데이터 준비 // 데이터 준비
$data['retry_count'] = 0; $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) { } catch (\Exception $e) {
log_message('error', 'Redis Push Error: ' . $e->getMessage()); log_message('error', 'Redis Push Error: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([ return $this->response->setStatusCode(500)->setJSON([
'resultCode' => 'E999', 'code' => 'E999',
'resultMessage' => 'Redis Connection Error' 'message' => 'Redis Connection Error'
]); ]);
} }
// 4. 성공 응답 // 6. 성공 응답
return $this->response->setStatusCode(Response::HTTP_ACCEPTED) // 202 Accepted return $this->response->setStatusCode(Response::HTTP_OK) // 200 OK
->setJSON([ ->setJSON([
'resultCode' => 'S000', 'code' => 'success',
'resultMessage' => 'Request successfully queued for processing', 'message' => ''
'articleNumber' => $data['articleNumber']
]); ]);
} }
} }

View File

@@ -3,6 +3,7 @@ namespace App\Controllers;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Models\common\LoginModel; use App\Models\common\LoginModel;
use CodeIgniter\Session\Handlers\DatabaseHandler;
class Login extends BaseController class Login extends BaseController
{ {
@@ -13,6 +14,37 @@ class Login extends BaseController
$this->loginModel = new LoginModel(); $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 public function index(): string
{ {
$user_id = get_cookie('save_id'); $user_id = get_cookie('save_id');
@@ -59,7 +91,7 @@ class Login extends BaseController
]; ];
if (!$this->validate($rules)) { if (!$this->validate($rules)) {
return $this->response->setJSON([ return $this->jsonResponse([
'code' => '1', 'code' => '1',
'errors' => $this->validator->getErrors() 'errors' => $this->validator->getErrors()
]); ]);
@@ -82,7 +114,7 @@ class Login extends BaseController
$this->loginModel->insertUserLog($logs); $this->loginModel->insertUserLog($logs);
return $this->response->setJSON([ return $this->jsonResponse([
'code' => '1', 'code' => '1',
'msg' => '존재하지 않는 아이디입니다.' 'msg' => '존재하지 않는 아이디입니다.'
]); ]);
@@ -96,7 +128,7 @@ class Login extends BaseController
$this->loginModel->insertUserLog($logs); $this->loginModel->insertUserLog($logs);
return $this->response->setJSON(body: [ return $this->jsonResponse([
'code' => '1', 'code' => '1',
'msg' => '잘못된 비밀번호 입니다.' 'msg' => '잘못된 비밀번호 입니다.'
]); ]);
@@ -135,7 +167,7 @@ class Login extends BaseController
$this->session->set($newdata); $this->session->set($newdata);
return $this->response->setJSON([ return $this->jsonResponse([
'code' => '0', 'code' => '0',
'msg' => 'success' 'msg' => 'success'
]); ]);
@@ -148,7 +180,17 @@ class Login extends BaseController
log_message('error', '[LOGIN ERROR] ' . $e->getMessage()); log_message('error', '[LOGIN ERROR] ' . $e->getMessage());
log_message('error', $e->getTraceAsString()); 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', 'code' => '9',
'msg' => '서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' 'msg' => '서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
]); ]);

View File

@@ -13,10 +13,31 @@ class AuthCheck implements FilterInterface
$session = session(); $session = session();
log_message('debug', 'URI PATH: ' . service('uri')->getPath()); log_message('debug', 'URI PATH: ' . service('uri')->getPath());
// 로그인 체크 try {
if (!$session->get('logged_in')) { // 세션 읽기 시도
// 로그인 안 되어 있으면 로그인 페이지로 $loggedIn = $session->get('logged_in');
return redirect()->to('/login');
// 로그인 체크
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', '세션 서비스 오류입니다. 시스템 관리자에게 문의해주세요.');
} }
} }

View File

@@ -0,0 +1,82 @@
<?php
if (!function_exists('get_redis_connection')) {
/**
* Redis 연결을 반환하는 헬퍼 함수
* .env 환경변수에서 설정을 읽어 Redis에 연결합니다.
*
* @param string $type 'worker' 또는 'session' (기본값: 'worker')
* @return Redis|false Redis 객체 또는 연결 실패 시 false
* @throws RedisException
*/
function get_redis_connection(string $type = 'worker')
{
$redis = new \Redis();
try {
// 타입에 따라 다른 환경변수 사용
if ($type === 'session') {
$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', '');
} else {
// worker, default
$host = env('REDIS_HOST', '127.0.0.1');
$port = env('REDIS_PORT', '6379');
$database = env('REDIS_DATABASE', '9');
$password = env('REDIS_PASSWORD', '');
}
// Redis 연결 (타임아웃: 0.5초)
$timeout = 0.5;
$success = $redis->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', ''),
];
}
}

View File

@@ -53,9 +53,17 @@ class TypeSHandler
// 2. Result 데이터 저장 // 2. Result 데이터 저장
$resultData = $this->parameterMapper->mapResult($rcptSq, $rawData); $resultData = $this->parameterMapper->mapResult($rcptSq, $rawData);
write_custom_log("Result Insert 데이터: " . json_encode($resultData, JSON_UNESCAPED_UNICODE), 'INFO', 'service');
if (!$this->resultModel->insert($resultData)) { 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')); CLI::write(CLI::color('✅ Result 저장 성공', 'blue'));
// 3. 트랜잭션 커밋 // 3. 트랜잭션 커밋

View File

@@ -115,21 +115,39 @@ class TypeSParameterMapper extends BaseParameterMapper
$charge = null; $charge = null;
if ($sectorNumber) { if ($sectorNumber) {
$charge = $this->regionModel->getChargeByRegionCd($sectorNumber); $charge = $this->regionModel->getChargeByRegionCd($sectorNumber);
log_message('info', "[TypeSParameterMapper] 지역코드: {$sectorNumber}, 조회 결과: " . json_encode($charge, JSON_UNESCAPED_UNICODE));
} }
// 2. 기본 담당자 설정 (지역별 담당자가 없으면) // 2. 기본 담당자 설정 (지역별 담당자가 없으면)
$deptSq = $charge['dept_sq'] ?? '26'; $deptSq = $charge['dept_sq'] ?? '26';
$usrSq = $charge['usr_sq'] ?? '1'; $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 검증인 경우 환경별 담당자로 덮어쓰기 // 3. VR 검증인 경우 환경별 담당자로 덮어쓰기
if ($isVrVerification) { if ($isVrVerification) {
log_message('info', "[TypeSParameterMapper] ENVIRONMENT 상수 값: " . ENVIRONMENT);
if (ENVIRONMENT === 'development') { if (ENVIRONMENT === 'development') {
$deptSq = '29'; $deptSq = '29';
$usrSq = '472'; $usrSq = '472'; // vradmin
log_message('info', "[TypeSParameterMapper] VR 검증 (DEV) - dept_sq: {$deptSq}, usr_sq: {$usrSq}");
} else { } else {
// production // production - TODO: 실제 프로덕션 VR 담당자로 변경 필요
$deptSq = '33'; $deptSq = '29';
$usrSq = '1993'; $usrSq = '472'; // 임시: vradmin (프로덕션 VR 담당자 확인 후 수정)
log_message('warning', "[TypeSParameterMapper] VR 검증 (PROD) - 프로덕션 VR 담당자 미설정, 임시 담당자 사용: usr_sq={$usrSq}");
} }
} }

View File

@@ -69,6 +69,15 @@
<div class="h-100 bg-plum-plate bg-animation"> <div class="h-100 bg-plum-plate bg-animation">
<div class="d-flex h-100 justify-content-center align-items-center py-4"> <div class="d-flex h-100 justify-content-center align-items-center py-4">
<div class="mx-auto col-sm-10 col-md-8 col-lg-6 col-xl-5"> <div class="mx-auto col-sm-10 col-md-8 col-lg-6 col-xl-5">
<!-- Redis 장애 경고 영역 -->
<div id="redis-warning" class="alert alert-warning alert-dismissible fade d-none mb-3"
style="border-radius: 16px; background: rgba(255, 193, 7, 0.95); backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0,0,0,0.15);">
<strong><i class="fa fa-exclamation-triangle me-2"></i>시스템 알림</strong>
<p id="redis-warning-message" class="mb-0 mt-2"></p>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<div class="card border-0" <div class="card border-0"
style="border-radius: 24px; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(20px); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);"> style="border-radius: 24px; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(20px); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);">
<div class="card-body p-5"> <div class="card-body p-5">
@@ -202,6 +211,27 @@
const tpl = document.querySelector('.my-loader-template'); const tpl = document.querySelector('.my-loader-template');
var table; 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 () { $(function () {
$("#btnSearch").on("click", function () { $("#btnSearch").on("click", function () {
@@ -231,6 +261,9 @@
console.log(xhr.responseText); console.log(xhr.responseText);
}, },
success: function (result) { success: function (result) {
// 시스템 상태 체크 (Redis 장애 여부)
checkSystemStatus(result);
if (result.code === "0") { if (result.code === "0") {
location.href = '/' location.href = '/'
} else { } else {

View File

@@ -4,6 +4,40 @@
* - 프레임워크를 로드하지 않아 매우 빠르고 안전함 * - 프레임워크를 로드하지 않아 매우 빠르고 안전함
* - 받은 데이터를 Redis 큐에 넣고 즉시 응답 테스트 * - 받은 데이터를 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 로그 내용 * @param string $message 로그 내용
@@ -76,67 +110,111 @@ writeLog("REQUEST_INFO | " . json_encode($requestInfo, JSON_UNESCAPED_UNICODE |
// ================================================================ // ================================================================
// 2. 보안 키 체크 (URL 파라미터 key=값) // 2. 보안 키 체크 (URL 파라미터 key=값)
$configKey = "7EE868F4B36D36B3D86736828F4729EAC4992083"; // 실제 사용할 키값으로 변경하세요 $configKey = getenv('API_RECEIVER_KEY') ?: "7EE868F4B36D36B3D86736828F4729EAC4992083";
$receivedKey = $_GET['key'] ?? ''; $receivedKey = $_GET['key'] ?? '';
$logDir = __DIR__ . '/logs/';
if ($receivedKey !== $configKey) { if ($receivedKey !== $configKey) {
writeLog("SECURITY_FAIL | Invalid key: $receivedKey", 'WARNING'); writeLog("SECURITY_FAIL | Invalid key: $receivedKey", 'WARNING');
http_response_code(403); http_response_code(403);
echo apiResponse([ echo apiResponse([
'code' => '-1', 'code' => 'E403',
'message' => 'Unregistered key' 'message' => 'Unregistered key'
]); ]);
exit; exit;
} }
try { try {
// 3. 데이터 수신 (POST JSON 또는 GET 파라미터) // 3. 데이터 수신 및 검증 (POST JSON)
if (empty($rawData)) {
throw new Exception("No data received");
}
$data = json_decode($rawData, true); $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)) { if (empty($data)) {
throw new Exception("Empty data received"); 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) { // 4. 페이로드 준비
throw new Exception("Could not connect to Redis");
}
$redis->select(9); // 10번 DB 사용
// 5. 큐에 넣을 데이터 포맷팅
$payload = [ $payload = [
'request_data' => $data, 'request_data' => $data,
'received_at' => date('Y-m-d H:i:s'), 'received_at' => date('Y-m-d H:i:s'),
'client_ip' => $_SERVER['REMOTE_ADDR'] 'client_ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]; ];
// 'naver:raw_queue'라는 이름의 리스트에 저장 // 5. Redis 연결 시도 및 폴백 처리
$redis->lPush('naver:raw_queue', json_encode($payload)); $redisSuccess = false;
$redis = new Redis();
// 6. 네이버측에 성공 응답 (202 Accepted) 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}");
}
$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); http_response_code(200);
echo apiResponse([ echo apiResponse([
'code' => 'success', 'code' => 'success',
'message' => '' 'message' => ''
]); ]);
} catch (Exception $e) { } catch (Exception $e) {
// 7. 장애 발생 시 로그 기록 (시스템 로그) // 7. 완전 장애 발생 시 (Redis도 실패, File도 실패)
writeLog( 'Exception :' . apiResponse($data) , 'ERROR'); writeLog('CRITICAL_ERROR: ' . $e->getMessage() . ' | Data: ' . substr($rawData, 0, 200), 'ERROR');
http_response_code(500); http_response_code(500);
echo apiResponse([ echo apiResponse([
'code' => '-1', 'code' => 'E999',
'message' => $e->getMessage() 'message' => 'Internal server error'
]); ]);
} }