동작하는 코드를 진짜 동작하게 만들기

## — 나라장터 자동화 2편: SPA 대응, 실크롤링 검증, 코드 다이어트

1차 사례글 작성일: 2026-03-28 / 2차 작성일: 2026-04-11

---

## 1편 요약

3개 Word 문서 → 하루 → 7,500줄 시스템.

Flask 서버, Selenium 크롤러, SQLite DB, 웹 대시보드까지 구축했고  

갭 분석 Match Rate 96%로 마무리했다.

남은 과제로 "실제 나라장터 크롤링 테스트"를 명시했다.

그리고 얼마 뒤, 실제로 돌려봤다.

결과는 0건이었다.

---

## 나라장터가 바뀌어 있었다

나라장터(g2b.go.kr)는 조용히 WebSquare SPA로 전환돼 있었다.

SPA(Single Page Application) 구조에서는 페이지가 바뀌어도 URL이 그대로다.  

모든 화면 전환이 JavaScript 안에서 일어난다.  

기존 크롤러가 가정하던 "URL 직접 이동 → HTML 파싱" 방식이 통째로 무너졌다.

증거는 개발자 도구에서 바로 보였다.

```

기존 코드가 찾던 ID:

  bidNm (공고명 입력 필드)

  btnSearch (검색 버튼)

실제 페이지에 있는 ID:

  mf_wfm_container_tacBidPbancLst_contents_tab2_body_bidPbancNm

  mf_wfm_container_tacBidPbancLst_contents_tab2_body_btnS0004

```

WebSquare 프레임워크가 생성하는 ID 체계였다.  

그냥 셀렉터만 바꾸면 되는 게 아니었다.

---

## 진단부터 했다

"뭔가 안 된다"를 "정확히 어디서 안 된다"로 바꾸는 게 먼저였다.

7단계 진단 스크립트(`test_crawl_diagnostic.py`)를 만들어 단계별로 끊었다.

```

[STEP 1] 패키지 import        → OK

[STEP 2] WebDriver 시작        → OK  

[STEP 3] 메인 페이지 접속      → OK

[STEP 4] 입찰공고목록 메뉴 이동 → FAIL

```

STEP 4에서 멈췄다. 브라우저는 메뉴를 클릭했는데, 콘텐츠가 아직 안 떴다.

재시도하면 됐다.

원인: 타이밍. SPA는 메뉴 클릭 후 화면을 그리는 데 5~8초가 걸렸다.  

코드는 3초 기다리고 탐색을 시작했다. 2~5초 이른 것이다.

셀렉터 자체는 맞았다. 대기 시간이 문제였다.

---

## 8개 버그를 잡았다

타이밍 하나가 아니었다. 실제 그리드 구조를 분석하면서 연달아 발견됐다.

### 달력 행 문제

WebSquare 그리드에는 세 종류의 행이 섞여 있었다.

| 행 종류 | 셀 수 |

|---------|-------|

| 달력 날짜 행 | 7셀 |

| 필터/헤더 행 | 87셀 |

| 실제 데이터 행 | 16셀 |

기존 코드는 "셀이 7개 이상이면 데이터"로 판단했다.  

달력 날짜가 데이터로 파싱됐다. 결과에 26, 27, 28... 같은 숫자만 들어왔다.

수정: cells.length >= 14로 올리고, 첫 번째 셀이 숫자인 것만 실제 데이터로 인정.

### 컬럼 인덱스 오류

실제 데이터 행의 구조:

```

[0]=번호  [1]=업무구분  [2]=업무여부  [3]=공백  [4]=단계

[5]=입찰공고번호  [6]=공고명  [7]=공고기관  ...

```

코드는 [4]=공고번호, [5]=공고명으로 읽고 있었다. 1칸씩 밀렸다.

### 결과 대기 오류

데이터를 기다리는 함수가 87셀짜리 필터 헤더 행을 "데이터 있음"으로 오인하고  

실제 데이터가 오기 전에 파싱을 시작했다.

수정: JS로 "첫 셀이 숫자인 행"이 생겼을 때만 통과.

```javascript

if (/^\d+$/.test(first)) return true;

```

### 업종 필터 구조 오해

업종코드 1261/1262를 직접 입력하면 될 거라 생각했다.  

실제 G2B는 업종 선택이 팝업 다이얼로그 방식이었다.  

직접 코드 입력창이 없다.

대안: "업종전체" 체크박스를 체크해서 전 업종 수집 → 키워드 매칭과  

2차 필터로 여행업 관련 공고를 가려냄.

---

## 결과: 9건

수정 후 '해외연수' 키워드로 테스트했다.

```

[이동] OK

[수집] 9건 / 48.6초

[1] 2026 해외체험학습 프로그램 및 해외연수 | R26BK01441329-001 | 경북매일신문(주)여행사

[2] 드론 주변 야영장활성화계획연구(밀양3-9) 해외연수(한경로) | R26BK01458454-000

[3] (글로벌프랜드) K-비젼 Factory 탐방 성과 공유 연수[용] | R26BK01458046-000

...

```

공고번호 형식(`R26BK...`), 공고명, 기관, 날짜, URL. 전부 정상이었다.

---

## 코드를 다이어트했다

동작을 검증한 뒤 코드 전체를 다시 봤다.

없애도 되는 것들이 많았다.

| 항목 | 이유 |

|------|------|

| _parse_row() (~100줄) | BeautifulSoup 기반 구파서. SPA 전환 후 완전 미사용 |

| _parse_price() | _parse_row에서만 사용. 따라서 같이 삭제 |

| _find_result_table() | 동일 이유 |

| _navigate_via_menu() | 실제 네비게이션 함수에서 호출 안 됨 |

| _try_js_search() | _enter_keyword_and_search()로 통합됨 |

| _click_industry_search_btn() | 팝업 방식으로 바꾸면서 불필요 |

약 200줄이 사라졌다.

중복도 제거했다.

_select_option_by_text()와 _select_option_by_id()는 거의 같은 코드였다.  

Select 요소를 찾는 방법만 달랐다.

공통 로직을 _do_select()로 분리하고, 두 메서드는 각각 탐색 후  

이 헬퍼를 호출하도록 정리했다.

data_processor.py에서는 더 명확했다.  

6개 메서드에서 DB 연결 코드가 반복됐다.

```python

# 기존: 6번 반복

conn = sqlite3.connect(self.db_path)

conn.row_factory = sqlite3.Row

cursor = conn.cursor()

# ...

conn.commit()

conn.close()

```

context manager 하나로 정리했다.

```python

@contextmanager

def _db(self):

    conn = sqlite3.connect(self.db_path)

    conn.row_factory = sqlite3.Row

    try:

        yield conn

        conn.commit()

    except Exception:

        conn.rollback()

        raise

    finally:

        conn.close()

```

에러 발생 시 롤백까지 추가됐다. 이전 코드에는 없던 안전장치다.

버그도 두 개 발견했다.

_parse_spa_row()에서 수집일시를 항상 빈 문자열로 반환하고 있었다.  

DB에 수집 시각이 한 번도 기록된 적이 없었다는 뜻이다.

단계 컬럼이 세부절차와 같은 인덱스`[10]`를 참조하고 있었다.  

실제 단계는 [4]번 셀(입찰공고/재공고 구분)이었다.

동작은 됐다. 그런데 데이터가 틀렸다.

---

## 크롤러 시작이 6초 빨라졌다

매 실행마다 ChromeDriver 버전을 인터넷으로 확인했다.

```

Get LATEST chromedriver version for google-chrome  ← 이게 2번 나옴

Get LATEST chromedriver version for google-chrome

Driver found in cache

```

캐시에 있다고 하면서도 매번 버전 조회 요청을 보냈다.  

webdriver_manager v4의 동작 방식이었다.

파일 기반 캐시를 직접 구현했다.

```python

def resolvechromedriver_path() -> str:

    # 7일 이내 기록이 있고 파일도 존재하면 그냥 반환

    if cache_valid and path_exists:

        return cached_path

    # 아니면 새로 받아서 저장

    path = ChromeDriverManager().install()

    save_cache(path)

    return path

```

첫 실행: 6.3초 → 두 번째부터: 0.009초.

---

## 수치로 보면

| 항목 | 1편 (2026-03-28) | 2편 (2026-04-11) |

|------|-----------------|-----------------|

| 실제 크롤링 동작 | 미검증 | 확인 (9건/키워드) |

| g2b_crawler.py | ~1,200줄 | 1,046줄 |

| data_processor.py | ~805줄 | 740줄 |

| 버그 (수집일시 누락) | 있음 | 수정 |

| 버그 (단계 컬럼 오매핑) | 있음 | 수정 |

| WebDriver 시작 시간 | ~8초 | ~2초 (캐시) |

| DB 연결 패턴 | 6곳 중복 | context manager 1개 |

| secret_key | 하드코딩 | 환경변수 우선 |

---

## 배운 것

### "동작하는 코드"와 "제대로 동작하는 코드"는 다르다

1편 기준으로 시스템은 "동작했다". 서버가 떴고, 더미 데이터는 잘 보였다.  

실제 사이트에 붙여보니 0건이었다.

테스트 데이터로만 검증한 코드는 절반짜리 검증이다.

### 진단 스크립트가 디버깅 시간을 줄인다

"왜 안 되지?"를 반복하는 대신  

"어느 단계에서 처음 실패하는지"를 찾는 스크립트를 먼저 만들었다.  

단계 4에서 멈추는 걸 보자마자 원인 범위가 좁혀졌다.

### 코드는 처음 만들 때보다 두 번째 볼 때 더 잘 보인다

초기 구현은 빠르게 돌아가게 만드는 데 집중한다.  

동작이 검증된 뒤에 다시 보면 — 미사용 코드, 중복, 잘못된 값 — 이 보인다.  

이번 세션에서 제거한 ~220줄은 기능과 무관한 코드였다.

---

## 지금 시스템 상태

- 실제 나라장터에서 데이터 수집 동작 확인

- Flask 웹 대시보드 정상 동작

- XLSX / CSV / JSON 내보내기

- 공고번호 복사 (SPA 딥링크 대안)

남은 것:

- 이메일 알림 (Gmail 앱 비밀번호 입력만 하면 됨)

- Windows 작업 스케줄러 등록

---

이 글은 실제 운영 중인 시스템의 개발 과정을 기록한 것입니다.  

코드: https://github.com/shinhyun0000/g2b-project (private)

1개의 답글
지피터스 뉴스레터

이번 주 AI, 딱 쓸 것만

매주 월요일 오후,
읽고 바로 써먹을 AI 소식만