- ํ๋ก์ ํธ ๊ธฐ๊ฐ: 2024. 04. 13. ~ 2024. 05. 05.
- ์์ ๋ด์ญ: SeSAC x Memolease iOS 4๊ธฐ Light Service Level Project(LSLP) ๊ฒฝ์ง๋ํ 3๋ฑ
- ์ด์ง ๋ง์ง ๊ณ ๋ฏผ๋๋ ํจ์ ์์ดํ ์ ๊ณต์ ํ์ฌ ํจ๊ป ํฌํํ ๊ฒฐ๊ณผ๋ก ์ผํ ๊ฒฐ์ ์ ๋์์ด ๋ ์ ์๋ ์ดํ
- Configuration: iOS 16.0+
-
ํ์๊ฐ์ ๋ก์ง ๊ตฌํ (์ด๋ฉ์ผ, ๋น๋ฐ๋ฒํธ ๋ฑ ์ ๊ท์ ๊ฒ์ฌ) ๋ฐ ์์ , ํํด, ๋ก๊ทธ์ธ, ๋ก๊ทธ์์
-
ํฌ์คํธ ์ ๋ก๋ / ์ญ์ / ์กฐํ
-
ํฌ์คํธ ์ข์์ / ์ซ์ด์
-
ํด์ํ๊ทธ ๊ฒ์ / ํน์ ๊ฒ์๋ฌผ ์กฐํ
-
๋๊ธ ์์ฑ / ์์ / ์ญ์ / ์กฐํ
-
ํ๋กํ ์กฐํ ( ํ๋ก์ฐ, ํ๋ก์ / ์์ฑํ ๊ฒ์๋ฌผ / ์ข์์ํ ๊ฒ์๋ฌผ / ์ซ์ด์ํ ๊ฒ์๋ฌผ / ๊ฒฐ์ ํ ๊ฒ์๋ฌผ ์กฐํ )
-
ํ๋ก์ฐ / ์ธํ๋ก์ฐ
-
PortOne ์ด์ฉํ PG๊ฒฐ์
-
1:1 ์ฑํ ๋ฐฉ ๊ฐ์ค / ๋ด ์ฑํ ๋ฐฉ ์กฐํ / ์ฑํ ๋ฐฉ ๋ํ ๋ด์ญ ์กฐํ / ์์ผ ํ์ฉํ ์ค์๊ฐ ๋ํ (Updated at 2024. 05. 24.)
-
Framework
- UIKit (RxSwift ๊ธฐ๋ฐ)
-
Pattern
- MVVM
- UI์ ๋น์ฆ๋์ค ๋ก์ง์ ๋ถ๋ฆฌํ์ฌ ์ ์ง๋ณด์์ ํ ์คํธ๊ฐ ์ฉ์ดํ๋๋ก ํ๊ธฐ ์ํด MVVM ํจํด์ ์ฌ์ฉ
- MVVM
-
Library
-
๋น๋๊ธฐ ๋ฐ ๋ฐ์ํ ํ๋ก๊ทธ๋๋ฐ
- RxSwift
- ๋น๋๊ธฐ ํ๋ก๊ทธ๋๋ฐ๊ณผ ๋ฐ์ดํฐ ์คํธ๋ฆผ์ ๊ด๋ฆฌํ๋ ๋ฐ ์์ด์ ํจ์จ์ ์ด๊ณ ๊ฐ๊ฒฐํ ์ฝ๋๋ฅผ ์์ฑ์ ์ํด ์ฌ์ฉ
- RxGesture
- RxSwift์ ์ฐ๋ํ์ฌ, ์ ์ค์ฒ ์ธ์์ ์ฝ๊ฒ ์ฒ๋ฆฌํ๊ธฐ ์ํด ์ฌ์ฉ
- RxSwift
-
๋คํธ์ํฌ
- Alamofire
- ๋คํธ์ํฌ ์์ฒญ์ ๊ฐํธํ๊ณ ํจ์จ์ ์ผ๋ก ์ฒ๋ฆฌํ๊ธฐ ์ํด ์ฌ์ฉ
- Alamofire
-
์ด๋ฏธ์ง ์ฒ๋ฆฌ
- Kingfisher
- ์ด๋ฏธ์ง๋ฅผ ๋ค์ด๋ก๋ํ๊ณ ์บ์ฑํ๋ ์์ ์ ๊ฐํธํ๊ฒ ์ฒ๋ฆฌํ๊ธฐ ์ํด ์ฌ์ฉ
- Kingfisher
-
๋ณด์ ๋ฐ ์ธ์ฆ
- KeychainSwift
- ์ฌ์ฉ์ ์ธ์ฆ ์ ๋ณด๋ฅผ ์์ ํ๊ฒ ์ ์ฅํ๊ณ ์๋ ๋ก๊ทธ์ธ ๊ธฐ๋ฅ์ ๊ตฌํํ๊ธฐ ์ํด ์ฌ์ฉ
- KeychainSwift
-
๊ฒฐ์
- iamport-ios
- ๊ฒฐ์ ๊ธฐ๋ฅ์ ์ฝ๊ฒ ํตํฉํ๊ณ ๊ด๋ฆฌํ๊ธฐ ์ํด ์ฌ์ฉ
- iamport-ios
-
์ ๋๋ฉ์ด์
- Lottie
- ์ ๋๋ฉ์ด์ ์ ์ฝ๊ฒ ๊ตฌํํ์ฌ ์ฌ์ฉ์ ๊ฒฝํ์ ํฅ์์ํค๊ธฐ ์ํด ์ฌ์ฉ
- Lottie
-
UI ๊ตฌ์ฑ
- Tabman
- ์ง๊ด์ ์ด๊ณ ์ฌ์ฉํ๊ธฐ ์ฌ์ด ํญ ์ธํฐํ์ด์ค๋ฅผ ์ ๊ณตํ๊ธฐ ์ํด ์ฌ์ฉ
- Snapkit
- ์คํ ๋ ์ด์์์ ์ฝ๋๋ก ์ฝ๊ฒ ์์ฑํ๊ณ ๊ด๋ฆฌํ๊ธฐ ์ํด ์ฌ์ฉ
- Toast
- ์ฌ์ฉ์์๊ฒ ๊ฐ๋จํ ํ์ ๋ฉ์์ง๋ฅผ ํ์ํ์ฌ ์ธํฐํ์ด์ค๋ฅผ ์ง๊ด์ ์ผ๋ก ๋ง๋ค๊ธฐ ์ํด ์ฌ์ฉ
- IQKeyboardManager
- ํค๋ณด๋๊ฐ ๋ํ๋ ๋ UI๋ฅผ ์๋์ผ๋ก ์กฐ์ ํ์ฌ ์ฌ์ฉ์ฑ ํฅ์์ ์ํด ์ฌ์ฉ
- Tabman
-
keyfeature.mp4
-
์ด๋ฏธ์ง์ ๊ฐ์ด๋ฐ x์ขํ๋ฅผ 0์ผ๋ก ๊ฐ์ , ์ผ์ชฝ ์์ญ(x < 0)์ ๋๋ธํญํ๋ฉด "์ข์์", ์ค๋ฅธ์ชฝ ์์ญ(x > 0)์ ๋๋ธํญํ๋ฉด "์ซ์ด์" ๊ธฐ๋ฅ์ด ๋์ํ๋๋ก ๊ตฌํ
-
๋์ผํ ์์ญ์ ๋ค์ ํฌํํ ๊ฒฝ์ฐ ๊ธฐ์กด ํฌํ๋ฅผ ์ทจ์
์ฃผ์์ฝ๋
cell.postImageView.rx.tapGesture(configuration: { gestureRecognizer, delegate in gestureRecognizer.numberOfTapsRequired = 2 }) .when(.recognized) .subscribe(onNext: { [weak self] gesture in let touchPoint = gesture.location(in: gesture.view) if let width = gesture.view?.bounds.width { if touchPoint.x < width / 2 { likeButtonTapped.onNext(row) self?.playAppropriateAnimation(for: "like", likeCondition: cell.like, dislikeCondition: cell.dislike) } else { disLikeButtonTapped.onNext(row) self?.playAppropriateAnimation(for: "dislike", likeCondition: cell.like, dislikeCondition: cell.dislike) } } }) .disposed(by: cell.disposeBag)
-
ViewModel์ ์ธํฐํ์ด์ค๋ฅผ ๋ช ํํ๊ฒ ์ ์ํ์ฌ ์ผ๊ด๋ ๊ตฌ์กฐ ์ ์ง
-
Input, Output ํ์ ์ ํตํ์ฌ ์ , ์ถ๋ ฅ ๋ช ํ
-
RxSwift์ disposeBag์ ์ฌ์ฉํ ๋ฉ๋ชจ๋ฆฌ ๊ด๋ฆฌ
์ฃผ์์ฝ๋
protocol ViewModelType { associatedtype Input associatedtype Output var disposeBag: DisposeBag { get set } func transform(input: Input) -> Output }
-
TargetType ํ๋กํ ์ฝ๊ณผ Router ์ด๊ฑฐํ์ ์ฌ์ฉํ์ฌ ๋ค์ํ API ์๋ํฌ์ธํธ๋ฅผ ์ ์ํ๊ณ , ๊ฐ ์์ฒญ์ ์ธ๋ถ ์ฌํญ์ ์ค์
-
๋คํธ์ํฌ ์์ฒญ์ ํ๋์ Router ์ด๊ฑฐํ์ผ๋ก ๊ด๋ฆฌํจ์ผ๋ก์จ ์ฝ๋์ ๋ชจ๋ํ์ ์ฌ์ฌ์ฉ์ฑ์ ๋์
-
์๋ก์ด API ์๋ํฌ์ธํธ๋ฅผ ์ถ๊ฐํ ๋ Router ์ด๊ฑฐํ์ ์๋ก์ด ์ผ์ด์ค๋ฅผ ์ถ๊ฐํ๊ณ ํ์ํ ์์ฑ์ ์ ์ํ๋ฉด ๋๋ฏ๋ก, ํ์ฅ์ฑ์ด ๋ฐ์ด๋จ
์ฃผ์์ฝ๋
protocol TargetType: URLRequestConvertible { var baseURL: String { get } var method: HTTPMethod { get } var path: String { get } var header: [String: String] { get } var parameters: String? { get } var queryItems: [URLQueryItem]? { get } var body: Data? { get } } enum Router { case tokenRefresh } extension Router: TargetType { ... }
-
๋คํธ์ํฌ ์์ฒญ์ด AccessToken ๋ง๋ฃ ์ค๋ฅ์ธ HTTP ์ํ ์ฝ๋ 419๋ก ์คํจํ ๊ฒฝ์ฐ, RefreshToken์ ์ด์ฉํ์ฌ ํ ํฐ์ ๊ฐฑ์ ํ๊ณ ์๋์ ์์ฒญ์ ๋ค์ ์๋
์ฃผ์์ฝ๋
static func performRequest<T: Decodable>(route: Router, decodingType: T.Type?) -> Single<T> { return Single<T>.create { single in do { let urlRequest = try route.asURLRequest() AF.request(urlRequest) .validate(statusCode: 200..<300) .responseDecodable(of: T.self) { response in switch response.result { case .success(let result): single(.success(result)) case .failure(let error): single(.failure(error)) } } } catch { single(.failure(error)) } return Disposables.create() } .retry(when: { errors in errors.flatMap { error -> Single<Void> in guard let afError = error as? AFError, afError.responseCode == 419 else { throw error } return refreshToken().flatMap { _ in performRequest(route: route, decodingType: T.self).map { _ in Void() } } } }) }
- performRequest<T: Decodable>: ์ฃผ์ด์ง ๋ผ์ฐํธ์ ๋ฐ๋ผ ๋คํธ์ํฌ ์์ฒญ์ ์ํํ๊ณ , ๊ฒฐ๊ณผ๋ฅผ Single๋ก ๋ฐํํฉ๋๋ค.
- ์์ฒญ์ด ์คํจํ๋ฉด retry(when:) ์ฐ์ฐ์๊ฐ ์คํ๋์ด ์ค๋ฅ๋ฅผ ๊ฒ์ฌํฉ๋๋ค.
- ์ค๋ฅ๊ฐ HTTP 419 ์ํ ์ฝ๋์ธ ๊ฒฝ์ฐ refreshToken() ๋ฉ์๋๋ฅผ ํธ์ถํ์ฌ ํ ํฐ์ ๊ฐฑ์ ํฉ๋๋ค.
- ํ ํฐ์ด ๊ฐฑ์ ๋๋ฉด ์๋์ ์์ฒญ์ ๋ค์ ์๋ํฉ๋๋ค.
- refreshToken(): ํ ํฐ ๊ฐฑ์ ์ ์ฒ๋ฆฌํ๋ ๋ฉ์๋๋ก, ์ฑ๊ณต์ ์ผ๋ก ํ ํฐ์ด ๊ฐฑ์ ๋๋ฉด Single๋ฅผ ๋ฐํํ์ฌ ์ฌ์๋๋ฅผ ํ์ฉํฉ๋๋ค. ์คํจํ๋ฉด ์ค๋ฅ๋ฅผ ๋ฐ์์์ผ ์ฌ์๋๋ฅผ ์ค๋จํ๊ณ ๋ก๊ทธ์ธ ์ฐฝ์ผ๋ก ์ด๋ํ๋๋ก ํฉ๋๋ค.
-
์์ผ ์ฐ๊ฒฐ ์ํ๋ฅผ ์ผ๊ด๋๊ฒ ์ ์งํ๊ธฐ ์ํ์ฌ Singleton Pattern ์ ์ฉ
์ฃผ์์ฝ๋
final class SocketIOManager { static var shared: SocketIOManager = SocketIOManager() var manager: SocketManager? var socket: SocketIOClient? let baseURL = URL(string: "\(APIKey.baseURL.rawValue)/v1")! var receivedChatData = PassthroughSubject<ChatContentModel, Never>() private init() {} func fetchSocket(roomId: String) { manager = SocketManager(socketURL: baseURL, config: [.log(true), .compress]) socket = manager?.socket(forNamespace: "/chats-\(roomId)") socket?.on(clientEvent: .connect) { data, ack in print("socket connected", data, ack) } socket?.on(clientEvent: .disconnect) { data, ack in print("socket disconnected", data, ack) } socket?.on("chat") { dataArray, ack in print("chat received", dataArray, ack ) if let data = dataArray.first { do { let result = try JSONSerialization.data(withJSONObject: data) let decodedData = try JSONDecoder().decode(ChatContentModel.self, from: result) self.receivedChatData.send(decodedData) } catch { print(error.localizedDescription) } } } } func establishConnection() { socket?.connect() } func leaveConnection() { socket?.disconnect() } }
-
์์ฃผ ์ฌ์ฉ๋๋ UserDefaults ์ ์์ฉ๊ตฌ ์ฝ๋๋ฅผ ์ค์ด๊ณ ๊ฐ๋ ์ฑ์ ๋์ด๊ธฐ ์ํ์ฌ ๊ตฌํ
-
@propertyWrapper ์์ฑ์ ์ ์ฉํ MyDefaults ๊ตฌ์กฐ์ฒด ์์ฑ ํ UserDefaultsManager ์ ์
์ฃผ์์ฝ๋
@propertyWrapper struct MyDefaults<T> { let key: String let defaultValue: T var wrappedValue: T { get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue } set { UserDefaults.standard.setValue(newValue, forKey: key) } } } enum UserDefaultsManager { enum Key: String { case userId ... } @MyDefaults(key: Key.userId.rawValue, defaultValue: "") static var userId: String ... } //๊ธฐ์กด ์ฌ์ฉ๋ฒ UserDefaults.standard.string(forKey: UserDefaultsKey.userId.key) ?? "" //๊ฐ์ ๋ ์ฌ์ฉ๋ฒ let myId = UserDefaultsManager.userId //get UserDefaultsManager.userId = "๋ณ๊ฒฝ๋๋๋ค์" //set
-
Base๊ฐ UIScrollView ํ์ ์ธ ๊ฒฝ์ฐ, UIScrollView๊ฐ ๋ฐ๋ฅ์์ 400 ํฌ์ธํธ์ ๋๋ฌํ ๋๋ง๋ค ์ด๋ฒคํธ๋ฅผ ๋ฐฉ์ถํ๋ reachedBottom ํ๋กํผํฐ๋ฅผ ์ ์
์ฃผ์์ฝ๋
extension Reactive where Base: UIScrollView { var reachedBottom: Observable<Void> { return contentOffset .debounce(.milliseconds(100), scheduler: MainScheduler.instance) .flatMap { [weak base] _ -> Observable<Void> in guard let scrollView = base else { return .empty() } let contentHeight = scrollView.contentSize.height let scrollViewHeight = scrollView.bounds.size.height let scrollPosition = scrollView.contentOffset.y + scrollViewHeight let threshold = contentHeight - 400 if scrollPosition >= threshold { return .just(()) } else { return .empty() } } } }
![]() |
![]() |
![]() |
![]() |
---|---|---|---|
ํ์๊ฐ์ ~ ํํด | Keychain ํ์ฉํ ์๋๋ก๊ทธ์ธ | ํ๋กํ ์์ | ํฌ์คํธ CRUD |
![]() |
![]() |
![]() |
![]() |
---|---|---|---|
ํฌ์คํธ ์ข์์/์ซ์ด์ | ๋ด ํ๋กํ ์กฐํ (๋ด ๊ฒ์๊ธ, ์ข์์/์ซ์ด์ํ ๊ฒ์๊ธ, ๊ฒฐ์ ๋ด์ญ ์กฐํ) |
๋ค๋ฅธ ์ ์ ํ๋กํ ์กฐํ | ํ๋ก์ฐ |
![]() |
![]() |
![]() |
---|---|---|
๊ฒ์๋ฌผ ์กฐํ | ๋๊ธ ์์ฑ, ์ญ์ , ์์ | ๊ฒฐ์ ๊ธฐ๋ฅ |
![]() |
![]() |
---|---|
๋ด ์ฑํ ๋ฐฉ ๋ชฉ๋ก | ๋ค๋ฅธ์ ์ ํ๋กํ์์ ์ฑํ ๋ฐฉ ์ง์ |