공지사항 크롤러 리팩터링: 기존 코드와 코루틴 도입 코드의 차이점
Spring 기반의 공지사항 크롤링 및 이메일 발송 기능을 구현하면서, 기존의 스레드 기반 비동기 처리에서 Kotlin의 코루틴 기반 비동기 처리로 리팩터링을 진행했습니다. 이 글에서는 기존 코드와 새로운 코드의 차이점을 비교하고, 코루틴을 도입한 이유와 그로 인한 장점에 대해 자세히 설명하고자 합니다.
1. 기존 코드의 문제점
기존 코드에서는 Java의 CompletableFuture와 ExecutorService를 사용해 비동기 처리를 구현했습니다. 다음은 기존 코드에서의 주요 문제점입니다
1-1. 복잡한 비동기 처리
- 비동기 작업마다 CompletableFuture.runAsync를 사용하여 스레드를 명시적으로 관리해야 했습니다.
- 결과를 기다리기 위해 CompletableFuture.allOf(*futures.toTypedArray()).join()을 사용했는데, 이는 모든 작업이 완료될 때까지 스레드가 차단(blocking) 되었습니다.
1-2. 스레드 관리의 비효율성
- ExecutorService를 사용하여 스레드 풀을 구성했지만, 많은 작업이 동시에 발생하면 스레드 풀이 과도하게 사용될 가능성이 있었습니다.
- 특히, Thread.sleep을 사용한 지수 백오프(exponential backoff) 방식은 스레드를 불필요하게 차단하여 리소스 낭비를 초래했습니다.
1-3. 코드 가독성 저하
- 비동기 로직과 스레드 관리 코드가 혼재되어, 전체적인 코드 흐름을 이해하기 어려웠습니다.
- 예외 처리, 재시도 로직 등이 중첩되면서 코드가 장황해졌습니다.
2. 코루틴 기반으로 리팩터링한 코드
2-1. 코루틴의 도입
리팩터링한 코드에서는 Kotlin의 코루틴(Coroutines)을 활용해 비동기 작업을 간결하고 효율적으로 처리했습니다.
- 코루틴 스코프를 사용하여 비동기 작업을 명시적으로 관리.
- suspend 함수를 사용해 블로킹 없이 대기.
- async, launch와 같은 코루틴 빌더를 사용해 비동기 작업을 간단히 정의.
3. 리팩터링 전후의 코드 비교
3-1. 비동기 작업 실행
CompletableFuture.runAsync를 사용하여 비동기 작업 실행. | async와 launch로 코루틴 기반 비동기 작업 실행. |
스레드 풀(ExecutorService) 관리 필요. | Dispatchers.IO를 사용해 스레드 관리 자동화. |
CompletableFuture.allOf로 모든 작업이 끝날 때까지 대기. | jobs.joinAll()로 모든 코루틴 작업 완료 대기. |
기존 코드:
val futures = members.map { member ->
CompletableFuture.runAsync({
retrySendEmail(member, noticeInfo)
}, emailExecutor)
}
CompletableFuture.allOf(*futures.toTypedArray()).join()
리팩터링 후 코드:
val jobs = members.map { member ->
launch(emailDispatcher) {
retrySendEmail(member, noticeInfo)
}
}
jobs.joinAll()
3-2. 지수 백오프 대기
Thread.sleep 사용으로 스레드 차단 발생. | delay로 CPU 비차단 대기 구현. |
스레드 효율성이 낮고 리소스 낭비. | 비동기적으로 대기하여 스레드 효율성 극대화. |
기존 코드:
val waitTime = (1 shl attempt) * 1500L
Thread.sleep(waitTime)
리팩터링 후 코드:
val waitTime = (1 shl attempt) * 1500L
delay(waitTime)
3-3. 크롤링 작업 병렬화
순차적으로 URL 크롤링 작업 실행. | async를 사용해 URL 크롤링 병렬화. |
다수의 URL을 처리할 때 시간 소요 증가. | 비동기로 크롤링하여 시간 단축. |
기존 코드:
crawlNotices(page1Url, noticeList)
crawlNotices(page2Url, noticeList)
리팩터링 후 코드:
val urls = listOf(page1Url, page2Url)
val crawlJobs = urls.map { url -> async { crawlNotices(url) } }
crawlJobs.awaitAll().forEach { noticeList.addAll(it) }
4. 코루틴 도입 이유
4-1. 비동기 처리의 효율성
- 코루틴은 경량화된 스레드로 실행되므로, 수많은 비동기 작업을 처리해도 리소스 사용이 적습니다.
- delay를 사용해 대기 중에도 다른 코루틴이 실행될 수 있어 스레드 블로킹을 방지합니다.
4-2. 코드 가독성 개선
- 비동기 작업과 동기 작업을 순차적으로 작성할 수 있어 코드의 흐름이 명확합니다.
- 기존 코드에서 스레드 풀, CompletableFuture, Thread.sleep과 같은 복잡한 요소가 제거되었습니다.
4-3. 유지보수성 향상
- 코루틴 기반 코드는 구조화된 동시성(Structured Concurrency)을 지원하므로, 오류 발생 시 작업 단위를 명확히 관리할 수 있습니다.
- 예외 처리와 재시도 로직이 간단해졌습니다.
4-4. 성능 최적화
- 기존 스레드 기반 로직보다 적은 메모리와 CPU를 사용하며, 병렬 작업 성능이 향상되었습니다.
5. 코루틴 도입 후 얻은 이점
- 코드 간결성:
- 기존의 복잡한 스레드 관리 코드를 제거하고, 코루틴으로 간단히 비동기 작업을 정의.
- 예외 처리와 재시도 로직이 더 간결해짐.
- 자원 효율성:
- Thread.sleep 대신 delay를 사용하여 CPU를 블로킹하지 않음.
- 경량 코루틴으로 스레드 풀이 효율적으로 사용됨.
- 병렬 처리 최적화:
- 여러 페이지를 동시에 크롤링하여 실행 시간을 단축.
- 이메일 발송 작업도 병렬로 처리하여 높은 처리량을 지원.
- 가독성과 유지보수성:
- 순차적인 코드 스타일로 작성되어 로직 이해가 쉬움.
- 코루틴 스코프를 활용하여 명확한 작업 범위 관리 가능.
6. 결론
코루틴 기반으로 리팩터링하면서 코드의 효율성과 가독성을 동시에 확보할 수 있었습니다. 기존 코드의 스레드 차단 문제와 복잡한 비동기 로직을 제거하고, Kotlin 코루틴의 경량성과 비차단 대기 기능을 적극 활용하여 보다 효율적이고 유지보수 가능한 구조를 만들었습니다.
Kotlin 코루틴의 도입은 단순히 새로운 기술을 사용하는 것 이상의 가치를 제공합니다. 앞으로도 코루틴을 활용해 더 많은 성능 최적화와 코드 품질 개선을 이루고자 합니다. 🚀
리팩토링 전 전체 코드
더보기
더보기
package io.jyp.crawler.service;
import static io.jyp.crawler.util.HtmlParser.createNoticeInfoHtml;
import static io.jyp.crawler.util.HtmlParser.createNoticeRowHtml;
import io.jyp.crawler.entity.Member;
import io.jyp.crawler.repository.MemberRepository;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.List;
@Slf4j
@Service
public class NoticeCrawlerService {
private final ExecutorService emailExecutor = Executors.newFixedThreadPool(10); // 테스트 결과 적정개수 10개
private final MemberRepository memberRepository;
private final EmailService emailService;
// 생성자를 통한 의존성 주입
public NoticeCrawlerService(MemberRepository memberRepository, EmailService emailService) {
this.memberRepository = memberRepository;
this.emailService = emailService;
}
public void checkTodayNotice() {
try {
List<String> noticeList = new ArrayList<>();
// 1페이지와 2페이지에서 공지사항을 수집
String main_p1 = "https://www.hallym.ac.kr/hallym_univ/sub05/cP3/sCP1.html?nttId=0&bbsTyCode=BBST00&bbsAttrbCode=BBSA03&authFlag=N&pageIndex=1&searchType=0&searchWrd=";
String main_p2 = "https://www.hallym.ac.kr/hallym_univ/sub05/cP3/sCP1.html?nttId=0&bbsTyCode=BBST00&bbsAttrbCode=BBSA03&authFlag=N&pageIndex=2&searchType=0&searchWrd=";
crawlNotices(main_p1, noticeList);
crawlNotices(main_p2, noticeList);
// 수집한 공지사항이 있으면 이메일 발송
if (!noticeList.isEmpty()) {
String htmlContent = createNoticeInfoHtml(noticeList);
List<Member> members = memberRepository.findByNoticeFlagOrderByIdDesc(true);
notifyNoticeMembers(htmlContent, members);
log.info("당일 공지사항이 이메일로 발송되었습니다.");
} else {
log.info("오늘의 새로운 공지사항이 없습니다.");
}
} catch (IOException e) {
log.error("공지사항 페이지를 불러오는 중 오류 발생", e);
}
}
// URL을 사용하여 공지사항을 크롤링하고 당일 공지만 noticeList에 추가하는 메서드
private void crawlNotices(String url, List<String> noticeList) throws IOException {
Document doc = Jsoup.connect(url).get();
Elements notices = doc.select(".tbl-body .tbl-row");
for (Element notice : notices) {
String date = notice.select(".col-5 span").text().trim().replace("등록일 ", "");
if (isToday(date)) {
// if(isToday(date, LocalDate.of(2024, 10, 31))) { // Test
String title = notice.select(".col-2 a").text().trim();
String link = notice.select(".col-2 a").attr("href").trim();
String author = notice.select(".col-3 span").text().trim().replace("작성자 ", "");
String noticeInfo = createNoticeRowHtml(title, link, author);
noticeList.add(noticeInfo);
}
}
}
public boolean isToday(String date) {
return isToday(date, LocalDate.now());
}
public boolean isToday(String date, LocalDate currentDate) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
LocalDate noticeDate = LocalDate.parse(date, formatter);
return noticeDate.isEqual(currentDate);
}
public void notifyNoticeMembers(String noticeInfo, List<Member> members) {
List<CompletableFuture<Void>> futures = members.stream()
.map(member -> CompletableFuture.runAsync(() -> {
// 예외 처리를 모두 내부에서 수행하도록 수정
retrySendEmail(member, noticeInfo, 10); // 최대 10회 재시도
}, emailExecutor))
.toList();
// 모든 비동기 작업들이 완료될 때까지 대기
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
// 재시도 로직을 포함한 이메일 발송 메서드
private void retrySendEmail(Member member, String noticeInfo, int maxRetries) {
int attempt = 0;
while (attempt < maxRetries) {
try {
emailService.sendEmail(member, noticeInfo);
log.info("[이메일 발송 성공] {} {}", member.getEmail(), member.getId());
return; // 성공 시 종료
} catch (Exception e) {
attempt++;
log.warn("[이메일 발송 재시도] 시도 {}회 | 오류: {} | 이메일: {} | ID: {}", attempt, e.getMessage(), member.getEmail(), member.getId());
long waitTime = (long) Math.pow(2, attempt) * 15000; // 지수 백오프 방식 (30초, 60초, 120초, 240초, 480초)
try {
Thread.sleep(waitTime);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
log.error("재시도 중 인터럽트 발생 - {}", member.getEmail());
return;
}
}
}
log.error("이메일 발송 실패: {}", member.getEmail());
}
}
리팩토링 후 전체 코드
더보기
더보기
package io.jyp.crawler.service
import io.jyp.crawler.entity.Member
import io.jyp.crawler.repository.MemberRepository
import io.jyp.crawler.util.HtmlParser
import kotlinx.coroutines.*;
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.select.Elements
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.io.IOException
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Service
class NoticeCrawlerService(
private val memberRepository: MemberRepository,
private val emailService: EmailService
) {
private val log = LoggerFactory.getLogger(NoticeCrawlerService::class.java)
suspend fun checkTodayNotice() = coroutineScope {
try {
val noticeList = mutableListOf<String>()
// 1페이지와 2페이지 공지사항을 비동기로 크롤링
val urls = listOf(
"https://www.hallym.ac.kr/hallym_univ/sub05/cP3/sCP1.html?nttId=0&bbsTyCode=BBST00&bbsAttrbCode=BBSA03&authFlag=N&pageIndex=1&searchType=0&searchWrd=",
"https://www.hallym.ac.kr/hallym_univ/sub05/cP3/sCP1.html?nttId=0&bbsTyCode=BBST00&bbsAttrbCode=BBSA03&authFlag=N&pageIndex=2&searchType=0&searchWrd="
)
val crawlJobs = urls.map { url ->
async { crawlNotices(url) }
}
// 모든 크롤링 작업 완료 후 결과 병합
crawlJobs.awaitAll().forEach { noticeList.addAll(it) }
// 공지사항이 있으면 이메일 발송
if (noticeList.isNotEmpty()) {
val htmlContent = HtmlParser.createNoticeInfoHtml(noticeList)
val members = memberRepository.findByNoticeFlagOrderByIdDesc(true)
notifyNoticeMembers(htmlContent, members)
log.info("당일 공지사항이 이메일로 발송되었습니다.")
} else {
log.info("오늘의 새로운 공지사항이 없습니다.")
}
} catch (e: IOException) {
log.error("공지사항 페이지를 불러오는 중 오류 발생", e)
}
}
private suspend fun crawlNotices(url: String): List<String> {
val noticeList = mutableListOf<String>()
try {
val doc: Document = Jsoup.connect(url).get()
val notices: Elements = doc.select(".tbl-body .tbl-row")
for (notice in notices) {
val date = notice.select(".col-5 span").text().trim().replace("등록일 ", "")
if (isToday(date)) {
val title = notice.select(".col-2 a").text().trim()
val link = notice.select(".col-2 a").attr("href").trim()
val author = notice.select(".col-3 span").text().trim().replace("작성자 ", "")
val noticeInfo = HtmlParser.createNoticeRowHtml(title, link, author)
noticeList.add(noticeInfo)
}
}
} catch (e: Exception) {
log.error("크롤링 실패: URL = $url", e)
}
return noticeList
}
private fun isToday(date: String, currentDate: LocalDate = LocalDate.now()): Boolean {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
val noticeDate = LocalDate.parse(date, formatter)
return noticeDate.isEqual(currentDate)
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun notifyNoticeMembers(noticeInfo: String, members: List<Member>) = coroutineScope {
val emailDispatcher = Dispatchers.IO.limitedParallelism(8)
// 각 멤버의 이메일 발송을 병렬로 처리
val jobs = members.map { member ->
launch(emailDispatcher) {
retrySendEmail(member, noticeInfo)
}
}
// 모든 작업 완료 대기
jobs.joinAll()
}
private suspend fun retrySendEmail(member: Member, noticeInfo: String) {
var attempt = 0
while (attempt < 10) {
try {
emailService.sendEmail(member, noticeInfo)
log.info("[이메일 발송 성공] {} {}", member.email, member.id)
return
} catch (e: Exception) {
attempt++
log.warn(
"[이메일 발송 재시도] 시도 {}회 | 오류: {} | 이메일: {} | ID: {}",
attempt,
e.message,
member.email,
member.id
)
val waitTime = (1 shl attempt) * 1500L // 지수 백오프 방식
delay(waitTime) // 코루틴 친화적 대기
}
}
log.error("이메일 발송 실패: {}", member.email)
}
}
'notice-crawler' 카테고리의 다른 글
개발일기 15편 [숨은 참조(BCC)를 활용한 벌크 이메일 전송] (4) | 2024.11.25 |
---|---|
개발일기 13편 [지수 백오프 적용] (0) | 2024.11.20 |
개발일기 12편 [서비스 중 오류발생 및 해결방법] (0) | 2024.11.19 |
개발일기 11편 [서비스 개선] (1) | 2024.11.18 |
개발일기 10편 [UX 개선] (1) | 2024.11.17 |