이 글은 새로운 프로젝트를 시작하는 백엔드 개발자를 위한 실험적인 API 설계 방법을 다룹니다. 기존 백엔드를 수정하자는 제안이 아닌, 처음부터 BFF(Backend For Frontend) 친화적으로 설계하는 방법을 소개합니다.
💡 잠깐만요!
BFF는 Backend For Frontend의 약자로, 프론트엔드의 요구사항에 최적화된 백엔드 계층을 의미합니다.
Next.js, SvelteKit 같은 프레임워크가 이 역할을 수행할 수 있습니다.
🔍 현대의 빠른 네트워크 환경에서는 초기 단계에 충분히 수용 가능한 트레이드오프입니다.
특히 기획이 자주 변경되는 초기 단계에서는, 늘어난 네트워크 통신량보다 기획 대응의 유연성이 더 큰 가치를 제공합니다.
MVP(Minimum Viable Product) 개발 단계
"빠르게 만들고, 시장 반응을 보고 수정하자!"
빠른 프로토타이핑이 필요한 경우
"다음 주까지 데모 버전이 필요해요!"
프론트엔드 요구사항이 자주 변경되는 초기 단계
"UI/UX가 아직 확정되지 않았어요."
이미 안정화된 대규모 서비스
"일일 사용자가 100만 명이 넘는 서비스입니다."
네트워크 대역폭이 제한적인 환경
"모바일 환경에서 데이터 사용량을 최소화해야 해요."
엄격한 성능 요구사항이 있는 경우
"응답 시간이 50ms를 넘으면 안 됩니다."
API 설계, 처음에는 단순해 보입니다. 리소스 간의 관계를 URL 구조로 표현하면 되니까요. 예를 들어:
/members/{memberId}/orders // 회원의 주문 목록
/blogs/{blogId}/posts // 블로그의 게시글 목록
직관적이고 깔끔해 보이죠? 하지만 실전에서는 이야기가 달라집니다.
제가 겪은 실제 사례를 공유해드릴게요. 처음에는 단순했습니다:
그런데 시간이 지날수록 요구사항이 복잡해졌어요:
결국 이런 식으로 변하기 시작했죠:
/orders?memberId={...} // 어드민용
/members/{memberId}/orders // 사용자용
/posts?blogId={...}&withAuthor=true&withCommentCount=true // 이건 뭐죠?
리소스의 소유 관계라는 게 처음에는 명확해 보이지만, 실제로는 상황에 따라 굉장히 모호해질 수 있습니다.
예를 들어:
이런 고민이 들기 시작하면서, 저는 다른 접근 방식이 필요하다는 걸 깨달았습니다.
그래서 시도한 것이 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
}
}
물론 이 방식이 완벽한 건 아닙니다. GraphQL을 도입하는 것도 좋은 대안이 될 수 있죠. 하지만 초기 프로젝트나 빠른 프로토타이핑이 필요한 상황에서는, 이런 DB-like API 접근이 효과적인 선택이 될 수 있습니다.
💡 참고: 이는 실험적인 접근 방식입니다. 대규모 서비스나 성능이 중요한 상황에서는 신중한 검토가 필요해요.
제가 처음 이 방식을 시도했을 때는 망설임이 있었습니다. "API가 너무 단순한 것 아닐까?", "이렇게 해도 될까?" 하는 생각이 들었거든요. 하지만 실제로 적용해보니, 이 단순함이 오히려 큰 강점이 되었습니다.
// 기존의 복잡한 계층 구조
/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)
}
모든 리스트 조회는 배열 형태의 필터링을 지원합니다:
@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 방식은 몇 가지 도전 과제를 마주하게 됩니다:
하지만 걱정하지 마세요. 이런 문제들은 점진적으로 해결할 수 있습니다.
가장 먼저 시도할 수 있는 건 기존 API의 최적화입니다:
// Before: 모든 필드를 반환
@GetMapping("/posts")
fun getPosts(): List<Post>
// After: 필요한 필드만 선택적으로 반환
@GetMapping("/posts")
fun getPosts(
@RequestParam fields: List<String>? = null
): List<Map<String, Any>>
특정 화면이나 기능이 자주 사용되고 안정화되었다면, 전용 API를 만드는 것도 좋은 전략입니다:
// 자주 사용되는 블로그 홈 화면용 전용 API
@GetMapping("/blog-home/{blogId}")
fun getBlogHome(
@PathVariable blogId: Long
): BlogHomeResponse {
return blogHomeService.getBlogHome(blogId)
}
모든 API를 한 번에 바꿀 필요는 없습니다. 점진적으로 최적화가 필요한 부분만 전통적인 방식으로 전환할 수 있습니다:
// DB-like API는 그대로 유지
@GetMapping("/posts")
fun getPosts(): List<Post>
// 최적화가 필요한 부분만 전용 API 추가
@GetMapping("/blogs/{blogId}/summary")
fun getBlogSummary(
@PathVariable blogId: Long
): BlogSummary
제가 경험한 실제 사례를 공유해드리겠습니다.
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
}))
}
}
// 백엔드에 전용 API 추가
@GetMapping("/blogs/{blogId}/home")
fun getBlogHome(@PathVariable blogId: Long): BlogHomeResponse {
return blogService.getBlogHome(blogId)
}
// 기존 DB-like API는 관리자 기능 등을 위해 유지
@GetMapping("/posts")
fun getPosts(): List<Post>
점진적 접근
데이터 기반 의사결정
하위 호환성 유지
💡 조언: 너무 이른 최적화는 독이 될 수 있습니다. 실제 문제가 발생했을 때 최적화하세요.
이러한 전략을 통해, 우리는 초기의 빠른 개발 속도를 유지하면서도, 필요에 따라 점진적으로 서비스를 최적화할 수 있습니다. 결국 중요한 건 균형이죠!
지금까지 우리는 BFF 패턴을 위한 새로운 백엔드 API 설계 방법을 살펴보았습니다. 이제 핵심 내용을 실용적인 관점에서 정리해보겠습니다.
// Before: 복잡한 계층 구조
@GetMapping("/blogs/{blogId}/posts/{postId}/comments") // ❌
// After: 단순한 리소스 중심
@GetMapping("/posts") // ✅
@GetMapping("/comments") // ✅
@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>
@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)
}
// 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>
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...)
초기 단계
성장 단계
최적화 단계
이 접근 방식은 완벽한 해결책이 아닌, 초기 개발에 특화된 전략임을 기억해주세요:
여러분의 프로젝트에 맞는 최적의 방식을 선택하시길 바랍니다! 🙌
#BackendDevelopment #BFF #APIDesign #실용적접근
엄청 심플해지네요!