- ๊ฐ์ธ ํ๋ก์ ํธ
- ํ๋ก์ ํธ ๊ธฐ๊ฐ: 2022.05.04 - 2022.05.14
- ํค์๋
- ํ๋ก์ ํธ ์๊ฐ
- ํ๋ก์ ํธ ์ฃผ์๊ธฐ๋ฅ
- ๊ธฐ์ ์ ๋์
- Trouble Shooting
- ์๋กญ๊ฒ ์๊ฒ๋ ๊ฒ
Architecture
/Design Pattern
Clean Architecture MVVM
Coordinator
Delegate
Event Listeners
UIKit
UICollectionViewDiffableDataSource
UIGestureRecognizer
Appearance
GitHub Rest API
OAuth 2.0
Keychain
NSCache
Github Rest API๋ฅผ ํ์ฉํ ์ฑ์ด์์.
๋ค๋ฅธ ์ฌ๋์ ๋ ํ์งํ ๋ฆฌ๋ฅผ ๊ฒ์ํ ์ ์๋ ๊ธฐ๋ฅ์ด ์์ด์.
๋ณํ ํ์๋ฅผ ํฐ์นํ์ฌ ์ฒดํฌ/ํด์ ๋ฅผ ํ ์ ์์ด์.
๋ก๊ทธ์ธ์ ํ๋ฉด, ์์ ์ ํ๋กํ ๋ฐ ๋ ํ์งํ ๋ฆฌ๋ฅผ ๋ถ๋ฌ์ฌ ์ ์์ด์.
๐ ๊ฒ์์ฐฝ์์ ์ํ๋ ๋ ํ์งํ ๋ฆฌ๋ฅผ ๊ฒ์ํด๋ณผ ์ ์์ด์.
โ๏ธ ๋ก๊ทธ์ธ ์ํ๊ฐ ์๋๋ผ๋ฉด ๋ณํ ํ์๋ฅผ ์ฒดํฌ/ํด์ ํ ์ ์์ด์, ๋ก๊ทธ์ธ ํ๋ฉด์ผ๋ก ์ด๋ํ๊ฒ ๋ฉ๋๋ค.
๐ค ๋ก๊ทธ์ธ ์ ํ๋กํ ๋ฐ ์์ ์ ๋ ํ์งํ ๋ฆฌ ๋ชฉ๋ก์ ๊ฐ์ ธ์ต๋๋ค.
โญ๏ธ ๋ก๊ทธ์ธ/๋ก๊ทธ์์ ์ ์์ ์ ๊ธฐ์กด ๋ณํ ๋ชฉ๋ก๊ณผ ๋๊ธฐํ ๋ฉ๋๋ค.
โ ๋ณํ ์ฒดํฌ ์ ๋ ํ๋ฉด ๋ชจ๋ ๋๊ธฐํ ๋ฉ๋๋ค.
๐๐ป ์ผ์ ๊ธธ์ด ์ด์ ์คํฌ๋กค ์ ๋ค์ ํ์ด์ง๋ฅผ Prefetch ํฉ๋๋ค.
- ๋ทฐ์ปจํธ๋กค๋ฌ์ ๋ทฐ๋ ํ๋ฉด์ ๊ทธ๋ฆฌ๋ ์ญํ ์๋ง ์ง์ค์ํค๊ณ ๋ฐ์ดํฐ ๊ด๋ฆฌ์ ๋น์ฆ๋์ค ๋ก์ง์ ๋ทฐ๋ชจ๋ธ์์ ์งํ๋๋๋ก ๊ตฌ์ฑํ์์ต๋๋ค.
- ๋ทฐ๋ชจ๋ธ์ ๋น์ฆ๋์ค ๋ก์ง๋ค์ ์ ์ฆ์ผ์ด์ค๋ก, ๋คํธ์ํฌ์ ๋ํ ์์ฒญ์ repository๋ก ๋ถ๋ฆฌํด ๊ฐ ๋ ์ด์ด์ ์ญํ ์ ๋ถ๋ช ํ๊ฒ ๋๋์์ต๋๋ค.
- ํ๋ฉด ์ ํ์ ๋ํ ๋ก์ง์ ViewController๋ก๋ถํฐ ๋ถ๋ฆฌํ๊ณ ์์กด์ฑ ๊ฐ์ฒด์ ๋ํ ์ฃผ์ ์ ์ธ๋ถ์์ ์ฒ๋ฆฌํ๋๋ก ํ๊ธฐ ์ํด์ ์ฝ๋๋ค์ดํฐ ํจํด์ ์ฌ์ฉํ๊ฒ ๋์์ต๋๋ค.
Thread 7: "Fatal: supplied item identifiers are not unique. Duplicate identifiers:
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Fatal: supplied item identifiers are not unique. Duplicate identifiers: {(
GithubSearchApp.RepositoryItem(id: 190487472, name: "animal-crossing-music-sim", login: "owendaprile", description: "Animal Crossing Music Simulator", isMarkedStar: false, starredCount: 2),
GithubSearchApp.RepositoryItem(id: 256757675, name: "Animal-Crossing-App", login: "carluqcor", description: "", isMarkedStar: false, starredCount: 2)
)}'
์ํฉ
diffable datasource๋ก ์ปฌ๋ ์ ๋ทฐ๋ฅผ ์์ฑํ๊ณ , ์คํฌ๋กค ํ๋ ๋์ค ์์ ๊ฐ์ ์๋ฌ๊ฐ ๋ฐ์ํ๋ค.์ด์
์ ์ ์๋ณ์๊ฐ ๊ณ ์ ํ์ง ์๋ค๋ ์๋ฌ์๋ค. ์ ์ ์ ๋ณด๋ฅผ ๋ด๊ณ ์๋ RepositoryItem ํ์ ์ด ๋จ์ํ Hashable์ ์ค์ํ๊ณ ๋ ์์ง๋ง, ๋์ผํ ์ ๋ณด๋ฅผ ๊ฐ์ง ์ ์์ผ๋ฉฐ, ์ด ์ํฉ์์ ์ค๋ฅ๊ฐ ๋ฐ์ํ๋ ๊ฒ์ด์๋ค.ํด๊ฒฐ
๋ฐ์์ค๋ ๊ฐ์ค ๊ณ ์ ํ๋ค๊ณ ์๊ฐ๋๋ ๋ฐ์ดํฐ๋ฅผ ํด์ฑํ๋๋ก hash(into:) ๋ฉ์๋๋ฅผ ์ฌ์ ์ ํด์ฃผ์๋ค.
extension RepositoryItem: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(name)
hasher.combine(login)
hasher.combine(description)
}
static func ==(lhs: RepositoryItem, rhs: RepositoryItem) -> Bool {
return lhs.id == rhs.id && lhs.name == rhs.name && lhs.login == rhs.description
}
}
'*** +[NSURLComponents setPercentEncodedQueryItems:]: invalid characters in percentEncodedQueryItems'
์ํฉ
์ฟผ๋ฆฌ๋ฅผ ํ์ฉํ์ฌ ๊ฒ์์ ์๋ํ๋ค๊ฐ ์์ ๊ฐ์ ์๋ฌ๊ฐ ๋ฐ์ํ๋ค.์ด์
URL์ ์ธ์ฝ๋ฉ ํ ๋, ๊ฒ์ํค์๋ ์ค ๋์ด์ฐ๊ธฐ(๊ณต๋ฐฑ)๋ URL๋ก ์ธ์ํ ์ ์๋ ์ธ์ด๊ฐ ์๋๊ธฐ ๋๋ฌธ์ ๋๋ ์๋ฌ์๋ค.ํด๊ฒฐ
๋ฐ๋ผ์ URL์ ๋ฐํํ๊ธฐ ์ ์ ์ฟผ๋ฆฌ๋ฅผ ์ธ์ฝ๋ฉํด์ฃผ๋ ์์ ์ ์ถ๊ฐํด์ฃผ๋ ํด๊ฒฐ๋์๋ค.
var url: URL? {
guard let url = self.baseURL?.appendingPathComponent(self.path) else {
return nil
}
var urlComponents = URLComponents(string: url.absoluteString)
let urlQuries = self.parameters.map { key, value -> URLQueryItem in
let value = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) // ์ธ์ฝ๋ฉ ์์
์ ์ถ๊ฐํด์ฃผ๋ ํด๊ฒฐ๋์๋ค.
return URLQueryItem(name: key, value: value)
}
urlComponents?.percentEncodedQueryItems = urlQuries
return urlComponents?.url
}
์ํฉ
์์น๋ฐ๋ฅผ ํด๋ฆญํ ๋ ๋ง๋ค ๊ฒ์๋ฐ๊ฐ ๋น์ ์์ ์ผ๋ก ํตํต ํ๋ ํ์์ด ๋ํ๋ฌ๋ค.์๋
searchController์ hidesNavigationBarDuringPresentation ์์ฑ์ false๋ก ์ค๋ณด์๋๋ฐ ํด๊ฒฐ๋์ง ์์๋ค.ํด๊ฒฐ
๋ญ๊ฐ ์ฌ๋ฌ ์ํฉ์์ ์์น๋ฐ๋ฅผ ํด๋ฆญํ ๋๋ง๋ค ์ปฌ๋ ์ ๋ทฐ์ ์ ๋ค์ด ์์ฐ์ค๋ฌ์ด ๋๋์ด ์๋ ๋ฏธ๋ฆฌ ์๋ฆฌ๋ฅผ ์ก๋ ๋๋์ด๋ผ์, ViewController ์์ฑ ์ค edgesForExtendedLayout์ ๊ฐ์ .bottom์ผ๋ก ํ ๋นํด์ฃผ์๋๋ ํด๋น ํ์์ด ํด๊ฒฐ๋์๋ค.- ์ด๋ ๊ฒ ํ๊ฒ๋๋ฉด ๋ค๋น๊ฒ์ด์ ๋ฐ๊ฐ ์๋ ์์ญ์๋ ์ฝํ ์ธ ๊ฐ ์๋ฆฌ์ก์ง ์๋๋ค.
- tintColor๋, appearance ๊ด๋ จ๋ ์์ฑ๊ฐ๋ค์ ๋ชจ๋ ๋ทฐ ์ปจํธ๋กค๋ฌ๊ฐ ๋์ผํ๊ฒ ๊ฐ์ ธ๊ฐ์ผ๋ฉด ํด์ ์ฐพ์๋ณด์๋ค.
- ๋จผ์ AppAppearance์ด๋ผ๋ ํด๋์ค ํ์ ์ ์ ์ธํด์ค๋ค.
final class AppAppearance {
static func setUpAppearance() {
UITabBar.appearance().tintColor = .label
UIBarButtonItem.appearance().tintColor = .label
UINavigationBar.appearance().prefersLargeTitles = true
}
}
- ๊ทธ๋ฆฌ๊ณ AppDelegate์์ ์ ์ญ์ ์ผ๋ก ์ธํ ํ ์ ์๋๋ก ํธ์ถํด์ค๋ค.
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
AppAppearance.setUpAppearance() // ํธ์ถ
return true
}
- DTO ํ์ ๋ฌธ์ ๋ก ๋์ฝ๋ฉ ์คํจ๊ฐ ๋๋๋ฐ, ์๋ฌ๋ฉ์ธ์ง๊ฐ ๋ฐ๋ก ๋จ์ง ์์์ ์์ธ์ ์ฐพ๋๊ฒ ์ด๋ ต๋ ์์ค์ ์ฐพ์ ๋ฐฉ๋ฒ์ด๋ค.
do {
let decoder = JSONDecoder()
let decoded = try decoder.decode([Root].self, from: data!)
} catch {
// print(localizedDescription) // <- โ ๏ธ Don't use this!
print(String(describing: error)) // <- โ
Use this for debuging!
}
- ๋ณดํต error์
localizedDescription
์ ํตํด ์๋ฌ๋ฉ์ธ์ง๋ฅผ ํ์ธํด๋ณด๋ ค๊ณ ํ๋๋ฐ, ๋์ฝ๋ฉ ์๋ฌ์ ๊ฒฝ์ฐString(describing:)
์ด๋์ ๋ผ์ด์ ๋ฅผ ์ฌ์ฉํ์ฌ ์๋ฌ๋ฉ์ธ์ง๋ฅผ ๋๋ฒ๊น ํด๋ณผ ์ ์์๋ค.
- ๋ก๊ทธ์ธ์ ํ์ ๋, ๋ก๊ทธ์์์ ํ์ ๋ ๋ทฐ๊ฐ ๋ณด์ฌ์ฃผ๋ ๋ฐ์ดํฐ๋ฅผ ๋ค๋ฅด๊ฒ ํ์ํ๋๋ก ํด์ผํ๋๋ฐ, ์ด๋ค ๋ฐฉ๋ฒ์ด ์๋ ์ฐพ์๋ณด๋ค๊ฐ ํด๋น ๊ธ์ ๋ณด๊ฒ ๋์๋ค.
- ๋ฐฉ์์ ๋ฆฌ์ค๋๋ผ๋ ํ๋กํ ์ฝ์ ๋ง๋ค๊ณ , ๋ก๊ทธ์ธํ๊ฑฐ๋ ๋ก๊ทธ์์ ํ์ ๋ ์คํํ ๋ฉ์๋๋ฅผ ์ ์ํด์ค๋ค.
- ๊ทธ๋ฆฌ๊ณ ๋ก๊ทธ์ธ/๋ก๊ทธ์์ ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ ๋๋ง๋ค ์ผ์ ํด์ผํ๋ ๊ฐ์ฒด๋ค์๊ฒ ๋ฆฌ์ค๋๋ฅผ ์ค์ํ๋๋ก ํ๊ฒํ๊ณ , ๋ก๊ทธ์ธ ๋งค๋์ ๋ฅผ ํ์ฉํด ์ด๋ฒคํธ๊ฐ ๋ฐ์ํ ๋๋ง๋ค ๋ฆฌ์ค๋๋ค์ด ์ผ์ฒ๋ฆฌ๋ฅผ ํ ์ ์๋๋ก ๊ตฌ์ฑํด์ฃผ์๋ค.
- ๋์ ๊ฒฝ์ฐ๋ Storage > Repository > UseCase ์์๋๋ก ๋ฐ์ดํฐ๋ฅผ ๋คํธ์ํฌ๋ก ๋ถ๋ฌ์ค๋ ์์ ์ ์ ํํ ํ์ ํ ์๊ฐ ์์ด์ ์ต์ ๋ฒ๋ฅผ ํ์ฉํ์ฌ ๋ค์ ๋ฆฌ์ค๋๊ฐ ์ผ์ฒ๋ฆฌ๋ฅผ ํ ์ ์๋๋ก ๊ตฌ์ฑํด์ฃผ์๋ค.
final class LoginManager {
static let shared = LoginManager()
private init() {}
private let apiRequest: APIProvider = DefaultAPIProvider()
var isLogged: Bool {
KeychainStorage.shard.load("Token") == nil ? false : true
}
// ๋ฆฌ์ค๋๋ค์ ๋ด์๋๋ ๋ฐฐ์ด
private var listeners: [WeakReference<AuthChangeListener>] = []
// ๋ฆฌ์ค๋ ๋ฑ๋ก
func addListener(_ listener: AuthChangeListener) {
if listeners.compactMap({ $0.value?.instanceName() }).contains(listener.instanceName()) {
return
}
listeners.append(WeakReference(value: listener))
}
func authorize() {
guard let url = APIAddress.code(clientID: Secrets.clinetID, scope: "repo,user").url else {
return
}
UIApplication.shared.open(url)
}
func requestToken(code: String, completion: ((Result<Bool, Error>) -> Void)?) {
guard let url = APIAddress.token(clientID: Secrets.clinetID, clientSecret: Secrets.clinetSecret, code: code).url else {
return
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Accept")
apiRequest.execute(request: request) { result in
switch result {
case .success(let data):
data.flatMap { try? JSONSerialization.jsonObject(with: $0, options: []) as? [String: String] }
.flatMap { $0["access_token"] }
.flatMap {
if KeychainStorage.shard.save(key: "Token", value: $0) {
print("์ฌ์ฉ์์ ํ ํฐ์ ํค์ฒด์ธ์ ์ ์ฅํ๋๋ฐ ์ฑ๊ณตํ์ต๋๋ค!")
// ์ฒซ๋ฒ์ฌ ๋ฆฌ์ค๋๊ฐ ์ด๋ฒคํธ๋ฅผ ๋ฐ์ ์ผ์ฒ๋ฆฌ๋ฅผ ํ๋๋ก ๋ฉ์๋ ํธ์ถ
self.listeners.first?.value?.authStateDidChange(isLogged: true)
completion?(.success(true))
} else {
print("ํ ํฐ์ ๊ฐ์ ธ์ค์ง ๋ชปํ์ต๋๋ค.")
completion?(.success(false))
}
}
case .failure(let error):
completion?(.failure(error))
}
}
}
func logout() {
KeychainStorage.shard.delete(key: "Token")
listeners.forEach {
$0.value?.authStateDidChange(isLogged: false)
}
}
// ์ฒซ๋ฒ์ฌ ๋ฆฌ์ค๋๊ฐ ์ผ์ ๋ชจ๋ ๋ง์น๋ฉด ์ด ๋ฉ์๋๋ฅผ ํ์ฉํ์ฌ ๋ค์ ๋ฆฌ์ค๋๊ฐ ์ผ์ฒ๋ฆฌ๋ฅผ ํ ์ ์๋๋ก ํ๋ค.
func executeNextWork(_ order: String, isLogged: Bool = LoginManager.shared.isLogged) {
guard let index = listeners.compactMap({ $0.value?.instanceName() }).firstIndex(of: order) else {
return
}
listeners[index].value?.authStateDidChange(isLogged: isLogged)
}
}
- ๊ทธ๋ฆฌ๊ณ ์ถํ ๋ฉ๋ชจ๋ฆฌ ๋์๋ฅผ ๋ฐฉ์งํ๊ธฐ ์ํด WeakReference๋ผ๋ ํด๋์ค๋ฅผ ๋ง๋ค์ด ๋ฆฌ์ค๋๋ค์ ๋ด์์ฃผ์๋ค.