공지사항을 스크래핑하는 데에는 일정 시간이 소요된다. 따라서 매번 API 요청이 들어올 때마다 스크래핑을 수행하면 불필요한 지연이 발생하고 서버에 과부하를 줄 수 있다. 또한 새로운 공지사항이 게시될 경우 이를 빠르게 감지하고 반영해야 하는데, 이를 효율적으로 처리하기 위해서는 캐싱(Cache)과 스케줄링(Scheduling)이 필수적이다.
먼저, 한 번 스크래핑된 데이터는 캐시에 저장되며 30분간 유지된다. API 요청이 들어오면 기존에 저장된 데이터를 즉시 반환하고, 새로운 스크래핑을 수행하지 않는다. 이를 통해 반복적인 데이터 요청으로 인한 성능 저하를 방지하고, 사용자에게 빠른 응답을 제공할 수 있다.
공지사항 데이터는 5분마다 자동으로 스크래핑되며, 기존 데이터와 비교하여 변경 사항이 있는지 확인한다. 새로운 공지사항이 발견되지 않으면 기존 캐시를 유지하고, 불필요한 데이터 갱신을 방지한다. 하지만 새로운 공지가 감지되면, 기존 캐시를 즉시 삭제(Evict)하고 최신 데이터로 갱신하여 최신상태를 유지한다.
이렇게 캐싱과 스케줄링을 통해 API 요청 시 캐싱된 데이터를 즉시 제공하므로 공지사항 로딩 속도가 빨라지고, 서버의 불필요한 부하를 줄일 수 있다. 또한 5분 단위로 최신 공지사항을 감지하고 반영하기 때문에, 항상 최신 정보를 확인할 수 있다.
CacheConfig.java
package zerogod.android_backend.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("notices"); // "notices"는 캐시 이름
}
}
CacheConfig 클래스는 Spring의 캐싱 기능을 활성화하고, 특정 캐시를 설정하는 역할을 한다.
먼저, @Configuration 어노테이션을 사용하여 Spring의 설정 클래스로 지정하고, @EnableCaching 어노테이션을 통해 애플리케이션 전반에서 캐싱 기능을 활성화한다.
cacheManager() 메서드는 Spring의 CacheManager를 설정하며 ConcurrentMapCacheManager를 사용하여 "notices"라는 이름의 캐시를 관리하도록 지정한다. 이를 통해 공지사항 데이터를 캐시할 수 있는 환경을 구성할 수 있다.
WebConfig.java
package zerogod.android_backend.config;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.io.IOException;
// Web Config
@Configuration
public class WebConfig implements WebMvcConfigurer {
// RestTemplate 빈 정의
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
// http://10.0.2.2:8080은 설치된 애플리케이션 내부 url
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://10.0.2.2:8080")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 허용할 HTTP 메서드
.allowedHeaders("*") // 허용할 헤더
.allowCredentials(true);
}
// 캐시 제어를 위한 필터
@Bean
public Filter cacheControlHeaderFilter() {
return new Filter() {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (response instanceof HttpServletResponse httpServletResponse) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 모든 notices API에 대한 공통 캐싱 정책
// 모든 공지사항은 30분간 캐시됨
if (httpRequest.getRequestURI().startsWith("/api/notices")) {
httpServletResponse.setHeader("Cache-Control", "max-age=1800, public"); // 30분 캐싱
} else {
httpServletResponse.setHeader("Cache-Control", "no-store");
}
}
chain.doFilter(request, response);
}
@Override
public void init(FilterConfig filterConfig) {
// 초기화 필요 시 추가
}
@Override
public void destroy() {
// 자원 정리 필요 시 추가
}
};
}
// UTF-8 인코딩 필터
@Bean
public Filter utf8CharsetFilter() {
return new Filter() {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (response instanceof HttpServletResponse httpServletResponse) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// API 경로가 아닌 경우에만 Content-Type 설정
if (!httpRequest.getRequestURI().startsWith("/api/")) {
httpServletResponse.setContentType("text/html; charset=UTF-8");
}
}
chain.doFilter(request, response);
}
@Override
public void init(FilterConfig filterConfig) {
// 초기화 필요 시 추가
}
@Override
public void destroy() {
// 자원 정리 필요 시 추가
}
};
}
}
WebConfig에는 캐싱 정책을 작성한다.
// 캐시 제어를 위한 필터
@Bean
public Filter cacheControlHeaderFilter() {
return new Filter() {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (response instanceof HttpServletResponse httpServletResponse) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 모든 notices API에 대한 공통 캐싱 정책
// 모든 공지사항은 30분간 캐시됨
if (httpRequest.getRequestURI().startsWith("/api/notices")) {
httpServletResponse.setHeader("Cache-Control", "max-age=1800, public"); // 30분 캐싱
} else {
httpServletResponse.setHeader("Cache-Control", "no-store");
}
}
chain.doFilter(request, response);
}
@Override
public void init(FilterConfig filterConfig) {
// 초기화 필요 시 추가
}
@Override
public void destroy() {
// 자원 정리 필요 시 추가
}
};
}
모든 공지사항들은 한번에 관리되며, 30분간 캐싱된다.
application.properties에도 다음과 같이 캐시 설정을 적용해준다.
spring.application.name=android_backend
# Session timeout
server.servlet.session.timeout=30m
# cache
spring.cache.type=simple
spring.cache.cache-names=notices
spring.cache.caffeine.spec=maximumSize=100,expireAfterWrite=30m
# application.properties
firebase.service.account.json=${FIREBASE_SERVICE_ACCOUNT_JSON}
NoticeController와 NoticeScraper는 별도 설명 없이 주석으로 대체한다. Firebase 관련 설정은 다음 포스트에서 정리한다.
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;
// 공지사항 스크래핑을 수행하는 Rest Controller
@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가 캐시를 제거
}
}
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"
);
private final PushNotificationService pushNotificationService;
// 타입별 "첫 스크래핑 시점" 푸시 스킵 여부
private final Map<String, Boolean> isFirstLoadMap = new ConcurrentHashMap<>();
// 최근 scrapeNotices 수행에서 "새 공지"가 몇 개 발견되었는지 계산
private int lastNewCount = 0;
@Autowired
public NoticeScraper(PushNotificationService pushNotificationService) {
this.pushNotificationService = pushNotificationService;
}
// 새 공지 발견 여부 조회 (직전 scrapeNotices 결과)
public boolean isNewFound() {
return lastNewCount > 0;
}
/**
* 지정된 URL에서 공지사항 데이터를 스크래핑
* 고정 공지사항은 단일 페이지에서 모두 파싱
* 고정되지 않은(일반) 공지사항은 offset이 0부터 40까지(총 50개) 페이지를 순회하여 수집
*
* @param url 대상 URL (예: "https://www.ajou.ac.kr/kr/ajou/notice.do")
* @param cachedNotices 이전 캐시된 공지사항 목록
* @param type 공지사항 타입 (예: "general", "scholarship" 등)
* @return 전체 Notice 목록 (고정 + 일반)
* @throws IOException 스크래핑 실패 시 예외 전파
*/
public List<Notice> scrapeNotices(String url, List<Notice> cachedNotices, String type) throws IOException {
boolean isFirstForThisType = isFirstLoadMap.getOrDefault(type, true);
List<Notice> allNotices = new ArrayList<>();
List<Notice> newNotices = new ArrayList<>();
logger.info("[Info] Start scraping: {}", url);
try {
// 1. 고정 공지사항 처리 (단일 페이지)
Document doc = Jsoup.connect(url).get();
Elements fixedRows = doc.select("tr.b-top-box");
addNotices(allNotices, newNotices, fixedRows, url, true, cachedNotices, fixedRows.size());
// 2. 고정되지 않은(일반) 공지사항 처리: offset 0부터 40까지 (최대 50개)
int articleLimit = 10; // 페이지당 항목 수
for (int offset = 0; offset < 50; offset += articleLimit) {
String pagedUrl = url + "?mode=list&&articleLimit=" + articleLimit + "&article.offset=" + offset;
logger.info("Scraping non-fixed URL: {}", pagedUrl);
Document pagedDoc = Jsoup.connect(pagedUrl).get();
Elements generalRows = pagedDoc.select("tr:not(.b-top-box)");
if (generalRows.isEmpty()) {
logger.info("No non-fixed notices found at offset {}.", offset);
break;
}
// 처리할 개수는 해당 페이지의 전체 행 수
addNotices(allNotices, newNotices, generalRows, url, false, cachedNotices, generalRows.size());
}
logger.info("Total notices scraped: {}", allNotices.size());
logger.info("New notices found: {}", newNotices.size());
lastNewCount = newNotices.size();
// 새 공지 알림 전송
if (!isFirstForThisType && !newNotices.isEmpty()) {
sendPushNotification(newNotices, url);
}
// 캐시 갱신
cachedNotices.clear();
cachedNotices.addAll(allNotices);
isFirstLoadMap.put(type, false);
} catch (IOException e) {
logger.error("Failed to scrape notices from URL: {}", url, e);
throw e;
}
return allNotices;
}
// HTML 행 데이터를 Notice 객체로 변환하여 리스트에 추가
private void addNotices(List<Notice> notices, List<Notice> newNotices, Elements rows, String url, boolean isFixed, List<Notice> cachedNotices, int limit) {
limit = Math.min(rows.size(), limit);
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;
if (URLS_REQUIRE_POSTED_DATE.contains(url))
date = fetchPostedDate(link);
Notice notice = new Notice(number, category, title, department, date, link);
notices.add(notice);
if (isNoticeNew(cachedNotices, notice)) {
newNotices.add(notice);
}
}
} catch (Exception e) {
logger.error("Error processing row index={}, reason: {}", i, e.getMessage());
}
}
}
// 새로운 공지사항인지 여부 판단 (중복 확인)
private boolean isNoticeNew(List<Notice> cachedNotices, Notice notice) {
return cachedNotices.stream().noneMatch(n -> n.link().equals(notice.link()));
}
private void sendPushNotification(List<Notice> newNotices, String url) {
String category;
if (url.contains("ajou/notice.do"))
category = "general";
else if (url.contains("ajou/notice_scholarship.do"))
category = "scholarship";
else if (url.contains("dorm"))
category = "dormitory";
else if (url.contains("ece"))
category = "department_ece";
else if (url.contains("aisemi"))
category = "department_aisemi";
else
category = "others";
pushNotificationService.sendPushNotification(newNotices, category);
}
// 게시일 추출
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";
}
}
private String getElementText(Element row, int index) {
Elements elements = row.select("td");
return elements.size() > index ? elements.get(index).text() : "";
}
}
NoticeScheduler.java
package zerogod.android_backend.model;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import zerogod.android_backend.controller.NoticeController;
import java.util.List;
// 주기적으로 캐시를 우회하여 스크래핑 → 새 공지를 발견하면 푸시 알림 전송
@Component
public class NoticeScheduler {
private final NoticeController noticeController;
// 5개 notice type
private static final List<String> NOTICE_TYPES = List.of(
"general", "scholarship", "dormitory", "department_ece", "department_aisemi"
);
public NoticeScheduler(NoticeController noticeController) {
this.noticeController = noticeController;
}
// 5분마다 한번씩 강제 스크래핑
@Scheduled(fixedRate = 300000) // 5분마다 실행
public void refreshNoticesCache() {
NOTICE_TYPES.forEach(noticeController::forceScrapeNotices);
}
}
Spring Boot 애플리케이션이 실행되면, @Scheduled 어노테이션에 의해 5분마다 refreshNoticesCache()가 자동 호출된다. 이 과정에서 noticeController.forceScrapeNotices(type)이 실행되어 자동으로 5분마다 스크래핑이 수행된다.
NoticeStartupInitializer.java
package zerogod.android_backend.model;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import zerogod.android_backend.controller.NoticeController;
import java.util.List;
// 서버 실행 시 최초 스크래핑
@Component
public class NoticeStartupInitializer {
private final NoticeController noticeController;
public NoticeStartupInitializer(NoticeController noticeController) {
this.noticeController = noticeController;
}
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
// 서버가 완전히 열린 직후, 스케줄러가 돌기 전에 바로 한 번 강제 스크래핑해서 cache 채워놓기
List<String> types = List.of("general", "scholarship", "dormitory", "department_ece", "department_aisemi");
types.forEach(noticeController::forceScrapeNotices);
}
}
NoticeStartupInitializer 클래스는 서버가 실행된 직후 즉시 공지사항 데이터를 스크래핑하여 캐시에 저장하는 역할을 한다. Spring Boot 애플리케이션이 실행될 때, @EventListener(ApplicationReadyEvent.class) 어노테이션을 통해 onApplicationReady() 메서드가 호출된다. 이 메서드는 스케줄러가 주기적으로 실행되기 전에 한 번 강제 스크래핑을 수행하여 공지사항 데이터를 미리 가져오도록 한다. 이를 통해 서버가 실행된 직후 최초 요청 시 데이터가 캐시에 존재하지 않아 발생할 수 있는 지연 문제와 모든 공지사항이 새 공지로 인식되는 오류를 방지한다.
'Side Project > Application' 카테고리의 다른 글
[Application] [Backend] 공지사항 스크래핑 역할 분리 (0) | 2025.01.30 |
---|---|
[Application] Android Studio - 사이드바를 이용한 페이지 구현 (0) | 2025.01.17 |
[Application] 엑셀 파일을 Parsing해서 DB로 저장하기 (0) | 2025.01.16 |