채용공고 크롤링(1) - jsp연결

RYU·2025년 6월 13일

개인 프로젝트

목록 보기
7/11

이번에는 채용공고를 크롤링해야한다. 단순히 채용정보를 보여주기 위한 것이 아니라, '경호'분야에 관련된 사이트를 구축하고 있기 때문에, 일반적인 취업 포털과는 차별화된 정보를 제공할 필요를 느꼈다. 이미 잡코리아, 사람인과 같은 사이트가 더 많은 채용 정보를 가지고 있고 더 잘 되어 있지만, 내가 만들고 있는 사이트에서는 오로지 '경호 관련 업체'의 공고만 선별하여 보여주는 것이 핵심이다.

이전에 뉴스 정보를 실시간으로 제공하기 위해 파이썬으로 크롤링을 구현한 적이 있어, 이와 비슷하게 파이썬으로 먼저 크롤링을 시도해보고, 그 다음 자바로 바꾸어 구현해보겠다.


파이썬(PyCharm 사용)


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("✔ 종료")

Controller

PostControllershowList부분에 해당 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

@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;

VO


@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에 저장하여 제공하는 방식이 더 적합하다고 생각이 들었다. 이제 이에 맞춰 코드를 수정해야 한다.

0개의 댓글