−시퀀스 다이어그램은 상품 카드를 여는 요청 한 건을 시간 흐름에 따라 보여준다. 세로 점선은 각 참여자의 생명선이며, 위에서 아래로 메시지를 읽는다. par 영역에서는 상품 조회와 재고 조회를 병렬로 진행한다.
[SVG 다이어그램]
BFF는 두 조회 결과를 받은 뒤 수량을 inStock으로 바꾸고 앱에 반환한다. 앱에서 BFF로 가는 요청은 한 번이고, BFF와 내부 서비스 사이에는 두 번의 조회가 있다. 병렬 조회는 두 서비스를 기다리는 시간을 겹치게 하지만, 응답 시간은 실제 네트워크와 느린 조회의 영향을 받는다.[각주 7]
같은 흐름을 코드로 읽기
아래 JavaScript를 .mjs 파일로 저장하면 Node.js로 실행할 수 있다. 통신은 두 비동기 함수로 대신했다. 각 함수는 성공이면 ok: true와 value, 실패면 ok: false와 error를 반환한다고 약속하며, mobileProduct는 두 결과를 받아 화면용 객체를 만든다.
async function mobileProduct(id, services) {
const [product, inventory] = await Promise.all([
services.getProduct(id),
services.getInventory(id),
]);
if (!product.ok) return product;
if (!inventory.ok) return inventory;
return {
ok: true,
value: {
id: product.value.id,
name: product.value.name,
inStock: inventory.value.quantity > 0,
},
};
}
const services = {
getProduct: async (id) => ({
ok: true, value: { id, name: "Travel mug", price: 12000 },
}),
getInventory: async () => ({ ok: true, value: { quantity: 7 } }),
};
console.log(await mobileProduct("p-100", services));
{ "ok": true, "value": { "id": "p-100", "name": "Travel mug", "inStock": true } }
재고 조회가 실패하면 함수는 그 실패를 반환한다. 실패를 수량 0으로 바꾸면 앱은 조회 장애를 품절로 표시하게 되므로 이 예제에서는 그 둘을 구별한다. 실제 API에서는 이 결과를 화면이 이해하는 오류 응답으로 변환한다.
어떤 규칙을 어디에 둘까
부분 | 이 예제에서 맡는 일
프런트엔드 | 상품 카드를 그리고 사용자 입력을 받는다.
BFF | 상품·재고 결과를 모아 화면용 응답을 만든다.
업무 서비스 | 상품 가격, 재고 수량과 예약 가능 여부를 결정한다.
API Gateway | 여러 API의 공통 진입점에서 라우팅과 공통 접근 정책을 적용한다.
+시퀀스 다이어그램은 캐시에 없는 목록을 읽는 경우다. BFF가 콘텐츠 서비스에서 응답을 받은 뒤 캐시 정책을 적용하고, Next.js 프록시를 거쳐 브라우저로 돌려준다. 캐시 HIT이면 BFF가 저장된 응답을 반환하므로 콘텐츠 서비스 조회를 건너뛴다.[각주 10][각주 9]
[SVG 다이어그램]
실제 코드 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'인 글을 고른다. 이어 아래 코드가 본문으로 읽는 시간을 계산하고, 본문 필드를 뺀 목록과 전체 개수를 반환한다. 읽는 시간 계산은 이 경로에서 콘텐츠 서비스의 책임이다.[각주 11][각주 12]
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 | 게시물의 상태와 본문을 저장한다.