Post

공통 기능을 가진 ViewModel 만들어 재사용하기

ViewModel을 재사용하기 위한 고찰

이전 블로그 글에서 MVVM 패턴을 적용했던 과정에 대해 남겨보았습니다. 그 이후로도 저는 여러가지 ViewModel과 Model을 구현해보았는데요, 매일 마주하는 코드인데도 불구하고 고쳐야 할 점이 매번 새롭게 보이고 있습니다. 쉽지 않은 내용인 만큼 아직도 MVVM 패턴에 익숙해지는 중인 것 같습니다. 최근 한달동안 가장 많이 고민 했던 내용이 MVVM이었던 터라 이번 글도 MVVM과 관련된 내용이지만, 오늘은 저번보다 조금! 더 발전한 내용을 가지고 와보았습니다.

처음 MVVM 패턴을 적용하면서는 “이렇게 작성하면 이런저런 부분에서 재사용이 가능하겠구나!” 하는 대략적인 생각을 했고, 몇가지의 ViewModel과 Model을 더 만들어보며 비슷하게 생긴 컴포넌트들에 ViewModel을 재사용 해보기도 했습니다. 모양새가 유사한 컴포넌트들은 서로 다른 모듈이어도 ViewModel을 재사용하기가 어렵지 않았습니다. 생긴것이 비슷하니 비교적 추상화가 쉬웠기 때문입니다. 하지만 이번에 제가 가져온 컴포넌트는 조금 다르게 생긴 이 두 친구들입니다.

네비게이션 컴포넌트 메뉴 컴포넌트

첫번째 컴포넌트는 메뉴 컴포넌트, 두번째 컴포넌트는 네비게이션 컴포넌트 입니다.

MVVM 패턴에 익숙하신 분들은 어떻게 생각하실지 모르겠지만, 저는 이 두 컴포넌트를 처음 봤을때 “모양이 다르게 생겼는데 어떻게 ViewModel을 재사용하지?”라는 생각을 했습니다. 하지만 ViewModel의 역할을 다시 잘 생각해보면, UI를 보여주는 일을 하는 것도 아니고 특정 데이터를 가져오는 일을 하지도 않습니다. 단순히 어떤 데이터를 어떠한 형태로 전달해주기만 합니다. 즉, 모양이 다르고, 사용하는 데이터가 달라도, 그 기능이 비슷하면 ViewModel은 재사용될 수 있습니다.

1. 공통 기능 추상화하기

이제 두 컴포넌트의 겉모습은 잊어야 합니다. 그래야 추상화가 쉬워지니까요. 우선, 두 컴포넌트가 어떤 기능을 동일하게 수행하는지 생각해봅니다.

기능 추상화

둘 다 어떤 선택지를 클릭했을 때, 해당 선택지에 해당하는 결과물을 내려주는 기능을 합니다. 기능에 집중해보니 이 두 컴포넌트는 같은 기능을 하고 있다는 것을 알게 되었습니다. 이제 이 기능들을 포함하고 있는 ViewModel을 작성해볼까요?

2. 공통 기능을 가진 ViewModel 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function OptionSelectViewModel(data: OptionListType): OptionSelectVM {
	const [selectedOption, setSelectedOption] = useState('')

        // 어떤 메뉴가 클릭되었는지 기록하는 함수
	function handleSelectMenu(option: string) {
		setSelectedMenu(option)
	}

	// 클릭된 메뉴에 따라 알맞은 결과물을 리턴해주는 함수
	function getResult() {
		return (
			data.getList().filter((it) => it.getTitle() === selectedOption) ?? []
		)
	}

	// 전체 메뉴를 선택했을 때 전체보기로 메뉴 리셋을 해주는 함수
	function handleResetOptions(section?: string) {
		setSelectedOption('')
	}

	return {
		handleSelectMenu,
		getResult,
		handleResetOptions,
	}
}

두 컴포넌트의 공통된 기능들을 포함한 ViewModel이 만들어졌습니다. 이제 이 ViewModel에 전달해줄 Model도 작성해봅시다.

3. ViewModel에 전달해줄 Model 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class OptionItem implements OptionItemType {
	private readonly title: string

	constructor(raw: string) {
		this.title = raw.category
	}

	getTitle = () => this.title
}

class OptionList implements OptionListType {
	private readonly list: OptionItemType[]

	constructor(raw: OptionItemType[]) {
		this.list = raw
	}

	getList = () => this.list
}

// 이렇게 구현해버리면 OptionListType 인터페이스를 구현할 수 없어 ViewModel이 인자로 받지 못하게 됨.
class OptionMenu {
	private readonly list: OptionMenuCategoryType[]

	constructor(raw: OptionMenuCategoryType[]) {
		this.list = raw
	}

	getList = () => this.list
}

그런데 여기서 한가지 문제가 생겼습니다. 위에 작성한 Model은 네비게이션 컴포넌트에 사용되는 데이터 모델에만 적용되고 메뉴 컴포넌트에 사용되는 데이터 모델에는 적용되지 않습니다. 네비게이션 컴포넌트는 메뉴를 선택하면 해당 메뉴의 title만 리턴해주면 되는데, 메뉴 컴포넌트는 해당 메뉴의 하위 카테고리 리스트를 내려주어야 하기 때문입니다.

Image Alt 텍스트

Image Alt 텍스트

ViewModel은 OptionListType 타입만 인자로 받는데, 메뉴 컴포넌트용 데이터 모델을 인자로 받는 ViewModel을 새로 만들어야한다면 기존의 ViewModel을 재사용할 수 없게 됩니다. 어떻게 할까 방법을 고민하다가, 클래스는 인터페이스를 다중 구현할 수 있다는 점을 이용해보기로 했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class OptionItem implements OptionItemType {
	private readonly title: string

	constructor(raw: string) {
		this.title = raw.name
	}

	getTitle = () => this.title
}

class OptionList implements OptionListType, OptionItemType {
	private readonly list: OptionItemType[]

	constructor(raw: OptionItemType[]) {
		this.list = raw
	}

	getList = () => this.list

	// 타입을 맞추기 위해 임의의 값을 넣어줌
	getTitle = () => String(this.list)
}

class OptionMenu implements OptionListType {
	private readonly list: OptionItemType[]

	constructor(raw: OptionItemType[]) {
		this.list = raw
	}

	getList = () => this.list
}

OptionListOptionItemType 인터페이스에 맞추기 위해 메서드를 끼워맞추는 과정이 필요했지만, 무사히 두개의 인터페이스를 구현했습니다. 이제 돌려주는 값의 데이터 타입이 달라 OptionList에 들어갈 수 없었던 메뉴 컴포넌트의 데이터가 OptionMenu를 통해 ViewModel에 사용되어질 수 있게 되었습니다. 네비게이션 컴포넌트는 기존의 OptionListOptionMenu 둘 다 사용가능합니다.

1
2
3
4
5
6
7
8
9
10
const navPreData = NavTitleList.map((it) => new OptionItem(it))
const navData = new OptionList(navPreData)

return (
    <Nav 
      data={OptionSelectViewModel(navData)} 
    />
)


1
2
3
4
5
6
7
8
9
10
const menuPreData = MenuCategoryList.map((it) => new OptionList(it))
const menuData = new OptionMenu(menuPreData)

return (
    <Menu
      data={OptionSelectViewModel(menuData)}
    />
)


이제 이렇게 ViewModel을 두개의 View에 재사용할 수 있습니다.

참고로 아래의 컴포넌트도 같은 ViewModel을 사용할 수 있답니다. 기능이 비슷하다는 생각이 드시나요?

Image Alt 텍스트

마치며

어려운 과정은 아니었지만, 보이는 것에서 멀어지고 기능에 집중한 추상화를 진행했다는 점에서 한단계 성장했다는 생각을 하게 된 시간이었습니다. 또, 개인적인 성장 뿐만 아니라 코드적인 측면에서도 중복코드를 줄이고, 유닛 테스트를 진행하기에도 더욱 용이한 발전된 코드가 되었습니다. ViewModel은 비즈니스 로직을 UI에서 분리하고, 더욱 작은 조각으로 만들어주어 ‘단위’별로 테스트가 가능하게 해주는 장점을 가지는데, 이 ViewModel을 재사용하게 되면 테스트를 진행해야 하는 모듈의 수가 적어지기 때문에 단위 테스트를 진행하기가 더욱 편해집니다. 다음에는 ViewModel과 Model로 진행한 단위 테스트에 대해서도 글을 남겨보면 좋을 것 같습니다. 다음 글에서 만나뵙겠습니다 안녕 🤗

This post is licensed under CC BY 4.0 by the author.