-
Spring Batch로 성능 최적화하기Spring 2024. 7. 3. 13:28
[ 대충 목차 ]
- 문제 상황 설명
- Spring Batch 간단 설명
- 구현 과정 설명
- 구현 결과? 효과? 보여주기
- 결론
문제 상황
Scoop이라는 프로젝트에 매일 오전 6시와 12시에 네이버 뉴스 기사를 크롤링하는 로직이 있다. 이때, 크롤링한 뉴스를 요약까지 해야했고, 요약에는 GPT API를 사용했다. 또한, 이러한 GPT API는 FastAPI에 구현하여, 다른 서버에 존재한다.
간단한 흐름은 다음과 같다.
1. 네이버 뉴스의 각 카테고리의 뉴스들의 URL을 크롤링한다.
2. 크롤링한 뉴스 URL을 사용하여 뉴스 본문을 크롤링한다.
3. 크롤링한 뉴스 본문 내용 메시지를 publish 한다.
4. FastAPI 서버에서 해당 메시지를 consume하여 뉴스 내용을 요약한다.
5. 요약된 뉴스 내용 메시지를 publish 한다.
6. Spring 서버에서 메시지를 consume하여 요약된 뉴스 내용을 DB에 저장한다.
하지만 위 로직이 전부 처리되는 데 약 2분 30초 넘게 걸렸다. 그래서 비동기 처리의 필요성을 느껴 RabbitMQ를 적용하여 두 서버간 데이터를 주고받도록 했다. 하지만 크롤링 및 DB저장에도 18초가 소요되었다.
18초 동안 사용자의 요청을 처리하지 못한다는 것은 큰 문제라고 판단했다. 그래서 크롤링과 DB저장을 비동기처리하는 방법을 찾아봤고 결국 Spring Batch를 적용하기로 했다.
Spring Batch를 선택한 가장 큰 이유는 내 상황에 가장 적절했기 때문이다.
- 일정 주기(매일 오전6시와 12시)로 실행
- 실시간 처리가 어려운 대량의 데이터를 처리
→ 이런 작업을 하나의 애플리케이션에서 수행하면 성능 저하를 유발할 수 있으니 배치 애플리케이션을 구현
더불어, 트랜잭션으로 데이터 처리 중 실패한 작업은 롤백하여 데이터 일관성을 유지해준다.
또한, 배치 작업이 실패하면 재시도해주기 때문에 선택했다.
Spring Batch 적용하기
구현
이제 Spring Batch를 적용해보겠다.
1. 배치 관련 설정
1-1. build.gradle에 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-batch' testImplementation 'org.springframework.batch:spring-batch-test'
1-3. yml
batch: job: enabled: false
- enabled를 true로 설정하면 애플리케이션을 실행할때마다 배치도 실행된다.
* 참고로 메인 클래스에 @EnableBatchProcessing 를 추가해야 한다는 설명이 많은데, SpringBoot 3 부터는 사용하지 않는다.
-> 복붙: 스프링 부트의 자동설정을 밀어내고(back-off), 애플리케이션의 설정을 커스텀하는 용도로 사용된다.
2. ScrapJobConfig
간단한 플로우는 다음과 같다.
scrapJob
- crawlingNewsUrlStep
- newsUrlReader
- newsUrlWriter
- crawlingNewsContentStep
- uncrawledNewsContentReader
- summarizeContentProcessor
- newsContentWriter@Configuration @RequiredArgsConstructor public class ScrapJobConfig { @Value("${batch.chunk-size}") private int chunkSize; private final DataSource dataSource; private final NewsRepository newsRepository; private final ContentProducer contentProducer; @Bean public Job scrapJob(JobRepository jobRepository, Step crawlingNewsUrlStep, Step crawlingNewsContentStep) { return new JobBuilder("scrapJob", jobRepository) .incrementer(new RunIdIncrementer()) .start(crawlingNewsUrlStep) .next(crawlingNewsContentStep) .build(); } @Bean @JobScope public Step crawlingNewsUrlStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { return new StepBuilder("crawlingNewsUrlStep", jobRepository) .allowStartIfComplete(true) .<InitNewsDto, InitNewsDto>chunk(30, transactionManager) .reader(newsUrlReader()) .writer(newsUrlWriter()) .build(); } @Bean @StepScope public ItemReader<InitNewsDto> newsUrlReader() { return new NewsUrlReader(); } @Bean @StepScope public JdbcBatchItemWriter<InitNewsDto> newsUrlWriter() { return new JdbcBatchItemWriterBuilder<InitNewsDto>() .dataSource(dataSource) .sql("insert into news(url, category) values (:url, :newsCategory)") .beanMapped() .build(); } @Bean @JobScope public Step crawlingNewsContentStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) { return new StepBuilder("crawlingNewsContentStep", jobRepository) .allowStartIfComplete(true) .<PreSummarizedNewsDto, PreSummarizedNewsDto>chunk(chunkSize, transactionManager) .reader(uncrawledNewsContentReader()) .processor(summarizeContentProcessor()) .writer(newsContentWriter()) .build(); } @Bean @StepScope public ItemReader<PreSummarizedNewsDto> uncrawledNewsContentReader() { return new UncrawledNewsContentReader(newsRepository); } @Bean @StepScope public ItemProcessor<PreSummarizedNewsDto, PreSummarizedNewsDto> summarizeContentProcessor() { return dto -> { contentProducer.sendMessage(new ContentMessageDto(dto.getId(), dto.getContent())); return dto; }; } @Bean @StepScope public JdbcBatchItemWriter<PreSummarizedNewsDto> newsContentWriter() { return new JdbcBatchItemWriterBuilder<PreSummarizedNewsDto>() .dataSource(dataSource) .sql("update news set title = :title, content = :content, post_date = :postDate" + " where id = :id") .beanMapped() .build(); } }
- JdbcBatchItemWriter는 JDBC의 Batch 기능을 사용하여 한번에 Database로 전달하여 Database 내부에서 쿼리들이 실행되도록 한다. (jojoldu 블로그 참고)
3. NewsUrlReader
public class NewsUrlReader implements ItemReader<InitNewsDto> { @Value("${crawling.quantity}") private int crawlingQuantity; private final Iterator<NewsCategory> categories; private final Queue<InitNewsDto> initNews = new LinkedList<>(); public NewsUrlReader() { categories = Arrays.stream(NewsCategory.values()).collect(Collectors.toList()).iterator(); } @Override public InitNewsDto read() throws IOException { while (initNews.isEmpty() && categories.hasNext()) { NewsCategory category = categories.next(); initNews.addAll(scrapCategoryNews(category)); } return initNews.poll(); } private List<InitNewsDto> scrapCategoryNews(NewsCategory category) throws IOException { Document doc = Jsoup.connect(category.getCategoryUrl()).get(); Elements newsList = doc.select(".sa_list").select("li"); if (newsList.size() < crawlingQuantity) { return scrapNewsUrl(newsList.size(), newsList, category); } return scrapNewsUrl(crawlingQuantity, newsList, category); } private List<InitNewsDto> scrapNewsUrl(int quantity, Elements newsList, NewsCategory category) { List<InitNewsDto> urls = new ArrayList<>(); for (int i = 0; i < quantity; i++) { Element news = newsList.get(i); String url = Objects.requireNonNull(news.selectFirst(".sa_text_title")).attr("href"); urls.add(new InitNewsDto(category.getName(), url)); } return urls; } }
4. UncrawledNewsContentReader
public class UncrawledNewsContentReader implements ItemReader<PreSummarizedNewsDto> { private final NewsRepository newsRepository; private Iterator<News> uncrawledNewsContents; public UncrawledNewsContentReader(NewsRepository newsRepository) { this.newsRepository = newsRepository; uncrawledNewsContents = newsRepository.findAllByContentIsNull().iterator(); } @Override public PreSummarizedNewsDto read() throws IOException { if (!hasNextUncrawledNews()) { return null; } News news = uncrawledNewsContents.next(); Document doc = Jsoup.connect(news.getUrl()).get(); return getNewsContent(news, doc); } private boolean hasNextUncrawledNews() { if (!uncrawledNewsContents.hasNext()) { uncrawledNewsContents = newsRepository.findAllByContentIsNull().iterator(); } return uncrawledNewsContents.hasNext(); } private PreSummarizedNewsDto getNewsContent(News news, Document doc) { return PreSummarizedNewsDto.builder() .id(news.getId()) .title(scrapTitle(doc)) .content(scrapContent(doc)) .postDate(scrapPostDate(doc)) .build(); } private String scrapTitle(final Document doc) { Element titleElement = doc.selectFirst("#ct > div.media_end_head.go_trans > div.media_end_head_title > h2"); if (titleElement == null) { titleElement = doc.selectFirst("#content > div.end_ct > div > h2"); } if (titleElement != null) { return titleElement.text(); } return null; } private String scrapContent(final Document doc) { Elements contentElements = doc.select("article#dic_area"); if (contentElements.isEmpty()) { contentElements = doc.select("#articeBody"); } return contentElements.outerHtml().replaceAll("\\<[^>]*>|\\n", ""); } private String scrapPostDate(final Document doc) { Element dateElement = doc.selectFirst("div#ct> div.media_end_head.go_trans > div.media_end_head_info.nv_notrans > div.media_end_head_info_datestamp > div > span"); if (dateElement != null) { return dateElement.attr("data-date-time"); } else { Element altDateElement = doc.selectFirst("#content > div.end_ct > div > div.article_info > span > em"); if (altDateElement != null) { return altDateElement.text(); } } return null; } }
Reader의 로직이 길어서 위와 같이 분리하여 따로 클래스로 만들어서 ItemReader를 implements했다.나머지 코드는 깃허브를 참고해주세용
스프링 5가 되면서 Spring Batch에 변화가 많았다. 이 글을 보는 분들은 막힘없이 구현되길... 🙏🏽
결과
1. 비동기 적용으로 대용량 데이터 처리 중에도 사용자 요청 처리 가능
2. 18초 -> 15초로 단축되는 효과
시간 단축은 크게 기대하지 않은 부분인데 이러한 효과를 얻어 땡잡았당~
여담
배치는 보통 서버를 따로 둔다고 한다. 이후에 MSA를 적용하면 배치 부분은 서버를 따로 분리해 봐야겠다.
[참고] (많아요 ㅎㅎ..)
https://www.youtube.com/watch?v=1xJU8HfBREY
https://jojoldu.tistory.com/325
https://github.com/AcornPublishing/definitive-spring-batch
https://joyfulviper.tistory.com/43
https://europani.github.io/spring/2023/06/26/052-spring-batch-version5.html
'Spring' 카테고리의 다른 글
Spring Batch로 대용량 데이터 처리 비동기화하기 (1) 2024.08.05 [트러블 슈팅] @InjectMocks @Mock vs. @Autowired @MockBean (0) 2024.06.17 Github Actions + Git Submodule로 application.yml 관리하기 (0) 2024.04.10 [Spring Security] kakao 소셜 로그인 (0) 2023.11.21 Event Publisher / Event Listener (0) 2023.07.04