모델#
모델은 데이터베이스의 테이블이나 컬렉션에 저장된 데이터를 나타냅니다. 모델은 코드화 가능한(codable) 값을 저장하는 하나 이상의 필드를 가집니다. 모든 모델은 고유한 식별자를 가집니다. 프로퍼티 래퍼를 사용해서 식별자, 필드, 관계를 표시합니다.
아래는 필드가 하나인 간단한 모델의 예시입니다. 모델은 제약 조건, 인덱스, 외래 키 같은 데이터베이스 스키마 전체를 표현하지 않는다는 점에 유의하세요. 스키마는 마이그레이션에서 정의됩니다. 모델은 데이터베이스 스키마에 저장된 데이터를 표현하는 데 초점을 맞춥니다.
final class Planet: Model {
// Name of the table or collection.
static let schema = "planets"
// Unique identifier for this Planet.
@ID(key: .id)
var id: UUID?
// The Planet's name.
@Field(key: "name")
var name: String
// Creates a new, empty Planet.
init() { }
// Creates a new Planet with all properties set.
init(id: UUID? = nil, name: String) {
self.id = id
self.name = name
}
}
스키마#
모든 모델은 정적이고 읽기 전용인 schema 프로퍼티가 필요합니다. 이 문자열은 이 모델이 나타내는 테이블 또는 컬렉션의 이름을 참조합니다.
final class Planet: Model {
// Name of the table or collection.
static let schema = "planets"
}
이 모델을 쿼리할 때, 데이터는 "planets"라는 이름의 스키마에서 가져오거나 저장됩니다.
Tip
스키마 이름은 일반적으로 클래스 이름을 복수형으로 만들고 소문자로 변환한 것입니다.
식별자#
모든 모델은 @ID 프로퍼티 래퍼를 사용해서 정의된 id 프로퍼티를 가져야 합니다. 이 필드는 모델 인스턴스를 고유하게 식별합니다.
final class Planet: Model {
// Unique identifier for this Planet.
@ID(key: .id)
var id: UUID?
}
기본적으로 @ID 프로퍼티는 특별한 .id 키를 사용해야 하며, 이는 기반이 되는 데이터베이스 드라이버에 맞는 적절한 키로 변환됩니다. SQL에서는 "id"이고, NoSQL에서는 "_id"입니다.
@ID는 UUID 타입이어야 합니다. 이는 현재 모든 데이터베이스 드라이버가 지원하는 유일한 식별자 값입니다. Fluent는 모델이 생성될 때 새로운 UUID 식별자를 자동으로 생성합니다.
@ID는 옵셔널 값을 가집니다. 저장되지 않은 모델은 아직 식별자가 없을 수 있기 때문입니다. 식별자를 가져오거나 에러를 던지려면 requireID를 사용하세요.
let id = try planet.requireID()
Exists#
@ID는 모델이 데이터베이스에 존재하는지 여부를 나타내는 exists 프로퍼티를 가집니다. 모델을 초기화하면 이 값은 false입니다. 모델을 저장하거나 데이터베이스에서 모델을 가져오면 이 값은 true가 됩니다. 이 프로퍼티는 변경 가능합니다.
if planet.$id.exists {
// This model exists in database.
}
커스텀 식별자#
Fluent는 @ID(custom:) 오버로드를 사용해서 커스텀 식별자 키와 타입을 지원합니다.
final class Planet: Model {
// Unique identifier for this Planet.
@ID(custom: "foo")
var id: Int?
}
위의 예시는 커스텀 키 "foo"와 식별자 타입 Int를 사용하는 @ID를 보여줍니다. 이는 자동 증가 기본 키(auto-incrementing primary key)를 사용하는 SQL 데이터베이스와는 호환되지만, NoSQL과는 호환되지 않습니다.
커스텀 @ID를 사용하면 generatedBy 매개변수를 사용해서 식별자가 어떻게 생성되어야 하는지 지정할 수 있습니다.
@ID(custom: "foo", generatedBy: .user)
generatedBy 매개변수는 다음과 같은 경우를 지원합니다.
| Generated By | 설명 |
|---|---|
.user |
새 모델을 저장하기 전에 @ID 프로퍼티가 설정되어 있어야 합니다. |
.random |
@ID 값 타입은 RandomGeneratable을 준수해야 합니다. |
.database |
저장 시 데이터베이스가 값을 생성해야 합니다. |
generatedBy 매개변수가 생략되면, Fluent는 @ID 값 타입을 기반으로 적절한 경우를 추론하려고 시도합니다. 예를 들어 Int는 별도로 지정하지 않으면 기본적으로 .database 생성을 사용합니다.
초기화 메서드#
모델은 빈 초기화 메서드를 가져야 합니다.
final class Planet: Model {
// Creates a new, empty Planet.
init() { }
}
Fluent는 쿼리에서 반환된 모델을 초기화하기 위해 내부적으로 이 메서드를 필요로 합니다. 이는 리플렉션(reflection)에도 사용됩니다.
모델에 모든 프로퍼티를 받는 편의 초기화 메서드를 추가하고 싶을 수도 있습니다.
final class Planet: Model {
// Creates a new Planet with all properties set.
init(id: UUID? = nil, name: String) {
self.id = id
self.name = name
}
}
편의 초기화 메서드를 사용하면 나중에 모델에 새로운 프로퍼티를 추가하기가 더 쉬워집니다.
Field#
모델은 데이터를 저장하기 위한 @Field 프로퍼티를 0개 이상 가질 수 있습니다.
final class Planet: Model {
// The Planet's name.
@Field(key: "name")
var name: String
}
필드는 데이터베이스 키를 명시적으로 정의해야 합니다. 이 키는 프로퍼티 이름과 동일할 필요는 없습니다.
Tip
Fluent는 데이터베이스 키에는 snake_case를, 프로퍼티 이름에는 camelCase를 사용할 것을 권장합니다.
필드 값은 Codable을 준수하는 어떤 타입이든 될 수 있습니다. @Field에 중첩된 구조체와 배열을 저장하는 것도 지원되지만, 필터링 작업에는 제한이 있습니다. 대안으로 @Group을 참고하세요.
옵셔널 값을 담는 필드에는 @OptionalField를 사용하세요.
@OptionalField(key: "tag")
var tag: String?
Warning
현재 값을 참조하는 willSet 프로퍼티 옵저버나 oldValue를 참조하는 didSet 프로퍼티 옵저버를 가진 옵셔널이 아닌 필드는 치명적 에러(fatal error)를 발생시킵니다.
Relations#
모델은 @Parent, @Children, @Siblings와 같이 다른 모델을 참조하는 관계 프로퍼티를 0개 이상 가질 수 있습니다. 관계에 대해 더 알아보려면 관계 섹션을 참고하세요.
Timestamp#
@Timestamp는 Foundation.Date를 저장하는 특수한 형태의 @Field입니다. Timestamp는 선택한 트리거에 따라 Fluent가 자동으로 설정합니다.
final class Planet: Model {
// When this Planet was created.
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
// When this Planet was last updated.
@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
}
@Timestamp는 다음과 같은 트리거를 지원합니다.
| 트리거 | 설명 |
|---|---|
.create |
새 모델 인스턴스가 데이터베이스에 저장될 때 설정됩니다. |
.update |
기존 모델 인스턴스가 데이터베이스에 저장될 때 설정됩니다. |
.delete |
모델이 데이터베이스에서 삭제될 때 설정됩니다. 소프트 삭제를 참고하세요. |
@Timestamp의 date 값은 옵셔널이며, 새 모델을 초기화할 때는 nil로 설정되어야 합니다.
Timestamp 포맷#
기본적으로 @Timestamp는 사용 중인 데이터베이스 드라이버에 맞는 효율적인 datetime 인코딩을 사용합니다. format 매개변수를 사용해서 타임스탬프가 데이터베이스에 저장되는 방식을 커스터마이즈할 수 있습니다.
// Stores an ISO 8601 formatted timestamp representing
// when this model was last updated.
@Timestamp(key: "updated_at", on: .update, format: .iso8601)
var updatedAt: Date?
이 .iso8601 예시에 대응하는 마이그레이션은 .string 형식으로 저장하도록 요구된다는 점에 유의하세요.
.field("updated_at", .string)
사용 가능한 timestamp 포맷은 아래에 나열되어 있습니다.
| 포맷 | 설명 | 타입 |
|---|---|---|
.default |
특정 데이터베이스에 맞는 효율적인 datetime 인코딩을 사용합니다. |
Date |
.iso8601 |
ISO 8601 문자열입니다. withMilliseconds 매개변수를 지원합니다. |
String |
.unix |
Unix epoch 이후 경과된 초(fraction 포함)입니다. | Double |
timestamp 프로퍼티를 사용해서 원시(raw) 타임스탬프 값에 직접 접근할 수 있습니다.
// Manually set the timestamp value on this ISO 8601
// formatted @Timestamp.
model.$updatedAt.timestamp = "2020-06-03T16:20:14+00:00"
Soft Delete#
모델에 .delete 트리거를 사용하는 @Timestamp를 추가하면 소프트 삭제(soft-deletion)가 활성화됩니다.
final class Planet: Model {
// When this Planet was deleted.
@Timestamp(key: "deleted_at", on: .delete)
var deletedAt: Date?
}
소프트 삭제된 모델은 삭제 후에도 데이터베이스에 여전히 존재하지만, 쿼리에서는 반환되지 않습니다.
Tip
삭제 시점 타임스탬프를 미래의 날짜로 수동 설정할 수 있습니다. 이는 만료일로 사용할 수 있습니다.
소프트 삭제가 가능한 모델을 강제로 데이터베이스에서 제거하려면, delete의 force 매개변수를 사용하세요.
// Deletes from the database even if the model
// is soft deletable.
model.delete(force: true, on: database)
소프트 삭제된 모델을 복원하려면, restore 메서드를 사용하세요.
// Clears the on delete timestamp allowing this
// model to be returned in queries.
model.restore(on: database)
쿼리에 소프트 삭제된 모델을 포함하려면, withDeleted를 사용하세요.
// Fetches all planets including soft deleted.
Planet.query(on: database).withDeleted().all()
Enum#
@Enum은 문자열로 표현 가능한 타입을 네이티브 데이터베이스 enum으로 저장하기 위한 특수한 형태의 @Field입니다. 네이티브 데이터베이스 enum은 데이터베이스에 추가적인 타입 안전성을 제공하며, 원시(raw) enum보다 성능이 더 좋을 수 있습니다.
// String representable, Codable enum for animal types.
enum Animal: String, Codable {
case dog, cat
}
final class Pet: Model {
// Stores type of animal as a native database enum.
@Enum(key: "type")
var type: Animal
}
RawRepresentable을 준수하고 RawValue가 String인 타입만 @Enum과 호환됩니다. String을 기반으로 하는 enum은 기본적으로 이 요구사항을 충족합니다.
옵셔널 enum을 저장하려면, @OptionalEnum을 사용하세요.
데이터베이스는 마이그레이션을 통해 enum을 처리할 준비가 되어 있어야 합니다. 더 자세한 정보는 enum을 참고하세요.
Raw Enum#
String이나 Int처럼 Codable 타입을 기반으로 하는 모든 enum은 @Field에 저장할 수 있습니다. 이는 원시 값(raw value)으로 데이터베이스에 저장됩니다.
Group#
@Group을 사용하면 필드로 이루어진 중첩 그룹을 모델의 단일 프로퍼티로 저장할 수 있습니다. @Field에 저장된 Codable 구조체와 달리, @Group의 필드는 쿼리가 가능합니다. Fluent는 @Group을 데이터베이스에 평면 구조(flat structure)로 저장함으로써 이를 구현합니다.
@Group을 사용하려면, 먼저 Fields 프로토콜을 사용해서 저장하고 싶은 중첩 구조를 정의하세요. 이는 식별자나 스키마 이름이 필요하지 않다는 점을 제외하면 Model과 매우 유사합니다. 여기에는 Model이 지원하는 @Field, @Enum, 심지어 또 다른 @Group과 같은 많은 프로퍼티를 저장할 수 있습니다.
// A pet with name and animal type.
final class Pet: Fields {
// The pet's name.
@Field(key: "name")
var name: String
// The type of pet.
@Field(key: "type")
var type: String
// Creates a new, empty Pet.
init() { }
}
필드 정의를 생성한 후에는, 이를 @Group 프로퍼티의 값으로 사용할 수 있습니다.
final class User: Model {
// The user's nested pet.
@Group(key: "pet")
var pet: Pet
}
@Group의 필드는 점 표기법(dot-syntax)을 통해 접근할 수 있습니다.
let user: User = ...
print(user.pet.name) // String
프로퍼티 래퍼에 점 표기법을 사용해서 평소와 같이 중첩 필드를 쿼리할 수 있습니다.
User.query(on: database).filter(\.$pet.$name == "Zizek").all()
데이터베이스에서 @Group은 키가 _로 연결된 평면 구조로 저장됩니다. 아래는 User가 데이터베이스에서 어떻게 보이는지에 대한 예시입니다.
| id | name | pet_name | pet_type |
|---|---|---|---|
| 1 | Tanner | Zizek | Cat |
| 2 | Logan | Runa | Dog |
Codable#
모델은 기본적으로 Codable을 준수합니다. 즉, Content 프로토콜에 대한 준수성을 추가하면 Vapor의 콘텐츠 API와 함께 모델을 사용할 수 있습니다.
extension Planet: Content { }
app.get("planets") { req async throws in
// Return an array of all planets.
try await Planet.query(on: req.db).all()
}
Codable로/에서 직렬화할 때, 모델 프로퍼티는 키 대신 변수 이름을 사용합니다. 관계는 중첩된 구조로 직렬화되며, eager loading된 데이터가 있다면 함께 포함됩니다.
Info
거의 모든 경우에 API 응답과 요청 본문에는 모델 대신 DTO를 사용하는 것을 권장합니다. 더 자세한 정보는 데이터 전송 객체를 참고하세요.
데이터 전송 객체#
모델의 기본 Codable 준수성은 간단한 사용과 프로토타이핑을 더 쉽게 만들어 줄 수 있습니다. 그러나 이는 기반이 되는 데이터베이스 정보를 API에 노출시킵니다. 이는 보안 측면에서 대개 바람직하지 않으며(예를 들어 사용자의 비밀번호 해시와 같은 민감한 필드를 반환하는 것은 좋지 않은 생각입니다), 사용성 측면에서도 마찬가지입니다. 이는 API를 손상시키지 않고 데이터베이스 스키마를 변경하거나, 다른 형식으로 데이터를 받거나 반환하거나, API에 필드를 추가하거나 제거하는 것을 어렵게 만듭니다.
대부분의 경우, 모델 대신 DTO, 즉 데이터 전송 객체(data transfer object, 도메인 전송 객체(domain transfer object)라고도 알려져 있습니다)를 사용해야 합니다. DTO는 인코딩하거나 디코딩하고 싶은 데이터 구조를 나타내는 별도의 Codable 타입입니다. 이는 API를 데이터베이스 스키마로부터 분리시켜서, 앱의 공개 API를 손상시키지 않고 모델을 변경할 수 있게 하고, 서로 다른 버전을 가질 수 있게 하며, 클라이언트가 API를 더 편리하게 사용할 수 있게 해줍니다.
이후 예시에서는 다음의 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
}
DTO의 흔한 사용 사례 중 하나는 PATCH 요청을 구현하는 것입니다. 이러한 요청에는 업데이트되어야 하는 필드에 대한 값만 포함됩니다. 이러한 요청에서 Model을 직접 디코딩하려고 시도하면, 필수 필드 중 하나라도 누락된 경우 실패합니다. 아래 예시에서, DTO를 사용해서 요청 데이터를 디코딩하고 모델을 업데이트하는 것을 볼 수 있습니다.
// Structure of PATCH /users/:id request.
struct PatchUser: Decodable {
var firstName: String?
var lastName: String?
}
app.patch("users", ":id") { req async throws -> User in
// Decode the request data.
let patch = try req.content.decode(PatchUser.self)
// Fetch the desired user from the database.
guard let user = try await User.find(req.parameters.get("id"), on: req.db) else {
throw Abort(.notFound)
}
// 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.
try await user.save(on: req.db)
return user
}
DTO의 또 다른 흔한 사용 사례는 API 응답의 형식을 커스터마이즈하는 것입니다. 아래 예시는 DTO를 사용해서 응답에 계산된 필드를 추가하는 방법을 보여줍니다.
// Structure of GET /users response.
struct GetUser: Content {
var id: UUID
var name: String
}
app.get("users") { req async throws -> [GetUser] in
// Fetch all users from the database.
let users = try await User.query(on: req.db).all()
return try users.map { user in
// Convert each user to GET return type.
try GetUser(
id: user.requireID(),
name: "\(user.firstName) \(user.lastName)"
)
}
}
또 다른 흔한 사용 사례는 부모 관계(parent relation)나 자식 관계(children relation)와 같은 관계를 다룰 때입니다. @Parent 관계를 가진 모델을 디코딩하기 쉽게 만들기 위해 DTO를 사용하는 방법의 예시는 Parent 문서를 참고하세요.
DTO의 구조가 모델의 Codable 준수성과 동일하더라도, 이를 별도의 타입으로 만드는 것은 대규모 프로젝트를 깔끔하게 유지하는 데 도움이 될 수 있습니다. 모델의 프로퍼티를 변경해야 할 때, 앱의 공개 API가 손상될 걱정을 할 필요가 없습니다. 또한 DTO를 별도의 패키지에 두어 API 사용자와 공유하고, Vapor 앱에서 Content 준수성을 추가하는 것도 고려해 볼 수 있습니다.
Alias#
ModelAlias 프로토콜을 사용하면 쿼리에서 여러 번 조인되는 모델을 고유하게 식별할 수 있습니다. 더 자세한 정보는 조인을 참고하세요.
Save#
모델을 데이터베이스에 저장하려면, save(on:) 메서드를 사용하세요.
planet.save(on: database)
이 메서드는 모델이 데이터베이스에 이미 존재하는지 여부에 따라 내부적으로 create 또는 update를 호출합니다.
Create#
create 메서드를 호출해서 새 모델을 데이터베이스에 저장할 수 있습니다.
let planet = Planet(name: "Earth")
planet.create(on: database)
create는 모델의 배열에서도 사용할 수 있습니다. 이는 모든 모델을 단일 배치(batch)/쿼리로 데이터베이스에 저장합니다.
// Example of batch create.
[earth, mars].create(on: database)
Warning
.database 생성기(보통 자동 증가하는 Int)와 함께 @ID(custom:)를 사용하는 모델은 배치 생성 후 새로 생성된 식별자에 접근할 수 없습니다. 식별자에 접근해야 하는 상황이라면, 각 모델에서 개별적으로 create를 호출하세요.
모델의 배열을 개별적으로 생성하려면, map + flatten을 사용하세요.
[earth, mars].map { $0.create(on: database) }
.flatten(on: database.eventLoop)
async/await를 사용한다면 다음과 같이 사용할 수 있습니다.
await withThrowingTaskGroup(of: Void.self) { taskGroup in
[earth, mars].forEach { model in
taskGroup.addTask { try await model.create(on: database) }
}
}
Update#
데이터베이스에서 가져온 모델을 저장하려면 update 메서드를 호출할 수 있습니다.
guard let planet = try await Planet.find(..., on: database) else {
throw Abort(.notFound)
}
planet.name = "Earth"
try await planet.update(on: database)
모델의 배열을 업데이트하려면, map + flatten을 사용하세요.
[earth, mars].map { $0.update(on: database) }
.flatten(on: database.eventLoop)
// TOOD
Query#
모델은 쿼리 빌더를 반환하는 정적 메서드 query(on:)을 노출합니다.
Planet.query(on: database).all()
쿼리에 대해 더 알아보려면 쿼리 섹션을 참고하세요.
Find#
모델은 식별자로 모델 인스턴스를 조회하기 위한 정적 메서드 find(_:on:)을 가집니다.
Planet.find(req.parameters.get("id"), on: database)
이 메서드는 해당 식별자를 가진 모델을 찾지 못하면 nil을 반환합니다.
Lifecycle#
모델 미들웨어를 사용하면 모델의 라이프사이클 이벤트에 개입할 수 있습니다. 다음의 라이프사이클 이벤트가 지원됩니다.
| 메서드 | 설명 |
|---|---|
create |
모델이 생성되기 전에 실행됩니다. |
update |
모델이 업데이트되기 전에 실행됩니다. |
delete(force:) |
모델이 삭제되기 전에 실행됩니다. |
softDelete |
모델이 소프트 삭제되기 전에 실행됩니다. |
restore |
모델이 복원되기 전에 실행됩니다(소프트 삭제의 반대). |
모델 미들웨어는 ModelMiddleware 또는 AsyncModelMiddleware 프로토콜을 사용해서 선언됩니다. 모든 라이프사이클 메서드는 기본 구현을 가지고 있으므로, 필요한 메서드만 구현하면 됩니다. 각 메서드는 대상 모델, 데이터베이스에 대한 참조, 체인에서 다음에 실행될 액션을 인자로 받습니다. 미들웨어는 조기에 반환하거나, 실패한 future를 반환하거나, 다음 액션을 호출해서 정상적으로 계속 진행하도록 선택할 수 있습니다.
이러한 메서드를 사용하면 특정 이벤트가 완료되기 전과 후 모두에서 작업을 수행할 수 있습니다. 이벤트가 완료된 후에 작업을 수행하려면, 다음 응답자(responder)로부터 반환된 future를 매핑하면 됩니다.
// Example middleware that capitalizes names.
struct PlanetMiddleware: ModelMiddleware {
func create(model: Planet, on db: Database, next: AnyModelResponder) -> EventLoopFuture<Void> {
// The model can be altered here before it is created.
model.name = model.name.capitalized()
return next.create(model, on: db).map {
// Once the planet has been created, the code
// here will be executed.
print ("Planet \(model.name) was created")
}
}
}
또는 async/await를 사용한다면 다음과 같습니다.
struct PlanetMiddleware: AsyncModelMiddleware {
func create(model: Planet, on db: Database, next: AnyAsyncModelResponder) async throws {
// The model can be altered here before it is created.
model.name = model.name.capitalized()
try await next.create(model, on: db)
// Once the planet has been created, the code
// here will be executed.
print ("Planet \(model.name) was created")
}
}
미들웨어를 만들었다면, app.databases.middleware를 사용해서 이를 활성화할 수 있습니다.
// Example of configuring model middleware.
app.databases.middleware.use(PlanetMiddleware(), on: .psql)
Database Space#
Fluent는 모델의 공간(space) 설정을 지원하며, 이를 통해 개별 Fluent 모델을 PostgreSQL 스키마, MySQL 데이터베이스, 그리고 여러 개의 연결된 SQLite 데이터베이스 사이에 분할할 수 있습니다. 이 글을 작성하는 시점에서 MongoDB는 space를 지원하지 않습니다. 모델을 기본 공간이 아닌 다른 공간에 두려면, 모델에 새로운 정적 프로퍼티를 추가하세요.
public static let schema = "planets"
public static let space: String? = "mirror_universe"
// ...
Fluent는 모든 데이터베이스 쿼리를 구성할 때 이를 사용합니다.