저번 프로젝트 - (LSLP)를 마무리하고 다시 한 번 공수산정과 코드들을 보면
되돌아보는 시간을 가지었었는데, 하나의 API를 다른 목적에 맞게끔 바꾸거나 혹은
서버에서 변경되는 사항들이 빈번했을 경우를 고민해 보았었습니다.
”어떻게
하면 개발자는 더욱 쉽게서버에서 데이터가 변경
되더라도빠르게 대처
할수 있었을까?”
라는 질문에서 시작하는 글입니다.
DTO는 Swift 가 아니더라도 공용적으로 사용하는 단어입니다.
( Data Transfer Object: 데이터 전송 객체 ) 라는 의미를 가지고 있는데,
데이터를 전송 하기위한 객체 라면..? 이미 난 그렇게 하고 있는게 아닐까? 라고 생각했었습니다.
// 프로필 모델
struct ProfileModel: Decodable {
let userID: String
let email: String
let nick: String
let phoneNum: String
let followers, following: [Creator]
let profileImage: String
let posts: [String]
enum CodingKeys: String, CodingKey {
case userID = "user_id"
case email, nick, profileImage, followers, following, posts
case phoneNum
}
init() {
self.userID = ""
self.email = ""
self.nick = ""
self.phoneNum = ""
self.followers = []
self.following = []
self.profileImage = ""
self.posts = []
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.userID = try container.decode(String.self, forKey: .userID)
self.email = try container.decodeIfPresent(String.self, forKey: .email) ?? ""
self.nick = try container.decode(String.self, forKey: .nick)
self.profileImage = try container.decodeIfPresent(String.self, forKey: .profileImage) ?? ""
self.followers = try container.decode([Creator].self, forKey: .followers)
self.following = try container.decode([Creator].self, forKey: .following)
self.posts = try container.decode([String].self, forKey: .posts)
self.phoneNum = try container.decodeIfPresent(String.self, forKey: .phoneNum) ?? ""
}
}
자 그렇다면… 해당하는 모델은 DTO 일까요?
…. 맞습니다!
저희는 이미 DTO를 사용하고 있었습니다! 다만 DTO의 정의를 다시한번 살펴 보도록 하겠습니다.
데이터 전송 객체
→ DB, API호출
등 결과를 표현하는데 사용되는 객체로,로직을 포함
하지 않습니다.
// SNS 모델
final class SNSDataModel: Decodable, Equatable, Hashable {
let postId: String
let productId: String
let title: String
let content: String
let content2: String
let content3: String
let createdAt: String
let creator: Creator
let files: [String]
var likes: [String]
let hashTags: [String]
var comments: [CommentsModel]
var currentRow = 0
var animated: Bool = false
var currentIamgeAt = 0
enum CodingKeys: String, CodingKey {
case postId = "post_id"
case productId = "product_id"
case title
case content2
case content3
case content
case createdAt = "createdAt"
case creator
case files
case likes
case hashTags
case comments
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.postId = try container.decode(String.self, forKey: .postId)
self.productId = try container.decodeIfPresent(String.self, forKey: .productId) ?? ""
self.title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
self.content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
self.content2 = try container.decodeIfPresent(String.self, forKey: .content2) ?? ""
self.content3 = try container.decodeIfPresent(String.self, forKey: .content3) ?? ""
self.createdAt = try container.decode(String.self, forKey: .createdAt)
self.creator = try container.decode(Creator.self, forKey: .creator)
self.files = try container.decode([String].self, forKey: .files)
self.likes = try container.decode([String].self, forKey: .likes)
self.hashTags = try container.decode([String].self, forKey: .hashTags)
self.comments = try container.decode([CommentsModel].self, forKey: .comments)
}
func changeLikeModel(_ userID: String, likeBool: Bool) {
if likeBool {
likes.append(userID)
} else {
if let index = likes.firstIndex(of: userID) {
likes.remove(at: index)
}
}
}
static func == (lhs: SNSDataModel, rhs: SNSDataModel) -> Bool {
if lhs.postId == rhs.postId {
return true
}
return false
}
func hash(into hasher: inout Hasher) {
hasher.combine(postId)
}
}
위와같이 View 가 사용하게 될 모델이 되어서는 안된다는 의미가 되겠죠!
자 DTO는 호출을 위해서만 사용하게 될 모델이 란걸 알았습니다.
그렇다면 Entity는 무엇일까요?
View가 사용할 객체로, 비즈니스 로직을 포함할 수 있으며,
데이터베이스와 직접 매핑되는 경우가 많습니다. 즉 → 뷰가 사용해야할 모델을 의미합니다.
import Foundation
class ExampleUSerEntity {
var id: Int
var name: String
var email: String
var password: String
init(id: Int, name: String, email: String, password: String) {
self.id = id
self.name = name
self.email = email
self.password = password
}
func changeEmail(newEmail: String) {
// 비즈니스 로직을 포함할 수 있습니다.
self.email = newEmail
}
func isPasswordValid(input: String) -> Bool {
return input == self.password
}
}
순수하게 API 요청을 받는 모델을 선언해 보겠습니다.
struct ShopItemDTOModel: DTOType {
let title: String
let link: String
let image: String
let lprice, hprice: String
let mallName: String
let productId: String
enum CodingKeys: CodingKey {
case title
case link
case image
case lprice
case hprice
case mallName
case productId
}
init(title: String, link: String, image: String, lprice: String, hprice: String, mallName: String, productId: String) {
self.title = title
self.link = link
self.image = image
self.lprice = lprice
self.hprice = hprice
self.mallName = mallName
self.productId = productId
}
}
중간 레이어로 자리 잡아 서로 다른 데이터를 변환하는 책임을 가지고있는 클래스 입니다.
만약 서버나 API 의 변화가 발생했다면 매퍼를 통해 (View는 변경될일이 없을때) 변경을 하여
빠르게 대처할수 있습니다!
struct ShopEntityMapper {
func toEntity(_ dto: ShopItemDTOModel) -> ShopEntityModel? {
let entity = ShopEntityModel(
productId: dto.productId,
title: dto.title.rmHTMLBold,
link: dto.link,
image: dto.image,
lprice: NumberManager.shared.getTextToMoney(text: dto.lprice),
hprice: dto.hprice,
mallName: mallNameProcess(name: dto.mallName)
)
return entity
}
}
혹은 아래와 같은 방법으로도 가능합니다!
extension ExampleEntity {
enum Post {
struct Request {
let name: String
let introduce: String
}
struct Response {
let name: String
let introduce: String
let id: String
let createdAt: String
}
}
}
//
// SearchResultRepository.swift
// ShopY
//
// Created by Jae hyung Kim on 5/23/24.
//
import Foundation
import Combine
final class ShopItemsRepository {
private
let shopMapper = ShopEntityMapper()
private
let repository = RealmRepository()
}
extension ShopItemsRepository {
func requestPost(
search: String,
next: Int,
sort: SortCase
) -> AnyPublisher<(total:Int, models:[ShopEntityModel]),NetworkError>
{
let query = SearchQueryItems(
searchText: search,
display: Const.NaverAPi.display,
start: next,
sort: sort.rawValue
)
print(query)
return NetworkManager.fetchNetwork(
model: ShopDTOModlel.self,
router: .search(query: query)
)
.compactMap { [weak self] model in
return self?.shopMapper.toEntity(dtoP: model)
}
.receive(on: DispatchQueue.main)
.compactMap{ [weak self] (total, models) in
var models = models.compactMap {
self?.ifLikeModel(model: $0)
}
return (total: total, models: models)
}
.eraseToAnyPublisher()
}
}
// MARK: CRUD
extension ShopItemsRepository {
func likeRegOrDel(_ model: ShopEntityModel) -> Result<Void, RealmError> {
guard case .success(let ifModel) = repository.findById(
type: LikePostModel.self,
id: model.productId
) else {
return .failure(.cantFindModel)
}
if ifModel == nil {
let like = LikePostModel(
postId: model.productId,
title: model.title,
sellerName: model.mallName,
postUrlString: model.image
)
let result = repository.add(like)
switch result {
case .success:
return .success(())
case .failure(let failure):
return .failure(failure)
}
} else {
let result = repository.findIDAndRemove(
type: LikePostModel.self,
id: model.productId
)
switch result {
case .success(let void):
return .success(void)
case .failure(let failure):
return .failure(failure)
}
}
}
private
func ifLikeModel(model: ShopEntityModel) -> ShopEntityModel {
var model = model
let result = repository.findById(
type: LikePostModel.self,
id: model.productId
)
switch result {
case .success(let ifLike):
if let ifLike {
model.likeState = true
} else {
model.likeState = false
}
return model
case .failure:
return model
}
}
}
DTO와 Entity의 개념을 기본이나마 이해해 보며 실제로 작업중인 프로젝트에 적용하는데
꽤 많은 시간을 쏟아 부었었는데, 확실히 직접 적용시켜 보면 “ 아 왜 이렇게 하려고 했는지 알겠다 “ 를
깨닫게 해주는 시간들이 였습니다.
다시한번 정리해 보면서 생각해보면 DTO는 단순히 데이터를 전달
하는 역할을 하며,
엔티티
는 비즈니스 로직을 포함
하고, View가 바라볼 객체
로 동작하게 된다! 를
이해하면서 작성하니 코드의 가독성과 유지보수성은 좋아 질수 있겠다! 라고 느껴지는 시간이였습니다!
매퍼(Mappers)는 DTO와 Entity 간의 변환을 담당하는 중간 계층으로,
변환할 수 있는 로직을 한 곳에 모아두어 코드의 재사용성과 관리의 시간을 절약할수 있고,
이를 통해 데이터 구조가 변경되더라도 쉽게 대응할 수 있으며, 코드의 일관성을 유지할 수 있었습니다!