개발지식 먹는 하마 님의 블로그

[내일배움캠프 70일차] Java 기반 웹 크롤링 본문

내일배움캠프 (CS25)

[내일배움캠프 70일차] Java 기반 웹 크롤링

devhippo 2025. 5. 29. 12:31

웹 크롤링 (Web Crawling) 이란

정보의 바다 인터넷에 퍼진 수많은 정보들, 웹 문서를 웹 크롤러가 규칙에 따라 탐색 및 수집하는 것이다.

크롤러는 인터넷의 여러 웹 사이트에 접속하여,
해당 페이지의 내용과 링크의 복사본을 생성 및 다운로드해 요약본을 만든다.

+ 검색 시, 유용한 정보만을 노출하도록 검색 색인을 추가한다.

현재 구현하고자 하는 프로젝트는 사용자가 풀 수 있는 문제에 대한 데이터가 필요했다.
이 문제를 문제은행의 데이터를 크롤링해 오고자 하였다.


Java 기반 크롤링 라이브러리 JSoup

크롤링을 할 수 있는 라이브러리에는 여러 가지가 있으나 
현재 Java 기반의 프로젝트를 진행 중이니 Java 기반의 크롤링 라이브러리를 사용하면 좋을 것 같았다.

Java 기반 웹 크롤링 관련 라이브러리에는 Jsoup이 있다.

https://jsoup.org/cookbook/

 

Cookbook: jsoup Java HTML parser

 

jsoup.org

Jsoup는 HTML5 DOM 메서드와 CSS 선택자를 활용하여 URL을 가져오고 데이터를 추출 및 조작할 수 있는 기능을 제공한다.


Jsoup 원리

1) Parser로 HTML 문서를 Document로 파싱 하기

데이터를 크롤링하기 전에 먼저 해당 HTML형식이 올바른지 여부와 관계없이 이를 깔끔하게 정리한다.
이때 생성되는 객체가 Document이다.
HTML 전체 내용이 담겼다고 생각하면 된다.

Document doc = Jsoup.parse(html);

 


connect 메서드는 url 주소를 기반으로  새 Connection을 생성하고 get 메서드로 parse처럼 HTML 파일을 가져와서 파싱 한다.
* 오류 발생 시, IOException이 발생한다.

Document doc = Jsoup.connect("http://example.com/").get();

 

. html 파일을 기반으로 Document를 생성하고자 한다면 아래와 같이 사용할 수 있다.

File input = new File("/tmp/input.html");
Document doc = Jsoup.parse(input, "UTF-8", "http://example.com/");

 

메모리에 담기에는 큰 용량의 데이터는 StreamParser를 사용해야 한다.
StreamParser는 이벤트 기반 DOM + SAX 스타일로 문서를 파싱 할 수 있는 메서드이다.

https://jsoup.org/cookbook/input/streamparser-dom-sax

 

StreamParser: A hybrid Java SAX + DOM parser for large documents: jsoup Java HTML parser

Parse large documents efficiently with StreamParser Problem You need to parse an HTML or XML document that is too large to fit entirely into memory, or you want to process elements progressively as they are encountered. A typical use case is extracting spe

jsoup.org

 

 

2) Element 객체 뽑아오기

Document가 HTML의 전체 내용이라면,
Element는 HTML을 구성하고 있는 컨테이너라고 생각하면 좋다.

Document에서 데이터를 가져오는 방법은 매우 많다.
위에서 첨부했던 공식 문서의 Cookbook을 참고하길 바란다.

Element body = doc.body(); //body 영역 
Element content = doc.getElementById("content"); //Id 기반
Elements links = content.getElementsByTag("a");  //Tag 기반

 

Element를 복수형으로 표현하면 여러 개의 Element를 가져오고
CSS 선택자로 요소를 가져올 수도 있다.

Elements links = doc.select("a[href]"); // a with href
Elements pngs = doc.select("img[src$=.png]");

 

3) Element에서 필요한 데이터 추출하기

전체 내용의 일부 구역을 뽑아온 Element에서 필요한 값을 추출한다.

element.attr("href") //속성 값 추출
element.text() //내부 텍스트 추출
element.html() //HTML 문자열 추출

 


그럼 필요한 데이터를 뽑아 볼까요?

현재 문제와 관련된 ERD의 내용에 따라 추출해야 하는 내용은 다음과 같다.

  • 질문
  • 객관식 보기 (정답 포함)
  • 정답
  • 해설

 


 

회차별 문제 링크 추출하기

정보처리기사 회차별 문제 링크 HTML 구성

		//문제은행 도메인 주소
		final String domain = "https://cbtbank.kr";
		//문제은행에서 정보처리기사 문제 목록이 있는 페이지
		final String listCbtUrl = domain + "/category/정보처리기사";
        
		Document listPage = Jsoup.connect(listCbtUrl).get();
			Elements links = listPage.select("a[href^=/exam/]");
			List<String> examUrls = new ArrayList<>();
			for (Element link : links) {
				String relativeUrl = link.attr("href");
				String fullUrl = domain + relativeUrl;
				examUrls.add(fullUrl);
			}

정보처리기사 회차별 문제 링크 HTML 구성을 바탕으로 <a herf = "/exam/">에 속하는 요소들을 Element 객체로 가져와
내부 속성인 url만 추출하여 아래와 같은 링크 주소들을 리스트에 저장하였다.

 


 

문제 가져오기

	public static ParsedQuiz parseQuiz(Element box) {
		ParsedQuiz pq = new ParsedQuiz();

		String rawTitle = box.selectFirst("p.exam-title").text();
		pq.question = rawTitle.replaceFirst("^\\d+\\.", "").trim();

		Elements liItems = box.select("ol.circlednumbers > li");
		for (int i = 0; i < liItems.size(); i++) {
			String choiceText = liItems.get(i).text();
			pq.choices += choiceText;
			if (liItems.get(i).hasClass("correct")) {
				pq.answer = (i + 1) + "." + choiceText;
			}
		}

		Elements replies = box.select("li.reply-item");
		for (Element reply : replies) {
			if (reply.attr("data-info").contains("depth:0")) {
				Element comment = reply.selectFirst("div.reply-comment");
				if (comment != null) {
					pq.commentary = comment.text();
					break;
				}
			}
		}

		return pq;
	}

위와 같은 원리로 HTML 문을 분석하고 필요한 데이터를 추출하였다.
출력을 통해 데이터를 확인하면 아래와 같은 결과가 나온다.

 


크롤링한 데이터를 필요한 형태로 Json 파일 만들기

사실 크롤러를 프로젝트 내에 구현하여 크롤링한 데이터를 동일한 프로젝트 내에서 바로 적용할 수도 있었을 것이다.
그러나 나는 이를 별도의 프로젝트에서 구현하였다.
그 이유는 크롤링한 문제의 중복 제거에 있다.

중복된 문제 제거 방법 (feat. 고마워요 G선생)

프로젝트 설계 단계에서 우리 팀은 크롤링한 문제의 중복을 제거하는 로직을 어떻게 짜면 좋을까 하는 고민을 하였고,
이에 대해 튜터님께 조언을 구하였다.

중복 제거는 AI가 잘 제거해 줍니다.

그 결과, 크롤링은 자동으로 하고 중복은 AI가 한 후 중복이 제거된 파일을 DB에 등록하는 일종의 반자동 시스템이 된 것이다.


크롤링한 데이터를 Json 파일에 저장

Json 파일로 저장하기 위한 라이브러리를 찾아보니 gson과 Jackson이 있었다.
gson과 jackson은 Json 문자열과 java 객체 간의 변환을 쉽게 해 주어 Json 데이터 처리에 사용되는 대표적인 라이브러리이다.
gson은 Google이 개발하였고 jackson은 FasterXML이 개발하였다.

gson Vs Jackson

gson은 google이 개발한 라이브러리답게 쉽고 간단하다는 것이 주요 장점이다.

  • 장점
    • 쉽게 사용할 수 있다.
    • 코드가 간결하다.
    • 구성이 가볍다

그러나 Jackson에 비해 성능이 다소 떨어지고, 기능이 제한적이라는 단점이 있었다.

Jackson의 장점은 성능이 더 뛰어나고 Spring과 호환이 잘 된다는 것이 주요 장점이다.

  • 장점
    • 빠른 처리 속도와 높은 성능
    • 다양한 데이터 처리 방식
    • Spring과의 높은 호환성

그러나 초기 학습 곡선이 다소 높고 설정이 복잡할 수 있다는 단점이 있었다.

https://jframework.tistory.com/34

 

Jackson / GSON 라이브러리 차이점

jackson과 Gson은 Java에서 JSON 데이터를 처리하기 위해 널리 사용되는 두 가지 라이브러리입니다. 둘 다 JSON 문자열과 Java 객체 간의 변환을 쉽게 해주며, 각각의 라이브러리는 고유한 특징과 장단점

jframework.tistory.com

 

두 라이브러리의 장단점을 본 후, 현재 크롤러 프로젝트에서는 크게 중요하게 고려할 만한 부분은 없었지만
문제의 개수가 매우 많을 수 있다는 점과 추후 혹시 모를 Spring 사용을 고려할 때,
호환성이 높고 대용량 데이터 처리에 더 적합한 Jackson 라이브러리를 사용하기로 하였다.

 

Json 파일로 저장

어떤 라이브러리를 사용할지 고민한 것에 비해 실제 크롤러에서 Json 파일로 저장하는 코드는 굉장히 간단하다.

ObjectMapper mapper = new ObjectMapper();
			mapper.writerWithDefaultPrettyPrinter().writeValue(new File("quizzes.json"), quizzes);

 

그 결과 Json에 저장된 파일은 다음과 같다.

 

이렇게 Json 파일로 뽑아내어 GPT를 통해 중복을 제거한 문제를 어떻게 DB에 저장했는지는 다음 글에서 다루도록 하겠다!