Protocol 로 SwiftUI & Preview 200% 활용하기

Lee Di
DelightRoom
Published in
14 min readJul 19, 2023

시뮬레이터와 디바이스 빌드에게 보내는 굳바이 편지 👨‍💻

다들 행복하게 개발하고 계신가요? 🏋️‍♂️

다들 생산성 높은 개발 하고 계신가요..?
UI 개발에 고통 받고 계시진 않으신가요..?

SwiftUI에서 Preview를 활용할 수 있고, 이제 UIKit에서도 Preview를 활용 할 수 있다던데..
막상 사용하려니 얽힌 Dependency들로 인하여 정작 Preview는 활용하지 못하고, 디바이스 빌드만 돌리고 계시진 않으신가요?

그렇다면 진단 나왔습니다. protocol을 쓰셔야 합니다.

Why Preview? 🤔

다들 아시다시피 Preview는 SwiftUI에서 제공하는 기능입니다. Preview 를 이용하면 디바이스/시뮬레이터에 빌드하지 않고도 코드를 말 그대로 미리보기 할 수 있습니다. Font 하나의 변경을 체크하기 위해서 1분간 빌드를 기다리던 과거는 더 이상 없는거죠.

이를 통해 개발자는 디자인 작업 시간을 줄이고, 더욱 빠른 프로토타이핑을 할 수 있습니다. 그리고 이는 엄청난 생산성으로 돌아옵니다. 이렇게 아껴진 시간은 구조와 설계에 재사용하게 되고, 이는 더 큰 생산성으로 돌아오게 됩니다. 이전의 생산성과는 엄청난 초격차를 보이게 되는 것이죠.

최근 발표된 내용에 따르면 Xcode 15에서는 SwiftUI 뿐 아니라 UIKit 역시 Preview를 지원해준다는 아주 놀라운 소식이 포함되어 있었습니다. Amazing!! 이제 더 이상 레거시 코드들 수정시에 빌드 시간이라는 고통을 받지 않아도 된다는 것이죠! 🎉

But Preview 🥲

하지만 막상 이 좋은 Preview를 잘 활용하지 못하는 경우를 종종 보게 되곤 합니다. 분명 간단한 View인 경우에는 Preview를 활용하기 괜찮았는데, 점점 프로젝트가 커지고 레이어가 복잡해지면서 Preview가 유명무실 해지는 경우가 생기곤 합니다. 특히나 MVVM 등과 같은 아키텍쳐를 처음 만들고, 의존성 분리를 작업하는 경우에 이런 고민이 깊어지게 됩니다.

Preview포기 테크트리를 보면 다음과 같습니다.

1. 파일을 만들고 데이터를 주입한다.

‘음~ 데이터를 초기화시에 넣어주면 바로 UI를 볼 수 있네~ Preview 너무 좋다~’

struct AlarmyView: View {

let title: String

var body: some View {
Text(title)
}
}

struct AlarmyView_Previews: PreviewProvider {
static var previews: some View {
AlarmyView(title: "Hello Alarmy")
}
}

2. ViewModel을 넣는다.

‘아직 괜찮아! View 에 필요한 데이터를 ViewModel에서 주입하면 되니까!’

struct AlarmyView: View {

@ObservedObject private var viewModel: AlarmyViewModel

init(viewModel: AlarmyViewModel) {
self.viewModel = viewModel
}
...
}

struct AlarmyView_Previews: PreviewProvider {
static var previews: some View {
AlarmyView(viewModel: .init())
}
}

public class AlarmyViewModel: ObservableObject {
...
}

3. 의존성이 하나 둘 씩 늘어간다.

하지만 의존성이 늘어날 수록 ViewModel을 Preview 에서 초기화 해주는 데에 한계를 느끼기 시작하게 될 것입니다.

‘앗 초기화 시점에 외부 의존성이 생기니 점점 불편해지는데..?’

public class AlarmyViewModel: ObservableObject {    

init(
alarmyRepository: AlarmyRepository,
alarmyAPIProvider: AlarmyAPIProvider,
) {
...
}
}

특히나 CleanArchitecture를 도입하여 UseCase를 주입해주는 경우라면 더욱 심할 것 입니다.

public class AlarmyViewModel: ObservableObject {    

init(
fetchAlarmyDataUseCase: FetchAlarmyDataUseCase,
deleteAlarmyDataUseCase: DeleteAlarmyDataUseCase,
requestWeatherUseCase: RequestAlarmyHistoryUseCase,
requestNewsUseCase: RequestNewsUseCase
) {
...
}
}

struct FetchAlarmyDataUseCase {
private let alarmyRepository: AlarmyRepository

init(alarmyRepository: AlarmyRepository) {
self.alarmyRepository = alarmyRepository
}

func execute() -> AlarmyData {
alarmyRepository.fetchAlarmyData()
}
}

...

4. 포기하고 돌아간다.

이렇게 외부 Dependency가 늘어가게 되면 더이상 데이터를 View에 그려주는 부분에서도 한계가 생겨 포기하게 됩니다.

Data는 외부(Local DB, Server 등) 에서 받아와야하기 때문에, Preview 상태에서 매번 데이터를 불러오자니 비용이 이만저만이 아니죠. 그리고 더이상 필요없어진 Preview코드는 삭제되고, 이전과 같이 빌드를 통해 View를 검증하게 되곤 합니다.

Magical Protocol 🧙

그렇다면 우리는 이 문제를 어떻게 쉽게 해결할 수 있을까요?!

제목에서 앞서 이야기한 것 처럼 protocol 을 이용하면 우리는 좀 더 쉽고 멋지게 문제를 해결할 수 있게 됩니다.

더 정확하게는 interface 라는 개념이죠!

인터페이스(interface)는 서로 다른 두 개의 시스템, 장치 사이에서 정보나 신호를 주고받는 경우의 접점이나 경계면이다.

라고 위키백과에서 이야기하고 있지만, 쉽게 말해서 interface란 두 객체의 통신 규약이라고 볼 수 있습니다.

이해를 돕기 위해 예시 코드를 함께 보시죠!
아까 나온 예시 코드 중 외부에서 주입해주는 객체인 AlarmyRepository 를 protocol로 선언해 주었습니다.

public protocol AlarmyRepository {

func save(alarmyData: AlarmyData)
func delete(alarmyData: AlarmyData)
func fetchAlarmyData() -> AlarmyData
}

위의 AlarmyRepository protocol 은 다음과 같은 기능들을 수행에 대한 약속이 이뤄집니다. 저장(save)을 할 것이고, 삭제(delete)를 할 것이고, AlarmyData를 불러오는(fetch) 것을 약속하고 있습니다.

이렇게 protocol이 선언되고 나면, 우리는 이를 채택하여 실제 구현체(implement)를 구현할 수 있죠!

class AlarmRepositoryImpl: AlarmyRepository {

func save(alarmyData: AlarmyData) {
...
}

func delete(alarmyData: AlarmyData) {
...
}

func fetchAlarmyData() -> AlarmyData {
...
}
}

AlarmRepositoryImpl 는 AlarmyRepoistory protocol을 채택하고 있으며, 각 기능들을 실제로 동작하도록 구현해줍니다.

각 Data의 저장/삭제/불러오기는 CoreData, UserDefatults, Realm 등을 통해 구현될 수 있겠죠.

그리고 실제 ViewModel을 생성해 줄 때에는 AlarmRepositoryImpl 를 초기화하여 넣어주면 됩니다.

// AlarmyViewModel 내부 
public class AlarmyViewModel: ObservableObject {
init(
alarmyRepository: AlarmyRepository
) {
...
}
}

// AlarmyView 생성
let alarmyView = AlarmyView(viewModel: .init(alarmyRepository: AlarmRepositoryImpl()))

😤 그러면 이전 구현이랑 똑같잖아?! 저장 안해놓으면 화면에서 보이지도 않고..! swiftUI 에서 이걸 어떻게 쓰라고?!

라는 의문이 드실 텐데요!

🥁🥁🥁🥁🥁🥁🥁🥁

자! 여기서 interface의 매력이 확 상승되는 키워드를 하나 더 추가해보겠습니다. 바로 Fake 입니다.

Fake란 TDD에서 나오는 개념으로, 가짜 객체를 진짜로 구현하는 것을 의미합니다. 하지만 우리가 반드시 TDD에서만 Fake를 쓰라는 법은 없죠!? 그리고 사실 UI Test라는 의미에서는 이것 또한 일종의 TDD가 아닐까요..? ㅎㅎ 😏

다시 돌아와서 우리는 이미 선언된 AlarmyRepository protocol 을 이용해 Fake객체를 만들 수 있습니다. 그리고 UI를 그리는 데에 필요한 함수에서 우리가 원하는 형태의 객체를 돌려줄 수도 있습니다.

class FakeAlarmyRepository: AlarmyRepository {

func save(alarmyData: AlarmyData) { }
func delete(alarmyData: AlarmyData) { }

func fetchAlarmyData() -> AlarmyData {
AlarmyData()
}
}

그러고 나면 Preview에서는 ViewModel 초기화 시점에 FakeAlarmyRepository 를 넣어주면 끝이죠!

뺌-!

struct AlarmyView_Previews: PreviewProvider {
static var previews: some View {
AlarmyView(
viewModel: .init(alarmyRepository: FakeAlarmyRepository())
)
}
}

이제 우리는 SwiftUI 에서 우리가 원하는 데이터가 어떤 형태로 나오는지 확인할 수 있게 되었습니다! 🎉

그리고 심지어 이렇게 만들어진 FakeAlarmyRepository는 TDD에서도 사용이 가능하다는 것!

의존성 분리를 통한 앱의 안정성을 확보해주고
SwiftUI에서 필요한 데이터도 바로 넣을 수 있게 만들어주고,
TDD에서도 유용하게 사용할 수 있다니..!

protocol 아주 이 요망한 녀석..!! 너무 매력적인 이 녀석을 사용하지 않을 수가 없겠죠?

위의 과정을 까먹기 전에 리마인드용으로 요약해보면 다음과 같습니다.

  1. protocol(interface) 을 만들고
  2. 실제 Product에서 필요한 구현체(AlarmRepositoryImpl)와 SwiftUI에서 필요한 구현체(FakeAlarmyRepository)를 만든다.
  3. 초기화 시 필요에 따라서 다른 구현체를 만들어서 집어넣는다.

꿀팁 🧁

어때요 많이 도움이 되셨나요? 하지만 이대로 끝내긴 아쉬우니 꿀팁을 하나 더 추가해보겠습니다. UI 작업을 하다보면 여러 State에 대한 대응을 해야하는 상황을 겪게 되기도 합니다.

단순하게 데이터가 있는 경우 / 없는 경우를 넘어서 데이터의 case에 따라 다른 화면을 보여줘야하는 경우도 있죠.

예를 들어 실제 저희 제품에서, 7가지 케이스에 대하여 체크해야하는 상황이 발생하기도 했습니다.

base,                  // 기본
recentLaunched, // 최근 설치
longTimeNoSee, // 오랜만
everyDay, // 매일
onlyWeek, // 주중사용
alternate, // 격일
once // 하루

그리고 당연하게도 이를 테스트하기 위해 각기 다른 데이터가 필요했습니다. 실제로 다음과 같은 케이스를 매번 반복해서 데이터를 만들고 삭제한다면 비용이 엄청나게 들겠죠?

이를 쉽게 해결하려면 어떤 방법이 있을까요?

물론 해결 방법은 여러가지가 있겠지만, 저는 데이터를 미리 만들어 놓고 초기화 시점의 case에 따라 다르게 돌려주는 방법을 채택하였습니다.

예를 들어 FakeAlarmyRepository에서 다음과 같은 케이스를 테스트해야한다면 다음과 같이 표현할 수 있을 것 입니다.

class FakeAlarmRepository: AlarmyRepository {

private let dataType: DataType

init(
dataType: DataType
) {
self.dataType = dataType
}

func save(alarmyData: AlarmyData) { }
func delete(alarmyData: AlarmyData) { }
func fetchAlarmyData() -> AlarmyData {
dataType.data
}
}

extension FakeAlarmRepository {

enum DataType {
case base, // 기본
recentLaunched, // 최근 설치
longTimeNoSee, // 오랜만
everyDay, // 매일
onlyWeek, // 주중사용
alternate, // 격일
once // 하루

var data: AlarmyData {
switch self {
...
}
}
}
}

어때요

이제 우리는 우리가 필요한 케이스마다 다른 데이터를 돌려주는 객체를 너무나 쉽게 만들어 버렸습니다.

결론

우리는 더 좋은 제품과 기능을 더 빠른 시간안에 안정적으로 유저들에게 전달하고자 합니다. 이를 위해서 우리는 우리가 가진 도구를 더 잘 사용할 필요가 있죠!

protocol과 interface의 사용방법은 정말 무궁무진 합니다. 제가 알려드린 방법은 그 중 고작 하나의 예시에 불과합니다. 그리고 꿀팁 역시 더 좋은 활용을 위한 하나의 예시에 불과합니다.

지금까지 외부 객체들 때문에 네트워크 때문에 preview 를 잘 사용하지 못하셨다면 protocol을 만들고 이를 채택한 Fake 객체 만들어 보세요! 그리고 이제 시뮬레이터와 디바이스에게 굳바이 인사를 건내주세요 ! ㅎㅎ

끝까지 읽어주셔서 감사합니다 🙂

⏰ 딜라이트룸에서 알라미와 함께 아침을 바꿀 분들을 모십니다

🙌딜라이트룸의 다양한 채널들을 팔로우하고 빠르게 소식을 받아보세요!

--

--