좋아요 기능

soyeon·2023년 5월 21일
post-thumbnail

좋아요 기능을 구현하고 동시성 제어하기

기능 구현

하고

디버깅 모드

좋아요의 개수를 올리는 코드를 동시에 실행을 시킨다고 설정하고
어떤 문제점이 있는지 디버깅 모드로 확인해보자.

브레이크 포인트에서 오른쪽 마우스 버튼을 통해 서스펜드를 All일 경우 작업을 마칠때까지 대기하므로 동시에 같은 코드가 수행되도록 Thread로 변경하자.

디버깅 모드로 실행하고 터미널에서 curl을 통해 조회를 먼저 해보자.

 ~  curl -X 'GET' \                                                                     ✔
  'http://localhost:8080/posts/members/1?page=0&size=1' \
  -H 'accept: */*'
{"content":[{"id":2000004,"memberId":1,"contents":"내가 게시하면 3번, 4번에게 배달","createdDate":"2023-05-09","likeCount":2,"createdAt":"2023-05-09T23:30:53"}],"pageable":{"sort":{"empty":true,"unsorted":true,"sorted":false},"offset":0,"pageNumber":0,"pageSize":1,"paged":true,"unpaged":false},"last":false,"totalPages":3,"totalElements":3,"first":true,"size":1,"number":0,"sort":{"empty":true,"unsorted":true,"sorted":false},"numberOfElements":1,"empty":false}%

현재는 likeCount가 2이다.

이제 터미널 두개를 열어서 좋아요 요청을 각각 두개 날려보자. 그러면 두개의 작업을 잡고 있는 것이 보인다.

각각 살펴보면 둘다 likeCount를 2로 조회하였고 이를 +1 하고 업데이트를 해서 3으로 업데이트가 될 것이다.

이 상태에서 재개(옵션 + 커맨드 + R)를 하면 이어서 작업을 수행하게 한다. 왼쪽에 재개 아이콘을 두번 클릭하여 작업을 마친다.

그리고 다시 조회를 하면 두개의 요청을 보냈기에 좋아요의 개수는 2 + 2로 4가 되어야 하지만 3으로 조회가 된다.

 ~  curl -X 'GET' \                                                                     ✔
  'http://localhost:8080/posts/members/1?page=0&size=1' \
  -H 'accept: */*'
{"content":[{"id":2000004,"memberId":1,"contents":"내가 게시하면 3번, 4번에게 배달","createdDate":"2023-05-09","likeCount":3,"createdAt":"2023-05-09T23:30:53"}],"pageable":{"sort":{"empty":true,"unsorted":true,"sorted":false},"offset":0,"pageNumber":0,"pageSize":1,"paged":true,"unpaged":false},"last":false,"totalPages":3,"totalElements":3,"first":true,"size":1,"number":0,"sort":{"empty":true,"unsorted":true,"sorted":false},"numberOfElements":1,"empty":false}%

해결 1

이를 해결하기 위한 방법으로 SELECT FOR UPDATE를 통해 잠금을 획득하여 해결할 수 있다.

잠금 획득을 하기 위해서는 트랜젝션을 걸어줘야한다.
그리고 post의 findById에 SELECT FOR UPDATE를 걸어줄건데 모든 조회에 잠금을 획득하며 성능의 저하가 있을 수 있으니 파라미터를 받도록 하여 분기로 처리하자.

public Optional<Post> findById(Long postId, Boolean requiredLock) {
        var sql = String.format("SELECT * FROM %s WHERE id = :postId", TABLE);
        if (requiredLock) {
            sql += " FOR UPDATE";
        }
        var params = new MapSqlParameterSource().addValue("postId", postId);
        var nullablePost = namedParameterJdbcTemplate.queryForObject(sql, params, ROW_MAPPER);
        return Optional.ofNullable(nullablePost);
    }

다시 디버깅 모드를 실행하여 두개의 요청을 보내보자.

아까와 다르게 스레드가 하나만 존재한다. 이 요청이 처리될때까지 기다리고 있는 것이다.


위와 같이 3으로 읽고 F8을 하면 4로 업데이트를 한 것을 확인할 수 있다. 재개를 클릭하고 다음 작업도 수행해보면 아래와 같이 의도한대로 읽고 수정한다.

 ~  curl -X 'GET' \                                                                     ✔
  'http://localhost:8080/posts/members/1?page=0&size=1' \
  -H 'accept: */*'
{"content":[{"id":2000004,"memberId":1,"contents":"내가 게시하면 3번, 4번에게 배달","createdDate":"2023-05-09","likeCount":5,"createdAt":"2023-05-09T23:30:53"}],"pageable":{"sort":{"empty":true,"unsorted":true,"sorted":false},"offset":0,"pageNumber":0,"pageSize":1,"paged":true,"unpaged":false},"last":false,"totalPages":3,"totalElements":3,"first":true,"size":1,"number":0,"sort":{"empty":true,"unsorted":true,"sorted":false},"numberOfElements":1,"empty":false}%

이와 같이 정합성은 지켰지만 실제로 엄청 인기가 있는 게시글에 좋아요를 누르게 된다면 요청이 대기를 길게 이루게 될것이다.

해결 2

낙관적 락으로 동시성을 제어해보자.
일단 post에 version 필드를 추가한다. 그리고 update 함수에서 아래와 같이 추가해준다.

private Post update(Post post) {
        var sql = String.format("""
                UPDATE %s set
                memberId = :memberId,
                contents = :contents,
                createdDate = :createdDate,
                likeCount = :likeCount,
                createdAt = :createdAt,
                version = :version + 1
                WHERE id = :id and version = :version
                """, TABLE);
        SqlParameterSource params = new BeanPropertySqlParameterSource(post);
        var updatedCount = namedParameterJdbcTemplate.update(sql, params);

        if (updatedCount == 0) {
            throw new RuntimeException("갱신실패");
        }
        return post;
    }

결과 확인
위에서 진행한 방법과 동일하게 동시성 테스트를 해보자.

요청을 보내기전에 좋아요의 갯수는 7이고 동시에 좋아요를 눌렀을 때 하나의 요청은 업데이트에 실패를 해야한다. 이어서 진행하고 결과를 보자.

의도한 대로 갱신실패 처리가 되었고 좋아요 개수도 8개로 한번만 업데이트 되었다.

이와 같이 정합성은 지켰지만 실제로 엄청 인기가 있는 게시글에 좋아요를 누르게 된다면 계속 실패가 일어날 것이다.

해결 3

위의 두 방법의 문제점에 대해서 다시 얘기를 해보자면 비관적 락을 사용한 경우에는 하나의 게시글에 좋아요가 몰리게 되면 이 게시글에 대한 락이 줄을 서게 되어 처리속도가 느려질 가능성이 있다.
또한 낙관적 락의 경우 요청이 끊임없이 실패할 수 있다.
이런 병목지점을 해소하기 위해 좋아요 테이블을 분리하여 진행할 것이다.

회원이 좋아요한 게시글에 대한 정보를 담는 테이블을 생성하여 진행해보자. 이전에 만든 타임라인 entity와 동일하게 가져가되 이름만 PostLike라고 하겠다.

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

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

@Getter
public class PostLike {

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

    @Builder
    public PostLike(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;
    }
}
package com.example.twittermysql.domain.post.repository;

import com.example.twittermysql.domain.post.entity.PostLike;
import java.sql.ResultSet;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Repository;

@RequiredArgsConstructor
@Repository
public class PostLikeRepository {

    final static String TABLE = "PostLike";
    final private NamedParameterJdbcTemplate namedParameterJdbcTemplate;
    final static private RowMapper<PostLike> ROW_MAPPER = (ResultSet resultSet, int rowNum) -> PostLike.builder()
            .id(resultSet.getLong("id"))
            .memberId(resultSet.getLong("memberId"))
            .postId(resultSet.getLong("postId"))
            .createdAt(resultSet.getObject("createdAt", LocalDateTime.class))
            .build();

    public PostLike save(PostLike postLike) {
        if (postLike.getId() == null) {
            return insert(postLike);
        }

        throw new UnsupportedOperationException("Like는 갱신을 지원하지 않습니다.");
    }

    private PostLike insert(PostLike postLike) {
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(
                namedParameterJdbcTemplate.getJdbcTemplate())
                .withTableName(TABLE)
                .usingGeneratedKeyColumns("id");

        SqlParameterSource params = new BeanPropertySqlParameterSource(postLike);
        var id = jdbcInsert.executeAndReturnKey(params).longValue();

        return PostLike.builder()
                .id(id)
                .memberId(postLike.getMemberId())
                .postId(postLike.getPostId())
                .createdAt(postLike.getCreatedAt())
                .build();
    }
}
package com.example.twittermysql.domain.post.service;

import com.example.twittermysql.domain.member.entity.Member;
import com.example.twittermysql.domain.post.entity.Post;
import com.example.twittermysql.domain.post.entity.PostLike;
import com.example.twittermysql.domain.post.repository.PostLikeRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class PostLikeWriteService {

    final private PostLikeRepository postLikeRepository;

    public Long create(Post post, Member member) {
        var like = PostLike.builder()
                .postId(post.getId())
                .memberId(member.getId())
                .build();

        return postLikeRepository.save(like).getPostId();
    }
}

post, member, like 가 필요하므로 usecase를 따로 정의하였다.

package com.example.twittermysql.application.usecase;

import com.example.twittermysql.domain.member.service.MemberReadService;
import com.example.twittermysql.domain.post.service.PostLikeWriteService;
import com.example.twittermysql.domain.post.service.PostReadService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class CreatePostLikeUsecase {

    final private PostReadService postReadService;
    final private MemberReadService memberReadService;
    final private PostLikeWriteService postLikeWriteService;

    public void execute(Long postId, Long memberId) {
        var post = postReadService.getPost(postId);
        var member = memberReadService.getMember(memberId);
        postLikeWriteService.create(post, member);
    }
}

이와 같이 좋아요 테이블에 데이터를 생성하면 락을 걸지 않는다. 왜냐하면 조회가 아니라 생성을 하는 쿼리기 때문이다.

그리고 하나의 자원에 대해서 업데이트를 치기 위해 경합하지 않는다.

하지만 게시글 조회 시에 게시글 테이블에서 한번에 가져오지 않기 때문에 좀 더 처리가 필요하다.

PostDto를 생성하여 진행해보자.

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

import java.time.LocalDateTime;

public record PostDto(
        Long id,
        Long memberId,
        String contents,
        Long likeCount,
        LocalDateTime createdAt
) {

}

어떤 게시글에 누가 좋아요를 눌렀는지에 대한 정보를 쌓아놨으니 이를 조회해서 Dto에 매핑해주도록 작성하고

// PostLikeRepository
public Long count(Long postId) {
        var sql = String.format("""
                SELECT count(id)
                FROM %s
                WHERE postId = :postId
                """, TABLE);
        var params = new MapSqlParameterSource().addValue("postId", postId);
        return namedParameterJdbcTemplate.queryForObject(sql, params, Long.class);
    }
    
// PostReadService
public PostDto toDto(Post post) {
        return new PostDto(post.getId(),
                post.getMemberId(),
                post.getContents(),
                postLikeRepository.count(post.getId()),
                post.getCreatedAt());
    }

public Page<PostDto> getPosts(Long memberId, Pageable pageable) {
        return postRepository.findAllByMemberId(memberId, pageable).map(this::toDto);
    }

이전에 진행했던 조회 요청을 보내면 post의 likeCount와는 별개로 PostLike에 대한 데이터로 좋아요 수가 표시된다.

하지만 게시글의 정보를 읽을 때마다 카운트 쿼리를 실행하여 PostLike가 많을 경우 조회 시점에서 부하가 걸리게 된다.

마무리

해결1과 해결2의 방법은 게시글에 컬럼 추가를 통한 구현하였고 해결3의 방법은 좋아요 테이블을 추가하여 구현하였다.

컬럼을 통한 구현

  1. 조회 시 컬럼만 읽어오면 됨
  2. 쓰기 시 게시물 레코드에 대한 경합이 발생 -> 하나의 자원(게시물)을 두고 락 대기
  3. 같은 회원이 하나에 게시물에 대해 여러 번 좋아요를 누를 수 있음

좋아요 테이블을 통한 구현

  1. 조회 시 매번 카운트 쿼리 연산
  2. 쓰기 기 경합없이 인서트만 발생
  3. 회원정보등 다양한 정보 저장 가능

이 사이의 밸러스를 위한 대안을 찾아보자.
하지만 그 전에 좋아요 수는 정합성을 요구하는 데이터인가에 대해 고민해보자면 좋아요는 어느정도의 실시간성만 보장하면 된다.

대안

좋아요 테이블에 정보를 저장하고 일정 주기마다 스케줄러를 통해 카운트 쿼리를 쳐서 게시물의 좋아요 컬럼을 업데이트 하는 방법이다.
조회 시에 게시물의 좋아요 컬럼을 불러와서 조회마다 카운트 쿼리를 줄일 수 있게 된다.
여기에서 문제점은 좋아요를 누를 때 바로 반영이 되지 않아서 문제점으로 생각할 수 있지만 이는 클라이언트에서 누르면 바로 +1한 결과를 보여주는 걸로 해결할 수 있다.

대용량 트래픽, 대용량 데이터를 처리하는 데에는 데이터의 성질, 병목지점을 파악하고, 적당한 기술들을 도입해 해소할 수 있도록 하는 것이 중요하다.

앞으로 해볼 작업들

  • 레포지토리 레이어를 JPA로 리팩토링하기
  • 팔로워가 100만명인 유저의 게시물 작성 성능 테스트
    게시물 작성과 타임라인을 배달하는 트랜잭션이 붙이있기 때문에 성능이 떨어지는 문제 해결하기
    • nGrinder 활용해보기
    • 비동기 큐를 통해 개선
    • Mixed Push / Pull Model
  • 로그인 / 팔로우 승인, 취소 / 댓글 구현
  • MySQL Master / Slave 개념 공부하기
  • 파티셔닝
profile
사부작 사부작

0개의 댓글