Open SH0123 opened 5 months ago
View와 DB에서 같은 Entity를 사용하다보니 DB Entity의 attribute 이름을 변경했을 뿐인데, 모든 View에서 에러가 발생했고 수정이 불가피했다. View와 DB에서 사용하는 Entity를 분리하여 결합도를 낮춰야겠다는 생각을 했다. 현재의 구조와 DB Entity에 변경이 생길 때는 아래의 그림과 같다
아래의 문제를 해결하고자 했다
아래의 구조와 같이 변경하여 결합도를 낮춰 문제를 해결할 수 있었다
extension BookInfoEntity {
func toDomain() -> BookInfo {
// 생략
}
}
.toDomain()과 같은 함수를 사용하게 되면 DB Entity와 Domain Entity가 서로에 대해 알고 있어야한다. 즉 결합도가 증가하게 된다. Domain Entity의 property 변경이 발생이 미치는 범위가 DB Entity까지 이어지게 된다. mapper 객체를 만들어서 사용해주는 게 된다면 변경이 발생해도 mapper 객체에서만 변경이 이뤄지면 되기때문에 문제가 해결 될 것으로 생각된다. 좀 더 정확한 관련 자료를 찾아서 공부해보고 적용하고 싶은데 잘 안찾아진다...
지난 고찰의 과정에서 도메인 엔티티와 DB 엔티티의 분리에 대해 고민하고 구현한 경험이 있다. 당시에 이러한 분리의 이유를 결합도를 낮추기 위함이라는 결론을 지었고, 그 뒤로 결합도와 응집성를 기반으로 OOP 구현을 좀 더 올바르게 해보고 싶다는 생각에 오브젝트 책을 갖고 스터디를 하게 됐다.
프로젝트 이후 결과물을 보니 View 마다 DB에서 데이터를 불러오고, 저장하는 등의 CRUD 코드가 작성되어있었다.
문제점
오브젝트 스터디를 하면서 코드를 작성할 때 다음과 같은 것들을 신경써야한다고 했다
이를 바탕으로 아래와 같은 생각을 할 수 있었다
이를 올바르게 구현하기 위해서 Repository Pattern에 대해 알게 되었고 적용해보게 됐다.
아래와 같은 구조를 만드는 것을 목표로 한다. 도메인 목적 각각에 부합하는 UseCase 객체를 만들어주며, UseCase는 Repository의 구현체가 아닌 추상화 객체에 의존한다.
protocol BookNoteRepository {
func fetchBookNoteList(with isbn: String, of userId: String?, _ completion: @escaping ([BookNote])->Void)
}
구현체를 만들어주자
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)")
}
}
}
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에 대해 학습한 내용도 링크에서 확인해볼 수 있다
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)?)
}
기존에 알라딘 API를 이용해서 책 정보를 가져왔다. 앱의 확장에 따라 영어권 국가들도 타겟으로 정했다. 알라딘 API는 국내용이기 때문에 해외의 도서들을 포함하고 영어로 번역된 구글 북스 API를 사용하기로 했다.
문제점이 있었다.
구현한 코드의 장, 단점 그리고 고민 내용들은 링크를 통해 자세히 확인할 수 있다.
이를 바탕으로 아래와 같은 클래스 다이어그램을 그려봤다. 클래스 다이어그램과 함께 코드를 간단히 살펴보자
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)
}
}
배경
목차
1. Domain과 DB Entity의 분리
2. Repository Pattern
3. 동일한 역할을 하는 API 객체들을 하나의 타입으로 묶어보자