## — 나라장터 자동화 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)