UI
를 다 구성하고 buttonTapped
함수를 버튼과 연결하는 거까지 구현한 시점이었던 것 같다. 튜터님과 면담을 하면서 프로젝트 구조에 고민이 있다고 말씀드렸더니 패턴에 대한 조언을 주셨다.(공부 할 거리도..) 그리고, 버튼의 addTarget
호출은 커스텀 버튼 클래스 내부에서 하는 것이 아니라 ViewController
에서 해야한다고 하셨다. 스택 뷰와 버튼을 모두 따로 클래스로 정의하고 서브 뷰의 서브 뷰의 서브 뷰 관계로 엮인 상태에서 뷰 컨트롤러에서 어떻게 버튼까지 접근을 하고 탭 동작을 연결해야할지 당장 머리에 그려지지 않아 멍해졌다. 모든 탭 동작을 버튼 클래스 내부에 넣을 수는 없어 ButtonTapService
클래스를 분리하고 버튼 동작의 결과를 inputLabel.text
와 연동하는 과정에서 하루종일 코드만 쳐다보고 삽질했던 기억이 생생한데 비슷한 걸(삽질) 또 해야 한다고 생각하니 잠깐 도망치고 싶어졌다(그래서 도망침). 결국 덮어두고 예외처리를 열심히 했다. 근데 이것도 끝나고 나자 도망칠 곳이 없었다. (스크롤 뷰로 잠깐 도망쳤는데 이건 금방 동나버림) 슬슬 삽질하러 가볼까 싶은 찰나에 뷰 안에 뷰 안에 뷰에 접근하는데 성공한 동기의 사례를 보고 자극을 받았다. 그래 나도 해보자.
before
Button class 내부에서 호출
class Button: UIButton {
private let buttonTapService = ButtonTapService.shared
private let buttonInfo: ButtonInfo
init(buttonInfo: ButtonInfo) {
self.buttonInfo = buttonInfo
addTarget()
}
private func addTarget() {
addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
}
@objc func buttonTapped() {
buttonTapService.buttonTapped(of: buttonInfo)
}
}
Button
클래스가 초기화 시점에 주입 받은 버튼 정보를 갖고 있기 때문에, 그것을 그대로 내부에서 addTarget
호출과 함께 ButtonTapService
에 전달했다. 이 때도 사실 ButtonTapService
와 Button
클래스가 직접 소통해야 하는 부분이 아쉬웠지만 다른 방안을 떠올리지 못했기 때문에 이게 최선이었다.
내가 이 때 MainViewController
에서 Button
에 접근할 수 없다고 느꼈던 이유는 뷰를 생성하는 과정에 있다.
class VerticalStackView: UIStackView {
private lazy var firstHStack: HorizontalStackView = {
let stackView = HorizontalStackView()
let sevenButton = Button(buttonInfo: .init(role: .number, name: .seven))
let eightButton = Button(buttonInfo: .init(role: .number, name: .eight))
let nineButton = Button(buttonInfo: .init(role: .number, name: .nine))
let addButton = Button(buttonInfo: .init(role: .operation, name: .add))
stackView.addSubviews([sevenButton, eightButton, nineButton, addButton])
return stackView
}()
}
class MainViewController: UIViewController {
private lazy var scrollView = ScrollView()
private lazy var buttonView = VerticalStackView()
}
Button
의 생성이 VerticalStackView
클래스 내에서 HorizontalStackView
를 생성하는 과정에 있다. VerticalStackView
는 버튼을 이미 보유하고 뷰 레이아웃이 완성된 상태로 MainViewController
에서 buttonView
라는 이름으로 선언되는 것이다. 그래서 어떻게 저 안에 있는 버튼에 접근하지? 라고 생각했던 것이다.
after
MainViewController에서 button과 stackview를 선언하며 호출
class MainViewController: UIViewController {
private lazy var buttonView = VerticalStackView()
func addButtons() {
var hStacks = [HorizontalStackView]()
for row in 0..<4 {
var subviews = [Button]()
for col in 0..<4 {
let index = row * 4 + col
// 버튼 선언 : view model이 보유한 ButtonInfo array 사용
let button = Button(buttonInfo: vm.buttons[index])
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
subviews.append(button)
}
hStacks.append(HorizontalStackView(subviews))
}
buttonView.addSubviews(hStacks)
}
@objc func buttonTapped(_ sender: Button) {
// view model로 sender(Button)가 가진 buttonInfo 전송
vm.send(sender.buttonInfo)
}
}
VerticalStackView
내부에서 뷰를 구성한 채로 선언만 하던 것을 MainViewController
에서 이루어지도록 옮겨서 구현하였다. 이렇게 하자 Button
의 선언 시점에서 addTarget
을 함께 호출할 수 있었다.
이 과정에서 자연스럽게 MVVM
패턴 적용에 대한 아이디어가 떠올랐다.
버튼 탭 동작을 담당하는 서비스 클래스를 분리하는 시점부터, 눌린 버튼에 따라 바뀌는 텍스트를 @Published
변수로 갖는 객체와 inputLabel.text
가 소통하도록 관계 맺기를 하고 싶다고 생각했다. 그 때 ViewModel
을 정의하고 싶었지만, 탭 동작까지 뷰 모델이 담당하게 하고 싶지 않아서 고민이 되었다.
뷰 모델이 서비스 클래스를 일방적으로 의존하는 관계로 구현해야 하는데, 텍스트 변수가 탭 동작 함수의 직접적인 영향을 받기 때문에 뷰 모델의 프로퍼티에 탭 서비스 클래스가 직접 영향을 끼치면 상호 의존이 되기 때문에 부담이 되었다. 공부는 해본 적 없지만 이게 coupling
아닐까? 딱 봐도 바람직해보이지 않았다.
그래서 결국 뷰 모델 없이 ButtonTapService
를 싱글톤으로 정의하여 Button
과도 소통, MainViewController
와도 소통하도록 구현했던 것이다.
하지만 1번 리팩토링을 하는 과정에서 조금씩 생각이 열리고, 클로드의 도움도 받아 어떻게 ViewModel
을 정의할지 아이디어가 생겼다.
before
ButtonTapService가 view와의 소통 및 탭 동작 처리를 모두 담당
class ButtonTapService: ButtonTapServiceType {
static let shared = ButtonTapService()
private init() {}
@Published var textStack: String = "0"
func buttonTapped(of buttonInfo: ButtonInfo) {
// button tap에 따라 textStack에 변화를 줌
}
}
class Button: UIButton {
private let buttonTapService = ButtonTapService.shared
// button의 탭 동작을 전달 받기 위해 직접적인 소통
@objc func buttonTapped() {
buttonTapService.buttonTapped(of buttonInfo: ButtonInfo)
}
}
class MainViewController: UIViewController {
private let buttonTapService = ButtonTapService.shared
// button tap service의 textStack을 구독하여 label text update
private func bind() {
buttonTapService.$textStack
.sink { [weak self] text in
self?.scrollView.inputLabel.text = text
}
.store(in: &cancellables)
}
}
탭 동작의 input
과 output
의 흐름이 Button - ButtonTapService - MainViewController
로 이어진다.
after
ButtonTapService의 Publisher를 ViewModel이 구독하도록 구현
이전에는 방출 - 구독 - 방출 - 구독
이 연속으로 이어지는 게 비효율적인 게 아닐까? 싶어서 ViewModel
분리에 확신이 없었던 점도 있었는데, 객체 간 관계성을 따졌을 때 이렇게 구현하게 되었다.
class ButtonTapService: ButtonTapServiceType {
/* 싱글톤 패턴 삭제 */
// button tap에 따라 변화를 전송 받으면 그대로 view model에게 방출
var textPublisher = CurrentValueSubject<String, Never>("0")
func buttonTapped(of buttonInfo: ButtonInfo) {}
}
class MainViewModel {
// bind 함수를 통해 button tap service의 textPublisher를 구독,
// 그대로 MainViewController에게 방출
@Published var inputLabelText: String = "0"
private let buttonTapService: ButtonTapServiceType
init(buttonTapService: ButtonTapServiceType = ButtonTapService()) {
self.buttonTapService = buttonTapService
bind()
}
// MainViewController에서 호출되어 눌린 button의 정보를 받아 tap service에게 전달한다.
func send(_ buttonInfo: ButtonInfo) {
buttonTapService.buttonTapped(of: buttonInfo)
}
private func bind() {
buttonTapService.textPublisher
.sink { [weak self] text in
self?.inputLabelText = text
}
.store(in: &cancellables)
}
}
class MainViewController: UIViewController {
private let vm = MainViewModel()
// view model에게 눌린 button 정보 전달
@objc func buttonTapped(_ sender: Button) {
vm.send(sender.buttonInfo)
}
// view model의 @Published 변수를 구독하여 inputLabel text update
func bind() {
vm.$inputLabelText
.sink { [weak self] text in
self?.scrollView.inputLabel.text = text
}
.store(in: &cancellables)
}
}
이전과 달리 MainViewController - MainViewModel - ButtonTapService
의 자연스러운 흐름을 갖게 되었다. 뷰 컨트롤러는 뷰 모델로부터 뷰에 영향을 끼치는 데이터 변화 정보만 받는 것이다. 탭 서비스 역시 뷰와의 직접적인 소통을 할 필요 없이 받은 버튼 정보에 따른 탭 동작만 담당하게 되었다.
위에는 탭 동작과 그에 따른 뷰의 변화 흐름만 다뤘는데, ViewModel
을 생성하고 또 달라진 점이 있다.
before
만들어야 하는 ButtonInfo를 Button 생성 시 init으로 주입
class VerticalStackView: UIStackView {
private lazy var firstHStack: HorizontalStackView = {
let stackView = HorizontalStackView()
let sevenButton = Button(buttonInfo: .init(role: .number, name: .seven))
let eightButton = Button(buttonInfo: .init(role: .number, name: .eight))
let nineButton = Button(buttonInfo: .init(role: .number, name: .nine))
let addButton = Button(buttonInfo: .init(role: .operation, name: .add))
stackView.addSubviews([sevenButton, eightButton, nineButton, addButton])
return stackView
}()
}
1번에 이미 등장했던 코드다. ButtonInfo
가 Button
의 생성과 함께 .init
으로 주입되는 것을 볼 수 있다.
after
ButtonInfo data source를 뷰 모델이 담당
class MainViewModel {
// button을 생성할 순서에 맞게 선언
let buttons: [ButtonInfo] = [
.init(role: .number, name: .seven),
.init(role: .number, name: .eight),
.init(role: .number, name: .nine),
.init(role: .operation, name: .add),
.init(role: .number, name: .four),
.init(role: .number, name: .five),
.init(role: .number, name: .six),
.init(role: .operation, name: .subtract),
.init(role: .number, name: .one),
.init(role: .number, name: .two),
.init(role: .number, name: .three),
.init(role: .operation, name: .multiply),
.init(role: .completer, name: .clear),
.init(role: .number, name: .zero),
.init(role: .completer, name: .equal),
.init(role: .operation, name: .divide)
]
}
class MainViewController: UIViewController {
func addButtons() {
// ... //
// view model에서 button data를 받아온다.
let button = Button(buttonInfo: vm.buttons[index])
}
}
뷰 모델이 뷰를 만들고 바꾸는데 필요한 데이터를 갖도록 구현해보았다. MVVM 패턴을 완벽히 이해하고 잘 다룰줄 안다고 말할 수 없지만, 이번 리팩토링을 통해 뷰 모델의 성격이 어떤 건지 조금 실감할 수 있었다.
나는 매번 과제 제출이 끝처럼 느껴지지 않는다. 좀 더 완성도를 높일 수 있지 않았을까 하는 아쉬움이 늘 남고, 더 구현해보고 싶은 기능들도 있다.
- 숫자 포맷 적용하기 (
1,000,000
)- 버튼 크기 오토레이아웃 적용하기
+/-
,%
,.
버튼 추가하여 더 다양한 연산 적용하기 (+Double
연산)- 최신 iOS처럼 연산 하나 끝나면 위에 연산 내용 표시하는
label
넣기NSExpression
을 사용하지 않고 직접 연산 로직 구현해보기
당장 다음주에 팀 프로젝트가 시작되기 때문에 계산기 신경 쓸 시간이 있을지 모르겠지만, 장기 프로젝트라고 생각하고 짬이 날 때마다 계산기를 개선해나가고 싶다. 힘들고 재밌었던 과제였다.
이렇게 기록 잘하는 사람이 취업하는 거구나