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() }
}
끔찍한 when과 if, else의 향연..
문제가 있다고 판단하여 검색을 하며 지인에게 자문을 구하니 동적 query를 짜보는게 어떻겠냐고 했다.
querydsl이라는 동적 query 빌더가 주로 많이 쓰이는 걸로 보여서 이걸 바탕으로 저 분기처리를 좀 완화해보기로했다.
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를 만들어준다.
@Configuration
class QuerydslConfiguration(
@PersistenceContext
private val entityManager: EntityManager,
) {
@Bean
fun jpaQueryFactory(): JPAQueryFactory = JPAQueryFactory(entityManager)
}
QuerydslConfiguration 에서는 JPAQueryFactory로 entityManager를 넣어서 초기화 해주는 과정이 필요하다.
@PersistenceContext 어노테이션이 빠지면 제대로 동작하지 않는다고 하니 유의해야한다.
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에 대해 오름차순으로 정렬할 지 내림차순으로 정렬할지를 동적으로 결정할 수 있다.
앞서 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을 이용해서 구현해봤다.
좀 더 고민해보고 맞다고 생각하는 방향으로 고쳐봐야겠다.
https://github.com/T0nixx/TodoAPI
kapt는 유지보수 단계로 들어갔고 더 이상 추가 개발이 없다고 한다. Querydsl을 사용하려면 kapt를 필수적으로 사용해야하기 때문에 다른 동적 query 빌더들로 넘어가는 추세인 것 같다. 그 중 하나가 라인에서 만든 Kotlin JDSL이라는게 있다고 한다. 다음에는 이것도 한번 사용해봐야겠다.