본문 바로가기

프로젝트/Techfork

[25/01/02] 오늘의 개발 일지 - RSS 크롤링 성능 및 안정성 개선

오늘은 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쪽은 단일 스레드, ProcessorWriter에서는 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분마다 스케쥴러가 돌았는데,

 

이제는 이 스케쥴러가 필요없다고 판단되어 크롤링 후 좀비 프로세스 처리하도록 개선했습니다.