- 서비스 소개: 협업 소통 앱
- 개발 인원: 1인
- 개발 기간: 24.07.05 ~ 24.08.07(총 한달)
- 개발 환경
- 최소버전: iOS 16
- Portrait Orientation 지원
- 라이트 모드 지원
- 사용 협업 툴
- Jira, Swagger, Figma
- 회원 인증
- 회원 가입
- 로그인 / 소셜 로그인(카카오 / 애플)
- 로그아웃
- 워크스페이스 기능
- 워크스페이스 생성 / 조회 / 편집 / 삭제
- 워크스페이스 관리자 변경
- 워크스페이스 퇴장
- 멤버 초대
- 채널 기능
- 채널 생성 / 조회 / 탐색 / 편집 / 삭제
- 채널 관리자 변경
- 채널 퇴장
- 채널 채팅 생성
- DM 기능
- 채팅 생성
- 실시간 채팅 응답
- 프로필 조회 / 편집
- 코인 결제 기능
회원가입 | 로그인 | 로그아웃 | 소셜로그인 - 애플 |
---|---|---|---|
워크스페이스 생성 | 워크스페이스 편집 | 워크스페이스 관리자 변경 | 워크스페이스 삭제 |
---|---|---|---|
워크스페이스 퇴장 | 멤버 초대 |
---|---|
채널 생성 | 채널 편집 | 채널 관리자 변경 | 채널 삭제 |
---|---|---|---|
채널 퇴장 | 채널 탐색 + 채널 채팅 입장 |
---|---|
DM 채팅 생성 | DM 실시간 채팅 |
---|---|
프로필 수정 | 코인 결제 |
---|---|
- SwiftUI, Swift Concurrency, Combine
- Redux Pattern, Router / Coordinator Pattern, Repository Pattern
- Moya, LocalizedError, NSCache
- Realm, IAMPort, SocketIO, UserDefaults
- KakaoOpenSDK(Kakao Login) / AuthenticationServices(Apple Login)
- Redux 패턴을 통해 중앙 집중화된 상태 관리와 예측 가능한 단방향 데이터 흐름으로 확장성과 유지 보수성을 확보
- NSCache를 활용하여 메모리 효율적인 이미지 데이터 캐싱과 불필요한 이미지 데이터 재 다운로드 방지
- LocalizedError 프로토콜 준수를 통한 직관적이고 현지화된 네트워크 오류 메시지 제공
- Coordinator 패턴을 활용한 소셜 로그인 구현으로 책임 분리
- Realm을 활용한 채팅 내역 저장 DB 구현
- 여러 결제 대행사(PG) 및 간편결제 로직을 WebView 기반으로 구현하여 결제 기능 도입
- Navigation Stack을 Router로 구현하여 화면 전환 관리
- Moya의 Router 패턴 구현으로 네트워크 통신 모듈화
- PluginType 프로토콜 준수하는 Plugin 구성으로 네트워크 Logging 구현
- Realm의 Repository 패턴 구성으로 데이터 접근 로직 추상화
문제상황
-
Kingfisher로 이미지로 다운로드 시, Header에 토큰 추가는 가능하나 토큰 갱신 로직 추가는 Kingfisher 라이브러리 구조상 추가하기 불편함을 느낌
-
이에 이미지 다운로드 로직을 직접 구현함
해결방법
-
방법1: 토큰 갱신하는 모듈을 따로 두어 관리
- 이렇게 독립적인 모듈로서 관리된다면 유지보수 관점에서 상당히 큰 이점을 누릴 수 있지만 개인적인으로 생각했을 때 타이머를 이용해 Background에서 주기적으로 토큰 만료를 모니터링하여 불필요한 자원이 낭비되고, 또, 토큰 갱신 요청 시점이 API 요청 시점에 종속적이지 않아 정확한 시점에 토큰이 갱신되지 않을 것 같아 해당 방법은 배제
-
방법2: 다른 API 요청 시 토큰 만료 추적 및 갱신 로직과 통합 구현
-
이렇게 구현하면 방법1에서 언급되었던 자원 낭비 문제와 부정확한 토큰 갱신 시점 문제를 해결 가능
- 자원 낭비 문제 해결: 타이머를 이용해 항시 토큰 만료 여부를 모니터링하지 않아도 됨
- 부정확한 토큰 갱신 시점 문제 해결: API 요청 -> 토큰 만료 여부 확인 순으로 네트워크 요청이 이루어지기 때문에 정확한 시점에 토큰 갱신이 가능
토큰 갱신 코드
func performRequest<DecodedType: Decodable, ErrorType: RawRepresentable & Error>( _ target: Target, errorType: ErrorType.Type, retryCount: Int = 3, decodingHandler: (Data) throws -> DecodedType? ) async throws -> DecodedType? where ErrorType.RawValue == String { do { let response = try await request(target) switch response.statusCode { case 200: return try decodingHandler(response.data) case 400...500: let errorCode = try decode(response.data, as: ErrorCode.self) if let commonError = CommonError(rawValue: errorCode.errorCode) { /// 토큰 갱신 로직 - 재귀문을 통해 최대 3번까지 토큰 갱신 요청 if commonError == CommonError.expiredAccessToken { if retryCount > 0 { try await RefreshTokenProvider.shared.refreshToken() return try await performRequest(target, errorType: errorType, retryCount: retryCount - 1, decodingHandler: decodingHandler) } } else { throw commonError } } else if let specifiicError = ErrorType(rawValue: errorCode.errorCode) { /// Refresh 토큰 만료 로직 - Refresh 토큰 만료 시 유저를 사용자를 로그인 화면으로 유도 if let specifiicError = specifiicError as? RefreshTokenError, specifiicError == RefreshTokenError.expiredRefreshToken { UserDefaultsManager.removeObject(forKey: .userInfo) UserDefaultsManager.removeObject(forKey: .selectedWorkspace) NotificationCenter.default.post(name: .gobackToRootView, object: nil, userInfo: [NotificationNameKey.gobackToRootView: true]) } else { throw specifiicError } } default: break } } catch { throw error } return nil }
-
문제상황
- 토스트 메세지 구현 시 sheet로 새로운 뷰를 띄우거나 화면 전환 시 토스트 메세지가 가려짐
문제 원인 파악
- 일정한 뷰 계층에서만 토스트 메세지가 해당 해당 뷰에만 종속되어 문제 발생
해결방법
-
Alert창처럼 새로운 상위 계층의 window를 생성하여 어느 화면이던 토스트 메세지 창이 일정하게 표시되도록 구현
코드
final class WindowProvider: ObservableObject { private var toastMessageWindow: UIWindow? /// 토스트 메세지 표시 함수 func showToast(message: String, duration: Double = 2.5) { if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene { toastMessageWindow = UIWindow(windowScene: scene) toastMessageWindow?.windowLevel = .statusBar } guard let toastMessageWindow = toastMessageWindow else { return } /// 토스트 메세지 window 생성 let hostingController = UIHostingController(rootView: ToastView(message: message)) hostingController.view.backgroundColor = .clear toastMessageWindow.rootViewController = hostingController toastMessageWindow.makeKeyAndVisible() /// 토스트 메세지 크기에 따른 window 크기 동적 변화 let targetSize = hostingController.sizeThatFits( in: CGSize( width: UIScreen.main.bounds.width - 40, height: CGFloat.greatestFiniteMagnitude ) ) toastMessageWindow.frame = CGRect( x: (UIScreen.main.bounds.width - targetSize.width) / 2, y: UIScreen.main.bounds.height - targetSize.height - 80, width: targetSize.width, height: targetSize.height ) DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in guard let self else { return } dismissToast() } } /// 토스트 메세지 닫는 함수 private func dismissToast() { toastMessageWindow?.isHidden = true toastMessageWindow = nil } }
문제상황
-
프로젝트 타켓 최소 버전 iOS16 구성으로 onChange Modifier와 cornerRadius Modifier의 사용이 불편
-
iOS17 기준으로 onChange Modifier의 파라미터가 다름
// iOS17이상 func onChange<V>( of value: V, initial: Bool = false, _ action: @escaping (V, V) -> Void ) -> some View where V : Equatable // iOS17이하 func onChange<V>( of value: V, perform action: @escaping (V) -> Void ) -> some View where V : Equatable
- cornerRadius Modifier는 iOS18부터 deprecated 됨
-
문제 원인 파악
- 각 버전에 맞는 onChange Modifier와 cornerRadius Modifier의 재구성 필요
해결방법
-
onChange Modifier 버전별 대응
코드
struct OnChangeModifier<Value: Equatable>: ViewModifier { let value: Value let action: (Value) -> Void func body(content: Content) -> some View { if #available(iOS 17.0, *) { // iOS17이상 대응 content .onChange(of: value) { _, newValue in action(newValue) } } else { // iOS17이하 대응 content .onChange(of: value, perform: { newValue in action(newValue) }) } } } extension View { func onChange<Value: Equatable>( of value: Value, action: @escaping (Value) -> Void ) -> some View { modifier(OnChangeModifier(value: value, action: action)) } }
-
모든 버전 대응 가능한 cornerRadius Modifier 구현
코드
struct RoundedCorner: Shape { var radius: CGFloat = .infinity var corners: UIRectCorner = .allCorners func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius) ) return Path(path.cgPath) } } extension View { func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners)) } }