오늘은 RSS 크롤링을 각 잡고 개선했습니다.
1. RSS 크롤링을 실행하는 WebClient 설정 개선
먼저 과거에 우아한 형제들 테크블로그에서 SSL 인증서 문제때문에 크롤링이 되지 않는 문제가 있어
모든 SSL 인증서를 허용하도록 했었습니다.
하지만 이 경우 MITM(Man In The Middle) 공격을 받을 수 있다는 문제가 있었습니다.
그때 당시에는 게시글을 가져오는 게 중요해서 그렇게 조치했지만
출시를 목적으로 개발 중이기에 안전성을 고려하지 않을 수 없었습니다.
오늘 한 번 체크를 해보니 인증서 문제가 발생하지 않았고,
그에 따라 모든 SSL 인증서 허용 설정을 제거했습니다.
또한 기존에는 HttpClient에 응답 타임아웃만 존재했는데,
해당 테크 블로그가 일시적 장애로 연결에 장애가 있을 수 있으므로 연결 타임아웃 코드를 추가하였습니다.
SslContext sslContext = SslContextBuilder
.forClient()
.trustManager(InsecureTrustManagerFactory.INSTANCE)
.build();
// HttpClient 설정 (Netty 기반)
HttpClient httpClient = HttpClient.create()
.secure(sslContextSpec -> sslContextSpec.sslContext(sslContext))
.responseTimeout(Duration.ofSeconds(30))
.followRedirect(true); // Redirect 자동 추적
기존 코드
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) // 연결 타임아웃
.responseTimeout(Duration.ofSeconds(30)) // 응답 타임아웃
.followRedirect(true); // Redirect 자동 추적
변경된 코드
2. 병렬 처리 방식 개선
기존에는 크롤링 시 Spring Batch의 taskExecutor를 활용하여 병렬 처리를 시도했습니다.
하지만 Reader쪽에서 동시성 문제가 발생하여,
Reader쪽은 단일 스레드, Processor와 Writer에서는 taskExecutor를 활용하여 5개의 스레드로 동작하도록 코드를 구성했습니다.
사실 지연 시간의 대부분은 네트워크 I/O와 DB I/O인 만큼 WebClient의 병렬 실행이 중요했는데,
제가 이 부분을 놓치고 코드를 짰던거 같습니다.
이 부분을 개선하여 순차 처리 대신 pararrelStream을 활용하여 각 테크블로그마다 WebClient가 동작하도록 하였고,
불필요하다고 생각한 taskExecutor 설정을 제거했습니다.
taskExecutor 설정이 없어짐에 따라 중복 url 체크를 하는 Processor에서 속도 저하가 발생하였고,
(5개 스레드 -> 1개 스레드)
다시 taskExecutor를 도입해보는 것과
Reader에서 IN 절을 사용한 쿼리로 중복 체크를 미리 하는 것을 테스트 해보았습니다.
2026-01-02T23:23:31.203+09:00 INFO 16820 --- [TechFork] [nio-8080-exec-1] o.s.batch.core.job.SimpleStepHandler : Executing step: [fetchAndSaveRssStep]
2026-01-02T23:23:56.155+09:00 INFO 16820 --- [TechFork] [ rss-crawl-4] c.t.domain.source.batch.RssFeedReader : 모든 RSS 피드 수집 완료
2026-01-02T23:32:56.503+09:00 INFO 27868 --- [TechFork] [nio-8080-exec-1] o.s.batch.core.job.SimpleStepHandler : Executing step: [fetchAndSaveRssStep]
2026-01-02T23:33:08.737+09:00 INFO 27868 --- [TechFork] [nio-8080-exec-1] c.t.domain.source.batch.RssFeedReader : 모든 RSS 피드 수집 완료: 총 0개
위의 결과가 taskExecutor의 도입. (25초)
아래의 결과가 IN절을 사용한 결과입니다. (12초)
약 50%의 속도 향상을 확인할 수 있었습니다.
public class RssFeedReader implements ItemReader<RssFeedItem> {
private final TechBlogRepository techBlogRepository;
private final WebClient webClient;
private ConcurrentLinkedQueue<RssFeedItem> itemQueue;
@Override
public RssFeedItem read() {
// 첫 실행 시 모든 RSS 아이템을 큐에 추가
if (itemQueue == null) {
initializeQueue();
}
// 큐에서 아이템 꺼내기 (Thread-Safe)
RssFeedItem item = itemQueue.poll();
if (item == null) {
log.info("모든 RSS 피드 수집 완료");
}
return item;
}
/**
* 모든 RSS 피드를 미리 수집하여 큐에 저장
* 한 번만 실행되며, 여러 스레드가 큐에서 안전하게 아이템을 가져감
*/
private synchronized void initializeQueue() {
// Double-checked locking
if (itemQueue != null) {
return;
}
itemQueue = new ConcurrentLinkedQueue<>();
List<TechBlog> techBlogs = techBlogRepository.findAll();
log.info("총 {}개 테크 블로그 RSS 수집 시작", techBlogs.size());
int totalItems = 0;
for (TechBlog techBlog : techBlogs) {
try {
List<RssFeedItem> items = fetchRssFeed(techBlog);
if (!items.isEmpty()) {
itemQueue.addAll(items);
totalItems += items.size();
log.info("[{}] RSS 수집 성공: {}개 아이템", techBlog.getCompanyName(), items.size());
} else {
log.warn("[{}] RSS 피드에 아이템이 없습니다", techBlog.getCompanyName());
}
} catch (Exception e) {
log.error("[{}] RSS 수집 실패: {}", techBlog.getCompanyName(), e.getMessage(), e);
// 실패해도 다음 블로그 계속 처리
}
}
log.info("RSS 수집 초기화 완료: 총 {}개 아이템을 큐에 추가", totalItems);
}
// ...
}
기존 코드
public class RssFeedReader implements ItemReader<RssFeedItem> {
private final TechBlogRepository techBlogRepository;
private final PostRepository postRepository;
private final WebClient webClient;
private List<RssFeedItem> items;
private int currentIndex = 0;
@Override
public RssFeedItem read() {
if (items == null) {
initializeItems();
}
if (currentIndex >= items.size()) {
log.info("모든 RSS 피드 수집 완료: 총 {}개", items.size());
return null;
}
return items.get(currentIndex++);
}
private void initializeItems() {
List<TechBlog> techBlogs = techBlogRepository.findAll();
log.info("총 {}개 테크 블로그 RSS 수집 시작", techBlogs.size());
List<RssFeedItem> allItems = techBlogs.parallelStream()
.flatMap(techBlog -> {
try {
List<RssFeedItem> feedItems = fetchRssFeed(techBlog);
log.info("[{}] RSS 수집 성공: {}개", techBlog.getCompanyName(), feedItems.size());
return feedItems.stream();
} catch (Exception e) {
log.error("[{}] RSS 수집 실패: {}", techBlog.getCompanyName(), e.getMessage());
return Stream.empty();
}
})
.toList();
Set<String> existingUrls = postRepository.findExistingUrls(
allItems.stream().map(RssFeedItem::url).toList()
);
items = allItems.stream()
.filter(item -> !existingUrls.contains(item.url()))
.toList();
log.info("RSS 수집 초기화 완료: 총 {}개 아이템", items.size());
}
// ...
}
개선된 코드
그런데 지금은 긁어오는 테크 블로그의 수가 적고,
Post 테이블의 개수가 600개라서 속도가 괜찮았던게 아닐까? 하는 의문이 들었습니다.
좀 더 조사해본 결과,
RSS 크롤링은 최신 피드만 가져오는만큼 개수가 크게 늘어나지 않으므로
Post 테이블의 크기가 문제가 될 수 있습니다.
하지만 이것도 IN절에 사용되는 Post 엔티티의 url 필드는 unique 속성이므로
인덱스를 탈 수 있어 속도 저하가 발생하지 않을 것이라고 생각했습니다.
3. 좀비 프로세스 처리 로직 개선
초기에 프로젝트를 기획할 때는 1시간마다 크롤링을 시도했었습니다.
하지만
이게 1시간마다 크롤링을 했을 때 Bot으로 처리되어 막히는 문제도 있고,
테크 블로그의 특성상 즉시성이 중요하지 않다고 판단하여 1일에 한 번으로 크롤링 스케쥴러 주기를 변경했습니다.
기존에 1시간마다 크롤링을 할 때
좀비 프로세스 정리를 위해 5분마다 스케쥴러가 돌았는데,
이제는 이 스케쥴러가 필요없다고 판단되어 크롤링 후 좀비 프로세스 처리하도록 개선했습니다.
'프로젝트 > Techfork' 카테고리의 다른 글
| [26/01/05] 오늘의 개발 일지 - Resilience4j 도입 (1) | 2026.01.06 |
|---|---|
| [26/01/03] 오늘의 개발 일지 - Spring Batch JobExecutionListener 도입 (0) | 2026.01.04 |
| [26/01/01] 오늘의 개발 일지 - OCI 서버 배포 완료 (0) | 2026.01.01 |
| [25/12/31] 오늘의 개발 일지 - Oracle Cloud Infrastructure 가입! (0) | 2026.01.01 |
| [25/12/30] 오늘의 개발 일지 - 테스트 컨테이너 스프링 빈으로 변경 (0) | 2025.12.31 |