Post

신입 프론트엔드 개발자의 MVVM 패턴 적용기

MVVM 패턴은 디자인 패턴의 한 종류로, Model, View, ViewModel을 사용해 UI 관련 로직과 비즈니스 로직을 명확하게 분리합니다. 취업을 준비하던 시기에 진행했던 토이 프로젝트는 규모가 작아 이러한 패턴을 적용할 필요성을 느끼지 못했습니다. 그러나 프론트엔드 개발자로 일을 시작하고 사용자들이 사용할 실제 서비스를 제작하며, 코드의 유지보수성과 재사용성을 높이기 위해 MVVM 패턴을 적용해보는게 좋을것 같다는 코드리뷰 피드백을 받게 되었습니다. 신입 프론트엔드 개발자로써 한번도 써보지 않았던 MVVM 패턴을 어떻게 코드에 적용했는지, 또 리팩토링을 진행하며 어떤 부분을 고민했었는지 글을 통해 나눠보고자 합니다.

리팩토링 과정

초기 코드

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
interface DropdownListProps {
  data: string[];
  placeholder: string;
}

function DropdownList(props: DropdownListProps) {
  const [isListOpen, setIsListOpen] = useState<boolean>(false);
  const [selectedOption, setSelectedOption] = useState<string>("");
  const { data, placeholder } = props;

  useEffect(() => {
    setSelectedOption(placeholder);
  }, []);

  return (
    <DropdownListContainer onClick={() => setIsListOpen(!isListOpen)}>
      {selectedOption}
      {isListOpen ? (
        <DropdownListOptionContainer>
          {data.map((option) => (
            <DropdownListOption key={option} onClick={() => setSelectedOption(option)}>
              {option}
            </DropdownListOption>
          ))}
        </DropdownListOptionContainer>
      ) : (
        <div />
      )}
    </DropdownListContainer>
  );
}

export default DropdownList

가져온 코드는 공통 컴포넌트로 제작한 드롭다운 리스트 컴포넌트입니다. 제일 처음에 작성했던 코드인데, 컴포넌트를 사용하는 곳에서 서버 데이터를 props로 넘겨받고 그 데이터를 바로 TSX에 적용해주는 형식입니다. 하나의 파일에서 컴포넌트의 모든 것을 컨트롤 하고 있기 때문에 당연히 UI 로직과 비즈니스 로직은 구분되어있지 않습니다.

이 컴포넌트에 MVVM 패턴을 적용해보라는 과제를 받고 로직을 어떻게 나눠야 하나 많은 고민을 했습니다. View가 가져가야하는 부분은 TSX가 리턴되는 부분으로 비교적 명확하게 보였으나, 데이터를 다루는 비즈니스 로직이 보이지 않았기 때문입니다. 그리고 ViewModel을 실제로 어떻게 구현해야하는지 감이 잡히지 않았습니다. 그래서 우선 데이터를 불러오는 부분을 Model로 분리해보는 일부터 시작해보기로 했습니다. View는 어느정도 완성이 된 상태이니 Model을 작성하고 나면 ViewModel의 역할도 보이지 않을까? 하는 생각이었습니다.

1차 리팩토링

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Model
const rawDataCountries = ['Korea', 'Japan', 'Singapore', 'Australia']

interface DropdownListItem {
    getTitle: () => string
    isSelected: () => boolean
    setSelected: (isSelected: boolean) => void
    isSelectable: () => boolean
}

class DropdownListItemCountry implements DropdownListItem {
    private readonly title: string

    private selected: boolean

    private readonly selectable: boolean = true

    constructor(title: string) {
        this.title = title
        this.selected = false
        if (!title) this.selectable = false
    }

    getTitle = () => this.title

    isSelected = () => this.selected

    setSelected = (selected: boolean) => {
        this.selected = selected
    }

    isSelectable = () => this.selectable
}

interface DropdownListProcessedData {
    getCountryList: () => DropdownListItem[]
}

class ProcessedData implements DropdownListProcessedData {
    private countryList: DropdownListItem[]

    constructor() {
        this.countryList = rawDataCountries.map(
            (item) => new DropdownListItemCountry(item)
        )
    }

    getCountryList = () => this.countryList
}

export default ProcessedData

1차 리팩토링 후 Model로 분리한 코드입니다. 문자열 배열 형식의 더미 데이터를 미리 정의해두고, 해당 데이터가 서버에서 넘어왔다고 가정한 후 코드를 작성했습니다.

데이터를 불러와 따로 분리해놓고 나니, 해당 선택지가 선택이 된 상태인지, 선택이 가능한 상태인지에 관한 정보도 함께 들어있으면 좋겠다는 생각이 들었습니다. 그래서 속성으로는 제가 원하는 정보를 저장하고, 행위는 해당 객체가 가진 정보를 수정할 수 있도록 각각의 선택지들을 객체로 만들어주는 클래스를 만들어주었습니다. 저는 간단하게 어떤 선택지인지 알려주는 title과 해당 선택지가 선택되었는지 알려주는 selected, 선택지가 선택이 가능한지 알려주는 selectable 속성 세 개를 정의해주었습니다. 그리고 세 개의 속성을 리턴해주는 메서드와 선택지가 선택되었을때 selected 속성값을 바꿔주는 setSelected 메서드도 포함해주었습니다. 단순하게 이름만 가지던 선택지들이 selectedselectable 속성을 가지게 됨으로써 드롭다운 리스트에서 어떤 선택지를 선택했을 때, 다른 컴포넌트에서 선택된 옵션을 받아 제품 목록을 보여주는 등의 방법으로 재사용될 수 있게 되었습니다.

그리고 최종적으로 각각의 선택지 객체들을 리스트로 모아주는 객체를 만들어 ViewModel이 바로 활용할 수 있는 형태를 반환하도록 했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ViewModel
import { useState } from "react";
import ProcessedData from "@components/dropdown-list/model";

function DropdownListViewModel() {
  const [isListOpen, setIsListOpen] = useState<boolean>(false);
  const [selectedCountryOption, setSelectedCountryOption] = useState<string | undefined>(undefined);
  const countryList = new ProcessedData().getCountryList();

  function handleListDisplay() {
    setIsListOpen(!isListOpen);
  }

  function handleSelectOption(idx: number, list: string) {
    if (list === "countryList") setSelectedCountryOption(countryList[idx].getTitle());
  }

  return { countryList, isListOpen, selectedCountryOption, handleListDisplay, handleSelectOption };
}

export default DropdownListViewModel;

View와 Model의 윤곽이 잡히고 나니 ViewModel도 구현이 가능했습니다. View와 Model이 직접적으로 연결이 된다면 서로에 대한 의존성이 높아지기 때문에, 단순한 작업이라도 ViewModel 통하도록 해주었습니다. ViewModel이 Model로부터 데이터를 전달받아 View로 전달해주고, View에서 Model을 변경하는 인터렉션이 일어나면 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
32
33
34
35
36
37
38
39
40
// View
// ... Styled components 등 다른 import들
import useDropdownListViewModel from "@components/dropdown-list/view-model";

interface DropdownListProps {
  placeholder: string;
}

function DropdownList(props: DropdownListProps) {
  const {
    countryList,
    isListOpen,
    selectedCountryOption,
    handleListDisplay,
    handleSelectOption,
  } = useDropdownListViewModel();
  const { placeholder } = props;

  const text = !selectedCountryOption ? placeholder : selectedCountryOption;

  return (
    <DropdownListContainer onClick={handleListDisplay}>
      {text}
      {isListOpen ? (
        <DropdownListOptionContainer>
          {countryList.map((el, idx) => (
            <DropdownListOption
              key={el.getTitle()}
              onClick={() => handleSelectOption(idx, "countryList")}
            >
              {el.getTitle()}
            </DropdownListOption>
          ))}
        </DropdownListOptionContainer>
      ) : (
        <div />
      )}
    </DropdownListContainer>
  );
}

View는 이전과 큰 차이는 없지만, 데이터를 받아오고 수정하는 모든 로직을 ViewModel을 통해서 받게 되었습니다.

첫 리팩토링을 거친 후 받은 피드백은 두 가지였습니다.

첫 번째 피드백은 Model과 ViewModel이 특정 데이터(Country 선택지 데이터)에 맞춰져있다는 것이었습니다. 그렇게 되면 Model과 ViewModel을 재사용하기가 힘들기 때문에 다양한 선택지 데이터를 적용하여 사용할 수 있도록 수정해달라고 하셨습니다.

두 번째 피드백은 Model에서 리스트를 만들어주는 클래스가 의미없는 인스턴스를 생성한다는 것이었습니다. 선택지를 추가한다거나 삭제하는 등 리스트 데이터를 변경하는 로직이 필요하다면 리스트를 다루는 클래스가 필요하겠지만, 현재로서는 그런 로직이 없으므로 클래스를 사용하지 않는 것이 좋겠다고 하셨습니다.

2차 리팩토링

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
34
35
36
37
38
// Model
interface DropdownListItem {
  getTitle: () => string;
  isSelected: () => boolean;
  setSelected: (isSelected: boolean) => void;
  isSelectable: () => boolean;
}

class DropdownListItemCreation implements DropdownListItem {
  private readonly title: string;

  private selected: boolean;

  private readonly selectable: boolean = true;

  constructor(title: string) {
    this.title = title;
    this.selected = false;
    if (!title) this.selectable = false;
  }

  getTitle = () => this.title;

  isSelected = () => this.selected;

  setSelected = (selected: boolean) => {
    this.selected = selected;
  };

  isSelectable = () => this.selectable;
}

const rawDataCountries = ["Korea", "Japan", "Singapore", "Australia"];

const countryData = rawDataCountries.map((it) => new DropdownListItemCreation(it));

export type { DropdownListItem };
export { countryData };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ViewModel
import { useState } from "react";
import { DropdownListItem } from "@components/dropdown-list/model";
import { ViewModelInterface } from "@components/dropdown-list";

function DropdownListViewModel(data: DropdownListItem[]): ViewModelInterface {
  const [isListOpen, setIsListOpen] = useState<boolean>(false);
  const [selectedOption, setSelectedOption] = useState<string | undefined>(undefined);
  const dropdownList = data;

  function handleListDisplay() {
    setIsListOpen(!isListOpen);
  }

  function handleSelectOption(idx: number) {
    setSelectedOption(dropdownList[idx].getTitle());
  }

  return { dropdownList, isListOpen, selectedOption, handleListDisplay, handleSelectOption };
}

export default DropdownListViewModel;
export type { ViewModelInterface };

피드백을 받았던 내용을 바탕으로 두 번째 리팩토링을 진행했습니다. 우선 해당 Model이 Country 데이터만 다루는 것처럼 보이지 않도록 이름에서 Country라는 단어를 제거하였습니다. 그리고 Country 리스트 데이터만 리턴하는 클래스를 삭제하고, 필요한 데이터는 변수에 바로 담아 map 함수로 요소들을 인스턴스화 시켜 배열에 담아 export 해주었습니다. 만약 Country 데이터가 아닌 Fruit 데이터가 사용된다면 Fruit 리스트 데이터를 담을 변수를 선언한 후, 같은 클래스로 인스턴스화 하여 재사용할 수 있겠죠?

ViewModel에서도 특정 데이터를 지칭하는 단어가 없어져 혼란을 방지하고 데이터와 상관없이 View와 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
32
33
34
35
36
37
38
39
40
41
42
43
// View
// ... Styled components 등 다른 import들
import { DropdownListItem } from "@components/dropdown-list/model";

interface ViewModelInterface {
  dropdownList: DropdownListItem[];
  isListOpen: boolean;
  selectedOption?: string;
  handleListDisplay: () => void;
  handleSelectOption: (idx: number) => void;
}

interface DropdownListProps {
  placeholder: string;
  viewModelProps: ViewModelInterface;
}

function DropdownList(props: DropdownListProps) {
  const { placeholder, viewModelProps } = props;
  const { dropdownList, isListOpen, selectedOption, handleListDisplay, handleSelectOption } = viewModelProps;

  const text = !selectedOption ? placeholder : selectedOption;

  return (
    <DropdownListContainer onClick={handleListDisplay}>
      {text}
      {isListOpen ? (
        <DropdownListOptionContainer>
          {dropdownList.map((el, idx) => (
            <DropdownListOption key={el.getTitle()} onClick={() => handleSelectOption(idx)}>
              {el.getTitle()}
            </DropdownListOption>
          ))}
        </DropdownListOptionContainer>
      ) : (
        <div />
      )}
    </DropdownListContainer>
  );
}

export default DropdownList;
export type { ViewModelInterface };

View 또한 특정 데이터에 얽혀있는 컴포넌트가 아니어서, ViewModel로부터 받아온 데이터의 이름을 바꿔준 것 만으로도 여러곳에서 다양하게 사용하기 편해졌습니다.

그리고, View와 ViewModel을 연결할 때는 View 내부에서 ViewModel을 불러오는 것 보다 컴포넌트를 사용하는 곳에서 props를 통해 ViewModel을 넘겨주는 것이 테스트를 진행하기 용이하다고 알려주셔서 연결방법을 바꿔주었습니다.

1
<DropdownList placeholder={"선택해주세요"} viewModelProps={DropdownListViewModel(countryData)} />

컴포넌트를 사용할 때 이렇게 ViewModel에 데이터를 담은 후 props로 전달해주게 됩니다.

3차 리팩토링

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ViewModel
import { useState } from "react";
import { DropdownListItem } from "@components/dropdown-list/model";
import { DropdownViewModelInterface } from "@components/dropdown-list";

function DropdownListViewModel(data: DropdownListItem[]): DropdownViewModelInterface {
  // selectedOption 상태값의 경우 단순히 어떤 선택지가 골라졌는지 view에서 보여주기 위해 사용하는 것뿐만 아니라, form을 제출한다던지 하는 경우에 그 값을 넘겨주기 위해 사용될 가능성이 있다.
  // 그렇게 넘겨진 값은 다른 컴포넌트의 뷰를 조정하는 과정에서 쓰일 수 있다.
  // 따라서 이 상태값은 특정 뷰에서 관리되는 것이 아니라 뷰모델에서 관리한다.
  const [selectedOption, setSelectedOption] = (useState < string) | (undefined > undefined);
  const dropdownList = data;

  function handleSelectOption(idx: number) {
    setSelectedOption(dropdownList[idx].getTitle());
    dropdownList.map((it: DropdownListItem, index: number) => it.setSelected(idx === index));
  }

  return { dropdownList, selectedOption, handleSelectOption };
}

export default DropdownListViewModel;
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
34
35
36
37
38
39
40
41
42
// View
// ... Styled components 등 다른 import들
import { DropdownListItem } from "@components/dropdown-list/model";

interface DropdownViewModelInterface {
  dropdownList: DropdownListItem[];
  selectedOption?: string;
  handleSelectOption: (idx: number) => void;
}

interface DropdownListProps {
  placeholder: string;
  viewModelProps: DropdownViewModelInterface;
}

function DropdownList(props: DropdownListProps) {
  const [isListOpen, setIsListOpen] = useState<boolean>(false);
  const { placeholder, styleProps, viewModelProps } = props;
  const { dropdownList, selectedOption, handleSelectOption } = viewModelProps;

  const text = !selectedOption ? placeholder : selectedOption;

  return (
    <DropdownListContainer onClick={() => setIsListOpen(!isListOpen)}>
      {text}
      {isListOpen ? (
        <DropdownListOptionContainer>
          {dropdownList.map((el, idx) => (
            <DropdownListOption key={el.getTitle()} onClick={() => handleSelectOption(idx)}>
              {el.getTitle()}
            </DropdownListOption>
          ))}
        </DropdownListOptionContainer>
      ) : (
        <div />
      )}
    </DropdownListContainer>
  );
}

export default DropdownList;
export type { DropdownViewModelInterface };

ViewModel 안의 코드를 보다보니 UI 로직이 포함되어있는 것이 보였습니다. 리스트가 열렸는지 확인하는 로직은 View가 관리해야 할 영역이라고 생각되어 ViewModel에 있던 코드를 View로 옮겨주었습니다. 이를 통해 UI 로직은 오롯이 View에서 관리하게 되었고, 불필요하게 ViewModel을 거쳐 화면을 바꾸는 코드가 삭제되어 간결해졌습니다.

마치며

몇 단계를 거쳐 완벽하지는 않지만 Model-View-ViewModel의 역할이 구분된 코드가 만들어졌습니다. MVVM 패턴을 적용하면서 가장 어려웠던 점은 ViewModel의 역할을 찾는 것이었습니다. 이전에는 데이터를 UI에 바로 적용했기 때문에 ViewModel이라는 것을 한번도 사용해보지 않았기 때문인 것 같습니다. 하지만 이렇게 코드를 나누고 보니 디자인이 다른 새로운 드롭다운 리스트 컴포넌트를 만들게 되더라도 미리 만들어놓은 ViewModel과 Model을 쉽게 재사용하면 되겠다는 생각이 들었습니다. 그리고 중복되는 코드가 줄어들면서 유지보수도 훨씬 쉬워질 것 같습니다. 저는 이 경험을 시작으로, 복잡한 로직을 더 쉽게 다룰수 있는 방법을 계속 고민하고 공부해보아야겠습니다. 😊

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