DTO 넌 무엇이고 왜 써야하지?

김재형·2024년 5월 22일
1
post-custom-banner

시작하기에 앞서… 이걸 왜 알아야 할까요?

저번 프로젝트 - (LSLP)를 마무리하고 다시 한 번 공수산정과 코드들을 보면
되돌아보는 시간을 가지었었는데, 하나의 API를 다른 목적에 맞게끔 바꾸거나 혹은
서버에서 변경되는 사항들이 빈번했을 경우를 고민해 보았었습니다.
어떻게 하면 개발자는 더욱 쉽게 서버에서 데이터가 변경 되더라도 빠르게 대처 할수 있었을까?”
라는 질문에서 시작하는 글입니다.

DTO 넌 누구니?

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를 사용하고 있었습니다! 다만 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 가 사용하게 될 모델이 되어서는 안된다는 의미가 되겠죠!

Entity

자 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
    }
}

이제 활용해 봅시다.

예시) 쇼핑아이템 DTO

순수하게 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
    }
}

Mapper OR Converter

중간 레이어로 자리 잡아 서로 다른 데이터를 변환하는 책임을 가지고있는 클래스 입니다.
만약 서버나 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 간의 변환을 담당하는 중간 계층으로,
변환할 수 있는 로직을 한 곳에 모아두어 코드의 재사용성과 관리의 시간을 절약할수 있고,
이를 통해 데이터 구조가 변경되더라도 쉽게 대응할 수 있으며, 코드의 일관성을 유지할 수 있었습니다!

profile
IOS 개발자 새싹이
post-custom-banner

0개의 댓글