CompletableFuture [비동기 리팩토링] (4/8~9)

세젤게으름뱅이·2025년 4월 9일

Spring Webflux

목록 보기
8/16
post-thumbnail

현재 블로킹하게 작동 중인 코드를 비동기적으로 변경해보려 한다.

사전환경

  • Entity는 임시 디비 개념으로 사용
  • Domain에 실제 객체 저장
  • Service에서 반환받은 Entity들을 Domain으로 변환
  • Repository 내부에서 조회시, 작업시간을 Thread sleep 1000ms씩 강제

Repository

User (user 정보 + image, article, followCount)

  • ID 조회 후 여부에 따라 UserEntity를 Optional로 return
  • 후에 User ID 및 정보 + Image, Article, Follow Count

Image

  • 조회된 유저ID를 통해 이미지 존재 여부에 따라 ImageEntity를 Optional로 return

Article

  • 조회된 유저ID를 통해 일치하는 게시글 ArticleEntity를 List로 return

Follow

  • 조회된 유저ID를 통해 팔로워수를 return, 없을시 default 0

Service

  • 반환받은 Entity들을 Domain 객체에 맞게 변환하여, User 객체로 반환

UserBlockingService

@RequiredArgsConstructor
public class UserBlockingService {
    private final UserRepository userRepository;
    private final ArticleRepository articleRepository;
    private final ImageRepository imageRepository;
    private final FollowRepository followRepository;

    public Optional<User> getUserById(String id) {
        return userRepository.findById(id)
                .map(user -> {
                    var image = imageRepository.findById(user.getProfileImageId())
                            .map(imageEntity -> {
                                return new Image(imageEntity.getId(), imageEntity.getName(), imageEntity.getUrl());
                            });

                    var articles = articleRepository.findAllByUserId(user.getId())
                            .stream().map(articleEntity ->
                                    new Article(articleEntity.getId(), articleEntity.getTitle(), articleEntity.getContent()))
                            .collect(Collectors.toList());

                    var followCount = followRepository.countByUserId(user.getId());

                    return new User(
                            user.getId(),
                            user.getName(),
                            user.getAge(),
                            image,
                            articles,
                            followCount
                    );
                });
    }
}

기존문제

  • 기존 UserBlockingService는 유저정보 조회를 먼저 하여, 유저 ID를 토대로 이미지 -> 게시글 -> 팔로우수 순서대로 하나씩 차례로 실행을 한다.
  • 각 Repository는 내부적으로 1초씩 소요된다. (Thead sleep 걸어둠)
  • 위 세가지를 모두 조회하려면 대략 3초 이상이 소요될 것이며, 이는 완전한 동기 Blocking 구조이다.

리팩토링 목표

  • 동시에 여러 작업을 병렬로 실행하여, 응답 속도 개선
  • .get()을 제거해 blocking을 없애고
  • CompletableFuture 기반 비동기 로직으로 변환
    • 동기 같지만, 실제는 비동기 구조를 구현



UserFutureService (최종)

@Slf4j
@RequiredArgsConstructor
public class UserFutureService {
    private final UserFutureRepository userRepository;
    private final ArticleFutureRepository articleRepository;
    private final ImageFutureRepository imageRepository;
    private final FollowFutureRepository followRepository;

    @SneakyThrows
    public CompletableFuture<Optional<User>> getUserById(String id) {
        return userRepository.findById(id)
                .thenComposeAsync(this::getUser);
    }

    @SneakyThrows
    private CompletableFuture<Optional<User>> getUser(Optional<UserEntity> userEntityOptional) {
        if (userEntityOptional.isEmpty()) {
            return CompletableFuture.completedFuture(Optional.empty());
        }

        var userEntity = userEntityOptional.get();

        var imageFuture = imageRepository.findById(userEntity.getProfileImageId())
                .thenApplyAsync(imageEntityOptional ->
                        imageEntityOptional.map(imageEntity ->
                            new Image(imageEntity.getId(), imageEntity.getName(), imageEntity.getUrl())
                        )
                );


        var articlesFuture = articleRepository.findAllByUserId(userEntity.getId())
                .thenApplyAsync(articleEntities ->
                        articleEntities.stream()
                                .map(articleEntity ->
                                    new Article(articleEntity.getId(), articleEntity.getTitle(), articleEntity.getContent())
                                )
                        .collect(Collectors.toList())
                );

        var followCountFuture = followRepository.countByUserId(userEntity.getId());

        return CompletableFuture.allOf(imageFuture, articlesFuture, followCountFuture)
                .thenAcceptAsync(v -> {
                    log.info("Three futures are completed");
                })
                .thenRunAsync(() -> {
                    log.info("Three futures are also completed");
                })
                .thenApplyAsync(v -> {
                    try {
                        var image = imageFuture.get();
                        var articles = articlesFuture.get();
                        var followCount = followCountFuture.get();

                        return Optional.of(
                                new User(
                                        userEntity.getId(),
                                        userEntity.getName(),
                                        userEntity.getAge(),
                                        image,
                                        articles,
                                        followCount
                                )
                        );
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                });
    }
}

결과

리팩토링 순서

  • CompletableFuture<Optional<User'>>를 반환하는 getUser()로 역할 분리
  • 우선 User, Image, Article, Follow Repository에서의 반환타입을 모두 CompletableFuture<>로 통일
  • getUserById의 반환타입도 CompletableFuture<>로 변경.
  • getUserById에 get() --> thenApplyAsync or thenComposeAsync로 변경
    • UserRepository의 findById에서 반환받은 UserEntity는 image, article, follow와 함께 User Domain으로 돌아와야 한다.
    • 입력값과 반환값이 정의되어있으니, thenApplyAsync or thenComposeAsync를 고려.
      • ( T : 각 Entity, U : 각 Domain)
    • UserRepository의 findById는 CompletableFuture를 반환 중이지만, 사용하는 getUserById에 get()은 모든 future를 다 대기하는 문제를 일으킨다. 즉, blocking한 상황
  • UserEntity를 확인하여, Early return 추가
    • Optional이기에 Optional.empty()처리는 당연하고, Completable.completedFuture()로 future값 완성
  • findById(image), findAllByUserId(article), countByUserId(follow)에서 get() --> 각각 thenApplyAsync 변경
    • get()은 값이 들어올 때까지 기다리므로, 동기 blocking 상황.
    • image (1초) + article(1초) + follow(1초)의 직접적인 원인을 제공
    • thenApply를 활용하여 비동기적인 처리 추가 ( T : 각 Entity, U : 각 Domain)
  • allOf 추가
    • User Domain을 가공하기 위해서는, Entity에서 Domain으로 변환된 future에서 get()해오는 구간이 필요했음
    • allOf는 각 future의 모든 작업이 완료된 시점을 보장.
    • 이 시점에서 한번에 모든 Domain들의 get()을 처리 후 UserDomain을 가공하여 리턴
    • allOf는 CompletableFuture<Void'>를 리턴하기 때문에, 적절히 thenApply 활용

UserServiceTest (테스트)

    @Test
    void testGetUser() throws ExecutionException, InterruptedException {
        // given
        String userId = "1234";

        // when
        Optional<User> optionalUser = userComService.getUserById(userId).get();

        // then
        assertFalse(optionalUser.isEmpty());
        var user = optionalUser.get();
        assertEquals(user.getName(), "lkm");
        assertEquals(user.getAge(), 32);

        assertFalse(user.getProfileImage().isEmpty());
        var image = user.getProfileImage().get();
        assertEquals(image.getId(), "image#1000");
        assertEquals(image.getName(), "profileImage");
        assertEquals(image.getUrl(), "https://www.naver.com");

        assertEquals(2, user.getArticleList().size());

        assertEquals(1000, user.getFollowCount());
    }
profile
🤦🏻‍♂️

0개의 댓글