내용 정리
내가 만든 개인 과제와 해설 영상의 코드를 비교해보며 내 개인 과제에서 아쉬운 부분이 무엇인지 확인해보자
주로CoreData,API,MVC 아키텍처 패턴위주로 설명한다.
과제 해설 영상에서는 시작부터 MVC 아키텍처 패턴을 이용하여 과제를 구현할 것이라고 강조하고, 이를 위해 시작부터 디렉토리를 나누고 작업을 진행한다. 특히 ViewController에서는 화면을 보여주는 역할 만을 담당하고 최대한 로직을 담당하지 않도록 별도의 VC를 만들어 진행하는데, 꽤나 놀랐던 것 같다.
제일 놀란 부분은 loadView 메소드를 사용하는 부분이었다. 내가 알기론 뷰 컨트롤러의 생명주기 중 loadView는 이니셜라이저로 뷰 컨트롤러의 인스턴스가 생성된 직후 호출되는 생명주기로, 애플에서 웬만하면 사용하지 말라는 당부가 있는 생명주기 메소드이다.
그러나 해설 영상에서는 아무렇지 않게 loadView 메소드를 사용해서 뷰를 구현했다.
class secondViewController: UIViewController {
let listView = ListView() // 커스텀 UIView
override func loadView() {
view = listView
}
}
위의 코드와 같은 형식으로 사용했는데, 이렇게 하면 아마 viewDidLoad 메소드로 뷰 컨트롤러가 메모리에 로드될 때, 커스텀 뷰가 기본 뷰가 되어서 보여질 것이다. 저렇게도 사용할 수 있는 코드구나 싶어서 놀랐던 것 같다.
그리고 디렉토리와 클래스 분리에서 놀랐는데, 과제 해설에서는 아래와 같은 디렉토리 및 파일 분리 형식을 가진다.
PokemonPhoneBook
├── AppDelegate.swift
├── Assets.xcassets
├── Info.plist
│
├── List
│ ├── ContactCell.swift
│ ├── ListView.swift
│ └── ListViewController.swift
│
├── PhoneBook
│ ├── PhoneBookView.swift
│ └── PhoneBookViewController.swift
│
├── Repository
│ ├── CoreDataRepository.swift
│ └── ImageRepository.swift
│
├── SceneDelegate.swift
└── ViewController.swift
딱 봤을 때 어떤 곳에서 어떤 역할을 맡고 있는지 구분이 잘 되어있는 느낌이다.
이제 내가 만든 과제의 디렉토리 구조를 보자.
PokemonPhoneBook
├── PhoneBookData
│ ├── PhoneBookData+CoreDataClass.swift
│ └── PhoneBookData+CoreDataProperties.swift
│
├── PokemonPhoneBook
│ ├── AppDelegate.swift
│ ├── AppSetting
│ │ ├── Assets.xcassets
│ │ └── Base.lproj
│ │ └── LaunchScreen.storyboard
│ ├── Info.plist
│ │
│ ├── PokemonModel
│ │ └── PokemonModel.swift
│ │
│ ├── SceneDelegate.swift
│ └── ViewControllers
│ ├── DataFetched.swift
│ ├── MyProfileView.swift
│ ├── PhoneBookCell.swift
│ ├── PhoneBookDataDelegate.swift
│ ├── PhoneBookViewController.swift
│ ├── ValidationAlert.swift
│ └── ViewController.swift
│
└── PokemonPhoneBook.xcodeproj

음... 디렉토리나 파일 분리도 제대로 되어있지 않은 느낌이고, 각 디렉토리나 파일들이 무슨 역할을 하는지 명확한 구분이 어려운 것 같다. 그래서 이 점을 반성하게 되었다. 다음부터는 나도 아키텍처 패턴을 명확히 정하고 디렉토리 구조를 생각하며 작성하는게 좋겠다고 생각했다.
이와 별개로 마치 블록을 쌓듯이 UI의 영역을 나누어 UI 구조를 만드는 것은 꽤 마음에 들어서 앞으로 나의 코드 작성 방식도 비슷한 방향으로 따라가지 않을까 생각했다.
해설의 코드를 보기 전에 내가 작성한 코드를 구경하자
// API 통신을 담당하는 프로토콜
protocol DataFetched {
func fetchData<T: Decodable>(_ completion: @escaping (T?) -> Void)
}
extension DataFetched {
/// 서버에서 데이터를 받아오는 메소드
/// - Parameter completion: 받아온 데이터를 디코딩하고 클로저에 전달
func fetchData<T: Decodable>(_ completion: @escaping (T?) -> Void) {
let randomNumber = Int.random(in: 1...1000)
guard let url = URL(string: "https://pokeapi.co/api/v2/pokemon/\(randomNumber)") else {
print("잘못된 URL 입니다")
completion(nil)
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
URLSession.shared.dataTask(with: request) { (data, response, error) in
guard let data, error == nil else {
print("잘못된 호출입니다.")
completion(nil)
return
}
if let response = response as? HTTPURLResponse {
let successRange: Range = 200..<300
guard successRange.contains(response.statusCode) else {
print("데이터 요청 실패")
completion(nil)
return
}
do {
let decodedData = try JSONDecoder().decode(T.self, from: data)
print("디코딩 성공")
completion(decodedData)
return
} catch {
print(error)
completion(nil)
}
} else {
print("http 요청 실패")
completion(nil)
}
}.resume()
}
}
위의 코드말고도 데이터를 가져오는 다른 코드도 있지만...
내가 API를 담당하는 코드를 프로토콜로 별도로 정의하여 사용한 이유는 2개의 뷰 컨트롤러에서 중복되게 코드를 사용하고 있기 때문이었다. 그래서 프로토콜로 만들고 뷰 컨트롤러에게 이 프로토콜을 상속시켜서 fetchData 메소드를 사용할 수 있도록 하였다.
이 방법도 나쁘다고는 생각하지 않지만, 해설 영상에서 사용하는 API 모델 관리가 좀 더 깔끔해 보였다.
class ImageRepository {
func fetchRandomPokemonImage(completion: @escaping (UIImage?) -> Void) {
let randomId = Int.random(in: 1...1000)
let urlString = "https://pokeapi.co/api/v2/pokemon/\(randomId)"
guard let url = URL(string: urlString) else {
completion(nil)
return
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data, error == nil,
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let sprites = json["sprites"] as? [String: Any],
let imageUrlString = sprites["front_default"] as? String,
let imageUrl = URL(string: imageUrlString) else {
completion(nil)
return
}
self.downloadImage(from: imageUrl, completion: completion)
}
task.resume()
}
private func downloadImage(from url: URL, completion: @escaping (UIImage?) -> Void) {
DispatchQueue.global().async {
if let imageData = try? Data(contentsOf: url),
let image = UIImage(data: imageData) {
DispatchQueue.main.async {
completion(image)
}
} else {
DispatchQueue.main.async {
completion(nil)
}
}
}
}
}
다른 점이 꽤나 많이 보이는데, 일단 해설 영상에서는 별도의 JSON Model 파일을 만들지 않고 직접 디코딩하여 사용한다. 저렇게 쓸 수 있는 줄 몰랐기 때문에 공부가 되었다.
그리고 나는 URL을 URLRequest로 만들어 JSON형식으로 파일을 GET할 것이라고 명시적으로 선언했지만, 해설 영상에서는 그런 과정없이 바로 dataTast를 시작했다. 안정성에 있어서는 내 코드 쪽이 더 괜찮지 않을까? 싶으면서도 지식이 짧아 확신하지는 못하겠다...
어쨌든 해설에서는 class로 API를 담당하는 타입을 하나 만들고, 이를 인스턴스화하여 API 작업을 진행한다. 프로토콜로 사용하는 것과 클래스로 사용하는 것 어느쪽이 더 성능면에서 유리할지는 공부해봐야 할 것 같다.
그래도 위의 방식이 신기해서 테스트 삼아 한 번 코드를 작성해 보았다.
class FetchPokemonImage {
func fetchRandomImage(_ completion: @escaping ((name: String?, image: UIImage?)) -> Void) {
let randomId = Int.random(in: 1...1000)
let urlString = "https://pokeapi.co/api/v2/pokemon/\(randomId)"
guard let url = URL(string: urlString) else { return }
URLSession.shared.dataTask(with: url) { data, reponse, error in
guard let data, error == nil else { return }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let name = json["name"] as? String,
let sprites = json["sprites"] as? [String: Any],
let imageUrl = sprites["front_default"] as? String,
let stringUrl = URL(string: imageUrl)
else {
completion((name: nil, image: nil))
return
}
self.downloadImage(from: stringUrl, name: name, completion: completion)
}.resume()
}
private func downloadImage(from url: URL, name: String, completion: @escaping ((name: String?, image: UIImage?)) -> Void) {
DispatchQueue.global().async {
guard let imageData = try? Data(contentsOf: url) else { return }
if let image = UIImage(data: imageData) {
DispatchQueue.main.async {
completion((name: name, image: image))
}
} else {
return
}
}
}
}
똑같아 보이지만 조금 다르다... API에서 이름을 가져오는 코드도 추가되어 있다.
아직은 어색하기도 하고 연구가 더 필요할 것 같다.
해설 영상을 보며 제일 충격을 받은 부분인데, 내가 작성한 코드와 달리 너무 간단히(?) 사용할 수 있게 구현해서... 많이 놀랐다.
나는 코어데이터의 엔티티 CodeGenerator를 Manual/None으로 해서 직접 클래스를 생성하고 관리했는데, 해설에서는 Class Difinition을 사용하여 클래스를 생성하지 않고 사용했다.
여기서 코드 제너레이터에 대해 공부했을 때는 직접 실험해보지 않아서 몰랐는데, Class Difinition로 설정하면 클래스를 아예 생성하지 않아도 프로그램이 자동으로 만들어 관리하기 때문에 엔티티를 사용할 수 있었다...
이와 관련된 내용은 애플의 공식 문서에서 찾아볼 수 있었다. (Generating code - Apple Developer)
어쨌든, 해설 영상에서는 코어 데이터를 아래 코드처럼 관리한다.
class CoreDataRepository {
var contacts: [Contact] = []
let persistentContainer: NSPersistentContainer
init(modelName: String) {
persistentContainer = NSPersistentContainer(name: modelName)
persistentContainer.loadPersistentStores { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
}
}
var context: NSManagedObjectContext {
return persistentContainer.viewContext
}
func fetch() {
let fetchRequest: NSFetchRequest<Contact> = Contact.fetchRequest()
// 이름순으로 정렬
let sortDescriptor = NSSortDescriptor(key: "name", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor]
do {
contacts = try self.context.fetch(fetchRequest)
} catch {
print("Failed to fetch contacts: \(error)")
}
}
func saveContext() {
if context.hasChanges {
do {
try context.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
}
이 코드를 보자마자 느낀 점은...
코어 데이터 코드가 이렇게 짧을 수도 있었어...??
정말 짧다고 느꼈다.. 왜냐하면 내가 작성한 코드는...
// 코어 데이터의 CRUD를 담당하는 프로토콜
protocol PhoneBookDataDelegate {
var container: NSPersistentContainer { get }
func createNewPhoneNumber(name: String, number: String, profileImage: UIImage)
func readAllData() -> [PhoneBookData]
func readSelectData(_ selectData: String) -> PhoneBookData?
func updatePhoneNumber(currentName: String, currentNumber: String, updateName: String, updateNumber: String, updateImage: UIImage)
func deleteData(name: String, number: String)
func deleteAllData()
}
// MARK: - CoreData CRUD Method
extension PhoneBookDataDelegate {
// 코어데이터와 연결하는 프로퍼티
var container: NSPersistentContainer {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
return appDelegate.persistentContainer
}
/// 코어데이터에 새로운 데이터를 저장하는 메소드
/// - Parameters:
/// - name: 저장할 데이터 이름
/// - number: 저장할 데이터 번호
/// - profileImage: 저장할 데이터 이미지
func createNewPhoneNumber(name: String, number: String, profileImage: UIImage) {
guard let entity = NSEntityDescription.entity(forEntityName: PhoneBookData.className, in: self.container.viewContext) else { return }
let newNumber = NSManagedObject.init(entity: entity, insertInto: self.container.viewContext)
newNumber.setValue(name, forKey: PhoneBookData.Key.name)
newNumber.setValue(number, forKey: PhoneBookData.Key.number)
newNumber.setValue(profileImage.pngData(), forKey: PhoneBookData.Key.profile)
do {
try self.container.viewContext.save()
print("번호 저장 성공")
} catch {
print("번호 저장 실패", error)
return
}
}
/// 코어 데이터의 모든 정보를 불러오는 메소드
/// - Returns: JSON 디코딩 데이터 모델 배열
func readAllData() -> [PhoneBookData] {
do {
let phoneBooks = try self.container.viewContext.fetch(PhoneBookData.fetchRequest())
print("데이터 불러오기 성공")
return phoneBooks
} catch {
print("데이터 불러오기 실패", error)
return []
}
}
/// 특정 데이터의 정보를 수정(updata)하는 메소드
/// - Parameters:
/// - currentName: 수정 할 데이터 이름
/// - currentNumber: 수정 할 데이터 번호
/// - updateName: 새로운 이름
/// - updateNumber: 새로운 번호
/// - updateImage: 새로운 이미지
func updatePhoneNumber(currentName: String, currentNumber: String, updateName: String, updateNumber: String, updateImage: UIImage) {
let fetchRequest = PhoneBookData.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "name == %@ AND number == %@", currentName, currentNumber)
do {
let fetchData = try self.container.viewContext.fetch(fetchRequest)
let result = fetchData as [NSManagedObject]
if let data = result.first {
data.setValue(updateName, forKey: PhoneBookData.Key.name)
data.setValue(updateNumber, forKey: PhoneBookData.Key.number)
data.setValue(updateImage.pngData(), forKey: PhoneBookData.Key.profile)
}
try self.container.viewContext.save()
print("데이터 업데이트 성공")
} catch {
print("업데이트 실패", error)
}
}
}
정말 길기 때문이다... 물론 프로토콜로 구현하고, 기존 과제보다 디테일업을 위해 추가한 기능들이 있기 때문에 더 길기도 하지만, 내 코드보다 훨씬 깔끔하다는 인상이 있었다.
일단은 저런 방법도 있구나... 하고 다음에도 코어 데이터를 활용할 일이 있다면 잘 짜집기 해서 사용해보자고 생각했다.
나는 이번에도 코어 데이터를 프로토콜로 구현했는데, 이렇게 하면 코어데이터가 필요한 모든 부분에서 프로토콜 상속만으로 간단히 코어데이터와 관련된 모든 작업을 할 수 있기 때문이었다. 게다가 container는 수정하지 못하고, 코드의 은닉화도 되기 때문에 좋지 않을까 생각했다. 코어 데이터가 필요할 때마다 새로운 인스턴스를 만드는 것보다 프로토콜을 상속시켜서 사용하는게 더욱 깔끔해 보였다.
음... 이번 과제에서 생각보다 막히는 부분도 별로 없고 재밌게 진행해서 조금 자신이 있었는데, 해설 영상을 보니 살짝 자신감이 꺾였다. 특히 아키텍처 패턴이나 API, CoreData 부분은 내가 아직 부족한 부분이라서 그런걸까 더욱 자신감이 꺾였다...
그래도 해설 영상을 통해 다른 방법도 있다는 것을 알았고, 아직 공부할 내용이 무척 많다는 것을 알 수 있었다.
어서 공부해서 해설 영상의 내용도 내 것으로 만들고, 더 깔끔하고 예쁘고 멋진 코드를 작성할 수 있도록 성장하고 싶다고 생각했다.
오늘은 개인과제를 제출하고 해설 영상과 내 과제물을 비교하며 부족한 점에 대해 생각해보는 시간을 가졌다.
역시 부족한 점은 너무나 많고, 그런 내게 조금 자신감이 떨어지는 것을 느꼈다.
벽이 높다고 해야하나...
그래도 꾸준히 하다보면 언젠가 넘을 수 있으리라 생각하며
오늘도 산처럼 쌓인 공부 내용을 보며 하나하나 해결해 가보려고 한다.
저는 하루종일 코드 주석과 리드미와 TIL을 작성하느라 해설영상은 켜보지도 못했는데, 이 글만 봤을 때 저는 오히려 인상 깊게 봤어요. 여러 객체에서 갖다 쓰는 기능에 대해 프로토콜에 기본 구현을 해두고 채택만 시켜서 쓰도록 하신 부분이 엄청 좋아보이고, 따라서 연습해보고 싶네요! 상경님이 해설을 통해 다른 코드를 보고 배우시는 것처럼, 다른 누군가(ex. 에밀리)에게 상경님의 코드도 보고 배우는 코드가 되었습니다🙂 배워갑니다