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

개발 초기에 Post 공통 도메인을 둔 이유는 Record, Vote, Feed 모두 게시글이라는 같은 성격의 도메인으로 해석했기 때문에 댓글 수, 좋아요 수, 글쓴이, 글 컨텐츠 같은 공통 컬럼을 활용하고자 하는 목적이였습니다.
실제로 공통 도메인을 활용했을 때 제가 느낀 효과는 다음과 같습니다.
OOP가 지켜지며 가독성과 유지보수성이 좋다.
Post라는 추상적인 개념을 통해 Record, Vote, Feed가 모두 '게시물'의 한 종류라는 것을 명확히 표현할 수 있습니다. 이는 코드의 의도를 파악하기 쉽게 만듭니다.Post 클래스에 캡슐화하여 코드 중복을 피하고, 다형성을 활용해 post.updateLikeCount()와 같이 게시물의 구체적인 타입에 의존하지 않는 공통 메서드를 호출할 수 있어 유지보수 비용이 감소합니다.공통 도메인인 만큼 연관된 도메인을 다루기 쉬워진다.
예를 들어 Comment(댓글) 엔티티는 어떤 종류의 게시물에 달려있는지 구분할 필요 없이, 단일한 Post에 대한 연관 관계(@ManyToOne private Post post)만 설정하면 됩니다. 만약 공통 도메인이 없다면 Comment 엔티티는 record_id, vote_id, feed_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
?