캐시와 배치, 비동기를 활용해 알림 생성 로직을 667% 개선한 경험 (여러개의 CompletableFuture 결과 조합하기) 🔥

초록·2023년 11월 23일
1
post-thumbnail

요약

날씨 정보를 기반으로 하는 알림을 더 빠르게 생성하기 위해 다양한 부분으로 고민했습니다. 일단 배치를 통해 DB와 캐시에 미리 날씨 데이터를 불러놓아 알림 생성 시 날씨API 요청을 할 필요없게 만들었습니다. 그리고 알림메시지를 캐싱해놓음으로써 중복된 메시지 생성을 피해 성능을 237% 개선했습니다. 그리고 DB 조회로직이 포함된 알림 생성로직을 CompletableFuture를 이용해 비동기적으로 진행해 성능을 추가적으로 127% 개선해, 그 전에 비해 총 667% 개선되었습니다.

프로젝트 간략 소개

'날씨 알리미'는 "오늘 오후 5시에 비가오니 우산을 챙기세요"와 같이 날씨 정보를 기반으로 알림을 주도록 하는 서비스입니다. 날씨 정보는 기상청 공공 API를 활용합니다.

어떻게하면 알림을 더 빠르게 보낼까?

알림조건, 지역 등 사용자들이 설정한 조건이 다양한데, 각 사용자에게 알림을 보낼 때마다 동기적으로 공공 API에 요청해서 알림을 가공해낸다면 RTT로 인해 알림 전송이 늦어지고 불필요한 네트워크 비용이 발생할거라고 생각했습니다. 어떻게하면 더 빠르게 개선할 수 있을지를 여러 포인트에서 고민했습니다.

데이터를 미리 저장해 놓고 알림 생성할 때 써먹자

일단 알림생성시마다 API를 호출하는 것 대신, 새벽에 미리 전국의 날씨정보를 일괄적으로 불러오는 배치작업을 해서 DB와 캐시에 미리 저장하기로 했습니다(배치 코드). 아래 그림에서 파란색부분에 해당합니다.

그리고 사용자가 설정해놓은 알림 시간에 맞추어 해당 사용자에게 필요한 알림을 생성해서 전송합니다(배치 코드). 아래 그림에서 노란색부분에 해당합니다. 아래 그림을 보시면 알림내용을 생성하는 부분이 있는데, 이 부분은 아래에서 자세히 설명해드리겠습니다.

각 배치코드는 스프링배치로 작성했습니다.

image

추상 팩토리 메서드로 체계적이면서 확장이 쉬운 알림생성 클래스들

이 서비스는 기상청 API 정보를 기반으로 등 여러 알림을 제공하는데, 각 알림 메시지를 생성하는 부분은 팩토리 인터페이스 를 구현한 객체들을 리스트로 주입받아 일괄적으로 generate 메서드를 호출함으로써 다형성의 이점을 누렸습니다. 각 Generator의 내부에는 또 세부적인 알림메시지를 생성하는 Generator들을 호출하도록 되어있습니다.

이렇게 만들어진 다양한 알림 메시지를 하나로 합쳐 한 명의 사용자에게 보내지는 프로세스입니다. 현재는 날씨API를 이용한 날씨 알림만 제공하지만, 추후에 날씨 API 뿐 아니라 미세먼지 API 등 다른 API를 활용하게 될 때도 코드가 쉽게 확장할 수 있도록 했습니다.

아래 그림에서 보면, NotiGen이 user정보를 넘겨서 알림생성을 요청하면, NotiStringGen을 상속하는 [Weather/Dust]NotiStringGen이 DB에서 각자 필요한 정보를 불러와 알림생성을 시작합니다. 이 안에서 또 알림을 여러가지로 쪼개서 저마다의 Generator에게 알림메시지 생성을 요청합니다. WeatherNotiStringGen의 경우 더운날 알림, 추운날 알림, 비 알림을 만드는 Generator에게 알림생성을 요청합니다.

image

🔗 더운 날 알림 생성 코드
🔗 추운 날 알림 생성 코드
🔗 비 알림 알림 생성 코드

비동기처리와 캐싱으로 성능 향상

알림 메시지를 생성하는 과정에서 캐싱을 활용해 성능을 237% 개선했고, 비동기처리를 통해 추가적으로 성능을 127% 개선해, 그 전에 비해 총 667% 개선했습니다. 아래에서 해당 내용에 대해 자세히 설명해드리겠습니다.
image

전에 생성했던 알림은 새로 생성하지 말고 캐시에서 가져오자

같은 알림 조건을 설정한 사용자에겐 같은 알림 메시지를 생성하기 때문에, 생성된 알림 메시지를 캐시함으로써 중복되는 알림메시지를 생성하지 않도록 했습니다. 덕분에 알림 생성로직의 성능이 237% 개선되었습니다.

public String generate(final User user, final WeatherInfoList weatherInfoList) {

    // 알림이 꺼져있다면 빈 문자열 반환
    if(!user.getColdNotiSetting().isOn())
        return "";

    // 캐시 조회 위한 키 (날짜 + 조건). 같은 날짜와 조건을 가진 사용자라면 캐시에 있는 알림 메시지를 그대로 가져올 수 있음
    final String key = WeatherApiTimeConverter.serializeToDate(LocalDateTime.now()) + user.getColdNotiSetting().getConditionCelcius();

	// 캐시에 있다면 캐시를 사용하고, 아니라면 날씨정보와 사용자정보를 기반으로 새로 만든다.
    return cacheModule.getCacheOrLoad(CacheEnv.WEATHER_MSG_COLD
            , key
            , (_key) -> generateForReal(user, weatherInfoList));
}

CompletableFuture로 DB조회를 비동기적으로 진행

각 메시지 조각(추운 날, 더운 날, 비)을 생성하는 과정에서 캐시나 DB와 통신하는 부분이 있는데, 이 부분에서 싱글스레드로 진행하면 DB로부터의 응답을 대기하는 동안 지연이 발생하기 때문에 동기적으로 처리하면 비효율적이라 생각했습니다. 각 메시지를 생성하는 부분은 CompletableFuture를 통해 비동기적으로 진행해 성능을 추가적으로 127% 개선했습니다.

CompletableFuture를 사용하면 ForkJoinPool의 commonPool에 의해 여러 스레드가 병렬적으로 일을 처리하게됩니다. 병렬적으로 처리한다고 무조건 빨라지진 않지만, 데이터베이스같은 I/O작업은 RTT 자체가 길어서 비동기적으로 진행하면 아래 이미지처럼 병렬적으로 진행되어 많은 시간을 단축할 수 있습니다.

🔗 날씨 알림 생성기

private String generateMessageByRegion(final User user, final Region region){

        // 해당 지역의 날씨정보들을 불러옴
        final WeatherInfoList weatherInfoList = weatherInfoService.getWeatherInfoListToday(region.getWeatherRegion());

        // 여러가지의 날씨 메시지 생성을 비동기적으로 호출.
        // 결과를 담은 future들을 리스트에 삽입.
        final List<CompletableFuture<String>> msgFutures = msgGenerators.stream().map( // 생성기 인터페이스 구현체들을 List로 주입받아
                generator -> CompletableFuture.supplyAsync(()
                                -> generator.generate(user, weatherInfoList)) // 추상 팩토리 메서드로 각각 다른 문자열 생성
                        .orTimeout(60L, TimeUnit.SECONDS) // Time제한
                        .exceptionally(this::handleException)
        ).collect(Collectors.toUnmodifiableList());

        // future 리스트에 담긴 값들을 읽어 문자열로 조합
        final String msgString = futureHandler.joinFutureList(msgFutures).stream()
                .filter(item -> !item.isEmpty())
                .collect(Collectors.joining("\n\n"));

        return msgString.isBlank() ? "" : generateHeader(region) + msgString;
    }

위 코드에서 generator들을 이용해 알림 문자열을 생성하는 CompletableFuture 리스트를 뽑아낸뒤, 그걸 futureHandler라는 객체가 join하고 있습니다. 이는 제가 만든 클래스로, 요청한 내용들이 전부 완료되면 해당 내용들을 한 데 묶어서 처리를 하는 클래스입니다.

@Component
public class FutureHandler {

    public <T> List<T> joinFutureList(List<CompletableFuture<T>> futureList){
        //future 리스트를 순회하며 join
        return futureList.stream().map(CompletableFuture::join).collect(Collectors.toUnmodifiableList());
    }
}

배치도 비동기로

날씨 API를 호출하는 배치 도 싱글스레드로 진행하면 API RTT로 인한 대기 지연이 발생하기 때문에, AsyncItemProcessor/Writer를 통해 비동기적으로 실행해서 소요시간을 27% 수준으로 줄였습니다.

마무리

날씨 알림 전송이라는 로직을 어떻게 하면 더 튜닝할 수 있을까를 고민하다가 배치와 캐시, 비동기, 추상 팩토리 메서드 등의 방법을 적용해보았는데요, 이 글이 여러분들의 코드를 튜닝할 힌트가 되었으면 좋겠습니다.

감사합니다!

profile
몰입하고 성장하는 삶을 동경합니다

0개의 댓글