line / kotlin-jdsl

Kotlin library that makes it easy to build and execute queries without generated metamodel
https://kotlin-jdsl.gitbook.io/docs/
Apache License 2.0
703 stars 85 forks source link

n+1 problem #247

Closed teaseon closed 1 year ago

teaseon commented 1 year ago

현재 n+1 문제가 발생하고 있는 상황입니다. 현재 상황은, Post가 ManyToOne을 통해 Member를 보고 있습니다.

i got a n+1 problem. this situation is, Post Entity is connected with Member Entity through ManyToOne annotation.

image

이후 jdsl을 통해 fetch join을 구현했습니다. and after that, i do Fetch Join with JDSL

image

이에 대한 로그로 이렇게 쿼리가 두개가 나오고 있는데, 잘못 사용하고 있거나 따로 설정해야 하는 것들이 있을까요? as this log, that code makes twice query. so am i wrong or should i configue anything else?

image
shouwn commented 1 year ago

안녕하세요.

이슈 상황을 재현하기 위해 최대한 작성해주신 것과 비슷한 구조로 Entity를 작성하고 테스트 해보았는데요. 제 로컬에서는 inner join이 포함된 쿼리가 완성 되었습니다.

select post0_.id            as id1_43_0_,
       member1_.id          as id1_11_1_,
       post0_.created_at    as created_2_43_0_,
       post0_.modified_at   as modified3_43_0_,
       post0_.content       as content4_43_0_,
       post0_.member_id     as member_i6_43_0_,
       post0_.title         as title5_43_0_,
       member1_.created_at  as created_2_11_1_,
       member1_.modified_at as modified3_11_1_,
       member1_.email       as email4_11_1_,
       member1_.role        as role5_11_1_
from post post0_
         inner join member member1_ on post0_.member_id = member1_.id
order by post0_.id asc
limit 10

작성해주신 Entity 구조 전체를 봐야 실제 상세 내용을 알 수 있을 것으로 보이는데요. 괜찮으시다면 Entity 내용과 테스트에 사용하셨던 샘플 데이터를 알 수 있을까요?

제가 작성한 Entity는 아래와 같습니다.

@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class AuditingEntity {
    @Id
    @Column(name = "id", updatable = false, nullable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0

    @CreatedDate
    lateinit var createdAt: OffsetDateTime

    @LastModifiedDate
    lateinit var modifiedAt: OffsetDateTime
}

@Entity
@Table(name = "post")
class Post(
    title: String,
    content: String,
    member: Member,
) : AuditingEntity() {
    @Column(name = "title", nullable = false)
    var title: String = title

    @Column(name = "content", nullable = false)
    var content: String = content

    @ManyToOne(fetch = FetchType.LAZY, targetEntity = Member::class)
    val member: Member = member

    override fun toString(): String {
        return "Post(title='$title', content='$content', member=$member)"
    }
}

@Entity
@Table(name = "member")
class Member(
    email: String,
    role: String,
) : AuditingEntity() {
    @Column(name = "email", nullable = false)
    val email: String = email

    @Column(name = "role", updatable = false, nullable = false)
    var role: String = role
}

fun test() {
        val member1 = Member(email = "test1@gmail.com", role = "ADMIN")
        val member2 = Member(email = "test2@gmail.com", role = "MEMBER")

        val post1 = Post(title = "This is test1", content = "This is test for ManyToOne", member = member1)
        val post2 = Post(title = "This is test2", content = "This is test for ManyToOne", member = member1)
        val post3 = Post(title = "This is test3", content = "This is test for ManyToOne", member = member1)

        entityManager.persistAll(
            member1,
            member2,
            post1,
            post2,
            post3,
        )

        entityManager.flushAndClear()

        val pageable = PageRequest.of(0, 10)

        val posts = queryFactory.listQuery<Post> {
            select(entity(Post::class))
            from(entity(Post::class))
            fetch(Post::member)
            limit(pageable.pageSize)
            offset(pageable.offset.toInt())
            orderBy(column(Post::id).asc())
        }

        println(posts)
}
teaseon commented 1 year ago

답변 감사합니다.

제 관점에서는 직접 테스트 해주신 코드와 제 코드를 대조해보았을 때 크게 다른 점은 없어보입니다. 그래도 혹시 모르니 코드 올려봅니다.

Entity 전체 코드입니다.


// AuditingEntity
@EntityListeners(AuditingEntityListener::class)
@MappedSuperclass
abstract class AuditingEntity(): AuditingEntityId() {
    @CreatedDate
    @Column(name = "create_dt", nullable = false, updatable = false)
    lateinit var createDT : LocalDateTime
        protected set

    @LastModifiedDate
    @Column(name = "update_dt")
    lateinit var updateDT : LocalDateTime
        protected set
}

@EntityListeners(value = [AuditingEntityListener::class])
@MappedSuperclass
abstract class AuditingEntityId : Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id:Long? = null
}
// AuditingEntity

// PostEntity
@Entity
@Table(name = "Post")
class Post (
    title:String,
    content:String,
    member: Member
) : AuditingEntity() {
    @Column(name = "title", nullable = false)
    var title:String = title
        protected set

    @Column(name = "content")
    var content:String = content
        protected set

    @ManyToOne(fetch = FetchType.LAZY, targetEntity = Member::class)
    var member:Member = member
        protected set

    override fun toString(): String {
        return "Post(id=$id, title=$title, content=$content, member=$member)"
    }
}

fun Post.toDto() : PostRes = PostRes(
    id = this.id!!,
    title = this.title,
    content = this.content,
    member = this.member.toDto()
)
// PostEntity

// MemberEntity
@Entity
@Table(name = "Member")
class Member (
    email:String,
    password:String,
    role:Role
) :AuditingEntity() {

    @Column(name = "email", nullable = false)
    var email:String = email
        protected set

    @Column(name = "password", nullable = false)
    var password:String = password
        protected set

    @Enumerated(EnumType.STRING)
    var role:Role = role
        protected set

    override fun toString(): String {
        return "Member(id=$id, email=$email, password=$password, role=$role)"
    }

    companion object {
        fun createFakeMember(memberId:Long): Member {
            val member = Member(" ", " ", Role.USER)
            member.id = memberId
            return member
        }
    }
}

fun Member.toDto() : MemberRes = MemberRes(
    id = this.id!!,
    email = this.email,
    password = this.password,
    role = this.role
)

enum class Role {
    USER, ADMIN
}
// MemberEntity

Repository 입니다.

interface PostRepository : JpaRepository<Post, Long> {

}

interface PostCustomRepository {

    fun findPosts(pageable: Pageable): Page<Post>
}

class PostCustomRepositoryImpl (
    private val queryFactory: SpringDataQueryFactory
) : PostCustomRepository {
    override fun findPosts (pageable: Pageable) : Page<Post> {

//        jdsl
        val postPage = queryFactory.listQuery<Post> {
            select(entity(Post::class))
            from(entity(Post::class))
            fetch(Post::member)
            limit(pageable.pageSize)
            offset(pageable.offset.toInt())
            orderBy(ExpressionOrderSpec(column(Post::id), false))
        }

        val count = queryFactory.listQuery<Post> {
            select(entity(Post::class))
            from(entity(Post::class))
        }

        return PageableExecutionUtils.getPage(postPage, pageable){
            count.size.toLong()
        }
    }
}
shouwn commented 1 year ago

주신 코드를 통해 확인해 보았을 때에도 동일하게 제 로컬에서는 fetch join이 정상 동작하네요. 😢

가능성은 적지만 Transactional 이슈일 가능성이 떠올라서 PostCustomeRepositoryImpl에 Transactional 어노테이션의 추가를 한 뒤에 한번 확인해주세요.

Hibernate가 사용하시는 DB가 Join을 지원 안 한다고 판단해서 안 했다거나...

teaseon commented 1 year ago

음,, 일단 MariaDB를 사용하고 있고 따로 커스텀한 것은 없습니다. 혹시 jdsl이나 다른 라이브러리들의 버전 문제일 가능성도 있을까요??

jdsl은 spring-data-kotlin-jdsl-starter-jakarta:2.2.1 버전 사용하고 있습니다.

shouwn commented 1 year ago

작성해주신 Entity의 이름을 통해 미루어 보면 사내 프로젝트가 아닌 개인 연습용 프로젝트로 생각이 드는데요. 혹시 개인 레포에 올려주실 수 있으신가요? 현재 제가 코드나 디펜던시 관계를 파악할 수 없어 개인 레포에 올려주시면 pull을 받아서 확인해보려고요.

teaseon commented 1 year ago

@shouwn 관심 가져주셔서 감사합니다. 현재 kotlin + jpa 환경을 혼자 학습해보고자 만들어보고 있었습니다. https://github.com/teaseon/kotlin_blog 프로젝트 링크입니다.

shouwn commented 1 year ago

공유 주신 프로젝트 링크를 통해 확인해보니 PostCustomRepository를 사용하지 않고 JPA에서 기본으로 제공하는 findAll을 통해 조회하신 것으로 추측됩니다.

PostRepository에 PostCustomRepository를 상속해서 PostService를 수정하니 정상적으로 동작하는 것을 H2 DB를 이용해 확인했습니다.

teaseon commented 1 year ago

@shouwn 와 너무 감사합니다. 제가 어리석었군요. 확인 후 수정 통해 해결헀습니다. 감사합니다.