타임라인 최적화

soyeon·2023년 5월 9일
post-thumbnail

타임라인

SNS에서 팔로워들의 게시물을 보여주는 피드

  • 요구사항
    • 회원의 ID를 받아 해당 회원의 팔로워들의 게시물을 시간순으로 조회한다.
    • 커서 기반 페이징으로 구현(스크롤)

구현

먼저, 게시글과 팔로워 정보를 가져와야한다.
1. memberId -> follow 조회
2. 1번 결과로 게시글 조회
와 같은 방법으로 구현하려하며 GetTimelinePostsUsecase이란 usecase를 작성해주겠다.

PostRepository

우선 저번의 페이징 구현에서와 같이 키값이 있을 때와 없을 때의 여부에 따른 실행분기를 할 수 있는 함수를 작성하자.

// 키값이 없을 경우 실핼 시킬 함수
public List<Post> findAllByInMemberIdsAndOrderByDesc(List<Long> memberIds, int size) {
        if (memberIds.isEmpty()) {
            return List.of();
        }

        var sql = String.format("""
                SELECT *
                FROM %s
                WHERE memberId IN (:memberId)
                ORDER BY id DESC
                LIMIT :size
                """, TABLE);
        var params = new MapSqlParameterSource()
                .addValue("memberIds", memberIds)
                .addValue("size", size);

        return namedParameterJdbcTemplate.query(sql, params, ROW_MAPPER);
    }
    
    // 키값이 있을 경우 실행 시킬 함수
    public List<Post> findAllByLessThanIdAndInMemberIdsAndOrderByIdDesc(Long id,
            List<Long> memberIds,
            int size) {
        if (memberIds.isEmpty()) {
            return List.of();
        }

        var sql = String.format("""
                SELECT *
                FROM %s
                WHERE memberId IN (:memberId) AND id < :id
                ORDER BY id DESC
                LIMIT :size
                """, TABLE);
        var params = new MapSqlParameterSource()
                .addValue("memberIds", memberIds)
                .addValue("id", id)
                .addValue("size", size);

        return namedParameterJdbcTemplate.query(sql, params, ROW_MAPPER);
    }

PostReadService

key값의 여부로 findAllByLessThanIdAndInMemberIdsAndOrderByIdDesc 또는
findAllByInMemberIdsAndOrderByDesc를 실행

public PageCursor<Post> getPosts(List<Long> memberIds, CursorRequest cursorRequest) {
        var posts = findAllBy(memberIds, cursorRequest);
        var nextKey = getNextKey(posts);
        return new PageCursor<>(cursorRequest.next(nextKey), posts);
    }
    
private List<Post> findAllBy(List<Long> memberIds, CursorRequest cursorRequest) {
        if (cursorRequest.hasKey()) {
            return postRepository.findAllByLessThanIdAndInMemberIdsAndOrderByIdDesc(
                    cursorRequest.key(), memberIds, cursorRequest.size());
        }
        return postRepository.findAllByInMemberIdsAndOrderByDesc(memberIds, cursorRequest.size());
    }

    private static long getNextKey(List<Post> posts) {
        return posts.stream()
                .mapToLong(Post::getId)
                .min()
                .orElse(CursorRequest.NONE_KEY);
    }

GetTimelinePostsUsecase

FollowReadService 와 PostReadService 을 주입받아 팔로우한 멤버의 id값을 가져와 해당 게시글을 최신순으로 size만큼 반환한다.

package com.example.twittermysql.application.usecase;

import com.example.twittermysql.domain.follow.entity.Follow;
import com.example.twittermysql.domain.follow.service.FollowReadService;
import com.example.twittermysql.domain.post.entity.Post;
import com.example.twittermysql.domain.post.service.PostReadService;
import com.example.twittermysql.util.CursorRequest;
import com.example.twittermysql.util.PageCursor;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class GetTimelinePostsUsecase {

    final private FollowReadService followReadService;
    final private PostReadService postReadService;

    PageCursor<Post> execute(Long memberId, CursorRequest cursorRequest) {
        var followings = followReadService.getFollowings(memberId);
        var followingMemberIds = followings.stream().map(Follow::getToMemberId).toList();
        return postReadService.getPosts(followingMemberIds, cursorRequest);
    }
}

PostController

@GetMapping("/member/{memberId}/timeline")
    public PageCursor<Post> getTimeline(
            @PathVariable Long memberId,
            CursorRequest cursorRequest
    ) {
        return getTimelinePostsUsecase.execute(memberId, cursorRequest);
    }

작성할 테스트 코드 TODO...
1. 팔로우한 멤버가 없을 경우

  • 빈 리스트 반환
  1. 팔로우한 멤버가 있고 해당 멤버가 게시한 글이 없는 경우
  • 빈 리스트 반환
  1. 팔로우한 멤버가 있고 해당 멤버가 게시한 글이 있는 경우
  • 팔로우한 멤버의 게시글을 최신순으로 size만큼 반환

일단은 간단하게 테스트 했을 때 위의 경우 모두 잘 반환하는 것을 테스트 했지만 다음에 위와 같이 테스트 코드를 추가해보자.

시간복잡도

log(Follow 전체 레코드) + 해당 회원의 Following * log(Post 전체 코드)

위의 시간복잡도는 Follow의 fromMemberId와 Post의 memberId에 인덱스가 걸려있다고 가정했을 때의 계산이다.

쿼리문에서도 봤듯이 IN절을 통해 팔로잉 멤버의 게시글을 가져오는데 이때 팔로잉의 수만큼 해당 post를 조회하는 흐름으로 팔로잉수가 많은 사용자일수록 매번 홈에 접속할때마다 크게 부하가 발생할 가능성이 높아진다.

이를 팬아웃온리드 방식이라고 하며 조회시점에 부하가 있는 방식이다. 이와 같은 조회 시의 주하를 줄일 수 있는 해결방법 중 하나로 팬아웃온라이트에 대해 알아보자.

팬아웃온라이트(Fan out on write)

작성 시점에 팬아웃을 하는 방식으로 게시물 작성 시, 해당 회원을 팔로우하는 회원들에게 데이터를 배달한다.

1번 유저가 2번 유저를 팔로우하고 있다.
이때, 2번 유저가 게시글을 작성하는 시점에 2번을 팔로우하고 있는 멤버인 1번에게 게시글을 배달하고 Timeline이라는 테이블에 팔로우를 한 memberId(1번)와 postId의 값을 가지도록 insert한다.

이렇게 되면 타임라인을 조회할 때 Timeline 테이블에서 memberId를 통해 해당 유저에게 배달해야할 post의 id값을 가져올 수 있다.

구현 (createPost 수정)

기존 게시글을 작성 시 게시글만 작성하던 방식에서
게시글, 타임라인에 insert하도록 수정할 것이다.

Tineline entity 작성

package com.example.twittermysql.domain.post.entity;

import java.time.LocalDateTime;
import java.util.Objects;
import lombok.Builder;
import lombok.Getter;

@Getter
public class Timeline {

    final private Long id;
    final private Long memberId;
    final private Long postId;
    final private LocalDateTime createdAt;

    @Builder
    public Timeline(Long id, Long memberId, Long postId, LocalDateTime createdAt) {
        this.id = id;
        this.memberId = Objects.requireNonNull(memberId);
        this.postId = Objects.requireNonNull(postId);
        this.createdAt = createdAt == null ? LocalDateTime.now() : createdAt;
    }
}

게시글을 게시할 때 Follow, Post, Time 이 세개의 도메인이 관련이 있으므로 따로 CreatePostUsecase를 작성하자.

public class CreatePostUsecase {

    final private PostWriteService postWriteService;
    final private FollowReadService followReadService;
    final private TimelineWriteService timelineWriteService;

    public Long execute(PostCommand postCommand) {
        var postId = postWriteService.create(postCommand);

        var followerMemberIds = followReadService.getFollowers(postCommand.memberId()).stream().map(
                Follow::getFromMemberId).toList();

        timelineWriteService.deliveryToTimeline(postId, followerMemberIds);

        return postId;
    }
}

// PostControllr
// 기존 코드
/* @PostMapping()
    public Long create(PostCommand command) {
        return postWriteService.create(command);
    }
    */
    
// 수정ver
@PostMapping("")
    public Long create(PostCommand command) {
        return createPostUsecase.execute(command);
    }

200003번 게시할때는 3번만 1번을 팔로우했을 경우고
200004번 게시할때는 3,4번이 1번을 팔로우했을 경우다.

구현 (GetTimelinePostsUsecase)

GetTimelinePostsUsecase 에서 timeline을 통해 조회하도록 작성해보자.
1. Timeline에서 memberId로 timeline 조회 (TimelineReadService 추가)

// TimelineReadService
@RequiredArgsConstructor
@Service
public class TimelineReadService {

    final private TimelineRepository timelineRepository;

    public PageCursor<Timeline> getTimelines(Long memberId, CursorRequest cursorRequest) {
        var timelines = findAllBy(memberId, cursorRequest);
        var nextKey = timelines.stream().mapToLong(Timeline::getId).min()
                .orElse(CursorRequest.NONE_KEY);

        return new PageCursor<>(cursorRequest.next(nextKey), timelines);
    }

    private List<Timeline> findAllBy(Long memberId, CursorRequest cursorRequest) {
        if (cursorRequest.hasKey()) {
            return timelineRepository.findAllByLessThanIdAndMemberIdAndOrderByIdDesc(
                    cursorRequest.key(), memberId, cursorRequest.size());
        }

        return timelineRepository.findAllByMemberIdAndOrderByIdDesc(memberId, cursorRequest.size());
    }
}
  1. 조회한 timeline에서 postIds 도출
  2. postIds로 게시글 조회하여 PageCursor<Post> 반환
public PageCursor<Post> executeByTimeline(Long memberId, CursorRequest cursorRequest) {
        var pagedTimelines = timelineReadService.getTimelines(memberId, cursorRequest);
        var postIds = pagedTimelines.body().stream().map(Timeline::getPostId).toList();
        var posts = postReadService.getPosts(postIds);
        return new PageCursor<>(pagedTimelines.nextCursorRequest(), posts);
    }

정합성과 성능의 트레이드 오프

fan out on read 는 push Model이라고도 하며 공간복잡도를 희생한다. timeline과 같은 별도의 테이블을 필요로 하기 때문이다.
fan out on write 는 pull model 이라고도 하며 시간복잡도를 희생한다.

pull model은 원본 데이터를 직접 참조하므로, 정합성 보장에 유리하다는 장점이 있지만 Follow가 많은 회원일수록 처리속도가 느려진다.

facebook, Instagram pull model을 사용하는데 위의 단점을 팔로우 수를 제한한 것으로 해결했다.
최대치를 초과할 수 없으며 새로 추가하려면 기존 친구를 제거해야만 할 수 있다.

트위터는 push model을 사용하는데 다음과 같은 제한이 있다.
최대치가 있긴 하지만 추가로 팔로우를 할 수 있다. 또한 게시글에 대한 한도가 있는 것을 볼 수 있다.

push model에서는 게시글 작성과 타임라인 배달의 정합성 보장에 대한 고민이 필요하다.
팔로워가 엄청 많은 모든 회원의 타임라인에 배달되기 전까지 게시물 작성의 트랜잭션을 유지하는 것이 맞을까? 비활성중인 회원이 있거나 탈퇴 회원에 대한 제약이 있을 것 같다.

구현하면서 push model은 pull model에 비해 시스템 복잡도가 높다는 것을 느낄 수 있었다. 하지만 그만큼 비즈니스, 기술 측면에서 유연성을 확보시켜준다.

push model을 사용했을 때와 다르게 timeline이라는 post와 별도의 공간이 있고 이를 비즈니스적으로 활용했기 때문이다.

그래서 오늘도 결국에는 꼭 정답은 없고 여러 상황을 고려하여 기술을 적용하는 것이라는 걸로 마무리를 해보겠다..

profile
사부작 사부작

0개의 댓글