문제 인식

현재 저희 JPA 구조는 다음과 같이 JPA 상속 전략 중 SINGLE_TABLE 전략을 사용하고 있는데요.

image.png

개발 초기에 Post 공통 도메인을 둔 이유는 Record, Vote, Feed 모두 게시글이라는 같은 성격의 도메인으로 해석했기 때문에 댓글 수, 좋아요 수, 글쓴이, 글 컨텐츠 같은 공통 컬럼을 활용하고자 하는 목적이였습니다.

실제로 공통 도메인을 활용했을 때 제가 느낀 효과는 다음과 같습니다.

  1. OOP가 지켜지며 가독성과 유지보수성이 좋다.

  2. 공통 도메인인 만큼 연관된 도메인을 다루기 쉬워진다.

    예를 들어 Comment(댓글) 엔티티는 어떤 종류의 게시물에 달려있는지 구분할 필요 없이, 단일한 Post에 대한 연관 관계(@ManyToOne private Post post)만 설정하면 됩니다. 만약 공통 도메인이 없다면 Comment 엔티티는 record_idvote_idfeed_id를 각각의 외래 키로 가져야 했을 것이며, 이는 스키마와 관련 로직을 복잡하게 만듭니다. Like나 Notification 같은 다른 연관 도메인에도 동일한 이점이 적용됩니다.

하지만, 점점 개발을 하다보니 Record/Vote와 Feed는 다른 성격을 띄고 있다는 것이 느껴지고 이 부분은 기록 조회 API에서 가장 큰 문제로 다가왔습니다.

기록장 조회 API는 방에 존재하는 모든 게시글을 조회하는 API다 보니 Record와 Vote 모두 조회해야 하므로 Post 테이블로 바로 접근해서 Record와 Vote를 가져오도록 했습니다.

하지만, Post 테이블에는 방 게시글의 성격을 띄고 있는 총평 기록 여부, 책 페이지, 관련된 방에 대한 컬럼이 없기 때문에 Querydsl에서 treat 메서드를 활용하여 Post를 다운캐스팅 한 다음, 컬럼을 접근해야 했습니다.

다음은 기존 Querydsl 로직입니다.

@Override
public List<RoomPostQueryDto> findGroupRecordsOrderBySortType(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview, RoomPostSortType roomPostSortType) {
    BooleanBuilder where = buildRecordVoteCondition(roomId, pageStart, pageEnd, isOverview);

    if (!cursor.isFirstRequest()) {
        where.and(buildCursorPredicateForSortType(roomPostSortType, cursor));
    }

    return queryFactory
            .select(selectPostQueryDto())
            .from(post)
            .join(post.userJpaEntity, user)
            .where(where)
            .orderBy(getOrderSpecifiers(roomPostSortType))
            .limit(cursor.getPageSize() + 1)
            .fetch();
}

private BooleanBuilder buildRecordVoteCondition(Long roomId, Integer pageStart, Integer pageEnd, Boolean isOverview) {
    BooleanBuilder where = new BooleanBuilder();

    // VOTE
    BooleanBuilder voteCondition = new BooleanBuilder()
            .and(post.dtype.eq(VOTE.getType()))
            .and(treat(post, QVoteJpaEntity.class).roomJpaEntity.roomId.eq(roomId));

    if (isOverview) {
        voteCondition.and(treat(post, QVoteJpaEntity.class).isOverview.isTrue());
    } else {
        voteCondition.and(treat(post, QVoteJpaEntity.class).isOverview.isFalse())
                .and(treat(post, QVoteJpaEntity.class).page.between(pageStart, pageEnd));
    }

    // RECORD
    BooleanBuilder recordCondition = new BooleanBuilder()
            .and(post.dtype.eq(RECORD.getType()))
            .and(treat(post, QRecordJpaEntity.class).roomJpaEntity.roomId.eq(roomId));

    if (isOverview) {
        recordCondition.and(treat(post, QRecordJpaEntity.class).isOverview.isTrue());
    } else {
        recordCondition.and(treat(post, QRecordJpaEntity.class).isOverview.isFalse())
                .and(treat(post, QRecordJpaEntity.class).page.between(pageStart, pageEnd));
    }

    where.and(voteCondition.or(recordCondition));

    return where;
}

// Case: pageExpr (Record, Vote 분기)
private NumberExpression<Integer> pageExpr() {
    return new CaseBuilder()
            .when(post.dtype.eq(RECORD.getType()))
            .then(treat(post, QRecordJpaEntity.class).page)
            .when(post.dtype.eq(VOTE.getType()))
            .then(treat(post, QVoteJpaEntity.class).page)
            .otherwise(0);
}

// Case: isOverviewExpr (총평 여부를 정렬 기준으로 사용)
private NumberExpression<Integer> isOverviewExpr() {
    return new CaseBuilder()
            .when(post.dtype.eq(RECORD.getType()))
            .then(treat(post, QRecordJpaEntity.class).isOverview.castToNum(Integer.class))
            .when(post.dtype.eq(VOTE.getType()))
            .then(treat(post, QVoteJpaEntity.class).isOverview.castToNum(Integer.class))
            .otherwise(0);
}

private OrderSpecifier<?>[] getOrderSpecifiers(RoomPostSortType roomPostSortType) {
    return switch (roomPostSortType) {
        case CREATED_AT -> new OrderSpecifier[] { post.postId.desc() };
        case LIKE_COUNT -> new OrderSpecifier[] { post.likeCount.desc(), post.postId.desc() };
        case COMMENT_COUNT -> new OrderSpecifier[] { post.commentCount.desc(), post.postId.desc() };
        case MINE -> new OrderSpecifier[] { isOverviewExpr().desc(), pageExpr().desc(), post.postId.desc() };
    };
}

treat 메서드를 사용하게 되면서 SQL 쿼리문에는 다음과 같이 CASE문이 등장하게 됩니다.

select
        pje1_0.post_id,
        pje1_0.dtype,
        pje1_0.created_at,
        case 
            when (pje1_0.dtype=?) 
                then case 
                    when pje1_0.dtype='RECORD' 
                        then pje1_0.page 
                end 
            when (pje1_0.dtype=?) 
                then case 
                    when pje1_0.dtype='VOTE' 
                        then pje1_0.page 
                end 
            else 0 
    end,
    uje1_0.user_id,
    uje1_0.nickname,
    uje1_0.alias,
    pje1_0.content,
    pje1_0.like_count,
    pje1_0.comment_count,
    case 
        when (pje1_0.dtype=?) 
            then cast(case 
                when pje1_0.dtype='RECORD' 
                    then pje1_0.is_overview 
            end as signed) 
        when (pje1_0.dtype=?) 
            then cast(case 
                when pje1_0.dtype='VOTE' 
                    then pje1_0.is_overview 
            end as signed) 
        else 0 
end=cast(? as signed) 
from
    posts pje1_0 
join
    users uje1_0 
        on uje1_0.user_id=pje1_0.user_id 
where
    pje1_0.status in (?) 
    and (
        pje1_0.dtype=? 
        and pje1_0.room_id=? 
        and case 
            when pje1_0.dtype='VOTE' 
                then pje1_0.is_overview 
        end=? 
        and case 
            when pje1_0.dtype='VOTE' 
                then pje1_0.page 
        end between ? and ? 
        or pje1_0.dtype=? 
        and pje1_0.room_id=? 
        and case 
            when pje1_0.dtype='RECORD' 
                then pje1_0.is_overview 
        end=? 
        and case 
            when pje1_0.dtype='RECORD' 
                then pje1_0.page 
        end between ? and ?
    ) 
order by
    pje1_0.like_count desc,
    pje1_0.post_id desc 
limit
    ?

CASE문이 나오는 이유