Thiiing(띠잉) iOS 앱 개발기
안녕하세요. 저는 영상 기반 소셜 서비스인 Thiiing(띠잉) iOS 앱을 개발하고 있는 박태현입니다.
2019년 12월 띠잉셀에 합류하여 약 3개월 동안 개발하여 신규 서비스 Thiiing(띠잉) 앱을 출시하였는데요,
프로젝트 세팅부터 출시까지 앱 출시에 필요한 모든 것을 간략하게 공유하려고 합니다.
앱 출시를 하기 위해서는 다음과 같은 항목들을 고려해야 하는데요,
- 프로젝트
- 지원 버전, 언어
- 아키텍처
- UI 개발
- 라이브러리 관리
- 컨벤션 관리
- 배포(CI/CD)
- 연동 작업 (푸시, 이벤트 로깅 등등)
위 항목들에 대해서 어떻게 적용했는지 간단히 정리해보았습니다.
(신규 서비스를 개발하는 사람들에게 도움이 되면 좋겠습니다 😄)
프로젝트
지원버전, 언어
띠잉은 최소 지원 버전 iOS 11, Swift5.1로 개발되었습니다.
기존에 사용해보지 못했던 Color Asset Catalog, Property Wrapper, Result 등을 사용해볼 수 있었습니다.
특히 Color Asset Catalog 기술을 활용하여 색상을 다룰 때 발생할 수 있는 실수를 최소한으로 줄일 수 있었는데요,
후기는 뒷부분에서 따로 다루겠습니다.
아키텍처
UI/UX가 복잡해짐에 따라 사용자 액션 및 데이터를 비동기적으로 다룰 수 있는 Reactive Programming은 필수라고 생각하였고,
Reactive Programming와 가장 잘 어울리는 MVVM 아키텍처를 채택하여 적용하였습니다.
MVVM 아키텍처에 대해서는 수많은 블로그에서 자세히 다루고 있기 때문에,
간단한 설명과 적용 코드를 공유드리겠습니다.
MVVM 아키텍처는 M(Model), V(View), VM(ViewModel)로 구성되며 다음과 같은 역할 가지고 있습니다.
- View
- 화면에 표시되는 레이아웃에 대해 담당 (UI와 관련된 로직, 애니메이션 등을 수행)
- MVVM에서는 UIViewController도 View 레이어로 분류됨
- ViewModel
- View와 Model을 연결시키는 중재자 역할
- View에서 사용자 이벤트 반응하여 Model을 갱신
- Model로부터 수정된 내용에 반응하여 View를 갱신
- 사용자 이벤트와 데이터는 Data Binding을 통하여 이루어짐
- (띠잉에서는 RxSwift를 이용하여 Data Binding 처리를 했습니다.)
- Model
- 서비스에서 사용되는 데이터 및 데이터 접근 레이어
추가로 Thiiing에서는 Repository라는 레이어를 추가하였습니다.
Repository 레이어는 네트워크, DB 등의 외부에서 데이터를 조회, 삭제, 저장, 수정을 진행하는 레이어입니다.
Presentation 레이어(View, ViewModel)에서 Repository 레이어를 통해 네트워크, DB에 접근하기 때문에,
네트워크 API 변경 및 DB 스키마 변경, 라이브러리 업데이트 등으로 인한 코드 수정에도 Presentation 레이어는 코드 수정이 필요 없다는 장점이 있습니다.
각 레이어(MVVM + Repository) 별로 예시를 들어보겠습니다.
다음 화면은 Thiiing에서 소셜 로그인 및 이메일 로그인을 할 수 있는 팝업인데요,
네이버로 시작하기 버튼을 눌렀을 때 각 레이어의 역할을 코드로 설명드리겠습니다.
View는 naverLoginButton tap 이벤트를 ViewModal과 바인딩 시켜줍니다.
override func viewDidLoad() {
super.viewDidLoad()
naverLoginButton.rx.tap
.bind(to: viewModel.action.naverLoginButtonTapped)
.disposed(by: disposeBag)
}
ViewModel에서는 naverLoginButton tap 이벤트에 대해서 처리를 해주어야 하는데요,
그에 앞서서 ViewModel의 구성부터 말씀드리겠습니다.
ViewModel protocol은 Action, State 두 가지 타입을 가지고 있으며,
Action은 View의 이벤트를 바인딩 하기 위한 타입이며,
State는 앱의 상태를 나타내는 타입입니다.
protocol ViewModel {
associatedtype Action
associatedtype State
var action: Action { get }
var state: State { get }
}
ViewModel protocol을 구현한 LoginViewModel 입니다.
class LoginViewModel: ViewModel {
struct Action {
// View에서 바인딩되는 네이버 로그인 버튼 탭 이벤트 시퀀스
let naverLoginButtonTapped = PublishRelay<Void>()
}
struct State {
// 회원가입 화면으로 이동 (네이버 ID가 띠잉 계정과 연동되어 있지 않는 경우)
let shouldSignUp = PublishRelay<SocialUser>()
// 로그인 완료 (네이버 ID로 이미 띠잉 계정과 연동되어 있는 경우)
let successLogin = PublishRelay<OAuthToken>()
// 에러 발생시 View에서 표시해야할 에러
let error = PublishRelay<API.Error>()
}
let state = State()
let action = Action()
let disposeBag = DisposeBag()
init(
socialAuthRepository: SocialAuthRepository = SocialAuthRepositoryImpl(),
authRepository: AuthRepository = AuthRepositoryImpl()
) {
...
}
}
action.naverLoginButtonTapped 이벤트가 발행됐을 때 처리 로직은 다음과 같습니다.
(주석을 통해서 Rx 시퀀스가 어떻게 생성되고 State에 바인딩 되는지 설명 해놓았습니다.)
init(
socialAuthRepository: SocialAuthRepository = SocialAuthRepositoryImpl(),
authRepository: AuthRepository = AuthRepositoryImpl()
) {
// naverLoginButtonTapped 이벤트가 발행됬을때 네이버 로그인 유저 정보를 요청하는 시퀀스를 생성
let requestNaverUser = action.naverLoginButtonTapped
.flatMapLatest {
socialAuthRepository.requestNaverUser()
}
.share()
let naverUser = requestNaverUser
.compactMap { $0.user }
// 네이버 로그인 유저 정보를 바탕으로 AuthRepository를 통해 기존에 등록되어 있는 유저인지 판별하는 시퀀스를 생성
let requestValidSocialAuth = naverUser
.flatMapLatest {
authRepository.validSocialAuth(socialUser: $0)
}
.share()
// 네이버 로그인 유저 정보가 Thiiing 서비스 계정과 연동되어 있지 않는 경우
requestValidSocialAuth
.filter { $0.isExist == false }
.withLatestFrom(naverUser)
// naverUser 시퀀스의 마지막 값으로 shouldSignUp 상태로 바인딩
.bind(to: state.shouldSignUp)
.disposed(by: disposeBag)
// 네이버 로그인 유저 정보가 Thiiing 서비스 계정과 연동되어 있는 경우
let requestSocialLogin = requestValidSocialAuth
.filter { $0.isExist == true }
.withLatestFrom(naverUser)
// AuthRepository를 통해 해당 네이버 로그인 정보로 로그인 요청 시퀀스 생성
.flatMap {
authRepository.socialLogin(socialUser: $0)
}
// AuthRepository를 통해 로그인 완료 후 OAuth 토큰 요청 시퀀스 생성
.flatMap {
authRepository.requestToken(userName: $0.userName, accessToken: $0.accessToken)
}
// 로그인 완료 상태로 바인딩
.bind(to: state.successLogin)
.disposed(by: disposeBag)
// Error 처리
Observable<API.Error>
.merge(
requestNaverUser.compactMap { $0.error },
requestValidSocialAuth.compactMap { $0.error },
requestSocialLogin.compactMap { $0.error }
)
.bind(to: state.error)
.disposed(by: disposeBag)
}
View는 ViewModel의 State(상태)를 구독하면서 UI에 대한 로직을 처리합니다.
override func viewDidLoad() {
super.viewDidLoad()
viewModel.state.shouldSignUp
.bind(onNext: { socialUser in
let viewModel = SignUpViewModel(user: socialUser)
let viewController = SignUpViewController(viewModel: viewModel)
self?.navigationController?.pushViewController(viewController, animated: true)
})
.disposed(by: disposeBag)
viewModel.state.successLogin
.bind(onNext: { token in
self?.handleLogin(with: token)
})
.disposed(by: disposeBag)
viewModel.state.error
.bind(onNext: { error in
self.showToastMessage(with: error)
})
.disposed(by: disposeBag)
}
다음은 Repository 레이어의 구현부 입니다.
네트워크, DB 등의 외부에서 데이터를 조회, 삭제, 저장, 수정을 구현합니다.
SocialAuthRepositoryImpl 구현부
class SocialAuthRepositoryImpl: SocialAuthRepository {
lazy var naverAuthDelegator = NaverAuthDelegator()
func requestNaverUser() -> Single<Result<SocialUser, SocialAuthError>> {
naverAuthDelegator.requestAuthorization()
.map { result in
result.mapError { naverError in
switch naverError {
case .failed(let error):
return SocialAuthError.failed(reason: error.localizedDescription)
case .default:
return SocialAuthError.default
}
}
}
}
}
AuthRepositoryImpl 구현부
class AuthRepositoryImpl: AuthRepository {
func validSocialAuth(socialUser: SocialUser) -> Single<Result<API.Account.Valid.SocialResponse, API.Error>> {
API.Account.Valid.SocialRequest(socialUser: socialUser).responseToResult()
}
func socialLogin(socialUser: SocialUser) -> Single<API.Response> {
API.Account.Social.LoginRequest(socialUser: socialUser).response()
}
func requestToken(userName: String, accessToken: String) -> Single<API.OAuth.TokenResponse> {
API.OAuth.TokenRequest(userName: userName, accessToken: accessToken).response()
}
}
UI 개발 방법
UI 개발은 인터페이스 빌더를 사용하였으며 XIB 기반으로 개발하였습니다.
Storyboard를 사용하지 않은 이유는 다음과 같았습니다.
- Storyboard를 사용하더라도 Segue(세그웨이) 방식을 사용하지 않음
- XIB 사용 시 init 함수를 사용할 수 있음
- 어차피 Storyboard 파일을 나누어서 사용하기 때문에 크게 이점이 없다고 생각됨
그리고, Thiiing에서는 몇 가지 ViewController Transition을 커스텀 해서 구현한 부분이 있는데요,
UIViewControllerAnimatedTransitioning, UIPercentDrivenInteractiveTransition 클래스를 상속받아 커스텀 트랜지션 애니메이션을 구현하였습니다.
(개인적으로 구현 시 참고가 많이 되었던 도서입니다)
아래 GIF는 커스텀 트랜지션이 구현된 부분입니다.
라이브러리 관리
개발 기간을 단축하기 위해 많은 라이브러리를 사용하게 되는데요,
오히려 유지보수 되지 않는 라이브러리를 사용했다가 추후 걷어내는 작업을 해야 하거나 프로젝트 규모가 너무 커져서 컴파일 시간이 오래 걸리고 앱 사이즈가 커질 수 있는 약점이 있습니다.
따라서 저희는 다음과 같은 규칙에 부합하는 라이브러리를 추려서 추가하였습니다.
- 최근까지 관리가 되고 있는 라이브러리
- 완성도 및 안정성이 보장되어 있는 라이브러리 (Issue 처리, Start 개수 등으로 측정)
- 리포딩된 메이저 버그가 없는 라이브러리 (메모리 릭, 크래시 등등)
그리고, 보통 라이브러리 의존성 관리 도구를 사용해서 라이브러리는 관리하는데요, iOS의 경우 아래 3가지가 가장 많이 사용되고 있습니다.
각 도구들은 나름의 장단점을 가지고 있는데요, 저희는 CococaPods와 Carthage를 사용하였습니다.
CocoaPods의 경우 빌드 시 라이브러리도 함께 빌드 되어 컴파일 속도에 영향을 끼치기 때문에 비교적 가벼운 라이브러리 및 내부 코드에서 디버깅이 필요한 라이브러리를 추가하였고,
Carthage의 경우 내부 코드 디버깅이 필요하지 않고 빌드 시간을 줄일 라이브러리를 추가하였습니다.
사용한 라이브러리 몇 가지를 소개 드립니다.
- Firebase: 푸시, 크래시리포트, 이벤트 로깅, 다이나믹 링크
- Appboy-iOS-SDK: 마케팅 푸시, 인앱 메세징
- AdBrixRemastered: 마케팅, 디퍼드딥링크
- Alamofire: HTTP 네트워킹
- Kingfisher: 웹 이미지 다운로드 / 캐싱
- RxSwift: 비동기 처리
- mobile-ffmpeg-full: 영상 압축 및 인코딩
- lottie-ios: 커스텀 애니메이션
컨벤션 관리
코딩 스타일에 대한 코드 리뷰 비용을 아끼고자 SwiftLint를 엄격하게 사용하여 코딩 컨벤션을 관리하였습니다.
SwiftLint는 GitHub’s Swift Style Guide를 베이스로 하는 코딩 컨벤션 강제화 툴입니다.
각종 룰은 Document에서 확인할 수 있으며 프로젝트 상황에 맞게 룰을 추가하거나 비활성화 시킬 수 있습니다.
사용법에 대해서 간단히 공유드리겠습니다.
-
SwiftLint 설치 후 Project > Build Phases에 2개의 Run Script를 추가
-
.swiftlint.yml 파일을 프로젝트 디렉토리에 생성 후 사용할 룰 및 설정사항을 추가
# 활성화 시킬 룰 추가
whitelist_rules:
- anyobject_protocol
- array_init
- attributes
...
# 검사할 파일경로
included:
- Thiiing/SourceCode
- PlayerListController/SourceCode
# 제외할 파일경로
excluded:
- Pods
# 룰별 커스터마이징
large_tuple:
warning: 4
force_cast:
severity: error
...
- 빌드 시 정의한 룰에 맞게 AutoCorrect(자동 수정) 되거나 warning 혹은 컴파일 에러 발생
현재 Thiiing 프로젝트에서는 총 190개 룰 중 약 140개 정도를 사용 중이며 신규 룰이 나오게 되면,
주기적으로 팀원들과 상의를 통해 신규 룰을 적용합니다.
(최근 toggle_bool 룰을 적용시킨 예시입니다.)
그외 새롭게 경험해본 것
Color Asset Catalog (iOS 11 이상)
색상을 다루는 것은 앱 개발자라면 너무 흔한 일입니다.
만약 기존에 사용하던 메인 색상이 디자인 개편으로 변경된다면 해당 색상이 어디에서 사용되는지 찾는 것은 매우 수고스러운 일이며 실수로 변경하지 못하는 부분이 생겨 디자인팀의 마음을 아프게 하는 일이 종종 발생합니다.
하지만 Color Asset Catalog 기술을 사용하면 위와 같은 실수를 방지할 수 있습니다.
기존에는 아래와 같이 UIColor의 extension에 상수로 정의하여 사용했었는데요,
extension UIColor {
class var facebookButtonColor: UIColor {
UIColor(red: 53 / 255, green: 120 / 255, blue: 136 / 255, alpha: 1.0)
}
}
위의 경우 코드레벨에서 facebookButtonColor 값을 사용하여 공통된 RGB 값을 사용하는 것이 가능하지만,
Storyboard, XIB에서 facebookButtonColor 값을 참조할 수 없기 때문에 수동으로 색상을 설정하는 일이 발생합니다.
따라서 facebookButtonColor 값이 변경됬을 때 Storyboard, XIB와 다른 RGB값이 되는 문제가 생깁니다.
Color Asset Catalog는 다음과 같은 방식으로 코드레벨과 Storayboard, XIB등 인터페이스 빌더에서 동일한 RGB를 참조할 수 있습니다.
-
.xcassets 파일에 + 버튼을 눌러서 New Color Set 항목을 클릭
-
Color에 고유 이름을 정하고 우측 Inspectors에서 RGB 값으로 색상을 정의
- iOS 11에 새로 추가된 생성자를 사용하여 UIColor 생성 (named 파라미터는 .xcassets 파일에 생성한 Color 이름으로 설정)
extension UIColor { class var facebookButtonColor: UIColor? { UIColor(named: "facebookButtonColor") } }
- 코드 및 Storyboard에서 사용
button.backgroundColor = .facebookButtonColor
배포 (CI/CD)
3개월 만에 빠르게 앱을 만들어야 하기 때문에 배포 자동화는 선택이 아닌 필수였습니다.
빌드 스크립트는 Fastlane를 사용하여 작성하였고,
빌드 자동화를 위해 Jenkins를 사용하였는데요,
Jenkins Master는 편의성과 고가용성을 위해 Kubernetes 상에서 구동하고,
실제 빌드를 수행하는 Agent는 유휴 Mac 장비를 지급받아 사무실에 설치했습니다.
배포는 2가지로 나누어서 진행하였습니다.
- 베타 버전 빌드: Firebase App Distribution
- 프로덕션 버전 빌드: Testflight
배포 구성(개발, 베타, 기타 등등) 별로 Jenkins job 을 각각 작성하면 중복이 발생하고 UI로 관리가 힘들어지기에 Pipeline 을 사용해서 Job을 코드로 관리했습니다.
코드 서명 설정 변경, Fastlane, swiftlint, 빌드 유틸리티 설치 등 빌드 머신의 설정을 변경해야 할 경우 사람이 머신에 접근할 필요가 없도록 Job 코드 내에에서 제어할 수 있도록 처리하였습니다.
또한, 전체 수행 시간을 단축하기 위해 라이브러리 의존성 업그레이드나 빌드 결과물 업로드 단계 등은 병렬로 처리하도록 하였습니다.
그리고 파이프라인이 복잡해짐에 따라 표준 Jenkins 콘솔 UI로는 각 단계를 확인하기가 어려워졌는데요,
Jenkins 플러그인인 Blue Ocean 을 설치하여 빌드 과정을 시각화하였습니다
빌드 실행시 빌드할 Git refs 정보, Carthage build cache 사용 여부, 배포 여부를 설정할 수 있습니다.
빌드시 각 Stage를 시각화하여 볼 수 있고 빌드 실패시 어느 부분에서 실패했는지 명확하게 알 수 있습니다.
연동작업
푸시
-
Firebase Cloud Messaging: 무료로 메시지를 안정적으로 전송할 수 있는 교차 플랫폼 메시징 솔루션이며 서비스 내에서 채팅 및 좋아요 액션 등을 푸시 노티피케이션으로 보여주기 위하여 사용하였습니다.
-
Braze: 마케팅 툴로써 사용하였으며 유저를 타겟팅 하여 푸시를 보낼 때 사용하였습니다.
이벤트로깅
- Firebase Events: 사용자 행동, 시스템 이벤트, 오류 등 앱에서 발생하는 상황을 파악하는 용도로 사용하였습니다.
크래시분석
- Firebase Crashlytics: 앱이 비정상 종료되는 상황을 감지하고 알림을 받기 위해서 사용하였습니다. 특히 Firebase Events와 함께 사용했을 때 유저의 행동을 파악하여 어떤 시점에 비정상 종료가 되는지 쉽게 알 수 있습니다.
딥링킹
- Firebase DynamicLink: 앱 초대 링크를 만들기 위해 사용하였으며, 초대 링크를 딥링크와 연동하여 초대링크를 열었을때 딥링킹할 수 있으며 그뿐만 아니라 앱이 설치되어 있지 않은 경우에도 AppStore로 이동시켜 설치 후 실행 시 동일하게 딥링크에 접근할 수 있는 기능이 제공됩니다.
마치며..
많은 내용을 담으려고 하니 깊이가 부족한 것 같아서 아쉬움이 있습니다 😭
iOS 앱 서비스를 개발하는 방법은 천차만별이고 제가 위에 나열한 방법들이 정답은 아닙니다.
각 서비스의 상황(리소스, 지식, 일정)에 따라서 가장 효율적인 방법으로 개발하는 것이 중요하다고 생각합니다.
다만, 신규 서비스를 시작할 때 조금이나마 도움이 되고 싶은 마음으로 블로그를 작성하게 되었습니다.
그리고, 큰 변화를 앞두고 있는 띠잉 서비스를 앞으로도 많이 지켜봐 주세요 🙇♂️
감사합니다.