[Vapor/Swift] Fluent 모델 알아보기

Ryan (Geonhee) Son·2021년 7월 6일
0

지난 시간까지 로컬과 Heroku를 통해 원격에 배포한 서버에 접근하는 과정까지 다루었습니다.

이번에는 본격적으로 원하는 API를 구성하기 위해 request를 통해 전달 받은 데이터의 디코딩 타입을 만들고, DB를 구성하는 과정으로 넘어가볼텐데요, 이번에는 먼저 Fluent 모델에 대한 이론적인 내용을 다루어 보겠습니다.

모델

모델은 데이터베이스의 테이블 또는 컬렉션에 저장된 데이터를 나타냅니다. 모델은 Codable 값을 저장할 수 있는 필드를 가질 수 있습니다. 모든 모델들은 고유 식별자가 있고, Property wrapper가 식별자, 필드와 관계를 나타내는데 사용됩니다.

아래 코드는 하나의 필드를 가지고 있는 예시 모델을 나타내고 있습니다. 한 가지 주의하여야 할 사항은 모델이 제약사항, 인덱스, foreign key와 같은 데이터베이스의 모든 스키마를 나타내고 있지 않다는 점입니다. 모델은 단지 데이터가 데이터베이스 스키마에 저장되는 모양을 나타내는 수단일 뿐이에요.

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)

모든 모델들은 schema라는 읽기 전용 프로퍼티를 필요로 합니다. 이 문자열은 모델이 나타내는 테이블 또는 컬렉션의 이름을 나타냅니다.

final class Planet: Model {
    // Name of the table or collection.
    static let schema = "planets"
}

이 모델에 쿼리(데이터 요청)를 할 때, planets라 이름 붙여진 스키마에 데이터를 저장하거나 스키마로부터 데이터를 불러옵니다.

schema 이름은 통상적으로 클래스 이름을 소문자, 복수형으로 정의합니다.

식별자 (Identifier)

모든 모델들은 @ID property wrapper를 이용해 정의된 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는 저장되지 않은 모델들이 식별자를 아직 가지고 있지 않을 수 있기 때문에 optional로 정의되어 있습니다. 식별자를 가져오면서 식별자가 없는 경우 에러를 던져주고자 한다면 아래처럼 requireID 메서드를 사용하시면 됩니다.

let id = try planet.requireID()

Exists

@ID는 데이터베이스에 존재하는지 여부를 나타내는 exists라는 프로퍼티를 가지고 있습니다. 모델을 초기화할 때 이 값은 false이지만, 저장하거나 데이터베이스에서 모델을 가져온 후에는 true입니다. 이 값은 변경할 수 있습니다.

if planet.$id.exists {
    // This model exists in database.
}

사용자 정의 식별자 (Custom Identifier)

Fluent는 @ID(custom:)를 통해 사용자 정의 식별자 키와 타입을 정의할 수 있습니다.

final class Planet: Model {
    // Unique identifier for this Planet.
    @ID(custom: "foo")
    var id: Int?
}

위의 예시는 Int 타입을 가진 "foo"라는 사용자 정의 식별자 키를 가지고 @ID를 정의했습니다. 이는 자동으로 증가하는 primary key를 이용하는 SQL 데이터베이스와 호환될 수 있습니다만 NoSQL과는 호환되지 않습니다.

사용자 정의된 @ID들은 generatedBy 파라미터를 통해 식별자가 생성되는 방식을 지정할 수 있습니다.

@ID(custom: "foo", generatedBy: .user)
Generated ByDescription
.user새로운 모델을 저장하기 전에 사용자가 @ID 프로퍼티를 세팅함
.random@ID 값 타입은 RandomGeneratable 프로토콜을 채택해야 함 (자동 생성)
.database저장 시 데이터베이스가 값을 생성함

generatedBy 파라미터를 생략하면 Fluent는 @ID 값 타입에 따라 적절하게 추론하여 적용합니다. 예를 들어 별도로 지정되지 않으면 Int는 기본적으로 .database 설정으로 값을 생성합니다.

생성자 (Initializer)

모델은 반드시 빈 이니셜라이저 메서드를 가지고 있어야 합니다.

final class Planet: Model {
    // Creates a new, empty Planet.
    init() { }
}

Fluent는 쿼리의 결과로 반환되는 모델을 내부적으로 생성하기 위해 이 메서드를 필요로 합니다.

원한다면 모든 프로퍼티를 가지는 convenience initializer를 추가할 수 있습니다.

final class Planet: Model {
    // Creates a new Planet with all properties set.
    init(id: UUID? = nil, name: String) {
        self.id = id
        self.name = name
    }
}

Convenience initializer를 이용하면 향후 모델에 새로운 프로퍼티를 추가하는 작업을 편하게 만들어줄 수 있습니다.

필드 (Field)

모델들은 데이터를 저장하기 위해 @Field 프로퍼티들을 가질 수 있습니다.

final class Planet: Model {
    // The Planet's name.
    @Field(key: "name")
    var name: String
}

필드를 사용하려면 위의 예시와 같이 데이터베이스 키를 명시적으로 정의하여야 합니다. 다만 프로퍼티 이름과 반드시 같을 필요는 없습니다.

Fluent는 데이터베이스 키를 snake_case식 이름으로, 프로퍼티 이름을 camelCase식 이름으로 짓는 것을 권장합니다.

필드 값들은 Codable을 따르는 모든 타입으로 설정할 수 있습니다. @Field는 중첩 구조 (nested structures)와 배열을 저장할 수 있지만 이를 이용한 필터링 작업은 제한됩니다. 필터링이 필요하다면 @Group을 대체재로 알아보시면 됩니다.

옵셔널 값을 가지는 필드를 정의하고자 한다면 @OptionalField를 사용하세요.

@OptionalField(key: "tag")
var tag: String?

관계 (Relations)

모델들은 @Parent, @Children, @Siblings와 같은 다른 모델들을 참조할 수 있는 관계 프로퍼티를 가질 수 있습니다. 관련한 내용은 Relation 섹션을 확인해보세요.

Timestamp

@TimestampFoundation.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는 아래의 트리거를 지원합니다.

TriggerDescription
.create새로운 모델 인스턴스가 데이터베이스에 저장될 때 세팅.
.update기존의 모델 인스턴스가 데이터베이스에 저장될 때 세팅.
.delete모델이 데이터베이스에서 삭제될 때 세팅. soft delete 참조

Timestamp 형식

기본적으로 @Timestamp는 사용하는 데이터베이스 드라이버에 따라 인코딩하기 효율적인 datetime을 사용합니다. format 파라미터를 통해 데이터베이스에 timestamp가 저장되는 방식을 설정할 수 있습니다.

// 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 형식은 아래와 같습니다.

FormatDescriptionType
.default데이터베이스에 따라 인코딩하기 효율적인 datetime을 사용함Date
.iso8601ISO 8601 문자열. withMilliseconds 파라미터 사용을 지원함String
.unixfraction을 포함한 Unix epoch 시점으로부터의 경과된 시간(초)Double

timestamp 프로퍼티를 통해 raw timestamp 값에 직접 접근하실 수 있습니다.

// 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?
}

Soft하게 삭제된 모델들은 삭제된 후에도 데이터베이스에 존재하지만 쿼리로부터 반환되지는 않습니다.

삭제 시 timestamp를 미래의 시점으로 설정할 수 있습니다. 이는 실제로 데이터가 삭제되는 만료 시점으로 사용할 수 있습니다.

데이터베이스로부터 soft-delete를 지원하는 모델을 강제로 삭제하고자 한다면 deleteforce 파라미터를 사용하시면 됩니다.

// Deletes from the database even if the model 
// is soft deletable. 
model.delete(force: true, on: database)

soft하게 삭제된 모델을 복구할 때는 restore 메서드를 사용하시면 됩니다.

// Clears the on delete timestamp allowing this 
// model to be returned in queries. 
model.restore(on: database)

soft 삭제된 모델들을 쿼리에 포함하고 싶으시다면 withDeleted를 사용하시면 됩니다.

// Fetches all planets including soft deleted.
Planet.query(on: database).withDeleted().all()

열거형 (Enum)

@Enum은 문자열으로 표현할 수 있는 타입을 네이티브 데이터베이스 열거값으로 저장하기 위한 특수 타입 @Field입니다. 네이티브 데이터베이스 열거형은 데이터베이스에 타입 안전 계층을 추가하므로 raw 열거형보다 성능이 더 우수할 수 있습니다.

// 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이 채택된 열거형은 기본적으로 이 요구 사항을 충족합니다.

옵셔널 형태의 열거형을 저장하고자 한다면 @OptionalEnum을 사용하세요.

마이그레이션을 통해 열거형을 다룰 수 있도록 데이터베이스를 준비해야 합니다. 자세한 내용은 enum을 참조하세요.

Raw Enums

String 또는 Int와 같이 Codable이 지원되는 모든 열거형을 @Field에 저장할 수 있습니다. 이 값은 데이터베이스에 raw value로 저장됩니다.

Group

@Group을 사용하면 중첩된 필드 그룹을 모델의 단일 속성으로써 저장할 수 있습니다. @Field에 저장된 Codable 구조체들과 달리 @Group의 필드는 쿼리할 수 있습니다. Fluent는 데이터베이스에 @Group을 플랫한 구조로 저장하여 쿼리할 수 있도록 만듭니다.

@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 문법으로 접근할 수 있습니다.

let user: User = ...
print(user.pet.name) // String

property wrapper에서 사용하던 dot 문법과 같이 중첩된 필드에 쿼리할 수 있습니다.

User.query(on: database).filter(\.$pet.$name == "Zizek").all()

데이터베이스에서 @Group_로 연결된 키를 가진 플랫 구조로 저장됩니다. 아래는 데이터베이스에서 User가 어떻게 표시되는지 보여 주는 예시입니다.

idnamepet_namepet_type
1TannerZizekCat
2LoganRunaDog

Codable

모델들은 기본적으로 Codable을 채택하고 있습니다. 모델에 Vapor의 content를 채택함으로써 Codable을 함께 채택하게 됩니다.

extension Planet: Content { }

app.get("planets") { req in 
    // Return an array of all planets.
    Planet.query(on: req.db).all()
}

Codable을 통해 데이터를 인코딩/디코딩할 때 모델 프로퍼티들은 키 대신에 가지고 있는 변수 이름을 사용합니다.

Data Transfer Object (DTO)

이 내용은 추후 Create, Read, Update, Delete (CRUD) 기능을 구현할 때 다시 다루도록 하겠습니다.

profile
합리적인 해법 찾기를 좋아합니다.

0개의 댓글