이번에는 채용공고를 크롤링해야한다. 단순히 채용정보를 보여주기 위한 것이 아니라, '경호'분야에 관련된 사이트를 구축하고 있기 때문에, 일반적인 취업 포털과는 차별화된 정보를 제공할 필요를 느꼈다. 이미 잡코리아, 사람인과 같은 사이트가 더 많은 채용 정보를 가지고 있고 더 잘 되어 있지만, 내가 만들고 있는 사이트에서는 오로지 '경호 관련 업체'의 공고만 선별하여 보여주는 것이 핵심이다.
이전에 뉴스 정보를 실시간으로 제공하기 위해 파이썬으로 크롤링을 구현한 적이 있어, 이와 비슷하게 파이썬으로 먼저 크롤링을 시도해보고, 그 다음 자바로 바꾸어 구현해보겠다.
headers = {
"User-Agent": "Mozilla/5.0",
"Referer": "https://www.jobkorea.co.kr/recruit/joblist?menucode=duty",
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
"Accept-Encoding": "gzip, deflate, br"
}
dutyCtgr = "10039"
duty = "1000317"
payload = {
"condition": {
"dutyCtgr": 0,
"duty": duty,
"dutyArr": [duty],
"dutyCtgrSelect": [dutyCtgr],
"dutySelect": [duty],
"isAllDutySearch": False
},
"TotalCount": 455,
"Page": 1,
"PageSize": 455
}
url = "https://www.jobkorea.co.kr/Recruit/Home/_GI_List/"
session = requests.Session()
session.get("https://www.jobkorea.co.kr/")
session.headers.update(headers)
response = session.post(url, json=payload)
data_list = []
if response.status_code == 200:
soup = BeautifulSoup(response.text, "lxml")
jobs = soup.select(".devTplTabBx table .tplTit > .titBx")
for job in jobs:
a_tag = job.select_one("a")
href = a_tag["href"] if a_tag else None
if href:
match = re.search(r'/Recruit/GI_Read/(\d+)', href)
if match:
gno = match.group(1)
detail_url = f"https://www.jobkorea.co.kr/Recruit/GI_Read/{gno}"
try:
detail_res = session.get(detail_url, timeout=10)
time.sleep(random.uniform(1, 3))
if detail_res.status_code == 200:
detail_soup = BeautifulSoup(detail_res.text, "lxml")
# 공고제목 (따옴표 안)
hd_3 = detail_soup.select_one(".hd_3")
title_text = "없음"
if hd_3:
full_text = hd_3.get_text(strip=True)
match = re.search(r'“(.+?)”', full_text)
title_text = match.group(1) if match else full_text
# 회사명
company = detail_soup.select_one(".hd_3 > .header > span.coName")
company_name = company.text.strip() if company else "없음"
# 시작일 & 마감일
start_date, deadline = "", ""
dl_date = detail_soup.select_one("dl.date")
if dl_date:
for dt in dl_date.find_all("dt"):
dd = dt.find_next_sibling("dd")
span = dd.find("span") if dd else None
if span:
if "시작일" in dt.text:
start_date = span.text.strip()
elif "마감일" in dt.text:
deadline = span.text.strip()
# 우대 자격증
cert_list = []
popup_pref = detail_soup.select_one("#popupPref")
dt_elements = popup_pref.select(".tbAdd dt") if popup_pref else detail_soup.select(".artReadJobSum .tbList dt")
for dt in dt_elements:
if '자격' in dt.text:
dd = dt.find_next_sibling("dd")
if dd:
certs = dd.text.strip().rstrip(',').split(',')
cert_list = [c.strip() for c in certs if c.strip()]
break
data_list.append({
"공고제목": title_text,
"회사명": company_name,
"시작일": start_date,
"마감일": deadline,
"우대 자격증": ', '.join(cert_list) if cert_list else "없음"
})
except Exception as e:
print(f"[예외] 공고 {gno} 처리 중 오류 발생:", e)
else:
print("[오류] 리스트 페이지 요청 실패:", response.status_code)
# pandas 사용 -> 엑셀 저장
df = pd.DataFrame(data_list)
file_path = "jobkorea_requirements.xlsx"
if os.path.exists(file_path):
existing_df = pd.read_excel(file_path)
combined_df = pd.concat([existing_df, df], ignore_index=True)
else:
combined_df = df
combined_df.to_excel(file_path, index=False)
print("✔ 종료")
PostController의 showList부분에 해당 boardId를 if문으로 넣어 추가하였다.
if (boardId == 7) {
try {
List<JobPosting> jobPostings = jobKoreaService.crawlJobPostings();
System.out.println("크롤링된 공고 수: " + jobPostings.size());
model.addAttribute("jobPostings", jobPostings);
model.addAttribute("board", board);
return "/usr/post/joblist"; // JSP 파일명
} catch (Exception e) {
e.printStackTrace();
return rq.historyBackOnView("공고 데이터를 가져오는 데 실패했습니다.");
}
}
@Service
public class JobKoreaService {
public List<JobPosting> crawlJobPostings() throws Exception {
String dutyCtgr = "10039";
String duty = "1000317";
JsonObject condition = new JsonObject();
condition.addProperty("dutyCtgr", 0);
condition.addProperty("duty", duty);
condition.add("dutyArr", new Gson().toJsonTree(List.of(duty)));
condition.add("dutyCtgrSelect", new Gson().toJsonTree(List.of(dutyCtgr)));
condition.add("dutySelect", new Gson().toJsonTree(List.of(duty)));
condition.addProperty("isAllDutySearch", false);
JsonObject payload = new JsonObject();
payload.add("condition", condition);
payload.addProperty("TotalCount", 455);
payload.addProperty("Page", 1);
payload.addProperty("PageSize", 455);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://www.jobkorea.co.kr/Recruit/Home/_GI_List/"))
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
.header("Referer", "https://www.jobkorea.co.kr/recruit/joblist?menucode=duty")
.header("Origin", "https://www.jobkorea.co.kr")
.header("X-Requested-With", "XMLHttpRequest")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload.toString()))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("응답 상태코드: " + response.statusCode());
System.out.println("응답 바디 일부:\n" + response.body().substring(0, Math.min(300, response.body().length())));
Document doc = Jsoup.parse(response.body());
Elements jobs = doc.select(".devTplTabBx table .tplTit > .titBx");
System.out.println("크롤링된 공고 수: " + jobs.size());
List<JobPosting> postings = new ArrayList<>();
for (Element job : jobs) {
Element aTag = job.selectFirst("a");
if (aTag == null) continue;
String href = aTag.attr("href");
System.out.println("공고 href: " + href);
Matcher matcher = Pattern.compile("/Recruit/GI_Read/(\\d+)").matcher(href);
if (!matcher.find()) continue;
String gno = matcher.group(1);
String detailUrl = "https://www.jobkorea.co.kr/Recruit/GI_Read/" + gno;
try {
Document detailDoc = Jsoup.connect(detailUrl)
.userAgent("Mozilla/5.0")
.timeout(10000)
.get();
// 시작일 & 마감일 추출
String startDate = "", deadline = "";
Element dateBlock = detailDoc.selectFirst("dl.date");
if (dateBlock != null) {
for (Element dt : dateBlock.select("dt")) {
Element dd = dt.nextElementSibling();
if (dd == null) continue;
Element span = dd.selectFirst("span");
if (span == null) continue;
if (dt.text().contains("시작일")) startDate = span.text().trim();
if (dt.text().contains("마감일")) deadline = span.text().trim();
}
}
// 기업명 추출
String company = "";
Element companyEl = detailDoc.selectFirst(".hd_3 > .header > span.coName");
if (companyEl != null) company = companyEl.text().trim();
// 제목 추출
String title = "";
Element titleEl = detailDoc.selectFirst(".hd_3");
if (titleEl != null) title = titleEl.ownText().trim();
// 자격증 추출
List<String> certList = new ArrayList<>();
Elements dtElements = detailDoc.select("#popupPref .tbAdd dt");
if (dtElements.isEmpty()) {
dtElements = detailDoc.select(".artReadJobSum .tbList dt");
}
for (Element dt : dtElements) {
if (dt.text().contains("자격")) {
Element dd = dt.nextElementSibling();
if (dd != null) {
String certText = dd.text().trim().replaceAll(",$", "");
String[] certs = certText.split(",");
for (String cert : certs) {
certList.add(cert.trim());
}
}
break;
}
}
JobPosting post = new JobPosting();
post.setGno(gno);
post.setCompany(company);
post.setTitle(title);
post.setStartDate(startDate);
post.setDeadline(deadline);
post.setLink("https://www.jobkorea.co.kr" + href);
post.setCertificates(certList);
postings.add(post);
} catch (Exception e) {
System.err.println("상세 페이지 요청 실패: " + gno);
e.printStackTrace();
}
}
return postings;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class JobPosting {
private String gno;
private String company;
private String title;
private List<String> certificates;
private String startDate;
private String deadline;
private String link;
}
원래는 실시간으로 채용공고를 반영하여 보여주는 방식으로 구현하고자 했지만, 크롤링 도중 예상치 못한 문제가 발생했다. 비록 캡처는 하지 못했지만, "회원님께서는 현재 입력할 수 없는 문자열의 사용으로 인해 차단이 되었습니다. 문제가 지속적으로 발생할 경우 아래 고객센터로 문의하시기 바랍니다."라는 메시지를 받으며 사이트 접근이 차단된 것이다.
다행히 시간이 지나면서 차단은 해제되었지만, 자칫하면 잘못된 방향으로 갈 뻔했다. 채용공고는 매일 새로 올라오는 정보지만, 안전하게 하기 위해서는 실시간 크롤링보다는 DB에 저장하여 제공하는 방식이 더 적합하다고 생각이 들었다. 이제 이에 맞춰 코드를 수정해야 한다.