
현재 블로킹하게 작동 중인 코드를 비동기적으로 변경해보려 한다.
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 객체로 반환
@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 기반 비동기 로직으로 변환
- 동기 같지만, 실제는 비동기 구조를 구현
@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 활용
@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());
}