개발/spingboot

Root Aggregate 에서 팔로우, 좋아요, 북마크 같은 Aggregate 카운팅하기

ikkison 2026. 8. 17. 13:51
반응형

들어가며

게시물, 주문 등 생성/수정/조회/삭제 인 CRUD Operation이 모두 존재하는 Root Aggregate Entity 들은 다양한 Aggregate 들을 포함할 수 있다.

 

그 중에서, 팔로우, 좋아요, 북마크 등 마치 토글스위치 같이 on/off 만 하는 Aggregate 들이 있다.

https://ikkison.tistory.com/11

 

BaseEntity 구현 전략

Domain 상세 설계부터 Class Diagram, ERD, 구현 단계를 거치면서 Entity 들의 공통된 Attribute 들이 발견된다. 나는 의미론적으로 무게가 높은 반복되고 명시적인 코드가 아니면 무조건 abstract 로 추상화하

ikkison.tistory.com

 

본 글에서는 팔로우 엔티티를 대표적으로써 내용을 정리해보려한다.

 

 

내가 마주한 문제들

편의상 Root Aggregate Entity 에서 이런 Aggregate 들의 summary 값만 관리하는 경우가 많은데, 아래와 같이 성능에 영향을 주는 포인트들을 발견하였다.

 

1. 행위 주체와 대상의 존재 검증 시 성능저하 예상

2. DB Insert 시 동시성 문제

 

사실 이 2개의 문제점들은 문법적으로나 Springboot 사용법으로써 연관성이 높으나, 분리된 작은 문제에 입각하여 최대한 정리해보았다.

1. 행위 주체와 대상의 존재 검증 시 성능저하 예상

Problem > 너무 많은 DB 쿼리

팔로우를 하거나 취소할 때, 사용자가 UI에서 버튼을 클릭 후 BE 에서 DB 트랜잭션 처리 전 사이 하필 팔로우 대상이 서비스정지가 되거나 회원탈퇴를 하는 상황이 발생할 수 있다.

이렇게 재수없는 상황을 대비하여 Service 에서 팔로우하는 사용자, 팔로우받는 사용자 정보 둘다 체크하고자 한다.

@Service
@Transactional
@RequiredArgsConstructor
public class FollowAddService {
    private final FollowRepository followRepository;
    private final MemberRepository memberRepository;

    public FollowAddResDto addFollow(
        FollowAddReqDto reqDto
    ) {
    	// Query 1. followingId 존재 검증
        if (memberRepository.existsById(reqDto.followingId())) {
            throw new IllegalArgumentException("UNKNWON_FOLLOWER_ID");
        }
        // Query 2. followerId 존재 검증
        if (memberRepository.existsById(reqDto.followerId())) {
            throw new IllegalArgumentException("UNKNWON_FOLLOWING_ID");
        }
        if (TextUtil.equalsUuid(reqDto.followingId(), reqDto.followerId())) {
            throw new IllegalArgumentException("INVALID_FOLLOW_BY_SELF");
        }
        // Query 3. followingId 의 Member 조회
        MemberEntity followingEntity = memberRepository.findById(reqDto.followingId())
        // Query 4. followerId 의 Member 조회
        MemberEntity followerEntity = memberRepository.findById(reqDto.followerId())
        
        // Dirty Checking - Update * 2
        followingEntity.increaseFollowingCount();
        followerEntity.increaseFollowerCount();
        
        FollowEntity newEntity = reqDto.toEntity();
        // Query 5. followerId 의 Member 조회
        FollowEntity savedEntity = followRepository.save(newEntity);

        return FollowAddResDto.fromEntity(savedEntity);
    }
}

 

해당 소스를 보면 Following Member 존재확인, Follower Member 존재확인, Following Member find, Follower Member find 총 4회 DB 쿼리가 발생하게 된다.

이 서비스의 본래 역할인 Follow 추가까지 총 5번 발생하게된다.

Service Transactional 의 Dirty Checking 까지 고려하면 increase 함수들 2개까지 포함하게되어 Update SQL쿼리가 추가 발생한다.

 

Solution > find 와 orElseThrow 로 1번만 쿼리 실행.

findById 와 orElseThrow 으로 존재확인과 Member Find 를 한번에 하도록 변경하였다.

즉, Following/Follower Member 가 없으면 orElseThrow 로 Exception 을 발생시킨다.

 

package codes.workshop.koget.api.application.follow.service;

import org.springframework.stereotype.Service;

import codes.workshop.koget.api.application.follow.dto.request.FollowAddReqDto;
import codes.workshop.koget.api.application.follow.dto.response.FollowAddResDto;
import codes.workshop.koget.core.domain.follow.entity.FollowEntity;
import codes.workshop.koget.core.domain.follow.repository.FollowRepository;
import codes.workshop.koget.core.domain.member.entity.MemberEntity;
import codes.workshop.koget.core.domain.member.repository.MemberRepository;
import codes.workshop.koget.core.utils.TextUtil;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;

@Service
@Transactional
@RequiredArgsConstructor
public class FollowAddService {
    private final FollowRepository followRepository;
    private final MemberRepository memberRepository;

    public FollowAddResDto addFollow(
        FollowAddReqDto reqDto
    ) {
        if (TextUtil.equalsUuid(reqDto.followingId(), reqDto.followerId())) {
            throw new IllegalArgumentException("INVALID_FOLLOW_BY_SELF");
        }

        MemberEntity followingEntity = memberRepository.findById(reqDto.followingId()).orElseThrow(
            () -> new IllegalArgumentException("UNKNWON_FOLLOWING_ID")
        );

        MemberEntity followerEntity = memberRepository.findById(reqDto.followerId()).orElseThrow(
            () -> new IllegalArgumentException("UNKNWON_FOLLOWER_ID")
        );

        FollowEntity newEntity = reqDto.toEntity();

        followingEntity.increaseFollowingCount();
        followerEntity.increaseFollowerCount();
        
        FollowEntity savedEntity = followRepository.save(newEntity);

        return FollowAddResDto.fromEntity(savedEntity);
    }
}

 

2. DB Insert 시 동시성 문제

Problem > 인플루언서의 정신없는 Follow 수치 변동

팔로우를 하거나 취소할 때 인플루언서/셀럽들은 순식간에 많은 사람들이 몰려들기에 사용자가 UI에서 버튼을 클릭 후 BE 에서 DB 트랜잭션 처리 전 사이 팔로우 개수가 +/- 변동이 심하게 발생할 수 있다.

예를 들어, 여러명이 동시에 팔로우를 하여서 increase 함수로 인해 +N가 되어야하는데, +1 이 되는 현상이 발생할 수 있다.

마치 병렬처리시 동시접근하는 크리티컬 섹션이 존재하여 뮤텍스락이 필요한 상황처럼 보인다.

        followingEntity.increaseFollowingCount();
        followerEntity.increaseFollowerCount();

 

Entity 에서 정의한 increase/decrease 함수에서 이 문제가 발생하기에 2가지 해결방법은 찾아 보았다.

 

Solution 1 > 비관적 락 (Pessimistic Lock)

데이터를 조회하는 시점부터 update 할때 까지 Lock 하는 방법이다.

다른 트랜잭션이 해당 Row값을 Read/Write 하지 못도록 DB 레벨에서 배타락을 거는 방법이다.

public interface MemberRepository extends JpaRepository<MemberEntity, UUID> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT m FROM MemberEntity m WHERE m.id = :id")
    Optional<MemberEntity> findByIdWithLock(@Param("id") UUID id);
}

 

글자만 읽어도 이건 지금 상황과 어울리는 방안은 아니다.

 

짧은 시간에 수많은 팔로우/언팔로우가 발생할 텐데, DB에서 락을 하면 대기 시간이 발생하기에 전체 시스템처리 성능이 떨어지게되고 최악의 경우 요청을 기다리다가 Timeout 까지 발생할 수 있다.

그리고, 2명의 사용자가 동시에 팔로우를 하게된다면 데드락처럼 서로의 락을 기다리는 가능성도 있다.

(정확히 말하면 데드락이 발생하지 않는다고 DBMS별로 공식적은 정보나 검증이 필요하나, 이러한 상황때문에 DBMS별로 전부 찾아내거나 일일이 검증하는 절차는 시간낭비이다.)

 

Solution 2 > DB Direct Update

Repository 소스에서 JPQL Bulk Update 를 하는 방식이다.

DB 엔진 레벨에서 += 1 을 연산하므로

단일 SQL 실행 순간에만 DB 내부 락이 아주 짧게 걸렸다가 해제되므로 성능과 처리량이 뛰어나기에, 비관적 락 대비해서 락 대기 시간을 최소화하고 데드락 위험도 거의 없다.

public interface MemberRepository extends JpaRepository<MemberEntity, UUID> {
    @Modifying(clearAutomatically = true)
    @Query("UPDATE MemberEntity m SET m.followingCount = m.followingCount + 1 WHERE m.id = :id")
    void increaseFollowingCount(@Param("id") UUID id);

    @Modifying(clearAutomatically = true)
    @Query("UPDATE MemberEntity m SET m.followerCount = m.followerCount + 1 WHERE m.id = :id")
    void increaseFollowerCount(@Param("id") UUID id);
}

 

JPA 1차 캐시 주의

JPA 1차 캐시(영속성 컨텍스트) 불일치를 주의해야하는데, @Modifying 과 @Query 로 Bulk Update 시 Bulk Update 가 JPA 영속성 컨텍스트를 거치지 않고 DB에 바로 쿼리를 날리는데, 이미 조회된 MemberEntity 의 메모리 값과 DB의 실제 값이 불일치가 발생할 수 있다.

 

  • @Modifying 쿼리는 JPA 영속성 컨텍스트를 거치지 않고 DB로 바로 전달되므로, SELECT 등의 요청을 하더라도 DB에 접근하지 않고 이미 로딩된 엔티티 메모리 상태를 사용하기에 DB 실데이터 간의 불일치가 발생할 수 있다
  • @Modifying(clearAutomatically = true) 옵션을 주면 쿼리 실행 후 영속성 컨텍스트를 자동으로 초기화(clear())하여 추후 조회 시 DB의 최신값을 다시 불러 사용한다.

 

이는 지금 화면UI에 보이는 팔로우/좋아요/북마크 개수는 웹브라우저, FE, BE, DB의 연계된 처리로 인해 시차가 발생하므로 작은 오차가 발생할 수 있지만, 실제 DB에서는 정상적으로 연산하고 있다.

이는 화면에 보이는 잠깐의 숫자는 사용자 경험(UX) 영역이고, 데이터베이스에 최종 기록되는 연산은 데이터 정합성(Data Consistency)의 영역이기 때문에 둘을 분리해서 봐야한다.

증가/감소 연산 중간에 랜더링되어 UI에서는 이전의 팔로우 수로 보이지만, 실제 DB에는 정상적으로 저장되는것이 중요하다.

 

**끝없는 욕심을 내어 수천만명이 팔로우, 좋아요, 북마크 정보를 즉각적으로 오차없이 사용자의 웹브라우저에 보여주고 싶으면 이런 소스코드가 아닌 MessageQueue 와 DBMS를 비롯한 인프라에 좀더 관심을 가져야한다.

 

마치며

글쓴이 본인은 웹 진영보다는 AI서빙, Server To Server 형태의 AI/연산엔진 개발을 주로 해왔다.

그렇기에 Line By Line 성능개선에 아직도 집착하고, Opensouce 의 소스코드의 무게와 속도를 계속 의심하고는 버릇이 있다.

덕분에 새로운것도 알아가고 문제도 해결해서 다행이다.

반응형