Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

프로젝트 매니저 [STEP 2-2] Ari #104

Merged
merged 42 commits into from
Mar 22, 2022

Conversation

leeari95
Copy link
Member

안녕하세요. 라이언(@ryan-son) 🦁 아리(@leeari95)입니다~
이번 PR은 ViewModel을 개선하고, View 구성 후 바인딩까지 모두 마쳤습니다.
Rx를 익히는데 어려움이 있어서 늦어졌는데, 최대한 할 수 있는 부분 모두 Rx로 구현마치고 PR 보내드리게 되었습니다.
아직도 클린 아키텍처 부분은 능숙치가 않아서.. 부족한 부분이 있을 것 같은데, 이부분 체크해주시면 감사하겠습니다!

고민했던 점

1. ViewModel을 Input과 Output으로 구분지어 설계

  • 가독성을 위해 ViewModel의 Input과 Output을 Nested Type으로 구현하였습니다.
  • Input과 Output을 통해 뷰와 뷰모델 간의 바인딩이 매우 간결해졌습니다.

2. Coordinator 패턴 적용

  • 화면 전환, 화면 제어를 담당하는 타입을 따로 설계하여 화면 전환 시 ViewController에서 사용할 ViewModel을 함께 주입해주는 역할도 동시에 합니다.
  • ViewController가 담당하던 일들을 Coordinator 패턴을 통해 분리가 가능해졌습니다.

3. Memory leak을 방지

  • Rx의 경우 클로저를 활용하여 구성하기 때문에 self 사용에 의한 강한 참조 사이클 발생을 방지하기 위해서 withUnretained() operator를 활용했습니다.
  • Modal의 경우 버튼이 아니라 modal창 외부를 터치해서 창을 닫았을 때 계속 메모리에서 사라지지않고 남아있는 부분을 확인했으며, View Life Cycle을 통해 뷰가 사라질 때 ViewModel, Coordinator, Controller 모두 메모리에서 사라질 수 있도록 해주었습니다.

개선하기 전

개선하고난 후

궁금했던 것 / 조언을 얻고 싶은 점

1. Coordinator를 적절히 사용했는지??

  • Coordinator라는 패턴을 처음 접해서 사용해보았는데, ViewModel 내부에 위치해있습니다. 근데 지금 형태가 괜찮은지...? 이상한 점이 있다면 조언 부탁드리겠습니다.

2. ViewModel에 UIKit이 import 되어있는데 괜찮을까요?

  • 현재 MainViewController에서 longPressGesture 이벤트를 받았을 때 UITableViewcell을 ViewModel에게 넘겨주고 있습니다.
    • 넘겨주는 이유는 Cell을 sourceView 삼아서 ActionSheet를 띄우기 위함인데요.
    • ViewModel에 UIKit이 import 되어있는 부분이 몹시 불편합니다... 보통은 UIKit을 import하지 않는다고 들었는데, 이 부분 아무리 생각해도 어떻게 개선해야할지 잘 떠오르지가 않습니다. 이대로 냅둬도 괜찮을까요?

3. Memory leak 방지를 위한 동작이 올바른지?

  • DetailViewController 내부 viewDidDisappear 시점에 viewModel과 Coordinator를 nil처리 해주고있는데... 괜찮은 방법인지 여쭤보고 싶습니다.

4. ViewModel의 transform 메소드 길이

  • Input에 대한 부분을 subscribe 해주기 위해서 따로 나누지 않고 쭉 이어서 코드를 작성했는데, 분리하는게 좋을까요?
    • 메소드 길이가 길지만.. 분리하지 않은 이유가 이대로 이어서 쭉 읽는게 더 가독성이 좋다고 느껴서인데... 혹시 더 괜찮은 방법이 있는지? 여쭤보고싶습니다.

5. ViewModel에 비즈니스 로직을 넣지 않으려고 했는데요...

  • 비즈니스 로직은 UseCase에게 할당해주려고 노력했는데... 어딘가 어색한 부분이 있는지 봐주셨으면 합니다!

6. UITableViewDelegate 부분 HeaderFooterView 설정

  • 이 부분은 Rx로 할 수 있는 방법이 없는 것 같아서 delegate 채택 후 설정해주었는데, 혹시 Rx로 리팩토링할 수 있는지 여쭤보고 싶습니다.
    • setUpTableView 내부에서 직접 headerView를 할당해주려고 했으나, Storyboard로 만들어둔 객체라 IBOutlet 할당 시점(nil상태)과 엇갈려서... headerView의 configure 메소드를 호출할 수가 없었습니다.

- Storage의 delete 메소드의 파라미터 타입을 UUID에서 Project로 재수정
- Storage 수정에 따른 Repository, UseCase 전반적인 수정
- ProjectListViewModel 수정에 따른 이전 테스트코드 제거
- ProjectListViewModel를 Input, Output, transform으로 나누어 재설계
- HeaderView에 초기 설정하는 configure 메소드 추가
- Cell에 초기설정하는 configure 메소드 추가
- UITableViewDelegate에 viewForHeaderInSection 메소드 구현
- formattedString 프로퍼티를 static 메소드로 수정
- ProjectListUserCase에 구현하지 않은 정의부분 제거
- ProjectState에 rawValue를 추가
- ProjectListViewModel 내부 Input 타입을 RxCocoa에 맞게 리팩토링
- Output 타입을 외부에서 값을 주입하지 못하도록 Observable로 수정
- Input과 Output 수정에 따른 transform 메소드 내부를 대폭 수정
- MainViewController에 UILongPressGestureRecognizer를 추가
- ViewModel과 View를 바인딩 처리하는 setUpBindings 메소드 추가
- tableView가 deselect 될 수 있도록 delegate 메소드 구현
- MainCoordinator 타입 생성
- info.plist에서 Main Storyboard를 제거
- SceneDelegate에서 Coordinator를 활용하여 초기설정을 하도록 구현
- Cell을 터치했을 때 read 모드의 DetailViewController를 present하도록 구현
- DetailCoordinator 타입 생성
- MainCoordinator에서 DetailCoordinator를 생성하여 화면전환을 구성하도록 리팩토링
- DetailViewModel에 coordinator 프로퍼티를 추가하고 init을 수정
- Detail 스토리보드 내부 TextView의 스크롤을 비활성화 시키고, 오토레이아웃을 조정하여 스크롤 되도록 수정
- Storage에 1개의 요소만 가져올 수 있는 fetch 메소드 오버로드
- Input에 타입을 RxCocoa에 맞춰 옵셔널로 리팩토링
- DetailViewController의 기존 configure 메소드를 제거하고 binding 형태로 대폭 수정
- ProjectCell의 ChangedDateColor 메소드 오타를 수정
- input 등록 시 변경되었을 때의 값만 구독하도록 changed로 수정
- transform 내부에 current 변수들을 옵셔널하게 수정
- 길게 터치시 뷰모델에게 터치한 Cell과 Project를 전달하도록 구성
- 전달받은 Cell과 Project를 활용하여 Coordinator에게 액션시트를 띄워달라고 요청
- 액션시트에서 버튼을 터치했을 때 useCase에게 상태를 업데이트해달라고 요청
- ProjectListUseCase에 changedState 메소드를 추가
- ProjectState 타입에 excluded 프로퍼티를 추가
…록 리팩토링 #3

- DetailViewModel 내부 mode에 따라 update, create를 하는 부분 개선
- excluded를 튜플에서 배열로 수정
- MainCoordinator의 showActionSheet 메소드를 Observable로 리팩토링
- 글자수가 일정숫자 넘어가게 되면 Done 버튼을 비활성화
- DetailViewModel 내부 Output에 isDescriptionTextValid 프로퍼티 추가
- 뷰모델 내부 축약가능한 부분 리팩토링
- forEach를 for-in문으로 수정
- if로 옵셔널 바인딩 처리하는 부분 flatMap으로 대체
- 접근제한
- 매직넘버를 .zero로 수정
- self를 활용하는 곳에 withUnretained 메소드를 활용하여 리팩토링
- DetailViewController가 닫힐 때마다 ViewModel, Coordinator, Controller 모두 메모리에서 없어지도록 개선
- Coordinator 프로토콜 구현
- 줄바꿈 컨벤션 수정
- MainCoordinator에 테스트용으로 적었던 didSet을 제거
- 테스트 타겟에 RxBlocking, RxTest 라이브러리 추가
Copy link

@ryan-son ryan-son left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아리, 이번에도 고생 많으셨습니다! 갈수록 반응형 프로그래밍 프레임워크를 다루는 감각이 늘어가고 있다는게 느껴지네요!

앱 디자인이 굉장히 깔끔했고, withUnretained(_:)와 같이 새로 도입된 연산자를 사용해보시며 메모리 관리를 하셨다는 부분이 굉장히 인상적이었습니다. 전제적으로 오탈자가 다소 발생하고 있는데, 그때그때 체크하기 어려우시다면 아래 그림과 같은 기능을 이용해서 살펴볼 수도 있습니다.
image

질의응답

  1. Coordinator를 적절히 사용했는지??

네, 코디네이터 패턴이 잘 적용되어 있네요. 저도 어떤 로직에 따라 화면 전환 이벤트가 발생할테니 View 보다는 ViewModel에 위치하는게 적절하다고 생각해요. CoordinatorType은 지금은 필요한지 잘 모르겠네요. 화면 전환 이벤트를 열거형의 케이스로 관리하고 싶으신걸까요?

코디네이터 패턴은 구현해보셨듯이 매 프로젝트마다 보일러플레이트가 다소 발생하는데, 이 때문에 라이브러리가 꽤 있어요. 패턴에 익숙해지시면 이 라이브러리도 참고해보세요. RxFlow

  1. ViewModel에 UIKit이 import 되어있는데 괜찮을까요?

ViewModel의 태생적 목적상 View와 무관한 상태로 유지하는 것이 가장 이상적이긴 해요. 극단적으로 View의 어떤 요소에도 의존하고 있지 않다면, UIKit을 사용하는 모바일이든, AppKit을 사용하는 데스크탑/랩탑이든 플랫폼에 관계없이 이 로직을 재사용할 수 있으니까요. 하지만 ViewModelView를 모름으로써 생기는 장점 (1:N 관계 형성 가능 등)을 다소 포기하더라도 필요할 때는 아리와 같은 결정을 할 수 있어야 한다고 생각합니다. 절충은 가끔 필요하죠.

  1. Memory leak 방지를 위한 동작이 올바른지?

누가 memory leak을 일으키고 있나요? 그것이 명확하면 적용하신 방법을 사용하지 않아도 아리가 알고 있는 방법으로 해결할 수 있습니다! 잘 모르겠다면 회의실에서 보시죠 😊

  1. ViewModel의 transform 메소드 길이

바인딩을 등록하는 메서드의 경우 저는 한 메서드가 여러가지 일을 한다라고 생각하지 않습니다. 해당 메서드는 여러가지 일을 하는 것이 아니라 단지 어떤 일이 일어나면 무엇을 할 것인지라는 바인딩에 충실한 것이니까요. 그래서 메서드 길이가 문제가 되지는 않는다고 생각합니다. 하지만 하나하나의 바인딩 로직이 같은 동작을 해도 어렵고 길게 만들 수도 있고, 반대의 경우도 가능하기 때문에 최대한 간결해야한다고 생각합니다 (특정 이벤트가 발생했을 때 실행되는 로직이므로 오히려 이것이 하나의 메서드처럼 관리되어야 한다고 생각).

  1. ViewModel에 비즈니스 로직을 넣지 않으려고 했는데요...

현재는 두 타입 모두 테스트 가능하니 큰 문제 없어보이네요. ViewModel은 단지 View에 전달할 내용으로 가공하는 타입이다라고 생각하신다면 UseCase에 일부 옮겨보는 것도 고려해볼 수 있을 것 같습니다.

  1. UITableViewDelegate 부분 HeaderFooterView 설정

tableView.rx.tableHeaderViewnext 이벤트를 전달해서 뷰를 전달해줄 수는 있겠지만 현재 delegate 메서드처럼 변화가 있을 때마다 호출되어 헤더를 갱신시켜주지는 않을거에요. 이 방식을 채택하려면 추가적인 바인딩이 필요할겁니다. ((todo, doing, done) 갯수 변동 이벤트 발행 -> 필요한 뷰로 매핑하여 tableView.rx.tableHeaderView에 바인딩)

추가

상태값을 할당한 후에 반복적으로 onNext(_:)accept(_:)를 호출하고 있다면, 프로퍼티 옵저버를 활용해본 것도 좋을 것 같네요.

계속해서 화이팅입니다!! 💪 궁금한점, 필요한 것 있으시면 언제든지 말씀해주세요!

<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="qCv-vy-mOU">

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번 PR에서는 그런 사례가 발생하지 않았지만, 스토리보드는 만지기만 해도 변경사항이 생길 때가 있죠. 이런 점 때문에 협업할 때 사용하기 번거롭다는 의견도 있는데, 왜 그런 현상이 일어나는지, 대략 어떤 내용이 바뀌는지 관심 가져보시는 것도 좋을 것 같네요. 추가로 Interface builder에서 보는 내용이 XML 형식으로는 어떻게 작성되어 있는지도 잡지식 정도로 알아두는건 어떨까요~?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오.. 조언 감사합니다.
이번 프로젝트의 경우 혼자서 하는 개인프로젝트이기도 해서 과감하게 스토리보드를 선택했었는데요.
협업할 때에는 스토리보드 사용을 좀 더 고려해봐야겠네요.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스토리보드는 어떤 iOS 개발자가 합류해도 일정한 생산성을 보장할 수 있다는 측면에서 매력적이에요. 스토리보드도 훌륭한 도구입니다!

@@ -54,6 +55,19 @@ extension DefaultProjectStorage: ProjectStorage {
func fetch() -> BehaviorSubject<[Project]> {
return projectStore
}

func fetch(id: UUID) -> Single<Project> {
guard let project = projects.filter({ $0.id == id }).first else {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 59 to 70
func fetch(id: UUID) -> Single<Project> {
guard let project = projects.filter({ $0.id == id }).first else {
return Single.create { observer in
observer(.failure(StorageError.notFound))
return Disposables.create()
}
}
return Single.create { observer in
observer(.success(project))
return Disposables.create()
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런건 어떠신가요~?

func fetch(id: UUID) -> Single<Project> {
    return Single.create { observer in
        guard let project = projects.first(where: { $0.id == id }) else {
            observer(.failure(StorageError.notFound))
            return Disposables.create()
        }
        observer(.success(project))
        return Disposables.create()
    }
}

프로젝트 수가 많아진다면 데이터 수에 관계 없이 키 값을 통해 O(1)로 접근 가능한 해시테이블(딕셔너리)로 변경해보는 것도 재미있겠네요. 데이터 수가 많지 않을 것이라고 예상되는 지금 프로젝트에서는 큰 의미는 없을 것 같기는 합니다.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오...? 이렇게도 줄일 수 있군요. 감사합니다!!
딕셔너리는 고려해봤었는데, 어짜피 로컬을 구현하게 된다면
이 구현부는 없애버릴 생각으로 간단하게 배열로 구현해보았습니다 ㅎㅎ

Comment on lines 23 to 37
enum ProjectState: String, CaseIterable {
case todo = "TODO"
case doing = "DOING"
case done = "DONE"

var index: Int {
switch self {
case .todo:
return 0
case .doing:
return 1
case .done:
return 2
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재는 ProjectState 인스턴스가 있으면 index로 매핑은 되지만 반대 상황에서 매핑을 지원하지는 않네요. 향후에 그런 소요가 발생한다면 rawValueInt로, 현재 사용하는 rawValueuppercased()와 같은 메서드를 지원해봐도 좋을 것 같아요. 테이블뷰를 세팅하는 메서드에서 테이블뷰의 index를 알고 있는 상태에서 어떤 ProjectState와 매핑되는지 알아내고 싶을 때 사용할 수 있을 것 같아요.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇.. 그렇네요. 생각치 못한 부분인데, 의견 감사합니다.
적극 반영해보겠습니다!!! 😤

Comment on lines 39 to 48
var excluded: [String] {
switch self {
case .todo:
return [ProjectState.doing.rawValue, ProjectState.done.rawValue]
case .doing:
return [ProjectState.todo.rawValue, ProjectState.done.rawValue]
case .done:
return [ProjectState.todo.rawValue, ProjectState.doing.rawValue]
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프로퍼티 이름을 통해 자신이 제외되어 있다는 것은 파악이 가능한데, 반환값에 대한 예상이 쉽지 않네요. 프로퍼티 이름에 모든 것을 넣기 어렵다면 문서화 주석을 이용해봐도 좋겠어요.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오.. 문서화 주석... 감사합니다!
안그래도 프로퍼티 이름에 모든걸 넣기가 애매해서... 저렇게 네이밍 처리를 했었는데... 좋은 방법이 있었네요 🥺

Comment on lines 19 to 23
if date.isPastDeadline {
dateLabel.textColor = .systemRed
} else {
dateLabel.textColor = .black
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

삼항연산자를 사용할 때와 그렇지 않을 때의 기준이 있나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오잉...? 여긴 왜안썼져....
간결하게 표현될 수 있는 조건문일 경우에는 활용해주는 편입니다!
이부분은 리팩토링 해보도록 할게요~!

@@ -76,7 +76,7 @@ class MemoryUseCaseTests: XCTestCase {
sut?.create(project)

// when
let result = try? sut?.delete(project2.id).toBlocking().first()
let result = try? sut?.delete(project2).toBlocking().first()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

유닛 테스트에서는 테스트 함수를 throwing function으로 정의하면 (func someFunction() throws) 에러 처리를 하지 않고도 try 키워드를 사용할 수 있어요. 그렇다면 만약 이렇게 해두고 에러가 발생한다면 어떻게 될까요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오... throwing function을 활용해볼 수도 있었군요. 몰랐네요.
찾아보니 실패할경우 테스트가 실패했다고 표시된다고 하네요! 감사합니다 😊

@@ -93,10 +93,85 @@ class MemoryUseCaseTests: XCTestCase {
let project3 = Project(title: "실패", description: "실패 내용", date: Date())

// when
let result = try? sut?.delete(project3.id).toBlocking().first()
let result = try? sut?.delete(project3).toBlocking().first()

// then
XCTAssertNil(result)
XCTAssertTrue(try! sut?.fetch().toBlocking(timeout: 1).first()?.count == 2)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 구문은 읽기가 쉽지는 않네요.. 😇

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇.. 그렇네요. 리팩토링 진행해봐야겠어요. 감사합니다 😁

let text = "1234567889"

// when
let result = sut!.isNotValidate(text)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 사용할 계획이라면 sut의 타입을 암시적 옵셔널 해제 타입으로 설정해두는 것도 고려해볼 수 있겠네요.

Comment on lines 10 to 13
enum CoordinatorType {
case main
case datil
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각 코디네이터가 이 타입의 인스턴스를 가지고 있을뿐 이것으로 하는 행동은 없어보이는데, 향후에 어떤 용도로 사용할 계획이신가요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 타입으로 MainCoordinator 내부에서 childCoordinator를 제거해줄 때 필터링 용도로 활용하고 있습니다!

- 가독성을 위해 보기 어려운 부분을 변수로 따로 할당해주어 리팩토링
- sut을 암시적 옵셔널 타입으로 수정
- fetch(id:) 메소드 내부 간결하게 수정
- 줄바꿈 컨벤션 수정
- 인덱스로 케이스에 접근할 수 있도록 rawValue를 문자열이 아닌 정수타입으로 수정
- 변수, 메소드에 문서화 주석을 활용하여 퀵헬프를 추가
- enum 타입의 Storyboard를 구현
- StoryboardCreatable 프로토콜 생성 및 UIStoryboard extension 추가
- 각 ViewController에 StoryboardCreatable 프로토콜 준수 및 storyboard 프로퍼티 구현
- SceneDelegate에서 생성후 주입시켜주기 때문에 혼동을 줄 수 있어 제거
@leeari95
Copy link
Member Author

leeari95 commented Mar 18, 2022

안녕하세요. 라이언!!
코멘트 주신 부분 모두 확인하였고, 개선할 수 있는 부분들을 아래와 같이 리팩토링 해보았습니다~

수정사항

  • 오탈자 수정
  • 좀 더 간결하게 혹은 Rx스럽게 바꿀 수 있는 부분들을 리팩토링 진행
  • 테스트 코드 일부 메소드를 throwing function으로 리팩토링
  • 문서화 주석을 활용하여 좀더 부가적인 설명이 필요한 곳에 퀵헬프 추가
  • 스토리보드를 호출하여 인스턴스화 하는 부분을 타입을 별도로 추가하여 리팩토링
  • ProjectState 타입 rawValue를 String > Int로 변경

이외에 몇가지 답변이 잘 이해가 가지 않는 부분들이 있어서 같이 봐주셨으면 합니다!
편하신 시간 때 DM 부탁드리겠습니다! 😊🙏🏻

@leeari95 leeari95 requested a review from ryan-son March 18, 2022 18:16
@ryan-son
Copy link

아리, 이번 스텝도 훌륭하게 해내셨네요! 👏

질문해주신 부분들에 대해서는 회의실에서 이야기도 나누었고, 이번 리뷰의 코멘트로도 달아두었어요. 구조상 크게 달라져야하는 부분은 없으니 이번 스텝은 현 상태에서 머지하도록 하겠습니다.

고생 많으셨고 다음 스텝에서 만나요~🚀 화이팅!💪

@ryan-son ryan-son merged commit c9db68f into yagom-academy:4_leeari95 Mar 22, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants