Backend/Spring Framework

[Spring Boot] DTO객체 알뜰살뜰하게 사용하기

mopil 2023. 12. 5. 15:28
반응형

서버 어플리케이션에서 DTO를 만들어서 사용하는 것은 아주 흔한 일이다.

 

이 글에서는 필자가 서버 어플리케이션을 개발할 때 사용하는 DTO 명명법과 역할에 대한 생각을 공유하고자 한다.

 

# DTO의 명칭

필자는 DTO 이름을 명명할 때 다음과 같은 관례를 따르도록 작성하고 있다.

 

~Reqeust

요청 query param이나 request body, 컨트롤러가 받는 요청 객체

ex) UpdateUserNicknameRequest

 

~Response

응답 DTO

ex) UpdateUserNicknameResponse

  • Request,Response에는 꼭 행위(Create, Update, Get)를 붙힌다.
  • 파일 정렬등을 고려하면 도메인명을 앞으로 빼도 무방하다 (UserUpdateNicknameRequest)

 

~Dto

  • 요청/응답 필드가 동일한 경우
  • 서비스레이어에서의 중간 정보 전달용 객체

ex) UserInfoDto

 

~Params

  • 데이터 액세스 계층으로 넘기는 파라미터들 (QueryDSL, MyBatis 파라미터들)

ex) SearchFeedsParams

 


DTO의 역할은 서비스레이어에 포함될 필요가 없는 코드를 일부 가져오는 역할이라고 생각한다.

즉, 서비스레이어에 굳이 포함될 필요는 없지만 포함되어서 서비스 함수의 라인수를 증가시키는 요소들을 DTO 객체로 위임하는 것이다.

 

# 역할1. Swagger 명시

API 문서화로 Swagger를 많이 사용하는데, DTO 객체에게 Swagger 문서 생성용 어노테이션을 선언한다. (특히 Request)

data class UpdateUserNicknameRequest(
    @ApiModelProperty(value = "새로운 닉네임", required = true, example = "mopil")
    val newNickname: String
)

 

# 역할2. 검증 수행

검증은 서비스레이어에서 진행해야 하는 경우도 많지만, 우선적으로 DTO에서 수행한다.

data class UpdateUserNicknameRequest(
    @field:NotBlank
    @field:Size(max = 20)
    val newNickname: String
)

 

 

Bean Validation을 최우선적으로 적용하고, 이로 검증할 수 없는 로직은 따로 메서드로 분리하거나 Validator 객체를 만들어서 관리한다.

 

data class UpdateUserNicknameRequest(
    @field:NotBlank
    @field:Size(max = 20)
    val newNickname: String
) {
    fun validate() {
        // 데이터베이스 접근 없이 호출 가능 -> 컨트롤러 레이어에서 호출
        require(newNickname !in forbbidenNicknames) {
            "금지 목록에 있는 닉네임 입니다."
        }
    }
}

 

validate 호출은 보통 컨트롤러 레이어에서 하고 서비스 레이어로 넘겨준다.

다만 데이터베이스를 접근해서 값을 체크하고 검증해야하는 경우는 서비스레이어에서 수행한다.

 

data class UpdateUserNicknameRequest(
    @field:NotBlank
    @field:Size(max = 20)
    val newNickname: String
) {
    fun validate(beforeNicknames: List<String>) {
        // beforeNicknames는 데이터베이스에서 가져와야함 -> 서비스레이어 호출
        require(newNickname !in beforeNicknames) {
            "이미 사용했던 닉네임입니다."
        }
    }
}

 

만약 위 두개의 모두 validate가 필요하면 서비스레이어에서 한번에 검증을 수행한다.

 

# 역할3. 엔티티 변환 or 다른 DTO 변환 메서드

request로 받은 값을 엔티티로 변환해서 저장해야하는 비즈니스 로직은 DTO에 위임한다.

    data class CreateUserRequest(
        val nickname: String,
        val password: String
    ) {
        fun toEntity(): User = User(nickname, password)
    }

 

이는 요청 필드가 많아질 수록 서비스레이어의 코드가 깔끔해지는 효과를 얻을 수 있다.

 

as is

    fun createUser(request: CreateUserRequest) {
        val user = User(
            nickname = request.nickname,
            password = request.password,
            address = request.address,
            addressDetail = request.addressDetail,
            phoneNumber = request.phoneNumber,
            email = request.email,
            age = request.age,
            gender = request.gender,
            job = request.job,
            hobby = request.hobby,
            favorite = request.favorite,
            favoriteType = request.favoriteType,
            favoriteColor = request.favoriteColor,
            favoriteAnimal = request.favoriteAnimal,
            favoriteFood = request.favoriteFood,
            favoriteSeason = request.favoriteSeason,
            favoritePlace = request.favoritePlace,
            favoriteMovie = request.favoriteMovie,
            favoriteMusic = request.favoriteMusic,
        )
        repository.save(user)
    }

 

to be

    fun createUser(request: CreateUserRequest) {
        repository.save(request.toEntity())
    }

 

 

# 역할4. Request 객체 가공

요청 필드로 받은 값을 적절하게 가공해야하는 경우 DTO에서 이를 수행한다.

    data class UpdateUserInfoRequest(
        val password: String
    ) {
       fun encryptPassword() {
           // 암호화
       }
    }

 

    data class UpdateUserInfoRequest(
        var birthdate: LocaDate?
    ) {
       fun process() {
           if (birthdate == null) {
                birthdate = LocaDate.now()
           }
       }
    }

 

물론 이런 가공 메서드 호출은 컨트롤러에서 호출하고 서비스레이어로 넘겨준다.

 

# DTO 생성 방법에는 팩토리 메서드 패턴을 활용하라

유저 전체를 가져와서 일반 유저와 VIP 유저를 나눠서 리턴하는 함수를 가정해보자.

 

일반적으로 Response DTO 객체를 만들 때 다음과 같이 만들 수 있다.

    data class SearchUserListResponse(
        val totalCount: Int,
        val normalUsers: List<User>,
        val normalUserCount: Int,
        val vipUsers: List<User>,
        val vipUserCount: Int,
    )

    fun getAllUsers() {
        val users = userRepository.findAll()
        val normalUsers = users.filter { it.isVip == false }
        val vipUsers = users.filter { it.isVip == true }
        val totalCount = users.size
        val normalUserCount = normalUsers.size
        val vipUserCount = vipUsers.size
        return SearchUserListResponse(
            totalCount = totalCount,
            normalUsers = normalUsers,
            normalUserCount = normalUserCount,
            vipUsers = vipUsers,
            vipUserCount = vipUserCount,
        )   
    }

 

리팩토링1. second constructor를 사용

DTO 객체의 second constructor를 사용하여 서비스 레이어의 코드를 DTO로 옮길 수 있다.

이러면 훨씬 깔끔해진다.

    data class SearchUserListResponse(
        val totalCount: Int,
        val normalUsers: List<User>,
        val normalUserCount: Int,
        val vipUsers: List<User>,
        val vipUserCount: Int,
    ) {
        constructor(users: List<User>) : this(
            totalCount = users.size,
            normalUsers = users.filter { it.isVip == false },
            normalUserCount = users.filter { it.isVip == false }.size,
            vipUsers = users.filter { it.isVip == true },
            vipUserCount = users.filter { it.isVip == true }.size,
    }

    fun getAllUsers() {
        val users = repository.findAll()
        return SearchUserListResponse(users)
    }

 

그런데 만약 다음과 같은 비즈니스 로직이 추가되었다고 가정해보자

    fun getAllUsers() {
        val users = repository.findAll()
        val onlineUsers = users.filter { it.isOnline == true }
        val offlineUsers = users.filter { it.isOnline == false }
        if (onlineUsers.size > offlineUsers) {
            // 온라인 유저가 더 많을 땐 vip는 빼고 리턴
        } else {
            // 오프라인 유저가 더 많을 땐 vip도 포함해서 리턴
        }
    }

 

이러면 위에서 만든 second constructor로는 표현하기 어렵다.

 

리팩토링2. 팩토리 메서드 적용

companion object를 활용하여 팩토리 메서드 패턴으로 생성자를 만들 수 있다.

이러면 생성자에 명칭도 부여할 수 있어서 second constructor를 사용한 방식보다 훨씬 유연하게 구조를 가져갈 수 있다.

   data class SearchUserListResponse(
        val totalCount: Int,
        val normalUsers: List<User>,
        val normalUserCount: Int,
        val vipUsers: List<User>,
        val vipUserCount: Int,
    ) {
        companion object {
            fun ofWithVips(users: List<User>): SearchUserListResponse {
                val vipUsers = users.filter { it.isVip == true }
                return SearchUserListResponse(
                    totalCount = users.size,
                    normalUsers = emptyList(),
                    normalUserCount = 0,
                    vipUsers = vipUsers,
                    vipUserCount = vipUsers.size,
                )
            }
            
            fun ofWithoutVips(users: List<User>): SearchUserListResponse {
                val normalUsers = users.filter { it.isVip == false }
                return SearchUserListResponse(
                    totalCount = users.size,
                    normalUsers = normalUsers,
                    normalUserCount = normalUsers.size,
                    vipUsers = emptyList(),
                    vipUserCount = 0,
                )
            }
        }
    }

 

이는 이펙티브 코틀린 책에서도 권장하는 방법이다.

 

참고로 팩토리 메서드 함수 명명은 ~of나 ~from으로 명명한다.

반응형