서드파티 라이브러리, 그냥 쓰지 말고 래핑하자

프로젝트의 지속가능성을 위한 작은 실천

자바스크립트 생태계에서 라이브러리를 활용하는 것은 공공연하다. 회사 프로젝트던 개인 프로젝트던 package.json을 한번 열어보자. 필연적으로 스크롤을 내려야 할 정도로 의존성이 많을 것이라고 장담할 수 있다. 모든걸 직접 개발할 수는 없지 않은가. 이는 node.js 생태계의 특성이 아니라 오픈소스 생태계의 특성이다.

서드파티 라이브러리를 프로젝트에 도입할 때면 늘 고민이 된다. 당장 필요한 기능을 빠르게 구현할 수 있다는 장점이 있지만, 라이브러리가 업데이트되거나 deprecated 될 때마다 프로젝트 전체를 수정해야 하는 리스크가 있다. 반대로 버전 팔로잉을 하려고 해도 호환성 문제가 있다. 다양한 프로젝트에서 두루 쓰이는 패키지의 경우 이 문제가 더욱 심해서, 버전 분기가 발생하곤 한다.

이런 문제를 해결하기 위한 좋은 방법 중 하나가 바로 서드파티 라이브러리를 래핑(wrapping)하는 것이다.

래핑이란 외부 라이브러리를 대부분 따르되, 우리 조직과 프로젝트의 요구사항에 맞게 수정해서 사용하는 것을 의미한다. 이를 통해 라이브러리의 기능을 프로젝트에 맞게 커스터마이징하고, 프로젝트의 요구사항을 충족시킬 수 있다.

예를 들어 radix UI의 버튼 컴포넌트를 사용하는 경우, 다음과 같이 래핑할 수 있다.

// radix-ui 버튼 컴포넌트를 가져온다.
import { Button as RadixButton, ButtonProps as RadixButtonProps } from '@radix-ui/react-button';
import { forwardRef } from 'react';

interface ButtonProps extends RadixButtonProps {
	variant: 'primary' | 'secondary' | 'good-button';
}

// 래핑된 버튼 컴포넌트를 만듭니다
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ variant, ...props }, ref) => {
	// variant에 따른 스타일 로직
	return <RadixButton ref={ref} {...props} />;
});

코드를 보면 알겠지만 래핑된 버튼 컴포넌트는 radix UI의 버튼 컴포넌트를 대부분 따르되 우리 조직과 프로젝트의 요구사항에 맞게 수정해서 사용하는 것을 의미한다. 기계적으로 가져와서 약간의 수정과 함께 내보내기만 하면 되는, 딱히 어려운 작업도 아니다. 작업 자체는 AI와 함께라면 뚝딱이다. 막연히 생각해보면 래핑이 무슨 중요한 작업인가 싶지만, 이 작업이 프로젝트의 지속가능성을 결정하는 중요한 요소가 될 수 있다.

보통 오픈소스 라이브러리를 만들다보면, 초반에는 날카롭게 특정 기능만을 충실히 한다. 크기도 작고, 사용법도 간단하다. 우리는 그 작은 코드조각의 도움을 받아 빠르게 문제를 해결해나갈 수 있었다.

점차 해당 오픈소스를 찾는 사람들이 많다보면 이것도 있으면 좋겠고 저것도 있으면 좋겠다는 사람들이 등장하기 시작한다. 보통 해당 레포의 github issue에서 이런 제안이 이루어지고 maintainer도 나름 필요한 기능들을 구현해나가기 시작한다. 이런 과정을 거치다보면 라이브러리의 크기가 점점 커지고 복잡해진다. 라이브러리가 제공하는 100가지 옵션 중에 우리는 딱 하나의 기능만 필요한데, 트리쉐이킹이 되어 성능에 영향을 안준다고 하더라도 개발자 경험은 썩 좋지 않다.

우리가 최초로 정의한 문제가 원격 자원의 상태관리라면 @tanstack/query에서 쓸데없는 api와 options의 80%는 없어도 무방하다. 또한 우리가 정의한 문제를 라이브러리가 100% 해결해주지 못할 가능성이 크다. 마치 우리의 문제정의와 라이브러리가 제공하는 해결책이 이루는 교집합이 둘의 합집합의 크기에 비해 과하게 작아질 수 있다는 뜻이다. 이또한 하나의 도구이므로, 되는대로 라이브러리에 맞춰쓰는게 아니라 우리가 해결하려는 문제에 맞게 날카롭게 가다듬어 써야한다고 생각한다.

라이브러리를 한번 래핑하게 되면 외부 라이브러리의 변경사항이 프로젝트 전체에 미치는 영향을 최소화할 수 있다. 또한 이렇게 특정 레이어에 격리시키면 구체적인 구현이 아닌 추상화된 인터페이스에 의존하므로, 인터페이스만 지켜서 패키지만 수정하면 사용부에서는 수정할 필요가 없기에 추후 라이브러리 교체에도 용이하다.

요즘 미친듯이 확장중인 tanstack 마피아의 보스, tanstack/query로 예를 들어보자.

//lib/query.ts
import {
	useQuery as useQueryOriginal,
	UseQueryOptions,
	UseQueryResult,
} from '@tanstack/react-query';

export const useQuery = <TData, TError = unknown>(
	options: UseQueryOptions<TData, TError>,
): Omit<UseQueryResult<TData, TError>, 'refetch'> => {
	const { refetch, ...rest } = useQueryOriginal({
		staleTime: 1000 * 60 * 5,
		refetchOnWindowFocus: false,
		retry: 1,
		...options, // 호출부에서 전달받은 옵션을 우선한다.
		onError: error => {
			// 프로젝트 공통 에러 처리
			trackError('query_error', error);
			options.onError?.(error);
		},
	});

	// refetch 기능을 의도적으로 제거하고 나머지만 반환
	return rest;
};

이렇게 수정하면 우리 프로젝트에서는 useQuery를 사용할 때 특정 옵션들이 디폴트로 들어가고, refetch 기능을 사용할 수 없게 된다. 우리 팀의 데이터 재요청 방식이 invalidateQueries라면 이 방식을 강제하게 되어 일관성 있는 데이터 관리가 가능해진다. 물론 이건 좋은 예시는 아니다. refetch는 좋은 기능이니까..!

// 직접 사용하는 경우
import { useQuery } from '@tanstack/react-query';

const { data } = useQuery({
	queryKey: ['users'],
	queryFn: () => fetch('/api/users').then(res => res.json()),
});

// 래핑해서 사용하는 경우
import { useQuery } from '@/lib/query';

const { data } = useQuery({
	queryKey: ['users'],
	queryFn: () => fetch('/api/users').then(res => res.json()),
});

사용법을 예를 들면 위와 같다. 보다시피 딱히 달라지는건 없다. 라이브러리의 사용법을 따르면 된다. 커스텀을 해서 다른 인터페이스가 정의해졌다면 그 방식을 따르면 된다. Docs나 Readme를 제공하는 방법도 좋다.

  • 프로젝트의 도메인과 비즈니스 로직에 맞는 인터페이스를 설계할 수 있다.
  • 불필요한 기능은 제외하고 필요한 기능만 노출시킬 수 있다.

예를 들어 어떤 프로젝트에서 date-fns 라이브러리를 사용한다고 가정해보자. 이 라이브러리에서 제공하는 기능 중 대표적으로 format이라는 유틸을 가장 많이 사용한다. 이 유틸은 자유도가 높아서 인자로 받은 Date객체를 어떤 포맷으로든 바꿔준다.

// 라이브러리 사용 예시
import { format } from 'date-fns';

const formattedDate = format(new Date(), '바야흐로 yyyy년 MM월 dd일 이었소..');
// 출력 : 바야흐로 2025년 01월 25일 이었소..

하지만 우리 프로젝트에서는 날짜를 표현하는 방식이 대표적으로 2가지 타입이다. 모든 날짜 포매팅은 아래 두 가지 타입만 쓰인다고 가정해보자.

  • 년월일
  • 년월일 시간

그렇다면 자유도를 제한하고 관리 포인트를 일원화할 필요가 있다. 아래와 같이 포매팅 모듈을 정의하고 내보내면 된다.

// lib/date-format.ts
// date-fns 라이브러리를 래핑한 예시
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';

export const dateUtils = {
	// 프로젝트에서 자주 사용하는 포맷만 노출
	formatDate: (date: Date) => format(date, 'yyyy년 MM월 dd일', { locale: ko }),
	formatTime: (date: Date) => format(date, 'HH:mm', { locale: ko }),
};

// 필요하다면 원문을 내보내는 것도 가능하다.
export { format } from 'date-fns';

이 방식은 date-fns라는 라이브러리를 by-pass하는건 아니지만 라이브러리에서 제공하는 모든 기능을 가져다 쓰지 않고 일부만 내보내는 형식이다.

만약 나중에 프로젝트가 날짜를 포매팅하는 정책이 yyyy.mm.dd로 변경되거나 추가되더라도 이 모듈에 추가하거나 수정해주면 된다. 만약 format이라는 함수를 date-fns에서 그대로 가져왔다면 전체 사용처를 전부 수정해줘야 했을것이다.

필요하다면 라이브러리의 원문을 내보내는 것도 가능하다. 이런 경우에는 wrapping의 이점을 보기 힘들지만 실제 개발하다보면 요구사항에 맞춰 기정의된 모듈 이외의 기능을 구현해야할 때가 있는데, 이 때 블로킹당하지 않기 위해 라이브러리의 원문을 내보내는 것도 좋은 방법이다. 다만 이 경우엔 Fixme Comment를 달아서 모듈화할 것을 명시해두는 것이 좋겠다.

여러 프로젝트나 패키지에서 동일한 라이브러리를 사용할 때 버전 일관성을 유지하기 쉬워진다. 버전 업그레이드나 다운그레이딩 시 래퍼 레이어에서 한 번만 검증하면 되므로 안전한 마이그레이션이 가능해진다.

물론 이는 양날의 검이다. 라이브러리의 버전 업/다운그레이드가 필요할 때 이 작은 변화를 위해 라이브러리의 버전을 올리거나 내려야하는 하는 문제가 있다. 물론 그 기능을 직접 구현해서 내보낼 수 있다는 자유도는 있다만, 라이브러리의 코어로직과 강하게 결합된 기능을 직접 구현하는 것은 라이브러리의 버전 업/다운그레이드 문제를 해결하는 것이 아니라 새로운 문제를 만들어내는 것일지 모른다.

  1. 초기 개발 시간 증가

    래퍼 클래스/함수를 설계하고 구현하는 시간이 추가로 필요하다. 특히 작은 프로젝트에서는 과도한 작업이 될 수 있다.

  2. 추가적인 추상화 계층

    레이어가 하나 더 생기는 것이기 때문에 디버깅 과정에서 검증할 대상이 늘어나는 것이다. 래핑한 라이브러리가 늘어날수록 그 수고는 더해진다.

  3. 기준의 부재

    모든 서드파티 라이브러리를 래핑할 필요는 없고 이는 사실상 불가능하다(너무 많다). 또한 래핑할 때 무엇을 래핑할지, 무엇부터 어떻게 래핑할지 결정하기 어렵다.

모든 서드파티 라이브러리를 무조건 래핑할 필요는 없다. 다음과 같은 경우에 래핑을 고려해보자:

  1. 여러 프로젝트 전반에 걸쳐 자주 사용되는 라이브러리

    • axiosfetch와 같은 HTTP 클라이언트
    • date-fnsdayjs 같은 날짜 처리 라이브러리
    • lodashramda 같은 유틸리티 라이브러리
  2. 비즈니스 로직과 밀접하게 연관된 기능을 제공하는 라이브러리

    • 결제 관련 라이브러리 (예: stripe-js)
    • 인증 관련 라이브러리 (예: firebase/auth)
    • 분석 도구 (예: amplitude, google-analytics)
  3. 향후 교체 가능성이 있는 라이브러리

    • UI 컴포넌트 라이브러리 (예: MUI에서 Chakra UI로 변경)
    • 상태 관리 라이브러리 (예: Redux에서 Zustand로 변경)
    • CSS-in-JS 라이브러리 (예: styled-components에서 emotion으로 변경)
  4. 커스텀한 에러 처리나 로깅이 필요한 경우

    • API 클라이언트 (에러 응답 포맷 통일)
    • 외부 서비스 연동 (예: S3 업로드 실패 시 재시도 로직)
    • 결제 프로세스 (트랜잭션 로깅)

서드파티 라이브러리 래핑은 단기적으로는 추가 작업이 필요하지만, 장기적으로 프로젝트의 유지보수성과 확장성을 크게 향상시킬 수 있다. 특히 규모가 큰 프로젝트에서는 이러한 작은 실천이 프로젝트의 지속가능성을 결정짓는 중요한 요소가 될 수 있다.

나는 이 작업을 할 때 turborepo의 internal package를 활용했기 때문에 래핑 작업을 쉽게 할 수 있었다. polyrepo 환경에서는 npm private registry를 활용하거나 github submodule을 활용하는 방법도 있다. 아니면 어플리케이션 레벨에서 library layer를 두고 라이브러리를 래핑하는 방법도 있다. 그렇게 하고 lint에 라이브러리를 직접 참조하는 것을 방지하는 것도 좋은 방법이다.

이 과정은 팀 플레이를 할 때 요긴하다고 생각한다. 라이브러리가 제공하는것을 전부 받아들이지 않고, 우리만의 문제를 정의하고 이에 따라 우리만의 인터페이스를 정하는 과정이다.

Copyright © HOJUN IN. All rights reserved