Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

33장. DTO 설계하기

32장에서 요청과 응답을
DTO라는 객체로 표현한다고 했습니다.

DTO는 Data Transfer Object,
“데이터를 실어 나르는 객체“입니다.

이 장에서는 DTO를
어떻게 잘 설계하는지 구체적으로 다룹니다.

DTO는 단순해 보이지만,
잘 설계하면 서비스 전체가 깔끔해집니다.


33.1 요청 객체 만들기

요청 객체(Request DTO)는
바깥에서 들어오는 데이터를 담습니다.

data class CreateUserRequest(
    val name: String,
    val email: String,
    val password: String
)

data class로 만드는 것이 자연스럽습니다. (9장)
값을 담는 것이 주 역할이기 때문입니다.

요청 객체는
“클라이언트가 우리에게 보내는 양식“이라고
생각하면 됩니다.


33.2 응답 객체 만들기

응답 객체(Response DTO)는
바깥으로 내보낼 데이터를 담습니다.

data class UserResponse(
    val id: Long,
    val name: String,
    val email: String
)

여기서 중요한 점이 있습니다.
CreateUserRequest에는 있던 password
UserResponse에는 없습니다.

비밀번호는 절대 응답으로 내보내면 안 되니까요.

요청에 담는 것과 응답에 담는 것은
서로 다르다.

이 차이 때문에 요청과 응답을
별도의 객체로 나눕니다.


33.3 Data Class 활용하기

DTO에 data class가 잘 맞는 이유는
9장에서 배운 기능들 덕분입니다.

  • equals/hashCode : 값 비교가 쉬움
  • toString : 로그 찍기 좋음
  • copy : 일부만 바꾼 새 객체 만들기 쉬움
val request = CreateUserRequest("홍길동", "hong@test.com", "1234")
println(request)   // 값이 그대로 출력되어 디버깅에 편리

DTO는 “값 그 자체“가 중요하므로
data class의 장점을 그대로 누립니다.


33.4 Nullable Field 결정하기

DTO를 만들 때
“이 필드가 null일 수 있는가“를
신중히 정해야 합니다. (5장 null 안전성)

data class UpdateUserRequest(
    val name: String?,      // 안 바꿀 수도 있으니 null 허용
    val email: String?
)

“반드시 있어야 하는 값“은 non-null로,
“없을 수도 있는 값“만 nullable로 둡니다.

습관적으로 모든 필드를 nullable로 두지 마세요.
null 허용은 “정말 없을 수 있을 때“만.

타입만 봐도
어떤 값이 필수인지 드러나게 하는 것이 좋습니다.


33.5 Default Parameter 사용하기

기본값이 있는 필드는
기본 인자로 처리하면 편리합니다. (4장)

data class SearchRequest(
    val keyword: String,
    val page: Int = 1,
    val size: Int = 20
)

pagesize를 넘기지 않으면
자동으로 1과 20이 쓰입니다.

SearchRequest(keyword = "노트북")            // page=1, size=20
SearchRequest(keyword = "노트북", page = 2)  // size=20

기본 인자 덕분에
불필요한 오버로딩 없이 유연해집니다.


33.6 Domain Object를 그대로 응답하지 않기

가장 흔한 실수 중 하나가
도메인 객체를 그대로 응답으로 내보내는 것입니다.

// 나쁜 예: 도메인 객체를 그대로 반환
fun getUser(id: Long): User {
    return userRepository.findById(id)!!
}

이렇게 하면 문제가 생깁니다.

  • 비밀번호 같은 민감한 정보가 노출될 수 있음
  • 도메인이 바뀌면 응답 형태도 딸려서 바뀜
  • 내부 구조가 바깥에 그대로 드러남

그래서 도메인은
반드시 응답 DTO로 변환해서 내보냅니다.


33.7 Domain과 DTO 변환하기

변환은 보통 이렇게 합니다.

fun toResponse(user: User): UserResponse {
    return UserResponse(
        id = user.id,
        name = user.name,
        email = user.email
    )
}

도메인에서 필요한 값만 골라
응답 객체를 만듭니다.

반대로 요청을 도메인으로 바꾸는 변환도 있습니다.

fun CreateUserRequest.toDomain(): User {
    return User(id = 0, name = name, email = email)
}

이렇게 요청 → 도메인 → 저장 → 응답으로
데이터가 흐릅니다.


33.8 Extension Function으로 변환 코드 정리하기

변환 코드가 여기저기 흩어지면 지저분해집니다.
확장 함수(17장)로 깔끔하게 모을 수 있습니다.

fun User.toResponse(): UserResponse =
    UserResponse(id = id, name = name, email = email)

fun CreateUserRequest.toDomain(): User =
    User(id = 0, name = name, email = email)

이제 변환이 아주 자연스럽게 읽힙니다.

val user = request.toDomain()
val saved = userRepository.save(user)
return saved.toResponse()

request.toDomain(), saved.toResponse()처럼
마치 원래 있던 기능처럼 부를 수 있습니다.

변환 로직을 확장 함수로 모아 두면
Service 코드가 훨씬 읽기 좋아집니다.


33장을 마치며

이 장에서 우리는 다음을 배웠습니다.

  • 요청과 응답 DTO를 나누는 이유
  • data class가 DTO에 잘 맞는 이유
  • Nullable 필드와 기본 인자를 신중히 정하기
  • 도메인 객체를 그대로 응답하지 않기
  • 확장 함수로 변환 코드를 깔끔하게 모으기

다음 장에서는
서비스의 심장인 도메인 객체를 설계합니다.