프론트엔드, 서버로부터 독립을 선포하다(2) - 뷰 모델로 데이터 모델 의존성 줄이기
프론트엔드는 애플리케이션에서 클라이언트 단에 위치하며 비즈니스 로직을 포함한 코드 영역을 말하며, 백엔드는 서버 단에 위치한 코드 영역을 의미한다. 이 두 요소는 각각 독립적으로 변경되고 발전하므로, 대다수의 애플리케이션 개발에서는 물리적으로 분리된 각기 다른 코드베이스를 가지는 경우가 많다.
그러나, 많은 애플리케이션들이 서버에서 데이터를 저장하고 클라이언트에서는 데이터를 캐시 하는 구조를 가지고 있기 때문에, 프론트엔드는 애플리케이션의 목표를 달성하기 위해 필연적으로 서버에 의존적일 수밖에 없다. 그리고 이런 의존성은 프론트엔드 개발과 테스트를 어렵게 하며, 변경에 취약하게 만든다.
API 명세는 이러한 의존성을 약하게 만드는 방법이다. 하지만 그 자체로는 충분하지 않다. API 명세를 만드는 데에도 시간이 걸리고, 명세는 명세일 뿐 실제 동작을 반영하지 않기 때문이다. 또한 API 명세는 약속일뿐이므로 어떤 상황에 따라 언제든지 변경될 수 있다.
이전 글에서는 프론트엔드와 서버 간의 세 가지 의존성 - 개발 의존성, 테스트 의존성, 데이터 모델 의존성에 대해 알아보고, 모킹 라이브러리인 MSW를 사용하여 모킹 계층을 구현하여, 개발 의존성과 테스트 의존성을 제거하는 방법을 살펴봤다. 이번 글에서는 뷰 모델이라는 방법을 통해 프론트엔드와 서버 간의 데이터 모델 의존성을 줄이는 방법을 알아보고, 이번 시리즈를 마무리하려고 한다.
데이터 모델 의존성을 제거하는 방법
대부분의 애플리케이션 상태는 서버에 저장되므로, 프론트엔드는 백엔드로부터 데이터를 받아 사용한다. 이러한 구조로 인해 프론트엔드는 서버의 응답값인 데이터 모델에 의존하게 된다. 이로 인해 프론트엔드는 API 명세 변경과 같은 예상치 못한 변경에 항상 노출되어 있다. 즉, 프론트엔드 코드가 변경되지 않았음에도 불구하고, 코드가 의도한 대로 작동하지 않을 수 있는 상황이 발생할 수 있는 것이다.
프론트엔드가 백엔드의 데이터 모델을 이용하는 것은 필수적이므로, 데이터 모델 의존성을 완전히 제거하는 것은 불가능하다. 하지만, 데이터 모델 의존성이 프론트엔드의 핵심 컴포넌트까지 이어지는 것은 제어할 수 있다. 이렇게 하면 API 변경에 대응하는 것이 훨씬 쉬워진다.
데이터 모델 의존성을 줄이는 방법의 원칙은 간단하다. 서버측 데이터 모델을 추상화하는 것이다. 즉, 서버 측 데이터 모델을 추상화 계층을 통해 프론트엔드 전용 데이터 모델로 변환하는 것이다. 이러한 추상화 계층은 프론트엔드와 서버 사이, 또는 프론트엔드 내에 위치시킬 수 있다. 프론트엔드와 서버 사이에 추상화 계층을 두는 경우, 중간 서버를 구축하는 프론트엔드를 위한 백엔드를 구성하는 것과 비슷하다. 이는 유효한 방법이지만, 추가적인 인프라 구성 및 관리가 필요하므로 특정 상황에서는 적용이 어려울 수 있다.
추상화 계층을 인프라가 아닌 프론트엔드 애플리케이션 코드로 관리하는 방법도 있다. 이 경우, 프론트엔드의 하위 계층은 서버 측 데이터 모델에 의존하지만, 추가 인프라 구성 없이도 프론트엔드의 핵심 컴포넌트와 서버 측 데이터 모델 의존성을 제거할 수 있다. 이 방법을 필자는 프론트엔드 특성을 고려하여 '뷰 모델'이라고 부르고 있다.
뷰 모델의 장점은 이것뿐만이 아니다. 클래스로 구성된 뷰 모델을 사용하면, 데이터 기반의 다양한 쿼리 로직을 해당 클래스에 캡슐화할 수 있다. 또한, 복잡한 뷰 처리 로직이 있을 경우 다형성을 활용해 이를 쉽게 처리할 수 있다. 즉, 프론트엔드에 객체 지향 개념을 도입하는 것을 용이하게 해 준다.
뷰모델이란?
뷰 모델은 쉽게 서버 측 데이터 모델을 캡슐화하여, 컴포넌트가 해당 데이터 모델의 정보를 알 필요가 없게 만드는 것이다. 즉, 컴포넌트는 서버 측 데이터 모델의 자료 구조나 API의 변동 사항을 알 필요가 없다. 뷰 모델을 사용하면, 서버 측 데이터 모델의 자료 구조와 무관하게 필요한 데이터를 쉽게 확장할 수 있다. 자바스크립트의 접근자 함수를 이용해 단일 접근 원칙을 준수하면, 데이터가 서버에서 온 값인지 아니면 프론트엔드에서 계산한 값인지 컴포넌트가 알 수 없게 만들 수 있기 때문이다.
예를 들어, 아래와 같은 데이터 구조를 API 응답으로 받는다고 생각해 보자. 사용자가 커스텀한 예측 지표에 대한 정보를 보여주기 위해 서버로부터 데이터를 받아오고 있다.
export type CustomForecastIndicatorResponse = {
id: string;
name: string;
type: IndicatorType;
targetIndicatorName: string;
// ...
};
서버에서 받은 응답값을 별도로 캡슐화하지 않으면, 컴포넌트에서 다음과 같이 사용할 것이다.
type CustomForecastIndicatorListItemProps = {
customForecastIndicatorId: string;
};
export default function CustomForecastIndicatorListItem({ customForecastIndicatorId}: CustomForecastIndicatorListItemProps) {
const data: CustomForecastIndicatorResponse = useCustomForecastIndicator(customForecastIndicatorId)
const title = `${data.name}(${data.targetIndicatorName})`
return (
<ListItem>
<ListItem.Title>
<div className="py-1 pl-4">{title}</div>
</ListItem.Title>
<ListItem.Content>
// ...
</ListItem.Content>
</ListItem>
);
}
그러나 어느 날, API 명세가 변경되어 응답 값이 바뀌었다. 도메인에 대한 지식이 점점 증가하면서, 예측 대상인 타겟 지표에 대한 정보가 이름 외에도 더 많이 필요하다는 것을 깨달은 것이다.
export type CustomForecastIndicatorResponse = {
id: string;
customForecastIndicatorName: string;
type: IndicatorType;
// API 명세 변경, targetIndicatorName -> targetIndicator
targetIndicator: IndicatorResponse;
// ...
};
이 변경은 프론트엔드의 핵심 부인 컴포넌트까지 영향을 미치게 된다. 데이터의 변경된 부분을 사용하는 모든 부분을 찾아서 수정해줘야 하는 것이다.
export default function CustomForecastIndicatorListItem({ customForecastIndicatorId}: CustomForecastIndicatorListItemProps) {
const data: CustomForecastIndicatorResponse = useCustomForecastIndicator(customForecastIndicatorId)
// 컴포넌트 코드도 변경되어야 함
const title = `${data.name}(${data.targetIndicator.name})`
return (
<ListItem>
<ListItem.Title>
<div className="py-1 pl-4">{title}</div>
</ListItem.Title>
<ListItem.Content>
// ...
</ListItem.Content>
</ListItem>
);
}
예시에서는 하나의 컴포넌트만 변경하면 되었지만, targetIndicatorName를 사용하는 컴포넌트가 많을 경우 모두 수정해야 한다. 타입스크립트의 도움을 받으면 문제가 있는 부분을 쉽게 찾을 수 있지만, 변경 대상이 많을수록 변경 과정에서 발생할 수 있는 문제를 예측하기 어려워진다.
가장 헤로운 점은, 컴포넌트의 목적 달성과 전혀 관련이 없는 변화임에도 컴포넌트가 변경의 영향을 받는다는 것이다. 컴포넌트 입장에서는 타겟 지표의 이름을 사용하는데, data.targetIndicator.name 이든 data.targetIndicatorName 이든 전혀 알 필요가 없다.
이제 뷰 모델을 사용하여 컴포넌트를 이 불필요한 변경으로부터 보호해 보자. 뷰 모델이라는 용어가 다소 거창해 보일 수 있지만, 단순히 클래스를 활용하여 응답 데이터를 캡슐화하는 것이다. 처음 만들 때는 데이터를 매핑하는 수준으로 간단하게 만들면 된다.
// view model
export class CustomForecastIndicator {
readonly id: string;
readonly name: string;
readonly type: IndicatorType;
readonly targetIndicatorName: string;
// ...
constructor({
id,
name,
type,
targetIndicatorName,
// ...
}: CustomForecastIndicatorResponse) {
this.id = id;
this.name= customForecastIndicatorName;
this.type = type;
this.targetIndicatorName= targetIndicatorName;
}
}
// response 데이터를 뷰 모델로 변환해주는 함수
export const convertCustomForecastIndicatorViewModel = (customForecastIndicatorResponse: CustomForecastIndicatorResponse) => {
return new CustomForecastIndicator(customForecastIndicatorResponse);
};
이제 훅에서 변환 함수를 사용하여 응답 데이터를 뷰 모델로 변환한 후, 반환하여 컴포넌트가 뷰 모델을 사용하도록 한다.
export const useCustomForecastIndicatorViewModel = (customForecastIndicatorId: string) => {
const { data: customForecastIndicatorList, isValidating } = useFetchCustomForecastIndicatorList();
const customForecastIndicator = customForecastIndicatorList.find((i) => i.id === customForecastIndicatorId)
// 뷰 모델로 변환한다
const convertedCustomForecastIndicator = useMemo(() => {
if (!customForecastIndicator) return undefined;
return convertCustomForecastIndicatorViewModel(customForecastIndicator );
}, [customForecastIndicator]);
// 뷰 모델을 return 한다.
return {
customForecastIndicator: convertedCustomForecastIndicator
// ...
};
};
이때 뷰 모델을 사용하기 전과 후의 컴포넌트 코드에는 차이가 없다. 즉, 컴포넌트는 뷰 모델의 사용 여부나 사용하는 데이터 모델을 알 필요가 없는 것이다. 그저 인터페이스를 통해 목적을 달성하는데 필요한 데이터를 사용하면 된다.
이제 뷰 모델을 사용하는 상황에서 아까와 동일한 API 변경이 발생했다고 가정해 보자. 먼저 변경된 데이터 구조에 맞게 뷰 모델을 수정해 보자. 그리고 변경되어 사라진 속성을 접근자 함수를 통해 노출시켜 주면 된다.
// 변경 존재
export class CustomForecastIndicator {
readonly id: string;
readonly name: string;
readonly type: IndicatorType;
readonly targetIndicator: IndicatorResponse;
// ...
constructor({
id,
name,
type,
targetIndicator,
// ...
}: CustomForecastIndicatorResponse) {
this.id = id;
this.name= customForecastIndicatorName;
this.type = type;
this.targetIndicator= targetIndicator;
}
// API 변경으로 사라진 필드를 계산된 값으로 만들고 접근자 함수를 통해 노출한다.
get targetIndicatorName() {
return this.targetIndicator.name
}
}
이전에는 변경사항이 컴포넌트 코드에 영향을 주었지만, 뷰 모델을 사용하면 컴포넌트를 이전과 같이 사용해도 문제가 없다. 즉, API의 변경이 더 이상 컴포넌트에 영향을 미치지 않게 된 것이다.
// 컴포넌트 코드는 이전과 동일하다
export default function CustomForecastIndicatorListItem({ customForecastIndicatorId}: CustomForecastIndicatorListItemProps) {
const data = useCustomForecastIndicatorViewModel(customForecastIndicatorId)
// 변경이 발생해도 이전과 같이 사용하면 된다.
const title = `${data.name}(${data.targetIndicatorName})`
return (
<ListItem>
<ListItem.Title>
<div className="py-1 pl-4">{title}</div>
</ListItem.Title>
<ListItem.Content>
// ...
</ListItem.Content>
</ListItem>
);
}
다형성으로 복잡한 로직 쉽게 표현하기
프론트엔드 개발을 진행하다 보면, 복잡한 뷰 로직을 처리해야 하는 경우가 있다. 예를 들어, 필자의 경우 지표의 단위 유형에 따라 값을 변환하는 상황이 그러한 경우였다. 간단한 뷰 로직은 비즈니스 훅에서 처리할 수 있지만, 복잡해질 경우 비즈니스 훅의 길이가 과도하게 길어지며, 새로운 요구사항이 추가될 때 이를 반영하기가 점점 어려워진다.
이런 복잡한 로직을 처리할 때는 객체 지향적 코드 작성이 큰 도움이 된다. 복잡한 로직 처리를 전담하는 객체를 생성하고 이 객체에 처리를 위임하는 것이다. 뷰 모델은 이러한 객체 지향적 코드를 작성하기 좋은 위치이다. 이를 통해 애플리케이션의 로직을 적절한 객체에 분산시킬 수 있어, 비즈니스 로직이 과중되는 것을 방지할 수 있다.
아래의 코드는 지표의 실제 값에 대한 뷰 모델 코드이다. 지표의 단위 유형에 따라 값을 변환하여 전달해야 하는데, 각 단위 유형의 변환 로직이 상당히 복잡하다 또한, 단위 유형이 요구사항에 따라 계속 확장될 수 있으므로, 각 유형별로 로직을 처리하는 클래스를 생성하고 처리를 위임하였다.
export class IndicatorValues {
readonly id: string;
readonly values: IndicatorValueItem[] = [];
private _unitCalculator: UnitCalculator;
constructor({ id, values, unitType }: IndicatorValueResponse) {
this.id = id;
this.values = values;
this.unitType = unitType;
}
private set unitType(unitType: UnitType) {
this._unitCalculator= createUnitCalculator(unitType);
}
get unitTypeValues() {
return this._unitCalculator.calculate(this.values)
}
get formattedValues() {
return this.unitTypeValues.map((item, index) => ({
date: item.date,
value: this.values[index].value,
displayValue: item.value,
}));
}
}
// 단위 유형에 따라 값을 변환해주는 계산기 팩토리 함수
export function createUnitCalculator(unitType: UnitType): UnitCalculator {
switch (unitType) {
case 'index':
return new IndexUnitCalculator();
case 'MoM':
return new MoMUnitCalulator();
case 'YoY':
return new YoYUnitCalulator();
default:
return new DefaultUnitCalculator();
}
}
이런 식으로 뷰 모델은 프론트엔드에서 객체 지향을 쉽게 적용할 수 있게 해주는 장점도 있다.
뷰 모델 사용 시 주의할 점
뷰 모델은 공짜가 아니다
뷰 모델은 서버와의 데이터 의존성을 줄이고, 프론트엔드에서 객체 지향적 코드를 쉽게 작성할 수 있는 매우 유용한 기법이지만, 뷰 모델 역시 은 탄환은 아니다. 뷰 모델의 효과를 제대로 발휘하려면, 서버 측의 모든 중첩된 응답 데이터 유형을 뷰 모델로 변환해야 하지만, 이로 인해 상당한 양의 보일러 플레이트 코드가 발생한다
변경이 거의 예상되지 않거나 변경으로 인한 영향이 크지 않다면, 뷰 모델을 사용하지 않아도 된다. 하지만, 나는 이런 경우에도 컴포넌트에서 response 데이터를 사용하고 있다는 것을 명확히 하기 위해 'response' 접미사가 붙은 타입을 그대로 사용하는 것을 추천한다. 예를 들어, 'LectureResponse' 데이터를 'Lecture' 타입으로 정의하면 클라이언트에서 정의한 것처럼 잘못 이해될 수 있으므로 주의해야 한다.
뷰 모델을 가장 효과적으로 사용할 수 있는 시기는 개발 초기 단계이다. 이 시기에는 API 변경이 빈번하게 발생하므로, 뷰 모델의 장점을 가장 잘 체감할 수 있을 것이다.
뷰 모델은 불변해야 한다.
사실, 프론트엔드 코드 스타일에 있어서는 당연한 이야기이지만, 객체 지향 코드에 익숙하지 않은 사람들은 실수할 수 있기 때문에 명시하였다. 프론트엔드에서는 뷰 모델의 속성 변화를 알 수 없으므로, 뷰 모델은 불변 객체로 다루어야 한다. 그리고 정말 특별한 사정이 없는 한, 세터 함수를 만들지 않아야 한다.
또한, 뷰 모델에 비즈니스 로직을 추가하고 싶다는 생각이 들 수 있는데. 이럴 경우, 상태를 변경하는 것이 아니라 새로운 객체를 반환하도록 하자.
시리즈를 마무리하며
이번 시리즈는 예전부터 작성하고 싶었던 것이라서, 마무리하게 되어 기분이 매우 좋다. 마치 독립 선언이란 표현이 백엔드를 악당처럼 묘사하는 것처럼 보일 수 있지만, 서로 다른 물리적 코드베이스를 구축하는 개발 관행 상, 프론트엔드와 백엔드 간의 의존성을 줄이는 것은 서로를 위해 좋다. 이런 독립성은 서로 간의 개발 효율성을 높여줄 뿐만 아니라, 더 중요한 사항에 집중하고 고품질의 커뮤니케이션이 오가도록 도와줄 것이기 때문이다.
여기서, 오해해서 절대 안 되는 것은, 프론트엔드와 백엔드가 건전한 관계를 유지하고, 서로의 독립성을 유지하기 위해서는 무엇보다 서로 간의 효율적인 커뮤니케이션이 중요하다는 점이다. 여기서 필자가 작성한 방법들은 단지 보완 수단일 뿐이다. 따라서 백엔드와 프론트엔드 간에 문제가 지속적으로 발생한다면, 이 문서에서 언급한 기술을 바로 적용하여 백엔드와 담을 쌓기보다는, 먼저 효율적인 커뮤니케이션이 이루어지고 있는지 확인해야 한다. 그 후에 이 문서에서 제시된 방법들을 적용해 본다면 더 효율적으로 프론트엔드 코드베이스를 관리할 수 있을 것이다,
'개발 이야기' 카테고리의 다른 글
복잡한 애플리케이션을 위한 프론트엔드 아키텍처 (33) | 2024.07.20 |
---|---|
프론트엔드 렌더링 패러다임의 변화와 의미(ft. RSC, Streaming SSR, PPR) (35) | 2024.07.09 |
프론트엔드, 서버로부터 독립을 선포하다(1) - MSW로 개발 및 테스트 의존성 줄이기 (34) | 2024.06.26 |
지속 가능하고 효율적인 코드 리뷰를 하는 방법 (33) | 2024.06.17 |
Cypress로 E2E 테스트 작성하기(ft. App action vs Page object model) (30) | 2024.06.12 |