# Plan: KOBIS Crawler ↔ Movie 사이트 동기화 오류 수정

## Summary
movie 사이트(Laravel)와 crawler(Go/gin)가 KOBIS OpenAPI를 공유하는 구조에서, **Laravel이 crawler에 전달하는 KOBIS 인증키 3개가 전부 무효화**되어 박스오피스 신규 데이터 수집이 중단된 상태다(일별: 2026-03-12, 주간: 2026-06-07 이후 중단 — 로컬 DB 기준). crawler는 KOBIS 오류 응답(`faultInfo`)을 감지하지 못해 조용히 빈 목록을 반환하고, Laravel은 crawler 장애 시 500 에러가 난다. 본 계획은 키 동기화, 오류 감지/격리, 뷰 방어 처리, 수집 재가동을 다룬다.

## User Story
As a movie 사이트 방문자,
I want /movie/rank 에서 최신 일별·주간 박스오피스를 정상 조회하고 crawler 장애 시에도 안내를 받기를,
So that 데이터 공백이나 500 에러 없이 서비스를 이용할 수 있다.

## Problem → Solution
- **현재**: Laravel 상수의 KOBIS 키 3개 무효(라이브 검증: errorCode `320010`) → crawler가 그대로 KOBIS에 전달 → `faultInfo` 응답 → crawler는 200 + 빈 목록 반환 → 사이트는 원인 표시 없이 빈 페이지. crawler 다운 시 `Http::get` ConnectionException 미처리로 500.
- **해결**: 유효 키로 동기화(양쪽), crawler에 faultInfo 감지 추가, Laravel에 타임아웃+예외 격리, 뷰 null 방어, cron 수집 재가동.

## Metadata
- **Complexity**: Medium (2개 리포지토리, ~12 files, 신규 개념 없음)
- **Source PRD**: N/A (free-form)
- **PRD Phase**: N/A
- **Estimated Files**: movie 7 + crawler 4 + docs 1

---

## 검증된 사실 (2026-07-22 라이브 테스트)

| 항목 | 결과 |
|---|---|
| Laravel `KOBIS_API_1` `d470c8a1...` | ❌ 무효 (`320010 유효하지않은 키값입니다`) |
| Laravel `KOBIS_API_2` `52c8406a...` | ❌ 무효 |
| Laravel `KOBIS_API_3` `f5eef342...` | ❌ 무효 (crawler `cron.go GetKey()` 기본 fallback으로 하드코딩되어 있음) |
| crawler `movie.json apiKey_1` `b453d855...` | ✅ **유효** (박스오피스 데이터 정상 반환) |
| crawler `movie.json apiKey_2` `cf7ea46f...` | ✅ **유효** |
| crawler `movie.json apiKey_3` `82ca741a...` | ❌ 무효 — KOBIS 공식 문서의 **샘플 키**가 그대로 들어있음 |
| KOBIS REST 응답 구조 | 문서(`E:\server\project\crawler\docs\KOBIS-영화API.md`)와 일치 — **API 자체는 변경 없음** |
| KOBIS 상세페이지 스크래핑 셀렉터 (`item_tab basic`, `desc_info`, `fl thumb`, `#post`, `#stl`) | ✅ 여전히 유효 |
| TMDB 키 (`bfeb1c9a...`) | ✅ 유효 (메인/트레일러 페이지 정상) |
| crawler 서버 dev `192.168.0.10:1050` / prod `192.168.0.100:1050` | ⚠️ 현 개발 PC에서 응답 없음 (구동 여부 확인 필요) |
| DB 데이터 신선도 | tb_movie_daily 최종 2026-03-12, tb_movie_weekly 2026-06-07, tb_movie/tb_movie_info 2026-03-12 |
| tb_movie_detail `sale_acc`/`audi_acc` | 80,617행 중 ~15,000행만 채워짐 — crawler의 통계 스크래퍼(c2)가 주석 처리된 상태 |
| DB 스키마 ↔ crawler positional `SELECT *` 스캔 | ✅ 컬럼 순서 정확히 일치 (tb_movie 16, tb_movie_daily 23, tb_movie_weekly 24, tb_movie_info 20, tb_movie_detail 11 컬럼) |

### 영화사(Company) 페이지 확인 결과
- KOBIS 문서에는 영화사 목록/상세(`searchCompanyList`/`searchCompanyInfo`), 영화인, 공통코드 API가 정리되어 있으나 **crawler는 이 중 어느 것도 구현하지 않음** (구현: 일별/주간 박스오피스, 영화 목록, 영화 상세 4종 + 상세페이지 스크래핑).
- movie 사이트에도 **영화사 전용 라우트/페이지 없음**. 회사 정보는 영화 상세 뷰의 "제작/수입/배급사" 섹션에서 `info->Companys` JSON으로만 노출됨 ([show.blade.php:270-286](resources/views/desktop/movie/search/show.blade.php)).
- → 신규 기능이므로 본 계획의 범위에서 제외(NOT Building 참조).

---

## UX Design

### Before
```
/movie/rank?date=2026-07-21
┌────────────────────────────────┐
│ 인기 순위                       │
│ (빈 화면 — 무효 키로 인해       │
│  목록 없음, 원인 표시 없음)     │
│ crawler 다운 시: 500 에러 페이지 │
└────────────────────────────────┘
```

### After
```
/movie/rank?date=2026-07-21
┌────────────────────────────────┐
│ 인기 순위                       │
│ [1위 포스터][2위 포스터]...     │  ← 유효 키로 on-demand 수집 재개
│ crawler 장애 시:                │
│ "일시적으로 순위 정보를 불러올  │
│  수 없습니다" 안내 (200 응답)   │
└────────────────────────────────┘
```

### Interaction Changes
| Touchpoint | Before | After | Notes |
|---|---|---|---|
| /movie/rank (미수집 날짜) | 빈 목록 | 데이터 표시 | 키 교체로 해결 |
| /movie/rank (crawler 다운) | 500 | 안내 문구 | timeout+catch |
| /movie/search/{id} 누적 매출/관객 | `0원`/`0명` 표시 | 값 없으면 행 숨김 | sale_acc 대부분 NULL |
| /movie/rank/{id} 등 상세 | 동일 | 동일 | 변경 없음 |

---

## Mandatory Reading

| Priority | File | Lines | Why |
|---|---|---|---|
| P0 | `E:\server\project\movie\config\constants.php` | 297-309 | 무효 키 3개 + crawler 엔드포인트 상수 |
| P0 | `E:\server\project\crawler\config\movie.json` | all | 유효 키 1,2 / 무효 샘플 키 3 |
| P0 | `E:\server\project\crawler\controller\cron.go` | 492-500 | GetKey(): 무효 키 하드코딩 fallback |
| P0 | `E:\server\project\crawler\model\kobis.go` | 14-36 | KOBIS 호출 + faultInfo 미검사 지점 |
| P0 | `E:\server\project\crawler\controller\movie.go` | 73-77, 171-176, 251-254 | `c.JSON(400)` 후 `return` 누락 3곳 |
| P0 | `E:\server\project\movie\app\Http\Controllers\Movie\RankController.php` | 90-119 | Http::get 미보호 + `$data->total` 처리 |
| P1 | `E:\server\project\movie\resources\views\desktop\movie\rank\index.blade.php` | 30 | `$data->rows` null 미방어 (mobile 동일 파일 존재) |
| P1 | `E:\server\project\movie\resources\views\desktop\movie\search\show.blade.php` | 107-126 | SaleAcc/AudiAcc 0 표시 문제 (mobile 동일) |
| P1 | `E:\server\project\movie\app\Models\DTO\Movie\BoxOfficeParams.php` | 10-21 | key 주입 지점 (`KOBIS_API_1`) |
| P2 | `E:\server\project\crawler\model\movieDaily.go` | 21-58 | 요청/응답 구조체 패턴 |
| P2 | `E:\server\project\movie\app\Http\Controllers\Movie\SearchController.php` | 43, 117-169 | ListParams 죽은 주입 + show()의 try/catch 패턴 |

## External Documentation

| Topic | Source | Key Takeaway |
|---|---|---|
| KOBIS OpenAPI 명세 | `E:\server\project\crawler\docs\KOBIS-영화API.md` | 9종 API. 응답 구조 현행과 일치. 키는 발급 필요 |
| KOBIS 키 발급 | https://www.kobis.or.kr/kobisopenapi (키 발급/관리 메뉴) | 무효 키 3개 대체분 발급 필요 시 사용자 계정 필요 |
| 오류 응답 형식 | 라이브 검증 | `{"faultInfo":{"message":"...","errorCode":"320010"}}` — HTTP 200으로 반환됨 |

---

## Patterns to Mirror

### GO_ERROR_HANDLING (crawler)
```go
// SOURCE: E:\server\project\crawler\model\kobis.go:25-33
data, err := this.Rest.CallRestGetAPI(url)
if this.Rest.Check(err) {   // Check()가 error.txt 로그 기록 후 bool 반환
    return result, err
}

err = json.Unmarshal(data, &result)
if this.Rest.Check(err) {
    return result, err
}
```

### GO_LOGGING (crawler DB 로그)
```go
// SOURCE: E:\server\project\crawler\model\movieDaily.go:96-103
err := conn.QueryRow(query).Scan(&total)
if err != nil {
    db.SetErrorLog(err, query)
    return total
}
db.SetGeneralLog(config.GL_ACTION_SELECT, query, "select total movie daily")
```

### PHP_CONTROLLER_ERROR_HANDLING (movie)
```php
// SOURCE: E:\server\project\movie\app\Http\Controllers\Movie\RankController.php:129-186
try {
    ...
    $response = Http::get($url, $params->toArray());
    if(!$response->ok()) {
        throw new Exception('요청한 정보가 없습니다.');
    }
    ...
}catch(Exception $e) {
    abort($e->getCode(), $e->getMessage()); // 주의: getCode()=0 → invalid status. ?: 500 으로 보정할 것
}
```

### PHP_DTO_PARAMS (movie)
```php
// SOURCE: E:\server\project\movie\app\Models\DTO\Movie\BoxOfficeParams.php:10-21
public function __construct(
    public string $targetDt = "",
    ...
) {
    $this->key = KOBIS_API_1;   // ← 키 주입 지점. 상수 교체만으로 반영됨
    $this->weekGb = "0";
    $this->itemPerPage = "10";
}
```

### BLADE_JSON_FIELD (movie 뷰 — crawler Go 구조체는 JSON 태그가 없어 PascalCase로 직렬화됨)
```blade
{{-- SOURCE: E:\server\project\movie\resources\views\desktop\movie\rank\show.blade.php:50-58 --}}
@if($info->Directors)
    @foreach(json_decode($info->Directors) as $director)
        {{ $director->peopleNm }}
    @endforeach
@endif
```

### GO_GIN_BINDING (crawler 요청 파라미터)
```go
// SOURCE: E:\server\project\crawler\model\movieDaily.go:21-28
type SearchDailyBoxOfficeParams struct {
    Key      string `form:"key" url:"key" binding:"required"`
    TargetDt string `form:"targetDt" url:"targetDt" binding:"required"`
    ...
}
```

---

## Files to Change

| File | Action | Justification |
|---|---|---|
| `movie/config/constants.php` | UPDATE | KOBIS_API_1/2/3 → 유효 키로 교체 |
| `crawler/config/movie.json` | UPDATE | apiKey_3 샘플 키 → 유효 키로 교체 |
| `crawler/controller/cron.go` | UPDATE | GetKey() fallback을 config 참조로, key=3 지원 |
| `crawler/model/kobis.go` | UPDATE | faultInfo 감지 → error 반환 (4개 API 함수) |
| `crawler/controller/movie.go` | UPDATE | c.JSON(400) 후 return 누락 3곳 수정 |
| `movie/app/Http/Controllers/Movie/RankController.php` | UPDATE | Http timeout+catch, abort 코드 보정 |
| `movie/app/Http/Controllers/Movie/SearchController.php` | UPDATE | show() Http timeout, 죽은 ListParams 주입 제거 |
| `movie/resources/views/desktop/movie/rank/index.blade.php` | UPDATE | `$data` null 방어 + 장애 안내 |
| `movie/resources/views/mobile/movie/rank/index.blade.php` | UPDATE | 동일 |
| `movie/resources/views/desktop/movie/search/show.blade.php` | UPDATE | SaleAcc/AudiAcc null 시 행 숨김 |
| `movie/resources/views/mobile/movie/search/show.blade.php` | UPDATE | 동일 |
| `crawler/docs/KOBIS-영화API.md` | UPDATE | 구현 현황 매트릭스 + 키 운영 정책 추가 |

## NOT Building
- 영화사(company) 목록/상세 페이지 및 crawler의 `searchCompanyList`/`searchCompanyInfo` 프록시 — 신규 기능, 별도 PRD로
- 영화인(people)/공통코드(code) API 연동
- KCISA 박스오피스 통계(`getKFCC0502`, 주석 처리된 `/cron/stats`) 부활
- tb_movie_* 스키마 변경 (현재 정합함)
- weekGb 정책 변경 (현행 `0`=주간 유지 — 주말 진입 시 주간 순위를 보여주는 현행 동작은 의도로 간주)
- crawler c2(누적 매출/관객 스크래퍼) 재활성화 — 후속 과제로 기록만 (Task 6 GOTCHA 참조)

---

## Step-by-Step Tasks

### Task 1: KOBIS 키 동기화 (양쪽 리포)
- **ACTION**: 유효 키로 양쪽 통일. 검증된 유효 키: crawler movie.json의 apiKey_1, apiKey_2. 3번째 키는 KOBIS에서 신규 발급(사용자 계정 필요) 전까지 key_1 재사용.
- **IMPLEMENT**: `movie/config/constants.php:300-302` KOBIS_API_1/2/3 값 교체. `crawler/config/movie.json` apiKey_3 교체.
- **MIRROR**: 기존 상수 선언 형식 유지 (`const KOBIS_API_1 = '...';`)
- **IMPORTS**: 없음
- **GOTCHA**: crawler는 Laravel이 넘긴 `key` 파라미터를 그대로 KOBIS에 전달하므로 **Laravel 상수 교체가 실질적 수정점**. crawler의 movie.json은 cron 수집 전용. 배포 서버의 movie.json은 별도 반영 필요(git 관리 여부 확인).
- **VALIDATE**: `curl "http://www.kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/searchDailyBoxOfficeList.json?key=<새 KOBIS_API_1>&targetDt=<어제>"` → `boxOfficeResult` 반환 확인.

### Task 2: crawler GetKey() fallback 수정
- **ACTION**: `crawler/controller/cron.go:492-500`의 하드코딩 무효 키 제거.
- **IMPLEMENT**:
```go
func GetKey(c *gin.Context) string {
    switch c.Query("key") {
    case "1":
        return config.Movie.Kobis.ApiKey_1
    case "2":
        return config.Movie.Kobis.ApiKey_2
    default:
        return config.Movie.Kobis.ApiKey_3
    }
}
```
- **MIRROR**: 기존 config.Movie.Kobis 접근 패턴
- **IMPORTS**: 기존 `crawler/config` 이미 임포트됨
- **GOTCHA**: apiKey_3가 Task 1에서 유효 키로 교체된 후에만 의미 있음.
- **VALIDATE**: `go build ./...` 통과.

### Task 3: crawler faultInfo 감지
- **ACTION**: `crawler/model/kobis.go`의 4개 API 함수(MovieDailyBoxOfficeListAPI, MovieWeeklyBoxOfficeListAPI, MovieListAPI, MovieInfoAPI)에서 KOBIS 오류 응답을 error로 승격.
- **IMPLEMENT**: 파일 상단에 공용 타입/헬퍼 추가 후 각 함수의 `json.Unmarshal` 직전에 호출:
```go
type KobisFault struct {
    FaultInfo *struct {
        Message   string `json:"message"`
        ErrorCode string `json:"errorCode"`
    } `json:"faultInfo"`
}

func (this *Kobis) checkFault(data []byte) error {
    var fault KobisFault
    if json.Unmarshal(data, &fault) == nil && fault.FaultInfo != nil {
        err := fmt.Errorf("KOBIS fault %s: %s", fault.FaultInfo.ErrorCode, fault.FaultInfo.Message)
        this.Rest.Check(err) // error.txt 기록
        return err
    }
    return nil
}
```
- **MIRROR**: GO_ERROR_HANDLING 패턴 (`if this.Rest.Check(err) { return result, err }`)
- **IMPORTS**: `fmt` 추가
- **GOTCHA**: KOBIS는 오류도 HTTP 200으로 반환하므로 상태코드 검사로는 잡을 수 없음. 반드시 body 검사.
- **VALIDATE**: 무효 키로 crawler 엔드포인트 호출 시 200+빈 목록 대신 400+오류 메시지 반환.

### Task 4: crawler 컨트롤러 return 누락 수정
- **ACTION**: `crawler/controller/movie.go`에서 KOBIS API 오류 시 `c.JSON(http.StatusBadRequest, ...)` 후 실행이 계속되는 3곳 수정.
- **IMPLEMENT**: 75-77행(SearchDailyBoxOfficeList), 174-176행(SearchWeeklyBoxOfficeList), 252-254행(SearchMovieList)에 `return` 추가.
- **MIRROR**: 같은 파일 내 다른 오류 분기들(`c.JSON(...); return`)
- **GOTCHA**: return 누락 시 gin이 이중 응답 기록을 시도하고, 오류인데도 빈 데이터로 후속 처리 진행됨.
- **VALIDATE**: `go vet ./...` + 무효 키 호출 시 단일 400 응답.

### Task 5: Laravel crawler 장애 격리
- **ACTION**: RankController(index/show), SearchController(show)의 `Http::get`에 타임아웃과 예외 처리 추가, abort 코드 보정, 뷰 null 방어.
- **IMPLEMENT**:
  - `Http::timeout(5)->get($url, $params->toArray())` 로 교체, index()는 `try { ... } catch (ConnectionException) { $data = null; }`
  - show()들의 catch 블록: `abort($e->getCode() ?: 500, $e->getMessage());`
  - `desktop/movie/rank/index.blade.php:30` 및 mobile 동일 파일: `@if($data && $data->rows > 0)` + `@else` 분기에 "일시적으로 순위 정보를 불러올 수 없습니다." 안내 (기존 빈 목록 안내 마크업이 있으면 재사용)
- **MIRROR**: PHP_CONTROLLER_ERROR_HANDLING 패턴 (SearchController::show의 try/catch 구조)
- **IMPORTS**: `use Illuminate\Http\Client\ConnectionException;`
- **GOTCHA**: crawler 응답은 rows=0이어도 `total`(테이블 전체 COUNT)이 0보다 클 수 있음 — 목록 유무 판단은 `rows`/`list` 기준으로.
- **VALIDATE**: crawler 미기동 상태에서 /movie/rank 접속 → 500이 아닌 안내 문구 페이지.

### Task 6: 누적 매출/관객 표시 방어 (search/show)
- **ACTION**: tb_movie_detail의 sale_acc/audi_acc가 NULL인 80% 케이스에서 "0원/0명" 대신 행 숨김.
- **IMPLEMENT**: `desktop/movie/search/show.blade.php` 및 mobile 동일 파일의 누적 매출액/관객수 `<tr>`을 `@if($detail->SaleAcc)` / `@if($detail->AudiAcc)`로 감싸기. `number_format(null)` 경고 제거를 위해 `?? 0`을 인자 안쪽으로(`number_format($detail->SaleAcc ?? 0)`).
- **MIRROR**: 같은 뷰의 `@if($detail->Poster)` 조건부 섹션 패턴
- **GOTCHA**: 근본 원인은 crawler의 c2 통계 스크래퍼(`searchMovieDtl.do sType=stat`) 주석 처리. 재활성화는 셀렉터 재검증이 필요한 별도 작업 — `cron.go:324-335`, `movie.go:524-537` 주석 참조. 후속 과제로 남김.
- **VALIDATE**: sale_acc NULL인 영화 상세 페이지에서 해당 행 미표시.

### Task 7: SearchController 죽은 코드 정리
- **ACTION**: `SearchController::index(Request $request, ListParams $params)`의 `$params`가 84행에서 `SearchData::fromRequest`로 즉시 덮어써짐 — 시그니처에서 ListParams 제거. 미사용 상수 `MOVIE_LIST`(constants.php:306)에 `// unused: 검색 페이지는 DB 직조회` 주석 또는 제거.
- **MIRROR**: RankController 시그니처 스타일
- **GOTCHA**: `use App\Models\DTO\Movie\ListParams;` 임포트도 함께 정리. MOVIE_LIST 상수는 crawler 라우트에는 존재하므로 문서화 후 유지해도 무방.
- **VALIDATE**: `php -l` 통과, /movie/search 정상 렌더.

### Task 8: cron 수집 재가동 + 운영 문서화
- **ACTION**: crawler 서버(dev `192.168.0.10:1050`, prod `192.168.0.100:1050`) 기동 확인 후, 스케줄 트리거를 문서화. 두 서버 모두 현재 이 개발 PC에서 `/ping` 무응답이었음.
- **IMPLEMENT**: `crawler/docs/KOBIS-영화API.md`(또는 신규 `docs/OPERATIONS.md`)에 다음을 기록:
  - 일별 수집: `GET /cron/list?key=1` → `GET /cron/info?key=2` → `GET /cron/detail` (마지막 페이지는 `log/kobis/page.txt`에 저장됨)
  - 박스오피스는 사이트 조회 시 on-demand 수집이므로 별도 백필 불필요 (키만 유효하면 날짜별 첫 조회 때 채워짐)
  - 키 소유/발급 정책: 유효 키 목록과 관리 주체
  - 구현 현황 매트릭스: 9종 중 4종 구현(daily/weekly/movieList/movieInfo), company/people/code 미구현
- **VALIDATE**: crawler 기동 후 `curl http://<host>:1050/ping` → `pong`, `curl http://<host>:1050/db` → ms 응답.

---

## Testing Strategy

### Unit / Integration Tests

| Test | Input | Expected Output | Edge Case? |
|---|---|---|---|
| crawler faultInfo 감지 | 무효 키로 `/movie/searchDailyBoxOfficeList` | HTTP 400 + `KOBIS fault 320010...` | ✅ |
| crawler 정상 수집 | 유효 키 + 어제 날짜 | 200 + rows=10, DB upsert | |
| Laravel rank (crawler down) | crawler 중지 후 /movie/rank | 200 + 안내 문구 | ✅ |
| Laravel rank (정상) | 유효 키 + 오늘 | 순위 목록 렌더 | |
| Laravel search/show (sale_acc NULL) | 해당 movieCd | 누적 행 미표시 | ✅ |
| rank/show 상세 | dailyID base64 | stats/info/detail 렌더 | |

### Edge Cases Checklist
- [ ] KOBIS가 200 + faultInfo 반환 (무효 키/쿼터 초과)
- [ ] crawler 무응답(타임아웃)
- [ ] 조회 날짜에 상영작 없음 (미래 날짜)
- [ ] `Detail`이 null인 daily row (`$row->Detail->MainImg` — rank/index:36, null 가드 확인)
- [ ] 주말 날짜 조회 (weekly 경로, weekGb=0)
- [ ] base64 변조된 rank/show id

---

## Validation Commands

### Static Analysis
```powershell
# crawler (Go)
cd E:\server\project\crawler; go build ./...; go vet ./...
```
EXPECT: 빌드/벳 무오류

```powershell
# movie (PHP) — 수정 파일 문법 검사
cd E:\server\project\movie
php -l app/Http/Controllers/Movie/RankController.php
php -l app/Http/Controllers/Movie/SearchController.php
php -l config/constants.php
php artisan config:clear
```
EXPECT: No syntax errors

### Live API Validation
```powershell
# 1. 새 키 유효성
curl "http://www.kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/searchDailyBoxOfficeList.json?key=<KOBIS_API_1>&targetDt=<어제 yyyymmdd>"
# EXPECT: {"boxOfficeResult":{...}} (faultInfo 아님)

# 2. crawler 경유 (crawler 기동 후)
curl "http://localhost:1050/movie/searchDailyBoxOfficeList?key=<KOBIS_API_1>&targetDt=<어제>"
# EXPECT: {"total":N,"rows":10,"list":[...]}

# 3. 무효 키 → 오류 승격 확인
curl "http://localhost:1050/movie/searchDailyBoxOfficeList?key=invalid&targetDt=<어제>"
# EXPECT: HTTP 400 + KOBIS fault 320010
```

### Database Validation
```sql
-- 수집 재개 확인 (rank 페이지 1회 조회 후)
SELECT MAX(show_range) FROM movie.tb_movie_daily;   -- 어제 날짜 포함
SELECT MAX(created_at) FROM movie.tb_movie_daily;   -- 오늘 타임스탬프
```

### Browser Validation
- [ ] /movie/rank — 오늘/어제 데이터 표시
- [ ] /movie/rank?date=<주말 날짜> — weekly 경로 정상
- [ ] crawler 중지 후 /movie/rank — 500 아닌 안내 문구
- [ ] /movie/search → 상세 — 누적 매출/관객 NULL 행 숨김
- [ ] /movie/rank/{id} 상세 — 감독/장르/배우/제작사 JSON 필드 렌더

---

## Acceptance Criteria
- [ ] 양쪽 리포의 KOBIS 키가 동일한 유효 키 세트로 통일
- [ ] crawler가 KOBIS faultInfo를 400으로 승격 + error.txt 기록
- [ ] crawler 오류 분기 3곳 return 수정
- [ ] Laravel rank 페이지가 crawler 장애 시 200 + 안내
- [ ] tb_movie_daily에 최신 날짜 데이터 적재 확인
- [ ] `go build`/`go vet`/`php -l` 무오류

## Completion Checklist
- [ ] 코드가 발견된 패턴(Rest.Check, SetErrorLog, try/catch+abort)을 따름
- [ ] 하드코딩 키가 코드(cron.go)에 남아있지 않음
- [ ] mobile 뷰도 desktop과 동일하게 수정됨
- [ ] crawler docs에 구현 현황/키 정책 반영
- [ ] 범위 외 작업(영화사 페이지 등) 미포함

## Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| 유효 키 2개도 소유자 불명/추후 만료 | 중 | 높음 | KOBIS 계정으로 신규 발급, 발급 주체 문서화 |
| crawler 서버 접근 불가(현재 dev/prod 무응답) | 중 | 높음 | 서버 기동 상태 우선 확인, 로컬 기동으로 검증 대체 |
| 배포 서버의 movie.json이 git 밖 관리 | 중 | 중 | 배포 절차에 config 반영 단계 명시 |
| KOBIS 일 호출 쿼터 초과 | 낮 | 중 | cron 수집 시 key 파라미터 분산(기존 GetKey 설계 유지) |
| daily/weekly 과거 공백(3~7월) 미백필 | 확실 | 낮 | on-demand 구조라 사용자 조회 시 자동 채움 — 별도 조치 불필요 |

## Notes
- **KOBIS API 자체는 변경되지 않음** — 문서와 응답 구조 일치(라이브 검증). "API가 많이 변경됐을 것"이라는 가설의 실체는 **키 무효화**였음.
- crawler Go 구조체(MovieDailyTable 등)는 JSON 태그가 없어 PascalCase로 직렬화되며, Laravel 컨트롤러/뷰가 이에 의존(`$row->MovieCd`, `$detail->MainImg`). **crawler 구조체에 json 태그를 추가하면 사이트가 즉시 깨지므로 금지.**
- crawler `SELECT *` positional scan ↔ DB 컬럼 순서 의존 — 스키마 변경 시 양쪽 동시 수정 필요 (현재는 정합).
- 영화사/영화인/공통코드 API는 미구현 — 필요 시 별도 PRD(`/prp-prd`)로 진행.
- `.env`/`.env.production`의 미커밋 변경(크리덴셜 → 환경변수 치환)은 본 건과 무관한 진행 중 작업으로 보임 — 건드리지 않음.
