WebFlux와 코루틴으로 BFF(Backend For Frontend) 구현하기

WebFlux와 코루틴으로 BFF(Backend For Frontend) 구현하기

시작하며

안녕하세요. 카카오페이 오프라인결제서비스유닛에서 백엔드를 개발하고 있는 Happy라고 합니다. 카카오페이 오프라인 결제 서비스에서 새롭게 추가된 ‘내 주변 매장 찾기’ 서비스를 개발하기 위해 BFF 서버 구조에서 WebFlux, 코루틴으로 비동기 API 서버 개발 경험에 대해 이야기하려고 합니다.

서비스 소개: 내 주변 매장 찾기

서비스에 도입한 기술을 이해하기에 앞서, 내 주변 매장 찾기 서비스에 대해 소개드리려고 합니다. 내 주변 매장 찾기 서비스는 사용자가 사용자 주변 카카오페이 매장들을 쉽게 찾고, 매장의 혜택과 멤버십 정보들을 쉽게 확인할 수 있는 서비스입니다. 9월 20일부터 카카오페이 결제 바코드 화면에서 이용하실 수 있습니다.

간단해 보이는 서비스이지만 한 화면에서 위치 정보, 매장 정보, 혜택, 멤버십, 결제 정보 등 다양한 정보를 보여주고 있는데요. 서비스별로 정보가 산재되어 있는 MSA 환경에서 최적의 방법으로 정보를 가져오기 위해 비동기 방식으로 API를 개발하고, 이 데이터를 효율적으로 프론트에 전달하기 위해 BFF(Backend For Frontend) 패턴을 적용했습니다. 그렇다면 BFF란 무엇일까요?

BFF(Backend For Frontend)란

BFF란 Backend For Frontend의 줄임말로, 프론트엔드에 표현될 데이터를 위한 백엔드 즉, 프론트엔드 데이터에 대한 책임을 백엔드가 가진다는 것을 의미합니다. 백엔드는 당연히 프론트엔드에 표현되는 데이터를 제공하니, 기존 방식과 BFF가 무엇이 다른지 아직은 감이 안 오실 텐데요. BFF는 단순히 데이터를 제공하는 것에서 나아가 프론트엔드 친화적으로 데이터를 제공합니다.

프론트엔드 친화적인 API 서버

프론트엔드 개발자와 백엔드 개발자가 모여 API 스펙을 정의할 때, 어디서 처리해야 할지 애매한 포인트들이 있습니다. 아래처럼 즐겨찾기 한 매장이 보이는 화면이 있고, 여기에는 결제 수단 정보들이 보이게 됩니다. 결제 수단은 매장에서 카카오페이로 오프라인 결제를 할 때 사용할 수 있는 수단에 대한 정보이고, 결제와 직결되기 때문에 오프라인 결제 서버가 책임을 지고 관리하고 있습니다. BFF 서버에서는 결제 수단 정보를 가지고 프론트엔드에 보내줘야 하는데요. 오프라인 결제 서버에서는 이 정보를 아래와 같이 제공합니다.

{
  ...
  "payment_method": {
    "money_enabled": true,
    "point_enabled": false,
    "card_enabled": true
  }
  ...
}

이때 boolean 값들로 ‘페이머니’, ‘페이포인트’, ‘신용카드’라는 결제 수단 정보를 문자열로 표기하는 작업은 프론트엔드에서 해야 할까요? 백엔드에서 해야 할까요? 둘 다 할 수 있지만, BFF는 프론트엔드 친화적인 API 서버이기에 백엔드가 그 역할을 하게 됩니다. 화면에 보이는 데이터를 가공하는 책임은 서버가 지고, 프론트엔드는 UI를 그리는데 집중하게 됩니다. 따라서 아래와 같이 보이게 됩니다.

오프라인 결제 서비스는 결제 수단에 대한 데이터를 제공할 뿐, 프론트엔드에서 보이는 형태로 데이터를 제공할 책임이 없습니다. 따라서 BFF가 프론트엔드의 책임을 대신하여 데이터를 제공하게 됩니다. 프론트엔드에서 데이터를 가공하면, 사용자 모바일/데스크톱 컴퓨터 자원을 쓸 수 있다는 점은 좋지만 모바일의 경우 배터리 소모 문제를 고려해야 할 수도 있습니다. 이 예시의 경우엔 간단하기 때문에 많은 자원을 사용하지 않지만요. 하지만 복잡도 관점에서 프론트엔드가 UI 로직에 집중할 수 있도록 하기 때문에 유지보수와 캡슐화 관점에서 이익이 큽니다.

다음 예시로는 여러 조건에 따라 멤버십, 결제 버튼 영역이 각기 다르게 프론트엔드에 표현되어야 하는 경우에 대해 보여드리겠습니다.

  • 매장의 카카오페이 멤버십 가맹 여부
  • 유저의 카카오페이 멤버십 가입 여부
  • 유저가 해당 매장의 카카오페이 멤버십 연동 여부
  • 카카오페이 가입 여부(비회원 페이지인지)

예시 9가지만 가져왔지만 실제 멤버십의 할인 타입과 결제 시 멤버십이 자동으로 적립되는지 등에 따라 경우가 더욱 다양합니다. 이에 서버에서는 프론트엔드에 아래와 같은 방식으로 데이터를 전달합니다.

데이터의 형태가 거의 화면에서 필요한 컴포넌트의 형태와 유사하지요? 이처럼 여러 조건에 대한 처리는 서버가 처리하고 프론트엔드에 보일 데이터만 제공하는 것이 BFF의 역할입니다.

API 조합기 역할

만약 프론트엔드에서 바라보는 서버가 1개라면 BFF는 필요 없습니다. BFF는 MSA 환경에서 다양한 서비스들의 데이터를 조합해 프론트엔드에 내려주는 ‘API 조합기’ 역할을 해야 합니다.

앞서 프론트엔드가 데이터를 처리할 때, 서버가 데이터를 처리할 때를 비교해보았는데요. 이번에는 프론트엔드가 API 조합기 역할을 할 때, 서버가 API 조합기 역할을 할 때를 비교해보도록 하겠습니다.

API 조합기 역할: 프론트엔드
API 조합기 역할: 프론트엔드

프론트엔드가 API 조합기 역할을 하게 되면, 유저의 모바일 폰에서 각 서비스로 호출이 발생합니다. 보통 모바일 인터넷은 대역폭이 좁고 불안정한데요. 모바일 인터넷의 지연시간은 LAN보다 100배는 더 깁니다. 이때 서비스 A, B, C로의 통신이 모두 성공해야 하며 각 서비스로 호출이 발생할 때마다 그 앞의 API Gateway를 지나게 됩니다. 카카오페이의 경우 프론트엔드에서 인증 토큰을 넣어 서비스에 요청하게 되면 그 앞의 API Gateway에서 토큰을 유저 계정 정보로 변환하여 서비스에 넘겨주게 됩니다. 고로 서비스 A, B, C 각각의 호출마다 인증 서버를 거치게 되겠지요. 위 예시처럼 프론트엔드가 API 조합기 역할을 한다면, 느린 네트워크를 통해 왕복 3번의 통신이 필요하고 서비스별로 필요한 인증을 3번 거치게 된다는 단점이 발생합니다.

API 조합기 역할: 백엔드
API 조합기 역할: 백엔드

그렇다면 프론트엔드가 BFF만 바라보는 경우는 어떨까요? 프론트엔드가 BFF로 요청을 보내면, API Gateway를 통해 인증을 1회 조회하게 됩니다. BFF에서 각 서비스에 계정 정보를 활용하여 필요한 요청을 보내게 되겠죠. BFF에서 다른 마이크로 서비스와 통신할 때는 방화벽 내부에 있고, 고속 LAN 환경에서 통신하기 때문에 더 빠르게 데이터를 모아올 수 있습니다. 그리고 BFF를 적용하면 추후 캐시를 적용할 때도 쉽게 반영할 수 있다는 장점도 있습니다.

다양한 환경에서 구동된다면 GraphQL 고려하기

BFF는 마이크로 서비스로 나눠진 서버들 사이의 데이터를 프론트엔드 친화적으로 내려주게 되는데요. 이때 함께 사용되는 기술이 GraphQL입니다. 만약, 모바일과 데스크톱으로 서비스 화면이 여러 개로 나눠져 있다면 BFF에 GraphQL을 검토해보는 것이 좋습니다. GraphQL은 프론트엔드에서 질의를 통해, 자신에게 필요한 데이터를 백엔드로부터 질의해오는 기술인데요. 모바일과 데스크톱의 경우 UI 구성이 다르기 때문에 GraphQL을 제공한다면 서버에서는 API를 사용자 환경별로 즉, 모바일과 데스크톱에 따라 다르게 제공하지 않아도 됩니다. 유지보수성이 좋아지는 것은 물론이겠죠.

그러나 저희는 GraphQL을 도입하진 않았습니다. 내 주변 매장 찾기를 비롯한 카카오페이의 많은 서비스들에는 모바일에서 보이는 화면만 존재하기 때문에 이번 프로젝트에서 GraphQL을 도입할 이유가 없었기 때문입니다. 만약 다양한 환경에서 구동되는 서비스를 개발하고 계신다면 검토해보시면 좋을 것 같습니다.

‘내 주변 매장 찾기’ BFF 아키텍처

BFF 도입 배경

‘내 주변 매장 찾기’의 아키텍처를 간략화해보면 다음과 같습니다.

여러 서비스들의 데이터들이 BFF를 통해 프론트엔드로 제공됩니다. 이때 중요한 점은 BFF가 비동기로 동작해야 한다는 점입니다.

BFF와 마이크로 서비스 간 호출 구조

앞서 말씀드렸던 BFF는 MSA 환경에서 각기 분산된 데이터를 모으고, 프론트엔드에 내려주는 역할을 하게 됩니다. 이때 BFF는 여러 API를 통해 데이터를 빨리 가져오기 위해 non-blocking & 비동기 패턴을 적용해야 합니다. 이를 위해 Spring에서 리액티브 스트림을 구현한 제품인 WebFlux를 프로젝트에 도입했습니다.

서비스 API 모두를 한 번에 호출하고 싶지만 현실적으로는 데이터 간의 종속성 때문에 비동기 호출 간에도 순서에 대한 종속성이 생기기도 합니다. 내 주변 매장 찾기 서비스에서도 각 API 호출에 필요한 키 값들이 혼재되어 있어 비동기로 API를 호출하는 데에 종속성이 발생했습니다. 1, 2, 3단계 간에는 순서가 있어 온전한 비동기라고 할 수는 없지만 1단계에서 partnerKey를 가지고 3개의 서비스를 호출할 때는 비동기로 동작하게 됩니다. 그러다가 다음 서비스를 호출하는데 필요한 key 값을 앞선 1단계에서 받아오게 되면 2단계 호출이 바로 실행되고, 2단계를 통해 최종 key 값을 받아오면 3단계가 실행됩니다. 물론 도중에 key 값이 존재하지 않거나 에러가 발생하는 경우는 끝까지 실행되지 않고 필요한 요청까지만 하고 프론트엔드로 응답을 전달하고 종료됩니다.

이 부분을 처리하기 위해 WebFlux + 코루틴(Coroutine)을 어떻게 적용했는지 코드로 보여드리겠습니다.

WebFlux, 코루틴을 통한 비동기 API 서버 개발

WebFlux를 이용해 리액티브 스트림을 구현하기 위해서는 리액터 프로젝트의 Mono, Flux 같은 타입을 사용해 프로그래밍해야 합니다. 이는 명령형 프로그래밍 방식과 전혀 다르기에 학습 난이도가 꽤 어려운 편입니다.

Spring WebFlux 5.2 이상에서는 코틀린의 코루틴을 리액티브 스트림에 대응해 사용할 수 있게 되었습니다. Spring MVC와 코드를 작성하는 패러다임은 비슷하지만 내부는 리액티브 스트림으로 동작하도록 쉽게 코드를 개발할 수 있습니다. 이 때문에 WebFlux와 코루틴을 활용해서 리액티브 스트림을 구현하기로 결정했습니다.

WebFlux, 코루틴 설정

WebFlux 관련 설정은 함께 프로젝트를 한 제이크(jake)가 주로 작업해주셨는데요. 아래처럼 WebFlux, 코루틴을 위한 종속성과 jackson 대신 사용한 kotlin.serialization을 추가해줍니다. (이 부분은 트러블 슈팅 부분에서 더 살펴보겠습니다.)

dependencies {
    // WebFlux
    implementation("org.springframework.boot:spring-boot-starter-webflux")

    // 코루틴
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.6.3")

    // serializer
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3")
}

코루틴 async 사용 코드

@RestController
class PartnerHubController(
    private val partnerHubService: PartnerHubService
) {
    @GetMapping("/api/external/v1/partners/{partnerKey}")
    suspend fun getHub(
        @RequestHeader(ACCOUNT_INFO) account: Header.AccountInfo,
        @PathVariable partnerKey: String
    ): AroundApiResponse<PartnerHub.Response> = AroundApiResponse.Success(
        partnerHubService.getPartner(account.userAccountId, partnerKey)
    )
}
suspend fun getPartner(
      userAccountId: Long,
      partnerKey: String,
  ): PartnerHub.Response = coroutineScope {
     // 앞의 코루틴 Scope를 상속. 비동기 호출들이 같은 scope를 공유하기에 에러가 발생하게 되면 이후 작업들이 취소됩니다.

      // 1단계
      val map = async {
          kakaoMapAdapter.getMapData(partnerKey)
      }


      val payment = async {
          offlinePaymentAdapter.getPartner(partnerKey)
      }

      val benefits = async {
          benefitAdapter.getBenefits(partnerKey)
      }

      // 2단계
      // map의 API 응답값을 사용하므로, map.await()으로 응답을 받고 나서 async가 실행됨
      val bizPartner = async {
          bizPartnerAdapter.getPartnerHub(map.await().partnerCode)
      }

      // 3단계 - paymentInfo, bizPartner API 2개에 종속적
      // 2개 API가 모두 응답을 받고 나면 async 내부의 로직이 실행됨
      val membership = async {
          membershipAdapter.getUserMembership(
              userAccountId,
              companyId = payment.await().companyId,
              partnerId = bizPartner.await().getMembershipPartnerId(),
          )
      }

      PartnerHub.Response(
          placeName = map.await().placeName,
          bizHours = map.await().bizHour?.toDisplay(),
          paymentMethods = payment.await().paymentMethod.toList(),
          keywords = bizPartner.await().keywords,
          benefits = BizEventThenBenefits.of(bizPartner.await(), benefits.await()).toList(),
          notice = bizPartner.await().notice,
          membership = MembershipArea.of(membership.await()),
          buttonArea = ButtonArea.of(payment.await(), membership.await())
      )  // 모든 API의 응답이 왔을 때 Response 객체 생성
  }

각 어댑터는 WebClient를 사용해 WebFlux의 컨트롤러를 통해 들어온 요청이 계속해서 non-blocking으로 동작하도록 했습니다.

트러블 슈팅

Kotlin Serialization 사용

Spring MVC와 WebFlux에서는 JSON으로 변환 시 디폴트 설정인 Jackson 라이브러리를 사용하는데요. 그러나 Jackson은 동기로 데이터를 변환하기 때문에 응답을 줄 때 동기로 동작합니다. 이에 kotlin.serialization를 사용하도록 수정했습니다. 앞서 dependency에 implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3")를 추가했던 이유입니다.

@Configuration
class WebConfig(val kJson: Json): WebFluxConfigurer {

    override fun configureHttpMessageCodecs(configurer: ServerCodecConfigurer) {
        configurer.defaultCodecs().configureDefaultCodec { KotlinSerializationJsonHttpMessageConverter(kJson) }
        configurer.defaultCodecs().kotlinSerializationJsonEncoder(KotlinSerializationJsonEncoder(kJson))
        configurer.defaultCodecs().kotlinSerializationJsonDecoder(KotlinSerializationJsonDecoder(kJson))
    }

    override fun addFormatters(registry: FormatterRegistry) {
        registry.addConverter(XPayAccountConverter(kJson))
    }
}

WebFlux의 ExceptionHandler

Spring Web을 사용하신다면 컨트롤러에서 발생하는 예외를 처리하기 위해 ExceptionHandler를 많이 사용하실 텐데요. WebFlux에서는 ExceptionHandler를 다음과 같은 방법으로 사용합니다.

@Component
class GlobalExceptionHandler(
    globalErrorAttributes: GlobalErrorAttributes?,
    applicationContext: ApplicationContext?,
    serverCodecConfigurer: ServerCodecConfigurer
) : AbstractErrorWebExceptionHandler(
    globalErrorAttributes,
    WebProperties.Resources(),
    applicationContext
) {
    override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction<ServerResponse> {
        return RouterFunctions.route(RequestPredicates.all()) { request: ServerRequest ->
            renderErrorResponse(request)
            // request에서 error를 가져와 핸들링
        }
    }

    // .. 중략
}

마치며

BFF의 역할은 프론트엔드 친화적인 데이터를 전달하는 것뿐만 아니라, API 조합기 역할을 하고 이를 위해서는 비동기 방식으로 개발하는 것이 처리량을 높일 수 있습니다. 카카오페이 내 주변 매장 찾기 서비스처럼 MSA 환경에서 여러 마이크로 서비스 호출이 필요하다면 BFF 패턴에 WebFlux, 코루틴을 적용해보시면 좋을 것 같습니다.

참고자료

happy.together
happy.together

카카오페이 오프라인결제서비스 백엔드 개발을 하고 있는 해피입니다. 개발만큼 자기 개발을 좋아합니다.