본 글은 Web Crawling -2 에 이어서 작성하는 글입니다. 전체적으로 코드 리팩토링이 발생하였습니다. 문제 풀이는 완료되었으며 풀이에 대한 과정을 작성하는 글입니다.
백엔드 요구 사항
번호 | 기능 | 세부사항 |
R01 | 네이버 IT 뉴스 일반 크롤링 하여 DB에 저장 | DB 정보 : 제목, 내용(요약), 언론사명, 업로드 시기, 이미지 |
R02 | 버튼 클릭 시 크롤링 진행 | DB 초기화 및 재생성 필요 |
R03 | Spring Boot 사용 | Design Pattern: MVC |
R04 | DB CRUD | Java Persistence API (Hibernate) |
R05 | 페이지네이션 시 DB에서 작업 | limit , offset으로 잘라서 가져오기 |
프론트엔드 요구 사항
번호 | 기능 | 세부사항 |
R01 | 초기페이지 | 버튼, 표(제목, 내용 ... 등등) , 드랍 다운 메뉴(10,20,30개 씩 보기) |
R02 | 버튼 클릭 시 크롤링 진행 | 크롤링 후 초기 페이지(1페이지)를 표시 |
R03 | 페이지네이션 | 첫 페이지 시 First , Prev 비활성화 첫 페이지 그룹에서 Prev 비활성화 First : 첫 페이지로 이동 Prev : 이전 페이지로 이동 마지막 페이지 시 Next, End 비활성화 마지막 그룹에서 Next 비활성화 End : 마지막 페이지로 이동 Next : 다음 페이지로 이동 |
R04 | 페이지네이션 조건 | server-side로 구현하기(화면에 출력할 항목만 읽어오기) 테이블 내용, 페이지네이션 요소는 JS로 그리기(테이블 동적 draw) 통신은 ajax 이용한 비동기 통신(jquery 필요) page unit(한 페이지 당 항목 수) select로 선택(ex. 10, 20, 30) |
백엔드 구현
Spring Boot + MVC
제목에서 알 수 있듯이 Spring Boot와 디자인 패턴 중 하나인 MVC 패턴을 활용하였다. MVC에 대한 내용은 이전 글에서도 언급하였지만 중요한 개념이니 한 번 더 짚고 넘어가면서 깊게 파고 들어가 보자.
필자는 프런트 컨트롤러로 Servlet의 일종인 DispatcherServlet을 사용했다.
DispatcherServlet에 대해 알아보면서 Servelt에 대해서도 같이 알아보도록 하자.
Servelt
클라이언트의 요청을 처리하고, 그 결과를 반환하는 Servlet 클래스의 구현 규칙을 지킨 자바 웹 프로그래밍 기술
Servlet 특징
- 클라이언트의 요청에 대해 동적으로 작동하는 웹 애플리케이션 컴포넌트
- html을 사용하여 요청에 응답한다.
- Java Thread를 이용하여 동작한다.
- MVC 패턴에서 Controller로 이용된다.
- HTTP 프로토콜 서비스를 지원하는 javax.servlet.http.HttpServlet 클래스를 상속받는다.
- UDP보다 처리 속도가 느리다.
- HTML 변경 시 Servlet을 재컴파일해야 하는 단점이 있다.
Servlet 동작 과정

- 사용자로부터 url을 받아 HTTP Request가 Servlet Container로 전송
- 요청을 전송받은 Servlet Container는 HttpServletRequest , HttpServletResponse 객체 생성
- web.xml을 기반으로 사용자가 요청한 url이 어느 서블릿에 대한 요청인지 찾음
- 해당 서블릿에서 service 메서드를 호출한 후 클라이언트의 GET, POST 여부에 따라 doGet(), doPost() 메소드 호출
- doGet(), doPost() 메서드는 동적 페이지를 생성한 후 HttpServletResponse 객체에 응답
- 응답 종료 후 HttpServletRequest, HttpServletResponse 소멸
그렇다면 계속 언급되는 Servlet Container는 무엇인가?
우리가 서버에 서블릿을 생성했다고 해서 스스로 동작하는 것이 아니라 서블릿을 관리해 줄 무언가가 필요하다. 해당 역할을 하는 것이 서블릿 컨테이너이다. 서블릿 컨테이너는 클라이언트의 요청을 받아주고 응답을 할 수 있도록 웹 서버와 소켓으로 통신한다. 대표적인 예로 톰캣이 있다.
Servlet Container 역할
- 웹 서버와 통신 지원
- 통신이라면 소켓, listen, accept 등 복잡한 과정을 지나야 하지만 서블릿 컨테이너가 이러한 과정을 API로 제공하여 개발자는 비즈니스 로직에만 집중 가능
- 서블릿 생명주기 관리
- 서블릿 클래스 로딩 => 인스턴스화 => 초기화 메서드 호출 => 서블릿 메소드 호출
- 생명이 다하면 가비지 컬렉션 진행
- 멀티스레드 지원 및 관리
- 서버가 다중 스레드를 생성 및 운영해 줌
- 선언적인 보안 관리
DispatcherServlet 이란?
- DispatcherServlet은 가장 먼저 요청을 받고 적절하게 처리할 함수, 컨트롤러를 찾아 정해주는 역할을 한다.
- 프런트 컨트롤러로써 스프링 MVC의 중앙 서블릿이며 애플리케이션으로 오는 모든 요청을 핸들링하고 공통 작업을 처리한다.
- 디스패처 서블릿은 정적, 동적 자원을 분할 처리한다.
- 무슨 말이냐면, 요청을 처리할 컨트롤러를 먼저 찾고(동적) 컨트롤러가 없다면 2차로 설정된 정적 자원을 탐색한다.
- 위 과정으로 효율적인 리소스 관리가 가능하다.
Servlet 배포
DispatcherServlet을 사용했다면서 아래 내용에는 DispatcherServlet에 관한 직접적인 내용이 없다는 점이 의문이 들 것이다. 이에 대한 설명은 아래 코드와 함께 하겠다.
package com.insilicogen;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
public class ServletInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(CrawlingApplication.class);
}
}
- SpringBootServletInitializer
- Spring Boot 애플리케이션을 서블릿 컨테이너에 배포
- cofigure()
- SpringApplicationBuilder를 사용하여 애플리케이션 설정
- application.sources(CrawlingApplication.class)으로 CrawlingApplication을 주 구성 클래스로 등록
위 클래스를 사용해서 WAR 파일로 패키징 된 Spring Boot 애플리케이션을 외부 웹 서블릿 컨테이너(ex.Tomcat)에 배포할 수 있다. 따라서 따로 DispatcherServlet을 등록하고 filter 처리할 번거로움이 없는 것이다. 프로젝트가 커진다면 filter 설정이 필요할 것 같지만 해당 프로젝트에서는 위 클래스 만으로 충분하다.
Servlet 등록
필자는 아래와 같이 등록하였고 WEB-INF/Spring/servlet-context.xml 경로를 가진다.
<?xml version="1.0" encoding="UTF-8"?>
<beans>
<!-- spring MVC annotation(@)을 사용하기 위한 설정 -->
<context:annotation-config />
<!-- controller로 처리할 view 위치 및 확장자명 설정 -->
<bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/views/" />
<property name="suffix" value=".jsp" />
</bean>
<!-- 공통 패키지(공통 경로) 설정 -->
<context:component-scan base-package="com.gon.crawling" />
</beans>
MVC의 View로 설정할 경로를 지정한다. 즉 컨트롤러가 url을 통해 찾을 앞단의 경로를 지정하는 것이다.
<property name="prefix" value="/WEB-INF/views/" /> => /WEB-INF/views/ 경로로 찾아가 파일을 탐색한다.
<property name="suffix" value=".jsp" /> => 확장자가 .jsp인 파일을 찾는다. (단, 컨트롤러에서 보내는 이름과 같아야 함)
<context:component-scan base-package="com.gon.crawling" /> => 공통적인 경로를 설정한다.
server.servlet.context-path=/crawl =>해당 설정으로 서버를 시작하면 url이 localhost:8080/crawl/ 로 시작
위와 같이 설정한다면 컨트롤러 단에서 리턴값을 "hello"로 주면, 서블릿이 views 폴더 내에 hello.jsp를 찾아 웹페이지에 동작시키게 된다.
NewsController (컨트롤러)
news
@GetMapping("/news")
public String news() {
return "news"; // news.jsp로 포워딩
}
웹 서버 동작 시 화면에 동작할 컨트롤러(GET 요청) : news.jsp로 포워딩
selectNewsList
@PostMapping("/news/selectNewsList")
@ResponseBody
public Page<InfoDto> selectNewsList(@RequestBody InfoDto infoDto) {
// , infoDto.getImageDto().getImageUrl()
System.out.println("현재 페이지 번호 : " + infoDto.getPageNo() + " 현재 페이지 단위 : " + infoDto.getPageUnit());
return newsService.getPagedNews(infoDto.getPageNo(),infoDto.getPageUnit());
}
페이지 이동 시 페이지 네이션 로직을 실행시킬 컨트롤러
밑에서 언급하겠지만 페이지네이션에 필요한 요소를 Body 값으로 받아와 서비스 로직에 던져준다. 특이점은 InfoDto 객체를 사용하는데, 현재 InfoDto는 엔터티로 설정되어 있다. 뉴스 정보를 담는 엔터티에 페이지 번호와 페이지 유닛은 필요하지 않다. 필요하지 않은 요소를 제외시키려면 @Transient 어노테이션을 사용하여 테이블 생성에서 제외시킬 수 있다.
initCrawling
@GetMapping("/news/initCrawling")
public ModelAndView initCrawling(Model model) {
try {
newsService.crawlAndSaveNews();
return new ModelAndView("news", model.asMap());
} catch (Exception e) {
e.printStackTrace();
return new ModelAndView("errorPage", "error", "Error initiating crawling.");
}
}
크롤링을 진행할 컨트롤러
crawlAndSaveNews() 서비스 로직을 불러오고 뷰 리졸버가 "news.jsp"를 찾도록 "news"를 리턴한다.
NewsService (서비스)
crawlAndSaveNews()
public List<InfoDto> crawlAndSaveNews() {
System.out.println("크롤링 시작!!");
List<InfoDto> newsList = new ArrayList<>();
truncate(); // 테이블 비우기
// news.naver.com/breakingnews/section/105/230?date=20240125
try {
for (int date = 0; date < 30; date++) {
Date currentDate = new Date();
currentDate = decrementDate(currentDate, date);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");
String formattedDate = simpleDateFormat.format(currentDate);
String newUrl = newsUrl.concat("?date=").concat(formattedDate);
System.out.println(newUrl);
Document doc = Jsoup.connect(newUrl).get();
List<InfoDto> dailyNewsList = new ArrayList<>(); // 하루의 뉴스를 저장할 리스트 생성
for (int i = 1; i <= 6; i++) {
String selector = "#newsct > div.section_latest > div > div.section_latest_article._CONTENT_LIST._PERSIST_META > div:nth-child(1) > ul > li:nth-child("+ i + ") > div > div > div.sa_text";
String imgSelctor = "#newsct > div.section_latest > div > div.section_latest_article._CONTENT_LIST._PERSIST_META >"+ " div:nth-child(1) > ul > li:nth-child(" + i+ ") > div > div > div.sa_thumb._LAZY_LOADING_ERROR_HIDE > div > a";
Elements elements = doc.select(selector);
Elements imgElemnets = doc.select(imgSelctor);
dailyNewsList.addAll(crawlingNewsInInfo(elements, imgElemnets));
}
newsList.addAll(dailyNewsList);
System.out.println("크롤링 완료");
}
} catch (IOException e) {
e.printStackTrace();
}
newsRepository.saveAll(newsList);
return newsList;
}
크롤링을 진행하기 전 기초 토대를 마련하는 메서드이다. 필자는 30일치의 기사를 크롤링하는데 6개씩 30일치 총 180개의 뉴스 기사를 크롤링 한다.
truncate()는 왜 있는가?
@Transactional
@Modifying
@Query(value = "truncate info_dto",nativeQuery = true)
void truncateInfoDtoTable();
NewsRepository 인터페이스내 정의된 내용이다. Repository에 대한 내용은 추후에 자세하게 다루도록 할 것이고 지금은 truncate에 대한 내용에 대해서만 다루도록 하겠다.
@Transactional 어노테이션으로 메소드 내 일어나는 작업은 트랜잭션으로 묶음을 정의한다.
@Modifying 어노테이션은 헤당 쿼리가 데이터베이스에 변경을 가할 것을 나타낸다.
@Query 어노테이션으로 sql구문을 정의하는데 이때, truncate 쿼리를 통해 테이블을 비우는 작업을 진행한다. 테이블을 비우는 것이지 삭제하는 것이 아니다.
크롤링 시 데이터베이스를 비우지 않는다면 데이터베이스에 크롤링 시 마다 데이터가 쌓이는 문제가 발생한다. 해당 이슈는 더미 데이터를 쌓는 것에는 좋지만 프로젝트의 방향성에는 맞지 않다고 생각하여 테이블 내용을 지우고 크롤링이 진행될 때마다 데이터를 지웠다가 새로 삽입하는 것이 좋을 것이라 판단되었다. truncate 구문은 delete 구문보다 속도가 빠르기 때문에 성능 측면에서 크게 우려될 요소가 없다.
decrementDate()
/* 날짜가 주어지면 하루 씩 줄어드는 메소드 작성 */
private static Date decrementDate(Date date, int minusDate) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
// 현재 일자에서 1일을 뺍니다.
calendar.add(Calendar.DATE, -minusDate);
return calendar.getTime();
}
매개변수로 넘어오는 Date 객체의 날짜를 하루 씩 줄이고 줄어든 날짜를 반환한다.
해당 메서드가 사용되는 부분은 아래와 같다.
Date currentDate = new Date();
currentDate = decrementDate(currentDate, date);
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");
String formattedDate = simpleDateFormat.format(currentDate);
String newUrl = newsUrl.concat("?date=").concat(formattedDate);
반환된 날짜는 SimpleDateFormat에 의해 형식에 맞게 파싱하고 파싱한 데이터를 url 형태에 맞게 붙여주어 새로운 url을 생성한다.
crawlingNewsInInfo()
private List<InfoDto> crawlingNewsInInfo(Elements elements, Elements imgElement) {
List<InfoDto> newsList = new ArrayList<>();
System.out.println("daily 기사 개수 : " + elements.size());
// ul > li:nth-child(4) > div > div > div.sa_text > div.sa_text_lede
// ul > li:nth-child(5) > div > div > div.sa_text > div.sa_text_lede
// li:nth-child(1) > div > div > div.sa_text > a > strong
String title = elements.select("a > strong").text().trim();
String content = elements.select("div.sa_text_lede").text().trim();
String publisher = elements.select("div.sa_text_info > div.sa_text_info_left > div.sa_text_press").text()
.trim();
String upload1 = elements.select("div.sa_text_info > div.sa_text_info_left > div.sa_text_datetime.is_recent")
.text().trim();
String upload2 = elements.select("div.sa_text_info > div.sa_text_info_left > div.sa_text_datetime").text()
.trim();
if (upload1.contains("분")) {
String imageUrl = imgElement.select("a > img").attr("data-src");
System.out.println("url 길이 : " + imageUrl.length());
Info info = new Info(title, content, publisher, upload1, imageUrl);
System.out.println(info);
newsList.add(saveData(info));
} else {
String imageUrl = imgElement.select("a > img").attr("data-src");
System.out.println("url 길이 : " + imageUrl.length());
Info info = new Info(title, content, publisher, upload2, imageUrl);
System.out.println(info);
newsList.add(saveData(info));
}
return newsList;
}
실질적인 크롤링이 진행됨과 동시에 데이터베이스에 데이터를 넣는 과정이 담긴 코드이다. 크게 어려운 부분은 없었다.
getPageNews()
public Page<InfoDto> getPagedNews(int page, int pageSize) {
Pageable pageable = PageRequest.of(page - 1, pageSize);
return newsRepository.findPagedNewsList(pageable);
}
백엔드단에서 진행되는 페이지네이션 함수이다. 필자는 JPA에서 제공하는 Page 객체를 사용하였다. 해당 객체는 페이징 및 정렬 결과를 담는 객체로, 특정 페이지의 데이터와 페이지 정보를 포함한다.
Pageable pageable = PageRequest.of(page - 1, pageSize) 에서 PageRequest 클래스의 of 메소드를 사용하여 페이징 및 정렬 정보를 설정한 Pageable 객체를 생성한다. page는 조회하려는 페이지 번호이며, pageSize는 페이지당 표시할 아이템수(페이지 유닛) 이다. 페이지 번호는 1부터 시작하지만, Spring Data JPA는 0부터 시작하는 페이지 번호를 사용하므로 page - 1로 변환하여 사용한다.
return 값에 findPagedNewsList를 호출하는데, 이 메서드는 @Query 어노테이션을 사용하여 정의한 JPQL 쿼리를 실행한다.
NewsRepository
package com.insilicogen.crawl.repository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import com.insilicogen.crawl.dto.InfoDto;
@Repository
public interface NewsRepository extends JpaRepository<InfoDto, Long> {
@Query("SELECT n FROM InfoDto n")
Page<InfoDto> findPagedNewsList(Pageable pageable);
@Transactional
@Modifying
@Query(value = "truncate info_dto",nativeQuery = true)
void truncateInfoDtoTable();
}
데이터베이스와 상호 작용하는 NewsRepository 인터페이스를 정의한 부분이다.
public interface NewsRepository extends JpaRepository<InfoDto, Long> 로 Spring Data JPA에서 제공하는 JpaRepository를 상속하는데, 기본적인 CRUD 기능을 제공해준다. 엔터티 타입으로 InfoDto, 엔터티 식별자 타입으로 Long을 선언했다.
크롤링 한 기사를 나타내려면 데이터베이스에 해당되는 모든 내용을 조회해야 한다. 제목, 내용, 언론사, 업로드시기, 이미지 등의 정보를 조회하는 쿼리로 @Query("SELECT n FROM InfoDto n") 을 사용하였다. 이 메소드는 JPQL (Java Persistence Query Language)을 사용하여 데이터베이스에서 정보를 조회하는 쿼리를 정의한다. 여기서 InfoDto는 데이터베이스 테이블과 매핑된 엔터티 클래스를 나타내며, n은 엔터티 자체를 나타낸다. 이 메소드는 페이징 처리된 결과를 반환한다.
Page<InfoDto> findPagedNewsList(Pageable pageable): JPQL 쿼리를 실행하여 페이징 처리된 결과를 가져오는 메소드이다. Pageable은 Spring에서 제공하는 페이징 및 정렬 정보를 담고 있는 인터페이스로, 클라이언트가 요청한 페이지 및 정렬 정보를 전달한다.
하단에 대한 내용은 위에서 언급했기 때문에 생략하겠다.
프론트 엔드 구현
Ajax + JSP + JQuery + Bootstrap
Ajax에 대해선 앞선 글에서 알아봤기 때문에 서블릿과 연관있는 JSP에 대해서 먼저 알아보자.
JSP(Java Server Page)
HTML 코드에 JAVA 코드를 넣어 동적 웹페이지를 생성하는 웹어플리케이션 도구
JSP는 기존의 단순한 HTML의 기능을 발전시켜 웹 기반의 프로그램을 할 수 있도록 만든 것이다. 서블릿을 기반으로 서블릿의 프로그램적인 요소를 발전시켜 사용자가 보다 쉽게 다룰 수 있도록 만든 스크립트 기반 프로그램이다.

JSP 파일이 서블릿 컨테이너에 담겨지는 과정을 표현한 그림이다.
JSP 실행 → 자바 서블릿으로 변환 → 웹 어플리케이션 서버에 동작 → 기능 수행 → 웹 페이지와 함께 클라이언트로 응답
JSP는 서블릿 기술의 확장형이라고 볼 수 있다.

jQuery
- 웹사이트에 자바스크립트를 쉽게 활용할 수 있도록 도와주는 오픈소스 기반의 자바스크립트 라이브러리
- 코드가 브라우저의 영향을 받아 작동하지 못하는 문제를 해결하기 위해 개발
- 문서 객체 모델 (DOM)과 이벤트에 관한 처리를 간단하게 구현
- Ajax 응용 프로그램 및 플러그린도 제이쿼리를 활용하여 빠르게 개발 가능
jQuery 적용
- 제이쿼리는 자바스크립트 라이브러리이므로, 제이쿼리 파일은 자바스크립트 파일 형태로 존재하기 때문에 웹 페이지에서 제이쿼리를 사용하기 위해서는 제이쿼리 파일을 먼저 웹 페이지에 로드해야 한다.
- 웹 페이지에 제이쿼리 파일을 로드하는 방법
- 제이쿼리 파일을 다운받아 로드 => 필자가 선택한 방법
- CDN(Content Delivery Network)을 이용하여 로드
Bootstrap
트위터에서 사용하는 각종 레이아웃, 버튼, 입력창 등의 디자인을 CSS와 Javascript로 만들어 놓은 것
- 프론트엔트 개발을 빠르고 쉽게 할 수 있는 프레임 워크
- HTML과 CSS 기반의 템플릿 양식,버튼, 네비게이션 및 기타 페이지를 구성하는 요소 포함
- 자바스크립트를 선택적으로 확장 가능
News.jsp
script의 사실상 첫 실행 부분
$(document).ready(function() {
loadNews(1);
$(".dropdown-item").on("click", function() {
var selectedPageUnit = $(this).data("pageunit");
$("#pageUnit").val(selectedPageUnit);
loadNews(1);
});
// 버튼
$("#startCrawlingBtn").on("click", function () {
alert("최신 뉴스 기사를 크롤링을 진행합니다.");
initCrawling();
loadNews(1);
});
});
$(document).ready();
- DOM트리만 완성되면 바로 발생
- 여러번 사용되면 선언 순서에 따라 순차적으로 실행
- $(function(){}); 으로 간략하게 사용 가능
초기 페이지

버튼과 드랍 다운 메뉴를 구성하여 click 이벤트 발생 시 함수를 실행하거나 해당 값을 보내주는 역할을 한다.
loadNews()
서버에서 뉴스 목록을 비동기적으로 가져와 HTML에 동적으로 표시하는 역할
function loadNews(pageNo) {
var param = {
pageNo : pageNo,
pageUnit : $('#pageUnit').val()
}
$.ajax({
type : "POST",
url : "/crawl/news/selectNewsList",
contentType : "application/json;",
data : JSON.stringify(param),
success : function(res) {
var list = res.content;
var html = '';
console.log(list);
$("#newsTableBody").empty();
// newsList에 있는 데이터를 테이블에 추가
for (var i = 0; i < list.length; i++) {
html += '<tr>';
html += ' <td>' + list[i].title + '</td>';
html += ' <td>' + list[i].content + '</td>';
html += ' <td>' + list[i].publisher + '</td>';
html += ' <td>' + list[i].upload + '</td>';
html += "<td><img src=" + list[i].url + "></td>";
html += '</tr>';
}
$("#newsTableBody").html(html);
param.totalPages = res.totalPages;
param.totalElements = res.totalElements;
param.onclickNm = 'loadNews';
createPagination(param);
},
error : function(res) {
alert("페이지 네이션 로드 실패" + res);
}
})
}
- loadNews(pageNo)
- 서버에서 특정 페이지(pageNo)의 뉴스 목록을 가져온다.
- var param = {
pageNo : pageNo,
pageUnit : $('#pageUnit').val()
}- Ajax 요청 시 전송할 매개 변수 객체를 정의한다.
- pageNo : 가져올 페이지 번호
- pageUnit : $('#pageUnit').val() : id가 pageUnit인 요소의 값을 가져온다.
- $.ajax({
type : "POST",
url : "/crawl/news/selectNewsList",
contentType : "application/json;",
data : JSON.stringify(param),
success : function(res) { ...- 요청 타입 : POST
- URL : /crawl/news/selectNewsList
- contentType : "application/json;" : 으로 json 형태로 요청
- data : JSON.stringify(param) : param 객체 json 형태로 파싱
- success : function(res) 콜백 함수(ajax 요청 성공 시 실행)
- var list = res.content; => 응답 데이터 중 실제 뉴스 목록 저장
- var html = ''; => 새로운 HTML 코드를 생성하기 위해 빈 문자열 초기화
- $("#newsTableBody").empty();
- id가 newsTableBody인 요소 내용 비우기
- for 문 생략
- $("#newsTableBody").html(html);
- 동적으로 테이블 업데이트
- param.totalPages = res.totalPages;
param.totalElements = res.totalElements;
param.onclickNm = 'loadNews';
createPagination(param);
- createPagination(param); 를 호출하여 페징 UI 생성
- totalPages : 전체 페이지 수
- totalElements : 전체 항목 수
- onclickNm : 페이징 버튼 클릭 시 호출될 함수 이름 => loadNews로 정의
createPagination()
페이지네이션 UI를 동적으로 생성하여 HTML 페이지에 추가하는 함수 (전체 코드 먼저 보여주고 밑에 설명)
function createPagination(pages) {
var pageNo = pages.pageNo; // 현재 페이지 번호
var pageUnit = pages.pageUnit; // 한 화면에 보여줄 기사 개수
var totalPages = pages.totalPages; // 전체 페이지
var totalElements = pages.totalElements; // 전체 기사 개수
console.log('createPagination is called')
console.log(pages);
console.log('받아온 페이지 값 : ' + totalPages , totalElements)
$("#pagination").empty(); // 기존 페이지네이션 초기화
var currentGroup = Math.ceil(pageNo / pageUnit);
var firstPageNo = (currentGroup - 1) * pageUnit + 1; // 그룹 내 첫 번째 번호
var lastPageNo = Math.min(currentGroup * pageUnit, totalPages); // 그룹 내 마지막 번호
var endPageNo = Math.ceil(totalElements / pageUnit);
var onclickNm = (pages && pages.onclickNm) || 'fnSelectList';
onclickNm = 'javascript:' + onclickNm;
var totalPageNo = totalElements / totalPages; // 총 페이지 수
console.log("총 페이지 넘버 : " + totalPageNo);
var before = Math.max(firstPageNo - 1, 1); // 이전 페이지
var after = Math.min(lastPageNo + 1, totalPages + 1); // 다음 페이지
var html = '<ul class="pagination">'; // html 생성
if(pageNo > pageUnit) { // 첫 페이지
html += "<li class='page-item'><a class='page-link' href='" + onclickNm + "("+1+")'>First</a></li>";
}else {
html += "<li class='page-item disabled'><a class='page-link' href='javascript:void(0)'>First</a></li>";
}
if(pageNo > 1) { // Prev 버튼
html += "<li class='page-item'><a class='page-link' href='" + onclickNm + "("+before+")'>Prev</a></li>";
}else {
html += "<li class='page-item disabled'><a class='page-link' href='javascript:void(0)'>Prev</a></li>";
}
for (var i = firstPageNo; i <= lastPageNo; i++) {
html += "<li class='page-itemxltmxhfl " + (pageNo == i ? ' active' : '') + "'><a class='page-link' href='" + onclickNm + "(" + i + ")'>" + i + "</a></li>";
}
if (pageNo < totalPages) { // 다음 버튼
html += "<li class='page-item'><a class='page-link' href='" + onclickNm + "(" + after + ")'>Next</a></li>";
}else{
html += "<li class='page-item disabled'><a class='page-link' href='javascript:void(0)'>Next</a></li>";
}
if(currentGroup == Math.ceil(totalPages/pageUnit)) { // 마지막 페이지
html += "<li class='page-item disabled'><a class='page-link' href='javascript:void(0)'>End</a></li>";
}else {
html += "<li class='page-item'><a class='page-link' href='" + onclickNm + "(" + endPageNo + ")'>End</a></li>";
}
html += '</ul>';
$("#pagination").append(html);
}
- 매개 변수 할당
var pageNo = pages.pageNo;
var pageUnit = pages.pageUnit;
var totalPages = pages.totalPages;
var totalElements = pages.totalElements;
- id가 pagination인 HTML요소의 내용을 모두 비워 페이지 네이션 초기화
$("#pagination").empty();
- 페이징 그룹 계산
var currentGroup = Math.ceil(pageNo / pageUnit); // 현재 페이지가 속한 그룹
var firstPageNo = (currentGroup - 1) * pageUnit + 1; // 첫 번째 페이지 번호
var lastPageNo = Math.min(currentGroup * pageUnit, totalPages); // 마지막 페이지 번호
- 페이징 버튼 생성
var html = '';</ul class="pagination">
- 첫 페이지
if (pageNo > pageUnit) {
html += "<li class='page-item'><a class='page-link' href='" + onclickNm + "(" + 1 + ")'>First</a></li>";
} else {
html += "<li class='page-item disabled'><a class='page-link' href='javascript:void(0)'>First</a></li>";
}
첫 페이지로 가는 링크를 만든다. 만약 현재 페이지 번호가 페이지 유닛보다 작다면 첫 페이지로 가는 First는 클릭을 비활성화 시켜야 하므로 disabled 옵션을 추가한다.
- 이전 페이지
if (pageNo > 1) {
html += "<li class='page-item'><a class='page-link' href='" + onclickNm + "(" + before + ")'>Prev</a></li>";
} else {
html += "<li class='page-item disabled'><a class='page-link' href='javascript:void(0)'>Prev</a></li>";
}
페이지 번호가 첫 페이지 번호(1) 보다 크면 항상 이전 페이지로 가야한다. 첫 페이지라면 Prev 버튼 역시 비활성화 시킨다.
- 현재 페이지 및 그룹 내의 페이지들에 대한 링크
for (var i = firstPageNo; i <= lastPageNo; i++) {
html += "<li class='page-item " + (pageNo == i ? ' active' : '') + "'><a class='page-link' href='" + onclickNm + "(" + i + ")'>" + i + "</a></li>";
}
첫 번째 번호부터 마지막 번호까지 페이지들에 대한 링크를 만든다. 삼항 연산자로 표현된 부분에서는 현재 페이지 번호가 무엇인지 표시하기 위해 active 옵션을 사용하였다. 현재 페이지 번호와 동적으로 그려지고 있는 i의 값이 같다면 현재 페이지를 나타낸다. active는 아래 그림처럼 현재 페이지가 무엇인지 표시하기 위한 옵션이다

- 다음 페이지
if (pageNo < totalPages) {
html += "<li class='page-item'><a class='page-link' href='" + onclickNm + "(" + after + ")'>Next</a></li>";
} else {
html += "<li class='page-item disabled'><a class='page-link' href='javascript:void(0)'>Next</a></li>";
}
현재 페이지 번호가 총 페이지 수 보다 작으면 항상 다음 페이지 입력이 가능하게 한다.
- 마지막 페이지
if (currentGroup == Math.ceil(totalPages / pageUnit)) {
html += "<li class='page-item disabled'><a class='page-link' href='javascript:void(0)'>End</a></li>";
} else {
html += "<li class='page-item'><a class='page-link' href='" + onclickNm + "(" + endPageNo + ")'>End</a></li>";
}
마지막 페이지는 현재 그룹의 값과 (전체 페이지 / 페이지 유닛)의 올림 값이 같다면 마지막 그룹 이므로 End 버튼을 비활성화 시킨다.
- 페이지네이션 추가
html += '</ul>';
$("#pagination").append(html);
initCrawling()
크롤링을 진행하는 함수
function initCrawling() {
$.ajax({
type : "GET",
url : "/crawl/news/initCrawling",
success : function(res) {
loadNews(1);
},
error : function() {
alert("Error crawling.");
}
});
}
컨트롤러에서 요청한 url을 통해 크롤링을 진행하고 ajax 통신으로 크롤링 후 페이지를 다시 표시한다. 크롤링은 백단(컨트롤러 => 서비스)에서 실행하고 앞단에서는 요청만 받아 페이지를 다시 띄우는 역할을 한다.
뉴스 리스트를 표시하기 위한 기본 레이아웃 정의
<div class="container pt-5">
<div class="dropdown-center" id="pageDiv" style="float: right;">
<input type="hidden" name="pageUnit" id="pageUnit" value="10" />
<button class="btn btn-secondary dropdown-toggle" type="button"
data-toggle="dropdown" aria-expanded="false">Crawling
Display Menu</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="javascript:void(0);"
data-pageUnit="10">10개씩 보기</a></li>
<li><a class="dropdown-item" href="javascript:void(0);"
data-pageUnit="20">20개씩 보기</a></li>
<li><a class="dropdown-item" href="javascript:void(0);"
data-pageUnit="30">30개씩 보기</a></li>
</ul>
</div>
<button id="startCrawlingBtn" class="btn btn-primary" type="button"
style="float: left; position: relative;">Start Crawling</button>
<h2 class="text-center">News List</h2>
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Content</th>
<th>Publisher</th>
<th>Upload Date</th>
<th>Image</th>
</tr>
</thead>
<tbody id="newsTableBody"></tbody>
</table>
<nav aria-label="Page navigation">
<ul class="pagination" id="pagination"></ul>
</nav>
</div>
- 컨테이너 및 드롭다운:
- <div class="container pt-5">
- 부트스트랩 스타일을 사용하는 컨테이너
- 컨텐츠를 감싸고 수직 여백을 설정
- <div class="dropdown-center" id="pageDiv" style="float: right;">
- 화면 우측에 위치한 드롭다운을 나타내는 div
- dropdown-center 클래스는 드롭다운을 수평으로 중앙에 정렬하도록 스타일링
- <input type="hidden" name="pageUnit" id="pageUnit" value="10" />
- 페이지 당 보여줄 항목 수를 저장하는 숨겨진 입력 필드
- 초기값은 10으로 설정
- <button class="btn btn-secondary dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">Crawling Display Menu</button>
- 드롭다운을 트리거하는 버튼
- 버튼 텍스트는 "Crawling Display Menu"로 설정
- <ul class="dropdown-menu">
- 드롭다운 메뉴를 표시하는 목록
- 항목은 10, 20, 30개씩 보기를 선택하는 옵션으로 구성
- <div class="container pt-5">
- 시작 버튼:
- <button id="startCrawlingBtn" class="btn btn-primary" type="button" style="float: left; position: relative;">Start Crawling</button>
- 크롤링을 시작하는 버튼
- <button id="startCrawlingBtn" class="btn btn-primary" type="button" style="float: left; position: relative;">Start Crawling</button>
- 뉴스 리스트 헤더 및 테이블:
- <h2 class="text-center">News List</h2>
- "News List"라는 제목을 가진 텍스트를 중앙 정렬
- <table class="table">
- 부트스트랩 스타일을 사용하는 테이블로, 뉴스 리스트를 표시하는데 사용
- <thead>
- 테이블의 헤더 부분
- "Title", "Content", "Publisher", "Upload Date", "Image" 열을 가짐
- <tbody id="newsTableBody"></tbody>
- 테이블의 본문을 나타내는 부분
- 뉴스 목록이 동적으로 추가될 위치
- <h2 class="text-center">News List</h2>
- 페이징 네비게이션:
- <nav aria-label="Page navigation">
- 시각적으로 페이징을 나타내는 네비게이션 영역
- <ul class="pagination" id="pagination"></ul>
- 페이징 버튼이 표시될 <ul> 요소
- 페이지 번호에 따라 동적으로 생성된 버튼들이 추가
- <nav aria-label="Page navigation">
최종 화면(녹화본)
'인실리코젠' 카테고리의 다른 글
[인실리코젠] Spring CRUD - 회원 가입 (0) | 2024.02.16 |
---|---|
[인실리코젠] Spring CRUD 프로젝트 요구 사항 분석 및 데이터베이스 설계 (1) | 2024.02.16 |
[인실리코젠] WebCrawling - 2 (0) | 2024.01.22 |
[인실리코젠] Spring : 용어 정리(기본) (0) | 2024.01.19 |
[인실리코젠] Spring Boot + MVC 이론 정리 (0) | 2024.01.17 |