# 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에 멈춘 것으로 보아 수동 실행 체계. 캐치업 후 정기 스케줄(작업 스케줄러/크론) 등록 권장.
