grep

핀테크그룹의 GraphQL 기반 BFF와 프론트엔드 활용기

컬리

2025년 10월 17일

원문에서 보기 ↗

들어가며

안녕하세요. 컬리 핀테크그룹 김재민입니다.

처음에는 컬리 내부 스터디에서 '핀테크그룹의 BFF'를 소개해달라는 요청이 있어, 공부도 할 겸 발표를 자처하며 문서를 작성하기 시작했습니다. 작성하다 보니 GraphQL 기반 BFF를 도입·운영하며 얻은 인사이트가 컬리 구성원 이외에도 유의미할 수 있겠다는 생각이 들어, 보다 많은 분들과 공유하고자 글로 정리했습니다.

BFF는 무엇인가요?

등장 배경

개념과 역할

핀테크그룹의 BFF를 소개합니다

기술 스택

InMemoryCache: Apollo Client가 사용하는 메모리 기반 캐시로, 정규화된 GraphQL 응답을 저장하며 새로고침 시 초기화됩니다.

도입 효과

핀테크그룹 웹 아키텍처


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>;

© 2025. Kurly. All rights reserved.

이처럼 자동으로 생성된 use*Query 훅을 호출하면 data, loading, error 값을 type safety하게 사용할 수 있습니다.

// useMypage.ts (사용 예시)
const { data: { memberDetail: member } = {}, error, loading: memberLoading } = useMemberOnMyPageQuery();

© 2025. Kurly. All rights reserved.

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 };
}

© 2025. Kurly. All rights reserved.

Apollo Client의 캐싱 사용 시 주의할 점

"컴퓨터 과학에서 어려운 것은 단 두 가지다. 캐시 무효화와 이름 짓기" - 필 칼튼(Phil Karlton) 클라이언트 캐싱 vs 백엔드 캐싱

  • 클라이언트 캐싱 은 화면을 즉시 그려 사용자 체감 속도를 높이는 것이 목표입니다.
    • SWR(stale-while-revalidate): 네트워크 요청의 최신 데이터를 기다리면서, 이전에 캐싱된 데이터를 먼저 보여주는 전략입니다.
    • 사용자는 즉시 응답을 받아볼 수 있고, 동시에 백그라운드에서 최신 데이터를 가져와 갱신합니다. 따라서 반응성(빠른 응답)과 최신성(백그라운드 갱신)을 모두 확보할 수 있습니다.
  • 백엔드 캐싱은 데이터 일관성을 지키면서 시스템 부하를 줄이고 레이턴시를 단축하는 것이 목표입니다.

Apollo Client는 기본적으로 정규화 캐싱(normalized caching) 방식을 사용합니다. 정규화 캐싱은 서버 응답 데이터를 특정 식별 기준으로 쪼개어 개별 엔티티 단위로 저장 하고, 동일 데이터를 참조하는 모든 곳이 하나의 캐시 엔트리를 공유하도록 만드는 방식입니다.

Apollo Client의 기본 캐싱 정책

실제로 겪은 문제 사례

아래는 BFF 개발 과정에서 실제로 발생한 문제입니다. 공통적으로 사용하는 Fragment가 있었고, 다음과 같이 정의되어 있었습니다.

fragment RegistBusinessMember on RegistrationMember {
  id
  idType
  cddAndEddInfo
  kycState
  createdAt
  kycSeq
}

© 2025. Kurly. All rights reserved.

이 Fragment를 사용하는 RegistrationMember 리스트 조회 쿼리에서, 다음과 같은 조건이 있었습니다. 위의 캐싱 정책과 조건을 함께 고려하였을 때 어떤 문제가 발생할까요?

같은 id를 가지고 있으면서 서로 다른 idType, kycSeq 값을 가진 아이템이 하나의 캐시 엔트리로 병합되어, 서로 다른 아이템들이 모두 동일한 데이터로 보입니다.

이 문제를 해결하기 위해 공식 문서를 참고하여 식별 기준을 재정의해주었습니다.

keyFields를 설정하면 Apollo는 지정한 필드들을 조합해 캐시 id를 생성하며, 서로 다른 아이템이 하나의 캐시 엔트리로 병합되는 문제를 방지할 수 있습니다.

// apolloClient.tsx
import { ApolloClient, InMemoryCache } from '@apollo/client';
//...
  cache: new InMemoryCache({
    typePolicies: {
      RegistrationMember: {
        keyFields: ['id', 'idType', 'kycSeq'], // 고유 식별 키 직접 지정
      },
    },
  }),
//...

© 2025. Kurly. All rights reserved.

이렇게 설정하면 Apollo Client는 id, idType, kycSeq 조합을 기반으로 각 RegistrationMember 객체를 별도의 캐시 엔트리로 인식하게 됩니다.


BFF 도입 시 고려해야 할 점

BFF 도입 시 발생하는 사이드 이펙트

BFF는 프론트엔드 생산성과 유연성을 크게 높여주지만, 모든 프로젝트에 반드시 필요한 것은 아닙니다. NextJS 13부터 Server Component, Server Action 등이 본격적으로 도입되면서, 작은 규모나 단일 플랫폼의 프로젝트에서는 BFF 없이도 서버에서 데이터를 집계하고 가공하는 작업이 충분히 가능해졌습니다. 특히 개인 프로젝트·프로토타입·단일 페이지 위주의 서비스에서는 BFF 레이어 없이도 구현 복잡도를 낮출 수 있다는 장점이 있습니다.

반면, BFF를 도입하면 얻는 이점만큼이나 추가로 고려해야 할 책임과 사이드 이펙트가 생깁니다.

BFF 도입을 고려할 만한 시점

이런 상황에서 BFF는 복잡도를 흡수하고 팀 간의 경계를 완화하는 완충 계층으로 작동해, 장기적으로 유지보수성과 생산성을 모두 높일 수 있습니다.

마치며

처음 컬리에 인턴으로 합류했을 때부터 GraphQL 기반의 BFF가 구축되어 있어 편리하게 사용하고 있었지만, 정작 무엇을 위해 BFF를 쓰는지, 어떤 점을 고려해야 하는지에 대해서는 깊이 생각해본 적이 없었습니다. 이번 글을 작성하며 BFF의 등장 배경부터 그 이점까지 다시 돌아볼 수 있었고, 앞으로도 무언가를 깊이 이해하고 싶을 때 직접 글로 정리하는 과정이 큰 도움이 되겠다는 점을 새삼 느꼈습니다.

또한 백엔드 개발자 분들과의 대화를 통해, 클라이언트 관점의 캐싱과 서버 관점의 캐싱은 동일한 개념이 아닐 수도 있다는 점을 깨닫게 되었습니다. 같은 '캐싱'이라도 바라보는 책임과 목적이 다르다는 것을 이해하면서, 시스템을 더 다양한 관점에서 바라봐야겠다고 다짐했습니다.

끝으로 바쁜 일정 속에서도 검수와 피드백을 아끼지 않아 주신 박주용 님, 김현홍 님, 안재민 님, 이재서 님, 박병찬 님, 그 외 팀원분들께 감사의 말씀을 드립니다. 아울러 현재는 팀을 떠나셨지만, 글의 방향을 잡는 데 큰 도움을 주신 최진호 님께도 진심으로 감사드립니다. 이번 글이 BFF를 처음 접하는 분들께도 작은 인사이트가 되었으면 합니다.

Reference