[Spring Boot] Controller, Service, Repository에서의 DTO 분리 그리고 네이밍에 대해서

각 계층에서의 DTO의 분리를 해야하는 이유

들어가며

오랜만에 포스팅하게 되었다. 그동안 회사도 바빴고, 개인적으로 Spring boot를 이용한 게시판을 만들고 있었다. 예전과는 다르게, 실무에서 겪었던 문제들을 바탕으로 해결책들을 녹여내기 위해서 노력했다. (해당 게시판 …홍보도 겸사겸사)

사실 백엔드 개발을 하며 제일 크게 문제라고 느꼈던 건, 네이밍과 관심사 분리였다.

필자는 서버를 Controller, Service, Repository, Infrasturcture 계층으로 나누어서 주로 개발한다. 여기까지는 관습적으로 잘 지켜오고 있었는데, 그 외의 DTO가 문제였다.

이에 대한 이야기와, 나름대로 해결책을 정리해 보겠다.

Request DTO를 모든 곳에서 사용하지 말자

제일 크게 문제였던 것은, Request DTO였다. 필자는 이전까지 Request DTO를 모든 계층에서 돌려쓸 때가 있었다.

class FooController(
    private val fooService: FooService
){
    fun getData(@RequestBody() body: DataRequest){
        return fooService.fetchData(body)
    }
}

class FooService(
    private val fooRepository: FooRepository
){
    fun fetchData(body: DataRequest){
        return fooRepository.findOne(body.id)
    }
}

해당 코드를 보면 Controller, Service 계층에서 같은 DataRequest 를 쓰고 있다.

실제로 겪어본 결과, 이 방식은 문제점이 많았다.

  1. FooServicefetchData 메소드는 FooController에서만 사용되는 것이 아니다.
    • 다른 곳에서 사용될 수가 있을 텐데, 파라미터가 DataRequest인 것은 이상하다. 마치 FooController에 종속된 것처럼 보인다.
  2. DataRequest가 변경되면 모든 레이어에 영향을 끼친다. 이때 컴파일 시 에러가 발생하지 않고 런타임 때 발생할 수 있다.
    • 이를 추적하여 수정하는 것은 많은 비용이 든다.

그렇다면 어떻게 해야 할까…?

DTO를 모든 계층에서 분리하자!

문제를 해결하기 위해 모든 계층에서 DTO를 만들어보려고 했다. 다만 이럴 때 문제점은 DTO 이름이 너무나도 중복이 많이 되는 것이 문제였다.

그래서 주위에 물어보거나, 많은 자료조사 끝에, 나름대로 철칙을 만들었다.

  1. 모든 계층에서의 parameter, return type을 분리한다.
  2. 네이밍을 아래와 같이 정한다.
    • Controller
      • prameter: ...Request
      • return: ...Response
    • Service
      • parameter: ...Command,
      • return: 서비스 메소드에 맞게 정한다.
    • Repository
      • parameter: ...Condition
      • return: ...Projection

이렇게 분리하게 되면, 앞서 이야기한 문제가 해결된다. 단점은 DTO를 많이 만들고 수정해야 한다는 것이다. 하지만 런타임 에러를 방지한다는 목적으로 보자면 매우 효율적인 비용이라고 생각한다.

이제 적용 예시를 보자.

게시글 목록 조회

// 컨트롤러
@Controller
class BoardController(
        private val boardService: BoardService
) {
    @PreAuthorize("permitAll")
    @GetMapping("posts")
    fun getPostSummaries(@Valid @ModelAttribute query: PostSummariesRequest): PostSummaries {
        val page = PostSummariesCommand.Page(query.pageNumber, query.pageSize)
        val sort = PostSummariesCommand.Sort(query.sortCriteria, query.sortDirection)
        var search: PostSummariesCommand.Search? = null
        if (query.searchQuery != null && query.searchCriteria != null)
            search = PostSummariesCommand.Search(query.searchQuery!!, query.searchCriteria!!)

        val findPostsInput = PostSummariesCommand(page, sort, search)

        return boardService.filterPostSummaries(findPostsInput)
    }
}
//서비스
@Service
class BoardService(
    private val postRepository: PostRepository,
    private val commentRepository: CommentRepository,
    private val userRepository: UserRepository
) {
    fun filterPostSummaries(input: PostSummariesCommand): PostSummaries {
        val orderCondition = PostSummariesCondition.Order(input.sort.direction.queryDslOrder, input.sort.criteria.value)
        val searchCondition = PostSummariesCondition.Search(input.search?.searchQuery, input.search?.searchCriteria?.value)

        // 페이지네이션 조건 객체
        val pageable = PageRequest.of(input.page.number, input.page.size)

        // 객체 조회
        val page = postRepository.findPostSummaries(PostSummariesCondition(searchCondition, orderCondition), pageable)

        val postDtoList = page.content.stream().map {
            PostSummaries.PostSummary(it.id, it.userId, it.title, it.content, it.createdAt, it.updatedAt)
        }.toList()

        return PostSummaries(postDtoList, page.number, page.totalPages, page.totalElements)
    }
}
// QueryDSL
class PostQueryDslImpl(
    private val queryFactory: JPAQueryFactory
) : PostQueryDsl {
    override fun findPostSummaries(condition: PostSummariesCondition, pageable: Pageable): Page<PostSummariesProjection> {
        val post = QPost.post

        val whereConditions = arrayOf(QPost.post.status.eq(Post.Status.PUBLISHED), condition.search.searchExpression)

        // 쿼리
        val query = queryFactory
            .select(
                Projections.constructor(
                    PostSummariesProjection::class.java,
                    post.id,
                    post.user.id,
                    post.title,
                    post.content.substring(0, 30),
                    post.createdAt,
                    post.updatedAt
                )
            )
            .from(post)
            .where(*whereConditions)
            .orderBy(condition.order.orderSpecifier)
            .offset(pageable.offset)
            .limit(pageable.pageSize.toLong())
        val countQuery = queryFactory
            .select(post.count())
            .from(post)
            .where(*whereConditions)

        // 쿼리 실행
        val posts = query.fetch()
        val count = countQuery.fetchOne()!!

        return PageImpl(posts, pageable, count)
    }
}

모든 계층에서, 자신의 DTO를 다른 계층의 DTO로 변환해서 호출하는 것을 볼 수 있다. 이런 방식을 통해 하나의 DTO가 변경되더라도 런타임 에러를 방지할 수 있다.

마무리하며

DTO 이슈는 현업을 진행하며 많이 고민이 되었던 문제였다. 이번에 Spring 개발을 진행하며 나름대로 생각 정리가 되어서 후련하다. 사실 이번 DTO 관련 이슈를 제외하고서도 나름대로 정리된 내용이 많다. 다음 포스팅에서는 DTO의 역할에 대해서도 생각을 정리해 보는 시간을 가져보도록 하겠다.