SH0123 / BookAndMe

[책과 나의 조각] iOS 앱
MIT License
1 stars 0 forks source link

[기록] 스파게티 코드를 OOP에 맞춰 변경해나가는 과정 #7

Open SH0123 opened 5 months ago

SH0123 commented 5 months ago

배경

목차

1. Domain과 DB Entity의 분리

2. Repository Pattern

3. 동일한 역할을 하는 API 객체들을 하나의 타입으로 묶어보자

SH0123 commented 5 months ago

Reference

SH0123 commented 5 months ago

1. Domain, DB Entity의 분리

배경

View와 DB에서 같은 Entity를 사용하다보니 DB Entity의 attribute 이름을 변경했을 뿐인데, 모든 View에서 에러가 발생했고 수정이 불가피했다. View와 DB에서 사용하는 Entity를 분리하여 결합도를 낮춰야겠다는 생각을 했다. 현재의 구조와 DB Entity에 변경이 생길 때는 아래의 그림과 같다

과정

아래의 문제를 해결하고자 했다

  1. 목적에 따라 엔티티를 분리 하지 않아 결합도가 높았고, 이는 변경이 미치는 범위가 커져 취약해짐
  2. 데이터 타입의 불일치로 인해 지속적인 캐스팅 작업이 필요함

아래의 구조와 같이 변경하여 결합도를 낮춰 문제를 해결할 수 있었다

extension BookInfoEntity {
    func toDomain() -> BookInfo {
        // 생략
    }
}

좀 더 상세한 기록은 여기로

고민

.toDomain()과 같은 함수를 사용하게 되면 DB Entity와 Domain Entity가 서로에 대해 알고 있어야한다. 즉 결합도가 증가하게 된다. Domain Entity의 property 변경이 발생이 미치는 범위가 DB Entity까지 이어지게 된다. mapper 객체를 만들어서 사용해주는 게 된다면 변경이 발생해도 mapper 객체에서만 변경이 이뤄지면 되기때문에 문제가 해결 될 것으로 생각된다. 좀 더 정확한 관련 자료를 찾아서 공부해보고 적용하고 싶은데 잘 안찾아진다...

SH0123 commented 5 months ago

2. Repository Pattern

사설

지난 고찰의 과정에서 도메인 엔티티와 DB 엔티티의 분리에 대해 고민하고 구현한 경험이 있다. 당시에 이러한 분리의 이유를 결합도를 낮추기 위함이라는 결론을 지었고, 그 뒤로 결합도와 응집성를 기반으로 OOP 구현을 좀 더 올바르게 해보고 싶다는 생각에 오브젝트 책을 갖고 스터디를 하게 됐다.

배경

프로젝트 이후 결과물을 보니 View 마다 DB에서 데이터를 불러오고, 저장하는 등의 CRUD 코드가 작성되어있었다.

문제점

  1. 비즈니스 로직과 DB 접근 로직이 강하게 결합되어 있다. DB에 변경이 생긴다면 모든 View, ViewModel에서 코드 변경이 발생한다.
  2. 이는 코드 복잡도를 높이고 변경에 많은 시간을 들이게 한다.
  3. 한 가지 원인으로 인해 많은 파일에서의 변경이 발생한다면 이는 오류 발생 가능성을 야기할 수 있다

오브젝트 스터디를 하면서 코드를 작성할 때 다음과 같은 것들을 신경써야한다고 했다

이를 바탕으로 아래와 같은 생각을 할 수 있었다

  1. CRUD를 위한 Data 관리 용도의 별도 객체가 필요하다는 생각
  2. 추후에 파이어베이스의 DB를 사용할 예정인데 변경하기 쉽기 위해서는 Data 관리 용도의 별도 객체들의 public interface가 필요하겠다는 생각

이를 올바르게 구현하기 위해서 Repository Pattern에 대해 알게 되었고 적용해보게 됐다.

과정

아래와 같은 구조를 만드는 것을 목표로 한다. 도메인 목적 각각에 부합하는 UseCase 객체를 만들어주며, UseCase는 Repository의 구현체가 아닌 추상화 객체에 의존한다.


  1. protocol로 추상화 객체를 만들어주자. 단 이 과정에서 고수준 모듈이 필요로 하는 메세지만을 public interface로 만들어준다. 절대 절대 구현체에서 필요한 함수들을 기준으로 public interface를 만드는 과정은 하지 않는다.
    protocol BookNoteRepository {
    func fetchBookNoteList(with isbn: String, of userId: String?, _ completion: @escaping ([BookNote])->Void)
    }
  2. 구현체를 만들어주자

    final class BookNoteCoreDataRepository: BookNoteRepository {
    static let shared: BookNoteRepository = BookNoteCoreDataRepository()
    private var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
    private init() {}
    
    func fetchBookNoteList(with isbn: String, of userId: String?, _ completion: @escaping ([BookNote])->Void) {
        let fetchRequest: NSFetchRequest<BookNoteEntity>
    
        fetchRequest = BookNoteEntity.fetchRequest()
        fetchRequest.predicate = NSPredicate(format: "%K == %@",#keyPath(BookNoteEntity.bookInfo.isbn), isbn)
    
        do {
            let objects = try context.fetch(fetchRequest)
            completion(objects.map { $0.toDomain() })
        } catch {
            let nsError = error as NSError
            fatalError("Unresolved Error\(nsError)")
        }
    }
    }
  3. UseCase에서 Repository의 추상화 객체를 의존한다

    // UseCase도 protocol로 interface를 만들어주어 View, ViewModel 계층에서 변경을 최소화하고 UseCase Layer는 유연하게 만든다
    struct FetchBookNoteListUseCaseImpl: FetchBookNoteListUseCase {
    private var bookNoteRepository: BookNoteRepository
    
    init(bookNoteRepository: BookNoteRepository = BookNoteCoreDataRepository.shared) {
        self.bookNoteRepository = bookNoteRepository
    }
    func execute(with isbn: String, of userId: String?, _ completion: @escaping ([BookNote]) -> Void) {
        bookNoteRepository.fetchBookNoteList(with: isbn, of: userId) { notes in
            completion(notes)
        }
    }
    }

    만약 DB에 변경이 생겨도 변경이 미치는 범위는 아래와 같이 Repository Layer까지이다.


이 과정에서 Swift의 Struct와 Class에 대해 고민하고 공부하고 직접 코드를 작성해본 내용은 링크를 눌러보면 확인 가능하다. 또한 Singleton Pattern에 대해 학습한 내용도 링크에서 확인해볼 수 있다

고민과 궁금점

  1. BookInfo를 Fetch 할 때 읽고 있는 책 목록, 이미 다 읽은 책 목록 등 불러와야하는 객체 배열의 종류가 다양하다. 각각에 대해 아래와 같이 모든 함수를 작성했는데, 이렇게 모든 함수를 다 작성하고 사용하는게 맞는지 모르겠다.
    • FetchReadingBookList, FetchLikeBookList는 FetchAllBookList를 받아오고 프론트에서 filtering 해주는게 맞을까?
    • FetchAllBookList에서 가져온 데이터가 너무 크다면 filtering을 하는 것 보다는 목적에 맞는 데이터만 가져오는 코드를 작성해주는게 좋지 않을까?
      protocol BookInfoRepository {
      func fetchReadingBookList(of userId: String?, _ completion: @escaping ([BookInfo]) -> Void)
      func fetchLikeBookList(of userId: String?, _ completion: @escaping ([BookInfo]) -> Void)
      func fetchAllBookList(of userId: String?, _ completion: @escaping ([BookInfo]) -> Void)
      func fetchBookInfo(with isbn: String, _ completion: @escaping (BookInfo?) -> Void)
      func addBookInfo(book: BookInfo, _ completion: @escaping (BookInfo) -> Void)
      func updateBookInfo(book: BookInfo, of userId: String?, _ completion: ((BookInfo) -> Void)?)
      }
  2. Firebase의 DB로 Repository를 변경할 때 체감해보겠지만 얼마나 수월하게 변경할 수 있을지, 갈아끼우는 것 만으로 정말 될지 궁금하고 기대된다
SH0123 commented 5 months ago

3. 동일한 역할을 하는 API 객체들을 하나의 타입으로 묶어보자

배경

기존에 알라딘 API를 이용해서 책 정보를 가져왔다. 앱의 확장에 따라 영어권 국가들도 타겟으로 정했다. 알라딘 API는 국내용이기 때문에 해외의 도서들을 포함하고 영어로 번역된 구글 북스 API를 사용하기로 했다.

문제점이 있었다.

  1. 당시 API를 사용하는 코드는 필요로 하는 객체 모든 곳에 존재했으며 그렇기에 변경이 생기면 모든 코드를 계속 수정해야한다. 결합도가 너무 높았으며, 이는 시간의 낭비 뿐만 아니라 코드 수정으로 인해 오류를 야기할 수 있었다
  2. 사용자의 휴대폰 사용 지역에 따라 GoogleBooksAPI와 AladinAPI를 각각 생성하려고 하니 if else 분기문이 생기게 됐다. 이는 사용자의 국가에 따라 지원하는 API가 다양해질 수록 분기문이 더 많아지는데 이는 변경에 취약함을 보여준다.

해결

구현한 코드의 장, 단점 그리고 고민 내용들은 링크를 통해 자세히 확인할 수 있다.

  1. API들을 하나의 타입으로 묶을 수 있는 interface가 필요했다.
  2. API를 사용하는 매니저 객체가 interface에 의존하도록 하여 결합도를 낮출 필요가 있었다.
  3. URLSession에서 data를 decode할 객체가 Google과 Aladin에서 각각 달랐다. 이를 해결해주기 위한 generic 객체가 필요했다.
  4. GoogleBooksKeywordAPI, GoogleBooksIsbnAPI, AladinKeywordAPI, AladinIsbnAPI를 하나의 타입으로 구현하는데 있어서 상속과 합성 두가지 방법이 존재했다. 상속은 부모와 자식간에 캡슐화가 깨져 자식이 부모에 대해 잘 알고 있어야하고 이를 위해 문서화가 필요했다. 참고자료

이를 바탕으로 아래와 같은 클래스 다이어그램을 그려봤다. 클래스 다이어그램과 함께 코드를 간단히 살펴보자

class diagram drawio

protocol BookAPI {
    associatedtype DecodableType: Decodable

    var baseUrlString: String { get }
    var bookAPICaller: BookAPICaller<DecodableType> { get }

    func fetchBooks(keyword: String, maxResult: Int, currentPage: Int, _ completion: @escaping([BookInfo], Int)->Void)
}
struct AladinKeywordAPI: BookAPI {

    typealias DecodableType = AladinJsonResponse
    let baseUrlString = "http://www.aladin.co.kr/ttb/api/ItemSearch.aspx?ttbkey=\(ApiKey.aladinKey)&SearchTarget=Book&output=js&Version=20131101"
    let bookAPICaller = BookAPICaller<AladinJsonResponse>()
    let converter = AladinJsonConverter()

    func fetchBooks(keyword: String, maxResult: Int, currentPage: Int, _ completion: @escaping ([BookInfo], Int) -> Void) {
        let urlString = baseUrlString + "&Query=\(keyword)&QueryType=Keyword&MaxResults=\(maxResult)&start=\(currentPage)"
        guard let encodedUrlString = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
            return
        }

        bookAPICaller.fetchBooks(urlString: encodedUrlString) { aladinJsonResponse in
            let totalResults = aladinJsonResponse.totalResults
            let bookDataArray = converter.convertToBookInfo(from: aladinJsonResponse)
            completion(bookDataArray, totalResults)
        }
    }
}
struct BookAPICaller<BookType: Decodable> {

    func fetchBooks(urlString: String, _ completion: @escaping (BookType)->Void) {

        guard let url = URL(string: urlString) else {
            print("not possible with \(urlString)")
            return
        }

        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                print("Error: \(error)")
                return
            }
            guard let data = data else {
                return
            }
            if let string = String(data: data, encoding: .utf8) {
                  print(string)
            }
            do {
                let decoder = JSONDecoder()
                let decodedData = try decoder.decode(BookType.self, from: data)
                DispatchQueue.main.async {
                    completion(decodedData)
                }

            } catch {
                print("Error decoding JSON: \(error)")
            }
        }

        task.resume()

    }
}
struct BookAPIManager {
    var keywordApi: any BookAPI {

        if let countryCode = Locale.current.region?.identifier {
            switch countryCode {
            case "KR":
                return AladinKeywordAPI()
            default:
                return GoogleBooksKeywordAPI()
            }
        } else {
            return GoogleBooksKeywordAPI()
        }
    }

  func fetchBooks(keyword: String, maxResult: Int, currentPage: Int, _ completion: @escaping ([BookInfo], Int)->Void) {
        keywordApi.fetchBooks(keyword: keyword, maxResult: maxResult, currentPage: currentPage) { bookInfoList, resultsCount in
            completion(bookInfoList, resultsCount)
        }
    }