이 세상 모든 지각 꾸물이들의 정시 도착 꿈을 이뤄줄 꿈같은 서비스, 꾸물꿈 ⏰💤
34기 NOW SOPT AppJam 꾸물꿈 프로젝트입니다.
꾸물꿈에 대해 더 자세히 알고 싶다면? 프로젝트 설계 및 주요 기능 소개 보기✔️
- [프로젝트 소개]
- [iOS Team]
- [기술 스택]
- [코딩 컨벤션]
- [브랜치 전략]
- [폴더링]
- [트러블 슈팅]
김진웅 @JinUng41 |
이지훈 @hooni0918 |
이유진 @youz2me |
김수연 @mmaybei |
---|---|---|---|
![]() |
![]() |
![]() |
![]() |
약속 추가 플로우 , 모임 상세 |
푸시 알림 , 온보딩 , 마이페이지 |
모임 추가 플로우 , 약속 상세 |
홈 , 내 모임 , 준비 정보 입력 |
library
library | description |
---|---|
FirebaseSDK | FCM을 이용한 푸쉬 알림을 구현하기 위함 |
KakaoSDK | 카카오 소셜 로그인 구현을 위함 |
Lookin | UI 구현에 있어, 뷰 계층을 보다 쉽게 파악하기 위함 |
Moya | 추상화된 네트워크 레이어를 보다 간편하게 사용하기 위함 |
RxCocoa | 뷰의 상태 관리를 위한 동적 프로그래밍 도입 |
RxSwift | 뷰의 상태 관리를 위한 동적 프로그래밍 도입 |
Snapkit | UI 구현에 있어, 오토레이아웃을 보다 간편하게 사용하기 위함 |
Then | UI 구현에 있어, 클로저를 통해 인스턴스를 초기화하기 위함 |
Kingfisher | 이미지 캐싱 처리 및 UI 성능 개선을 위함 |
main 브랜치: 최종 제출용
suyeon 브랜치: 개발 작업용 (default 브랜치)
1. 기능 개발, 네트워크, 리팩토링, 세팅 등 작업할 내용에 대한 이슈 생성
2. suyeon 브랜치에서 이슈 브랜치 생성
3. 이슈 브랜치에서 작업
4. 작업 완료 후 PR 작성, 체크리스트를 통해 어떤 것을 해결한 이슈인지 명시
5. 코드리뷰를 통해 모든 구성원이 approve하였을 때 suyeon 브랜치로 머지
📁 Kkumulkkum
├── 📁 Application
│ ├── AppDelegate
│ ├── SceneDelegate
├── 📁 Source
│ ├── 🗂️ Onboarding
│ │ ├── 🗂️ Model
│ │ ├── 🗂️ ViewModel
│ │ ├── 🗂️ View
│ │ ├── 🗂️ ViewController
│ ├── 🗂️ Home
│ ├── 🗂️ My
│ ├── 🗂️ Core
│ │ ├── TabBar
│ │ ├── View
│ │ ├── Cell
├── 📁 Resource
| ├── 🗂️ Extension
| | ├── UIStackView+
| | ├── UIView+
| | ├── ...
| ├── 🗂️ Util
| | ├── ReuseIdentifiable
| | ├── Screen
| | ├── ...
| ├── 🗂️ Font
| | ├── .ttf
| ├── Asset.xcassets
│ ├── Info.plist
├── 📁 Network
- 홈 화면에서 여러 API 호출(로그인 유저, 가까운 약속, 다가오는 약속)이 동시에 이루어질 때 토큰 만료 시 Data Race 발생
- 약 10번 중 1번 정도 자동 로그인 실패 현상 확인
- 각 API 요청이 개별적으로 토큰 갱신 메커니즘을 트리거하여 중복 실행 문제
- TokenRefreshManager 클래스를 생성하여 토큰 갱신 로직 중앙화
private let queue = DispatchQueue(label: "com.TokenRefreshManager.queue")
- 토큰 갱신 중 새로운 요청은 대기 후 처리하는 방식 구현
if self.isRefreshing {
DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
self.refreshToken(completion: completion)
}
return
}
- NSLock: 세밀한 제어 가능하나 데드락 위험성
- 세마포어: 대기시간을 예측할 수 없어 사용자 경험 저하 우려
- 직렬 큐: 순차적 실행 보장, 낮은 오버헤드, 구현 단순성
- Alamofire Interceptor의 adapt와 retry 메서드가 컨커런시를 지원하지 않음
- 서드파티 라이브러리 의존성의 제약 경험
- 이를 통해 Third-party 라이브러리에 과도하게 의존하지 않는 아키텍처 설계의 중요성 학습
- 토큰 갱신 시 새로운 AccessToken과 RefreshToken 모두 저장하는 방식 적용
-
자동 로그인 성공률 99% 이상으로 향상
-
동시 API 요청 상황에서도 안정적인 토큰 갱신 보장
-
사용자가 토큰 만료로 로그아웃되는 상황 최소화
링크
3. 토큰 갱신 성능 최적화
링크
기존 토큰 갱신 메커니즘은 재귀적 호출 패턴을 사용해 동시에 여러 요청이 들어올 경우 시간 복잡도가 O(n)으로 증가했습니다. Instruments 프로파일링을 찍어본 결과
- 호출 스택이 깊어지고(20개 이상의 중첩된 워커 스레드), CPU 사용량이 불규칙한 스파이크를 보였습니다
- 각 요청마다 0.1초의 지연이 누적되어 반응성이 저하되었습니다
- 전체 실행 시간의 52.52%가 스택 트레이스에 소비되고 있었습니다
- 워커 스레드들이 각각 0.8%~3.6%의 CPU 시간을 분산 차지하며 리소스 사용이 비효율적이었습니다
이 문제를 해결하기 위해 다음 알고리즘과 자료구조를 적용했습니다:
- 콜백 큐 패턴: 모든 요청을 배열에 저장하고 일괄 처리하여 네트워크 요청을 O(1)로 감소
private var pendingCompletions: [(Result<String, Error>) -> Void] = []
- 디바운싱 알고리즘: 20ms 내 연속 요청을 단일 작업으로 통합하여 중복 제거
self.refreshWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.performTokenRefresh(with: currentRefreshToken)
}
- 지수 백오프 알고리즘: 네트워크 실패 시 2^n * 100ms 패턴으로 재시도하여 복원력 강화
let delay = pow(2.0, Double(self.requestCount)) * 0.1
- 호출 스택 깊이 감소: 20개 이상 → 5개 미만으로 간소화되어 메모리 사용량 최적화
- 중첩 재귀 호출 제거: 메인 스레드가 94.6%로 효율적으로 작업 처리
- CPU 사용 패턴: 불규칙한 스파이크에서 더 낮고 균일한 패턴으로 개선
- 응답 시간: 최대 작업 시간 162ms에서 큰 폭으로 감소
- 시간 복잡도: 네트워크 요청 횟수가 O(n) → O(1)로 개선되어 사용자 요청 수에 무관한 일정한 반응 시간 보장
- 재시도 메커니즘: 지수 백오프를 통한 네트워크 불안정 상황에서의 복원력 향상
Instruments를 통한 프로파일링에서 개선을 확인했습니다:
_dispatch_workloop_worker_thread
의 중첩 호출이 감소하고 메인 스레드(580ms)가 효율적으로 작업을 처리_CFRunLoopRun
과 같은 CoreFoundation 함수들이 더 체계적으로 호출- CPU 사용량 그래프가 더 일정하고 예측 가능한 패턴을 보임
refreshToken
함수의 재귀적 중첩 호출이 사라지고, 단일 호출 패턴으로 변경
이러한 최적화를 통해 토큰 갱신 처리 시간이 요청 수에 관계없이 일정하게 유지되어 앱의 전반적인 반응성과 성능을 향상시켯습니다.
링크
앱의 로그인 프로세스에서 네트워크 응답 지연 시 다음 문제가 발생했습니다:
- 중복 API 호출: 첫 설치 후 첫 로그인/회원가입 버튼 클릭 시 네트워크 응답이 지연되는 동안 사용자가 로그인 버튼을 여러 번 탭하면 동일한 API가 중복 호출됨
- 중복 알림 표시: 오류 발생 시 동일한 에러 알림이 여러 개 연속해서 표시됨
- 불안정한 화면 전환: 로그인 성공 후 화면 전환이 여러 번 시도되어 UI가 깜빡이거나 충돌 발생
초기에는 RxSwift를 활용하여 이 문제를 해결하고자 했으나 다음 이유로 대안을 모색했습니다:
- 이벤트 특성 불일치:
PublishRelay
는 지속적인 이벤트 스트림에 최적화되어 있어 로그인과 같은 단일 이벤트 처리에 다소 적합하지 않음 - 구독 제약:
BehaviorSubject
나ReplaySubject
는 이벤트 값을 캐싱할 수 있으나, 완료 후에는 새로운 구독이 불가능한 제약 존재 - 타입 복잡성: 로그인 컨텍스트(카카오 vs 애플)를 유지하기 위해 복잡한 타입 정의와 연산자 체인이 필요
- 기존 코드 호환성: 프로젝트의 기존 콜백 패턴을 크게 변경하지 않고 해결하고자 함
참고: debounce, removeDuplicates, take(1) 등의 RxSwift 연산자로도 해결 가능하지만, 기존 옵저버블 객체를 최대한 변경하지 않는 방향으로 접근했습니다.
ReactorKit의 일회성 이벤트 처리 메커니즘인 Pulse를 영감 받아 커스텀 Pulse 클래스를 구현했습니다:
class Pulse<T> {
typealias Listener = (T) -> Void
private var value: T?
private var listeners: [Listener] = []
private var isConsumed = false
// 이벤트는 한 번만 발생하도록 보장
func emit(_ value: T) {
guard !isConsumed else { return }
self.value = value
isConsumed = true
listeners.forEach { $0(value) }
listeners.removeAll()
}
// 이벤트가 이미 발생했더라도 새 구독자에게 전달
func subscribe(_ listener: @escaping Listener) {
if let value = value, isConsumed {
listener(value)
} else {
listeners.append(listener)
}
}
// 약한 참조로 구독 등록
func subscribe<O: AnyObject>(with object: O, listener: @escaping (O, T) -> Void) {
let wrappedListener: Listener = { [weak object] value in
guard let object = object else { return }
listener(object, value)
}
if let value = value, isConsumed {
wrappedListener(value)
} else {
listeners.append(wrappedListener)
}
}
}
-
이벤트 타입 분리: 로그인 결과, 네비게이션, 오류를 별도의 Pulse 인스턴스로 관리
private(set) var loginResultPulse = Pulse<Result<SocialLoginResponseModel, Error>>() private(set) var navigationPulse = Pulse<LoginNavigation>() private(set) var errorPulse = Pulse<String>()
-
이벤트 간 연결: 에러 발생 시 자동으로 네비게이션 처리
private func setupBindings() { errorPulse.subscribe { [weak self] errorMessage in if !errorMessage.isEmpty { self?.navigationPulse.emit(.showError(message: errorMessage)) } } }
-
중복 네비게이션 방지: ViewController에서
isNavigating
플래그 활용viewModel.navigationPulse.subscribe(with: self) { owner, navigation in // 중복 네비게이션 방지 guard !owner.isNavigating else { return } owner.isNavigating = true switch navigation { case .toMain: owner.navigateToMainScreen() // 다른 네비게이션 케이스 처리... } }
- API 중복 호출 방지: 사용자가 로그인 버튼을 여러 번 탭해도 네비게이션 이벤트는 한 번만 처리됨
- 알림 중복 표시 해결: 오류 알림이 한 번만 표시되어 사용자 경험 개선
- 안정적인 화면 전환: 로그인 성공 시 화면 전환이 정확히 한 번만 발생하도록 보장
- 기존 코드 유지: 기존의 콜백 패턴과 자연스럽게 통합되어 코드 변경 최소화
- 메모리 관리 개선: 약한 참조를 통한 자동 메모리 관리로 메모리 누수 가능성 감소