본문 바로가기
Project

[Project] 좋아요 로직 반정규화 및 동시성 처리

by 육빔 2025. 1. 31.
728x90
반응형

프로젝트를 진행 중 우리는 인스타 좋아요처럼 눌렀다가 취소할 수 있는 그런 기능을 구현하고자 했다. 사실 처음에는 아주 쉬울거라고 생각했고 바로 기능 구현에 들어갔다.

 

사실 처음에는 그냥 board에서 likeIncrement, likeDecrement함수를 각각 구현해 단순히 Board 엔티티에서 감소, 증가 로직만을 작성하였었다. 

 public void likeIncrement() {
        this.likeCount++;
    }

    public void likeDecrement() {
        if (this.likeCount > 0) {
            this.likeCount--;
        }
    }

 

하지만 이렇게 되면 여러문제점이 발생하고 만다.

 

크게 2가지 문제가 발생하는데

 

첫번째는 내가 원하는 좋아요를 눌렀다가 취소하기 위한 기능이 구현이 되지 않는다. 사용자가 이 게시물을 눌렀는지 안눌렀는지 확인을 해야하는데 확인을 못하기 때문.. 이와 연결되서 무한으로 좋아요를 누를수있다..

 

두번째는 동시에 여러사용자가 좋아요를 눌렀을때 동시성 문제가 발생할 수 있다. 현재는 likeCount로 증가, 감소를 처리하고 있기때문에 같은 데이터 likeCount 값을 여러 사용자가 읽고 처리하는 과정에 있어서 문제가 생길 수 있다.

 

 

이런 문제들을 해결하고자 여러 글들을 찾아보았다.

 

거의 다 이런 문제는 중간에 Like 테이블을 생성해 유저와 테이블의 정보를 남겨 매핑해주고 있었다.

 

public class Like {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "like_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private Board board;
 }

 

나도 이걸 본 다음에 딱 단순히 User정보, 게시물 정보를 매핑해서 게시물 조회할때 조회하면 Like 테이블의 개수를 카운트해서 보여주면 되겠구나!! 라는 생각으로


Board테이블에 LikeCount를 제거하고 Like 테이블을 만들어 진행해나가고 있었는데 문득 생각이 들엇다.

그러면 좋아요가 수십만개일경우에는 모든 테이블을 조인해서 count를 전부 해야되나? 상당히 오래걸릴 것이라고 판단했고 바로 부하테스트를 진행해보았다.

 

그 결과 예상대로 좋아요가 많아질 수록 조회하는데 시간이 점점 더 걸리는걸 확인할 수 있었다.

 

게시물을 한 번 확인하기 위해서 모든 좋아요를 조회하고 처리하는데 시간이 이렇게 걸리면 여러 게시물을 확인할때 사용자가 불편을 겪을 것으로 예상되어 반정규화 작업을 실시했다.

 

처음 생각한 로직 Board 테이블에 다시 likeCount를 추가하게 되었다. 이렇게 되면 게시물을 조회할때 Like 테이블을 조인을 하지 않고도 좋아요 개수를 파악 할 수 있기 때문이다. 하지만 이제 똑같은 정보가 2개의 테이블에 존재하게 되면 데이터의 정합성을 해결해야되는 문제가 발생한다.

 

이런 방법에서 내가 생각한건 2가지 방법이다. 

 

첫번째는 락을 거는 방법이다.
FOR UPDATE는 트랜잭션이 끝날 때까지 다른 트랜잭션의 접근을 차단하는 방법으로 한 트렌젝션이 like_count에 대한 조회에 대한 접근했을 경우 다른 트랜잭션의 접근을 완전히 차단하여 데이터의 정합성을 맞추는 방식이다.

 

하지만 이 방법은 조회에 대한 락을 걸게 되면 그 어떠한 트랜잭션도 접근하지 못하고 다른 트랜잭션이 끝나는 것을 기다려야되기때문에 성능상의 문제가 발생할 가능성이 매우 높아진다.

 

두번째 방법은 네이티브 쿼리를 사용한 방법이다.

직접적으로 쿼리를 작성해

  • UPDATE board SET like_count = like_count + 1 WHERE id = ?

와 같은 쿼리를 날리는 방법이다. update 명령어는 조회 없이 바로 증가시키기에 성능상의 향상을 취할수 있고, update는 동시에 실행되더라도 동시성을 보장하기에 정확한 값이 반영된다.

 

하지만 단점으로는 이것도 한 트렌젝션이 업데이트를 진행중이라면 해당 row에서는 update작업을 하지 않아 여러 사용자가 동시에 누르는 상황이 발생하면 서버에 무리가 갈 수 있다. 또한 단순한 증가는 쉽지만 추가 로직이 필요한 경우엔 조회를 진행하지 않기에 복잡한 비지니스 로직가 추가될 경우 전부 수정해야되는 단점이 있다. 또한 db레벨에 직접적으로 값을 수정하기에 jpa, redis에서 사용되는 캐쉬값의 값이 불일치 할 수 있기에 주의해서 사용해야된다.

 

그래서 다음 코드를 보자.

public interface BoardRepository extends JpaRepository<Board, Long> {
    List<Board> findAllByCategoryIn(List<Category> categories);

    @Transactional
    @Modifying(clearAutomatically = true)
    @Query(value = "UPDATE board SET like_count = like_count + 1 WHERE board_id = :boardId", nativeQuery = true)
    void increaseLikeCount(Long boardId);

    @Transactional
    @Modifying(clearAutomatically = true)
    @Query(value = "UPDATE board SET like_count = like_count - 1 WHERE board_id = :boardId", nativeQuery = true)
    void decreaseLikeCount(Long boardId);

}

 

현재 직접적으로 like_count를 증가시키거나 감소시키는 로직이다.

 

이거의 문제는 만약 jpa에서 캐시가 되어있는 상태에서 update를 직접 db에서 실행하게 되면 캐시와 db값의 데이터 불일치가 발생할 수 있다. 그래서 이러한 문제점을 발생하기 위해 @Modifying(clearAutomatically = true) 를 사용해준다.

위의 clearAutomatically = true 옵션을 설정하게 되면 쿼리가 실행된 다음 영속성 컨텍스트를 초기화하게 된다. 한마디로 해당 메서드가 실행된다면 기존에 캐싱되어있던 Board 엔티티 데이터를 지워서 이후 findByXX의 조회시 최신 데이터를 DB에서 가져오게 유도할 수 있다.

// 기존 데이터: board.likeCount = 100
Board board = boardRepository.findById(boardId).get();
System.out.println(board.getLikeCount()); // 100

// DB 값만 업데이트 (JPA 캐시는 그대로)
boardRepository.increaseLikeCount(boardId);

// 다시 조회
Board updatedBoard = boardRepository.findById(boardId).get();
System.out.println(updatedBoard.getLikeCount()); // 100 (여전히 이전 값)

 

그리고 clearAutomatically = true는 쿼리가 한 번 더 날아가게 된다. 왜냐면 값을 변경해주고 영속성 컨텍스트의 값이 비어있기에 select문이 더 날라간다.

728x90
반응형