프론트엔드에서 어댑터패턴 활용하기
어댑터 패턴을 활용해서 좀 더 유연하게 CMS를 관리해보자
들어가며
프론트엔드 개발을 하다 보면 우리는 종종 호환되지 않는 인터페이스들끼리 상호작용해야 하는 상황을 마주하게 된다.
예를 들어 외부 API의 응답 구조가 우리 도메인 모델과 맞지 않는다거나, 프로젝트 안에서 서로 다른 데이터 소스를 동시에 활용해야 할 때도 있다.
이런 상황에서는 자연스럽게 “이 다양한 인터페이스를 어떻게 하나의 일관된 방식으로 다룰 수 있을까?”라는 고민이 생긴다.
나의 상황에서 발견한 문제는 CMS를 Notion에서 MDX로 바꾸거나, GitHub에서 불러온 데이터를 기존 포맷과 동일하게 맞춰야 할 때처럼, 통합된 인터페이스가 필요한 순간들이 있었다.
여러 해결책을 고민해보다 프론트엔드 레벨에서 간단하게 해결할 수 있는 구조적 접근을 먼저 고민하게 되었고 어댑터 패턴으로 간단하게 해결해볼 수 있었다.
초기에는 Notion을 CMS로 사용했는데, 어댑터 패턴을 적용한 후에는 MDX, GitHub 등 다양한 CMS로 손쉽게 전환할 수 있는 형태로 구조를 바꿀 수 있었다.
이 글에서는 실제로 운영하던 블로그에 CMS를 어댑터 패턴을 활용해 교체 가능한 구조로 리팩토링했던 경험을 정리했다.
이전 구조의 문제점
1. 도메인 분리 없이 Notion API를 직접 호출함
초기 프로젝트는 굉장히 단순한 구조였다. CMS라고 해봐야 Notion 하나뿐이었기 때문에 “굳이 추상 레이어를 만들 필요가 있을까?”라고 생각하고 바로 Notion API를 호출하는 방식으로 구현했다.
import { Client } from "@notionhq/client";
export async function getAllPosts(): Promise<NotionPost[]> {
const client = new Client({ auth: process.env.NOTION_API_KEY });
const response = await client.search({ query: "", page_size: 100 });
// ...
}이 코드를 사용하는 곳들에서는 단순히 @/features/notion을 import해서 호출하면 됐기 때문에 처음에는 큰 불편함이 없었다. 하지만 시간이 지나면서 이 구조가 여러 문제를 만든다는 걸 깨닫게 됐다.
가장 큰 문제는 모든 비즈니스 로직이 Notion에 직접적으로 결합되어 있었다.
콘텐츠를 가져오는 방식이 바뀌거나, 슬러그 구조가 달라지거나, 날짜 포맷이 바뀌면 이 코드뿐 아니라 이 함수를 사용하는 모든 페이지가 영향을 받았다.
또한 프로젝트 전체가 @/features/notion에 의존하고 있었기 때문에 CMS를 교체할 때 굉장히 많은 파일을 수정해야 했다.
이렇게 되면 CMS를 교체하는 작업은 단순한 변경이 아니라 사실상 전체 프로젝트를 뒤흔드는 작업이 되어버린다.
2. CMS 교체 시 대규모 코드 수정이 필요했다
Notion API를 직접 사용하는 구조였기 때문에 CMS를 MDX 방식이나, github issue등 다양한 방식으로 전환하려고 하면 단순히 어댑터만 바꾸면 되는 게 아니라 프로젝트 전역에서 import 경로를 모두 수정해야 하는 상황이 생겼다.
// 이전
import { getAllPosts } from "@/features/notion";
// 변경 후
import { getAllPosts } from "@/features/mdx";문제는 이렇게 import가 들어간 파일이 수십 개에 달했다는 점인데 블로그 프로젝트 특성상 sitemap 생성, RSS feed 생성, 페이지네이션, 포스트 목록, 포스트 상세 등 다양한 영역에서 콘텐츠 데이터를 가져와야 했기 때문이다.
코드베이스에서 이런 import를 일일이 찾아 수정하는 작업은 단순히 귀찮은 게 아니라 실수할 가능성이 높은 작업이었다. 하나라도 놓치면 런타임에서 에러가 터지고, 그 에러가 바로 눈에 띄지도 않아 문제를 추적하는 데 시간이 걸린다.
이 때 “CMS는 결국 구현체일 뿐인데, 왜 프로젝트 전체가 이 구현체에 의존하고 있지?”라는 의문이 들었고 구조를 다시 설계해야겠다고 판단했다.
어댑터 패턴이란?

자동차가 레일위를 달리고 있다..!
어댑터 패턴은 서로 다른 인터페이스를 가진 객체를 동일한 인터페이스로 다룰 수 있게 만드는 패턴이다.
프론트엔드에서 적용하면 다음과 같은 효과가 있다.
- 외부 API나 라이브러리에 대한 직접 의존을 줄일 수 있다.
- 여러 데이터 소스를 동일한 인터페이스로 처리할 수 있다.
- 테스트 시 구현체 대신 모킹 어댑터를 사용할 수 있다.
내가 적용했던 방식은 다음과 같다.
- PostApi라는 공통 인터페이스를 정의하고
- 각각의 CMS(Notion, MDX, GitHub 등)는 이 인터페이스에 맞춰 어댑터를 구현하고
- 앱 초기화 단계에서 사용할 어댑터를 선택하도록 구조를 재구성했다
이 방식으로 CMS 교체 문제를 “구현체 교체” 수준으로 낮출 수 있었다.
구현 과정
어댑터 패턴을 적용하기로 결정한 뒤에는 우선 “이 프로젝트에서 CMS라는 개념을 어떻게 정의할 것인가?”부터 고민했다.
지금까지는 Notion API 형식에 맞춰 모든 로직이 짜여 있었기 때문에, 우선해야 할 일은 Notion이라는 구현체와 ‘포스트 데이터’라는 도메인을 분리하는 것이었다.
그래서 가장 먼저 한 작업이 추상 인터페이스를 만드는 일이었다.
1. 추상 인터페이스 정의
기존 코드가 Notion 형태에 맞춰져 있어서 도메인이 외부 API에 종속돼 있었다.
그래서 PostApi 라는 CMS 공통 인터페이스를 만들고, 애플리케이션이 기대하는 최소 기능만 명확히 정의했다.
export interface PostApi {
getAllPosts(): Promise<PostMetadata[]>;
getPostBySlug(slug: string, fetchContent?: boolean): Promise<BlogPost>;
}핵심은 CMS와 상관없이 애플리케이션이 사용할 공통 계약을 만들어서 이 인터페이스를 따르게 했다.
2. 어댑터 등록/주입
CMS 구현체를 직접 import하지 않고 현재 사용할 CMS 어댑터를 등록하고 가져오는 관리 레이어를 만들었다.
이 작업으로 도메인 레이어는 CMS 구현체를 전혀 몰라도 되는 구조가 되었다.
let postApiAdapter: PostApi | null = null;
export function setPostApiAdapter(adapter: PostApi): void {
postApiAdapter = adapter;
}
export function getPostApiAdapter(): PostApi {
if (!postApiAdapter) {
throw new Error("Post API adapter is not registered.");
}
return postApiAdapter;
}3. API 함수들은 어댑터를 통해 호출하도록 변경함
이전에는 도메인에서 Notion API를 직접 사용했지만,
이제는 항상 getPostApiAdapter()를 통해 등록된 어댑터만 사용하도록 바꿨다.
CMS 교체 시 도메인 코드를 손대지 않아도 되는 구조가 완성됐다.
export async function getAllPosts() {
return getPostApiAdapter().getAllPosts();
}이제 CMS 구현체에 직접 접근하지 않는 구조가 만들어졌다.
4. Notion 어댑터 구현
Notion API 호출 로직을 그대로 옮기되, 어댑터 내부에서 Notion 데이터를 도메인 모델 형태로 변환하도록 만들었다.
CMS별 구조 차이는 어댑터 내부에서만 해결하도록 분리했다.
export class NotionPostAdapter implements PostApi {
async getAllPosts() {
const notionPosts = await notionGetAllPosts();
return notionPosts.map(/* 변환 */);
}
async getPostBySlug(slug: string, fetchContent = true) {
return notionGetPostBySlug(slug, fetchContent);
}
}5. 앱 초기화 단계에서 어댑터 등록
앱이 실행될 때 어떤 CMS 어댑터를 사용할지 초기화 코드에서 등록했다.
이제 환경 변수로 Notion, MDX, GitHub 등 CMS를 런타임에 쉽게 교체할 수 있게 됐다.
import { setPostApiAdapter } from "@/entities/post/api";
import { notionPostAdapter } from "@/features/notion/adapter";
setPostApiAdapter(notionPostAdapter);페이지 레벨에서는 내부 구현을 몰라도된다.
const PostPage =({slug:string}))=> {
const post = await getPostBySlug(slug);
return(
<main>
{...}
</main>
)개선 결과
Before
- 앱 전체가 Notion에 직접 의존했다.
- CMS를 바꾸려면 전체 코드를 수정해야 했다.
After
- CMS 독립 구조가 만들어졌다.
- CMS 교체 시 어댑터만 바꾸면 됐다.
- 테스트도 훨씬 단순해졌다.
- 환경 변수에 따라 런타임에 CMS를 교체할 수 있는 구조가 되었다.
확장 예시 → MDX 어댑터 추가
새로운 CMS를 추가하는 과정은 매우 단순해졌다.
MdxAdapter를 만들어주고 내부 구현은 이 어댑터만 추가하면 되었다.
export class MdxPostAdapter implements PostApi {
async getAllPosts() { /* ... */ }
async getPostBySlug(slug: string) { /* ... */ }
}그리고 초기화 단계에서 어댑터를 주입만 해주면 된다..
setPostApiAdapter(mdxPostAdapter);배운 점
- 의존성 역전 원칙(DIP)이 왜 중요한지 체감했다.
- 공통 인터페이스를 정의하면 구현체 교체가 훨씬 쉬워진다.
- 어댑터 패턴은 외부 API와 내부 로직을 분리하는 데 유용하다.
- 런타임 초기화를 통해 환경별로 유연한 구조를 만들 수 있었다.
결론
어댑터 패턴을 적용한 후 다음과 같은 장점이 생겼다.
- 외부 의존성과 독립된 구조를 만들 수 있었다.
- 구현체 교체가 쉬워졌다.
- 새로운 CMS 추가가 간단해졌다.
- 유지보수성이 큰 폭으로 개선됐다.
- 테스트 비용이 줄었다.
CMS뿐만 아니라 외부 API나 스토리지, 써드파티 라이브러리 등을 추상화할 때도 충분히 활용할 수 있는 패턴이라고 생각한다.