나의 피와 땀을 흘려가며 만들었던 크롤러... 여러 에피소드가 많았는데 그래도 이제는 어찌어찌 잘 굴러가는 중이라 벨로그에 정리해본다.
원래는 집 와서 작업 이어서 하려고 했는데 분명 세팅 맞춰뒀건만... 안열려. 몰라몰라 안해!
GAENCHWIS/ # 프로젝트 루트 디렉토리
├── chrome_extension/ # 크롬 확장 프로그램 관련 코드
├── client/ # 프론트엔드 클라이언트 코드
└── server/ # 백엔드 서버 코드
├── crawler/ # 크롤링 서비스 메인 디렉토리
│ ├── __init__.py
│ ├── base/ # 크롤러 기본 구조 정의
│ │ ├── __init__.py
│ │ └── base_crawler.py # 크롤러 추상 클래스 (템플릿 메서드 패턴)
│ ├── crawlers/ # 사이트별 크롤러 구현
│ │ ├── __init__.py
│ │ ├── jobkorea_crawler.py # 잡코리아 크롤러 구현
│ │ └── saramin_crawler.py # 사람인 크롤러 구현
│ └── common/ # 공통 유틸리티 및 설정
│ ├── __init__.py
│ ├── config.py # 환경 설정 관리 (dataclass 기반)
│ ├── aws_client.py # AWS 서비스 연동 (싱글톤 패턴)
│ ├── enums.py # 상태 및 타입 열거형 정의
│ ├── constants.py # 상수 및 설정값 정의
│ ├── utils.py # 공통 유틸리티 함수
│ └── driver_setup.py # 웹드라이버 설정 (봇 감지 방지 로직)
├── Dockerfile # 도커 컨테이너 설정
├── requirements.txt # Python 패키지 의존성
└── main.py # 애플리케이션 진입점 (API/CLI 인터페이스)
당초에는 사람인 크롤러와 잡코리아 크롤러, 이후에 확장성을 고려하여 base_crawler.py를 만들고, 크롤러 사이트를 추가할 때는 이를 상속받아서 사용할 수 있게 하려고 했다.
그런데? 어쩌다보니... 잡코리아 크롤러를 못 쓰게 됨 ㅋㅎ 크롤링 함부로 하면 안된다는 사실을 이번에 많이 배워갑니다... 그래서 결국 잡코리아는 코드는 있지만 현재 사용은 못하는 중이다. 사람인만 크롤링 하게 되었어요... 머쓱...
classDiagram
class BaseCrawler {
<<abstract>>
-output_dir: str
-driver: WebDriver
-logger: Logger
+initialize()
+cleanup()
+natural_scroll()
+wait_random()
+wait_for_element()
+safe_page_navigation()
#crawl()*
}
class SaraminCrawler {
-url: str
-dynamodb: DynamoDB
+crawl()
-_setup_logger()
-_setup_aws_resources()
-_initialize_repositories()
-_generate_hash()
-process_and_save_data()
-_save_company_info()
-_process_tags()
-_save_job_posting()
-_save_job_tags()
-crawl_jobs()
}
class CrawlerExecutor {
-config: Config
-logger: Logger
+execute_crawler()
-_setup_logger()
}
class APIServer {
-app: Flask
-config: Config
-crawler_executor: CrawlerExecutor
+run_crawler()
+healthcheck()
+get_status()
+run()
}
BaseCrawler <|-- SaraminCrawler
CrawlerExecutor --> SaraminCrawler
APIServer --> CrawlerExecutor
base_crawler.py
BaseCrawler
는 모든 크롤러의 기본 기능을 제공한다.
추상 클래스로 구현했기 때문에 다른 사이트 크롤러를 추가하기 쉽게 만들었다.
자원 관리나 초기화 로직을 포함하고 있고, 봇 감지를 방지하기 위한 기본 메서드들을 제공하는 곳이다.
saramin_crawler.py
SaraminCrawler에서의 데이터 처리 파이프라인
🖥️ 수집 → 파싱 → 정규화 → 저장
중요한 함수들만 몇 가지 설명하려고 한다.
crawl()
함수 def crawl(self):
# 크롤링 메인 프로세스
try:
if not os.path.exists(self.output_dir):
os.mkdir(self.output_dir)
print(f"출력 디렉토리 생성: {self.output_dir}")
self.driver.get(self.url)
print("URL 접속 시도:", self.url)
# 초기 페이지 로딩 대기
if not self.wait_for_element(By.CSS_SELECTOR, "div.box_item"):
raise TimeoutException("초기 페이지 로딩 실패")
print("페이지 로딩 완료")
# 채용공고 크롤링
saramin_data = self.crawl_jobs()
print(f"크롤링된 데이터 수: {len(saramin_data)}")
if saramin_data:
print("데이터 처리 시작")
try:
# CSV 파일 저장
output_file = os.path.join(self.output_dir, 'saramin_job.csv')
save_to_csv(saramin_data, output_file)
print(f"CSV 파일 저장 완료: {output_file}")
# DynamoDB에 데이터 저장
print("DynamoDB 저장 시작")
self.process_and_save_data(saramin_data)
print("DynamoDB 저장 완료")
except PermissionError as e:
print(f"파일 저장 권한 오류: {str(e)}")
print("DynamoDB 저장 시작")
self.process_and_save_data(saramin_data)
print("DynamoDB 저장 완료")
except Exception as e:
print(f"데이터 처리 중 오류 발생: {str(e)}")
raise
크롤링 프로세스의 메인 진입점이다. 출력 디렉토리를 만들고, URL에 접속하고 초기 페이지 로딩 후 크롤링 데이터를 CSV로 저장한다. 이건... DynamoDB 저장하기 전에 데이터를 잘 긁어오는지 확인하려고 만들었던 거라... 지워야 하는데... 심지어 주석도 어디서 에러나는지 몰라서 열심히 그리고 끔찍하게 달아둔 모습니다. 이거 언제 다 정리하냐... 암튼 DynamoDB에 저장하는 프로세스도 여기에서 실행한다.
crawl_jobs()
def crawl_jobs(self) -> List[Dict]:
saramin_list = []
max_pages = 2
for page in range(1, max_pages + 1):
try:
if page > 1:
try:
# 페이지네이션 영역 찾기
pagination = self.wait_for_element(By.CLASS_NAME, "PageBox")
if pagination:
# 다음 페이지 버튼 찾기 (page 속성값으로 찾기)
next_page_button = self.driver.find_element(
By.CSS_SELECTOR,
f"button.BtnType.SizeS[page='{page}']"
)
# 버튼이 보이도록 스크롤
self.driver.execute_script(
"arguments[0].scrollIntoView({behavior: 'smooth', block: 'center'});",
next_page_button
)
self.wait_random(1, 2) # 스크롤 후 자연스러운 대기
# 클릭 전 약간의 지연
self.wait_random(0.5, 1)
# 버튼 클릭
next_page_button.click()
# 페이지 로딩 대기
self.wait_random(2, 3)
# 새 페이지의 컨텐츠가 로드될 때까지 대기
if not self.wait_for_element(By.CLASS_NAME, "box_item"):
self.logger.warning(f"페이지 {page}의 컨텐츠를 찾을 수 없습니다.")
continue
else:
self.logger.warning(f"페이지 {page}의 페이지네이션을 찾을 수 없습니다.")
continue
except Exception as e:
self.logger.error(f"페이지 {page} 이동 중 오류 발생: {str(e)}")
continue
# 채용공고 항목 대기
if not self.wait_for_element(By.CLASS_NAME, "box_item"):
self.logger.warning(f"페이지 {page}에서 채용공고 항목을 찾을 수 없음")
continue
# 자연스러운 스크롤 동작
self.natural_scroll()
# HTML 파싱
soup = BeautifulSoup(self.driver.page_source, 'html.parser')
job_items = soup.select('.box_item')
if not job_items:
print(f"페이지 {page}: 공고 항목을 찾을 수 없습니다. 다음 페이지로 진행합니다.")
continue
self.logger.info(f"페이지 {page} - 발견된 채용공고 수: {len(job_items)}")
for item in job_items:
parsed_item = self._parse_job_item(item)
if parsed_item:
saramin_list.append(parsed_item)
self.logger.info(f"수집된 공고: {parsed_item['회사명']} - {parsed_item['공고제목']}")
self.wait_random(2, 3) # 자연스러운 딜레이
self.wait_random(15, 30) # 페이지 이동 전 딜레이
except TimeoutException:
self.logger.error(f"페이지 {page} 처리 중 시간 초과")
continue
except Exception as e:
self.logger.error(f"페이지 {page} 처리 중 오류 발생: {str(e)}")
continue
self.logger.info(f"총 수집된 공고 수: {len(saramin_list)}")
return saramin_list
실제 공고 데이터를 수집하는 기능은 이 함수가 담당하고 있다. 페이지네이션 처리나 자연스러운 스크롤 동작을 구현하고 있고, 여기에서 BeautifulSoup을 통해서 HTML을 파싱한다.
_parse_job_item()
개별 채용 공고 항목을 파싱하는 역할을 담당하는 중요한 함수이다.
{
'회사명': company,
'공고제목': title,
'직무분야': sector,
'근무지': location,
'경력/고용형태': career,
'학력': education,
'마감일': deadline,
'등록일': posted,
'공고URL': job_url
}
이런 내용들을 파싱하는데,
디테일한 작업이 필요한 경우에는 따로 보조 함수를 분리해서 기능을 분리할 수 있도록 했다.
process_and_save_data()
def process_and_save_data(self, saramin_data: List[Dict]) -> None:
print(f"처리할 데이터 개수: {len(saramin_data)}")
# 크롤링한 데이터 처리 및 저장
for job_data in saramin_data:
try:
print(f"현재 처리중인 데이터: {job_data}")
# 고유 ID 생성
company_id = self._generate_hash(job_data['회사명'])
post_id = self._generate_hash(job_data['공고URL'])
print(f"생성된 ID - company_id: {company_id}, post_id: {post_id}")
# 회사 정보 저장
try:
self._save_company_info(company_id, job_data)
except Exception as e:
print(f"회사 정보 저장 실패: {str(e)}")
raise
# 태그 처리 및 저장
try:
tags = self._process_tags(job_data)
except Exception as e:
print(f"태그 처리 실패: {str(e)}")
raise
# 채용 공고 저장
try:
self._save_job_posting(company_id, post_id, job_data)
except Exception as e:
print(f"채용공고 저장 실패: {str(e)}")
raise
# 공고-태그 매핑 저장
try:
self._save_job_tags(post_id, tags)
except Exception as e:
print(f"태그매핑 저장 실패: {str(e)}")
raise
self.logger.info(f"데이터 처리 완료: {job_data['회사명']} - {job_data['공고제목']}")
except Exception as e:
self.logger.error(f"데이터 처리 중 오류 ({job_data['회사명']}): {str(e)}")
print(f"전체 처리 실패: {str(e)}")
continue
수집된 데이터를 정규화하고 저장하는 기능이다.
고유의 ID를 만들고, 회사 정보를 정규화하고, 태그 데이터들을 처리한다. 처리할 게 굉장히 많았다. 채용 공고 정보도 정규화 한 다음 태그와 공고 사이의 매핑도 생성한다.
_process_tags()
각종 태그들을 처리하는 함수이다.
- 직무분야 (skill)
- 경력/고용형태 (position)
- 학력 (education)
- 지역 (location)
지역 같은 경우 서울이나 인천, 경기 지역 전체 카테고리에 들어갈 경우 level1, 구나 시 단위로 들어갈 경우 level2를 부여하여 지역 필터링을 용이하게 할 수 있도록 구성했다. 아... 좀 더 고칠 거 있는데 머였는지 까먹음. 해도해도 계속 뭐가 생겨요...
# _save_company_info() 함수
company_data = {
'PK': f"COMPANY#{company_id}",
'SK': f"METADATA#{company_id}",
'company_id': company_id,
'company_name': job_data['회사명'],
'created_at': datetime.now().isoformat(),
'updated_at': datetime.now().isoformat(),
'GSI1PK': "COMPANY#ALL",
'GSI1SK': job_data['회사명']
}
# _save_job_posting() 함수
job_data_processed = {
'PK': f"COMPANY#{company_id}",
'SK': f"JOB#{post_id}",
'post_id': post_id,
'post_name': job_data['공고제목'],
'company_id': company_id,
'company_name': job_data['회사명'],
'is_closed': deadline_str,
'post_url': job_data['공고URL'],
'rec_idx': rec_idx,
'status': 'active',
...
}
# _save_job_tags() 함수
mapping_data = {
'PK': f"JOB#{post_id}",
'SK': f"TAG#{tag_id}",
'job_tag_id': job_tag_id,
'job_id': post_id,
'tag_id': tag_id,
'created_at': datetime.now().isoformat(),
'GSI1PK': f"TAG#{tag_id}",
'GSI1SK': f"JOB#{post_id}"
}
이런 식으로 데이터를 DynamoDB에 저장할 수 있게 하는 함수들이다.
주요 컴포넌트만 설명을 좀 달겠음.
config.py
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class Config:
# API 설정
API_KEY: str = os.getenv('API_KEY', 'default_api_key')
# 서버 설정
HOST: str = os.getenv('HOST', '0.0.0.0')
PORT: int = int(os.getenv('PORT', '8000'))
DEBUG: bool = os.getenv('FLASK_ENV') == 'development'
# AWS 설정
AWS_REGION: str = os.getenv('AWS_REGION', 'ap-northeast-2')
AWS_ACCESS_KEY_ID: Optional[str] = os.getenv('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY: Optional[str] = os.getenv('AWS_SECRET_ACCESS_KEY')
# 크롤러 설정
CHROME_DRIVER_PATH: str = os.getenv('CHROME_DRIVER_PATH', '/usr/local/bin/chromedriver')
HEADLESS: bool = os.getenv('HEADLESS', 'true').lower() == 'true'
# 로깅 설정
LOG_LEVEL: str = os.getenv('LOG_LEVEL', 'INFO')
LOG_FILE: str = os.getenv('LOG_FILE', 'crawler_executor.log')
def __post_init__(self):
"""설정 유효성 검증"""
# AWS 인증 정보가 없을 경우 경고
if not self.AWS_ACCESS_KEY_ID or not self.AWS_SECRET_ACCESS_KEY:
print("Warning: AWS credentials not found in environment variables")
# 환경변수로 AWS 설정
if self.AWS_ACCESS_KEY_ID and self.AWS_SECRET_ACCESS_KEY:
os.environ['AWS_ACCESS_KEY_ID'] = self.AWS_ACCESS_KEY_ID
os.environ['AWS_SECRET_ACCESS_KEY'] = self.AWS_SECRET_ACCESS_KEY
os.environ['AWS_REGION'] = self.AWS_REGION
환경 변수를 기반으로 설정을 관리하고 있으며, AWS 인증 정보와 크롤러 설정을 관리한다.
aws_client.py
import boto3
from typing import Dict, Any
from .config import Config
class AWSClient:
_instances: Dict[str, Any] = {}
@classmethod
def get_client(cls, service_name: str):
if service_name not in cls._instances:
try:
config = Config()
if service_name == 'dynamodb':
cls._instances[service_name] = boto3.resource(
service_name,
aws_access_key_id=config.AWS_ACCESS_KEY_ID,
aws_secret_access_key=config.AWS_SECRET_ACCESS_KEY,
region_name=config.AWS_REGION
)
else:
cls._instances[service_name] = boto3.client(
service_name,
aws_access_key_id=config.AWS_ACCESS_KEY_ID,
aws_secret_access_key=config.AWS_SECRET_ACCESS_KEY,
region_name=config.AWS_REGION
)
except Exception as e:
raise Exception(f"AWS 클라이언트 생성 실패: {str(e)}")
return cls._instances[service_name]
AWS 서비스를 연동하는 부분이다. DynamoDB 리소스나 일반 클라이언트를 생성한다. 원래는... aws_service에 관련된 폴더를 모듈화해서 다른 백엔드 서버에서 이용할 수 있게 하려고 했는데(?) 마이크로서비스에서는(?) 안된다고 함... 그래서 이렇게 나눈거였다. 그냥 DynamoDB 리소스에만 접근할 수 있게 수정할 예정이다. 언젠간... 암튼 여기에서 aws 연동을 관리한다. 한 번 만들어뒨 아주 요긴하게 사용하고 있는 코드이다.
driver_setup.py
def get_chrome_version():
try:
# Chrome 버전 확인 (Docker 환경에서만 동작)
if os.environ.get('DOCKER_CONTAINER'):
version = os.popen('google-chrome --version').read()
version = version.strip('Google Chrome ').strip().split('.')[0]
return version
return None
except Exception as e:
print(f"Chrome 버전 확인 실패: {str(e)}")
return None
def get_chromedriver_url(version):
try:
# 특정 버전의 ChromeDriver URL 가져오기
response = requests.get(f'https://chromedriver.storage.googleapis.com/LATEST_RELEASE_{version}')
if response.status_code == 200:
driver_version = response.text.strip()
# ChromeDriver 다운로드 URL 반환
return f"https://chromedriver.storage.googleapis.com/{driver_version}/chromedriver_linux64.zip"
except Exception as e:
print(f"ChromeDriver URL 가져오기 실패: {str(e)}")
return None
# 웹 드라이버 설정
def setup_driver():
# Chrome 옵션 객체 생성
options = webdriver.ChromeOptions()
# Docker 환경 감지
in_docker = os.environ.get('DOCKER_CONTAINER', False)
# [봇 감지 방지를 위한 핵심 설정]
# 0. 기본 옵션
options.add_argument('--disable-infobars') # 자동화된 테스트 소프트웨어에 의해 제어되고 있다는 알림 배너 비활성화
options.add_argument('--disable-extensions')
options.add_argument('--disable-features=NetworkService')
# 1. 랜덤 User-Agent 설정
options.add_argument(f'user-agent={random.choice(CrawlerConfig.USER_AGENTS)}') # 랜덤 User-Agent로 봇 감지 방지
# 2. 자동화 흔적 제거
options.add_argument('--disable-blink-features=AutomationControlled') # 자동화 감지 플래그 제거
options.add_experimental_option('excludeSwitches', ['enable-logging']) # 자동화 관련 로그 숨김
options.add_experimental_option('useAutomationExtension', False)
# 3. 웹 드라이버 관련 플래그 제거
options.add_argument('--disable-web-security') # 웹 보안 정책 우회ㅑ
options.add_argument('--ignore-certificate-errors') # 인증서 오류 무시
options.add_argument('--ignore-ssl-errors') # SSL 오류 무시
# 4. 실제 브라우저처럼 보이게 하는 설정
options.add_argument('--start-maximized') # 실제 사용자처럼 전체 화면으로 사용
options.add_argument('--lang=ko_KR') # 한국어 설정으로 현지화
# 5. 필수 성능 옵션
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
# Docker 환경 전용 설정
if in_docker:
options.add_argument('--headless')
options.binary_location = '/usr/bin/google-chrome'
# 6. 추가 봇 감지 방지 설정
prefs = {
'profile.default_content_setting_values.notifications': 2,
'credentials_enable_service': False,
'profile.password_manager_enabled': False
}
options.add_experimental_option('prefs', prefs)
# 페이지 로드 전략 변경 (normal: 모든 리소스 로드 대기)
options.page_load_strategy = 'normal'
try:
if in_docker:
# Docker 환경에서는 설치된 Chrome 버전 확인
chrome_version = get_chrome_version()
print(f"Detected Chrome vserion: {chrome_version}")
if not chrome_version:
raise Exception("Chrome 버전을 확인할 수 없습니다.")
service = Service()
else:
# 로컬 환경에서는 자동으로 ChromeDriverManager 설치 및 관리
service = Service(ChromeDriverManager().install())
# WebDriver 인스턴스 생성
driver = webdriver.Chrome(
service=service,
options=options
)
# 7. WebDriver 속성 숨기기
driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
driver.implicitly_wait(30) # 요소 대기 시간 설정
return driver
except Exception as e:
print(f"ChromeDriver 초기화 실패: {str(e)}")
raise
이 친구 만드는데 제일 오래 걸렸다.
주요 기능
1. 크롬 버전 확인
2. 크롬 드라이버 URL 획득
3. 웹 드라이버 설정
봇인거 들키면 가차없이 차단당하는 사이트가 있다. (알고싶지 않았어요) 차단 몇 번 당해보고 나서야 이런저런 설정이 필요하다는 걸 알아서 피눈물 흘리며 설정들을 찾아 헤맸다. 잘 알지도 못하고 여기저기서 설정을 긁어오는 바람에 뭐가 봇 감지에 도움이 되고 되지 않는지 몰랐기 때문에 AI 한 번 돌려서 설정을 정리했다. 이런 설정들이 필요하다고 하네요...
나머지는 설정에 관련된 파일이다.
확장성을 고려한 설계를 하려고 노력했던 결과물이다. 잘 된건지... 하지만 결국 사람인 크롤러 하나만 사용하게 된 아이러니... 하지만 나중엔 다른 사이트를 더 붙여서 사용할 수 있겠죠?
만드는 데 정말 오래걸리고, 지금도 DB 관련해서 스키마 바뀌면 수정해야 하고, 여전히 수정하고 개선할 점들이 많은 애물단지같은 서비스지만 돌아가는 거 보면 밥 안먹어도 될만큼 뿌듯하다.
나는 갱스터 크롤러... 프로젝트라서 예의없게 여기저기 크롤링을 막 하고 다녔는데 실제로 상업적인 서비스에서는 이러면 안된다. 알고 있습니다...! 일단 우리 서비스에는 필요한 데이터들이기 때문에 열심히 긁긴 했다. 후... 나머지도 잘 마무리하고 프로젝트가 어서 끝났으면 좋겠다. 건강이 안좋아지는 것이 느껴져...!
본 포스팅은 글로벌소프트웨어캠퍼스와 교보DTS가 함께 진행하는 챌린지입니다.