admin@web.or.kr hai 3 días
pai
achega
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
 LOG_LEVEL=debug
 
 
 DB_CONNECTION=mysql
 DB_CONNECTION=mysql
-DB_HOST=db.web.or.kr
+DB_HOST="${LOCAL_IP}"
 DB_PORT=3306
 DB_PORT=3306
 DB_DATABASE=movie
 DB_DATABASE=movie
-DB_USERNAME=admin
-DB_PASSWORD=bluescreen!!
+DB_USERNAME="${MYSQL_USER}"
+DB_PASSWORD="${MYSQL_PASSWORD}"
 
 
 BROADCAST_DRIVER=log
 BROADCAST_DRIVER=log
 CACHE_DRIVER=redis
 CACHE_DRIVER=redis
@@ -25,18 +25,18 @@ SESSION_LIFETIME=120
 MEMCACHED_HOST=
 MEMCACHED_HOST=
 
 
 REDIS_HOST=redis
 REDIS_HOST=redis
-REDIS_PASSWORD=bluescreen!!
+REDIS_PASSWORD="${REDIS_PASSWORD}"
 REDIS_PORT=6379
 REDIS_PORT=6379
 REDIS_DB=2
 REDIS_DB=2
 REDIS_CACHE_DB=2
 REDIS_CACHE_DB=2
 
 
 MAIL_MAILER=smtp
 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_ENCRYPTION=ssl
-MAIL_FROM_ADDRESS=${SMTP_USER}
+MAIL_FROM_ADDRESS="${SMTP_USER}"
 MAIL_FROM_NAME="${APP_NAME}"
 MAIL_FROM_NAME="${APP_NAME}"
 MAIL_SMTP_TIMEOUT=30
 MAIL_SMTP_TIMEOUT=30
 
 

+ 8 - 8
.env.production

@@ -12,8 +12,8 @@ DB_CONNECTION=mysql
 DB_HOST=mysql
 DB_HOST=mysql
 DB_PORT=3306
 DB_PORT=3306
 DB_DATABASE=movie
 DB_DATABASE=movie
-DB_USERNAME=admin
-DB_PASSWORD=bluescreen!!
+DB_USERNAME="${MYSQL_USER}"
+DB_PASSWORD="${MYSQL_PASSWORD}"
 
 
 BROADCAST_DRIVER=log
 BROADCAST_DRIVER=log
 CACHE_DRIVER=redis
 CACHE_DRIVER=redis
@@ -27,18 +27,18 @@ SESSION_SAME_SITE=lax
 MEMCACHED_HOST=
 MEMCACHED_HOST=
 
 
 REDIS_HOST=redis
 REDIS_HOST=redis
-REDIS_PASSWORD=bluescreen!!
+REDIS_PASSWORD="${REDIS_PASSWORD}"
 REDIS_PORT=6379
 REDIS_PORT=6379
 REDIS_DB=2
 REDIS_DB=2
 REDIS_CACHE_DB=2
 REDIS_CACHE_DB=2
 
 
 MAIL_MAILER=smtp
 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_ENCRYPTION=ssl
-MAIL_FROM_ADDRESS=${SMTP_USER}
+MAIL_FROM_ADDRESS="${SMTP_USER}"
 MAIL_FROM_NAME="${APP_NAME}"
 MAIL_FROM_NAME="${APP_NAME}"
 MAIL_SMTP_TIMEOUT=30
 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;
 namespace App\Http\Controllers\Movie;
 
 
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+use Illuminate\Http\Client\ConnectionException;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Facades\Validator;
 use App\Http\Controllers\Controller;
 use App\Http\Controllers\Controller;
@@ -87,11 +88,15 @@ class RankController extends Controller
             }
             }
         }
         }
 
 
-        // 검색 조건
-        $response = Http::get($url, $params->toArray());
-
+        // 검색 조건 (crawler 장애 시 빈 목록으로 격리)
         $data = null;
         $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());
             $data = json_decode($response->body());
             if ($data->total > 0) {
             if ($data->total > 0) {
                 $queryString = $request->getQueryString();
                 $queryString = $request->getQueryString();
@@ -151,7 +156,7 @@ class RankController extends Controller
             }
             }
 
 
             $params->{$type. 'ID'} = $id;
             $params->{$type. 'ID'} = $id;
-            $response = Http::get($url, $params->toArray());
+            $response = Http::timeout(5)->get($url, $params->toArray());
 
 
             if(!$response->ok()) {
             if(!$response->ok()) {
                 throw new Exception('요청한 정보가 없습니다.');
                 throw new Exception('요청한 정보가 없습니다.');
@@ -181,7 +186,7 @@ class RankController extends Controller
             ]);
             ]);
 
 
         }catch(Exception $e) {
         }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\Controllers\Controller;
 use App\Http\Traits\CommonTrait;
 use App\Http\Traits\CommonTrait;
 use App\Http\Traits\PagingTrait;
 use App\Http\Traits\PagingTrait;
-use App\Models\DTO\Movie\ListParams;
 use App\Models\DTO\Movie\InfoParams;
 use App\Models\DTO\Movie\InfoParams;
 use App\Models\DTO\SearchData;
 use App\Models\DTO\SearchData;
 use App\Models\Movie\Movie;
 use App\Models\Movie\Movie;
@@ -40,7 +39,7 @@ class SearchController extends Controller
      * @method GET
      * @method GET
      * @see /movie/search
      * @see /movie/search
      */
      */
-    public function index(Request $request, ListParams $params)
+    public function index(Request $request)
     {
     {
         $rules = [
         $rules = [
             'page' => 'nullable|numeric',
             'page' => 'nullable|numeric',
@@ -137,7 +136,7 @@ class SearchController extends Controller
             $url = ($this->host . MOVIE_INFO);
             $url = ($this->host . MOVIE_INFO);
 
 
             $params->movieCd = $movieCd;
             $params->movieCd = $movieCd;
-            $response = Http::get($url, $params->toArray());
+            $response = Http::timeout(5)->get($url, $params->toArray());
 
 
             if(!$response->ok()) {
             if(!$response->ok()) {
                 throw new Exception('요청한 정보가 없습니다.');
                 throw new Exception('요청한 정보가 없습니다.');
@@ -164,7 +163,7 @@ class SearchController extends Controller
             ]);
             ]);
 
 
         }catch(Exception $e) {
         }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';
 const ADMIN_PATH = 'admin';
@@ -297,9 +297,9 @@ const MAP_EXP_CONTENT = [
 /**
 /**
  * 영화진흥위원회 API
  * 영화진흥위원회 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_DAILY_LIST  = '/movie/searchDailyBoxOfficeList';
 const MOVIE_WEEK_LIST   = '/movie/searchWeeklyBoxOfficeList';
 const MOVIE_WEEK_LIST   = '/movie/searchWeeklyBoxOfficeList';
@@ -307,6 +307,8 @@ const MOVIE_LIST        = '/movie/searchMovieList';
 const MOVIE_INFO        = '/movie/searchMovieInfo';
 const MOVIE_INFO        = '/movie/searchMovieInfo';
 const MOVIE_DAILY_INFO  = '/movie/searchDailyInfo';
 const MOVIE_DAILY_INFO  = '/movie/searchDailyInfo';
 const MOVIE_WEEKLY_INFO = '/movie/searchWeeklyInfo';
 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
  * The movie API
  */
  */
 const TMDB_HOST = 'https://api.themoviedb.org/3';
 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_NOW_PLAYING = '/movie/now_playing';
 const TMDB_GET_POPULAR     = '/movie/popular';
 const TMDB_GET_POPULAR     = '/movie/popular';
 const TMDB_GET_TOP_RATED   = '/movie/top_rated';
 const TMDB_GET_TOP_RATED   = '/movie/top_rated';

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

@@ -187,7 +187,7 @@
     <footer id="footer">
     <footer id="footer">
         <div class="ad-coupang">
         <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://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>
                 쿠팡 파트너스의 활동은 공정거래위원회의 심사지침에 따라 추천, 보증인인 파트너스 회원과 당사의 경제적 이해관계에 대하여 공개하여야 합니다.
                 쿠팡 파트너스의 활동은 공정거래위원회의 심사지침에 따라 추천, 보증인인 파트너스 회원과 당사의 경제적 이해관계에 대하여 공개하여야 합니다.
             </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>
     </form>
     <br/>
     <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">
     <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)
         @foreach($data->list as $row)
             <div class="col">
             <div class="col">
                 <a href="{{ $row->viewURL }}" class="poster-img" target="_self" title="{{ $row->MovieNm }}">
                 <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>
                 </a>
                 <dl>
                 <dl>
                     <dt>
                     <dt>

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

@@ -182,7 +182,7 @@
                 <!-- 쿠팡 광고 -->
                 <!-- 쿠팡 광고 -->
                 <div class="ad-coupang">
                 <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://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>
                         쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
                         쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
                     </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>
     </form>
     <br/>
     <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">
     <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)
         @foreach($data->list as $row)
             <div class="col">
             <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('/rank', Movie\RankController::class)->names('rank');
     Route::resource('/search', Movie\SearchController::class)->names('search');
     Route::resource('/search', Movie\SearchController::class)->names('search');
     Route::resource('/trailer', Movie\TrailerController::class)->names('trailer');
     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/like', [Movie\ReviewController::class, 'like'])->name('review.like');
     Route::post('/review/dislike', [Movie\ReviewController::class, 'dislike'])->name('review.dislike');
     Route::post('/review/dislike', [Movie\ReviewController::class, 'dislike'])->name('review.dislike');