새싹 커뮤니티 앱 소개
주의 ❗️ 클론코딩이 아닌 실제 디자인과 API 시트를 가지고 공부 목적으로 구현한 앱입니다. 개발기간은 4일입니다
대충 이런 CRUD 기반 커뮤니티 앱이예요! Snapkit으로 스토리보드 없이 코드로 오토레이아웃을 잡았습니다
API 규모
총 11개의 API로 구성되어 있습니다. 댓글 CRUD, 포스팅 글 CRUD, 회원가입로직 3개 API로 총 11개가 되겠네요! 4일만에 구현하기는 많은 양이였던것 같습니다. 왜냐면 이번 개발에서 새로운 기술을 마구 적용시켰기 때문이예요 !
새싹 커뮤니티 아키텍처 모습
새싹 커뮤니티 앱의 구현은 Presentation, Data, Domain 크게 세가지 영역으로 로직을 나누었습니다. 왜냐고요? 클린 아키텍쳐를 사용해서 테스트 코드를 짜려고 했죠! 4일만에 구현해야하는데 어마무시한 생각을 하였네요 ㅎ...ㅎ 사실상 도메인쪽으로 의존성이 주입되는 것을 목적으로 아키텍쳐를 구성하려 했는데, 이번 개발에서는 Domain에 필요한 핵심 ⭐️ UseCase는 구현하지 못하였습니다 또륵... 그래서 아키텍쳐를 수정하게 되었고. 기존에 많이 사용하던 로직 방향 그대로 구현을 하게 되었습니다.
Presentation영역에서 Input , Output을 이용한 Rxswift + MVVM 은 처음 적용시켜보게 되었어요. Rxswift + MVVM 패턴을 구현하는 방법은 관련 자료를 찾을 때마다 조금씩 차이가 있었지만, 보편적으로 Input, Output을 가장 많이 쓰는것 같았습니다.
import UIKit
import RxCocoa
import RxSwift
final class EditCommentViewController: UIViewController {
private let contentTextView = ContentTextView()
private lazy var completedBarButton = UIBarButtonItem(barButtonSystemItem: .done,
target: self,
action: #selector(completedBarButtonTap))
private lazy var input = EditCommentViewModel.Input(
requestEditCommentEvent: requestEditCommentEvent.asSignal()
)
private lazy var output = viewModel.transform(input: input)
private var viewModel: EditCommentViewModel
private let disposeBag = DisposeBag()
private let requestEditCommentEvent = PublishRelay<String>()
private let comment: CommentResponse
var closure: ((_ comment: String) -> ())?
init(comment: CommentResponse) {
self.comment = comment
self.viewModel = EditCommentViewModel(comment: comment)
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError()
}
override func viewDidLoad() {
super.viewDidLoad()
setView()
setConstraints()
setConfiguration()
bind()
}
private func bind() {
output.successAlertAction
.emit(onNext: { [weak self] title in
guard let self = self else { return }
let alert = self.confirmAlert(title: title, okHandler: { _ in
self.closure?(self.contentTextView.text!)
self.navigationController?.popViewController(animated: true)
})
self.present(alert, animated: true)
})
.disposed(by: disposeBag)
output.failAlertAction
.emit(onNext: { [unowned self] title in
let alert = self.confirmAlert(title: title) { [weak self] _ in
if title == "로그아웃 합니다." {
self?.changeIntroViewController()
}
}
self.present(alert, animated: true)
})
.disposed(by: disposeBag)
output.toastMessageAction
.emit(onNext: { [unowned self] message in
self.makeToastStyle()
self.view.makeToast(message, position: .top)
})
.disposed(by: disposeBag)
}
ViewController
import Foundation
import Moya
import RxCocoa
import RxMoya
import RxSwift
final class EditCommentViewModel: CommonViewModel, ViewModelType {
struct Input {
let requestEditCommentEvent: Signal<String>
}
struct Output {
let isLoading: Driver<Bool>
let toastMessageAction: Signal<String>
let successAlertAction: Signal<String>
let failAlertAction: Signal<String>
}
var disposeBag = DisposeBag()
private let isLoading = BehaviorRelay<Bool>(value: true)
private let toastMessageAction = PublishRelay<String>()
private let successAlertAction = PublishRelay<String>()
private let failAlertAction = PublishRelay<String>()
private let commentInfo: CommentResponse
init(comment: CommentResponse) {
self.commentInfo = comment
}
func transform(input: Input) -> Output {
input.requestEditCommentEvent
.emit { [unowned self] comment in
self.requestEditComment(comment: comment) { [weak self] response in
guard let self = self else { return }
switch response {
case .success( _):
self.isLoading.accept(true)
self.successAlertAction.accept("댓글이 수정되었습니다.")
case .failure(let error):
self.isLoading.accept(true)
let error = error as! SessacErrorCase
self.failAlertAction.accept(error.errorDescription)
}
}
}
.disposed(by: disposeBag)
return Output(
isLoading: isLoading.asDriver(),
toastMessageAction: toastMessageAction.asSignal(),
successAlertAction: successAlertAction.asSignal(),
failAlertAction: failAlertAction.asSignal()
)
}
}
extension EditCommentViewModel {
func requestEditComment(comment: String, completion: @escaping (Result<CommentResponse, Error>) -> Void ) {
let parameters = ["comment": comment, "post": "\(commentInfo.post.id)"]
provider.request(.updateComment(index: commentInfo.id, parameters: parameters)) { result in
self.process(type: CommentResponse.self, result: result, completion: completion)
}
}
}
ViewModel
Input, Output 구조로 나누니 비지니스 로직을 명확하게 구분할 수 있었어요. Input, Output구조로 나누면 딱 봤을때 '이건 뭐하는 기능이다' 라는 것 확실히 빠르게 캐치 할 수 있는것 같습니다. 조금 더 와닿는 얘기를 하자면, Output을 ViewController에서 먼저 구현해도 외관상 오류는 없어서 명확하게 구분되었구나을 느꼈어요 약간 함수짤 때 인자만 있으면 먼저 함수에 안에 기능부터 짜도 되잖아요? 그런 느낌인거죠 이러한 로직에 대한 명확한 구분이 테스트 코드를 짜는데 좋은 패턴이라는 걸 실제로 느낄 수 있었습니다 !
Moya
Moya 라이브러리도 이번에 처음 적용해보게 되었습니다. Moya가 대체 모야?.... 왜 그렇게 인기가 있는 건데 ❗️ 했는데, 한마디로 네트워크에 필요한 요소들을 템플릿 처럼 만들어놓고 Response랑 Request만을 처리할 수 있도록 한 라이브러리입니다. 모듈화의 힘은 정말 강력하더라구여
private func requestDeletePost(completion: @escaping (Result<PostResponse, Error>) -> Void ) {
provider.request(.deletePost(index: postID)) { result in
self.process(type: PostResponse.self, result: result, completion: completion)
}
}
새싹 커뮤니티에 네트워크처리 함수 인데요 정말 간단하죠! 따로 네트워크 처리하는 Manager 없습니다 4줄이라니… 🤣🤣물론 Request에 관한 공통 응답 처리를 상위클래스에 따로 빼두었지만, 그것말고도 눈에 띄는게 Get, Post, Header 등등 Network에 필요한 여러가지 요소들이 보이지 않네요 ❗️❗️
CustomView만드는 것을 귀찮아 하지말자 !
공통으로 사용될 뷰들은 커스텀으로 따로 구현을 하여 재사용에 용이하도록 구현하였습니다 그리고 이번 앱에서는 UITextView가 많이 등장하는 구조라서 TextView는 상속을 적극 활용하여 코드의 중복을 방지 하였습니다.
CommonViewModel과 ViewModelType 프로토콜 활용
ViewModel를 사용하다 보면 특히 Input, Output구조로 사용하다보면 정형화된 구조나 코드의 중복이 발생합니다 !
정형화된 모습은 Protocol로 정의해주었고, 공통 구조는 CommonViewModel로 상위 클래스를 두어 상속받도록 하였습니다.
protocol ViewModelType {
associatedtype Input
associatedtype Output
var disposeBag: DisposeBag { get set }
func transform(input: Input) -> Output
}
이번 개발에서 구현한 Input, Output의 정형화된 구조입니다. 이렇게 사진처럼 프로토콜로 찍어 놓으니 ViewModel이 어떤식으로 구현되어 있을지 한눈에 예측하기가 쉽네요!
class CommonViewModel {
let provider: MoyaProvider<SesacTarget>
init() {
provider = MoyaProvider<SesacTarget>()
}
}
extension CommonViewModel {
func process<T: Codable, E>(
type: T.Type,
result: Result<Response, MoyaError>,
completion: @escaping (Result<E, Error>) -> Void
) {
switch result {
case .success(let response):
if response.statusCode == 400 {
let errorResponse = try! response.map(InputErrorResponse.self)
completion (.failure(SessacErrorCase(messageId: errorResponse.message[0].messages[0].id)))
} else if response.statusCode >= 401 {
TokenUtils.delete(AppConfiguration.service, account: "accessToken")
let errorResponse = try! response.map(AccessErrorResponse.self)
completion (.failure(SessacErrorCase(messageId: errorResponse.error)))
} else {
let data = try! JSONDecoder().decode(type, from: response.data)
completion(.success(data as! E))
}
case .failure(let error):
completion(.failure(error))
}
}
}
그리고 ViewModel의 최상위 클래스입니다. 계속 사용하게 될 Provider를 최상위 클래스에서 생성하게 두었고, 네트워크 통신에서 응답처리에 필요한 공통 코드부분은 제네릭과 클로저를 사용하여 따로 빼두었습니다. 이렇게 해서 아까 위에서 보았던 4줄짜리 네트워크 요청 함수가 완성됩니다 !
자동 로그인시 필요한 토큰 저장은 UserDefaults가 아닌 KeyChain으로 ..!
이전에는 토큰을 항상 UserDefaults로 저장해두고 자동로그인을 구현하였는데 이번 개발에서는 키체인을 이용하였어요
키체인을 이용한 이유는 앱이 삭제되었다 다시깔아도 정보가 사라지지않으며 , 특히 가장 큰 이유는 보안에 강합니다
UserDefaults로 토큰을 저장하면 탈옥을 하면 그대로 노출이 되고, 유출될 위험이 있어요 ! 그래서 민감한 정보는 키체인으로 저장한다고 합니다.
// MARK: - KeyChain으로 토큰 저장
class TokenUtils {
// service 파라미터는 url주소를 의미
static func create(_ service: String, account: String, value: String) {
// 1. query작성
let keyChainQuery: NSDictionary = [
kSecClass : kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecValueData: value.data(using: .utf8, allowLossyConversion: false)!
]
// allowLossyConversion은 인코딩 과정에서 손실이 되는 것을 허용할 것인지 설정
// 2. Delete
// Key Chain은 Key값에 중복이 생기면 저장할 수 없기때문에 먼저 Delete
SecItemDelete(keyChainQuery)
// 3. Create
let status: OSStatus = SecItemAdd(keyChainQuery, nil)
assert(status == noErr, "failed to saving Token")
}
static func read(_ service: String, account: String) -> String? {
let KeyChainQuery: NSDictionary = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: kCFBooleanTrue, // CFData타입으로 불러오라는 의미
kSecMatchLimit: kSecMatchLimitOne // 중복되는 경우 하나의 값만 가져오라는 의미
]
// CFData 타입 -> AnyObject로 받고, Data로 타입변환해서 사용하면 됨
// Read
var dataTypeRef: AnyObject?
let status = SecItemCopyMatching(KeyChainQuery, &dataTypeRef)
if(status == errSecSuccess) { // Read 성공 및 실패한 경우
let retrievedData = dataTypeRef as! Data
let value = String(data: retrievedData, encoding: String.Encoding.utf8)
return value
} else {
print("failed to loading, status code = \(status)")
return nil
}
}
static func delete(_ service: String, account: String) {
let keyChainQuery: NSDictionary = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
let status = SecItemDelete(keyChainQuery)
assert(status == noErr, "failed to delete the value, status code = \(status)")
}
}
https://ios-development.tistory.com/374
위의 블로그에서 참고하여 활용하였습니다. 이분 블로그는 왠지모르게 잘읽힌단 말이져
회고
이번 싹에서 진행한 개발은 새로운 기술들을 습득하면서 경험한 새로운 이슈들 때문에 고통스러웠지만(기간 압박 때문에.. ), 한층 더 성장할 수 있었던 개발이였습니다. 한층이 아니라 코드적으로 많이 성장한 것 같아요 ..ㅎ
그리고 개발 초반에는 UI/UX을 어떻게 완벽하게 할까를 고민했다면 이제는 '어떻게 하면 좀 더 나중에 힘들지 않게 편하고 깔끔하게 짤 수 있을까' 라는 생각으로 기계적인 코드에서 탈피하고 있는 나으ㅣ 모습과 , 점점 글로된 레퍼런스 자료들이 쉽게 읽히는 걸 느끼며 뿌듯해하고 있습니다 ㅎ
조만간 이번 개발보다 큰 개발을 하게되는데 그 때도 여러가지 기술적 성장을 목표로 클린한 아키텍쳐를 구성하여 포스팅 하도록 하겠습니다
'🍒 iOS 개발 > 기술 포스팅' 카테고리의 다른 글
[SLP] 코디네이터패턴 적용기 MVVM-C 포스팅 (0) | 2022.02.27 |
---|---|
[SLP] iOS Clean Architecture + MVVM-C with Rxswift 포스팅 (4) | 2022.02.24 |