모델과 관련된 지난 포스팅 두 편(이론, 적용)을 통해서 앞으로 클라이언트와 서버 간의 Body 데이터를 주고 받는 양식을 Data Transfer Object (DTO)를 통해 정의할 수 있다고 말씀 드렸었습니다! 단적으로 말해 아래와 같은 상황들이 있을 수 있겠죠.
- 클라이언트: 저희는 DB에 있는 내용을 삭제하고 싶을 뿐이에요. ID 값만 드리면 되지 않나요?
- 서버: 이 필드값은 서버에서 데이터 관리용으로 사용하는 값이라 클라이언트가 받아가는 내용에서 제외해도 되는데.
이런 경우에 DTO를 활용해서 주고 받을 Body 데이터의 모양을 설정하실 수 있습니다. 그럼 계속해서 Fluent 프레임워크를 이용해 DTO를 작성하는 방법을 자세히 알아봅시다.
DTO는 인코드, 디코드하고 싶은 데이터 구조를 나타내는 별도의 Codable
타입을 의미합니다. 예를 들어 아래와 같이 User
라는 모델이 있다고 가정해 보겠습니다.
// Abridged user model for reference.
final class User: Model {
@ID(key: .id)
var id: UUID?
@Field(key: "first_name")
var firstName: String
@Field(key: "last_name")
var lastName: String
}
PATCH
요청을 처리하는 경우에 흔히 DTO를 사용하게 됩니다. PATCH
요청에는 업데이트가 필요한 필드에 대해서만 요청에 포함시켜 보내기 때문이죠. 이 때 User
모델을 디코딩 타입으로 설정하면 몇 가지의 필드가 빠졌다고 하며 요청이 실패하게 될겁니다. 아래 예시를 통해 모델을 업데이트 할 때 요청 데이터를 디코딩할 때 사용할 DTO를 확인하실 수 있습니다.
// Structure of PATCH /users/:id request.
struct PatchUser: Decodable {
var firstName: String?
var lastName: String?
}
app.patch("users", ":id") { req in
// Decode the request data.
let patch = try req.content.decode(PatchUser.self)
// Fetch the desired user from the database.
return User.find(req.parameters.get("id"), on: req.db)
.unwrap(or: Abort(.notFound))
.flatMap
{ user in
// If first name was supplied, update it.
if let firstName = patch.firstName {
user.firstName = firstName
}
// If new last name was supplied, update it.
if let lastName = patch.lastName {
user.lastName = lastName
}
// Save the user and return it.
return user.save(on: req.db)
.transform(to: user)
}
}
DTO를 사용하는 또 다른 경우 중에 하나로 위의 2 번 상황과 같이 API 응답 형식을 정의해주고 싶을 때가 있습니다. 2 번에서는 특정 내용을 제외하고 보내고 싶다고 하였지만, 아래 예시에서는 firstName
과 lastName
을 조합하여 name
이라는 하나의 키 값으로 응답하고 있음을 확인하실 수 있습니다.
// Structure of GET /users response.
struct GetUser: Content {
var id: UUID
var name: String
}
app.get("users") { req in
// Fetch all users from the database.
User.query(on: req.db).all().flatMapThrowing { users in
try users.map { user in
// Convert each user to GET return type.
try GetUser(
id: user.requireID(),
name: "\(user.firstName) \(user.lastName)"
)
}
}
}
비록 DTO의 구조가 모델의 Codable
만족에 따른 모양과 동일하더라도 별도의 타입으로 만들어 두면 대형 프로젝트를 수행할 때 깔끔한 구조를 유지하실 수 있으실 것입니다. 이렇게 구조를 마련해두시면 모델의 프로퍼티를 변경해야 하는 경우에도 앱의 공용 API를 손상시킬 염려가 없게 됩니다. API 이용자 (클라이언트)와 공유할 수 있는 별도의 패키지에 DTO를 넣는 것도 고려해 볼 수 있습니다.
이러한 이유들로 인해 대형 프로젝트의 경우에는 가능한 경우 DTO를 되도록이면 사용할 것을 권장 드립니다.
기존 프로젝트에 아래와 같이 모델 타입에 DTO들을 추가해보았습니다. 타입 이름은 HTTP 메서드 이름을 기준으로 지어주었습니다. 만든 DTO들을 타입별로 파일을 분리해준다면 프로젝트를 처음 접하는 사람들도 타입의 위치를 찾기 더 쉬울 것 같습니다. 위에서 설명드리지 않았지만 한 가지 추가된 내용은 PostProjectItem
타입을 통해 디코딩된 인스턴스 입력으로 ProjectItem
타입의 인스턴스를 생성할 수 있는 이니셜라이저를 하나 작성해 주었다는 것입니다.
import Fluent
import Vapor
struct PostProjectItem: Content {
let id: UUID?
let title: String
let content: String
let deadlineDate: Date
let progress: String
let index: Int
}
struct PatchProjectItem: Decodable {
let id: UUID
let title: String?
let content: String?
let deadlineDate: Date?
let progress: String?
let index: Int?
}
struct DeleteProjectItem: Decodable {
let id: UUID
}
final class ProjectItem: Model, Content {
static let schema = "projectItems"
@ID(key: .id)
var id: UUID?
@Field(key: "title")
var title: String
@Field(key: "content")
var content: String
@Field(key: "deadlineDate")
var deadlineDate: Date
@Field(key: "progress")
var progress: String
@Field(key: "index")
var index: Int
init() { }
init(id: UUID? = nil, title: String, content: String, deadlineDate: Date, progress: String, index: Int) {
self.id = id
self.title = title
self.content = content
self.deadlineDate = deadlineDate
self.progress = progress
self.index = index
}
init(_ projectItem: PostProjectItem) {
self.id = projectItem.id
self.title = projectItem.title
self.content = projectItem.content
self.deadlineDate = projectItem.deadlineDate
self.progress = projectItem.progress
self.index = projectItem.index
}
}
이제 담당하시는 프로젝트에서 DTO를 작성하실 수 있으시겠죠? 다음 포스팅에서는 만들어 둔 DTO를 이용해 대망의 Create, Read, Update, Delete (CRUD) 기능을 함께 구현해 보겠습니다!