API 수정 요청? 이제 그만! BFF로 프론트엔드에 책임 떠넘기기 🙅‍♂️💼

타락한스벨트전도사·2024년 10월 27일
12
post-thumbnail

BFF를 고려한 새로운 백엔드 설계: 실험적 접근

들어가며: 누구를 위한 글인가요? 🎯

이 글은 새로운 프로젝트를 시작하는 백엔드 개발자를 위한 실험적인 API 설계 방법을 다룹니다. 기존 백엔드를 수정하자는 제안이 아닌, 처음부터 BFF(Backend For Frontend) 친화적으로 설계하는 방법을 소개합니다.

💡 잠깐만요!
BFF는 Backend For Frontend의 약자로, 프론트엔드의 요구사항에 최적화된 백엔드 계층을 의미합니다.
Next.js, SvelteKit 같은 프레임워크가 이 역할을 수행할 수 있습니다.

트레이드오프: 이득과 손실 ⚖️

얻는 것 ✅

  • 기획 변경에 대한 유연한 대응
    • 백엔드 코드 수정 없이 BFF에서 데이터 재구성 가능
    • 새로운 기획 요구사항에 빠른 대응
    • A/B 테스트 용이
  • 프론트엔드 개발 자율성 확보
    • 필요한 데이터를 자유롭게 조합
    • UI 종속적인 로직을 BFF에서 처리
  • 백엔드 개발 생산성 향상
    • 단순한 API 설계
    • 변경 요청 감소

잃는 것 ❌

  • 네트워크 통신량 증가
  • BFF 계층의 복잡도 상승
  • 일부 성능 오버헤드

🔍 현대의 빠른 네트워크 환경에서는 초기 단계에 충분히 수용 가능한 트레이드오프입니다.
특히 기획이 자주 변경되는 초기 단계에서는, 늘어난 네트워크 통신량보다 기획 대응의 유연성이 더 큰 가치를 제공합니다.

적용 가이드: 언제 써야 할까요? 🤔

적합한 상황 👍

  • MVP(Minimum Viable Product) 개발 단계

    "빠르게 만들고, 시장 반응을 보고 수정하자!"
  • 빠른 프로토타이핑이 필요한 경우

    "다음 주까지 데모 버전이 필요해요!"
  • 프론트엔드 요구사항이 자주 변경되는 초기 단계

    "UI/UX가 아직 확정되지 않았어요."

부적합한 상황 👎

  • 이미 안정화된 대규모 서비스

    "일일 사용자가 100만 명이 넘는 서비스입니다."
  • 네트워크 대역폭이 제한적인 환경

    "모바일 환경에서 데이터 사용량을 최소화해야 해요."
  • 엄격한 성능 요구사항이 있는 경우

    "응답 시간이 50ms를 넘으면 안 됩니다."

TL;DR 📌

  • 이 접근 방식은 초기 개발 단계에서 빠른 진행을 위한 실험적인 방법입니다.
  • 네트워크 통신량 증가를 감수하고 개발 생산성을 높이는 트레이드오프가 있습니다.
  • 프로젝트가 성장하면서 필요한 부분은 점진적으로 최적화할 수 있습니다.

전통적 API와 DB-like API: 실전에서의 고민

리소스 관계 설계의 딜레마 🤔

API 설계, 처음에는 단순해 보입니다. 리소스 간의 관계를 URL 구조로 표현하면 되니까요. 예를 들어:

/members/{memberId}/orders  // 회원의 주문 목록
/blogs/{blogId}/posts      // 블로그의 게시글 목록

직관적이고 깔끔해 보이죠? 하지만 실전에서는 이야기가 달라집니다.

현실의 벽: 기획 변경의 연속 😮‍💨

제가 겪은 실제 사례를 공유해드릴게요. 처음에는 단순했습니다:

  • 회원별 주문 내역 조회 API
  • 블로그별 게시글 목록 API

그런데 시간이 지날수록 요구사항이 복잡해졌어요:

  • "어드민 페이지에서는 모든 주문을 한번에 봐야 해요"
  • "블로그 글 목록에 작성자 정보도 같이 보여주세요"
  • "아, 글 목록에 댓글 수도 표시해주세요"

결국 이런 식으로 변하기 시작했죠:

/orders?memberId={...}           // 어드민용
/members/{memberId}/orders      // 사용자용
/posts?blogId={...}&withAuthor=true&withCommentCount=true  // 이건 뭐죠?

깨달음: 소유 관계의 모호함 💡

리소스의 소유 관계라는 게 처음에는 명확해 보이지만, 실제로는 상황에 따라 굉장히 모호해질 수 있습니다.

예를 들어:

  • 주문은 회원의 소유물인가요?
  • 아니면 그냥 회원 ID가 있는 독립적인 데이터인가요?
  • 게시글은 블로그의 소유물인가요?
  • 아니면 작성자의 소유물인가요?

이런 고민이 들기 시작하면서, 저는 다른 접근 방식이 필요하다는 걸 깨달았습니다.

새로운 접근: DB-like API의 실험 🧪

그래서 시도한 것이 DB-like API 접근법입니다.

// 심플한 엔드포인트 구조
@GetMapping("/orders")
fun getOrders(@RequestParam memberId: Long? = null): List<Order>

@GetMapping("/members")
fun getMembers(@RequestParam orderIds: List<Long>): List<Member>

이제 BFF에서 데이터를 조합합니다:

async function getMemberOrders(memberId: number) {
  const orders = await fetchOrders({ memberId })
  const memberInfo = await fetchMembers({ orderIds: orders.map(o => o.memberId) })
  
  return {
    orders,
    memberInfo
  }
}

장점:

  1. 백엔드 API가 단순해져서 변경이 쉬워졌어요
  2. 기획 변경에 유연하게 대응할 수 있게 됐습니다
  3. 프론트엔드/BFF에서 필요한 데이터만 선택적으로 가져올 수 있어요

물론 이 방식이 완벽한 건 아닙니다. GraphQL을 도입하는 것도 좋은 대안이 될 수 있죠. 하지만 초기 프로젝트나 빠른 프로토타이핑이 필요한 상황에서는, 이런 DB-like API 접근이 효과적인 선택이 될 수 있습니다.

💡 참고: 이는 실험적인 접근 방식입니다. 대규모 서비스나 성능이 중요한 상황에서는 신중한 검토가 필요해요.

DB-like API 설계 원칙: 단순하게, 하지만 강력하게

단순한 엔드포인트 구조의 힘 💪

제가 처음 이 방식을 시도했을 때는 망설임이 있었습니다. "API가 너무 단순한 것 아닐까?", "이렇게 해도 될까?" 하는 생각이 들었거든요. 하지만 실제로 적용해보니, 이 단순함이 오히려 큰 강점이 되었습니다.

기본 원칙: DB 테이블 기준 설계

// 기존의 복잡한 계층 구조
/blogs/{blogId}/posts/{postId}/comments  // ❌

// DB-like API 방식
@GetMapping("/posts")
fun getPosts(@RequestParam blogIds: List<Long>? = null): List<Post>

@GetMapping("/comments")
fun getComments(@RequestParam postIds: List<Long>? = null): List<Comment>

여기서 중요한 점은 부모-자식 관계를 URL 구조로 표현하지 않는다는 거예요. 대신 query parameter로 필터링합니다.

트랜잭션 단위의 예외 허용

단, 데이터 정합성이 필요한 경우는 예외를 둡니다:

// 포스트 작성 시 태그도 함께 생성
@PostMapping("/posts")
@Transactional
fun createPost(@RequestBody request: PostCreateRequest): PostResponse {
    val post = postRepository.save(request.toPost())
    val tags = request.tags.map { Tag(postId = post.id, name = it) }
    tagRepository.saveAll(tags)
    return PostResponse(post, tags)
}

배열 기반 Query Parameters의 활용

모든 리스트 조회는 배열 형태의 필터링을 지원합니다:

@GetMapping("/posts")
fun getPosts(
    @RequestParam blogIds: List<Long>? = null,
    @RequestParam authorIds: List<Long>? = null,
    @RequestParam tagNames: List<String>? = null
): List<Post> {
    return postRepository.findByFilter(blogIds, authorIds, tagNames)
}

이렇게 하면 BFF에서 다양한 조합의 데이터 조회가 가능해집니다:

// Next.js나 SvelteKit의 서버 사이드 코드
async function getBlogOverview(blogId: number) {
  // 병렬로 필요한 데이터를 모두 조회
  const [posts, comments, authors, tags] = await Promise.all([
    fetch(`/api/posts?blogIds=${blogId}`),
    fetch(`/api/comments?blogIds=${blogId}`),
    fetch(`/api/authors?blogIds=${blogId}`),
    fetch(`/api/tags?blogIds=${blogId}`)
  ])

  // 데이터 조합은 BFF에서 자유롭게
  return {
    recentPosts: posts.slice(0, 5).map(post => ({
      ...post,
      author: authors.find(a => a.id === post.authorId),
      commentCount: comments.filter(c => c.postId === post.id).length,
      tags: tags.filter(t => t.postId === post.id)
    })),
    topAuthors: [...] // 자유로운 데이터 가공
  }
}

기획 변경? 걱정 마세요!

이제 새로운 화면이나 기능이 추가되어도 백엔드 API를 수정할 필요가 없습니다. BFF에서 기존 API를 조합해 새로운 형태의 데이터를 만들어내면 되니까요.

// 새로운 기획: 블로그 통계 페이지
async function getBlogStats(blogId: number) {
  // 기존 API를 활용해 새로운 통계 생성
  const [posts, comments, authors] = await Promise.all([...])

  return {
    totalPosts: posts.length,
    averageCommentsPerPost: comments.length / posts.length,
    topAuthors: authors
      .map(author => ({
        ...author,
        postCount: posts.filter(p => p.authorId === author.id).length
      }))
      .sort((a, b) => b.postCount - a.postCount)
      .slice(0, 5)
  }
}

정리하면... 📝

이 접근 방식의 핵심은:
1. DB 테이블 기준의 단순한 엔드포인트
2. 트랜잭션 단위의 예외 허용
3. 배열 기반의 유연한 필터링
4. BFF에서의 자유로운 데이터 조합

이게 전부입니다! 단순하지만, 이 단순함이 오히려 유연성을 가져다 줍니다.

대규모 서비스로의 진화 전략: DB-like API의 최적화

성장통: 우리가 마주할 문제들 🤔

프로젝트가 성장하면서 DB-like API 방식은 몇 가지 도전 과제를 마주하게 됩니다:

  1. 늘어나는 네트워크 요청 수
  2. 커져가는 응답 크기
  3. BFF 단의 복잡도 증가

하지만 걱정하지 마세요. 이런 문제들은 점진적으로 해결할 수 있습니다.

단계적 최적화 전략 📈

1단계: 기존 API 최적화

가장 먼저 시도할 수 있는 건 기존 API의 최적화입니다:

// Before: 모든 필드를 반환
@GetMapping("/posts")
fun getPosts(): List<Post>

// After: 필요한 필드만 선택적으로 반환
@GetMapping("/posts")
fun getPosts(
    @RequestParam fields: List<String>? = null
): List<Map<String, Any>>

2단계: 자주 쓰이는 조합은 전용 API로

특정 화면이나 기능이 자주 사용되고 안정화되었다면, 전용 API를 만드는 것도 좋은 전략입니다:

// 자주 사용되는 블로그 홈 화면용 전용 API
@GetMapping("/blog-home/{blogId}")
fun getBlogHome(
    @PathVariable blogId: Long
): BlogHomeResponse {
    return blogHomeService.getBlogHome(blogId)
}

3단계: 하이브리드 접근

모든 API를 한 번에 바꿀 필요는 없습니다. 점진적으로 최적화가 필요한 부분만 전통적인 방식으로 전환할 수 있습니다:

// DB-like API는 그대로 유지
@GetMapping("/posts")
fun getPosts(): List<Post>

// 최적화가 필요한 부분만 전용 API 추가
@GetMapping("/blogs/{blogId}/summary")
fun getBlogSummary(
    @PathVariable blogId: Long
): BlogSummary

실제 최적화 사례: 블로그 시스템 📝

제가 경험한 실제 사례를 공유해드리겠습니다.

Before: 초기 구현

async function getBlogHome(blogId: number) {
  // 여러 API 호출 필요
  const [posts, authors, comments] = await Promise.all([
    fetch(`/api/posts?blogId=${blogId}`),
    fetch(`/api/authors`),
    fetch(`/api/comments?blogId=${blogId}`)
  ])
  
  // BFF에서 데이터 조합
  return {
    posts: posts.map(post => ({
      ...post,
      author: authors.find(a => a.id === post.authorId),
      commentCount: comments.filter(c => c.postId === post.id).length
    }))
  }
}

After: 최적화된 버전

// 백엔드에 전용 API 추가
@GetMapping("/blogs/{blogId}/home")
fun getBlogHome(@PathVariable blogId: Long): BlogHomeResponse {
    return blogService.getBlogHome(blogId)
}

// 기존 DB-like API는 관리자 기능 등을 위해 유지
@GetMapping("/posts")
fun getPosts(): List<Post>

정리: 최적화의 황금률 🌟

  1. 점진적 접근

    • 성능 문제가 실제로 발생한 부분만 최적화
    • 기존 API는 그대로 유지하면서 새로운 API 추가
  2. 데이터 기반 의사결정

    • 실제 사용 패턴을 분석하여 최적화 대상 선정
    • 성능 메트릭을 통한 효과 측정
  3. 하위 호환성 유지

    • 기존 DB-like API는 그대로 유지
    • 새로운 최적화된 API 추가 방식으로 접근

💡 조언: 너무 이른 최적화는 독이 될 수 있습니다. 실제 문제가 발생했을 때 최적화하세요.

이러한 전략을 통해, 우리는 초기의 빠른 개발 속도를 유지하면서도, 필요에 따라 점진적으로 서비스를 최적화할 수 있습니다. 결국 중요한 건 균형이죠!

복습과 정리: BFF를 위한 실용적인 백엔드 API 설계

지금까지 우리는 BFF 패턴을 위한 새로운 백엔드 API 설계 방법을 살펴보았습니다. 이제 핵심 내용을 실용적인 관점에서 정리해보겠습니다.

핵심 설계 원칙 📋

1. 단일 리소스 원칙: 심플함이 미덕 🎯

// Before: 복잡한 계층 구조
@GetMapping("/blogs/{blogId}/posts/{postId}/comments")  // ❌

// After: 단순한 리소스 중심
@GetMapping("/posts")  // ✅
@GetMapping("/comments")  // ✅

2. Query Parameters의 활용 🔍

@GetMapping("/posts")
fun getPosts(
    @RequestParam blogIds: List<Long>? = null,
    @RequestParam authorIds: List<Long>? = null
): List<Post>

@GetMapping("/comments")
fun getComments(
    @RequestParam postIds: List<Long>? = null
): List<Comment>

3. 트랜잭션 단위의 작업 처리 ⚡

@PostMapping("/posts")
@Transactional
fun createPost(@RequestBody request: PostCreateRequest): PostResponse {
    val post = postRepository.save(request.toPost())
    val tags = tagRepository.saveAll(request.tags.map { Tag(postId = post.id, name = it) })
    return PostResponse(post, tags)
}

실전 응용: 블로그 시스템 예시 💡

백엔드 API 설계:

// 1. 포스트 정보
@GetMapping("/posts")
fun getPosts(@RequestParam blogIds: List<Long>? = null): List<Post>

// 2. 좋아요 정보
@GetMapping("/post-likes")
fun getPostLikes(@RequestParam postIds: List<Long>): List<PostLike>

// 3. 댓글 정보
@GetMapping("/comments")
fun getComments(@RequestParam postIds: List<Long>): List<Comment>

BFF에서의 데이터 조합:

async function getBlogOverview(blogId: number) {
  // 1. 필요한 데이터 병렬 요청
  const [posts, likes, comments] = await Promise.all([
    fetch(`/api/posts?blogIds=${blogId}`),
    fetch(`/api/post-likes?postIds=${postIds}`),
    fetch(`/api/comments?postIds=${postIds}`)
  ]);

  // 2. 데이터 조합
  return posts.map(post => ({
    ...post,
    likeCount: likes.find(l => l.postId === post.id)?.count ?? 0,
    commentCount: comments.filter(c => c.postId === post.id).length
  }));
}

시스템 아키텍처 도식 🏗️

    클라이언트 (React, Vue, Svelte...)
              ↑
              | (완성된 데이터)
              |
     +----------------+
     |      BFF      | → 데이터 조합
     |  (Next.js 등) | → 캐싱 처리
     +----------------+
         ↑    ↑    ↑
         |    |    |
    +----+----+----+----+
    |    |    |    |    |
  posts  |  likes  |  comments
         |         |
      authors    tags
    
    백엔드 API (Spring, NestJS...)

발전 전략 🚀

  1. 초기 단계

    • DB-like API로 빠른 개발
    • BFF에서 자유로운 데이터 조합
  2. 성장 단계

    • 자주 사용되는 조합은 전용 API로 최적화
    • 기존 DB-like API는 유지 (관리자 기능 등에 활용)
  3. 최적화 단계

    • 성능 병목 지점 분석
    • 필요한 부분만 선택적 최적화

마지막으로... 📝

이 접근 방식은 완벽한 해결책이 아닌, 초기 개발에 특화된 전략임을 기억해주세요:

  • 장점: 빠른 개발, 유연한 대응, 백엔드 단순화
  • ⚠️ 주의점: 네트워크 부하, BFF 복잡도 증가
  • 🎯 적합한 상황: MVP, 프로토타입, 빠른 실험이 필요한 경우

여러분의 프로젝트에 맞는 최적의 방식을 선택하시길 바랍니다! 🙌

#BackendDevelopment #BFF #APIDesign #실용적접근

profile
스벨트쓰고요. 오픈소스 운영합니다

5개의 댓글

comment-user-thumbnail
2024년 10월 31일

엄청 심플해지네요!

답글 달기
comment-user-thumbnail
2024년 11월 4일

RN도 있다면 bff를 어떻게 구성하는게 좋을까요?

1개의 답글
comment-user-thumbnail
2024년 11월 17일

bff 가 아닌 단순 network layer와 view model 의 분리로도 이룰 수 있는것 같은데 BFF 라는 복잡성을 가지고 가야하는 추가적인 요인이 있을까요?

1개의 답글