[TIL] iOS 입문 주차 과제 : 계산기 앱 만들기 day.06

Emily·2024년 11월 22일
2

CalculatorApp

목록 보기
11/11
post-thumbnail

프로젝트 리팩토링

UI를 다 구성하고 buttonTapped 함수를 버튼과 연결하는 거까지 구현한 시점이었던 것 같다. 튜터님과 면담을 하면서 프로젝트 구조에 고민이 있다고 말씀드렸더니 패턴에 대한 조언을 주셨다.(공부 할 거리도..) 그리고, 버튼의 addTarget 호출은 커스텀 버튼 클래스 내부에서 하는 것이 아니라 ViewController에서 해야한다고 하셨다. 스택 뷰와 버튼을 모두 따로 클래스로 정의하고 서브 뷰의 서브 뷰의 서브 뷰 관계로 엮인 상태에서 뷰 컨트롤러에서 어떻게 버튼까지 접근을 하고 탭 동작을 연결해야할지 당장 머리에 그려지지 않아 멍해졌다. 모든 탭 동작을 버튼 클래스 내부에 넣을 수는 없어 ButtonTapService 클래스를 분리하고 버튼 동작의 결과를 inputLabel.text와 연동하는 과정에서 하루종일 코드만 쳐다보고 삽질했던 기억이 생생한데 비슷한 걸(삽질) 또 해야 한다고 생각하니 잠깐 도망치고 싶어졌다(그래서 도망침). 결국 덮어두고 예외처리를 열심히 했다. 근데 이것도 끝나고 나자 도망칠 곳이 없었다. (스크롤 뷰로 잠깐 도망쳤는데 이건 금방 동나버림) 슬슬 삽질하러 가볼까 싶은 찰나에 뷰 안에 뷰 안에 뷰에 접근하는데 성공한 동기의 사례를 보고 자극을 받았다. 그래 나도 해보자.

01) UIButton addTarget 호출 시점

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에 전달했다. 이 때도 사실 ButtonTapServiceButton 클래스가 직접 소통해야 하는 부분이 아쉬웠지만 다른 방안을 떠올리지 못했기 때문에 이게 최선이었다.

내가 이 때 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 패턴 적용에 대한 아이디어가 떠올랐다.

02) 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)
    }
}

탭 동작의 inputoutput의 흐름이 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의 자연스러운 흐름을 갖게 되었다. 뷰 컨트롤러는 뷰 모델로부터 뷰에 영향을 끼치는 데이터 변화 정보만 받는 것이다. 탭 서비스 역시 뷰와의 직접적인 소통을 할 필요 없이 받은 버튼 정보에 따른 탭 동작만 담당하게 되었다.

03) MVVM 패턴 적용2

위에는 탭 동작과 그에 따른 뷰의 변화 흐름만 다뤘는데, 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번에 이미 등장했던 코드다. ButtonInfoButton의 생성과 함께 .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. 숫자 포맷 적용하기 (1,000,000)
  2. 버튼 크기 오토레이아웃 적용하기
  3. +/-, %, . 버튼 추가하여 더 다양한 연산 적용하기 (+ Double 연산)
  4. 최신 iOS처럼 연산 하나 끝나면 위에 연산 내용 표시하는 label 넣기
  5. NSExpression을 사용하지 않고 직접 연산 로직 구현해보기

당장 다음주에 팀 프로젝트가 시작되기 때문에 계산기 신경 쓸 시간이 있을지 모르겠지만, 장기 프로젝트라고 생각하고 짬이 날 때마다 계산기를 개선해나가고 싶다. 힘들고 재밌었던 과제였다.

profile
iOS Junior Developer

4개의 댓글

comment-user-thumbnail
2024년 11월 22일

이렇게 기록 잘하는 사람이 취업하는 거구나

1개의 답글
comment-user-thumbnail
2024년 11월 22일

MVVM까지....? 진자 두둑하네요 오늘 틸 맛잇다

1개의 답글