/CollaTalk

업무간 협업을 위한 소통 앱입니다. 🌱

Primary LanguageSwift

CollaTalk - 협업 소통 앱


CollaTalk

  • 서비스 소개: 협업 소통 앱
  • 개발 인원: 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로 구현하여 화면 전환 관리
  • MoyaRouter 패턴 구현으로 네트워크 통신 모듈화
  • PluginType 프로토콜 준수하는 Plugin 구성으로 네트워크 Logging 구현
  • Realm의 Repository 패턴 구성으로 데이터 접근 로직 추상화

⚙️ 아키텍처


💾 Realm DB 구조


🔥 트러블 슈팅

1. 이미지 다운로드 시, 토큰 만료 추적을 위한 갱신 로직 직접 구현

문제상황

  • 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
    } 

2. window 계층을 활용한 토스트 메시지 구현

문제상황

  • 토스트 메세지 구현 시 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
        }
    }
    해결화면

3. SwiftUI 버전 대응 - onChange, cornerRadius Modifier

문제상황

  • 프로젝트 타켓 최소 버전 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))
        }
    }