BFF 패턴
BFF의 도입 연도와 명명자, 등장 배경을 정리하고 MAKONEA 실제 코드와 UML로 웹·BFF·콘텐츠 서비스의 역할을 따라간다.
· 마지막 업데이트: 2026-10-28 · 10 min read
언제, 누구에 의해 생겼나
2013년 · SoundCloud 엔지니어링팀: 웹·모바일·외부 연동이 공유하던 API에서 벗어나 화면별 API를 도입했다. 도입 연도는 SoundCloud의 2021년 회고에 나온다.1
초기 SoundCloud 개발 과정 · Nick Fisher: 당시 웹 기술 책임자로, BFF라는 이름을 붙였다. Phil Calçado가 2015년 글에 이 경위를 기록했다.2
2015년 9월 18일 · Phil Calçado: SoundCloud에서 설계를 발전시킨 과정과 화면용 응답을 서버에서 조립하게 된 이유를 공개했다.2
2015년 11월 18일 · Sam Newman: REA와 SoundCloud 사례를 묶어 프런트엔드별 백엔드라는 패턴과 팀의 책임 범위를 설명했다.3
웹사이트 하나를 만들 때는 그 화면에 맞는 서버 API를 함께 만들기 쉽다. 여기에 모바일 앱과 외부 연동이 붙으면 같은 데이터를 쓰더라도 필요한 응답이 달라진다. 상품 상세를 넓게 보여주는 웹, 이동 중 짧게 확인하는 앱, 데이터를 다른 서비스로 가져가는 외부 개발자가 API 하나의 변경 순서를 함께 기다리게 된다.
2015년 12월 Thoughtworks가 소개한 SoundCloud 사례를 보면 당시의 작업 과정도 드러난다. 앱에 새 엔드포인트가 필요하면 공용 API를 맡은 팀을 설득하고 작업 순서를 기다려야 했다. BFF를 맡은 프런트엔드 팀은 필요한 데이터와 응답 형식을 직접 조정했다.4
개요
BFF(Backend for Frontend)는 특정 프런트엔드가 쓰기 좋은 API를 제공하는 서버 계층이다. 화면의 요청을 받아 필요한 서비스를 호출하고 응답을 구성하며, 화면을 만드는 팀이 이 API를 함께 관리한다. 여러 서비스의 데이터를 한 응답으로 합치는 일도 이 계층에서 맡을 수 있다.5
MAKONEA에서는 웹의 API 요청을 apps/bff-edge가 받아 처리한다. 공개 글을 조회할 때 언어와 조회 옵션을 검증하고 캐시를 확인한 뒤 content-svc에 요청을 보낸다. 글을 공개할지 판단하고 목록에 필요한 데이터를 만드는 코드는 콘텐츠 서비스에 있다. 아래 예제는 이 한 경로를 따라간다.
쉬운 예제: MAKONEA 블로그 목록 열기
블로그 카드에는 제목과 설명, 읽는 시간이 필요하다. 브라우저는 데이터베이스의 게시물 행을 직접 읽는 대신 공개 목록 API에 필요한 개수와 글 종류를 전달한다. 이 예제를 쓰면 화면의 요청이 서버 경계를 통과하는 과정과 각 계층이 맡은 일을 실제 코드로 확인할 수 있다.
GET /api/public/blog/ko?type=post&limit=10브라우저에서 이 주소를 호출하면 같은 출처의 Next.js API 프록시가 BFF로 전달한다. BFF는 이를 /v1/public/posts/ko라는 콘텐츠 서비스 경로로 연결한다. 서버에서 렌더링할 때는 설정된 BFF 주소로 직접 요청할 수도 있으므로, 아래 UML은 브라우저 API 호출 경로를 그렸다.67
UML로 보는 MAKONEA의 구성
컴포넌트 다이어그램의 점선 화살표는 사용하는 쪽에서 의존하는 쪽으로 향한다. 웹과 BFF, 콘텐츠 서비스가 분리돼 있으며 publicCacheStore는 BFF 프로세스 안의 메모리 캐시다. 데이터베이스 조회는 content-svc가 담당한다.89
UML로 보는 요청 순서
시퀀스 다이어그램은 캐시에 없는 목록을 읽는 경우다. BFF가 콘텐츠 서비스에서 응답을 받은 뒤 캐시 정책을 적용하고, Next.js 프록시를 거쳐 브라우저로 돌려준다. 캐시 HIT이면 BFF가 저장된 응답을 반환하므로 콘텐츠 서비스 조회를 건너뛴다.109
실제 코드 1: 웹에서 조회 옵션을 만든다
다음 코드는 revision 0b705e8의 실제 함수 발췌다. fetchPublicBlog는 언어와 글 종류, 페이지 크기 등을 URL에 넣는다. withBffBase가 호출 환경에 맞는 주소를 붙이고 withServerFetchTimeout은 서버 요청에 시간 제한을 적용한다.7
export async function fetchPublicBlog(
locale: string,
type?: 'post' | 'project' | 'casual' | 'memo' | 'lab',
init?: FetchInit,
opts: { limit?: number; offset?: number; category?: string; preview?: boolean } = {},
) {
const params = new URLSearchParams();
if (type) params.set('type', type);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
if (opts.category) params.set('category', opts.category);
if (opts.preview) params.set('preview', '1');
const qs = params.toString() ? `?${params.toString()}` : '';
return fetch(withBffBase(`/api/public/blog/${locale}${qs}`), withServerFetchTimeout(withPublicContentTag(init)));
}실제 코드 2: BFF가 캐시와 서비스 호출을 맡는다
아래 부분 앞에서 라우트는 localeParamSchema와 publicPostListQuerySchema로 입력을 검증한다. 검증된 언어·종류·분류·페이지 위치·미리보기 옵션이 캐시 키에 들어가므로, 서로 다른 목록을 구분해 보관한다. forwardWithCache에 전달한 함수는 원본 응답이 필요할 때 콘텐츠 서비스를 호출한다.10
const cacheKey = `list:${locale}:${type ?? 'any'}:${category ?? 'any'}:${offset ?? '0'}:${limit ?? 'default'}:preview=${preview ? '1' : '0'}`;
return await config.forwardWithCache(
'posts',
cacheKey,
BFF_CACHE_TTL.blogList,
async () => {
const res = await config.safeFetch(`${config.contentSvcUrl}/v1/public/posts/${encodeURIComponent(locale)}${qs}`, {
headers: config.withInternalAuthHeaders(),
});
if (!res.ok) {
config.log.warn({
event: 'PublicBlogList_DependencyFailed',
stage: 'CallDependency',
error: res.error.code,
});
return safeJsonErrorResponse('UPSTREAM_ERROR', 502);
}
return res.value;
},
{ validatePayload: requireNonEmptyObjectArrayFieldPayload('posts') },
);safeFetch가 연결 실패를 결과값으로 반환하면 이 분기는 UPSTREAM_ERROR와 HTTP 502를 돌려준다. withInternalAuthHeaders()는 내부 호출용 헤더를 만들고, validatePayload는 캐시에 넣을 응답의 posts 구조를 검사한다.
실제 코드 3: 콘텐츠 서비스가 목록 응답을 만든다
content-svc의 공개 목록 조회는 저장소에서 status='published'인 글을 고른다. 이어 아래 코드가 본문으로 읽는 시간을 계산하고, 본문 필드를 뺀 목록과 전체 개수를 반환한다. 읽는 시간 계산은 이 경로에서 콘텐츠 서비스의 책임이다.1112
const withReadingTime = result.value.posts.map((post: any) => {
const { content, ...rest } = post;
const readingSource = content || [
post.summary_title,
post.summary_text,
post.description,
post.title,
].filter((value) => typeof value === 'string' && value.trim()).join(' ');
const interactivePreview = preview
? post.content_type === 'lab'
? extractPublicLabPreview(content, post.lab_thumbnail_index)
: extractPublicShaderPreview(content)
: null;
return {
...rest,
...(interactivePreview ? { interactive_preview: interactivePreview } : {}),
reading_time: calculateReadTime(readingSource || ''),
};
});
reply.send({ posts: withReadingTime, total: result.value.total });이 목록 경로의 응답 조립은 콘텐츠 서비스에서 끝나며, BFF는 완성된 응답에 캐시 정책을 적용해 전달한다.
규칙의 담당자
부분 | MAKONEA 공개 글 목록에서 맡는 일 |
|---|---|
브라우저·apps/web | 카드를 표시하고 조회 옵션을 만든다. 브라우저 요청에는 API 프록시를 제공한다. |
apps/bff-edge | 입력을 검증하고 공개 응답 캐시와 콘텐츠 서비스 호출을 관리한다. |
content-svc | 공개 글을 조회하고 본문을 뺀 목록·읽는 시간·전체 개수를 반환한다. |
PostgreSQL | 게시물의 상태와 본문을 저장한다. |
- 부분
브라우저·apps/web
- MAKONEA 공개 글 목록에서 맡는 일
카드를 표시하고 조회 옵션을 만든다. 브라우저 요청에는 API 프록시를 제공한다.
- 부분
apps/bff-edge
- MAKONEA 공개 글 목록에서 맡는 일
입력을 검증하고 공개 응답 캐시와 콘텐츠 서비스 호출을 관리한다.
- 부분
content-svc
- MAKONEA 공개 글 목록에서 맡는 일
공개 글을 조회하고 본문을 뺀 목록·읽는 시간·전체 개수를 반환한다.
- 부분
PostgreSQL
- MAKONEA 공개 글 목록에서 맡는 일
게시물의 상태와 본문을 저장한다.
API Gateway는 여러 API에 공통으로 적용할 진입 규칙을 맡고, BFF는 특정 화면에 맞춘 응답을 맡는다. 배치에 따라 Gateway 뒤에 BFF를 두거나 한 서비스가 두 역할을 함께 담당할 수 있다. GraphQL 역시 API를 표현하는 방식이므로, 화면 전용 리졸버를 가진 BFF의 구현에 사용할 수 있다.5
운영하면서 지켜야 할 경계
처음에는 응답 몇 개를 조립하던 BFF에도 기능이 쌓인다. SoundCloud의 회고는 작은 CRUD 서비스들을 연결하는 업무 로직까지 BFF에 모이면 여러 BFF 사이에서 규칙이 복제되고 서로 달라지는 문제를 지적한다. 공통된 업무 개념이 드러나면 그 개념을 담당하는 서비스로 옮기고, BFF에는 화면별 조합을 남길 수 있다.1
글이 비공개로 바뀌면 그 상태를 판단하는 코드와 이미 저장된 응답을 만료시키는 경로가 함께 맞아야 한다. 이때 BFF의 공개 데이터 캐시와 웹의 HTML 캐시는 별도의 소비자다. 장애 응답을 정상적인 빈 목록으로 바꾸는 대신, 호출자가 구별할 수 있는 실패로 전달하는 것도 이 경계의 역할이다.
도입을 판단하는 기준
웹과 앱이 서로 다른 응답을 원하거나 화면 하나를 위해 여러 서비스를 반복해서 조회한다면, 그 조합을 BFF로 옮겼을 때 화면 코드와 팀 간 조율이 얼마나 줄어드는지 볼 만하다. 반대로 화면 하나가 이미 적절한 API 하나로 끝난다면 별도 BFF 서버를 배포하고 운영할 이유는 약하다.
BFF의 경계는 사용자 경험과 팀의 변경 방식에 맞춰 잡는다. 비슷한 iOS·Android 앱을 한 팀이 관리하면 하나를 함께 쓰는 선택도 가능하고, 기능과 출시 주기가 갈라지면 각각의 BFF를 둘 수 있다.3
다이어그램의 표기법은 UML 문서와 함께 읽을 수 있다.
개인 메모
내 홈페이지인 MAKONEA에도 BFF를 적용했다.
각주
- Jorge Creixell. Service Architecture at SoundCloud — Part 1: Backends for Frontends. SoundCloud Backstage, 2021-07-29. Backends for Frontends; The Bad. ↩
- Phil Calçado. The Back-end for Front-end Pattern (BFF). 2015-09-18. The challenges with dogfooding; The Back-end for Front-end Pattern (BFF). ↩
- Sam Newman. Pattern: Backends For Frontends. 2015-11-18. Introducing The Backend For Frontend; How Many BFFs?; And Autonomy. ↩
- Lukasz Plotnicki. BFF @ SoundCloud. Thoughtworks, 2015-12-09. Motivation; Solution; Evolution of a BFF. ↩
- Microsoft. Backends for Frontends pattern. Azure Architecture Center. Context and problem; Solution; Problems and considerations. ↩
- MAKONEA, revision 0b705e8. Next.js public-list proxy. ↩
- MAKONEA, revision 0b705e8. Web content client. ↩
- Object Management Group. Unified Modeling Language 2.5.1. December 2017. Components; Interactions. ↩
- MAKONEA, revision 0b705e8. BFF publicCacheStore. ↩
- MAKONEA, revision 0b705e8. BFF public post routes. ↩
- MAKONEA, revision 0b705e8. Post repository. ↩
- MAKONEA, revision 0b705e8. Content service post controller. ↩