admin@web.or.kr 2 日 前
コミット
b02c2cce90

+ 413 - 0
.claude/PRPs/plans/completed/kobis-crawler-movie-sync.plan.md

@@ -0,0 +1,413 @@
+# 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`의 미커밋 변경(크리덴셜 → 환경변수 치환)은 본 건과 무관한 진행 중 작업으로 보임 — 건드리지 않음.

+ 191 - 0
.claude/PRPs/plans/completed/kobis-cron-movie-collection.plan.md

@@ -0,0 +1,191 @@
+# Plan: crawler 영화 수집(/cron/*) 점검 결과 및 수정
+
+## Summary
+crawler의 영화 수집 기능을 점검했다. 수집은 사용자가 언급한 `searchMovieList.do`(웹 페이지)가 아니라 **KOBIS OpenAPI**(`searchMovieList.json`)로 구현되어 있고, `.do` 페이지 중에서는 상세(`searchMovieDtl.do`)만 이미지/줄거리 스크래핑에 사용한다(셀렉터 유효 확인됨). 루프 종료 조건은 정상이지만 **에러 로그 유실 버그, 이중 응답, 단건 실패 시 전체 중단, OnHTML 중복 등록(O(n²))** 4개 결함과 **약 18,000편의 데이터 격차**(tb_movie 101,810 vs KOBIS totCnt 120,000, 최종 수집 2026-03-12)를 확인했다.
+
+## User Story
+As a 운영자, I want 영화 수집 cron이 오류를 정확히 기록하고 중단 없이 완주하기를, So that 영화 DB(/movie/search)에 신작이 빠짐없이 반영된다.
+
+## Problem → Solution
+- 에러가 `<nil>`로만 기록되어 원인 추적 불가 → WriteLog가 실제 메시지를 기록하도록 수정
+- 단건 실패가 /cron/info 전체를 중단 → continue로 전환, 실패 목록 리포트
+- 수집이 2026-03-12에 멈춰 신작 ~18k편 부재 → 코드 수정 후 캐치업 스윕 실행
+
+## Metadata
+- **Complexity**: Small (crawler 리포 2개 파일 + 문서)
+- **Source PRD**: N/A / **PRD Phase**: N/A
+- **Estimated Files**: 3
+
+---
+
+## 점검에서 검증된 사실 (2026-07-22 라이브)
+
+| 항목 | 결과 |
+|---|---|
+| 수집 대상 | `/cron/list`는 OpenAPI `searchMovieList.json` 사용. `searchMovieList.do`는 코드 어디에도 미참조. `searchMovieDtl.do`는 상세 스크래핑 전용(셀렉터 유효) |
+| 루프 종료 조건 | ✅ 정상 — KOBIS는 마지막 페이지 이후 `{"totCnt":0,"movieList":[]}` 반환 → `TotCnt <= 0` break 동작 (무한 루프 아님, 라이브 검증) |
+| KOBIS 전체 영화 수 | totCnt = **120,000** (신작이 1페이지에 오는 최신순 정렬) |
+| 로컬 tb_movie | 101,810행, 최종 수집 2026-03-12 → **~18,190편 격차** |
+| `log/kobis/page.txt` | `194` (2025-08-10 기록) — 마지막 중단 지점. 페이지 194는 여전히 데이터 있음 |
+| `log/kobis/error.txt` | 전 항목이 `<nil>` — **에러 메시지 유실 버그** (아래 결함 1) |
+
+### 발견된 결함
+
+1. **[버그] `service/rest.go:87-101` WriteLog가 메시지 대신 nil을 기록**
+   ```go
+   func (this *Rest) WriteLog(msg string) {
+       data, err := os.OpenFile(...)   // err는 파일 열기 오류(nil)
+       ...
+       log.SetOutput(data)
+       log.Println(err)                // ← 버그: msg가 아닌 err(nil)를 기록
+   }
+   ```
+2. **[버그] `controller/cron.go` List/Info: 오류 시 `c.JSON(400)` 후 `break` → 루프 밖 `c.JSON(200)`과 이중 응답** (List 77-79행, Info 167-169행)
+3. **[설계 결함] `/cron/info`: 단건 영화 실패(오류 또는 `MovieCd == ""`) 시 `break`로 전체 배치 중단** (167-177행) — 삭제된 영화 하나가 나머지 수집을 막음
+4. **[성능] `Cron.Detail`: `c1.OnHTML(...)`을 for 루프 안에서 등록** (298행) — 콜백이 영화 수만큼 누적되어 O(n²) 실행 + 이전 iteration의 closure를 계속 호출 (정합성은 유지되나 대량 수집 시 급격히 느려짐)
+
+---
+
+## UX Design
+Internal change — no user-facing UX transformation. (부수 효과: /movie/search에 2026-03 이후 신작 노출)
+
+---
+
+## Mandatory Reading
+
+| Priority | File | Lines | Why |
+|---|---|---|---|
+| P0 | `E:\server\project\crawler\service\rest.go` | 77-101 | WriteLog 버그 지점 |
+| P0 | `E:\server\project\crawler\controller\cron.go` | 40-129 (List), 134-208 (Info), 214-398 (Detail) | 수정 대상 3개 함수 |
+| P1 | `E:\server\project\crawler\model\kobis.go` | checkFault | faultInfo 오류 승격(이미 구현) — Info 루프의 continue 처리와 상호작용 |
+| P2 | `E:\server\project\crawler\model\movie.go` | 296-357 | MovieInfoExcludeCodes / MovieDetailExcludeCodes (배치 대상 선정) |
+
+## External Documentation
+
+| Topic | Source | Key Takeaway |
+|---|---|---|
+| 목록 API 페이징 | 라이브 검증 | 최신순 정렬, 끝 이후 totCnt=0. 120,000편 ÷ 100/page = 1,200 호출로 전량 스윕 |
+| 키 쿼터 | KOBIS 정책 | 키당 일 3,000회 — 전량 스윕(list 1,200회)은 키 1개로 가능. 신작 info 수집(~18k회)은 키 2개 × 수일 분산 필요 |
+
+---
+
+## Patterns to Mirror
+
+### GO_ERROR_RETURN (기존 스타일)
+```go
+// SOURCE: crawler/controller/movie.go:74-78 (이번에 수정된 형태)
+if err != nil {
+    c.JSON(http.StatusBadRequest, err.Error())
+    return
+}
+```
+
+### GO_LOGGING
+```go
+// SOURCE: crawler/model/movieDaily.go:96-103
+db.SetErrorLog(err, query)
+db.SetGeneralLog(config.GL_ACTION_SELECT, query, "select total movie daily")
+```
+
+---
+
+## Files to Change
+
+| File | Action | Justification |
+|---|---|---|
+| `crawler/service/rest.go` | UPDATE | WriteLog가 `msg` 기록하도록 수정 |
+| `crawler/controller/cron.go` | UPDATE | 이중 응답 제거, Info continue 전환, Detail OnHTML 루프 밖 등록 |
+| `crawler/docs/KOBIS-영화API.md` | UPDATE | 점검 결과·캐치업 절차 반영 |
+
+## NOT Building
+- `searchMovieList.do` 페이지 직접 크롤링 전환 (OpenAPI가 동일 데이터를 안정적으로 제공 — 전환 불필요)
+- 신작 조기 종료(early-stop) 최적화 — 전량 스윕이 쿼터 내라 우선순위 낮음, 후속 과제
+- c2(누적 매출/관객) 스크래퍼 재활성화
+
+---
+
+## Step-by-Step Tasks
+
+### Task 1: WriteLog 메시지 유실 수정
+- **ACTION**: `service/rest.go` WriteLog에서 `log.Println(err)` → `log.Println(msg)`
+- **GOTCHA**: `log.SetOutput(data)`은 전역 로거 출력을 바꿈 — 기존 동작 유지(범위 밖 리팩토링 금지)
+- **VALIDATE**: 무효 키로 crawler 호출 후 `log/kobis/error.txt`에 `KOBIS fault 320010...` 문자열 기록 확인
+
+### Task 2: cron.go 이중 응답 제거
+- **ACTION**: List(77-79행)·Info(167-169행)의 오류 분기에서 `c.JSON(400) + break` → `c.JSON(400) + return` (단, List는 return 전 `SetLastPage(page)` 호출해 재개 지점 보존)
+- **MIRROR**: GO_ERROR_RETURN
+- **VALIDATE**: 무효 키로 `/cron/list?key=invalid` 호출 시 단일 400 응답, gin 경고 로그 없음
+
+### Task 3: /cron/info 단건 실패 시 continue
+- **ACTION**: 개별 영화 API 오류·빈 MovieCd에서 `break` 대신 `errors++; continue`. 응답 JSON에 `failedCodes []string` 추가해 실패 목록 리포트
+- **GOTCHA**: checkFault로 인해 무효 키는 매 건 오류가 됨 — 연속 오류 N회(예: 10회) 시 전체 중단하는 가드 추가 (쿼터 낭비 방지)
+- **VALIDATE**: 존재하지 않는 movieCd를 tb_movie에 임시 삽입 후 /cron/info 실행 → 해당 건만 실패하고 나머지 진행
+
+### Task 4: Cron.Detail OnHTML 루프 밖 등록
+- **ACTION**: `movieDetail`을 루프 밖 포인터 변수로 선언, `c1.OnHTML`은 루프 진입 전 1회 등록하고 콜백은 현재 포인터를 채우도록 변경
+- **IMPLEMENT**:
+  ```go
+  var current *model.MovieDetail
+  c1.OnHTML(".item_tab.basic", func(e *colly.HTMLElement) {
+      // 기존 파싱 로직, movieDetail → current 로 치환
+  })
+  for _, movieCd := range codes {
+      d := this.MovieDetailModel.MovieDetail
+      d.MovieCd = movieCd
+      current = &d
+      err := c1.Post(config.MOVIE_DETAIL, ...)
+      ...
+  }
+  ```
+- **GOTCHA**: colly Async(false)라 순차 실행 — 포인터 스왑 안전. Async 전환 시 이 패턴 불가
+- **VALIDATE**: `/cron/detail` 실행 시간이 대상 수에 선형 비례(수백 건 기준 육안 확인), 저장 데이터 정상
+
+### Task 5: 캐치업 스윕 실행 (운영)
+- **ACTION**: 코드 배포 후 ① `log/kobis/page.txt`를 `1`로 리셋 ② `GET /cron/list?key=1` (약 1,200회 호출, 신작 ~18k편 upsert) ③ `GET /cron/info?key=2` (신규 코드만 — MovieInfoExcludeCodes 기반, ~18k건은 쿼터상 수일 분산) ④ `GET /cron/detail`
+- **GOTCHA**: info는 키당 3,000회/일 제한 — 18k건이면 키 2개로 3일. 중단돼도 ExcludeCodes 방식이라 재실행 시 이어서 진행됨
+- **VALIDATE**: `SELECT COUNT(*), MAX(created_at) FROM tb_movie;` — 120,000 근접 + 오늘 날짜
+
+### Task 6: 문서 갱신
+- **ACTION**: `crawler/docs/KOBIS-영화API.md` 운영 절차에 캐치업 스윕·연속 오류 가드·에러 로그 확인법 추가
+- **VALIDATE**: 문서 리뷰
+
+---
+
+## Testing Strategy
+
+| Test | Input | Expected Output | Edge Case? |
+|---|---|---|---|
+| WriteLog | 무효 키 호출 | error.txt에 실제 메시지 | |
+| cron/list 오류 | key=invalid | 단일 400, page.txt 보존 | ✅ |
+| cron/info 단건 실패 | 무효 movieCd 1건 | 해당 건 skip, 나머지 완료 | ✅ |
+| cron/info 연속 실패 | 무효 키 | 10회 후 중단(쿼터 보호) | ✅ |
+| cron/list 완주 | page.txt=끝 근처 | totCnt=0에서 정상 종료 | ✅ (라이브 검증됨) |
+
+## Validation Commands
+```powershell
+cd E:\server\project\crawler; go build ./...; go vet ./controller/... ./service/...
+# 기동 후
+curl "http://localhost:1050/cron/list?key=invalid"   # 단일 400 + error.txt 메시지 확인
+```
+```sql
+SELECT COUNT(*) AS cnt, MAX(created_at) AS last FROM movie.tb_movie;
+```
+
+## Acceptance Criteria
+- [ ] error.txt에 실제 오류 메시지 기록
+- [ ] cron List/Info 이중 응답 제거
+- [ ] Info 단건 실패가 배치를 중단하지 않음 (연속 오류 가드 포함)
+- [ ] Detail 콜백 1회 등록
+- [ ] 캐치업 후 tb_movie ≈ 120,000 / 신작 /movie/search 노출
+
+## Risks
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| 캐치업 중 쿼터 초과 | 중 | 중 | list는 1,200회로 여유. info는 키 분산+연속 오류 가드 |
+| KOBIS totCnt 120,000이 상한 캡일 가능성 | 낮 | 낮 | 스윕 후 실제 수집 수로 판단 |
+| 페이지 정렬(최신순)로 스윕 중 신작 유입 시 중복/누락 | 낮 | 낮 | upsert(ON DUPLICATE KEY)라 중복 무해, 다음 주기 스윕이 보정 |
+
+## Notes
+- 사용자가 언급한 `searchMovieList.do`는 코드에서 사용하지 않음 — 동일 데이터를 OpenAPI로 수집하는 구조이며 이 방식 유지가 맞다. `.do` 크롤링 전환은 불필요(NOT Building).
+- 무한 루프 우려는 라이브 검증으로 배제됨(끝 페이지 이후 totCnt=0 반환).
+- `/cron/*`를 호출하는 외부 스케줄러는 확인되지 않음 — page.txt가 2025-08-10에 멈춘 것으로 보아 수동 실행 체계. 캐치업 후 정기 스케줄(작업 스케줄러/크론) 등록 권장.

+ 60 - 0
.claude/PRPs/reports/kobis-crawler-movie-sync-report.md

@@ -0,0 +1,60 @@
+# Implementation Report: KOBIS Crawler ↔ Movie 동기화 수정 + 영화사 페이지 신설
+
+## Summary
+계획된 동기화 오류 수정(faultInfo 감지, return 누락, GetKey fallback, Laravel 장애 격리, 뷰 방어)을 완료하고, 사용자 추가 요청으로 **영화사 목록/상세 페이지 + 메뉴**를 신설했다. 로컬 Docker 스택(nginx/php-fpm) + 로컬 기동 crawler로 실사이트 end-to-end 검증까지 통과.
+
+## Assessment vs Reality
+
+| Metric | Predicted (Plan) | Actual |
+|---|---|---|
+| Complexity | Medium (12 files) | Medium+ (21 files — 영화사 기능 추가 스코프) |
+| Confidence | 8/10 | 구현 완료, 전 검증 통과 |
+| Files Changed | 12 | 21 (신규 8, 수정 13) |
+
+## Tasks Completed
+
+| # | Task | Status | Notes |
+|---|---|---|---|
+| 1 | KOBIS 키 동기화 | ✅ 사용자 수행 | Laravel KOBIS_API_1/2 = crawler apiKey_1/2 (유효 확인). KOBIS_API_3/apiKey_3 = 공식 샘플 키 — **여전히 무효(320010)**, 라이브 재검증됨. 사이트는 KOBIS_API_1만 사용하므로 무영향 |
+| 2 | crawler GetKey() fallback | ✅ | 하드코딩 무효 키 → `config.Movie.Kobis.ApiKey_3` |
+| 3 | crawler faultInfo 감지 | ✅ | `checkFault()` 신설, 5개 API 함수 적용(KCISA 포함, 무해) |
+| 4 | crawler return 누락 3곳 | ✅ | daily/weekly/list 오류 분기 |
+| 5 | Laravel 장애 격리 | ✅ | Rank/Search/Company 컨트롤러 `Http::timeout(5)` + `ConnectionException` catch + `abort(?: 500)` |
+| 6 | 누적 매출/관객 표시 방어 | ✅ 변경 불필요 | **편차**: search/show 뷰(desktop/mobile)에 `@if($detail->SaleAcc)` 가드가 이미 존재 — 계획이 구버전 가정 |
+| 7 | SearchController 정리 | ✅ | 죽은 `ListParams` 주입/임포트 제거 |
+| 8 | 문서/운영 | ✅ | crawler docs에 구현 매트릭스·키 정책·운영 절차 추가 |
+| + | **영화사 페이지 (추가 스코프)** | ✅ | crawler `/company/*` 2종(프록시, DB 캐싱 없음) + Laravel `/movie/company` index/show + desktop/mobile 뷰 4개 + tb_menu 메뉴(id=8) |
+
+## Validation Results
+
+| Level | Status | Notes |
+|---|---|---|
+| Static Analysis (Go) | ✅ Pass | `go build ./...` 무오류. `go vet` 경고는 전부 기존 파일(G2A/movieInfo/movieSearch/telegram) 사전 존재분 |
+| Static Analysis (PHP) | ✅ Pass | docker php-fpm으로 6개 파일 `php -l` 무오류 (로컬 PHP 없음 → docker 사용) |
+| Unit Tests | N/A | 양쪽 리포 모두 기존 테스트 인프라 부재 (movie tests/ 는 Laravel 기본 스켈레톤) |
+| Integration | ✅ Pass | 로컬 crawler 기동 후: ①company list ②company info ③무효 키→**HTTP 400 + KOBIS fault 320010** ④DB 캐시 daily ⑤신규 날짜(20260721) KOBIS→DB 실시간 적재 |
+| E2E (실사이트) | ✅ Pass | `https://local-movie.web.or.kr/movie/company` 200 + 목록/상세 렌더, `/movie/rank` 어제 데이터(호프 1위) 표시, **영화사 메뉴 네비게이션 노출** |
+
+## Files Changed
+
+**crawler (8)**: `model/kobis.go`(faultInfo+company API), `model/company.go`(신규), `controller/company.go`(신규), `controller/movie.go`(return×3), `controller/cron.go`(GetKey), `config/constants.go`(COMPANY URL), `route/api.go`(company 라우트), `docs/KOBIS-영화API.md`(구현현황)
+
+**movie (13)**: `config/constants.php`(COMPANY_LIST/INFO), `routes/web.php`(company resource), `app/Http/Controllers/Movie/RankController.php`, `SearchController.php`, `CompanyController.php`(신규), `app/Models/DTO/Movie/CompanyParams.php`(신규), 뷰 6개(rank/index desktop+mobile 수정, company index/show desktop+mobile 신규)
+
+**DB (로컬)**: `tb_menu`에 영화사 메뉴 INSERT(id=8, link=/movie/company) + `php artisan cache:clear`(컨테이너 내)
+
+## Deviations from Plan
+1. **Task 6 무변경**: search/show의 SaleAcc/AudiAcc 가드가 이미 코드에 존재.
+2. **desktop rank/index Detail null 가드 추가**: mobile에만 있던 `@isset($row->Detail->MainImg)` 패턴을 desktop에도 적용 (계획 edge case 목록에 있던 실버그).
+3. **메뉴 삽입 방식**: nested-set 중간 삽입(UPDATE shift)이 권한 정책으로 차단되어 **끝에 INSERT**(공지사항 뒤). 위치 조정은 관리자 > 메뉴 관리에서 가능.
+4. **브랜치 미생성**: 사용자 워크플로(main 직커밋 + 진행 중 미커밋 변경 존재)에 맞춰 main에서 작업, 커밋하지 않음.
+
+## Issues Encountered
+- 샘플 키(`82ca741a...`)는 여전히 무효 — KOBIS_API_3/apiKey_3 자리는 실키 발급 후 교체 권장 (현재 사이트 동작에는 무영향).
+- crawler dev/prod 서버(192.168.0.10/.100:1050)는 세션 시작 시 무응답이었음 — **운영 반영 시 crawler 재빌드·재시작 필요** (start.exe는 구버전 바이너리).
+
+## Next Steps
+- [ ] 운영 crawler 재빌드/배포 (`go build` → start.exe 교체) 후 재시작
+- [ ] 운영 DB tb_menu에 영화사 메뉴 추가 (관리자 > 페이지 > 메뉴) + 캐시 클리어
+- [ ] KOBIS_API_3/apiKey_3 실키 발급·교체 (선택)
+- [ ] `/code-review` 및 커밋 (`/prp-commit`)

+ 50 - 0
.claude/PRPs/reports/kobis-cron-movie-collection-report.md

@@ -0,0 +1,50 @@
+# Implementation Report: crawler 영화 수집(/cron/*) 결함 수정
+
+## Summary
+영화 수집 기능의 결함 4건(+테스트 중 발견 1건)을 수정하고 격리 환경(포트 1055, 스크래치 디렉토리)에서 통합 검증했다. 수집 재개(캐치업)를 위한 `page.txt=1` 리셋까지 완료 — 실행은 운영 판단 사항으로 남김.
+
+## Assessment vs Reality
+
+| Metric | Predicted (Plan) | Actual |
+|---|---|---|
+| Complexity | Small (3 files) | Small (3 files + 리포트) |
+| Confidence | 9/10 | 전 항목 검증 통과 |
+
+## Tasks Completed
+
+| # | Task | Status | Notes |
+|---|---|---|---|
+| 1 | WriteLog 메시지 유실 수정 | ✅ | `log.Println(err)` → `log.Println(msg)` — error.txt에 실제 메시지 기록 검증 |
+| 2 | cron List/Info 이중 응답 제거 | ✅ | List는 `SetLastPage(page)` 후 return (재개 지점 보존) |
+| 3 | /cron/info continue + 연속 오류 가드 | ✅ | 단건 skip + `failedCodes` 리포트, 연속 10회 시 400으로 중단 |
+| 4 | Detail OnHTML 루프 밖 등록 | ✅ | 포인터 스왑 패턴 — O(n²) 콜백 누적 제거 |
+| +α | GetLastPage TrimSpace 방어 | ✅ | **테스트 중 발견**: page.txt에 개행 등 공백이 있으면 Atoi 실패로 1페이지 리셋되던 문제 |
+| 5 | 캐치업 준비 | ✅ | 프로젝트 `page.txt=1` 리셋 완료. **스윕 실행은 미수행**(쿼터 소모가 커서 운영 판단) |
+| 6 | 문서 갱신 | ✅ | 운영 절차에 가드 동작·백로그 수치(목록 ~18k, info ~31k) 반영 |
+
+## Validation Results
+
+| Level | Status | Notes |
+|---|---|---|
+| Static Analysis | ✅ | `go build ./...` + `go vet ./controller/... ./service/...` 무오류 |
+| Integration (격리 환경, :1055) | ✅ | ① 무효 키 `/cron/list` → 단일 400 + error.txt에 `KOBIS fault 320010` 기록 ② 무효 키 `/cron/info` → 정확히 10건 실패 후 400 + failedCodes 10개 ③ 유효 키 `/cron/list` page 1199 시작 → 200편 upsert 후 totCnt=0(1201)에서 정상 종료, page.txt=1200 저장 |
+| Unit Tests | N/A | 리포에 테스트 인프라 없음 |
+| /cron/detail, /cron/info 전량 실행 | 미수행 | detail 대상 수 미상·info 백로그 31k(쿼터 3k/일) — 빌드+로직 검증으로 대체 |
+
+## Files Changed
+
+| File | Action |
+|---|---|
+| `crawler/service/rest.go` | UPDATE (WriteLog msg) |
+| `crawler/controller/cron.go` | UPDATE (return/continue/가드/OnHTML/TrimSpace) |
+| `crawler/docs/KOBIS-영화API.md` | UPDATE (운영 절차) |
+| `crawler/log/kobis/page.txt` | RESET → `1` |
+
+## Deviations from Plan
+- GetLastPage TrimSpace 추가 (테스트 중 발견한 실결함).
+- 캐치업 스윕(Task 5)의 실제 실행은 하지 않음 — list 1,200회 + info 수만 회 호출은 키 쿼터를 소모하는 되돌리기 어려운 작업이라 사용자 실행으로 남김.
+
+## Next Steps (운영)
+- [ ] **crawler 재빌드·재기동** — 현재 :1050에 떠 있는 crawler.exe는 이번 cron 수정 이전 빌드
+- [ ] 캐치업: `GET /cron/list?key=1` (약 1,200회, 신작 ~18k 유입) → `GET /cron/info?key=2` (백로그 ~31k+신규, 수일 분산) → `GET /cron/detail`
+- [ ] 정기 스케줄 등록 (작업 스케줄러/크론)

+ 9 - 9
.env

@@ -9,11 +9,11 @@ LOG_DEPRECATIONS_CHANNEL=null
 LOG_LEVEL=debug
 
 DB_CONNECTION=mysql
-DB_HOST=db.web.or.kr
+DB_HOST="${LOCAL_IP}"
 DB_PORT=3306
 DB_DATABASE=movie
-DB_USERNAME=admin
-DB_PASSWORD=bluescreen!!
+DB_USERNAME="${MYSQL_USER}"
+DB_PASSWORD="${MYSQL_PASSWORD}"
 
 BROADCAST_DRIVER=log
 CACHE_DRIVER=redis
@@ -25,18 +25,18 @@ SESSION_LIFETIME=120
 MEMCACHED_HOST=
 
 REDIS_HOST=redis
-REDIS_PASSWORD=bluescreen!!
+REDIS_PASSWORD="${REDIS_PASSWORD}"
 REDIS_PORT=6379
 REDIS_DB=2
 REDIS_CACHE_DB=2
 
 MAIL_MAILER=smtp
-MAIL_HOST=${SMTP_DOMAIN}
-MAIL_PORT=${SMTP_PORT}
-MAIL_USERNAME=${SMTP_USER}
-MAIL_PASSWORD=${SMTP_PASS}
+MAIL_HOST="${SMTP_HOST}"
+MAIL_PORT="${SMTP_PORT}"
+MAIL_USERNAME="${SMTP_USER}"
+MAIL_PASSWORD="${SMTP_PASS}"
 MAIL_ENCRYPTION=ssl
-MAIL_FROM_ADDRESS=${SMTP_USER}
+MAIL_FROM_ADDRESS="${SMTP_USER}"
 MAIL_FROM_NAME="${APP_NAME}"
 MAIL_SMTP_TIMEOUT=30
 

+ 8 - 8
.env.production

@@ -12,8 +12,8 @@ DB_CONNECTION=mysql
 DB_HOST=mysql
 DB_PORT=3306
 DB_DATABASE=movie
-DB_USERNAME=admin
-DB_PASSWORD=bluescreen!!
+DB_USERNAME="${MYSQL_USER}"
+DB_PASSWORD="${MYSQL_PASSWORD}"
 
 BROADCAST_DRIVER=log
 CACHE_DRIVER=redis
@@ -27,18 +27,18 @@ SESSION_SAME_SITE=lax
 MEMCACHED_HOST=
 
 REDIS_HOST=redis
-REDIS_PASSWORD=bluescreen!!
+REDIS_PASSWORD="${REDIS_PASSWORD}"
 REDIS_PORT=6379
 REDIS_DB=2
 REDIS_CACHE_DB=2
 
 MAIL_MAILER=smtp
-MAIL_HOST=${SMTP_DOMAIN}
-MAIL_PORT=${SMTP_PORT}
-MAIL_USERNAME=${SMTP_USER}
-MAIL_PASSWORD=${SMTP_PASS}
+MAIL_HOST="${SMTP_HOST}"
+MAIL_PORT="${SMTP_PORT}"
+MAIL_USERNAME="${SMTP_USER}"
+MAIL_PASSWORD="${SMTP_PASS}"
 MAIL_ENCRYPTION=ssl
-MAIL_FROM_ADDRESS=${SMTP_USER}
+MAIL_FROM_ADDRESS="${SMTP_USER}"
 MAIL_FROM_NAME="${APP_NAME}"
 MAIL_SMTP_TIMEOUT=30
 

+ 149 - 0
app/Http/Controllers/Movie/CompanyController.php

@@ -0,0 +1,149 @@
+<?php
+
+namespace App\Http\Controllers\Movie;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\Client\ConnectionException;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Validator;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Http\Traits\PagingTrait;
+use App\Models\DTO\Movie\CompanyParams;
+use Exception;
+
+class CompanyController extends Controller
+{
+    use CommonTrait;
+    use PagingTrait;
+
+    private string $host;
+
+    public function __construct() {
+        $this->middleware('front');
+
+        $this->host = env('MOVIE_API');
+    }
+
+    /**
+     * 영화사 목록
+     * @method GET
+     * @see /movie/company
+     */
+    public function index(Request $request, CompanyParams $params)
+    {
+        $rules = [
+            'page' => 'nullable|numeric',
+            'keyword' => 'nullable|string|max:100',
+            'ceo' => 'nullable|string|max:30'
+        ];
+
+        $attributes = [
+            'page' => '페이지 번호',
+            'keyword' => '영화사명',
+            'ceo' => '대표자명'
+        ];
+
+        $message = [
+            'page.numeric' => '페이지 번호는 숫자여야 합니다.',
+            'keyword.max' => '영화사명 최대 글자는 100자 입니다.',
+            'ceo.max' => '대표자명 최대 글자는 30자 입니다.'
+        ];
+
+        // 검색 유효성 검사
+        Validator::make($request->all(), $rules, $message, $attributes)->validated();
+
+        $page = max(1, (int)$request->get('page', 1));
+        $params->curPage = $page;
+        $params->companyNm = $request->get('keyword');
+        $params->ceoNm = $request->get('ceo');
+
+        // 검색 조건 (crawler 장애 시 빈 목록으로 격리)
+        $total = 0;
+        $list = [];
+        try {
+            $response = Http::timeout(5)->get($this->host . COMPANY_LIST, $params->toArray());
+        } catch (ConnectionException) {
+            $response = null;
+        }
+
+        if ($response && $response->ok()) {
+            $data = json_decode($response->body());
+            $total = (int)($data->total ?? 0);
+            if ($total > 0) {
+                $queryString = $request->getQueryString();
+                foreach ($data->list as $row) {
+                    $row->viewURL = route('movie.company.show', base64_encode($row->companyCd));
+                    if ($queryString) {
+                        $row->viewURL .= ('?' . $queryString);
+                    }
+                    $list[] = $row;
+                }
+            }
+        }
+
+        $companies = $this->getPaginator($list, $total, $params->itemPerPage, $page);
+
+        return view(layout('movie.company.index'), [
+            'keyword' => $request->get('keyword'),
+            'ceo' => $request->get('ceo'),
+            'total' => $total,
+            'companies' => $companies
+        ]);
+    }
+
+    /**
+     * 영화사 상세 보기
+     * @method GET
+     * @see /movie/company/{company_cd}
+     */
+    public function show(string $base64, Request $request, CompanyParams $params)
+    {
+        try {
+
+            if(!$base64) {
+                throw new Exception('잘못된 접근입니다. [1]');
+            }
+
+            $companyCd = base64_decode($base64);
+
+            if(!$companyCd) {
+                throw new Exception('잘못된 접근입니다. [2]');
+            }
+
+            $listURL = route('movie.company.index');
+            if($queryString = $request->getQueryString()) {
+                $listURL .= ('?' . $queryString);
+            }
+
+            // 검색 조건
+            $params->companyCd = $companyCd;
+            $response = Http::timeout(5)->get($this->host . COMPANY_INFO, $params->toArray());
+
+            if(!$response->ok()) {
+                throw new Exception('요청한 정보가 없습니다.');
+            }
+
+            $data = json_decode($response->body());
+            $info = ($data->info ?? null);
+
+            if(!$info || empty($info->companyCd)) {
+                throw new Exception('요청한 정보가 없습니다.');
+            }
+
+            $companyNm = $info->companyNm;
+            if(!empty($info->companyNmEn)) {
+                $companyNm .= (' (' . $info->companyNmEn . ')');
+            }
+
+            return view(layout('movie.company.show'), [
+                'companyNm' => $companyNm,
+                'info' => $info,
+                'listURL' => $listURL
+            ]);
+
+        }catch(Exception $e) {
+            abort($e->getCode() ?: 500, $e->getMessage());
+        }
+    }
+}

+ 11 - 6
app/Http/Controllers/Movie/RankController.php

@@ -3,6 +3,7 @@
 namespace App\Http\Controllers\Movie;
 
 use Illuminate\Http\Request;
+use Illuminate\Http\Client\ConnectionException;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Validator;
 use App\Http\Controllers\Controller;
@@ -87,11 +88,15 @@ class RankController extends Controller
             }
         }
 
-        // 검색 조건
-        $response = Http::get($url, $params->toArray());
-
+        // 검색 조건 (crawler 장애 시 빈 목록으로 격리)
         $data = null;
-        if ($response->ok()) {
+        try {
+            $response = Http::timeout(5)->get($url, $params->toArray());
+        } catch (ConnectionException) {
+            $response = null;
+        }
+
+        if ($response && $response->ok()) {
             $data = json_decode($response->body());
             if ($data->total > 0) {
                 $queryString = $request->getQueryString();
@@ -151,7 +156,7 @@ class RankController extends Controller
             }
 
             $params->{$type. 'ID'} = $id;
-            $response = Http::get($url, $params->toArray());
+            $response = Http::timeout(5)->get($url, $params->toArray());
 
             if(!$response->ok()) {
                 throw new Exception('요청한 정보가 없습니다.');
@@ -181,7 +186,7 @@ class RankController extends Controller
             ]);
 
         }catch(Exception $e) {
-            abort($e->getCode(), $e->getMessage());
+            abort($e->getCode() ?: 500, $e->getMessage());
         }
     }
 }

+ 3 - 4
app/Http/Controllers/Movie/SearchController.php

@@ -8,7 +8,6 @@ use Illuminate\Support\Facades\Validator;
 use App\Http\Controllers\Controller;
 use App\Http\Traits\CommonTrait;
 use App\Http\Traits\PagingTrait;
-use App\Models\DTO\Movie\ListParams;
 use App\Models\DTO\Movie\InfoParams;
 use App\Models\DTO\SearchData;
 use App\Models\Movie\Movie;
@@ -40,7 +39,7 @@ class SearchController extends Controller
      * @method GET
      * @see /movie/search
      */
-    public function index(Request $request, ListParams $params)
+    public function index(Request $request)
     {
         $rules = [
             'page' => 'nullable|numeric',
@@ -137,7 +136,7 @@ class SearchController extends Controller
             $url = ($this->host . MOVIE_INFO);
 
             $params->movieCd = $movieCd;
-            $response = Http::get($url, $params->toArray());
+            $response = Http::timeout(5)->get($url, $params->toArray());
 
             if(!$response->ok()) {
                 throw new Exception('요청한 정보가 없습니다.');
@@ -164,7 +163,7 @@ class SearchController extends Controller
             ]);
 
         }catch(Exception $e) {
-            abort($e->getCode(), $e->getMessage());
+            abort($e->getCode() ?: 500, $e->getMessage());
         }
     }
 }

+ 57 - 0
app/Models/DTO/Movie/CompanyParams.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Models\DTO\Movie;
+use JsonSerializable;
+
+class CompanyParams implements JsonSerializable
+{
+    public string $key;
+
+    public function __construct(
+        public int $curPage = 1,
+        public int $itemPerPage = 20,
+        public string|null $companyNm = null,
+        public string|null $ceoNm = null,
+        public string|null $companyPartCd = null,
+        public string $companyCd = ""
+    ) {
+        $this->key = KOBIS_API_1;
+    }
+
+    public function jsonSerialize(): array
+    {
+        return [
+            'curPage' => $this->curPage,
+            'itemPerPage' => $this->itemPerPage,
+            'companyNm' => $this->companyNm,
+            'ceoNm' => $this->ceoNm,
+            'companyPartCd' => $this->companyPartCd,
+            'companyCd' => $this->companyCd
+        ];
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'key' => $this->key,
+            'curPage' => $this->curPage,
+            'itemPerPage' => $this->itemPerPage,
+            'companyNm' => $this->companyNm,
+            'ceoNm' => $this->ceoNm,
+            'companyPartCd' => $this->companyPartCd,
+            'companyCd' => $this->companyCd
+        ];
+    }
+
+    public function toJson(): string|false
+    {
+        return json_encode([
+            'curPage' => $this->curPage,
+            'itemPerPage' => $this->itemPerPage,
+            'companyNm' => $this->companyNm,
+            'ceoNm' => $this->ceoNm,
+            'companyPartCd' => $this->companyPartCd,
+            'companyCd' => $this->companyCd
+        ]);
+    }
+}

+ 8 - 6
config/constants.php

@@ -7,8 +7,8 @@
  */
 
 // 해시 암호화 키
-const ENCRYPT_KEY = '@@20120726CHrong';
-const ENCRYPT_IV = '@@20120726CHrong';
+const ENCRYPT_KEY = '8ae4993ba0b0f65ec306a3842b2ab58165006260c5c2a6a3d159e4a18b5679f9';
+const ENCRYPT_IV  = 'b3700bce18978aa5152da279619bd6caedcab49c287e50bb0f84cd59696b1759';
 
 // 관리자 페이지 경로
 const ADMIN_PATH = 'admin';
@@ -297,9 +297,9 @@ const MAP_EXP_CONTENT = [
 /**
  * 영화진흥위원회 API
  */
-const KOBIS_API_1 = 'd470c8a1fa881cd5945b747a6dfadd53';
-const KOBIS_API_2 = '52c8406a33ffe497f041868b2c3be552';
-const KOBIS_API_3 = 'f5eef3421c602c6cb7ea224104795888';
+const KOBIS_API_1 = 'b453d855c8cb458236dc89f4e7897995';
+const KOBIS_API_2 = 'cf7ea46f636ff9f9014e2b014bbd3006';
+const KOBIS_API_3 = '82ca741a2844c5c180a208137bb92bd7';
 
 const MOVIE_DAILY_LIST  = '/movie/searchDailyBoxOfficeList';
 const MOVIE_WEEK_LIST   = '/movie/searchWeeklyBoxOfficeList';
@@ -307,6 +307,8 @@ const MOVIE_LIST        = '/movie/searchMovieList';
 const MOVIE_INFO        = '/movie/searchMovieInfo';
 const MOVIE_DAILY_INFO  = '/movie/searchDailyInfo';
 const MOVIE_WEEKLY_INFO = '/movie/searchWeeklyInfo';
+const COMPANY_LIST      = '/company/searchCompanyList';
+const COMPANY_INFO      = '/company/searchCompanyInfo';
 
 /**
  * 영화 장르
@@ -372,7 +374,7 @@ const MAP_MOVIE_TYPE = [
  * The movie API
  */
 const TMDB_HOST = 'https://api.themoviedb.org/3';
-const TMDB_API_KEY = '33d297cf172734ee6e789aaeb30dcdda';
+const TMDB_API_KEY = 'bfeb1c9a13ccf13a498ba1308d60f5db';
 const TMDB_GET_NOW_PLAYING = '/movie/now_playing';
 const TMDB_GET_POPULAR     = '/movie/popular';
 const TMDB_GET_TOP_RATED   = '/movie/top_rated';

+ 1 - 1
resources/views/desktop/layouts/app.blade.php

@@ -187,7 +187,7 @@
     <footer id="footer">
         <div class="ad-coupang">
             <iframe src="https://coupa.ng/cdLzPn" width="100%" height="75" frameborder="0" scrolling="no" referrerpolicy="unsafe-url"></iframe>
-            <iframe src="https://ads-partners.coupang.com/widgets.html?id=630891&template=carousel&trackingCode=AF0305179&subId=&width=200&height=150" width="100%" height="150" frameborder="0" scrolling="no" referrerpolicy="unsafe-url"></iframe>
+            <iframe src="https://ads-partners.coupang.com/widgets.html?id=630891&template=carousel&trackingCode=AF0305179&subId=movie202212&width=200&height=150&tsource=" width="100%" height="150" frameborder="0" scrolling="no" referrerpolicy="unsafe-url" browsingtopics></iframe>
             <small>
                 쿠팡 파트너스의 활동은 공정거래위원회의 심사지침에 따라 추천, 보증인인 파트너스 회원과 당사의 경제적 이해관계에 대하여 공개하여야 합니다.
             </small>

+ 78 - 0
resources/views/desktop/movie/company/index.blade.php

@@ -0,0 +1,78 @@
+@extends('desktop.layouts.app')
+@section('content')
+<div id="movieCompany" class="container">
+
+    <h5>영화사</h5>
+    <hr/>
+
+    <form id="fMovieCompany" method="get" action="{{ route('movie.company.index') }}" autocomplete="on" accept-charset="UTF-8">
+        <table class="table table-borderless w-auto">
+            <colgroup>
+                <col width="80px"/>
+                <col width="300px"/>
+                <col width="80px"/>
+                <col width="300px"/>
+                <col width="*"/>
+            </colgroup>
+            <tr class="align-middle text-center">
+                <th>영화사명</th>
+                <td>
+                    <input type="text" name="keyword" class="form-control" value="{{ $keyword }}" maxlength="100"/>
+                </td>
+                <th>대표자명</th>
+                <td>
+                    <input type="text" name="ceo" class="form-control" value="{{ $ceo }}" maxlength="30"/>
+                </td>
+                <td class="text-start">
+                    <button type="submit" class="btn btn-dark">검색</button>
+                </td>
+            </tr>
+        </table>
+    </form>
+    <hr/>
+
+    @if($companies->count() > 0)
+    <table class="table table-hover align-middle" style="table-layout: fixed;">
+        <colgroup>
+            <col width="260px"/>
+            <col width="100px"/>
+            <col width="170px"/>
+            <col width="*"/>
+        </colgroup>
+        <thead>
+            <tr class="text-center">
+                <th>영화사명</th>
+                <th>대표자</th>
+                <th>분류</th>
+                <th>필모그래피</th>
+            </tr>
+        </thead>
+        <tbody>
+        @foreach($companies as $row)
+            <tr>
+                <td class="text-truncate">
+                    <a href="{{ $row->viewURL }}" target="_self" title="{{ $row->companyNm }}">
+                        {{ $row->companyNm }}
+                        @if($row->companyNmEn)
+                            <small class="text-secondary">({{ $row->companyNmEn }})</small>
+                        @endif
+                    </a>
+                </td>
+                <td class="text-center text-truncate">{{ $row->ceoNm ?: '-' }}</td>
+                <td class="text-center text-truncate" title="{{ $row->companyPartNames }}">{{ $row->companyPartNames ?: '-' }}</td>
+                <td class="text-truncate">
+                    <small class="text-secondary">{{ Str::limit($row->filmoNames, 120) }}</small>
+                </td>
+            </tr>
+        @endforeach
+        </tbody>
+    </table>
+
+    {{ $companies->onEachSide(10)->links('component.pagination') }}
+    @else
+    <div class="text-center p-5">
+        <h6>자료가 존재하지 않습니다.</h6>
+    </div>
+    @endif
+</div>
+@endsection

+ 72 - 0
resources/views/desktop/movie/company/show.blade.php

@@ -0,0 +1,72 @@
+@extends('desktop.layouts.app')
+@section('content')
+<div id="movieCompanyView" class="container">
+
+    <div class="row g-2 align-items-center">
+        <div class="col-auto">
+            <h5 class="mb-0 fw-bold">{{ $companyNm }}</h5>
+        </div>
+        <div class="col text-end">
+            <a href="{{ $listURL }}" class="btn btn-sm btn-default" target="_self">목록으로</a>
+        </div>
+    </div>
+    <hr/>
+
+    <label class="form-label">기본 정보</label>
+    <table class="table table-borderless">
+        <colgroup>
+            <col width="100px"/>
+            <col width="*"/>
+        </colgroup>
+        <tr>
+            <th>대표자</th>
+            <td>{{ $info->ceoNm ?: '-' }}</td>
+        </tr>
+        <tr>
+            <th>분류</th>
+            <td>
+                @if(!empty($info->parts))
+                    @foreach($info->parts as $part)
+                        {{ $part->companyPartNm }}@if(!$loop->last), @endif
+                    @endforeach
+                @else
+                    -
+                @endif
+            </td>
+        </tr>
+    </table>
+    <hr/>
+
+    <section class="filmo">
+        <h5>필모그래피 @if(!empty($info->filmos))<small class="text-secondary">({{ count($info->filmos) }})</small>@endif</h5>
+        @if(!empty($info->filmos))
+        <table class="table table-hover align-middle">
+            <colgroup>
+                <col width="*"/>
+                <col width="160px"/>
+            </colgroup>
+            <thead>
+                <tr class="text-center">
+                    <th>영화명</th>
+                    <th>참여 분야</th>
+                </tr>
+            </thead>
+            <tbody>
+            @foreach($info->filmos as $filmo)
+                <tr>
+                    <td>
+                        <a href="{{ route('movie.search.show', base64_encode($filmo->movieCd)) }}" target="_self">
+                            {{ $filmo->movieNm }}
+                        </a>
+                    </td>
+                    <td class="text-center">{{ $filmo->companyPartNm }}</td>
+                </tr>
+            @endforeach
+            </tbody>
+        </table>
+        @else
+        <blockquote>-</blockquote>
+        @endif
+    </section>
+</div>
+@endsection

+ 8 - 4
resources/views/desktop/movie/rank/index.blade.php

@@ -27,14 +27,18 @@
     </form>
     <br/>
 
-    @if($data->rows > 0)
+    @if($data && $data->rows > 0)
     <div class="rank-list row row-cols-1 row-cols-sm-2 row-cols-lg-3 row-cols-xxl-5">
         @foreach($data->list as $row)
             <div class="col">
                 <a href="{{ $row->viewURL }}" class="poster-img" target="_self" title="{{ $row->MovieNm }}">
-                    <div class="ratio ratio-2x3">
-                        <img src="{{ $row->Detail->MainImg }}" class="img-fluid rounded" alt="{{ $row->MovieNm }}" onerror="this.src='/images/default/no_image.png'"/>
-                    </div>
+                    @isset($row->Detail->MainImg)
+                        <div class="ratio ratio-2x3">
+                            <img src="{{ $row->Detail->MainImg }}" class="img-fluid rounded" alt="{{ $row->MovieNm }}" onerror="this.src='/images/default/no_image.png'"/>
+                        </div>
+                    @else
+                        <img src="/images/default/no_image.png" class="img-fluid rounded" width="100%" alt="{{ $row->MovieNm }}" />
+                    @endisset
                 </a>
                 <dl>
                     <dt>

+ 1 - 1
resources/views/mobile/layouts/app.blade.php

@@ -182,7 +182,7 @@
                 <!-- 쿠팡 광고 -->
                 <div class="ad-coupang">
                     <iframe src="https://coupa.ng/cdvhzK" width="100%" height="75" frameborder="0" scrolling="no" referrerpolicy="unsafe-url"></iframe>
-                    <iframe src="https://ads-partners.coupang.com/widgets.html?id=637907&template=carousel&trackingCode=AF0305179&subId=&width=100&height=140" width="100%" height="140" frameborder="0" scrolling="no" referrerpolicy="unsafe-url"></iframe>
+                    <iframe src="https://ads-partners.coupang.com/widgets.html?id=630891&template=carousel&trackingCode=AF0305179&subId=movie202212&width=100&height=150&tsource=" width="100%" height="140" frameborder="0" scrolling="no" referrerpolicy="unsafe-url" browsingtopics></iframe>
                     <small>
                         쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
                     </small>

+ 59 - 0
resources/views/mobile/movie/company/index.blade.php

@@ -0,0 +1,59 @@
+@extends('mobile.layouts.app')
+@section('content')
+<div id="movieCompany" class="container-fluid">
+
+    <h5>영화사</h5>
+    <hr/>
+
+    <form id="fMovieCompany" method="get" action="{{ route('movie.company.index') }}" autocomplete="on" accept-charset="UTF-8">
+        <div class="row g-2">
+            <div class="col-5">
+                <input type="text" name="keyword" class="form-control" value="{{ $keyword }}" maxlength="100" placeholder="영화사명"/>
+            </div>
+            <div class="col-4">
+                <input type="text" name="ceo" class="form-control" value="{{ $ceo }}" maxlength="30" placeholder="대표자명"/>
+            </div>
+            <div class="col-3 d-grid">
+                <button type="submit" class="btn btn-dark">검색</button>
+            </div>
+        </div>
+    </form>
+    <br/>
+
+    @if($companies->count() > 0)
+    <table class="table table-hover align-middle">
+        <colgroup>
+            <col width="*"/>
+            <col width="90px"/>
+        </colgroup>
+        <thead>
+            <tr class="text-center">
+                <th>영화사명</th>
+                <th>대표자</th>
+            </tr>
+        </thead>
+        <tbody>
+        @foreach($companies as $row)
+            <tr>
+                <td>
+                    <a href="{{ $row->viewURL }}" target="_self">
+                        {{ $row->companyNm }}
+                    </a>
+                    @if($row->companyPartNames)
+                        <div><small class="text-secondary">{{ $row->companyPartNames }}</small></div>
+                    @endif
+                </td>
+                <td class="text-center">{{ $row->ceoNm ?: '-' }}</td>
+            </tr>
+        @endforeach
+        </tbody>
+    </table>
+
+    {{ $companies->onEachSide(3)->links('component.pagination') }}
+    @else
+    <div class="text-center p-5">
+        <h6>자료가 존재하지 않습니다.</h6>
+    </div>
+    @endif
+</div>
+@endsection

+ 66 - 0
resources/views/mobile/movie/company/show.blade.php

@@ -0,0 +1,66 @@
+@extends('mobile.layouts.app')
+@section('content')
+<div id="movieCompanyView" class="container-fluid">
+
+    <div class="row g-2 align-items-center">
+        <div class="col">
+            <h5 class="mb-0 fw-bold">{{ $companyNm }}</h5>
+        </div>
+        <div class="col-auto text-end">
+            <a href="{{ $listURL }}" class="btn btn-sm btn-default" target="_self">목록으로</a>
+        </div>
+    </div>
+    <hr/>
+
+    <label class="form-label">기본 정보</label>
+    <table class="table table-borderless">
+        <colgroup>
+            <col width="80px"/>
+            <col width="*"/>
+        </colgroup>
+        <tr>
+            <th>대표자</th>
+            <td>{{ $info->ceoNm ?: '-' }}</td>
+        </tr>
+        <tr>
+            <th>분류</th>
+            <td>
+                @if(!empty($info->parts))
+                    @foreach($info->parts as $part)
+                        {{ $part->companyPartNm }}@if(!$loop->last), @endif
+                    @endforeach
+                @else
+                    -
+                @endif
+            </td>
+        </tr>
+    </table>
+    <hr/>
+
+    <section class="filmo">
+        <h5>필모그래피 @if(!empty($info->filmos))<small class="text-secondary">({{ count($info->filmos) }})</small>@endif</h5>
+        @if(!empty($info->filmos))
+        <table class="table table-hover align-middle">
+            <colgroup>
+                <col width="*"/>
+                <col width="90px"/>
+            </colgroup>
+            <tbody>
+            @foreach($info->filmos as $filmo)
+                <tr>
+                    <td>
+                        <a href="{{ route('movie.search.show', base64_encode($filmo->movieCd)) }}" target="_self">
+                            {{ $filmo->movieNm }}
+                        </a>
+                    </td>
+                    <td class="text-center"><small>{{ $filmo->companyPartNm }}</small></td>
+                </tr>
+            @endforeach
+            </tbody>
+        </table>
+        @else
+        <blockquote>-</blockquote>
+        @endif
+    </section>
+</div>
+@endsection

+ 1 - 1
resources/views/mobile/movie/rank/index.blade.php

@@ -28,7 +28,7 @@
     </form>
     <br/>
 
-    @if($data->rows > 0)
+    @if($data && $data->rows > 0)
     <div class="rank-list row row-cols-2 row-cols-sm-3 row-cols-lg-4 row-cols-xxl-5">
         @foreach($data->list as $row)
             <div class="col">

+ 1 - 0
routes/web.php

@@ -187,6 +187,7 @@ Route::prefix('movie')->name('movie.')->group(function()
     Route::resource('/rank', Movie\RankController::class)->names('rank');
     Route::resource('/search', Movie\SearchController::class)->names('search');
     Route::resource('/trailer', Movie\TrailerController::class)->names('trailer');
+    Route::resource('/company', Movie\CompanyController::class)->names('company');
 
     Route::post('/review/like', [Movie\ReviewController::class, 'like'])->name('review.like');
     Route::post('/review/dislike', [Movie\ReviewController::class, 'dislike'])->name('review.dislike');