핀테크그룹의 GraphQL 기반 BFF와 프론트엔드 활용기
BFF의 탄생 배경과 핀테크그룹 프론트엔드에서 BFF를 어떻게 활용하는지 소개합니다
- 들어가며
- BFF는 무엇인가요?
- 핀테크그룹의 BFF를 소개합니다
- Apollo Client와 GraphQL 사용 예시
- Apollo Client의 캐싱 사용 시 주의할 점
- BFF 도입 시 고려해야 할 점
- 마치며
- Reference
들어가며
안녕하세요. 컬리 핀테크그룹 김재민입니다.
처음에는 컬리 내부 스터디에서 ‘핀테크그룹의 BFF’를 소개해달라는 요청이 있어, 공부도 할 겸 발표를 자처하며 문서를 작성하기 시작했습니다. 작성하다 보니 GraphQL 기반 BFF를 도입·운영하며 얻은 인사이트가 컬리 구성원 이외에도 유의미할 수 있겠다는 생각이 들어, 보다 많은 분들과 공유하고자 글로 정리했습니다.
BFF는 무엇인가요?
등장 배경
- 서비스 확장의 한계를 극복하고자 MSA (MicroService Architecture) 도입이 증가했습니다.
- 프론트엔드 입장에서 새로운 문제에 직면하게 되었습니다.
- 엔드포인트가 서비스마다 흩어져 있어 관리가 어렵습니다.
- 여러 플랫폼과 API 스펙을 맞추기 위한 커뮤니케이션 비용이 커집니다.
- 플랫폼마다 인증 및 정책이 달라 중복 구현이 불가피합니다.
- 특히 웹 환경에서는 브라우저 보안 정책으로 인한 CORS 제약까지 고려해야 합니다.
- 이러한 문제들을 완화하기 위해 BFF가 등장했습니다.
개념과 역할
- BFF는 특정 프론트엔드를 전담하는 전용 서버 계층입니다.
- 프론트엔드가 필요로 하는 데이터를 여러 서비스에서 수집·가공하여 전달합니다.
- 각 서비스에 분산돼 있던 인증·인가, CORS 정책 등을 BFF 단일 진입점에서 통제할 수 있어 관리가 단순해집니다.
- 백엔드 스펙이 변경되더라도 BFF에서 응답을 매핑하여 전달함으로써 프론트엔드는 변경 없이 그대로 동작할 수 있습니다.
- 요약하자면, BFF는 백엔드와 프론트엔드 사이의 완충 계층으로 작동합니다.
핀테크그룹의 BFF를 소개합니다
기술 스택
- NestJS는 모듈화된 구조와 데코레이터 기반 코드 패턴을 제공해 유지보수하기 좋습니다.
- GraphQL은 한 번의 쿼리로 필요한 데이터만 가져와 불필요한 다중 API 호출을 줄이며, BFF에서 스키마 기반으로 데이터를 매핑하기 때문에 백엔드 변경에도 프론트엔드가 영향받지 않도록 할 수 있습니다.
- Fastify는 가볍고 빠른 런타임 특성 덕분에 짧고 잦은 GraphQL 요청을 처리하기에 적합합니다.
- Apollo는 GraphQL을 중심으로 한 대표적인 오픈소스 생태계로, Apollo Server와 Apollo Client가 각각 명확히 구분된 역할을 담당합니다.
- Apollo Server는 GraphQL API 서버 구현체입니다. 스키마(Schema)를 기반으로 데이터 질의 구조를 정의하고, 각 필드에 대한 리졸버(Resolver) 로직을 구현해 실제 응답을 생성합니다. 핀테크그룹 BFF에서는 NestJS 기반으로 Apollo Server를 통합하여 사용하고 있으며, 여러 마이크로서비스의 데이터를 단일 GraphQL 스키마로 통합하는 역할을 합니다.
- Apollo Client는 프론트엔드용 GraphQL 클라이언트 라이브러리입니다. 선언적인 query / mutation 호출로 데이터를 요청하고, 서버 응답을 InMemoryCache에 정규화해 저장·재사용하며,
graphql-codegen과 연계해 타입스크립트 타입을 자동 생성합니다. 이 덕분에 프론트엔드에서는 타입 안정성과 개발 생산성을 동시에 확보할 수 있습니다.
InMemoryCache: Apollo Client가 사용하는 메모리 기반 캐시로, 정규화된 GraphQL 응답을 저장하며 새로고침 시 초기화됩니다.
도입 효과
- 프론트엔드 생산성 향상
- GraphQL + TypeScript 기반으로 정적 타입을 보장합니다.
graphql-codegen을 활용해 쿼리만 작성하면 타입스크립트 코드가 자동 생성됩니다.- Apollo Client의 캐싱/쿼리 병합 기능을 활용할 수 있습니다.
- 백엔드 의존성 감소
- 백엔드 응답 구조 변경 시 BFF에서만 수정하면 프론트엔드는 영향받지 않습니다.
- 복잡도 감소
- 여러 마이크로서비스 API 호출을 BFF에서 집계하여 프론트엔드는 단순한 통신 구조만 다룹니다.
- 보안·정책 일원화
- 인증·인가·로깅 CORS 등 브라우저 관련 처리를 BFF 단일 진입점에서 수행합니다.
Apollo Client와 GraphQL 사용 예시
query문 작성과 타입스크립트 코드 자동 생성
아래 예시는 query 문을 작성하고, 이를 기반으로 타입스크립트 코드가 자동 생성되는 예시입니다.
// useMypage.ts (Query 작성)
import { gql } from '@apollo/client';
//...
const MemberOnMyPageDocument = gql`
query MemberOnMyPage {
memberDetail {
memberId
native
useNoAuthPayment
}
}
`;
// __generated__/useMypage.ts (자동 생성된 코드)
// ...
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
// ...
export const MemberOnMyPageDocument = gql`
query MemberOnMyPage {
memberDetail {
memberId
native
useNoAuthPayment
}
}
`;
/**
* __useMemberOnMyPageQuery__
*
* To run a query within a React component, call `useMemberOnMyPageQuery` and pass it any options that fit your needs.
* When your component renders, `useMemberOnMyPageQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useMemberOnMyPageQuery({
* variables: {
* },
* });
*/
export function useMemberOnMyPageQuery(baseOptions?: Apollo.QueryHookOptions<MemberOnMyPageQuery, MemberOnMyPageQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<MemberOnMyPageQuery, MemberOnMyPageQueryVariables>(MemberOnMyPageDocument, options);
}
export function useMemberOnMyPageLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<MemberOnMyPageQuery, MemberOnMyPageQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<MemberOnMyPageQuery, MemberOnMyPageQueryVariables>(MemberOnMyPageDocument, options);
}
export function useMemberOnMyPageSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions<MemberOnMyPageQuery, MemberOnMyPageQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSuspenseQuery<MemberOnMyPageQuery, MemberOnMyPageQueryVariables>(MemberOnMyPageDocument, options);
}
export type MemberOnMyPageQueryHookResult = ReturnType<typeof useMemberOnMyPageQuery>;
export type MemberOnMyPageLazyQueryHookResult = ReturnType<typeof useMemberOnMyPageLazyQuery>;
export type MemberOnMyPageSuspenseQueryHookResult = ReturnType<typeof useMemberOnMyPageSuspenseQuery>;
export type MemberOnMyPageQueryResult = Apollo.QueryResult<MemberOnMyPageQuery, MemberOnMyPageQueryVariables>;
이처럼 자동으로 생성된 use*Query 훅을 호출하면 data, loading, error 값을 type safety하게 사용할 수 있습니다.
// useMypage.ts (사용 예시)
const {
data: { memberDetail: member } = {},
error,
loading: memberLoading,
} = useMemberOnMyPageQuery();
REST 예시 (비교)
아래는 같은 동작을 명령형 스타일로 구현한 REST API 호출 예시입니다. (GraphQL과 비교하기 위해 작성한 것으로, 실제 코드는 아닙니다.)
// REST API 호출 (명령형)
export function useMypage() {
const [data, setData] = useState<User>();
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
const fetchUser = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch('/api/example/user'); // REST API 호출
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as User;
setData(json);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUser();
}, [fetchUser]);
return { data, error, loading, refetch: fetchUser };
}
- GraphQL과 Codegen을 조합하면 타입 안정성을 확보하면서 선언적이고 가독성 높은 코드로 데이터를 가져올 수 있습니다.
- 반대로 REST를 사용할 경우, 응답 스펙이 바뀔 때마다 타입을 직접 수정해야 하며 GraphQL처럼 자동으로 타입이 동기화되지 않기 때문에 유지보수 비용이 증가합니다.
- Swagger를 활용 중이라면, 타입 생성을 자동화하는 참고 글도 확인해 보시기 바랍니다.
Apollo Client의 캐싱 사용 시 주의할 점
"컴퓨터 과학에서 어려운 것은 단 두 가지다. 캐시 무효화와 이름 짓기" - 필 칼튼(Phil Karlton)
클라이언트 캐싱 vs 백엔드 캐싱
- 클라이언트 캐싱은 화면을 즉시 그려 사용자 체감 속도를 높이는 것이 목표입니다.
- SWR(stale-while-revalidate): 네트워크 요청의 최신 데이터를 기다리면서, 이전에 캐싱된 데이터를 먼저 보여주는 전략입니다.
- 사용자는 즉시 응답을 받아볼 수 있고, 동시에 백그라운드에서 최신 데이터를 가져와 갱신합니다. 따라서 반응성(빠른 응답)과 최신성(백그라운드 갱신)을 모두 확보할 수 있습니다.
- 백엔드 캐싱은 데이터 일관성을 지키면서 시스템 부하를 줄이고 레이턴시를 단축하는 것이 목표입니다.
Apollo Client는 기본적으로 정규화 캐싱(normalized caching) 방식을 사용합니다. 정규화 캐싱은 서버 응답 데이터를 특정 식별 기준으로 쪼개어 개별 엔티티 단위로 저장하고, 동일 데이터를 참조하는 모든 곳이 하나의 캐시 엔트리를 공유하도록 만드는 방식입니다.
Apollo Client의 기본 캐싱 정책
- Apollo Client는 각 응답 객체에
__typename과id가 모두 있을 경우, 이를 이용해 해당 객체를 개별 엔티티로 정규화하여 캐시에 저장합니다. - 이때 캐시 식별자(cache id)는 기본적으로
${__typename}:${id}형태로 자동 생성됩니다.- 다시 말해, 개발자가 별도로 캐시 id를 설정하지 않아도 Apollo가 자동으로 생성합니다.
실제로 겪은 문제 사례
아래는 BFF 개발 과정에서 실제로 발생한 문제입니다. 공통적으로 사용하는 Fragment가 있었고, 다음과 같이 정의되어 있었습니다.
fragment RegistBusinessMember on RegistrationMember {
id
idType
cddAndEddInfo
kycState
createdAt
kycSeq
}
이 Fragment를 사용하는 RegistrationMember 리스트 조회 쿼리에서, 다음과 같은 조건이 있었습니다. 위의 캐싱 정책과 조건을 함께 고려하였을 때 어떤 문제가 발생할까요?
- 동일한
id값을 가진 여러 아이템이 존재할 수 있음 - 각 아이템을 구분하려면
id,idType,kycSeq를 조합해야 유일성이 보장됨
같은 id를 가지고 있으면서 서로 다른 idType, kycSeq 값을 가진 아이템이 하나의 캐시 엔트리로 병합되어, 서로 다른 아이템들이 모두 동일한 데이터로 보입니다.
- Apollo Client는 기본적으로
__typename:id조합을 캐시 키로 사용합니다. - 그러나
RegistrationMember타입은 같은id를 가지지만idType,kycSeq가 서로 다른 여러 아이템이 존재할 수 있습니다. - 이 경우 Apollo Client는 이들을 모두 같은 캐시 엔트리로 간주하고, 나중에 들어온 항목을 기존 캐시 엔트리에 병합합니다.
- 결과: 서로 다른 데이터들이 병합되어 하나의 데이터로 보이며, UI에서는 여러 행의 데이터가 모두 동일한 값으로 표시되는 현상이 발생했습니다.
이 문제를 해결하기 위해 공식 문서를 참고하여 식별 기준을 재정의해주었습니다.
keyFields를 설정하면 Apollo는 지정한 필드들을 조합해 캐시 id를 생성하며, 서로 다른 아이템이 하나의 캐시 엔트리로 병합되는 문제를 방지할 수 있습니다.
// apolloClient.tsx
import { ApolloClient, InMemoryCache } from '@apollo/client';
//...
cache: new InMemoryCache({
typePolicies: {
RegistrationMember: {
keyFields: ['id', 'idType', 'kycSeq'], // 고유 식별 키 직접 지정
},
},
}),
//...
이렇게 설정하면 Apollo Client는 id, idType, kycSeq 조합을 기반으로 각 RegistrationMember 객체를 별도의 캐시 엔트리로 인식하게 됩니다.
BFF 도입 시 고려해야 할 점
BFF 도입 시 발생하는 사이드 이펙트
BFF는 프론트엔드 생산성과 유연성을 크게 높여주지만, 모든 프로젝트에 반드시 필요한 것은 아닙니다. NextJS 13부터 Server Component, Server Action 등이 본격적으로 도입되면서, 작은 규모나 단일 플랫폼의 프로젝트에서는 BFF 없이도 서버에서 데이터를 집계하고 가공하는 작업이 충분히 가능해졌습니다. 특히 개인 프로젝트·프로토타입·단일 페이지 위주의 서비스에서는 BFF 레이어 없이도 구현 복잡도를 낮출 수 있다는 장점이 있습니다.
반면, BFF를 도입하면 얻는 이점만큼이나 추가로 고려해야 할 책임과 사이드 이펙트가 생깁니다.
- 레이어 추가로 인한 실패 지점 증가
- BFF는 프론트엔드와 백엔드 사이에 위치한 추가 계층이므로, 이 계층에 장애가 나면 프론트엔드 전체 요청 흐름이 차단될 수 있습니다.
- 장애 대응·모니터링·헬스체크 등 운영 관리 포인트가 증가합니다.
- 시스템 복잡성 증가
- BFF는 별도의 코드베이스·배포 파이프라인·테스트 환경을 필요로 하기 때문에 운영·테스트·디버깅 범위가 증가합니다.
BFF 도입을 고려할 만한 시점
이런 상황에서 BFF는 복잡도를 흡수하고 팀 간의 경계를 완화하는 완충 계층으로 작동해, 장기적으로 유지보수성과 생산성을 모두 높일 수 있습니다.
- 서비스 도메인이 점점 복잡해지고 마이크로서비스가 늘어나기 시작할 때
- 백엔드 스펙 변경이 잦아 프론트엔드가 자주 영향을 받는 상황일 때
- 프론트엔드와 백엔드 간 요구사항 소통 비용이 급격히 증가할 때
마치며
처음 컬리에 인턴으로 합류했을 때부터 GraphQL 기반의 BFF가 구축되어 있어 편리하게 사용하고 있었지만, 정작 무엇을 위해 BFF를 쓰는지, 어떤 점을 고려해야 하는지에 대해서는 깊이 생각해본 적이 없었습니다. 이번 글을 작성하며 BFF의 등장 배경부터 그 이점까지 다시 돌아볼 수 있었고, 앞으로도 무언가를 깊이 이해하고 싶을 때 직접 글로 정리하는 과정이 큰 도움이 되겠다는 점을 새삼 느꼈습니다.
또한 백엔드 개발자 분들과의 대화를 통해, 클라이언트 관점의 캐싱과 서버 관점의 캐싱은 동일한 개념이 아닐 수도 있다는 점을 깨닫게 되었습니다. 같은 '캐싱'이라도 바라보는 책임과 목적이 다르다는 것을 이해하면서, 시스템을 더 다양한 관점에서 바라봐야겠다고 다짐했습니다.
끝으로 바쁜 일정 속에서도 검수와 피드백을 아끼지 않아 주신 박주용 님, 김현홍 님, 안재민 님, 이재서 님, 박병찬 님, 그 외 팀원분들께 감사의 말씀을 드립니다. 아울러 현재는 팀을 떠나셨지만, 글의 방향을 잡는 데 큰 도움을 주신 최진호 님께도 진심으로 감사드립니다. 이번 글이 BFF를 처음 접하는 분들께도 작은 인사이트가 되었으면 합니다.
Reference
- 카카오페이지는 BFF(Backend For Frontend)를 어떻게 적용했을까?
- BFF(Backend For Frontend)가 무엇일까?
- BFF(Backend for Frontend) 란?
- Caching in Apollo Client
- Codegen
- Relay, 그리고 Declarative에 대해 다시 생각하기
- How to use Next.js as a backend for your frontend
- 마이크로서비스(MSA) 의 다양한 패턴들 🍀 / 2️⃣ - API 게이트웨이 패턴, BFF 패턴
- BFF (Backend For Frontend) Pattern and API Gateway Optimization