마이크로서비스 집계를 위한 Go로 견고한 BFF 구축하기
James Reed
Infrastructure Engineer · Leapcell

소개
끊임없이 진화하는 현대 소프트웨어 아키텍처 환경에서 마이크로서비스는 확장 가능하고 탄력적이며 독립적으로 배포 가능한 애플리케이션을 구축하기 위한 사실상의 표준이 되었습니다. 마이크로서비스의 이점은 부인할 수 없지만, 특히 프론트엔드 개발에 있어 새로운 과제를 안겨줍니다. 단일 UI 페이지는 종종 여러 분산된 마이크로서비스에서 데이터를 검색해야 합니다. 이는 클라이언트가 수많은 요청을 보내 지연 시간을 늘리고, 데이터 집계를 복잡하게 하며, 프론트엔드와 개별 마이크로서비스 간의 긴밀한 결합을 생성하는 "수다스러운" 프론트엔드로 이어질 수 있습니다.
이것이 바로 백엔드 포 프론트엔드(BFF) 패턴이 빛을 발하는 지점입니다. BFF는 특정 프론트엔드(웹, 모바일 등)에 맞게 특별히 구축된 중개 계층 역할을 하며, 다양한 다운스트림 마이크로서비스에서 데이터를 집계하고 클라이언트가 직접 소비할 수 있는 형식으로 만듭니다. 프론트엔드를 마이크로서비스 아키텍처의 복잡성에서 분리하고, 프론트엔드 개발을 단순화하며, 네트워크 통신을 최적화합니다. Go는 우수한 동시성 기본 기능, 높은 성능, 강력한 표준 라이브러리를 갖추고 있어 이러한 중요한 구성 요소를 구축하는 데 이상적인 선택입니다. 이 글에서는 다운스트림 마이크로서비스를 집계하기 위해 Go를 사용하여 강력하고 효율적인 BFF 계층을 구축하는 방법에 대해 자세히 알아보겠습니다.
BFF 패턴 해부
구현에 들어가기 전에 BFF 패턴과 마이크로서비스 생태계에서의 역할과 관련된 몇 가지 핵심 개념을 명확히 해보겠습니다.
마이크로서비스: 애플리케이션을 느슨하게 결합되고 독립적으로 배포 가능한 서비스 컬렉션으로 구조화하는 아키텍처 스타일입니다. 각 서비스는 일반적으로 단일 비즈니스 기능에 중점을 둡니다.
백엔드 포 프론트엔드(BFF): 특정 사용자 인터페이스(UI) 또는 프론트엔드 애플리케이션에서 소비하기 위해 특별히 구축된 백엔드 서비스입니다. 단일의 범용 백엔드 대신, 특정 클라이언트(예: 웹용 하나, iOS용 하나, Android용 하나)에 최적화된 여러 BFF가 있을 수 있습니다.
API 게이트웨이: 마이크로서비스 시스템으로 들어오는 모든 클라이언트의 단일 진입점입니다. 라우팅, 인증, 권한 부여, 속도 제한 및 기타 교차 관심사를 처리할 수 있습니다. BFF는 일부 API 게이트웨이 기능을 통합할 수 있지만, 주된 초점은 특정 프론트엔드를 위한 데이터 집계 및 변환이며, API 게이트웨이는 더 범용적이며 중앙 프록시 역할을 합니다. 종종 BFF는 API 게이트웨이 뒤에 있습니다.
다운스트림 마이크로서비스: BFF가 데이터를 검색하고 집계하기 위해 상호 작용하는 개별 마이크로서비스입니다.
BFF의 핵심 아이디어는 프론트엔드 개발을 단순화하는 통합되고 클라이언트별 API를 제공하는 것입니다. 프론트엔드가 다섯 개의 다른 마이크로서비스를 알고 호출하는 대신, BFF에 한 번 호출하면 BFF는 해당 다섯 개 서비스에 대한 호출을 조정하고 결과를 집계하며 단일의 잘 구조화된 응답을 반환합니다.
BFF에 대한 Go의 선호 선택
Go의 강점은 고성능 BFF의 요구 사항과 완벽하게 일치합니다.
- 동시성 (고루틴 & 채널): BFF는 종종 서로 다른 다운스트림 서비스에 대해 여러 동시 요청을 해야 합니다. Go의 경량 고루틴과 채널은 동시 프로그래밍을 매우 간단하고 효율적으로 만들어 BFF가 데이터를 병렬로 가져오고 전체 응답 시간을 크게 줄일 수 있도록 합니다.
- 성능: Go는 네이티브 기계 코드로 컴파일되어 우수한 런타임 성능과 낮은 지연 시간을 제공하며, 이는 신속하게 응답해야 하는 중개 서비스에 매우 중요합니다.
- 강력한 네트워킹 지원: Go의
net/http
패키지는 강력하고 사용하기 쉬우며, 견고한 HTTP 서버 및 클라이언트를 구축하는 데 필요한 모든 것을 제공합니다. - 간결성과 가독성: Go의 구문은 간결하고 읽기 쉬워 개발 속도와 유지 관리성을 향상시킵니다.
- 작은 풋프린트: Go 바이너리는 정적으로 연결되고 상대적으로 작은 메모리 풋프린트를 가지므로 컨테이너화된 환경에 효율적으로 배포할 수 있습니다.
Go에서 기본 BFF 구현
실제 예시로 개념을 설명해 보겠습니다. 제품 세부 정보 페이지에 다음이 표시되어야 하는 가상 전자상거래 애플리케이션을 상상해 보세요:
- 제품 기본 정보 (
Product Service
에서) - 고객 리뷰 (
Review Service
에서) - 재고 (
Inventory Service
에서)
BFF가 없으면 프론트엔드는 세 개의 별도 HTTP 요청을 해야 합니다. BFF를 사용하면 한 번의 요청을 합니다.
프로젝트 설정
먼저 Go 모듈을 초기화합니다.
mkdir product-bff && cd product-bff go mod init product-bff
다운스트림 서비스 모의
시연을 위해 다운스트림 서비스를 모방하는 간단한 Go HTTP 서버를 사용하겠습니다. 실제 시나리오에서는 실제 마이크로서비스일 것입니다.
product_service/main.go
package main import ( "encoding/json" "fmt" "log" "net/http" "time" ) type Product struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Price int `json:"price"` } func main() { http.HandleFunc("/products/", func(w http.ResponseWriter, r *http.Request) { id := r.URL.Path[len("/products/"):] if id == "" { http.Error(w, "Product ID required", http.StatusBadRequest) return } // Simulate latency time.Sleep(50 * time.Millisecond) product := Product{ ID: id, Name: fmt.Sprintf("Awesome Gadget %s", id), Description: "This is an awesome gadget that will change your life!", Price: 9999, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(product) }) log.Println("Product Service running on :8081") log.Fatal(http.ListenAndServe(":8081", nil)) }
review_service/main.go
package main import ( "encoding/json" "fmt" "log" "net/http" "time" ) type Review struct { ProductID string `json:"productId"` Rating int `json:"rating"` Comment string `json:"comment"` Author string `json:"author"` } func main() { http.HandleFunc("/reviews/", func(w http.ResponseWriter, r *http.Request) { productID := r.URL.Path[len("/reviews/"):] if productID == "" { http.Error(w, "Product ID required", http.StatusBadRequest) return } // Simulate latency time.Sleep(80 * time.Millisecond) reviews := []Review{ {ProductID: productID, Rating: 5, Comment: "Love it!", Author: "Alice"}, {ProductID: productID, Rating: 4, Comment: "Pretty good.", Author: "Bob"}, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(reviews) }) log.Println("Review Service running on :8082") log.Fatal(http.ListenAndServe(":8082", nil)) }
inventory_service/main.go
package main import ( "encoding/json" "fmt" "log" "net/http" "time" ) type Inventory struct { ProductID string `json:"productId"` Stock int `json:"stock"` } func main() { http.HandleFunc("/inventory/", func(w http.ResponseWriter, r *http.Request) { productID := r.URL.Path[len("/inventory/"):] if productID == "" { http.Error(w, "Product ID required", http.StatusBadRequest) return } // Simulate latency time.Sleep(30 * time.Millisecond) inventory := Inventory{ ProductID: productID, Stock: 10 + len(productID)%5, // Dynamic stock } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(inventory) }) log.Println("Inventory Service running on :8083") log.Fatal(http.ListenAndServe(":8083", nil)) }
이 세 가지 서비스를 별도의 터미널에서 실행하십시오.
BFF 계층 (main.go
)
이제 BFF를 구축해 보겠습니다.
package main import ( "context" "encoding/json" "fmt" "log" "net/http" "time" ) // Define structs to match downstream service responses type Product struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Price int `json:"price"` } type Review struct { ProductID string `json:"productId"` Rating int `json:"rating"` Comment string `json:"comment"` Author string `json:"author"` } type Inventory struct { ProductID string `json:"productId"` Stock int `json:"stock"` } // Define the aggregated response structure for the frontend type ProductDetails struct { Product Product `json:"product"` Reviews []Review `json:"reviews"` Inventory Inventory `json:"inventory"` Error string `json:"error,omitempty"` // For partial errors } // httpClient with a timeout var client = &http.Client{Timeout: 2 * time.Second} // fetchProduct fetches product details from the Product Service func fetchProduct(ctx context.Context, productID string) (Product, error) { req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://localhost:8081/products/%s", productID), nil) if err != nil { return Product{}, fmt.Errorf("failed to create product request: %w", err) } resp, err := client.Do(req) if err != nil { return Product{}, fmt.Errorf("failed to fetch product: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return Product{}, fmt.Errorf("product service returned status %d", resp.StatusCode) } var product Product if err := json.NewDecoder(resp.Body).Decode(&product); err != nil { return Product{}, fmt.Errorf("failed to decode product response: %w", err) } return product, nil } // fetchReviews fetches reviews from the Review Service func fetchReviews(ctx context.Context, productID string) ([]Review, error) { req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://localhost:8082/reviews/%s", productID), nil) if err != nil { return nil, fmt.Errorf("failed to create review request: %w", err) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch reviews: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("review service returned status %d", resp.StatusCode) } var reviews []Review if err := json.NewDecoder(resp.Body).Decode(&reviews); err != nil { return nil, fmt.Errorf("failed to decode reviews response: %w", err) } return reviews, nil } // fetchInventory fetches inventory from the Inventory Service func fetchInventory(ctx context.Context, productID string) (Inventory, error) { req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://localhost:8083/inventory/%s", productID), nil) if err != nil { return Inventory{}, fmt.Errorf("failed to create inventory request: %w", err) } resp, err := client.Do(req) if err != nil { return Inventory{}, fmt.Errorf("failed to fetch inventory: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return Inventory{}, fmt.Errorf("inventory service returned status %d", resp.StatusCode) } var inventory Inventory if err := json.NewDecoder(resp.Body).Decode(&inventory); err != nil { return Inventory{}, fmt.Errorf("failed to decode inventory response: %w", err) } return inventory, nil } // getProductDetailsHandler handles requests for aggregated product details func getProductDetailsHandler(w http.ResponseWriter, r *http.Request) { productID := r.URL.Path[len("/product-details/"):] if productID == "" { http.Error(w, "Product ID required", http.StatusBadRequest) return } // Use a context with a timeout for the entire aggregation operation ictx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond) defer cancel() // Use channels to receive results concurrently productCh := make(chan struct { Product Product Err error }, 1) reviewsCh := make(chan struct { Reviews []Review Err error }, 1) inventoryCh := make(chan struct { Inventory Inventory Err error }, 1) // Fetch data concurrently using goroutines go func() { p, err := fetchProduct(ctx, productID) productCh <- struct { Product Product Err error }{p, err} }() go func() { r, err := fetchReviews(ctx, productID) reviewsCh <- struct { Reviews []Review Err error }{r, err} }() go func() { i, err := fetchInventory(ctx, productID) inventoryCh <- struct { Inventory Inventory Err error }{i, err} }() // Aggregate results details := ProductDetails{} var bffError string select { case res := <-productCh: if res.Err != nil { log.Printf("Error fetching product for %s: %v", productID, res.Err) bffError = fmt.Sprintf("failed to get product info: %s", res.Err.Error()) } else { details.Product = res.Product } case <-ctx.Done(): log.Printf("Context cancelled/timed out while waiting for product for %s: %v", productID, ctx.Err()) http.Error(w, "Timeout fetching product data", http.StatusGatewayTimeout) return } select { case res := <-reviewsCh: if res.Err != nil { log.Printf("Error fetching reviews for %s: %v", productID, res.Err) // We might still want to return partial data even if reviews fail details.Reviews = []Review{} // Default to empty if error } else { details.Reviews = res.Reviews } case <-ctx.Dont()}: log.Printf("Context cancelled/timed out while waiting for reviews for %s: %v", productID, ctx.Err()) http.Error(w, "Timeout fetching reviews data", http.StatusGatewayTimeout) return } select { case res := <-inventoryCh: if res.Err != nil { log.Printf("Error fetching inventory for %s: %v", productID, res.Err) // We might still want to return partial data even if inventory fails details.Inventory = Inventory{Stock: 0} // Default to 0 stock } else { details.Inventory = res.Inventory } case <-ctx.Done(): log.Printf("Context cancelled/timed out while waiting for inventory for %s: %v", productID, ctx.Err()) http.Error(w, "Timeout fetching inventory data", http.StatusGatewayTimeout) return } // If there was a fatal error (e.g., product details itself failed) if bffError != "" { http.Error(w, bffError, http.StatusInternalServerError) return } // Add the error message to the response if partial failure occurred if details.Product.ID == "" { // Product data is essential, if empty, it means error occurred for product http.Error(w, "Failed to get product details", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(details) } func main() { http.HandleFunc("/product-details/", getProductDetailsHandler) log.Println("BFF Service running on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
BFF 작동 방식:
- 요청 처리:
getProductDetailsHandler
는/product-details/{productID}
에 대한 요청을 받습니다. - 시간 제한이 있는 컨텍스트:
context.WithTimeout
은 전체 집계 작업이 정의된 시간 내에 완료되도록 보장하는 데 사용됩니다. 이는 느린 다운스트림 서비스가 BFF를 계속 붙잡는 것을 방지하는 데 중요합니다. - 동시 다운스트림 호출:
fetchProduct
,fetchReviews
,fetchInventory
에 대한 고루틴이 시작됩니다. 각 고루틴은 결과(또는 오류)를 전용 채널을 통해 다시 전달합니다.- 고루틴을 사용하면 이러한 호출이 병렬로 수행될 수 있습니다. 고루틴이 없으면 BFF는 순차적으로 호출하여
T_product + T_review + T_inventory
시간이 걸립니다. 동시성을 사용하면 약max(T_product, T_review, T_inventory)
시간이 걸립니다.
- 결과 집계: 메인 고루틴은
select
문을 사용하여 각 채널의 결과를 기다립니다.- 오류 처리가 내장되어 있습니다. 특정 다운스트림 서비스가 실패하거나 시간 초과되면(
ctx.Done()
이 트리거되어 발생), BFF는 전체 요청을 실패시키거나 부분 데이터를 반환할지 여부를 결정할 수 있습니다(예: 리뷰 없이 제품 세부 정보). 이것은 BFF를 더 탄력적으로 만듭니다. - 이 예시는 필수 구성 요소(리뷰, 재고)의 해당 서비스가 실패할 경우 빈 슬라이스/기본값을 반환하여 정상적인 성능 저하를 보여주지만, 핵심 제품 데이터를 검색할 수 없는 경우 오류를 발생시킵니다.
- 오류 처리가 내장되어 있습니다. 특정 다운스트림 서비스가 실패하거나 시간 초과되면(
- 응답 형식 지정: 결과는 프론트엔드에 맞게 조정된 단일
ProductDetails
구조체로 결합된 다음 JSON 응답으로 마샬링됩니다.
BFF 실행
- 별도의 터미널에서 세 개의 모의 서비스를 시작합니다.
- BFF를 실행합니다:
product-bff
디렉토리에서go run main.go
를 실행합니다. - 브라우저에서 또는
curl
을 사용하여 액세스합니다:http://localhost:8080/product-details/P001
세 서비스 모두의 데이터가 포함된 단일 JSON 응답을 받게 됩니다. 모의 서비스 중 하나에 지연 시간을 추가하거나 하나를 종료하면 BFF가 시간 초과 또는 부분 실패를 처리하는 방법을 볼 수 있습니다.
고급 고려 사항 및 모범 사례
우리 예시는 간단하지만, 실제 BFF는 더 많은 정교함을 요구합니다.
- 오류 처리 및 복원력:
- 서킷 브레이커: BFF가 실패한 다운스트림 서비스를 반복적으로 호출하는 것을 방지하여 복구 시간을 제공하기 위해 서킷 브레이커(예:
sony/gobreaker
와 같은 라이브러리 사용)를 구현합니다. - 재시도 (기하급수적 백오프 포함): 일시적인 오류의 경우 자동 재시도가 안정성을 향상시킬 수 있습니다.
- 정상적인 성능 저하: 예시에서 볼 수 있듯이, 다운스트림 서비스가 실패할 경우 어떤 부분의 데이터를 중요하게 간주하고 어떤 부분을 생략할 수 있는지 결정합니다.
- 서킷 브레이커: BFF가 실패한 다운스트림 서비스를 반복적으로 호출하는 것을 방지하여 복구 시간을 제공하기 위해 서킷 브레이커(예:
- 인증 및 권한 부여: BFF는 다운스트림 서비스에 대한 요청을 프록시하기 전에 클라이언트별 인증 및 권한 부여 규칙을 적용하는 이상적인 장소입니다. 필요한 헤더를 전달용으로 추가할 수 있습니다.
- 요청/응답 변환: BFF의 주요 역할은 데이터를 변환하는 것입니다. 여기에는 필터링, 병합, 필드 이름 변경 또는 파생 값 계산이 포함되어 프론트엔드 로직을 단순화할 수 있습니다.
- 캐싱: 성능을 더욱 향상시키고 다운스트림 서비스의 부하를 줄이기 위해 자주 액세스되는, 느리게 변경되는 데이터에 대한 캐싱 메커니즘(예: Redis)을 BFF 내에 구현합니다.
- 로깅 및 추적: BFF의 동작을 모니터링하고 마이크로서비스 환경 전반의 문제를 진단하기 위해 구조화된 로깅 및 분산 추적(예: OpenTelemetry)을 통합합니다.
- 부하 분산 및 확장: 증가하는 트래픽을 처리하기 위해 부하 분산기 뒤에 BFF의 여러 인스턴스를 배포합니다. Go의 효율성은 수평 확장에 적합하게 만듭니다.
- 서비스 검색: 동적인 마이크로서비스 환경에서는 BFF가 IP 주소나 포트를 하드코딩하는 대신 다운스트림 서비스를 찾기 위해 서비스 검색 메커니즘(예: Kubernetes DNS, Consul, Eureka)을 사용해야 합니다.
- 멱등성: BFF가 요청을 재시도할 때, 의도치 않은 부작용을 피하기 위해 데이터를 수정하는 작업에 대한 멱등성을 보장합니다.
결론
백엔드 포 프론트엔드 패턴은 정교한 마이크로서비스와 단순화된 프론트엔드 개발 사이의 격차를 해소하는 강력한 아키텍처 도구입니다. 지능형 오케스트레이터 및 데이터 집계자 역할을 함으로써 Go로 지원되는 BFF는 프론트엔드 경험을 크게 향상시키고, 복잡성을 줄이며, 애플리케이션의 전반적인 성능과 복원력을 향상시킵니다. Go의 동시성, 성능 및 네트워킹에 대한 고유한 강점은 견고하고 확장 가능한 BFF 계층을 구축하는 데 탁월한 선택이 되며, 개발자는 마이크로서비스 아키텍처의 이점을 유지하면서 더 빠르고 반응성이 뛰어난 사용자 인터페이스를 구축할 수 있습니다.