N+1 문제는 데이터베이스에서 특정 게시글을 조회할 때 발생할 수 있는 비효율적인 쿼리 문제입니다. 예를 들어, 게시글을 조회할 때 해당 게시글에 달린 댓글을 한 번에 가져오지 않고, 각 댓글에 대해 별도의 쿼리가 실행되는 현상입니다. 이로 인해 쿼리가 불필요하게 많이 실행되며, 성능 저하를 일으킬 수 있습니다.
N+1 문제 예시:
게시글을 1번 조회하는 쿼리 1회.
각 게시글에 달린 댓글 수 만큼 추가 쿼리가 실행됩니다.
따라서 N+1 문제를 해결하려면 게시글과 그에 해당하는 댓글을 한 번의 쿼리로 조회할 수 있어야 합니다.
Exposed ORM에서는 직접 JOIN을 사용하여 한 번의 쿼리로 게시글과 댓글을 조회할 수 있습니다. 이를 통해 N+1 문제를 해결할 수 있습니다.
data class FeedResponse(
val id: Long,
val creatorId: Long,
val title: String,
val userName: String,
val content: String,
val createdAt: LocalDateTime,
val modifiedAt: LocalDateTime?,
val comments: List<CommentResponse>
) {
companion object {
fun from(feed: Feed, user: User): FeedResponse {
return FeedResponse(
id = feed.id.value,
creatorId = feed.creatorId.value,
title = feed.title,
content = feed.content,
userName = user.username,
comments = feed.getComments().map { CommentResponse.from(it, user) },
createdAt = feed.createdAt,
modifiedAt = feed.modifiedAt
)
}
}
}
게시글 조회를 위한 DTO입니다. 게시글과 댓글을 함께 반환할 수 있도록 comments 리스트를 포함합니다.
data class CommentResponse(
val id: Long,
val feedId: Long,
val creatorId: Long,
val userName: String,
val title: String,
val content: String,
val createdAt: LocalDateTime,
val modifiedAt: LocalDateTime?,
){
companion object {
fun from(comment: Comment, user:User): CommentResponse {
return CommentResponse(
id = comment.id.value,
feedId = comment.feedId.value,
creatorId = comment.creatorId.value,
title = comment.title,
userName = user.username,
content = comment.content,
createdAt = comment.createdAt,
modifiedAt = comment.modifiedAt
)
}
}
}
댓글 데이터를 반환하는 DTO입니다. 댓글 작성자의 이름, 댓글 내용 등 필요한 정보를 포함하고 있습니다.
게시글과 댓글은 Many-to-One 관계로 설정되어 있습니다. Exposed에서는 댓글이 게시글 ID만 참조하기 때문에 JPA와 다르게 JOIN을 사용하여 관계를 맺어야 합니다.
fun Feed.getComments(): List<Comment> {
return Comment.find { CommentTable.feedId eq this@getComments.id }.toList()
}
getComments 함수는 게시글에 해당하는 댓글을 조회하는 함수입니다.
fun findByIdWithComment(id: Long): FeedResponse? {
val commentUserAlias = UserTable.alias("comment_user")
val feedUserAlias = UserTable.alias("feed_user")
val rows = FeedTable
.join(feedUserAlias, JoinType.LEFT, FeedTable.creatorId, feedUserAlias[UserTable.id])
.join(CommentTable, JoinType.LEFT, FeedTable.id, CommentTable.feedId)
.join(commentUserAlias, JoinType.LEFT, CommentTable.creatorId, commentUserAlias[UserTable.id])
.select(
FeedTable.id,
FeedTable.creatorId,
FeedTable.title,
FeedTable.content,
FeedTable.createdAt,
FeedTable.modifiedAt,
feedUserAlias[UserTable.username],
CommentTable.id,
CommentTable.creatorId,
CommentTable.feedId,
CommentTable.content,
CommentTable.createdAt,
CommentTable.modifiedAt,
CommentTable.title,
commentUserAlias[UserTable.username]
)
.where { FeedTable.id eq id }
.toList()
if (rows.isEmpty()) return null
// feed, 작성자 정보는 첫 row에서만 가져도 충분
val firstRow = rows.first()
val feedId = firstRow[FeedTable.id].value
val creatorId = firstRow[FeedTable.creatorId].value
val title = firstRow[FeedTable.title]
val content = firstRow[FeedTable.content]
val createdAt = firstRow[FeedTable.createdAt]
val modifiedAt = firstRow[FeedTable.modifiedAt]
val userName = firstRow[feedUserAlias[UserTable.username]]
val comments = rows.mapNotNull { row ->
val commentIdEntity = row[CommentTable.id]
if (commentIdEntity == null) return@mapNotNull null // 댓글이 없으면 제외
val commentId = commentIdEntity.value
val commentContent = row[CommentTable.content]
val commentCreatedAt = row[CommentTable.createdAt]
val commentCreatorId = row[CommentTable.creatorId].value
val commentUserName = row[commentUserAlias[UserTable.username]]
val commentFeedId = row[CommentTable.feedId].value
val commentModifiedAt = row[CommentTable.modifiedAt]
val commentTitle = row[CommentTable.title]
CommentResponse(
id = commentId,
creatorId = commentCreatorId,
content = commentContent,
createdAt = commentCreatedAt,
userName = commentUserName,
feedId = commentFeedId,
title = commentTitle,
modifiedAt = commentModifiedAt,
)
}
return FeedResponse(
id = feedId,
creatorId = creatorId,
title = title,
content = content,
userName = userName,
createdAt = createdAt,
modifiedAt = modifiedAt,
comments = comments
)
}
findByIdWithComment 함수는 게시글과 그에 달린 댓글들을 한 번의 쿼리로 조회합니다.
SELECT
FEED.ID,
FEED.CREATOR_ID,
FEED.TITLE,
FEED.CONTENT,
FEED.CREATED_AT,
FEED.MODIFIED_AT,
feed_user."name",
COMMENT.ID,
COMMENT.CREATOR_ID,
COMMENT."feedId",
COMMENT.CONTENT,
COMMENT.CREATED,
COMMENT.MODIFIED,
COMMENT.TITLE,
comment_user."name"
FROM FEED
LEFT JOIN "user" feed_user ON FEED.CREATOR_ID = feed_user.ID
LEFT JOIN COMMENT ON FEED.ID = COMMENT."feedId"
LEFT JOIN "user" comment_user ON COMMENT.CREATOR_ID = comment_user.ID
WHERE FEED.ID = 1;
한 번의 SQL 쿼리로 게시글과 댓글 정보를 함께 조회할 수 있습니다.
Exposed ORM은 JPA와 달리 직접 SQL 쿼리와 DSL 방식으로 데이터를 처리하므로, 복잡한 관계를 효율적으로 처리할 수 있습니다. JOIN을 활용하여 게시글과 댓글을 한 번의 쿼리로 조회하면 N+1 문제를 해결할 수 있습니다.
Exposed ORM은 복잡한 연관 관계를 다룰 때 더 세밀한 제어를 가능하게 하며, JPA의 경우처럼 별도의 DTO를 정의할 필요 없이 직접 필요한 컬럼만을 select하여 처리할 수 있습니다. 다만, 코드가 조금 더 복잡해질 수 있지만 성능 상의 이점이 큽니다.