버터플라이 아키텍처를 소개합니다

Jung Kim
13 min readApr 16, 2023

--

iOS 클린 아키텍처에 대한 해석

이 글은 2022년에 쓰기 시작한 iOS 아키텍처 책을 완성하지 못한 저의 반성에서 시작했습니다. 불행하게도 책은 여전히 완성하지 못했고 이렇게라도 일부를 공개해야 앞으로 계속할 것이라는 약속을 하지 않을까 싶습니다

이 글에서 소개하려는 버터플라이 아키텍처의 모습입니다. 🦋 나비 모양이 보이시나요?

버터플라이 아키텍처는 Clean Architecture를 iOS에 알맞게 적용해서 설명하려는 시도에서 출발했습니다. 최근 몇 년 사이에 클린 아키텍처가 유행이라서 iOS에도 클린 아키텍처로 설명하려는 시도가 많았었죠. 그래서 영향을 받은 VIPER나 RIBs를 살펴봐도 아쉬운 부분이 있었습니다. 제가 볼 때는 도메인과 유즈케이스(VIPER에서 인터액터), 레포지토리와 네트워크 부분에 대한 설명이나 구현은 모두 달랐습니다. 자 그럼 나비처럼 부드럽게 날개짓을 시작해보겠습니다.

아키텍처 기본 요소

시스템 블록 다이어그램

모든 엔지니어링의 시작은 시스템을 설계하는 것입니다. 입력에 대해 기대하는 특정한 동작을 처리해서 원하는 출력이 나오는 시스템을 만들어야 합니다. 요구사항에는 문장 형태로 입력부터 출력까지 흐름을 표현하고, 개발자들은 그것을 분석하고 해석해서 반복적으로 동작하는 장치는 만드는 거죠.

태초에 뷰가 있었다

iOS 앱 프로젝트를 만들어보면 템플릿에서 자동으로 생기는 AppDelegate.swift, SceneDelegate.swift 같은 파일들이 있습니다. macOS 프로젝트와 달리 iOS 프로젝트에는 main.swift가 생략되어 있어서 UIApplication을 생성하는 코드가 직접 보이지는 않습니다. 만약 AppDelegate 클래스 선언부에 있는 @main 을 지우고 main.swift 파일을 아래처럼 작성하면 원하는 Application 클래스와 AppDelegate 클래스를 지정할 수 있다.

UIApplicationMain(CommandLine.argc,
CommandLine.unsafeArgv,
NSStringFromClass(TopApplication.self),
NSStringFromClass(AppDelegate.self))

main에서 UIApplicationMain() 이전에 원하는 코드를 작성하는 게 가능할까? 가능합니다. iOS 앱도 원한다면 main.swift 파일 하나로 전부 구현할 수도 있습니다. 이쯤에서 왜 그러면 안되는가 질문을 던져보고 싶습니다. 나 혼자 쓰는 앱이라면 가능하지 않을까요?

당연하게도 iOS 앱 아키텍처를 이야기하려면 입력과 출력을 빼고 이야기 할 수 없습니다. iOS은 main에 모든 것을 구현하지 않고 적어도 하나의 뷰를 만들어야 리젝되지 않을테니까요. iOS에서 반드시 만들어야 하는 적어도 하나의 뷰는 바로 window입니다. 스토리보드는 없앨 수 있지만 윈도우는 없앨 수 없습니다.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
self.window = UIWindow.init(windowScene: windowScene)
self.window?.addSubview(BareView.init(frame: UIScreen.main.bounds))
self.window?.makeKeyAndVisible()
}
}

그런 경우가 없겠지만 UIWindow를 상속받아 필요한 모든 동작을 구현할 수도 있습니다. 그렇지만 iOS 는 UIKit 프레임워크에 화면을 처리하도록 돕는 UIView 계열 클래스를 제공합니다. 모든 동작을 다 포함하고 있는 BareView라는 뷰만 있어도 앱을 구현하고 앱 스토어에 올릴 수도 있습니다.

뷰 하나만 있는 앱

결론적으로 iOS 앱을 위해서는 입력과 출력을 담당하는 UI 영역이 필요합니다. 놀랍게도 이 구조는 SwiftUI로 앱 템플릿을 생성하면 만들어지는 구조와 비슷하네요.

뷰 컨트롤러는 왜 필요했나

그렇다면 뷰 컨트롤러는 왜 필요할까요? 위에 BareView를 가지는 앱을 ViewController를 rootViewController로 사용하도록 바꿔보겠습니다.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
self.window = UIWindow.init(windowScene: windowScene)
self.window?.rootViewController = BareViewController()
self.window?.makeKeyAndVisible()
}
}
class BareViewController: UIViewController {
override func loadView() {
self.view = BareView(frame: UIScreen.main.bounds)
(self.view as! BareView).delegate = self
}

override func viewDidLoad() {
super.viewDidLoad()
}
}

뷰-컨트롤러는 말 그대로 뷰를 조작(control)하기 위해서 필요합니다. UIControl 계열은 사용자 입력(이벤트)을 받아서 IBAction을 호출하거나 타깃-액션을 호출해줍니다. 이 과정은 앞서 설명한 Application 객체에서 sendEvent()나 sendAction()로 호출합니다. 대부분 이벤트는 UIResponder를 상속받은 뷰 객체들에게 전달됩니다. UIViewController는 window에 화면 가득 채우는 뷰를 제공하고, 여러 뷰에게 전달된 액션을 받아주거나 다시 IBOutlet으로 연결된 다른 뷰에게 출력하는 역할을 합니다.

뷰-컨트롤러 역할

MVC는 필요악인가

가장 처음 학습하는 MVC 패턴은 적어도 Model-View-Controller 3가지 계층으로 나누는 설계 방식입니다. 한 가지 아쉬운 부분은 macOS에서 지원하던 전통적인 (코코아) MVC를 iOS로 가져오면서 Controller를 View에 결합되는 ViewController로 제한해 버린 것입니다.

기본적인 MVC 구조

iOS 플랫폼에서는 View와 ViewController가 기본 제공되지만 Model은 따로 제공하지는 않다보니 앱을 만들다보면 ViewController도 기능이 많아지고, 다양한 Model이 만들어집니다.

앱 구성과 MVC

이렇게 독립적인 컨트롤러가 아니라 뷰-컨트롤러가 되면서 아쉬운 점은 다음과 같습니다. (아마 애플도 ViewController에 대한 역할 정의가 불명확했나 봅니다)

  • 테스트하기 어려워진다
  • 유즈케이스를 찾기 어려워진다
  • 동시 작업이 어려워진다

뷰컨트롤러를 나누기 어렵다보니 어떤 회사에서는 뷰컨트롤러 단위로 팀을 나누기도 합니다.

입력부터 출력까지 MVC

위에 MVC 그림은 뷰 컨트롤러가 하는 역할이 입력과 출력이 합쳐져서 표현되다보니 저는 주로 입력 흐름과 출력 흐름을 펼쳐서 그리는 편입니다. 그래야 입력부터 출력까지 흐름이 더 잘 보이고, 유즈케이스와 매칭해서 살펴보기 쉽기 때문입니다.

또는 MVC를 계층적으로 표현해서 데이터 흐름을 보고 싶을 때는 다음과 같이 계층을 옆에서 보는 그림으로 설명하기도 합니다.

MVC 계층적인 해석

앞에서 역설적으로 설명했던 main에 모두 구현하기 → window에서 모두 처리하기 → view 하나로 앱 만들기 → ViewController로 rootViewController 구현하기는 크게 다르지 않습니다. 단지 관심사를 분리하기 위한 역할과 책임을 어떤 단위로 묶어서 설계하느냐 구현하느냐 차이만 있을 뿐이니까요.

iOS에서 필요한 클린 아키텍처

개발자들마다 관심사를 분리하는 시야와 기준이 다릅니다. 그래서 아키텍처는 달라질 수 밖에 없습니다. 이제는 유행처럼 흘러간 클린 아키텍처에 대한 소개나 논의가 예전처럼 많지 않습니다만 다른 분야에 비해서 역사가 짧은 iOS 분야는 (애플이 나서서 아키텍처를 설명하지 않다보니) 이런저런 사례를 통해 배우게 됩니다.

클린 아키텍처의 해석

저의 클린 아키텍처의 해석은 데이터 흐름과 의존성의 관점에서 가장 바깥쪽에 APP 영역부터 UI 영역, PRESENTATION 영역, DOMAIN 영역으로 구분합니다. 엉클 밥이 그려준 동그라미 모양에서 일부 요소를 우측처럼 iOS에 맞게 변형해 봤지만, 설명하지 못하는 부분이 있었습니다. 그래서 좌측처럼 영역을 나누고 iOS 요소들을 배치했습니다.

클린 아키텍처는 갑자기 나온 것은 아니고 육각형(Hexagonal) 아키텍처를 엉클 밥이 정리한 거였죠. 육각형 아키텍처 이름을 제안했던 알리스테어 코어번은 사각형으로만 그려지는 게 생각을 고정해서 육각형으로 표현했는 데, 오히려 반대로 육각형만 되는 거라고 상상해서 이름을 잘못지었다고 후회했다는 일화가 있습니다. 이후에는 여러 포트와 어댑터로 역할과 책임을 구분하는 방식이라 포트와 어댑터 구조라고 부르는 편입니다. 포트와 어댑터 구조에서 놓치기 쉬운 부분은 설정 컴포넌트입니다. 의존성을 관리하기 위한 구조적인 선택이죠. TCA도 어느정도 이런 설정 컴포넌트의 역할을 포함하고 있습니다.

포트와 어댑터 (육각형) 아키텍처

iOS 구조에서 흔히 사용하는 포트와 어댑터를 표현해봤습니다. 육각형으로 그렸지만 6개 면이 중요한 게 아니라, 위에 그림에 그린 4개의 어댑터만 있어야 하는 것도 아닙니다. 테스트용 어댑터나 로그 어댑터가 더 필요할 수도 있으니까요.

버터플라이 아키텍처

결과적으로 포트와 어댑터 패턴을 고려해서 다시 그리고 보니 양쪽 날개를 가진 나비 모양 아키텍처가 되었습니다. 이 구조에서는 나비의 왼쪽 날개에 해당하는 APP → UI → Presenation → Domain 의존성 구조와 대비되는 오른쪽 날개 Infra. → Network → Data → Domain 구조와 Device → Storage → Data → Domain 으로 이어지는 의존성 구조를 포함합니다.

버터플라이 아키텍처를 나누는 기준은 여러 가지가 있지만 다음과 같은 기준을 제시합니다.

  • 테스트 가능한가
  • 재사용 가능한가
  • 독립적으로 빌드하고 배포 가능한가

유즈케이스 패턴

버터플라이 아키텍처를 소개하면 대부분 클린 아키텍처 소개하는 다른 자료들을 보고 이해하기 어렵지 않지만, 어려워 하는 부분은 유즈케이스 부분이었습니다. 왜냐하면 어떤 앱을 기준으로 설명하느냐에 따라서 구현이 모두 달랐기 때문입니다.

(작업하던 책에서는 유즈케이스마다 엄청 상세하게 설명하려고 준비했지만 요점만 정리해보겠습니다 ㅎㅎ)

제가 생각하는 기준에서 iOS 앱 유즈케이스는 크게 다섯 가지 카테고리로 나눌 수 있습니다. 특히 이벤트 루프를 처리하는 스레드가 있어서 발생하는 이벤트는 입력으로 분류했습니다.

  1. 사용자가 입력하는 이벤트 → 사용자에게 보여주는 출력 흐름
  2. 사용자가 입력하는 이벤트 → 저장하거나 요청 보내기
  3. 외부에서 들어오는 이벤트 → 저장하거나 응답 보내기
  4. 외부에서 들어오는 이벤트 → 사용자에게 보여주는 출력 흐름
  5. 앱 수준에서 받는 이벤트 → ?

유즈케이스는 mac, iOS 플랫폼이나 UIKit, SwiftUI 프레임워크나 네트워크, 파일에 독립적으로 어떤 앱이냐에 따라 결정되는 로직들을 말합니다.

POP가 필요해

버터플라이 아키텍처를 실제로 구현하기 위해서는 다양한 프로토콜을 포트로 활용해야 합니다. 응집도를 높이고 결합도를 낮춰서 영역별로 독립적으로 만들기 위해서 입니다.

스위프트에서 프로토콜은 구체 타입을 추상화하고, 영역별로 독립적으로 빌드하고 배포하고 조직할 수 있는 역할을 합니다. 앞서 많은 조직이 뷰 컨트롤러 단위로 팀을 나눈다는 언급처럼 <콘웨이 법칙>을 따르는 조직이 많습니다. 조직의 구조가 서비스의 구조, 앱의 구조를 이미 결정하는 중요한 요소가 되어버립니다.

나비 효과

버터플라이 아키텍처는 유연한 앱을 만들기 위한 영역별로 나눠놓은 구조입니다. 모든 앱을 이렇게 나눠야 하는 것은 아닙니다. 유연한 구조는 필연적으로 복잡해지죠. MVC 만으로 충분한 앱도 있을 수 있고, MVVM이나 RIBs, VIPER 가 적합한 앱도 있을 수 있습니다.

버터플라이 아키텍처는 도메인 영역을 중심으로 좌-우 날개가 대칭이며, 서로 독립적입니다. 마치 나비의 날개짓처럼 위 아래로 같은 영역에 여러 타입들이 생길 수 있습니다. 데이터 흐름이나 이벤트 흐름은 도메인을 거쳐서 다른 곳으로 흘러갑니다. 앱이 뷰 컨트롤러 단위로 UI와 Presentation, Domain이 묶어서 처리하는 것처럼 우측 날개도 Network와 Data, Domain을 묶을 수 있습니다. 이럴 경우 의존성 묶음을 어디서 관리하느냐도 고민할 부분이 될 겁니다.

맺음말

이 글을 시작하면서 어떻게 마무리를 할까 고민했습니다. 절반 정도 써놓은 책의 내용 중에서 핵심 부분만이라도 소개하고 싶었는데 너무 분량이 많아질 수 있을 꺼 같았습니다.

모든 앱을 나비처럼 유연한 구조를 만드는 게 목표가 아닙니다. 앱에 따라서 어떤 영역은 생략될 수도 있고 다른 영역과 합쳐질 수도 있습니다. 그럼에도 이렇게 까지 나눠보자고 말하는 것은 시야를 넓히기 위해서 입니다. 여러 회사들이 만들고 있는 앱 아키텍처는 얼마든지 공유해도 되는 (더 나아가 공유했으면 하는) 자료라고 생각합니다. 부끄럽지만 그냥 용기가 필요한 게 아닐까요. 작은 날개짓이 누군가에게 큰 변화를 줄 수도 있으니까요.

저도 작은 용기를 가지고, 제 스스로 해석한 구조에 이름을 붙여봤습니다 😎

--

--