- 서비스 소개: 실시간 코인 시세 조회 앱
- 개발 인원: 1인
- 개발 기간: 24.06.05 ~ 24.06.17(총 13일)
- 개발 환경
- 최소버전: iOS 15
- Portrait Orientation 지원
- 라이트 모드 지원
- 회원 인증: 회원 가입 / 로그인 / 로그아웃
- 소셜 로그인: 카카오톡 로그인 / 애플 로그인
- 프로필 조회 / 수정
- 코인 관련 기능
- 코인 랭킹 기능
- 코인 검색 / 조회 기능
- 실시간 코인 시세 변동 조회 기능
- 코인 즐겨찾기 기능
코인 랭킹 화면 | 코인 검색 화면 | 코인 상세정보 화면 | 실시간 코인 시세 화면 |
---|---|---|---|
즐겨찾기 화면 | 프로필 화면 | 진입 화면 | 로그인 화면 |
---|---|---|---|
- MVI
- SwiftUI, Combine
- Moya, Alamofire, WebSocket, KakaoOpenSDK
- Kingfisher, DGCharts, Realm, Keychain
- MVI 패턴을 통해 각 구성 요소의 커스텀 프로토콜 구성을 통한 역할을 분리와 각 화면별 단방향 데이터 흐름 구성
- PluginType 프로토콜 준수하는 Plugin 구성으로 네트워크 Logging 구현
- Interceptor를 활용한 토큰 갱신 로직 구성
- WebSocket을 활용한 실시간 소켓 통신 로직 구축
- Realm의 Repository 패턴 구성으로 데이터 접근 로직 추상화
- Custom Property Wrapper를 활용한 Keychain 관리 로직 추상화
- Kingfisher에서 토큰을 활용한 외부 이미지 다운로드 로직 모듈화
- DGCharts 라이브러리를 활용한 차트 구현으로 iOS15 버전 대응
- NWPathMonitor를 활용한 실시간 네트워크 연결 상태 모니터링 구축
- AuthenticationServices와 KakaoOpenSDK를 통한 소셜 로그인 구현(애플/카카오)
-
AuthenticationServices를 이용한 애플 소셜 로그인 구현
-
KakaoSDK를 이용한 카카오 소셜 로그인 구현
코드
import Foundation
import Combine
final class WebSocketManager: NSObject {
static let shared = WebSocketManager()
private var websocket: URLSessionWebSocketTask?
private var isOpen = false
private var timer: Timer?
var tickerSbj = PassthroughSubject<Ticker, Never>()
private override init() {}
func openWebSocket() {
if let url = URL(string: APIKeys.webSocketBaseURL) {
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
websocket = session.webSocketTask(with: url)
websocket?.resume()
ping()
}
}
func closeWebSocket() {
websocket?.cancel(with: .goingAway, reason: nil)
websocket = nil
timer?.invalidate()
timer = nil
isOpen = false
}
}
extension WebSocketManager: URLSessionWebSocketDelegate {
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
print("Socket Open")
isOpen = true
receiveSocketData()
}
func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
print("Socket Close")
isOpen = false
}
}
extension WebSocketManager {
func send(_ string: String) {
websocket?.send(.string(string)) { error in
print("Send Error")
}
}
func receiveSocketData() {
if isOpen {
websocket?.receive(completionHandler: { [weak self] result in
guard let self else { return }
switch result {
case .success(let success):
switch success {
case .data(let data):
do {
let decodedData = try JSONDecoder().decode(Ticker.self, from: data)
print(decodedData)
tickerSbj.send(decodedData)
} catch {
print("Decoding Error", error)
}
case .string(let string):
print(string)
@unknown default:
print("Unknown Default")
}
case .failure(let failure):
print("failure", failure)
}
receiveSocketData()
})
}
}
func ping() {
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true, block: { [weak self] _ in
guard let self else { return }
websocket?.sendPing(pongReceiveHandler: { error in
if let error = error {
print("ping pong error", error.localizedDescription)
} else {
print("ping ping ping")
}
})
})
}
}
-
Keychain Manager 정의
struct KeychainManager { enum Key: String, CaseIterable { case profileImage = "profileImage" // ... } // Keychain 생성 static func create(key: Key, value: String) { // ... } // Keychain 조회 static func read(key: Key) -> String? { // ... } // Keychain 삭제 static func delete(key: Key) { // ... } }
-
Custom Property Wrapper 정의
@propertyWrapper struct KeychainStorage: DynamicProperty { private let key: KeychainManager.Key @State private var data: String var wrappedValue: String { get { // getter 정의 } nonmutating set { // setter 정의 } } var projectedValue: Binding<String> { // projectedValue 정의 } init(wrappedValue: String, _ key: KeychainManager.Key) { self.key = key if let data = KeychainManager.read(key: key) { self.data = data } else { self.data = wrappedValue } } }
코드
import SwiftUI
import Network
final class Network: ObservableObject {
// NWPathMonitor 클래스 인스턴스 선언
let monitor = NWPathMonitor()
// 네트워크 모니터링 담당 Thread
let queue = DispatchQueue(label: "Monitor")
@Published private(set) var isConnected: Bool = false
func checkConnection() {
// 모니터링 시작
monitor.start(queue: queue)
// 네트워크 연결 상태 모니터링 결과 반환
monitor.pathUpdateHandler = { path in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// 네트워크가 연결되어있다면 isConnected에 true 값 전달
isConnected = path.status == .satisfied
}
}
}
func stop() {
// 모니터링 취소
monitor.cancel()
}
}
문제상황
문제 원인 파악
- RequestInterceptor 프로토콜에서 네트워크 응답에 접근하는 request 파라미터의 response 타입이 HTTPURLResponse이기 때문에, RequestInterceptor 프로토콜 메서드로 내에서 네트워크 응답 Body에 직접 접근 불가
해결방법
문제상황
- 프로젝트 타겟 최소 버전이 iOS15로 설정되어 iOS16부터 사용 가능한 SwiftUI의 Charts 프레임워크를 사용 불가
문제 원인 파악
- 타겟 최소 버전이 iOS15에 맞는 라이브러리 필요
해결방법
-
UIKit 기반 DGCharts 라이브러리 도입 및 UIViewRepresentable을 사용하여 ChartView 구현
import DGCharts import SwiftUI struct ChartView: UIViewRepresentable { let entries: [ChartDataEntry] func makeUIView(context: Context) -> LineChartView { // ChartView 정의 } func updateUIView(_ uiView: LineChartView, context: Context) { // ChartView 업데이트 } }