공지사항을 스크래핑하는 코드를 수정하였다. 기존에 공지사항 분류마다 각각 컨트롤러를 정의하던 것을 하나의 컨트롤러로 통합하여 링크만 추가하면 어떤 페이지든 스크래핑이 가능하다.
캐시 정책 및 공지사항 업데이트, 게시 날짜 파싱까지 대대적인 수정이 이루어졌으나 하나씩 다루도록 한다. 여기서는 스크래핑 클래스는 스크래핑만, 컨트롤러는 링크에 따라 스크래핑 동작, 그리고 링크를 저장하는 config까지 역할을 분리시킨 과정을 다룬다.
우선 스크래핑 클래스는 기존과 크게 다르지는 않지만, 기숙사 공지사항과 학과 공지사항 처리가 추가되었다. 기숙사 공지사항과 학과 공지사항의 경우 기본적으로 페이지에서 게시일이 아닌 마감일을 표시하고 있다. 이는 최근 새로 게시된 공지사항을 표시하는 기능에 방해가 되기 때문에 이를 게시일로 변환하는 코드를 추가하였다.
기존 코드에서 추후 포스트에서 다룰 내용들을 제거했기 때문에 아래 코드에서는 오류가 있을 수 있다.
NoticeScraper.java
package zerogod.android_backend.model;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import zerogod.android_backend.service.PushNotificationService;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class NoticeScraper {
private static final Logger logger = LoggerFactory.getLogger(NoticeScraper.class);
// 게시일 확인이 필요한 URL 리스트
private static final Set<String> URLS_REQUIRE_POSTED_DATE = Set.of(
"https://dorm.ajou.ac.kr/dorm/notice/notice.do",
"https://ece.ajou.ac.kr/ece/bachelor/notice.do"
);
// 지정된 URL에서 공지사항 데이터를 스크래핑
public List<Notice> scrapeNotices(String url, String type) throws IOException {
System.out.println("[Info] Start scraping: " + url);
List<Notice> notices = new ArrayList<>();
try {
// Jsoup을 사용하여 HTML 문서 로드
Document doc = Jsoup.connect(url).get();
// 고정된 공지사항 처리
Elements fixedRows = doc.select("tr.b-top-box");
addNotices(notices, newNotices, fixedRows, url);
// 일반 공지사항 처리
Elements generalRows = doc.select("tr:not(.b-top-box)");
addNotices(notices, generalRows, url);
} catch (IOException e) {
logger.error("Failed to scrape notices from URL: {}", url, e);
throw e; // 예외를 컨트롤러로 전달
}
return notices;
}
// HTML 행 데이터를 Notice 객체로 변환하여 리스트에 추가
private void addNotices(List<Notice> notices, Elements rows, String url, boolean isFixed) {
int limit = isFixed ? rows.size() : 10; // 고정 공지사항은 모두, 일반 공지사항은 최대 10개만 처리
for (int i = 0; i < limit; i++) {
try {
Element row = rows.get(i);
String number = isFixed ? "공지" : getElementText(row, 0); // 고정된 공지사항의 번호는 "공지"로 설정
String category = getElementText(row, 1); // 분류 텍스트 추출
Element titleElement = row.selectFirst("td a"); // 제목과 링크가 포함된 요소 선택
String department = getElementText(row, 4); // 공지부서 텍스트 추출
String date = getElementText(row, 5); // 작성일자 텍스트 추출
if (titleElement != null) {
String title = titleElement.text(); // 제목 텍스트 추출
String articleNo = titleElement.attr("href").split("articleNo=")[1].split("&")[0];
String link = url + "?mode=view&articleNo=" + articleNo;
// 게시일 확인이 필요한 URL 처리
if (URLS_REQUIRE_POSTED_DATE.contains(url))
date = fetchPostedDate(link);
// Notice 객체 생성 후 리스트에 추가
Notice notice = new Notice(number, category, title, department, date, link);
notices.add(notice);
}
} catch (Exception e) {
logger.error("Error processing row index={}, reason: {}", i, e.getMessage());
}
}
}
// 개별 공지사항의 링크에서 게시일을 가져옴
private String fetchPostedDate(String link) {
try {
// 공지사항 상세 페이지 로드
Document detailDoc = Jsoup.connect(link).get();
// 작성일 추출
Element dateElement = detailDoc.selectFirst("li.b-date-box span:contains(작성일) + span");
return dateElement != null ? dateElement.text() : "Unknown";
} catch (IOException e) {
logger.error("[Error] Failed to fetch posted date from link: {}", link, e);
return "Unknown"; // 기본값 반환
}
}
// HTML 행에서 지정된 열의 텍스트를 추출
private String getElementText(Element row, int index) {
Elements elements = row.select("td");
return elements.size() > index ? elements.get(index).text() : "";
}
}
우선 NoticeScraper 클래스에 @Component를 추가하여 bean으로 등록하였다. 기존의 Controller는 NoticeScraper를 상속받아 구현하였는데, 하나의 Controller로 통합하면서 bean으로 주입받아 사용하는 방식으로 변환하였다.
// 게시일 확인이 필요한 URL 리스트
private static final Set<String> URLS_REQUIRE_POSTED_DATE = Set.of(
"https://dorm.ajou.ac.kr/dorm/notice/notice.do",
"https://ece.ajou.ac.kr/ece/bachelor/notice.do"
);
게시일 확인이 필요한 리스트를 set으로 관리한다. 만약 다른 학과 등 스크래핑할 링크를 추가한다면 여기에 추가하면 되지만, 전자공학과와 지능형반도체공학과를 제외하고는 추가하지 않을 듯 하다.
스크래핑 코드인 scrapeNotices 메서드는 별 차이가 없다. 다만 스크래핑 후 Notice 객체로 변환할 때 addNotices 메서드에서 게시일 확인이 필요한 url의 경우 별도 처리를 거친다.
// HTML 행 데이터를 Notice 객체로 변환하여 리스트에 추가
private void addNotices(List<Notice> notices, List<Notice> newNotices, Elements rows, String url, boolean isFixed) {
int limit = isFixed ? rows.size() : 10; // 고정 공지사항은 모두, 일반 공지사항은 최대 10개만 처리
for (int i = 0; i < limit; i++) {
try {
Element row = rows.get(i);
String number = isFixed ? "공지" : getElementText(row, 0); // 고정된 공지사항의 번호는 "공지"로 설정
String category = getElementText(row, 1); // 분류 텍스트 추출
Element titleElement = row.selectFirst("td a"); // 제목과 링크가 포함된 요소 선택
String department = getElementText(row, 4); // 공지부서 텍스트 추출
String date = getElementText(row, 5); // 작성일자 텍스트 추출
if (titleElement != null) {
String title = titleElement.text(); // 제목 텍스트 추출
String articleNo = titleElement.attr("href").split("articleNo=")[1].split("&")[0];
String link = url + "?mode=view&articleNo=" + articleNo;
// 게시일 확인이 필요한 URL 처리
if (URLS_REQUIRE_POSTED_DATE.contains(url))
date = fetchPostedDate(link);
// Notice 객체 생성 후 리스트에 추가
Notice notice = new Notice(number, category, title, department, date, link);
notices.add(notice);
}
} catch (Exception e) {
logger.error("Error processing row index={}, reason: {}", i, e.getMessage());
}
}
}
객체 변환 코드에는 차이가 없으나, 게시일 확인이 필요한 url의 경우 fetchPostedDate 메서드를 호출하여 date를 게시일로 업데이트한다.
// 개별 공지사항의 링크에서 게시일을 가져옴
private String fetchPostedDate(String link) {
try {
// 공지사항 상세 페이지 로드
Document detailDoc = Jsoup.connect(link).get();
// 작성일 추출
Element dateElement = detailDoc.selectFirst("li.b-date-box span:contains(작성일) + span");
return dateElement != null ? dateElement.text() : "Unknown";
} catch (IOException e) {
logger.error("[Error] Failed to fetch posted date from link: {}", link, e);
return "Unknown"; // 기본값 반환
}
}
개별 공지사항에 접속하여 게시일을 스크래핑해야 하기 때문에 링크로 직접 접속한다. 접속 후 공지사항 스크래핑과 마찬가지로 html코드를 확인하여 작성일을 추출한다.
NoticeConfig.java
package zerogod.android_backend.config;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
@Configuration
public class NoticeConfig {
public static final Map<String, String> NOTICE_URLS = Map.of(
"general", "https://www.ajou.ac.kr/kr/ajou/notice.do",
"scholarship", "https://www.ajou.ac.kr/kr/ajou/notice_scholarship.do",
"dormitory", "https://dorm.ajou.ac.kr/dorm/notice/notice.do",
"department_ece", "https://ece.ajou.ac.kr/ece/bachelor/notice.do",
"department_aisemi", "https://www.ajou.ac.kr/aisemi/board/notice.do"
);
}
NoticeConfig에는 각 공지사항 페이지 링크를 Map으로 보관한다. 추가할 링크가 생긴다면 config에 추가함으로써 쉽게 업데이트할 수 있다.
NoticeController.java
package zerogod.android_backend.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import zerogod.android_backend.config.NoticeConfig;
import zerogod.android_backend.model.Notice;
import zerogod.android_backend.model.NoticeScraper;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@RestController
@RequestMapping("/api/notices")
public class NoticeController {
private static final Logger logger = LoggerFactory.getLogger(NoticeController.class);
private final NoticeScraper noticeScraper;
private final CacheManager cacheManager;
private final Map<String, String> NOTICE_URLS = NoticeConfig.NOTICE_URLS; // 공유 URL 사용
// 캐시와 별도로, 실제 데이터(목록)을 보관할 맵
private final Map<String, List<Notice>> cache = new ConcurrentHashMap<>();
public NoticeController(NoticeScraper noticeScraper, CacheManager cacheManager) {
this.noticeScraper = noticeScraper;
this.cacheManager = cacheManager;
}
/**
* 사용자 API - 30분 캐싱(@Cacheable)
* 캐시가 유효하면 Scraper 호출 없이 cache에서 반환
*/
@Cacheable(value = "notices", key = "#type") // 요청된 타입별 캐싱
@GetMapping(value = "/{type}", produces = MediaType.APPLICATION_JSON_VALUE)
public List<Notice> getNotices(@PathVariable String type) {
logger.info("[GET /api/notices/{}] Called - might use Cacheable data", type);
// 만약 캐시에 아무것도 없다면 빈 리스트 반환
// (처음 서버 띄운 직후, 스케줄러나 forceScrapeNotices가 아직 실행 안 됐다면 빈 값일 수 있음)
return cache.getOrDefault(type, Collections.emptyList());
}
/**
* 스케줄러 전용: 캐시를 무시하고 강제로 스크래핑
* - ScrapeNotices 호출 → 새 공지 알림 → cache 갱신
*/
public void forceScrapeNotices(String type) {
logger.info("[forceScrapeNotices] Force scraping notices for type={}", type);
String url = NOTICE_URLS.get(type);
if (url == null) {
logger.error("Invalid notice type: {}", type);
return;
}
synchronized (this) { // 동기화 블록 추가
// 캐시에 있는 기존 데이터 불러오기
List<Notice> cachedNotices = cache.getOrDefault(type, new ArrayList<>());
try {
// 항상 실제 스크래핑: 새 공지 발견 시 Scraper 내부에서 푸시 알림
List<Notice> updatedNotices = noticeScraper.scrapeNotices(url, cachedNotices, type);
// 만약 새 공지 발견되었다면, 스프링 캐시 무효화
if (noticeScraper.isNewFound()) {
evictNoticesCache(type); // 캐시 무효화
Objects.requireNonNull(cacheManager.getCache("notices")).put(type, updatedNotices); // 캐시 갱신
cache.put(type, updatedNotices);
logger.info("Cache evicted and updated for type={}", type);
} else
logger.info("No new notices found. Cache remains unchanged for type={}", type);
} catch (IOException e) {
logger.error("Failed to force scrape notices for type: {}", type, e);
}
}
}
// 새 공지 발견 시 캐시를 무효화해, 다음 GET 요청 때 갱신되도록 함
@CacheEvict(value = "notices", key = "#type")
public void evictNoticesCache(String type) {
logger.info("[evictNoticesCache] Evicting 'notices' cache for type={}", type);
// body가 없어도 @CacheEvict가 캐시를 제거
}
}
NoticeController에는 현재 캐싱 정책이 적용되어 있어 간단하게 다루고, 추후 캐싱 정책 포스트에서 자세히 다루겠다.
NoticeController의 역할은 /api/notices로 받은 요청을 처리한다. 타입별, 즉 general, scholarship등 공지사항 타입에 따라 캐싱된 요청을 제공함으로써 공지사항에 변화가 없을 경우 빠르게 기존 데이터를 제공한다.
NoticeController는 스케줄러에 등록되어 5분마다 강제 스크래핑을 수행한다. 이는 새로 올라온 공지사항이 있는지 탐지하고, 곧바로 업데이트하기 위함이다.
/**
* 스케줄러 전용: 캐시를 무시하고 강제로 스크래핑
* - ScrapeNotices 호출 → 새 공지 알림 → cache 갱신
*/
public void forceScrapeNotices(String type) {
logger.info("[forceScrapeNotices] Force scraping notices for type={}", type);
String url = NOTICE_URLS.get(type);
if (url == null) {
logger.error("Invalid notice type: {}", type);
return;
}
synchronized (this) { // 동기화 블록 추가
// 캐시에 있는 기존 데이터 불러오기
List<Notice> cachedNotices = cache.getOrDefault(type, new ArrayList<>());
try {
// 항상 실제 스크래핑: 새 공지 발견 시 Scraper 내부에서 푸시 알림
List<Notice> updatedNotices = noticeScraper.scrapeNotices(url, cachedNotices, type);
// 만약 새 공지 발견되었다면, 스프링 캐시 무효화
if (noticeScraper.isNewFound()) {
evictNoticesCache(type); // 캐시 무효화
Objects.requireNonNull(cacheManager.getCache("notices")).put(type, updatedNotices); // 캐시 갱신
cache.put(type, updatedNotices);
logger.info("Cache evicted and updated for type={}", type);
} else
logger.info("No new notices found. Cache remains unchanged for type={}", type);
} catch (IOException e) {
logger.error("Failed to force scrape notices for type: {}", type, e);
}
}
}
forceScrapeNotices 메서드는 스케줄러에 의해 5분마다 스크래핑을 수행한다. 캐시에 저장되어 있는 기존 데이터와 새로 스크래핑한 데이터를 비교해, 새로운 공지사항이 있다면 캐시를 무효화하고 업데이트한다. 이를 위해 @CacheEvict가 선언된 evictNoticesCache 메서드를 사용한다.
// 새 공지 발견 시 캐시를 무효화해, 다음 GET 요청 때 갱신되도록 함
@CacheEvict(value = "notices", key = "#type")
public void evictNoticesCache(String type) {
logger.info("[evictNoticesCache] Evicting 'notices' cache for type={}", type);
// body가 없어도 @CacheEvict가 캐시를 제거
}
캐시에 대해서는 다음 포스트에서 다루도록 하겠다.
'Side Project > Application' 카테고리의 다른 글
| [Application][Backend] ScrapingScheduler 설정 및 Caching 정책 (0) | 2025.02.11 |
|---|---|
| [Application] Android Studio - 사이드바를 이용한 페이지 구현 (0) | 2025.01.17 |
| [Application] 엑셀 파일을 Parsing해서 DB로 저장하기 (0) | 2025.01.16 |