Kotlin Exposed ORM - 4

구워먹는 삼겹살·2025년 4월 6일
0

1. N+1 문제란?

N+1 문제는 데이터베이스에서 특정 게시글을 조회할 때 발생할 수 있는 비효율적인 쿼리 문제입니다. 예를 들어, 게시글을 조회할 때 해당 게시글에 달린 댓글을 한 번에 가져오지 않고, 각 댓글에 대해 별도의 쿼리가 실행되는 현상입니다. 이로 인해 쿼리가 불필요하게 많이 실행되며, 성능 저하를 일으킬 수 있습니다.

N+1 문제 예시:

게시글을 1번 조회하는 쿼리 1회.
각 게시글에 달린 댓글 수 만큼 추가 쿼리가 실행됩니다.
따라서 N+1 문제를 해결하려면 게시글과 그에 해당하는 댓글을 한 번의 쿼리로 조회할 수 있어야 합니다.

2. Exposed ORM에서 해결 방법

Exposed ORM에서는 직접 JOIN을 사용하여 한 번의 쿼리로 게시글과 댓글을 조회할 수 있습니다. 이를 통해 N+1 문제를 해결할 수 있습니다.

2.1 게시글 조회 응답 dto

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 리스트를 포함합니다.

2.2 댓글 응답 dto

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입니다. 댓글 작성자의 이름, 댓글 내용 등 필요한 정보를 포함하고 있습니다.

2.3. 댓글과 게시글 연관관계

게시글과 댓글은 Many-to-One 관계로 설정되어 있습니다. Exposed에서는 댓글이 게시글 ID만 참조하기 때문에 JPA와 다르게 JOIN을 사용하여 관계를 맺어야 합니다.

fun Feed.getComments(): List<Comment> {
    return Comment.find { CommentTable.feedId eq this@getComments.id }.toList()
}

getComments 함수는 게시글에 해당하는 댓글을 조회하는 함수입니다.

2.4 게시글 조회 시 댓글 참조

    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 함수는 게시글과 그에 달린 댓글들을 한 번의 쿼리로 조회합니다.

2.5 쿼리문

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 쿼리로 게시글과 댓글 정보를 함께 조회할 수 있습니다.

3. 결론

Exposed ORM은 JPA와 달리 직접 SQL 쿼리와 DSL 방식으로 데이터를 처리하므로, 복잡한 관계를 효율적으로 처리할 수 있습니다. JOIN을 활용하여 게시글과 댓글을 한 번의 쿼리로 조회하면 N+1 문제를 해결할 수 있습니다.

4. what I learned

Exposed ORM은 복잡한 연관 관계를 다룰 때 더 세밀한 제어를 가능하게 하며, JPA의 경우처럼 별도의 DTO를 정의할 필요 없이 직접 필요한 컬럼만을 select하여 처리할 수 있습니다. 다만, 코드가 조금 더 복잡해질 수 있지만 성능 상의 이점이 큽니다.

0개의 댓글