PLAYDATA 주간회고

플레이데이터 풀스택 백엔드 9기 7월 4주차 회고

Berry-mas 2025. 7. 30. 23:11

플레이데이터 풀스택 백엔드 9기 19주차 주간회고 및 학습기록 (열아홉번째 기록)


 

최종프로젝트를 시작한지 벌써 2주가 지났다. 개발보다 기획이 더 어렵다는 것을 많이 느낀다. 그렇지만 꽤나 재밌다. 당연하게 여겼던 각종 서비스들에 대한 고민을 하고, 어떻게 하면 사용자들을 만족시킬 수 있을까 치열하게 논의하는 과정이 재미있다. 언젠가 프로젝트 매니저가 되어 기획하는 일을 하고 싶다는 생각이 들 정도이다. (기획이 얼마나 어렵고 힘든 일인지 제대로 느끼지 못해서 하는 말일지도 모른다)

 

이번 주는 자바로 크롤링을 진행해봤다. 생각보다 자바로도 할 만했다. 강사님께서 알려주신 방식으로 코드를 짜보니 크게 어렵지는 않았다. 파이썬에서 BeautifulSoup과 Selenium으로 크롤링을 하는 것처럼 자바에서는 JSoup과 Selenium으로 크롤링을 진행한다. 

 

크롤링을 보다 효율적으로 수행하기 위해 도입한 방식들을 기록하고자 한다.

 

1. 배치 처리 (Batch Processing)

여러 작업을 한 번에 묶어서 처리하는 방법

 

배치처리란 여러 작업을 쪼개서 순차적으로 하는 것이다. 

for (String link : allLinks) {
   crawl(link); 
}

 

위와 같은 방식에서 만약 allLinks의 데이터 개수가 1000개라면 반복문은 1000번의 작업이 한 번에 처리되어야 한다.

그러면 1000개 링크의 뉴스들을 모두 가져오고, 1000개의 DOM을 파싱하고, 1000개의 객체를 메모리에 동시에 올려야 한다. 이렇게 되면 메모리/CPU에 영향을 줄 수 있고 서버에도 무리가 갈 수 있다. 예외 발생 시에도 전체 작업을 중단하지 않고 해당 배치만 재시도하면 되기 때문에 훨씬 안정적이다. 따라서 아래와 같은 방식으로 진행했다.

private static final int BATCH_SIZE = 8;

private static List<NewsDetail> processNewsLinksInBatches(List<NewsLinkInfo> newsLinks) {
        List<NewsDetail> allResults = Collections.synchronizedList(new ArrayList<>());
        int totalBatches = (int) Math.ceil((double) newsLinks.size() / BATCH_SIZE);
        
        System.out.println("배치 크기: " + BATCH_SIZE + ", 총 배치 수: " + totalBatches);

        for (int i = 0; i < totalBatches; i++) {
            int startIndex = i * BATCH_SIZE;
            int endIndex = Math.min(startIndex + BATCH_SIZE, newsLinks.size());
            List<NewsLinkInfo> batch = newsLinks.subList(startIndex, endIndex);
            
            final int batchNumber = i + 1;
            System.out.println("배치 " + batchNumber + " 시작 (링크 " + (startIndex + 1) + "-" + endIndex + ")");
            System.out.println("현재 동시 요청 수: " + currentConcurrency.get());
            
            // 스마트 배치 처리
            processBatchSmart(batch, allResults, batchNumber);
            
            // 성공률에 따른 동시성 조절
            adjustConcurrency();
            
            System.out.println("배치 " + batchNumber + " 완료");
            
            // 배치 간 짧은 대기 (서버 부하 방지)
            if (i < totalBatches - 1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        }

 

이는 밥 한 그릇을 먹을 때 숟가락질 해서 삼켜야 다음 숟가락을 뜨는 것과 같다. 


2. 병렬 처리 (Parallel Processing)

여러 작업을 동시에 처리하는 방법

 

만약 1000개의 뉴스를 처리해야 한다면 이를 모두 크롤링하는 데 엄청나게 많은 시간이 필요할 것이다. 하나의 뉴스를 크롤링하는 데 3초가 걸린다면 1000개의 뉴스를 다 처리하는 데만 50분이 걸린다. 뉴스를 크롤링해서 띄우는 사이트를 만드는 입장에서 1000개 뉴스 크롤링에 50분을 투자하는 건 말이 안된다.

따라서 병렬처리를 통해 한번에 여러 뉴스를 동시에 처리할 필요가 있다. 

private static void processBatchSmart(List<NewsLinkInfo> batch, List<NewsDetail> allResults, int batchNumber) {
        System.out.println("배치 " + batchNumber + ": " + batch.size() + "개 링크 스마트 병렬 처리 시작");
        
        // 현재 동시성 수만큼 병렬 처리
        int concurrency = currentConcurrency.get();
        List<CompletableFuture<NewsDetail>> futures = new ArrayList<>();
        
        for (int i = 0; i < batch.size(); i++) {
            NewsLinkInfo linkInfo = batch.get(i);
            final int linkIndex = i + 1;
            
            CompletableFuture<NewsDetail> future = CompletableFuture.supplyAsync(() -> {
                System.out.println("배치 " + batchNumber + " - 링크 " + linkIndex + "/" + batch.size() + " 크롤링 중: " + 
                                 linkInfo.title.substring(0, Math.min(30, linkInfo.title.length())) + "...");
                return crawlNewsDetailWithRetry(linkInfo);
            }, executorService);
            
            futures.add(future);
            
            // 동시성 제한: 현재 설정된 동시성 수만큼만 동시 실행
            if (futures.size() >= concurrency) {
                waitForFutures(futures, allResults, batchNumber); // 실행 중인 작업 다 끝날 때까지 기다림
                futures.clear(); // 다시 다음 작업을 받기 위한 준비
            }
        }
        
        // 남은 futures 처리
        if (!futures.isEmpty()) {
            waitForFutures(futures, allResults, batchNumber);
        }
        
        System.out.println("배치 " + batchNumber + " 완료: 성공 " + successCount.get() + "개, 실패 " + failCount.get() + "개");
    }

 

자바에서는 위 코드와 같이 병렬 처리를 위해 CompletableFuture과 ExecutorService를 사용한다.

  • CompletableFuture
    : 비동기적으로 작업을 실행할 수 있는 객체
  • ExecutorService
    : 병렬 작업을 수행할 스레드를 미리 만들어 풀(pool)로 관리

그렇다고 한 번에 50개씩 병렬 처리를 한다면 서버에 막히거나, 메모리가 터지는 등 크롤링에 실패하게 된다. 따라서 동시성을 제한한다. 이러한 방식으로 많은 수의 기사들을 빠르게 크롤링할 수 있다.


3. 연결 풀링 (Connection Pooling)

쓰레드를 재활용해서 무한정 새로 만드는 걸 방지하는 기술

 

크롤링을 하다보면 서버에 기본 수백 번 요청하게 된다. 그때마다 새로운 연결을 만들면 너무 비효율적이다. 크롤링을 효율적으로 진행하기 위해 미리 만든 쓰레드풀을 돌려쓰는 것이 연결 풀링이다. 이를 통해 메모리 과부하를 막고 성능을 안정화시킬 수 있다. 아래는 예시코드이다.

private static ExecutorService executorService;

public static void main(String[] args) {
    try {
        executorService = Executors.newFixedThreadPool(MAX_CONCURRENT_REQUESTS);
        System.out.println("스마트 병렬 크롤러 시작 - 초기 동시 요청: " + INITIAL_CONCURRENT_REQUESTS);
        processCsvFilesAndCrawlDetails();
    } finally {
        if (executorService != null) {
            executorService.shutdown();
            try {
                if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
                    executorService.shutdownNow();
                }
            } catch (InterruptedException e) {
                executorService.shutdownNow();
            }
        }
    }
}
  • ExecutorService : 스레드 풀(=연결 풀)을 만들고 관리하는 객체
  • newFixedThreadPool(n)을 호출하면
    • 미리 n개의 작업자(스레드)를 만들어서
    • 그걸 재사용하면서 여러 병렬 작업을 처리
CompletableFuture<NewsDetail> future = CompletableFuture.supplyAsync(() -> {
                System.out.println("배치 " + batchNumber + " - 링크 " + linkIndex + "/" + batch.size() + " 크롤링 중: " + 
                                 linkInfo.title.substring(0, Math.min(30, linkInfo.title.length())) + "...");
                return crawlNewsDetailWithRetry(linkInfo);
            }, executorService);

 

위 병렬처리 부분의 코드에서 볼 수 있듯이, 각 뉴스 링크를 크롤링할 때 executorService 안에 있는 스레드 중 하나를 할당받아 작업을 수행하도록 하였다. (비동기 병렬 작업)

또한 자바의 ThreadPoolExecutor는 내부적으로 작업이 끝난 쓰레드를 다시 풀에 넣어 재사용한다고 한다.

executorService.shutdown();
if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) {
    executorService.shutdownNow();
}

 

물론 마지막에 자원을 깔끔하게 반납하는 것도 잊으면 안된다.