여러 프레임워크에서 사용할 수 있는 라이브러리 만들기
여러분은 라이브러리를 만들어봤거나, 만들어야겠다고 생각해본 적이 있나요? 아마 개발자라면 한 번 쯤은 생각해보셨을 것 같습니다. 라이브러리를 만들 때는 해결하려는 문제와 설계 목적, 그리고 개발자의 요구에 따라 고려해야 하는 요소들이 다양합니다. 그중에서도 라이브러리가 다양한 환경에서 사용하려는 목표가 있다면, 범용성을 고려하죠.
이번에는 프론트엔드 개발 환경에서 높은 범용성을 나타내는 용어 중 하나인 “Framework Agnostic”에 대해 이야기 해보려고 합니다.
Framework Agnostic
소프트웨어 엔지니어링에서 "Agnostic"이라는 용어는 소프트웨어가 특정 환경(예: 운영 체제, 하드웨어 플랫폼, 프로그래밍 언어, 프로토콜 또는 기타 기술 영역)에 독립적으로 설계되었다는 속성을 나타냅니다. 따라서 Framework Agnostic은 프레임워크라는 환경에 독립적으로 설계되었다는 속성을 나타내죠.
JavaScript 라이브러리 개발에서 Framework Agnostic 접근 방식은 특정 프레임워크에 종속되지 않는 라이브러리를 작성하는 것을 의미합니다. 이를 통해 라이브러리는 다양한 프레임워크와 호환 가능해지고, 개발자는 보다 넓은 범위의 프로젝트에서 라이브러리를 재사용할 수 있습니다.
조금 더 범위를 축소해 프론트엔드 개발 관점에서 바라본다면, Framework Agnostic하다는 것은 React, Vue와 같은 웹 프레임워크 혹은 라이브러리에 상관 없이 의도된 동작이 잘 실행되도록 설계되었다는 것을 의미합니다.
잘 알려진 Framework Agnostic 라이브러리, Tanstack Query 들여다보기
React를 사용한다면 Tanstack Query라는 라이브러리를 한 번 쯤은 들어보셨을텐데요. JavaScript 애플리케이션에서 데이터를 가져오고 서버와의 데이터 통신, 캐싱, 동기화, 데이터 무효화 등을 간소화해주는 역할을 해요.
Tanstack Query도 Framework Agnostic한 라이브러리 중 하나예요. React Query라는 이름을 가졌던 라이브러리였지만 v4 버전부터 이름을 Tanstack Query로 바꾸고 Framework Agnostic한 라이브러리로 거듭났어요. Vue, Svelte, Solid 등 다양한 프론트엔드 라이브러리 및 프레임워크에서도 사용할 수 있게 되었죠.
Tanstack Query는 어떻게 Framework Agnostic한 라이브러리를 만들 수 있었을까요? 소스 코드를 간단하게 분석해봤어요.
Tanstack Query 레포지토리 구성
Tanstack Query 소스 코드를 보면 모노레포로 구성된 것을 볼 수 있어요. 모노레포에서 운영되는 패키지들이 모여있는 packages
디렉터리를 볼까요?
이 디렉터리에 react-query
, svelte-query
등 Framework에 의존성이 있어보이는 패키지들이 여러 개 존재하네요. 또, query-core
라는 이름에서 알 수 있듯 핵심(core) 로직이 담겨있는 것처럼 보이는 패키지가 하나 있습니다.
react-query와 query-core의 관계성 알아보기
우리의 추측대로라면 query-core
는 프레임워크에 의존성이 없는 핵심 로직들만 들어있고, react-query
는 리액트에 의존성이 있는 로직들이 들어있을 것 같아요. 우리의 추측이 맞는지 이 두 패키지의 package.json를 들여다볼게요.
query-core
package.json를 보면 dependencies
, devDependencies
, peerDependencies
가 없어요. 어떤 것에도 의존하지 않는 독립적인 로직이 담겨있음을 알 수 있어요.
react-query
package.json을 보면 @tanstack/query-core
라는 패키지가 의존성으로 설치되어 있어요. 이를 통해 react-query
는 query-core
라는 패키지에 의존하고 있고, 이 패키지가 제공하는 모듈을 이용한 코드가 존재한다는 것을 알 수 있어요.
여기까지 확인한 점을 정리해볼게요.
query-core
라는 패키지는 Tanstack Query의 핵심 로직을 담고 있어요.react-query
,vue-query
와 같은 곳에서는query-core
를 이용해 React, Vue 등과 같은 프레임워크에 통합하는 패키지로 나누어져 운영되고 있어요.react-query
,vue-query
등은query-core
의 기능을 각 프레임워크(라이브러리)에서 사용할 수 있도록 어댑터 역할을 합니다.react-query
는query-core
에 의존성을 가지며, 이를 통해 핵심 로직을 재사용합니다.react-query
의 Hook(예:useQuery
)은 내부적으로query-core
의 API (예:QueryClient
)를 호출하여 데이터를 가져오고 상태를 관리합니다.react-query
를 사용하는 사용자는 간접적으로query-core
의 API를 호출하는 것이죠.
query-core
는 프레임워크에 종속되지 않은 상태로 모든 핵심 로직을 처리하고, react-query
는 이 로직을 React의 라이프 사이클에 통합하여 React 컴포넌트에서 쉽게 사용할 수 있게 해줘요.
Framework Agnostic한 코어, Framework에 통합하는 어댑터
Tanstack Query의 사례에서는 프레임워크와 관계 없이 동작하는 핵심 로직(query-core
)와 특정 프레임워크에 통합하기 위한 로직(react-query
, vue-query
…)이 철저히 구분되어 있어요.
이들을 각각 코어와 어댑터라고 부르는데요, 코어에는 프레임워크에 종속적이지 않은(Framework Agnostic) 핵심 로직들이 있고, 어댑터에는 React, Vue와 같은 프레임워크에서 사용할 수 있도록 코어의 인터페이스를 이용해 코어의 기능을 해당 프레임워크에서 이용할 수 있도록 통합해주는 로직들이 있어요.
이렇게 코어와 어댑터를 분리하는 구조는 다양한 장점이 있어요. 새로운 프레임워크가 등장하거나 기존 프레임워크가 업데이트되더라도 코어를 변경하지 않고 어댑터의 수정만으로 대응할 수 있어요. 뿐만 아니라 코어와 어댑터 각각이 독립적으로 개발되고 유지보수되기 때문에 개발 효율성을 크게 향상시킬 수 있어요.
반면에 단점도 존재하는데요. 특히 어댑터를 개발해 제공하는 것이 유지보수를 어렵게 만들 수도 있어요. 각 프레임워크마다 별도의 어댑터를 제공해야 하기 때문에, 프레임워크가 업데이트되거나 변경될 때마다 어댑터를 수정하고 테스트해야 하죠. 또, 코어의 인터페이스가 바뀐다면 이에 맞춰서 어댑터들도 변경이 일어나야 해요.
이러한 과정에서 발생하는 시간과 자원의 소모는 무시할 수 없습니다. 만약 코어가 충분히 사용하기 편하고 간단하다면 어댑터를 제공하지 않는 것이 오히려 유리할 수 있습니다. 코어 자체만으로도 다양한 환경에서 쉽게 통합되어 사용할 수 있다면, 불필요한 어댑터 개발과 유지보수에 대한 부담을 줄일 수 있기 때문이죠.
직접 설계해보기
앞서 Tanstack Query의 사례를 통해 Framework Agnostic한 코어와 특정 프레임워크에 맞춘 어댑터를 분리하한 설계 방법을 알아봤는데요. 이제, 이 개념을 실제로 적용하는 과정을 살펴볼게요. 기존에 특정 프레임워크에 강하게 결합된 라이브러리를 재설계하여, 다양한 프레임워크에서 재사용할 수 있도록 만들어볼 거예요.
이런 상황을 가정해볼게요. 한 회사에서 React를 사용하여 계산기 라이브러리를 개발했습니다. 이 라이브러리는 React와 강하게 결합되어 있어, React 환경에서만 사용할 수 있습니다. 즉, React의 컴포넌트와 상태 관리 시스템에 의존적이어서 다른 프레임워크에서는 사용할 수 없는 상태에요.
회사의 성장과 함께 내부적으로 다양한 프레임워크를 사용하는 개발팀이 생기면서 Vue를 사용하는 팀이 새로 생기고, 계산기 라이브러리의 로직이 필요한 프로젝트가 있다고 합니다. 그러나 현재 계산기 라이브러리는 React에서만 동작하기 때문에 Vue 개발팀에서는 사용할 수 없어요.
그래서 기존 계산기 라이브러리를 다양한 프레임워크에서 사용할 수 있도록 재설계하려고 합니다. 먼저 계산기 라이브러리의 코어 기능을 프레임워크에 독립적으로 분리하고, 이후 각 프레임워크에 통합하기 위한 어댑터를 만들거예요.
계산기 라이브러리의 기존 형태
기존의 React 계산기 라이브러리는 아래와 같은 그림으로 설계되어 있다고 가정합니다.
useCalculator
라는 하나의 Hook에 위 그림과 같은 구조로 상태와 API가 구현되어 있어요.
연산 함수들은 React의 useCallback
으로 감싸져 있고, 상태들은 useState
로 감싸져있어요. 오직 React 라이프사이클 안에서만 사용할 수 있다는 뜻이죠.
이렇게 React에 강하게 결합된 useCalculator
에서 핵심 로직인 부분과 React에 통합하기 위한 로직을 코어와 어댑터로 나눌게요. 모든 핵심 로직을 코어로 이동하고, 어댑터에서는 코어와 프레임워크를 연동하는 역할만 가지게 해볼게요.
코어 인터페이스 만들기
코어에는 계산기의 기본 연산 및 상태 관리 기능들을 정의할게요. 아래 그림은 코어가 제공해야하는 API와 내부적으로 관리해야 하는 상태, 그리고 각 API를 호출했을 때 어떤 상태 변화가 일어나야 하는지 보여주고 있어요.
- 더하기, 곱하기, 빼기, 나누기의 연산이 일어나면 결과를 “연산 결과” 상태에 저장해요.
- 연산 API를 호출하여 결과값이 업데이트될 때 이벤트를 발행하고, 그 이벤트를 구독할 수 있는 API도 제공해요.
이벤트를 발행하고 구독할 수 있도록 인터페이스를 노출하는 것은 코어가 각 프레임워크의 상태와 상호작용하는 핵심 방식이에요. 이벤트 발생 및 구독 인터페이스로 코어는 프레임워크에 상태 변화를 알릴 수 있어요. 프레임워크가 해당 이벤트에 반응하여 코어의 상태와 프레임워크의 상태를 동기화할 수 있게 해줍니다.
위에서 설명한 구조를 TypeScript로 의사 코드를 만들어보면 아래와 같아요.
type Unsubscriber = () => void;
class Calculator extends EventTarget {
private _result: number = 0;
get result() {
return this._result;
}
constructor() {
super();
}
updateResult(newResult: number) {
this._result = newResult;
this.dispatchEvent('update', {
detail: this.result
});
}
add(a: number, b: number) {
// ...
this.updateResult(...);
}
subtract(a: number, b: number) {
// ...
this.updateResult(...);
}
multiply(a: number, b: number) {
// ...
this.updateResult(...);
}
divide(a: number, b: number) {
// ...
this.updateResult(...);
}
}
이 클래스에서 이벤트를 쉽게 핸들링하기 위해 자바스크립트 표준인 EventTarget
을 extends합니다.
EventTarget
을 확장하면, 클래스 인스턴스가 DOM 객체처럼 addEventListener
, removeEventListener
, dispatchEvent
메서드를 사용할 수 있게 되어, 이벤트 리스너 등록, 제거, 이벤트 발행 기능을 가지게 돼요.
addEventListener
를 호출하면 내부적으로 이벤트 리스너를 등록하고, Calculator 인스턴스 내부에서 dispatchEvent
를 호출하면 이벤트가 발행됨과 동시에 해당 이벤트를 구독하고 있는 이벤트 리스너들을 호출합니다.
위 의사코드에서도 연산이 끝난 후 마지막에 updateResult()
를 호출하여 결과 값을 갱신하고 dispatchEvent
를 호출해 이벤트를 발행하고 있어요.
const calculator = new Calculator();
// 이벤트 리스너 등록
calculator.addEventListener('update', (event) => {
console.log('결과가 업데이트되었습니다:', event.detail);
});
// 연산 수행
calculator.add(5, 3); // 결과가 업데이트되었습니다: 8
calculator.subtract(10, 4); // 결과가 업데이트되었습니다: 6
calculator.multiply(7, 6); // 결과가 업데이트되었습니다: 42
calculator.divide(20, 5); // 결과가 업데이트되었습니다: 4
위 예시와 같이 이벤트 리스너를 등록하면, 연산을 통해 결과가 업데이트될 때 등록한 리스너들이 호출돼요. EventTarget
스펙에 대한 자세한 설명은 MDN 문서를 참고하세요.
React 어댑터 인터페이스 만들기
React 어댑터는 코어와 React의 상태 및 라이프사이클을 연동합니다.
React 환경에서 코어를 사용할 수 있도록 하기 위해, React의 API로 코어의 API를 감싸 제공합니다.
코어에서 발생하는 이벤트를 구독하여 React 상태를 업데이트하는 로직도 포함돼요. 이 동기화 로직은 React에서 제공하는 useEffect Hook을 이용합니다.
이렇게 코어와 React 상태를 연동하여, 코어에서 발생하는 상태 변화를 실시간으로 반영합니다. 이는 사용자가 연산을 수행할 때마다 React에서 그리는 UI가 즉각적으로 업데이트되도록 해줍니다.
위에서 이야기한 내용들을 정리하면, 아래 다이어그램과 같은 로직으로 작동하게 됩니다.
최종 구조
최종적으로 우리는 리액트에 강결합된 계산기 라이브러리를 Framework Agnostic한 코어와 React 어댑터로 분리했어요. 이제 Vue 어댑터를 만들면 Vue를 사용하는 팀에서도 이 라이브러리를 사용할 수 있게 되었어요.
마치며
오늘 Framework Agnostic이라는 개념에 대해 알아보았어요. Tanstack Query의 사례로부터 코어와 어댑터의 구분을 이해하고, 그 구분에 따라 라이브러리를 만드는 실습까지 했어요.
Framework Agnostic은 어떤 특성을 나타내는 용어일 뿐이에요. 그렇기 때문에 Framework Agnostic한 라이브러리를 만드는 유일한 방법이라는 것은 존재하지 않아요. Framework Agnostic이라는 특성 또한 라이브러리의 설계 목적과 구조에 따라 여러 가지 형태로 나타날 수 있죠.
범용적인 라이브러리를 만들고자 할 때, 반드시 Framework Agnostic한 특성이 고려되어야 하는 것은 아니에요. 범용성의 의미는 여러 가지로 해석될 수 있기 때문이에요. 예를 들어, React에 특화된 라이브러리일지라도, React에서 발생하는 다양한 문제 상황을 범용적으로 해결할 수 있는 기능을 제공한다면 그 자체로도 충분히 범용적이라 할 수 있습니다. 반면, 더 넓은 범위에서 여러 프레임워크에서 사용할 수 있는 라이브러리를 지향한다면 오늘 소개한 Framework Agnostic이라는 특성이 요구될 수도 있죠.
중요한 것은 라이브러리가 목표로 하는 사용자의 요구를 충족시키고, 그들이 직면하는 문제를 효과적으로 해결할 수 있는지 여부입니다. 만약 여러분들이 라이브러리를 만들 때 프레임워크에 대한 범용성이 중요한 특성이 된다면,오늘 소개드린 내용이 도움이 될 수 있길 바랍니다.