BackEnd/쿠링

[쿠링] Multi thread를 활용한 공지 조회속도 개선 (feat 동기화)

샤아이인 2023. 4. 28.

해당 글은 개인 프로젝트를 개선해 나가면서 내용을 정리하는 글입니다.

 

1. 도입 배경

쿠링에서는 학교의 전체 공지를 주기적으로 scrap해와야 한다.

하지만 이를 동기 처리 하다 보니 특정 학과의 scrap 속도가 늦어지면, 자연스럽게 전체 작업 속도가 늦어지는 문제가 발생하였다.

 

문제는 학교의 API를 사용하는 방식이 아니라, 직접 scrap을 통해 정보를 긁어오는 형식이기 때문에 주기적으로 신규 공지를 확인해줘야 하는데, 단일 코어상의 싱글 스레드로 처리하기에는 작업이 너무나 오래 걸렸다.

 

내가 생각한 공지 확인 간격보다, 한번 업데이트하는 시간이 더 길어지는 문제가 발생한 것이다!

나는 이를 해결하기 위해 Multi Thread를 도입한 비동기 처리를 해야겠다 생각하게 되었다!

 

2. 사전에 예상되는 문제지점들

2-1) Scheduler 스레드가 백그라운드에서 무한 대기하는것이 자원 낭비일까?

이번에 구현할 공지 스크랩 과정은 10분 주기로 발생하게 되는데, 이를 위하여 @Scheduler 를 사용하게 되었다.

 

근데 이게 생각해보니 하나의 Thread는 지속적으로 낭비되고 있는것은 아닐까? 라는 생각이 들었다.

하루 24시간에서 하루에 스케쥴이 시작되는 횟수는 60번 정도니... 어쩌면 자원 낭비라는 생각이 잠시 들었다.

차선책인 1분에 한 번씩 cron job을 실행하는 방식도 결국 스레드 1개는 무한히 백그라운드에서 대기를 한다.

 

하지만 이 문제는 결국 어떤 자원이든 최소 1개는 지속적으로 감시를 해야만 하는 상황이기에 어쩔수 없는 사용이라 결론을 내리게 되었다.

특정 예약된 시간에만 작업을 수행하는 방법이 오히려 불필요한 스레드를 낭비하지 않아 리소스를 아끼는 방법이라 생각했다.

 

2-2) 몇개의 스레드가 백그라운드에서 대기하는 것인가?

 

SpringBoot 기본 TaskSchedulingPropertiesd에서 스레드풀 사이즈는 1로 고정되어 있다.

즉, 하나의 스레드에서 여러 작업이 대기한다.

 

작업을 5개 예약하고, 예약 전후로 스레드 사이즈를 확인했을 때 정확히 1개 차이가 났다. (5 → 6)

 

2-3) 동기화 문제

Multi threading을 사용하면 빠질 수 없는 주제, 바로 동기화이다!

이렇게 여러 Thread상에서 하나의 DB에 insert 쿼리를 날리는것이 안전할까? 삽입하는 데이터 간의 정합성에 문제가 발생하지는 않을까?

 

찾아본 바에 따르면, MySQL의 경우 자동적으로 멀티 insert에 safe하다고 한다.

In MySQL, multi-threaded inserts can be safe if implemented and managed properly.
MySQL supports concurrent access and multiple threads executing queries simultaneously.
However, there are certain considerations and best practices to ensure the safety and integrity of data when performing multi-threaded inserts

원래는 비관적 Lock을 통해서 Insert를 해야 하나 생각했는데, Lock을 사용하지 않아도 되어서 정말 다행이다.

어찌 됐든 Lock을 사용하면 동시성 면에서 떨어진다 생각되기 때문이다!!

 

비동기 관련 테스트를 작성해 본 결과 정합성 또한 잘 지켜지고 있음을 확인하였다!!

 

3. 도입 과정

3 - 1) 적정 Thread 수는?

적정 스레드의 수를 어떻게 정해야 할까? EC2 t2.micro 같은 경우 core수가 1개뿐이다.

이런 상황에서 적정한 Thread의 수를 어떻게 확인할까?

 

물론 Thread의 수를 하나하나 변경하면서 직접 확인해 보는 방법도 있겠지만...

나 같은 경우 Brian Goetz의 유명한 저서인 "Java Concurrency in Practice"에 나오는 공식을 활용하였다.

 

여기서 대기 시간은 IO Bound 작업뿐만 아니라 스레드가 WAITING 혹은 TIMED_WAITED 상태로 대기 중인 시간을 뜻한다.

서비스 시간은 대기 시간을 제외한 실제로 작업이 동작 중인 시간을 뜻한다. 

 

CPU Bound 작업의 경우 대기시간이 0에 가깝기 때문에 적정 스레드 개수가 사용 가능한 코어 개수에 수렴하게 된다.

반면 IO Bound 작업의 경우 대기시간이 길다면 스레드 풀의 크기를 키워야 하고, 대기시간이 짧다면 스레드 풀의 크기를 줄여야 한다. 

 

우리 Kuring의 scrap 작업은 IO Bound 작업에 가깝다고 생각된다.

초당 연산할 양이 많은, 즉 실시간 응답이 필요한 작업이 아니라, scrap을 요청한 후 응답받기까지 기다리다, 응답을 받으면 처리하여 저장(IO)하는 작업이 대부분이다.

따라서 IO Bound Job에 해당된다.

 

하지만 이 공식은 너무나 단순화되었다!!

 

실제로는 HTTP 커넥션 풀 뿐만 아니라 JDBC 커넥션 풀, JMS로 부터의 요청 등 더 많은 요소들을 고려해야 한다. 

 

따라서 여러 클래스에서 각자의 스레드 풀, 즉 여러 개의 스레드 풀이 존재한다면 각자의 워크로드에 따라 이 수치를 조정해야 한다.

이 경우 CPU 목표 사용률을 공식에 추가해 줄 수 있다.

 

즉 다음과 같다

 

그럼 Kuring을 다시 생각해 보자.

1) 사용 가능한 코어 개수  = 1

2) 목표 CPU 사용률 = 50%

3) W/C =  50ms/5ms

로 계산하면 적정 스레드 수는 1 * (1/2) * (1 + 10) = 5.5이니 대략 6개의 Thread를 사용하면 될 것 같다! 

 

3 - 2) 적용하기

나는 ThreadPoolTaskExecutor를 사용할 것이다. 사용 방법은 예전에 정리해 둔 다음 나의 글을 참고해 달라~

 

 

[Spring] ThreadPoolTaskExecutor

1. ThreadPoolTaskExecutor 스레드 풀을 사용하는 Executor java.util.concurrent.Executor를 Spring에서 구현한 것 이다. org.springframework.scheduling.concurrent 패키지에서 제공 주로 spring에서 비동기처리를 위해 사용 스

blogshine.tistory.com

 

설정은 다음과 같이 사용 중이다. 각각의 의미는 바로 위 링크 글에서 정리해 두었다!

@Bean
public ThreadPoolTaskExecutor departmentNoticeUpdaterThreadTaskExecutor() {
    ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
    taskExecutor.setKeepAliveSeconds(10);
    taskExecutor.setCorePoolSize(6); // 위에서 계산한 방식대로 pool 사이즈는 6으로
    taskExecutor.setMaxPoolSize(10);
    taskExecutor.setQueueCapacity(100);
    taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
    taskExecutor.setThreadNamePrefix("Depart-thread-");
    taskExecutor.initialize();
    return taskExecutor;
}

 

이를 사용하여 병렬처리 하는 코드는 다음과 같다.

@Slf4j
@Service
@RequiredArgsConstructor
public class DepartmentNoticeUpdater implements Updater {

    // 일부 생략

    @Override
    @Scheduled(cron = "0 10/20 8-18 * * *", zone = "Asia/Seoul") // 학교 공지는 오전 8:10 ~ 오후 6:55분 사이에 20분마다 업데이트 된다.
    public void update() {
        log.info("******** 학과별 최신 공지 업데이트 시작 ********");
        startTime = System.currentTimeMillis();

        for (DeptInfo deptInfo : deptInfoList) {
            CompletableFuture
                    .supplyAsync(() -> updateDepartmentAsync(deptInfo, DeptInfo::scrapLatestPageHtml), departmentNoticeUpdaterThreadTaskExecutor)
                    .thenApply(scrapResults -> compareLatestAndUpdateDB(scrapResults, deptInfo.getDeptName()))
                    .thenAccept(this::sendNotificationByFcm);
        }
    }
}

CompletableFuture를 사용하여 Async 처리를 해주고 있다.

supplyAsync를 사용할 때 우리가 Bean으로 등록시킨 departmentNoticeUpdaterThreadTaskExecutor를 인자로 넘겨주었다!

이렇게 하면 해당 설정을 사용하면서 비동기 처리를 진행시켜 준다!

 

그럼 동기화 문제는??? 이는 성능 개선이 어느정도 되었는지 확인해 본 후에 알아보자!

 

3 - 3) 성능 개선

3 - 3 - 1) 단일 페이지 기준

우선 단일 페이지 기준은 다음과 같다. 단일페이지는 모든 학과의 공지 1, 2페이지만 갱신하는 작업이다

무려 4.39배나 개선이 되었다!!!!

 

3 - 3 - 2) 전체 페이지 기준

여기서 전체 페이지란, 60개의 학과의 모든 공지를 scarp해와서 기존 DB와 비교하고, 신규 공지를 저장하고, 삭제된 공지는 삭제하는 작업이다.

무려 4.70배나 개선이 되었다!!!!

직전의 단일 페이지에서의 성능 개선과 비슷한 수준의 개선량을 보여주었다!

 

4. 후기

이번 개선 과정을 통해서 병렬처리의 힘을 몸소 경험하게 되었다! (멀티스레드 만세!!)

더 나아가 AWS에서 core가 2개, 3개인 인스턴스를 사용할 수 있다면 성능이 얼마나 더 개선될지 궁금하다. 코어가 2개만 돼도 2의 배수인 8개 정도의 Thread pool 사이즈를 사용할 수 있지 않을까?? 나중에 직접 사용할 기회가 된다면 적용해 봐야겠다

 

또한 Spring과 ThreadPoolTaskExecutor 덕분에 편리하게 추상화된 level에서 병렬처리를 할 수 있다는 점이 매우 즐거웠다.

이러한 도움 없이 직접 시스템 프로그래밍을 할 때처럼 직접 처리해야 했다면 많이 어려운 작업이었을 것이다.

 

추가로 이러한 개선을 하고 나니, 학교 수업의 "병렬프로그래밍" 과목을 듣고 싶어졌다. 다음 학기 때 신청해서 들어봐야겠다!

댓글