좋아요 개수, 댓글 개수, 조회 수 테이블 분리 리팩토링

Refactor오늘의 식탁JavaSpring BootToday's Table
avatar
2025.04.28
·
13 min read

1. 리팩토링 배경

레시피 목록 조회 API의 성능과 확장성을 고려하여, 좋아요 수, 리뷰 수, 조회수 관리를 보다 효율적으로 개선할 필요가 있었습니다.

현재 코드 구조

  • Recipe 엔티티에 recipeViewCnt 조회수 필드 존재

  • Like, Review 테이블은 별도로 존재하여 각각 좋아요와 댓글을 관리

  • 페이징 결과 10개 레시피를 불러오면 3개의 쿼리(기본 조회 1 + 좋아요 1 + 댓글 1)가 발생

현재코드

Recipe

@Entity
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SQLDelete(sql = "UPDATE Recipe SET deleted_date = CURRENT_TIMESTAMP WHERE recipe_id = ?")
public class Recipe extends BaseEntity {

    @Id
    @Column(name = "recipe_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 제목
    private String recipeTitle;

    // 레시피 간단 설명
    @Column(columnDefinition = "text")
    private String recipeDescription;

    // 레시피 작성자
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    // 조회수
    @Column(columnDefinition = "integer default 0", nullable = false)
    private int recipeViewCnt;

    // 조리시간
    private String recipeCookingTime;

    // 몇인분
    private String recipeServings;

    // 썸네일
    @Setter
    private String thumbnailUrl;

    // 댓글
    @Builder.Default
    @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Review> reviewList = new ArrayList<>();

    // 좋아요
    @Builder.Default
    @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Like> likeList = new ArrayList<>();

    // 레시피 재료
    @Builder.Default
    @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<RecipeItem> itemList = new ArrayList<>();

    // 레시피 단계
    @Setter
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "recipe_id")
    @OrderBy("stepOrder ASC")
    private List<RecipeStep> stepList = new ArrayList<>();


    public void addViewCnt() {
        this.recipeViewCnt++;
    }

}

Like

@Entity
@Getter
@Builder
@Table(name = "LikeCount")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Like extends BaseEntity {

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

    // 좋아요를 누른 고객
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    // 좋아요를 받은 레시피
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "recipe_id")
    private Recipe recipe;

}

Review

@Entity
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Where(clause = "deleted_date IS NULL")
@SQLDelete(sql = "UPDATE Review SET deleted_date = CURRENT_TIMESTAMP WHERE review_id = ?")
public class Review extends BaseEntity {

    @Id
    @Column(name = "review_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "parent_id")
    private Long parentId;

    // 리뷰 내용
    private String reviewContent;

    // 리뷰를 작성한 고객
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "recipe_id")
    private Recipe recipe;

}

RecipeService

@Service
@Transactional
@RequiredArgsConstructor
public class RecipeService {

    private final LikeRepository likeRepository;
    private final CustomerRepository customerRepository;
    private final RecipeRepository recipeRepository;
    private final ReviewRepository reviewRepository;
    private final ItemRepository itemRepository;
    private final MessageUtil messageUtil;
    private final AwsS3Service awsS3Service;

    /**
     * 레시피 요약 정보를 페이지 단위로 조회합니다.
     */
    @Transactional(readOnly = true)
    public Page<SimpleRecipeDto> getAllRecipes(Pageable pageable) {
        Page<Recipe> recipes = recipeRepository.findAll(pageable);
        return mapToSimpleRecipePage(recipes);
    }

   
    /**
     * 레시피 목록을 SimpleRecipeDto 페이지로 변환
     */
    private Page<SimpleRecipeDto> mapToSimpleRecipePage(Page<Recipe> recipes) {
        List<Long> ids = recipes.stream().map(Recipe::getId).toList();
        Map<Long, Long> likes = likeRepository.getLikeCountByRecipeIds(ids);
        Map<Long, Long> reviews = reviewRepository.getReviewCountByRecipeIds(ids);
        return recipes.map(recipe -> toSimpleRecipeDto(recipe, likes, reviews));
    }

    /**
     * 단일 레시피를 SimpleRecipeDto로 변환
     */
    private SimpleRecipeDto toSimpleRecipeDto(Recipe recipe, Map<Long, Long> likes, Map<Long, Long> reviews) {
        return SimpleRecipeDto.builder()
                .recipeId(recipe.getId())
                .title(recipe.getRecipeTitle())
                .recipeDescription(recipe.getRecipeDescription())
                .writer(recipe.getCustomer().getNickName())
                .recipeCookingTime(recipe.getRecipeCookingTime())
                .recipeServings(recipe.getRecipeServings())
                .recipeView(recipe.getRecipeViewCnt())
                .thumbnail(recipe.getThumbnailUrl())
                .likeCnt(likes.getOrDefault(recipe.getId(), 0L))
                .reviewCnt(reviews.getOrDefault(recipe.getId(), 0L))
                .build();
    }
}

LikeRepository

public interface LikeRepository extends JpaRepository<Like, Long> {

    /**
     * 특정 레시피에 대한 좋아요 개수를 조회하는 메서드입니다.
     *
     * @param recipe 조회하고자 하는 레시피
     * @return Integer 좋아요 개수
     */
    Long countByRecipe(Recipe recipe);

    @Query("SELECT l.recipe.id, count(l) FROM Like l WHERE l.recipe.id in :recipeIds GROUP BY l.recipe.id")
    List<Object[]> countByRecipeIds(@Param("recipeIds") List<Long> recipeIds);

    default Map<Long,Long> getLikeCountByRecipeIds(List<Long> recipeIds) {
        return countByRecipeIds(recipeIds).stream().collect(
                Collectors.toMap(
                        row -> (Long) row[0],
                        row -> (Long) row[1]
                ));
    }
}

ReviewRepository

public interface ReviewRepository extends JpaRepository<Review, Long> {

    @Query("SELECT r.recipe.id, count(r) FROM Review r WHERE r.recipe.id in :recipeIds GROUP BY r.recipe.id")
    List<Object[]> countByRecipeIds(@Param("recipeIds") List<Long> recipeIds);

    default Map<Long, Long> getReviewCountByRecipeIds(List<Long> recipeIds) {
        return countByRecipeIds(recipeIds).stream()
                .collect(Collectors.toMap(
                        row -> (Long) row[0],
                        row -> (Long) row[1]
                ));
    }
}

2. 문제점 상세 분석

목록 조회 성능 문제

  • 좋아요 수, 댓글 수를 각각 별도 count 쿼리로 조회했습니다.

  • 데이터가 적을 때는 문제가 없었지만, 데이터량이 많거나 무한 스크롤 환경에서는 부하가 심각해질 수 있었습니다.

  • 특히 count 쿼리는 인덱스를 타더라도 부하가 크기 때문에 주의가 필요했습니다.

동시성 문제

  • addViewCnt()로 메모리 상에서 조회수를 증가시키는 방식 사용

  • 여러 사용자가 동시에 같은 레시피를 조회할 경우 경합 상태 발생

경합 상황 예시

  • 여러 스레드가 동일한 Recipe 엔티티를 DB에서 읽어와 각자의 메모리에 로드.

  • 각 스레드가 로드한 객체의 recipeViewCnt 값을 독립적으로 증가시킴 (N -> N+1).

  • 각 스레드가 계산된 N+1 값을 DB에 다시 저장하려고 시도.

  • 나중에 저장하는 스레드가 이전에 저장된 값을 덮어쓰면서 실제 조회 수보다 적은 값이 DB에 저장되는 데이터 누락 현상이 발생할 수 있었습니다.


3. 생각했던 방법

1. 반정규화 방식

Recipe 테이블에 좋아요 수, 댓글 수 등의 집계 필드를 직접 포함시켜, JOIN 없이 단일 쿼리로 빠르게 데이터를 조회하는 방식입니다.

장점

  • API 응답 속도 향상

  • 쿼리 수 감소로 DB 부하 완화

단점

  • 댓글/좋아요 등의 데이터 변경이 발생할 때마다 집계 필드도 업데이트해야 함

2. 별도 통계 테이블 분리

좋아요 수, 댓글 수, 조회수 등의 집계 정보를 별도 테이블로 분리하여 관리하는 방식입니다.

장점

  • 원본 데이터(Like, Review)와 분리되어 있어 관리 용이

  • 비동기 처리, 캐싱 전략, 주기적 집계(batch) 등 다양한 확장 전략 수립 가능

단점

  • 통계 테이블의 값이 실시간과 다를 수 있음


4. 해결 방안 - 통계 테이블 도입

좋아요 수, 댓글 수, 조회 수와 같은 집계 정보는 실시간 정합성이 필수적인 데이터는 아니라고 판단하여, 별도의 통계 테이블로 분리하는 방식을 선택했습니다.

초기에는 스케줄러를 통해 주기적으로 원본 데이터와의 정합성을 맞추는 방식도 고려했지만, 실제로는 좋아요나 댓글이 등록·삭제될 때마다 통계 테이블을 함께 원자적으로 업데이트하도록 설계했기 때문에, 정합성 보정 작업이 불필요하다고 판단했습니다.

또한, 캐싱 도입도 한 차례 검토했지만, 해당 정보가 완전히 불필요한 것은 아니면서도,
캐싱을 도입할 경우 오히려 Redis 등 외부 I/O가 빈번하게 발생해 오버헤드가 클 것으로 판단했습니다.
따라서 단순한 집계 데이터에 대해 캐시까지 운용하는 것은 비용 대비 효과가 낮다고 판단하여 제외했습니다.

RecipeMeta 엔티티를 생성

  • RecipeMeta 테이블을 생성하여 좋아요 수, 리뷰 수, 조회수를 별도로 관리하도록 설계했습니다.

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RecipeMeta extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "recipe_meta_id")
    private Long id;

    // 좋아요 수
    @Column(nullable = false)
    private Long likeCnt;

    // 리뷰 수
    @Column(nullable = false)
    private Long reviewCnt;

    // 조회수
    @Column(nullable = false)
    private Long viewCnt;
}

Recipe 관계 추가

  • Recipe 엔티티에 RecipeMeta@OneToOne으로 연관관계를 맺어 관리하도록 변경했습니다.

@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "recipe_meta_id")
private RecipeMeta recipeMeta;

RecipeMetaRepository 추가

  • 감소 시 0 이하로 떨어지지 않도록 방어 로직을 추가했습니다.

public interface RecipeMetaRepository extends JpaRepository<RecipeMeta, Long> {

    @Modifying
    @Query("update RecipeMeta rm set rm.viewCnt = rm.viewCnt + 1 where rm.id = :id")
    void incrementViewCnt(@Param("id") Long id);

    @Modifying
    @Query("update RecipeMeta rm set rm.likeCnt = rm.likeCnt + 1 where rm.id = :id")
    void incrementLikeCnt(@Param("id") Long id);

    @Modifying
    @Query("update RecipeMeta rm set rm.likeCnt = rm.likeCnt - 1 where rm.id = :id and rm.likeCnt > 0")
    void decrementLikeCnt(@Param("id") Long id);

    @Modifying
    @Query("update RecipeMeta rm set rm.reviewCnt = rm.reviewCnt + 1 where rm.id = :id")
    void incrementReviewCnt(@Param("id") Long id);

    @Modifying
    @Query("update RecipeMeta rm set rm.reviewCnt = rm.reviewCnt - 1 where rm.id = :id and rm.reviewCnt > 0")
    void decrementReviewCnt(@Param("id") Long id);
}

RecipeRepostiory 수정

  • 레시피 목록 조회 시 RecipeMeta를 조인하고 필요한 데이터만 DTO로 매핑하여 쿼리 1번으로 해결했습니다.

@RequiredArgsConstructor
public class RecipeRepositoryImpl implements RecipeCustomRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public Slice<SimpleRecipeDto> findAllSimpleRecipes(Pageable pageable) {
        List<SimpleRecipeDto> content = queryFactory
                .select(new QSimpleRecipeDto(
                        recipe.id,
                        recipe.recipeTitle,
                        recipe.recipeDescription,
                        recipe.customer.nickName,
                        recipe.recipeCookingTime,
                        recipe.recipeServings,
                        recipe.thumbnailUrl,
                        recipe.recipeMeta.viewCnt,
                        recipe.recipeMeta.likeCnt,
                        recipe.recipeMeta.reviewCnt
                ))
                .from(recipe)
                .join(recipe.customer)
                .join(recipe.recipeMeta)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .orderBy(getOrderSpecifier(pageable))
                .fetch();

        boolean hasNext = content.size() > pageable.getPageSize();

        if (hasNext) {
            content.remove(pageable.getPageSize());
        }

        return new SliceImpl<>(content, pageable, hasNext);
    }

    private OrderSpecifier<?> getOrderSpecifier(Pageable pageable) {
        Order direction = Order.DESC;
        String property = "createdDate";

        if (!pageable.getSort().isEmpty()) {
            Sort.Order order = pageable.getSort().iterator().next();
            property = order.getProperty();
            direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
        }

        switch (property) {
            case "likeCnt":
                return new OrderSpecifier<>(direction, recipe.recipeMeta.likeCnt);
            case "viewCnt":
                return new OrderSpecifier<>(direction, recipe.recipeMeta.viewCnt);
            case "reviewCnt":
                return new OrderSpecifier<>(direction, recipe.recipeMeta.reviewCnt);
            case "recipeTitle":
                return new OrderSpecifier<>(direction, recipe.recipeTitle);
            case "createdDate":
                return new OrderSpecifier<>(direction, recipe.createdDate);
            default:
                return new OrderSpecifier<>(direction, recipe.createdDate);
        }
    }

}

RecipeMetaService 추가

  • 조회수는 @Async를 이용하여 별도의 트랜잭션에서 증가시키고, 메인 로직 흐름에 영향을 주지 않도록 했습니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class RecipeMetaService {

    private final RecipeMetaRepository recipeMetaRepository;

    @Async
    @Transactional
    public void asyncIncreaseViewCnt(Long recipeMetaId) {
        try {
            recipeMetaRepository.incrementViewCnt(recipeMetaId);
        } catch (Exception e) {
            log.error("조회수 증가 실패", e);
        }
    }

    @Async
    @Transactional
    public void asyncIncreaseLikeCnt(Long recipeMetaId) {
        try {
            recipeMetaRepository.incrementLikeCnt(recipeMetaId);
        } catch (Exception e) {
            log.error("좋아요 수 증가 실패", e);
        }
    }

    @Async
    @Transactional
    public void asyncDecreaseLikeCnt(Long recipeMetaId) {
        try {
            recipeMetaRepository.decrementLikeCnt(recipeMetaId);
        } catch (Exception e) {
            log.error("좋아요 수 감소 실패", e);
        }
    }

    @Async
    @Transactional
    public void asyncIncreaseReviewCnt(Long recipeMetaId) {
        try {
            recipeMetaRepository.incrementReviewCnt(recipeMetaId);
        } catch (Exception e) {
            log.error("리뷰 수 증가 실패", e);
        }
    }

    @Async
    @Transactional
    public void asyncDecreaseReviewCnt(Long recipeMetaId) {
        try {
            recipeMetaRepository.decrementReviewCnt(recipeMetaId);
        } catch (Exception e) {
            log.error("리뷰 수 감소 실패", e);
        }
    }
}

RecipeService 수정

@Service
@Transactional
@RequiredArgsConstructor
public class RecipeService {

    private final LikeRepository likeRepository;
    private final CustomerRepository customerRepository;
    private final RecipeRepository recipeRepository;
    private final ReviewRepository reviewRepository;
    private final ItemRepository itemRepository;
    private final MessageUtil messageUtil;
    private final AwsS3Service awsS3Service;
    private final RecipeMetaService recipeMetaService;

    /**
     * 레시피 요약 정보를 페이지 단위로 조회합니다.
     */
    @Transactional(readOnly = true)
    public Slice<SimpleRecipeDto> getRecipes(Pageable pageable) {
        return recipeRepository.findAllSimpleRecipes(pageable);
    }

  
}

리팩토링 결과

  • 레시피 목록 조회 성능을 개선했습니다. (기본 조회 1개)

  • 좋아요 수, 댓글 수 변화도 Meta 테이블만 수정하면 되어 유지보수성이 향상되었습니다.







- 컬렉션 아티클