Kotlin에서 Querydsl 사용해보기

오지석·2024년 5월 24일

발단

Todo 목록 조회를 구현하는 중에 점점 요구사항과 그에 따른 분기처리가 늘어났다.

override fun getTodoList(
    sortDirection: Sort.Direction,
    writerId: Long?,
    cursor: Long,
): List<TodoResponseDto> {
    val pageable = PageRequest.of(0, 10, sortDirection, "createdAt")

    val todos = when {
        cursor == 0L -> {
            if (writerId == null) todoRepository.findAll(pageable)
            else {
                todoRepository.findAllByWriter(
                    writerId,
                    pageable,
                )
            }
        }

        sortDirection == Sort.Direction.DESC -> {
            if (writerId == null) todoRepository.findNextTodoPage(
                cursor,
                pageable,
            )
            else todoRepository.findNextTodoPageByWriter(
                cursor,
                writerId,
                pageable,
            )
        }

        else -> { // ASC
            if (writerId == null) todoRepository.findPreviousTodoPage(
                cursor,
                pageable,
            )
            else todoRepository.findPreviousTodoPageByWriter(
                cursor,
                writerId,
                pageable,
            )
        }
    }
    return todos.map { it.toResponseDto() }
}

끔찍한 whenif, else의 향연..
문제가 있다고 판단하여 검색을 하며 지인에게 자문을 구하니 동적 query를 짜보는게 어떻겠냐고 했다.
querydsl이라는 동적 query 빌더가 주로 많이 쓰이는 걸로 보여서 이걸 바탕으로 저 분기처리를 좀 완화해보기로했다.

build.gradle

querydsl을 kotlin에서 사용하기 위해 명시해줘야하는 의존성은 다음과 같다

plugins {
  	kotlin("kapt") version "2.0.0"
  	...
}

dependencies {
    implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")
    kapt("com.querydsl:querydsl-apt:5.0.0:jakarta")
}

다시 dependencies 쪽에 kapt는 build하기전에는 빨간 줄이 그일 수 있지만 일단 빌드하면 문제없이 작동한다.
검색해본 옛날 글들은 dependencies 맨 뒤쪽이 jakarta가 아니고 jpa 인 글들이 있는데 이름이 바뀐 걸로 알아서 그렇게 바꿨다.

kapt는 kotlin annotation을 처리해주는 플러그인으로 제대로 빌드가 되고 나면

이렇게 build 폴더 안에 Querydsl이 entity에 대한 QClass를 만들어준다.

QuerydslConfiguration

@Configuration
class QuerydslConfiguration(
    @PersistenceContext
    private val entityManager: EntityManager,
) {
    @Bean
    fun jpaQueryFactory(): JPAQueryFactory = JPAQueryFactory(entityManager)
}

QuerydslConfiguration 에서는 JPAQueryFactory로 entityManager를 넣어서 초기화 해주는 과정이 필요하다.
@PersistenceContext 어노테이션이 빠지면 제대로 동작하지 않는다고 하니 유의해야한다.

Querydsl을 사용한 Repository

class CustomTodoRepositoryImpl(private val queryFactory: JPAQueryFactory) :
    CustomTodoRepository {
    private val todo = QTodo.todo
    private fun getOrderSpecifier(sortDirection: Direction): OrderSpecifier<Instant> {
        return if (sortDirection == Direction.DESC == true) todo.createdAt.desc() else todo.createdAt.asc()
    }
	...
    override fun findPageFromCursorByWriterId(
        cursor: Long,
        writerId: Long,
        sortDirection: Direction,
    ): List<Todo> {
        val todo = QTodo.todo
        val isDescending = sortDirection == Direction.DESC
        val gtOrLtId =
            if (isDescending == true) todo.id.lt(cursor) else todo.id.gt(cursor)

        return queryFactory
            .selectFrom(todo)
            .leftJoin(todo.writer, QUser.user).fetchJoin()
            .where(gtOrLtId.and(todo.writer.id.eq(writerId)))
            .orderBy(getOrderSpecifier(sortDirection))
            .limit(PAGE_SIZE)
            .fetch()
    }

아까 분기처리가 많이 생겼던 이유 중 하나는 JpaRepository가 지원하는 메소드를 사용하면 정렬이 내림차순인지 오름차순인지에 따라 cursor 보다 큰 id를 찾을지 작은 id를 찾을지가 결정되기 때문이다.
QuerydslConfiguration에서 선언해둔 queryFactory를 주입받아서 sortDirection에 따라 미리 커서에 대해 GreaterThan을 할 지 LessThan을 할 지, createdAt에 대해 오름차순으로 정렬할 지 내림차순으로 정렬할지를 동적으로 결정할 수 있다.

JpaRepository와 같이 쓰기

앞서 CustomTodoRepositoryImpl은 CustomTodoRepository을 상속받고 있다.
CustomTodoRepository는 간단히 어떤 것들을 Querydsl을 이용해 구현할 지 선언해둔 interface 파일이다.
이렇게 Interface와 Impl을 분리하는 이유는 일반적으로 쓰던 JpaRepository에 미리 선언된 메소드들을 사용하기 위해서이다.
처음에는

@Service
class TodoServiceImpl(
    private val todoRepository: TodoRepository,
    private val todoRepositoryWithQuerydsl: TodoRepositoryWithQuerydsl,
    private val commentRepository: CommentRepository,
    private val userRepository: UserRepository,
) : TodoService {

이런식으로 interface 없이 바로 구현체를 넣어서 두개를 따로 썼지만 비슷한 역할을 하는 것이 분리되어 있는 것이 가독성이 떨어진다고 생각해서 검색을 진행했다.
공식 문서를 보니 앞서 말한 것 처럼 CustomTodoRepository, CustomTodoRepositoryImpl을 만들고 그리고 TodoRepository가 JpaRepository와 CustomTodoRepository를 상속하는 형식을 취하라고 했다.

interface TodoRepository : JpaRepository<Todo, Long>,
    CustomTodoRepository {
}

이러면 TodoRepository는 JpaRepository의 메소드와 CustomTodoRepository의 메소드를 모두 가지게 되고 스프링이 그 구현체를 주입해주게 된다.

리팩토링 후 코드

override fun getTodoList(
        sortDirection: Sort.Direction,
        writerId: Long?,
        cursor: Long,
    ): List<TodoResponseDto> {
        val todos = when {
            cursor == 0L -> {
                if (writerId == null) todoRepository.findPage(
                    sortDirection,
                )
                else {
                    todoRepository.findPageByWriterId(
                        writerId = writerId,
                        sortDirection = sortDirection,
                    )
                }
            }

            else -> {
                if (writerId == null) todoRepository.findPageFromCursor(
                    cursor = cursor,
                    sortDirection = sortDirection,
                )
                else todoRepository.findPageFromCursorByWriterId(
                    cursor = cursor,
                    writerId = writerId,
                    sortDirection = sortDirection,
                )
            }
        }
        return todos.map { it.toResponseDto() }
    }

이렇게 sortDirection과 관련된 분기는 사라졌다.
cursor나 writerId와 관련된 내용도 하나의 메소드 안에서 분기처리를 하는 식으로 한다면 할 수는 있지만 어디까지가 Repository Layer의 역할이고 어디까지가 Service Layer의 역할인지 확신이 서지 않아서 일단은 sortDirection 관련 내용만 Querydsl을 이용해서 구현해봤다.
좀 더 고민해보고 맞다고 생각하는 방향으로 고쳐봐야겠다.

참고

Github

https://github.com/T0nixx/TodoAPI

여담

kapt는 유지보수 단계로 들어갔고 더 이상 추가 개발이 없다고 한다. Querydsl을 사용하려면 kapt를 필수적으로 사용해야하기 때문에 다른 동적 query 빌더들로 넘어가는 추세인 것 같다. 그 중 하나가 라인에서 만든 Kotlin JDSL이라는게 있다고 한다. 다음에는 이것도 한번 사용해봐야겠다.

0개의 댓글