서브쿼리 vs 조인 vs 반정규화

김소희·2025년 12월 9일

좋아요 수 집계 방식

게시물이나 댓글에서 사용할 수 있는 좋아요 기능을 구현했다. 처음에는 LIKE_COUNT 컬럼을 각 게시판 테이블에 추가하는 반정규화 방식을 선택했다. 그런데 개발을 진행하면서 이 방식이 점점 어색하게 느껴졌다. 정규화된 데이터베이스 설계가 익숙했던 나에게 중복 데이터를 저장하고 동기화하는 로직을 작성하는 것이 불편했다. 결국 서브쿼리 방식으로 되돌렸지만 이것이 올바른 선택인지 확신이 서지 않았다.

성능 테스트를 해보고 싶었지만 현재 개발 환경에는 더미 데이터가 충분하지 않았다. 게시글 몇십 건으로는 의미 있는 성능 차이를 측정할 수 없다. 그래도 세 가지 방식을 이론적으로 비교하고 실무 관점에서 어떤 선택이 합리적인지 정리하고 싶었다.

세 가지 집계 방식

좋아요 수를 가져오는 방법은 크게 세 가지다. 각각 SQL 쿼리와 함께 살펴보자.

서브쿼리 방식

SELECT 절에서 서브쿼리로 좋아요 수를 직접 계산하는 방식이다. 가장 직관적이고 구현이 단순하다.

<!-- 게시글 상세 조회 (서브쿼리) -->
<select id="selectById" resultMap="freeboardDetailResultMap">
    SELECT
        f.FREEBOARD_ID,
        f.USER_ID,
        u.USER_NICKNAME,
        f.FREEBOARD_TITLE,
        f.FREEBOARD_CONTENT,
        f.FREEBOARD_CLICK,
        f.FREEBOARD_IMAGE,
        f.FREEBOARD_REPRESENT_IMAGE,
        f.FREEBOARD_CREATED_AT,
        (SELECT COUNT(*)
         FROM `LIKE` l
         WHERE l.REFERENCE_ID = f.FREEBOARD_ID
           AND l.REFERENCE_TYPE = 'POST_FREEBOARD') AS LIKE_COUNT
    FROM FREEBOARD f
    LEFT JOIN USERS u ON f.USER_ID = u.USER_ID
    WHERE f.FREEBOARD_ID = #{freeboardId}
      AND f.FREEBOARD_DELETED_YN = 'N'
</select>

게시글 목록 조회에서도 같은 패턴을 사용한다.

<!-- 게시글 목록 조회 (서브쿼리) -->
<select id="findPosts" resultMap="freeboardListResultMap">
    SELECT
        f.FREEBOARD_ID,
        f.USER_ID,
        u.USER_NICKNAME,
        f.FREEBOARD_TITLE,
        f.FREEBOARD_CLICK,
        f.FREEBOARD_REPRESENT_IMAGE,
        f.FREEBOARD_CREATED_AT,
        (SELECT COUNT(*)
         FROM `LIKE` l
         WHERE l.REFERENCE_ID = f.FREEBOARD_ID
           AND l.REFERENCE_TYPE = 'POST_FREEBOARD') AS LIKE_COUNT,
        (SELECT COUNT(*)
         FROM `COMMENT` c
         WHERE c.BOARD_ID = f.FREEBOARD_ID
           AND c.BOARD_TYPE = 'FREEBOARD'
           AND c.IS_DELETED = FALSE) AS COMMENT_COUNT
    FROM FREEBOARD f
    LEFT JOIN USERS u ON f.USER_ID = u.USER_ID
    WHERE f.FREEBOARD_DELETED_YN = 'N'
    ORDER BY f.FREEBOARD_CREATED_AT DESC
    LIMIT #{page.size} OFFSET #{page.offset}
</select>

서브쿼리로 생성된 LIKE_COUNT는 컬럼 별칭이므로 ORDER BY에서 직접 사용할 수 있다.

ORDER BY
<choose>
    <when test="sort.column == 'like_count'">
        LIKE_COUNT ${sort.directionSql}
    </when>
    <when test="sort.column == 'comment_count'">
        COMMENT_COUNT ${sort.directionSql}
    </when>
    <otherwise>
        f.FREEBOARD_CREATED_AT ${sort.directionSql}
    </otherwise>
</choose>

장점은 명확하다. 항상 정확한 값을 반환하고 좋아요를 추가하거나 삭제할 때 별도의 업데이트 로직이 필요 없다. LIKE 테이블만 수정하면 된다. 코드가 단순해서 버그가 발생할 여지가 적다.

단점은 성능이다. 게시글 100개를 조회하면 서브쿼리도 100번 실행된다. 데이터가 많아질수록 부담이 커진다. 하지만 인덱스만 제대로 걸려 있다면 수만 건까지는 큰 문제가 없다.

CREATE INDEX idx_like_reference ON `LIKE` (REFERENCE_TYPE, REFERENCE_ID);

조인 방식

LEFT JOIN으로 좋아요를 먼저 집계한 뒤 게시글과 결합하는 방식이다. 서브쿼리보다 효율적일 수 있다.

<!-- 게시글 상세 조회 (조인) -->
<select id="selectById" resultMap="codeboardDetailResultMap">
    SELECT
        c.CODEBOARD_ID,
        c.USER_ID,
        u.USER_NICKNAME,
        c.ANALYSIS_ID,
        c.CODEBOARD_TITLE,
        c.CODEBOARD_BLOCKS,
        c.CODEBOARD_CLICK,
        c.CODEBOARD_CREATED_AT,
        COALESCE(l.LIKE_COUNT, 0) AS LIKE_COUNT
    FROM CODEBOARD c
    INNER JOIN USERS u ON c.USER_ID = u.USER_ID
    LEFT JOIN (
        SELECT REFERENCE_ID, COUNT(*) AS LIKE_COUNT
        FROM `LIKE`
        WHERE REFERENCE_TYPE = 'POST_CODEBOARD'
        GROUP BY REFERENCE_ID
    ) l ON c.CODEBOARD_ID = l.REFERENCE_ID
    WHERE c.CODEBOARD_ID = #{codeboardId}
      AND c.CODEBOARD_DELETED_YN = 'N'
</select>

게시글 목록에서도 같은 패턴이다.

<!-- 게시글 목록 조회 (조인) -->
<select id="findPosts" resultMap="codeboardListResultMap">
    SELECT
        c.CODEBOARD_ID,
        c.USER_ID,
        u.USER_NICKNAME,
        c.CODEBOARD_TITLE,
        c.CODEBOARD_CLICK,
        c.CODEBOARD_CREATED_AT,
        COALESCE(l.LIKE_COUNT, 0) AS LIKE_COUNT,
        COALESCE(cm.COMMENT_COUNT, 0) AS COMMENT_COUNT
    FROM CODEBOARD c
    LEFT JOIN USERS u ON c.USER_ID = u.USER_ID
    LEFT JOIN (
        SELECT REFERENCE_ID, COUNT(*) AS LIKE_COUNT
        FROM `LIKE`
        WHERE REFERENCE_TYPE = 'POST_CODEBOARD'
        GROUP BY REFERENCE_ID
    ) l ON c.CODEBOARD_ID = l.REFERENCE_ID
    LEFT JOIN (
        SELECT BOARD_ID, COUNT(*) AS COMMENT_COUNT
        FROM `COMMENT`
        WHERE BOARD_TYPE = 'CODEBOARD'
          AND IS_DELETED = FALSE
        GROUP BY BOARD_ID
    ) cm ON c.CODEBOARD_ID = cm.BOARD_ID
    WHERE c.CODEBOARD_DELETED_YN = 'N'
    ORDER BY c.CODEBOARD_CREATED_AT DESC
    LIMIT #{page.size} OFFSET #{page.offset}
</select>

서브쿼리를 인라인 뷰로 만들고 GROUP BY로 집계한 뒤 LEFT JOIN한다. COALESCE로 좋아요가 없는 게시글은 0으로 처리한다.

서브쿼리는 게시글 100개를 조회하면 서브쿼리도 100번 실행된다. 각 게시글마다 좋아요 수를 따로 계산한다. 반면 조인은 GROUP BY로 모든 좋아요를 한 번에 집계한 뒤 게시글과 결합한다. 이론적으로는 조인이 더 효율적이다.

물론 인덱스가 제대로 걸려있다면 둘 다 빠르다.

CREATE INDEX idx_like_reference ON `LIKE` (REFERENCE_TYPE, REFERENCE_ID);

이 인덱스만 있으면 WHERE REFERENCE_TYPE = 'POST_CODEBOARD'로 필터링한 뒤 REFERENCE_ID로 그룹화하므로 전체 테이블 스캔이 발생하지 않는다. 서브쿼리든 조인이든 성능 차이가 크지 않다. 결국 쿼리 가독성과 유지보수 편의성의 문제가 된다.

반정규화 방식

게시판 테이블에 LIKE_COUNT 컬럼을 추가하고 좋아요가 증가하거나 감소할 때마다 업데이트하는 방식이다. 조회 성능은 가장 좋다.

ALTER TABLE FREEBOARD ADD COLUMN LIKE_COUNT INT DEFAULT 0;
ALTER TABLE CODEBOARD ADD COLUMN LIKE_COUNT INT DEFAULT 0;
ALTER TABLE COMMENT ADD COLUMN LIKE_COUNT INT DEFAULT 0;

기존 데이터의 좋아요 수를 계산해서 초기화한다.

UPDATE FREEBOARD f
SET LIKE_COUNT = (
    SELECT COUNT(*) 
    FROM `LIKE` l 
    WHERE l.REFERENCE_ID = f.FREEBOARD_ID 
      AND l.REFERENCE_TYPE = 'POST_FREEBOARD'
);

쿼리는 매우 단순해진다.

<!-- 게시글 상세 조회 (반정규화) -->
<select id="selectById" resultMap="freeboardDetailResultMap">
    SELECT
        f.FREEBOARD_ID,
        f.USER_ID,
        u.USER_NICKNAME,
        f.FREEBOARD_TITLE,
        f.FREEBOARD_CONTENT,
        f.FREEBOARD_CLICK,
        f.LIKE_COUNT,
        f.FREEBOARD_IMAGE,
        f.FREEBOARD_REPRESENT_IMAGE,
        f.FREEBOARD_CREATED_AT
    FROM FREEBOARD f
    LEFT JOIN USERS u ON f.USER_ID = u.USER_ID
    WHERE f.FREEBOARD_ID = #{freeboardId}
      AND f.FREEBOARD_DELETED_YN = 'N'
</select>

목록 조회도 마찬가지다.

<!-- 게시글 목록 조회 (반정규화) -->
<select id="findPosts" resultMap="freeboardListResultMap">
    SELECT
        f.FREEBOARD_ID,
        f.USER_ID,
        u.USER_NICKNAME,
        f.FREEBOARD_TITLE,
        f.FREEBOARD_CLICK,
        f.LIKE_COUNT,
        f.FREEBOARD_REPRESENT_IMAGE,
        f.FREEBOARD_CREATED_AT
    FROM FREEBOARD f
    LEFT JOIN USERS u ON f.USER_ID = u.USER_ID
    WHERE f.FREEBOARD_DELETED_YN = 'N'
    ORDER BY f.LIKE_COUNT DESC
    LIMIT #{page.size} OFFSET #{page.offset}
</select>

서브쿼리도 조인도 필요 없다. ORDER BY LIKE_COUNT도 간단하다. 조회 성능은 세 가지 방식 중 압도적으로 빠르다.

하지만 대가가 따른다. 좋아요를 추가하거나 삭제할 때 LIKE_COUNT를 업데이트해야 한다.

@Service
@RequiredArgsConstructor
public class LikeService {
    
    private final LikeRepository likeRepository;
    private final FreeboardRepository freeboardRepository;
    
    @Transactional
    public boolean toggleLike(Long userId, Long freeboardId) {
        Optional<LikeRecord> existingLike = likeRepository
            .findByUserIdAndReferenceTypeAndReferenceId(
                userId, ReferenceType.POST_FREEBOARD, freeboardId
            );
        
        if (existingLike.isPresent()) {
            likeRepository.delete(existingLike.get());
            freeboardRepository.decrementLikeCount(freeboardId);
            return false;
        } else {
            LikeRecord likeRecord = LikeRecord.builder()
                .userId(userId)
                .referenceType(ReferenceType.POST_FREEBOARD)
                .referenceId(freeboardId)
                .build();
            likeRepository.save(likeRecord);
            freeboardRepository.incrementLikeCount(freeboardId);
            return true;
        }
    }
}

Repository에서 증가와 감소 메서드를 제공해야 한다.

public interface FreeboardRepository extends JpaRepository<Freeboard, Long> {
    
    @Modifying
    @Query("UPDATE Freeboard f SET f.likeCount = f.likeCount + 1 WHERE f.freeboardId = :freeboardId")
    void incrementLikeCount(@Param("freeboardId") Long freeboardId);
    
    @Modifying
    @Query("UPDATE Freeboard f SET f.likeCount = f.likeCount - 1 WHERE f.freeboardId = :freeboardId")
    void decrementLikeCount(@Param("freeboardId") Long freeboardId);
}

MyBatis를 사용한다면 다음과 같다.

<update id="incrementLikeCount">
    UPDATE FREEBOARD
    SET LIKE_COUNT = LIKE_COUNT + 1
    WHERE FREEBOARD_ID = #{freeboardId}
</update>

<update id="decrementLikeCount">
    UPDATE FREEBOARD
    SET LIKE_COUNT = LIKE_COUNT - 1
    WHERE FREEBOARD_ID = #{freeboardId}
</update>

코드는 복잡해지지만 조회 성능은 최고다. 문제는 동시성이다. 여러 사용자가 동시에 좋아요를 누르면 LIKE_COUNT가 정확하지 않을 수 있다. UPDATE ... SET LIKE_COUNT = LIKE_COUNT + 1 같은 원자적 연산으로 처리하면 어느 정도 해결되지만 완벽하지는 않다.

정합성도 문제다. 좋아요를 추가하는 트랜잭션이 실패하면 LIKE_COUNT만 증가할 수 있다. 반대로 LIKE_COUNT 업데이트가 실패하면 좋아요는 추가되었는데 카운트는 그대로일 수 있다. 배치 작업으로 주기적으로 실제 좋아요 수와 LIKE_COUNT를 비교해서 동기화해야 한다.

@Scheduled(cron = "0 0 3 * * *")
@Transactional
public void syncLikeCount() {
    List<Freeboard> freeboards = freeboardRepository.findAll();
    
    for (Freeboard freeboard : freeboards) {
        long actualCount = likeRepository.countByReferenceTypeAndReferenceId(
            ReferenceType.POST_FREEBOARD, freeboard.getFreeboardId()
        );
        
        if (freeboard.getLikeCount() != actualCount) {
            freeboard.setLikeCount((int) actualCount);
            freeboardRepository.save(freeboard);
        }
    }
}

성능 비교 이론

세 가지 방식의 성능을 이론적으로 비교해보자. 실제 측정은 하지 못했지만 데이터베이스 동작 원리를 기반으로 예측할 수 있다.

조회 성능

게시글 100개를 조회한다고 가정하자.

서브쿼리 방식:

  • 메인 쿼리 1번 실행
  • 서브쿼리 100번 실행
  • 총 101번의 쿼리 실행

인덱스가 있다면 각 서브쿼리는 빠르다. REFERENCE_TYPEREFERENCE_ID로 인덱스 검색을 하므로 O(log N) 시간이다. 하지만 100번 실행되므로 누적 비용은 크다.

조인 방식:

  • 메인 쿼리 1번 실행
  • 인라인 뷰에서 GROUP BY 1번 실행
  • LEFT JOIN 1번 실행
  • 총 1번의 복합 쿼리

GROUP BY는 비용이 크다. LIKE 테이블 전체를 스캔하거나 인덱스를 타고 그룹화해야 한다. 하지만 한 번만 실행되므로 데이터가 많을수록 서브쿼리보다 유리할 수 있다.

반정규화 방식:

  • 메인 쿼리 1번 실행
  • 서브쿼리 없음
  • 조인 없음

단순 SELECT만 하므로 가장 빠르다. 인덱스 없이도 빠르고 데이터가 아무리 많아도 영향을 받지 않는다.

쓰기 성능

좋아요를 추가하거나 삭제할 때의 비용이다.

서브쿼리 방식:

  • LIKE 테이블에 INSERT 또는 DELETE 1번
  • 추가 작업 없음

조인 방식:

  • LIKE 테이블에 INSERT 또는 DELETE 1번
  • 추가 작업 없음

반정규화 방식:

  • LIKE 테이블에 INSERT 또는 DELETE 1번
  • 게시판 테이블에 UPDATE 1번
  • 총 2번의 쿼리

반정규화는 쓰기 비용이 2배다. 트랜잭션도 복잡해진다. 두 작업이 모두 성공해야 하므로 실패 시나리오를 고려해야 한다.

정렬 성능

ORDER BY LIKE_COUNT로 정렬할 때의 차이다.

서브쿼리 방식:

  • 서브쿼리로 계산된 LIKE_COUNT로 정렬
  • 인덱스를 사용할 수 없음
  • 계산 결과를 메모리에서 정렬

조인 방식:

  • 조인 결과에서 LIKE_COUNT로 정렬
  • 인덱스를 사용할 수 없음
  • 메모리에서 정렬

반정규화 방식:

  • LIKE_COUNT 컬럼으로 직접 정렬
  • 인덱스를 사용할 수 있음
  • 매우 빠름
CREATE INDEX idx_freeboard_like_count ON FREEBOARD(LIKE_COUNT DESC);

인덱스가 있으면 정렬이 거의 공짜다. 반정규화의 큰 장점이다.

실제 성능 측정의 어려움

세 가지 방식의 성능을 정확하게 비교하려면 실제 측정이 필요하다. 하지만 현실적으로 쉽지 않았다.

더미 데이터 부족

현재 개발 환경에는 게시글이 몇십 건밖에 없다. 이 정도 규모에서는 어떤 방식을 써도 응답 시간이 밀리초 단위다. 의미 있는 차이를 측정할 수 없다.

더미 데이터를 생성하는 스크립트를 작성할 수도 있다.

@Service
@RequiredArgsConstructor
public class DummyDataGenerator {
    
    private final FreeboardRepository freeboardRepository;
    private final LikeRepository likeRepository;
    
    @Transactional
    public void generateDummyData() {
        // 게시글 10만 건 생성
        for (int i = 1; i <= 100000; i++) {
            Freeboard freeboard = Freeboard.builder()
                .userId(1L)
                .freeboardTitle("더미 게시글 " + i)
                .freeboardContent("내용 " + i)
                .freeboardPlainText("내용 " + i)
                .freeboardDeletedYn("N")
                .build();
            freeboardRepository.save(freeboard);
            
            // 게시글마다 랜덤하게 좋아요 추가
            int likeCount = (int) (Math.random() * 100);
            for (int j = 0; j < likeCount; j++) {
                LikeRecord like = LikeRecord.builder()
                    .userId((long) (Math.random() * 1000))
                    .referenceType(ReferenceType.POST_FREEBOARD)
                    .referenceId((long) i)
                    .build();
                likeRepository.save(like);
            }
        }
    }
}

하지만 이렇게 생성한 데이터는 실제 프로덕션 데이터와 다르다. 분포가 다르고 접근 패턴도 다르다. 측정 결과를 그대로 신뢰하기 어렵다.

환경 차이

로컬 개발 환경과 실제 프로덕션 환경은 완전히 다르다. CPU, 메모리, 디스크 성능이 다르고 데이터베이스 설정도 다르다. 로컬에서 측정한 성능이 프로덕션에서 재현되지 않을 수 있다.

캐싱의 영향

실제 서비스에서는 캐싱이 큰 영향을 미친다. 목록 조회는 Redis로 캐싱하면 데이터베이스 쿼리 자체가 발생하지 않는다. 이런 상황에서는 서브쿼리든 조인이든 반정규화든 차이가 없다.

@Cacheable(value = "freeboard:list", key = "#page + '_' + #size + '_' + #sort")
public PageResponse<FreeboardListResponseDto> getList(int page, int size, String sort) {
    // ...
}

사용 패턴의 불확실성

실제 사용자들이 어떻게 서비스를 이용할지 예측하기 어렵다. 목록 조회가 많을지 상세 조회가 많을지, 정렬을 자주 사용할지, 어떤 정렬 기준을 선호할지 알 수 없다. 사용 패턴에 따라 최적의 방식이 달라진다.

결국 성능 측정은 의미 있는 규모의 데이터와 실제 프로덕션 환경, 그리고 실제 사용 패턴이 있을 때 가능하다. 초기 개발 단계에서는 이론적 이해를 바탕으로 합리적인 선택을 하는 수밖에 없다.

선택 기준

세 가지 방식 중 무엇을 선택할 것인가. 절대적인 정답은 없다. 상황에 따라 다르다.

데이터 규모

1만 건 미만:

  • 어떤 방식이든 무관하다
  • 서브쿼리로 시작하는 것을 추천한다
  • 코드가 단순하고 정합성이 보장된다

10만 건:

  • 서브쿼리로 시작한다
  • 인덱스를 제대로 설정한다
  • 성능 문제가 발생하면 조인이나 반정규화를 고려한다

100만 건 이상:

  • 반정규화를 진지하게 고려한다
  • 캐싱을 반드시 도입한다
  • 배치 작업으로 정합성을 검증한다

조회 패턴

상세 조회 위주:

  • 서브쿼리가 적합하다
  • 한 번에 한 건만 계산하므로 부담이 적다

목록 조회 위주:

  • 조인이나 반정규화가 유리하다
  • 여러 건을 한 번에 처리하므로 효율적이다

정렬이 중요:

  • 반정규화가 압도적으로 유리하다
  • 인덱스를 사용할 수 있다

정합성 요구사항

실시간 정확도 필수:

  • 서브쿼리를 사용한다
  • 항상 정확한 값을 보장한다

약간의 차이 허용:

  • 반정규화를 사용한다
  • 배치로 주기적으로 동기화한다

팀의 개발 철학

정규화 선호:

  • 서브쿼리나 조인을 선택한다
  • 중복 데이터를 피한다
  • 데이터베이스 레벨에서 일관성을 유지한다

성능 우선:

  • 반정규화를 선택한다
  • 애플리케이션 레벨에서 동기화를 관리한다
  • 복잡도를 감수한다

내가 선택한 방식

나는 서브쿼리를 선택했다. 반정규화를 구현했다가 다시 되돌린 이유는 명확하다.

현재 규모에서 충분하다

현재 서비스는 게시글이 수천 건 수준이다. 앞으로 몇 개월 동안도 수만 건을 넘지 않을 것으로 예상된다. 이 규모에서는 서브쿼리로도 응답 시간이 밀리초 단위다. 성능 문제가 발생할 가능성이 낮다.

CREATE INDEX idx_like_reference ON `LIKE` (REFERENCE_TYPE, REFERENCE_ID);
CREATE INDEX idx_comment_board ON `COMMENT` (BOARD_TYPE, BOARD_ID, IS_DELETED);

인덱스만 제대로 설정하면 서브쿼리는 매우 빠르다. REFERENCE_TYPEREFERENCE_ID로 좁혀진 범위에서 COUNT를 계산하므로 부담이 적다.

코드가 단순하다

서브쿼리는 SQL만 작성하면 된다. 좋아요를 추가하거나 삭제할 때 별도의 로직이 필요 없다.

@Service
@RequiredArgsConstructor
public class LikeService {
    
    private final LikeRepository likeRepository;
    
    @Transactional
    public boolean toggleLike(Long userId, ReferenceType referenceType, Long referenceId) {
        Optional<LikeRecord> existingLike = likeRepository
            .findByUserIdAndReferenceTypeAndReferenceId(userId, referenceType, referenceId);
        
        if (existingLike.isPresent()) {
            likeRepository.delete(existingLike.get());
            return false;
        } else {
            LikeRecord likeRecord = LikeRecord.builder()
                .userId(userId)
                .referenceType(referenceType)
                .referenceId(referenceId)
                .build();
            likeRepository.save(likeRecord);
            return true;
        }
    }
}

반정규화였다면 게시판 타입마다 다른 Repository를 호출해야 했다.

private void incrementLikeCount(ReferenceType referenceType, Long referenceId) {
    switch (referenceType) {
        case POST_CODEBOARD:
            codeboardRepository.incrementLikeCount(referenceId);
            break;
        case POST_FREEBOARD:
            freeboardRepository.incrementLikeCount(referenceId);
            break;
        case COMMENT:
            commentRepository.incrementLikeCount(referenceId);
            break;
    }
}

게시판이 추가될 때마다 switch 문이 길어진다. 서브쿼리는 이런 복잡도가 없다.

정합성이 보장된다

서브쿼리는 항상 정확한 값을 반환한다. 좋아요를 추가하는 트랜잭션이 실패하면 좋아요 수도 증가하지 않는다. 반대로 성공하면 바로 반영된다. 배치 작업으로 동기화할 필요가 없다.

반정규화는 동기화 실패 시나리오를 고려해야 한다. 좋아요는 추가되었는데 LIKE_COUNT가 업데이트되지 않을 수 있다. 배치로 검증하고 수정하는 로직이 필요하다.

@Scheduled(cron = "0 0 3 * * *")
@Transactional
public void syncLikeCount() {
    // 모든 게시글의 LIKE_COUNT를 검증하고 수정
}

이런 코드는 작성하고 테스트하고 유지보수하는 데 시간이 든다. 지금 단계에서는 불필요한 복잡도다.

나중에 전환할 수 있다

가장 중요한 이유다. 서브쿼리로 시작해도 나중에 반정규화로 전환할 수 있다. 반대는 어렵다. 반정규화를 먼저 구현하면 LIKE_COUNT 컬럼에 의존하는 코드가 많아진다. 서브쿼리로 되돌리려면 모든 쿼리를 수정해야 한다.

서브쿼리에서 반정규화로 전환하는 것은 간단하다.

// 1. 컬럼 추가
ALTER TABLE FREEBOARD ADD COLUMN LIKE_COUNT INT DEFAULT 0;

// 2. 초기 데이터 동기화
UPDATE FREEBOARD f
SET LIKE_COUNT = (
    SELECT COUNT(*) 
    FROM `LIKE` l 
    WHERE l.REFERENCE_ID = f.FREEBOARD_ID 
      AND l.REFERENCE_TYPE = 'POST_FREEBOARD'
);

// 3. 서비스 로직에 증가/감소 추가

쿼리도 서브쿼리 부분만 컬럼 이름으로 바꾸면 된다.

<!-- 기존 -->
(SELECT COUNT(*) FROM `LIKE` ...) AS LIKE_COUNT

<!-- 변경 후 -->
f.LIKE_COUNT

이렇게 점진적으로 전환할 수 있다. 실제 성능 문제가 측정되었을 때 반정규화로 전환해도 늦지 않다.

전환 시나리오

언제 반정규화로 전환할 것인가. 명확한 기준을 세워두었다.

응답 시간 기준

목록 조회 응답 시간이 500ms를 넘어가면 전환을 고려한다. 사용자가 체감할 수 있는 수준이다. 1초가 넘어가면 반드시 개선해야 한다.

Postman이나 브라우저 개발자 도구로 측정한다.

GET /api/freeboards?page=1&size=20&sort=like_count

Response Time: 523ms

데이터베이스 부하 기준

데이터베이스 CPU 사용률이 80%를 넘어가면 문제다. 쿼리 실행 계획을 확인하고 병목을 찾는다.

EXPLAIN SELECT
    f.FREEBOARD_ID,
    ...
    (SELECT COUNT(*) FROM `LIKE` ...) AS LIKE_COUNT
FROM FREEBOARD f
...

서브쿼리가 전체 테이블 스캔을 하고 있다면 인덱스를 먼저 추가한다. 인덱스로도 해결되지 않으면 반정규화를 고려한다.

사용자 불만 기준

가장 중요한 기준이다. 실제 사용자가 느리다고 불평하면 개선해야 한다. 성능 지표가 괜찮아도 사용자 경험이 나쁘면 의미가 없다.

캐싱 도입 후에도 느리면

반정규화 전에 캐싱을 먼저 시도한다. 목록 조회는 자주 바뀌지 않으므로 캐싱 효과가 크다.

@Cacheable(
    value = "freeboard:list",
    key = "#page + '_' + #size + '_' + #sort",
    unless = "#result.isEmpty()"
)
public PageResponse<FreeboardListResponseDto> getList(int page, int size, String sort) {
    // ...
}

Redis로 5분간 캐싱하면 대부분의 조회는 데이터베이스를 거치지 않는다. 캐싱으로도 해결되지 않으면 반정규화를 진지하게 고려한다.

비교 정리

세 가지 방식을 표로 정리하면 다음과 같다.

구분서브쿼리 방식조인 방식반정규화 방식
데이터 정합성매우 높음 (실시간)매우 높음 (실시간)중간 (애플리케이션 로직 의존)
조회 성능중 (인덱스 필요)중~높음 (GROUP BY 부담)매우 높음
쓰기 성능높음 (영향 없음)높음 (영향 없음)중간 (UPDATE 추가)
ORDER BY 성능중 (인덱스 불가)중 (인덱스 불가)매우 높음 (인덱스 가능)
구현 복잡도낮음중간높음
인덱스 의존도높음매우 높음낮음
코드 유지보수쉬움중간어려움
동시성 처리불필요불필요필요
배치 동기화불필요불필요필요
캐시 연계보통보통매우 좋음
대규모 트래픽부담부담가장 유리
초기 구현 속도빠름중간느림
확장성좋음좋음중간 (게시판 추가 시 switch 수정)
실무 사용 빈도소규모 서비스보조적 사용대규모 서비스 최종형

결론

좋아요 수를 집계하는 세 가지 방식을 비교했다. 서브쿼리, 조인, 반정규화 각각 장단점이 명확하다. 성능 측정을 하지 못한 것은 아쉽지만 이론적 이해만으로도 합리적인 선택을 할 수 있었다.

초기에는 서브쿼리로 시작하는 것이 합리적이다. 코드가 단순하고 정합성이 보장되며 인덱스만 잘 설정하면 수만 건까지는 충분히 빠르다. 실제 성능 문제가 측정되었을 때 반정규화로 전환해도 늦지 않다.

"나중에 최적화한다"는 것은 게으름이 아니라 엔지니어링 판단이다. 최적화는 가설이 아니라 관측된 병목을 기준으로 이루어져야 한다. 실제 병목을 측정하고 필요한 곳에 집중하는 것이 효율적이다.

지금은 서브쿼리로 충분하다. 사용자가 늘어나고 데이터가 쌓이면 그때 다시 고민하면 된다. 그때는 실제 데이터와 실제 사용 패턴이 있을 것이다. 더 정확한 판단을 할 수 있다.

profile
개발자 소희의 노트

0개의 댓글