5개월간 iOS앱을 리팩터링하며 알게된점(더 나은 MVC로의 여정)(2)

Youth·2023년 12월 21일
0

회고

목록 보기
2/10
  1. 해당 포스팅은 5개월간 iOS앱을 리팩터링하며 알게된점(SOPT회고)(1) 에서 이어지는 글입니다
  2. 해당 포스팅은 5개월간 iOS앱을 리팩터링하며 알게된점(MVVM으로의 여정)(3)으로 이어집니다

5개월간 iOS앱을 리팩터링하며 알게된점(MVVM으로의 여정)(3)은 추후에 링크를 달아놓겠습니다:)


엉망진창 MVC

라이온하트를 리팩터링해야겠다고 결심했을 때 앱의 상태는 정말 단순히 UI만 구현된 앱이었습니다
이유있는 MVC였다고는 하지만 사실상 시간에 쫓겨 로직이 분리도 안되어있고 객체자체를 강하게 바라보고있어 연쇄적으로 문제가 발생할수있는 이리저리 꼬인 스파게티코드였습니다

이런 코드 특: 코드 고치는 순간 바로 동작 안됨ㅋㅋ

문제점 1 : Massive한 ViewController

이때 참 많은 고민을 했던것같습니다 아무리 데이터 바인딩이 없어서 MVC라는 아키텍처를 선택했다고는 하나 ViewController에 평균적으로 5~600줄의 코드가 있었고 코드의 통일성이 전혀없었기에 내가 다른팀원의 코드를, 다른팀원이 나의 코드를 읽고 이해하기 어려울정도의 상황이었습니다

당연히 코드리뷰를 진행하려면 코드를 이해하는것부터 시작이어야하지만 이해하는데 시간이 오래걸릴뿐더러 구현에 집중했기때문에 로직자체가 정당하지 않은 상황도 많았습니다

MVC에서 ViewController가 massive해지면 어떤 문제가 발생하는지 이때 느꼈던것같습니다
메서드를 분리했다고 하나 팀내에서 통일성있게 규칙을 정하고 지키지 않으면 UI관련로직이 어디있는지 네트워킹 관련로직이 어디있는지를 알 수가 없습니다, 전부다 코드를 확인해야합니다

커뮤니케이션 비용이 많이 발생한다라는게 제가 피부로 느꼈던 MVC의 단점이었습니다
개발자는 혼자서 모든 일을 할 수 없고 그렇기때문에 커뮤니케이션은 매우 중요합니다 하지만 지금 상황에서는 코드로 커뮤니케이션을 할 수 없는 상황이라는걸 깨닫게 되었습니다

이 상황에서 Massive한 ViewController가 정확하게 무엇을 의미하는지를 팀내에서 고민을 해봤던것 같습니다
UI만으로도 엄청나게 복잡한 viewcontroller의 경우엔 막말로 UI관련 코드로만 5~600줄이 발생할수있는거고 아마도 viewcontroller내에 UI코드만 5~600줄 있다고 해서 이거 massive해라고 하지는 않을거같았습니다

여기서 말하는 massive한 viewcontroller란 viewcontroller가 담당역할인 UI외에 다른 여러가지 로직또한 수행하고 있을때를 massive하다고 표현하는게 아닐까라는 생각이 들었습니다, 역할의 비대함인거죠

문제점 2 : 결합도가 너무 높은 코드들

그리고 두번째 문제는 결합도가 너무 높은 코드들이었습니다. 모든 ViewController들과 객체들이 서로를 객체 자체로 참조하고 있어서 하나의 객체에 문제가 생기면 그 객체를 참조하고있는 다른 객체들에도 문제가 발생할 수 있는 가능성이 존재했습니다

언제 어디서 터질지 모르는 코드랄까요...?

이때 당시에 팀내에서 OCP라는 개방폐쇄원칙을 최대한 지켜보고 싶다는 팀원이 있었습니다, SRP라는 단일책임원칙도 이야기가 나왔던것 같은데 핵심은 한곳에서의 변화가 다른곳에 영향을 미치는 코드최대한 지양하자는 공감대 자체는 형성이 되었던것같습니다

하지만 현재 코드는 네트워크 레이어가 싱글톤으로 되어있어서 결합도가 높고 싱글톤 내부의 메서드 변경이 해당 싱글톤을 사용하고있는 모든 곳에서의 변화를 초래 할 수 있었기에 팀내에서 바라보고 있는 한곳에서의 변화가 다른곳에 영향을 미치는 코드최대한 지양하자는 목표에 벗어나는 방식이라고 생각했습니다

문제점 3 : n개의 역할을 수행하는 메서드들 천지

그리고 결합도와 비슷하게 하나의 메서드에서 너무나 많은 역할을 수행하는 탓에 어떤 메서드에서 문제가 생겼을때 해당 메서드가 수행하고있는 역할들이 너무 많아 어떤 로직이 문제인지를 매번 찾아줘야하는 문제가 있었습니다

특히 네트워킹의 경우에 error핸들링이 전혀 되어있지 않았었기에 decode에서 문제가 발생해서 return된건지 response가 400대라서 return된건지 다른곳에서 optional unwrapping이 안돼서 return된건지를 매번 찾아야했습니다

만약에 하나의 메서드가 하나의 역할만 수행했다면 문제가 있는 메서드를 찾기만하면 어떤 코드에서 문제가 발생했는지를 한번에 찾을 수 있었을겁니다

그리고 이런 요소들이 디버깅을 하기어렵게만들었고 간단한 문제도 시간을 잡아먹는 문제가 생겼고 QA에서는 팀전체의 개발속도가 더뎌지는 문제가 발생했습니다

리팩터링 방향성

그렇게 생각하고 팀내에서 결론을 내리니 리팩터링 방향이 명확해졌습니다

  1. massive한 viewcontroller를 한번 줄여보자
  2. 결합도를 낮추는 방식으로 설계를 바꿔보자
  3. 많은 역할을 맡고있는 메서드를 단일 로직을 수행하는 메서드나 레이어로 분리해보자

지금부터는 위의 세가지 방식을 어떻게 MVC에 적용했는지를 이야기해보려합니다


1. 네트워크 구조 재설계

위에서 말씀드렸던것처럼 라이언하트 리팩터링 전에는 네트워킹객체를 싱글톤으로 설계했습니다
저는 싱글톤이 해당앱에서는 아주 좋은 방식이었다고 생각합니다

싱글톤에 대해 조금 더 알고 싶다면?
싱글톤에 대한 오해와 진실

단순 API를 호출하는 메서드를 실행만 하는 상황이어서 적어도 라이온하트라는앱에서는 싱글톤의 고질적인 문제점이라고 말이 나오는 data race의 발생 가능성이 없었다고 생각합니다

하지만 위에서 말했던것처럼 객체자체를 싱글톤으로 들고있기에 싱글톤에서문제가 발생하거나 변화가 발생하면 싱글톤 객체를 들고있는 여러곳에서 변화가 필요할 수 있다는점이 걸렸습니다

그리고 기존 싱글톤자체는 네트워크 레이어가 단일이어서 하나의 레이어와 메서드에서 데이터를 받아오고 decode하고 DTO로 변환해주고 필요시 추가로 변환하는 기능을 전부 수행했기에 디버깅시 어려움이 있었습니다

객체간의 결합도는 낮추는 방법

우선 팀내에서 객체간의 결합도를 낮추는 방법에 대한 고민을 진행했습니다
팀내에서 스터디를 진행하면서 의존성주입을 통한 객체간 결합도를 낮추는 방식을 채택하기로 했습니다
그리고 의존성주입을 위한 도구로 protocol이라는 interface를 적극적으로 사용해야한다는걸 알게되었습니다

의존성주입에 대해 조금 더 알고 싶다면?
[iOS] 의존성주입(DI)랑 친해지기
[iOS] 의존성주입(DI)랑 베프먹기

네트워킹시 필요한 메서드를 interface로 분리하고 해당 프로토콜을 채택하는 객체를 외부에서 생성자에서 객체를 주입해주는 방식으로 네트워킹 레이어라는 객체와 viewcontroller라는 객체간의 결합도를 낮췄습니다

또한 네트워킹 레이어 자체도 API를 호출하는 레이어와 호출결과를 판단하고 decoding하는 레이어 그리고 decoding한 결과를 기존에 사용하던 data로 바꾸는 레이어로 나눴습니다

물론 해당 레이어들도 모두 의존성주입을 통해서 레이어간의 결합도를 낮췄습니다
해당리팩터링으로 인해서 문제가 발생했을 때 레이어만 알아내면 어떤 로직에서 문제가 발생했는지를 단번에 알 수 있어 디버깅시 수월해졌습니다

O(n)이 O(1)로 줄어든 느낌이랄까요...?

그리고 각각의 레이어가 interface를 바라보고있기에 객체의 변화와 문제가 다른 객체에 영향을 끼치지 않아 유지보수가 편리해 졌습니다

최종적인 네트워킹 레이어 구조도

당시에 작성했던 PullRequest링크를 함께 첨부합니다

[REFACTOR] API 레이어 분리 (#127)
[REFACTOR] 북마크 네트워크 레이어 분리(#129)
[REFACTOR]전체 DI적용(#131)


2. ViewController의 역할 분리하기

Coordinator Pattern도입

ViewController는 UI 관련 객체이기 때문에, 사용자 흐름을 처리하는것은 역할 범위(scope)를 벗어난다라는 의견이 스터디중에 나왔고 MVC의 단점인 ViewController의 역할이 비대해지는 문제를 해결하기 위해서는 화면전환 책임 전담을 위한 Coordintor Pattern을 도입해야한다는 의견에 모든 팀원의 동의를 하게 되었습니다

Coordinator Pattern에 대해 조금 더 알고 싶다면?
[iOS] Coordinator Pattern 알아보기

제가 생각했을때 리팩터링 과정중에서 가장 어려웠던 부분이 coordinator를 도입하는 과정이었습니다

사실 coordinator는 이전에도 몇번은 도입해보고자 혹은 공부해보고자 하는 주제중에 하나였습니다...하지만 이게 단순 아이디어를 구현하기만하면 어떤 방식이던 상관없는 패턴이기에 너무나 많은 방식과 레퍼런스가 있어서 어떤 방식을 선택해서 공부하고 이정표로 삼을지를 공부하는 과정이 방대하고 오래걸렸고 그래서 그동안 제대로 공부하고 익혀본적이 없었습니다

하지만 팀원들과 함께 레퍼런스를 찾고 하나의 레퍼런스를 기준으로 잡고 리팩터링을 진행했습니다
viewcontroller 여기저기에 흩어져있던 화면전환 로직이 전부 coordinator객체로 옮겨졌고 화면전환에대한 로직을 작성해야하거나 수정이 필요할때는 viewcontroller를 전부 확인하면서 찾는것이 아닌 해당 viewcontroller의 coordinator 객체를 찾아서 수정하면되기에 유지보수 측면에서도 보완이되었습니다

물론 해당 coordinator객체도 viewcontroller와 강하게 결합되지 않게 의존성주입으로 구현했습니다

최종적인 Coordinator 구조도

당시에 작성했던 PullRequest링크를 함께 첨부합니다

[REFACTOR] Coordinator Pattern 적용 (#139)


3. 관심사 분리에 대한 고민

coordinator도입을 마치고 나서 관심사분리라는 관점에서의 리팩터링을 다시한번 고민해보게되었습니다

MVC의 문제인 massive함을 해결하기 위해서 ViewController의 역할이 아닌 로직(화면전환로직)을 분리하기 위한 coordinator객체를 만든건데 그렇다면 coordinator자체도 화면전환로직외에 다른 로직을 가지고 있다면 massive한 coordinator객체가 되는건 아닐까 라는 생각을 하게 되었습니다

coordinator에서 화면전환을 위해서는 새로운 viewcontroller객체를 생성해야합니다
그러면 coordinator가 객체를 생성하는 역할이 coordinator객체를 massive하게 만든다는 생각이들었습니다

객체생성의 역할을 하는 객체를 새로만들자는 아이디어를 가지고 여러가지 방식을 찾아보기 시작했습니다

swinject에 대한 고민

처음에는 swinject라는 라이브러리를 알게되었습니다
그런데 라이브러리를 바로 사용하는것보다는 swinject라는 라이브러리가 어떤 아이디어를 가지고 객체를 생성해주는지를 공부해보자라는 의견이 나왔고

container라는걸 통해서 protocol을 key로 넣어주면 value인 해당 프로토콜을 채택하는 객체인 value를 반환해주는 아이디어라는걸 알게 되었습니다

그러면 우리 DI Container를 custom해서 만들면 어떨까?

그래서 Custom DI Container를 구현해봤고 구현중에 DI Container를 조금더 발전시키기위한 여러가지 방식을 사용해봤습니다. 하지만 reference count에 대한 의문이 생겼고 당시 스터디에서는 의문점을 해결하지 못한채 DI Container에 대한 논의만 계속 진행했었습니다

Custom DI Container가 궁금하다면?
[iOS] DI Container를 구현해보자(feat. Swinject)

그러다가 분명히 swinject내부에서도 우리와같은 dictionary방식을 사용할거고 그러면 우리가 발견한 reference count에 대한 문제도 분명 존재할텐데 내부적으로 어떻게 해결하는지를 알기 위해서는 라이브러리를 뜯어보는건어떨까라는 의견을 냇고 swinject내부의 로직을 공부해보기 시작했습니다

swinject라이브러리가 궁금하다면?
[iOS] Swinject 라이브러리 분석해보기

내부에서 따로 reference count를 관리해주는 로직이 있다는걸 알게되면서 그러면 custom DI Container로는 이렇게 할 수 없으니 dictonary로 쓰려면 swinject를 써야곘다고 결론을 내렸었습니다

하지만 하루정도 더 여러 레퍼런스를 찾아보니 swinject 우리의 상황에서 swinject를 사용하는게 조금 과한 방식이라는 생각이들었습니다, memory leak을 관리해주는 dictionary로 swinject의 container를 사용할 수 있겠지만 단순히 매번 새로운 객체를 생성하는건 사실 라이브러리 없이도 할수있는거니까요

swinject라이브러리는 생성하는 객체를 매번새로 생성하거나 싱글톤처럼 쓸수있게 공유되는 객체를 생성해주거나하는 방식으로 dictionary에서 객체를 꺼낼때의 객체의 상태를 선택할수있다는걸 알게되었습니다, 하지만 저희 프로젝트는 매번 객체를 새로생성하면되기에(viewcontroller나 viewmodel이 공유될일이없으니까) 굳이 라이브러리를 import하면서까지 쓸 이유를 찾지 못했습니다

swinject대신 Factory Pattern

단순히 객체를 매번생성해주는건 라이브러리가 아니라 내부적으로 디자인패턴으로 해결한다면 훨씬 좋은방식이라고 생각했습니다 외부라이브러리와의 의존성이 없는방식이기도 하니까요

팩토리패턴에도 여러가지 방식이있지만 저희는 interface를 활용해서 추상객체를 반환해주는(실제객체가 아닌 프로토콜타입을 반환해주는) 추상팩토리방식을 사용했습니다

최종적인 Factory Pattern 구조도

당시에 작성했던 PullRequest링크를 함께 첨부합니다

[REFACTOR] Factory Pattern도입(#149)
[REFACTOR] ArticleCategory, Challenge, Bookmark Factory Pattern 적용(#143)

4. 캡슐화에 대한 고민

당시에 iOS스터디로 오브젝트라는 책을 읽고 토론하는 스터디를 진행하고 있었습니다, 책에서 가장 신박했던 구절이 하나 있는데

메서드명을 너무 구체적으로 쓰는게 캡슐화에 위반 될 수있다

이게 무슨말인지를 한번 보면 당시에 저희의 coordinator pattern은 viewcontroller의 button actiono을 delegate로 받아서 coordinator가 화면전환 로직을 수행했습니다

그러다보니 coordinator는 단순히 화면전환 로직만 알고있으면 되는데 delegate로인해서 어떤 버튼이 눌렸는지 메서드명을 통해서 유저의 action를 추론할 수 있게 되었습니다

extension ArticleCategoryCoordinator: ArticleCategoryNavigation, ArticleListByCategoryNavigation {
    func articleListCellTapped(categoryName: String) { ... }
    func navigationRightButtonTapped() { ... }
    func navigationLeftButtonTapped() { ... }
}

coordinator내부에서 어떤 버튼이 tap된건지에 대한 관심사 분리가 되지 않는다는 생각이 들었고 완전한 관심사분리를 위해서는 두개의 interface(viewcontroller의 buttonaction interface와 coordinator의 화면전환 interface)를 연결해주는 adaptor를 도입하자는 의견이 나왔고

viewcontroller와 coordinator사이에 interface를 연결해주는 adaptor를 통해 viewcontroller는 화면전환 로직에서, coorindator는 유저의 action으로부터 완전한 관심사분리를 하게 되었습니다

AdaptorPattenr을 도입한 구조도

당시에 작성했던 PullRequest링크를 함께 첨부합니다

[REFACTOR] Coordinator에 Adaptor Pattern 도입 (#153)
[REFACTOR] today coordinator에 adaptor pattern 적용 (#152)


좀 더 나은 MVC

기존의 MVC에서 coordinator pattern을 도입했고 coordinator객체 내부에서의 관심사분리를 위한 factory pattern도입 그리고 viewcontroller와 coordiantor의 완전한 관심사분리를 위한 adptor pattern을 도입하면서

기존의 ViewController가 너무나 많은 로직을 수행했을때에 비해서는 네트워킹로직과 화면전환(+객체생성)의 로직에대한 역할을 덜어낼 수 있었고 대부분의 객체가 서로를 강하게 결합하고 있지 않기에 변화나 문제가발생했을때 다른 객체에 영향을 미치지 않는 결합도 낮는 방식으로의 리팩터링 과정을 거치게 되었습니다

앞으로의 여정

사실 앱잼시작할때 팀내부에서 하고싶은게 한가지 있었는데 바로 unit test였습니다
하지만 이번 앱잼때 나는 Unit test를 할거야!라고 하면 정말 10명이면 10명 전부다 이런말을 했습니다

mvc로는 하기 힘들텐데...?

사실 unit test자체를 고려했었기에 객체간 결합도를 낮추기 위한 시도를 했고 unit test를 위한 최소한의 준비는 되어있다고 생각했습니다

그래서 팀내의 분위기도, 제가 추구하는 성장의 방향도
우선 부딪혀보고 거기서 불편함을겪는다면 다른 방식을 시도해보는 방향이 좋은 방식이라고 생각했습니다

그래서 mvc로 우선 unit test를 해보고 다른사람들이 말하는 대로 불편함을 느끼면 그때가서 mvvm으로 바꾸는게 어떻겠냐고 제안을 했고 다른 팀원들도 긍정적으로 받아들여줬습니다

하지만 mvc로 unit test를 진행하기전에 이런저런 레퍼런스들을 참고하다가 애초에 mvc라는 아키텍처가 testable하지 않은게 아닐까라는 생각이 들게 되었습니다

mvc와 unit test의 관계가 궁금하다면?
[iOS] Unit test와 아키텍처에 관한 고찰

결론적으로는 mvc가 unit test를 하기에 불편하다보다는 애초에 testable하지 않은 아키텍처라는 생각이 들었고 해당 내용으로 팀원들과 이야기를 나누고 우리가 최종적으로 해야할 unit test를 위해서는 mvvm으로의 아키텍처변겅을 고려해야야한다는 결론에 도달하게 되었고 mvc에서 mvvm으로의 2차 리팩터링을 진행을 결정하게 되었습니다


본 포스팅은 리팩터링이 마무리된 12월에 작성된 포스팅이며
https://github.com/Team-LionHeart/LionHeart-iOS
해당 링크에 리팩터링 과정이 readme에 자세히 기록되어있습니다

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

0개의 댓글