Laravel + Vue (Inertia) + gRPC: gRPC User 서비스와 통신하는 간단한 BFF 구축

발행: (2026년 1월 4일 오전 05:49 GMT+9)
16 min read
원문: Dev.to

Source: Dev.to

Laravel + Vue + Inertia + gRPC: 간단한 BFF 만들기 (gRPC 사용자 서비스와 연동)

개요

이번 포스트에서는 Laravel을 백엔드 프레임워크로, VueInertia.js를 프론트엔드 레이어로, 그리고 gRPC를 통해 별도의 사용자 서비스와 통신하는 BFF (Backend‑For‑Frontend) 를 구축하는 과정을 단계별로 살펴보겠습니다.

  • Laravel: API 라우팅, 인증, 비즈니스 로직 담당
  • Vue + Inertia.js: 서버‑사이드 렌더링 없이 SPA‑같은 경험 제공
  • gRPC: 고성능 바이너리 프로토콜을 이용한 마이크로서비스 간 통신

목표: 프론트엔드에서 직접 gRPC 호출을 하지 않고, Laravel BFF가 중간에서 gRPC 클라이언트를 사용해 데이터를 받아 Vue 컴포넌트에 전달하도록 구현합니다.


사전 준비

항목버전비고
PHP^8.1Laravel 10 이상 권장
Laravel10.x
Composer2.x
Node.js>=18
npm / Yarn>=8
Vue3.x
Inertia.js^1.0
protobuf compiler (protoc)3.21+
grpc/grpc PHP extension1.55+(선택 사항, grpc/grpc 패키지도 사용 가능)

gRPC 서비스 정의 (proto)

syntax = "proto3";

package user;

service UserService {
  rpc GetUser (GetUserRequest) returns (GetUserResponse);
  rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  string id = 1;
  string name = 2;
  string email = 3;
}

message ListUsersRequest {}

message ListUsersResponse {
  repeated GetUserResponse users = 1;
}

위 파일을 proto/user.proto 로 저장하고, 아래 명령어로 PHP와 JavaScript용 스텁을 생성합니다.

# PHP 스텁
protoc --php_out=app/Grpc --grpc_out=app/Grpc \
  --plugin=protoc-gen-grpc=$(which grpc_php_plugin) \
  proto/user.proto

# JavaScript (Vue) 스텁 – 필요 시 사용
protoc --js_out=import_style=commonjs,binary:resources/js/grpc \
  --grpc-web_out=import_style=commonjs,mode=grpcwebtext:resources/js/grpc \
  proto/user.proto

Laravel 프로젝트 설정

1. 새 Laravel 프로젝트 생성

composer create-project laravel/laravel bff-grpc-demo
cd bff-grpc-demo

2. 필요한 패키지 설치

composer require guzzlehttp/guzzle
composer require spiral/roadrunner # (옵션) 고성능 서버
composer require spiral/roadrunner-grpc # gRPC 클라이언트 지원
composer require inertiajs/inertia-laravel
composer require laravel/ui # Vue 스캐폴딩용

3. Inertia와 Vue 설정

php artisan ui vue
npm install @inertiajs/inertia @inertiajs/inertia-vue3
npm install
npm run dev

resources/js/app.js 를 다음과 같이 수정합니다.

import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/inertia-vue3';

createInertiaApp({
  resolve: name => import(`./Pages/${name}.vue`),
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) })
      .use(plugin)
      .mount(el);
  },
});

4. gRPC 클라이언트 래퍼 만들기

app/Services/Grpc/UserService.php

<?php

namespace App\Services\Grpc;

use App\Grpc\User\UserServiceClient;
use App\Grpc\User\GetUserRequest;
use App\Grpc\User\ListUsersRequest;
use Grpc\UnaryCall;

class UserService
{
    protected UserServiceClient $client;

    public function __construct()
    {
        // gRPC 서버 주소 (예: localhost:50051)
        $this->client = new UserServiceClient('127.0.0.1:50051', [
            'credentials' => \Grpc\ChannelCredentials::createInsecure(),
        ]);
    }

    public function getUser(string $id)
    {
        $request = new GetUserRequest();
        $request->setId($id);

        /** @var UnaryCall $call */
        $call = $this->client->GetUser($request);
        [$response, $status] = $call->wait();

        if ($status->code !== \Grpc\STATUS_OK) {
            throw new \Exception($status->details, $status->code);
        }

        return $response;
    }

    public function listUsers(): array
    {
        $request = new ListUsersRequest();

        /** @var UnaryCall $call */
        $call = $this->client->ListUsers($request);
        [$response, $status] = $call->wait();

        if ($status->code !== \Grpc\STATUS_OK) {
            throw new \Exception($status->details, $status->code);
        }

        return $response->getUsers();
    }
}

Tip: UserServiceClient 클래스는 protoc 로 생성된 파일에 포함됩니다. 네임스페이스와 경로가 다를 경우 composer.jsonautoload 섹션을 조정해 주세요.

5. 라우트와 컨트롤러

routes/web.php

use App\Http\Controllers\UserController;
use Inertia\Inertia;

Route::get('/', fn() => Inertia::render('Dashboard'));

Route::prefix('users')->group(function () {
    Route::get('/', [UserController::class, 'index'])->name('users.index');
    Route::get('/{id}', [UserController::class, 'show'])->name('users.show');
});

app/Http/Controllers/UserController.php

<?php

namespace App\Http\Controllers;

use App\Services\Grpc\UserService;
use Inertia\Inertia;
use Illuminate\Http\Request;

class UserController extends Controller
{
    protected UserService $grpc;

    public function __construct(UserService $grpc)
    {
        $this->grpc = $grpc;
    }

    public function index()
    {
        $users = $this->grpc->listUsers();

        // Laravel 컬렉션으로 변환 후 Vue에 전달
        return Inertia::render('Users/Index', [
            'users' => collect($users)->map(fn($u) => [
                'id'    => $u->getId(),
                'name'  => $u->getName(),
                'email' => $u->getEmail(),
            ]),
        ]);
    }

    public function show($id)
    {
        $user = $this->grpc->getUser($id);

        return Inertia::render('Users/Show', [
            'user' => [
                'id'    => $user->getId(),
                'name'  => $user->getName(),
                'email' => $user->getEmail(),
            ],
        ]);
    }
}

Vue 페이지 구현

1. resources/js/Pages/Users/Index.vue

<template>
  <div>
    <h1 class="text-2xl font-bold mb-4">사용자 목록</h1>
    <ul class="space-y-2">
      <li v-for="user in users" :key="user.id" class="p-2 border rounded">
        <Link :href="route('users.show', user.id)">
          {{ user.name }} ({{ user.email }})
        </Link>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { Link } from '@inertiajs/inertia-vue3';
defineProps({
  users: Array,
});
</script>

2. resources/js/Pages/Users/Show.vue

<template>
  <div>
    <h1 class="text-2xl font-bold mb-4">사용자 상세</h1>
    <div class="p-4 border rounded">
      <p><strong>ID:</strong> {{ user.id }}</p>
      <p><strong>이름:</strong> {{ user.name }}</p>
      <p><strong>이메일:</strong> {{ user.email }}</p>
    </div>
    <Link class="mt-4 inline-block text-blue-600" :href="route('users.index')">
      ← 목록으로 돌아가기
    </Link>
  </div>
</template>

<script setup>
import { Link } from '@inertiajs/inertia-vue3';
defineProps({
  user: Object,
});
</script>

gRPC 사용자 서비스 (예시)

여기서는 Node.js 기반의 간단한 gRPC 서버를 예시로 보여줍니다. 실제 프로젝트에서는 Go, Java, Python 등 원하는 언어로 구현할 수 있습니다.

// server.js
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const packageDef = protoLoader.loadSync('proto/user.proto', {});
const userProto = grpc.loadPackageDefinition(packageDef).user;

const users = [
  { id: '1', name: '홍길동', email: 'hong@example.com' },
  { id: '2', name: '김철수', email: 'kim@example.com' },
];

function getUser(call, callback) {
  const user = users.find(u => u.id === call.request.id);
  if (user) {
    callback(null, user);
  } else {
    callback({
      code: grpc.status.NOT_FOUND,
      details: 'User not found',
    });
  }
}

function listUsers(_, callback) {
  callback(null, { users });
}

const server = new grpc.Server();
server.addService(userProto.UserService.service, {
  GetUser: getUser,
  ListUsers: listUsers,
});

server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
  console.log('gRPC 서버가 50051 포트에서 실행 중...');
  server.start();
});

주의: 프로덕션 환경에서는 TLS 인증서를 사용해 createSsl() 로 보안 채널을 구성해야 합니다.


테스트 및 디버깅

Laravel → gRPC 호출 확인

php artisan tinker
>>> $service = app(App\Services\Grpc\UserService::class);
>>> $service->listUsers();

위 명령이 정상적으로 사용자 배열을 반환한다면 BFF와 gRPC 서비스 간 연결이 성공한 것입니다.

프론트엔드 확인

npm run dev

브라우저에서 http://localhost:8000/users 로 접속하면 사용자 목록이 표시되고, 각 항목을 클릭하면 상세 페이지가 나타납니다. 네트워크 탭에서 Inertia 요청이 정상적으로 반환되는지 확인하세요.


마무리

  • Laravel이 gRPC 클라이언트 역할을 수행함으로써 프론트엔드에서는 전통적인 HTTP/JSON 인터페이스만 사용하게 됩니다.
  • Inertia.js 덕분에 Vue 컴포넌트는 라우팅과 상태 관리가 서버‑사이드 라우트와 자연스럽게 연결됩니다.
  • gRPC는 마이크로서비스 간에 낮은 레이턴시와 강력한 타입을 제공하므로, 복잡한 비즈니스 로직을 별도 서비스로 분리하고 싶을 때 이상적인 선택입니다.

다음 단계로는:

  1. 인증 – Laravel Sanctum 혹은 Passport 로 API 토큰을 발급하고, gRPC 호출에 메타데이터로 전달.
  2. 에러 핸들링 – gRPC 상태 코드를 Laravel 예외로 매핑해 사용자에게 친절한 오류 메시지 제공.
  3. CI/CDprotoc 자동 실행, Docker‑Compose 로

Laravel + Vue 프로젝트에서 gRPC를 사용하는 이유

현대적인 Laravel + Vue 앱을 구축할 때 기본적인 선택은 보통 REST/JSON입니다.
gRPC는 다릅니다: API를 .proto 파일에 계약으로 정의하고, 강타입 클라이언트/서버 코드(스텁) 를 생성합니다. HTTP/2 위에서 동작하며 Protocol Buffers를 사용해 압축된 직렬화를 제공합니다.

Note: 브라우저에서 실행되는 Vue 앱은 추가 인프라(gRPC‑Web + Envoy와 같은 프록시) 없이는 gRPC를 직접 사용할 수 없습니다.
실용적인 패턴은 다음과 같습니다.

✅ Vue (browser) → Laravel (HTTP/JSON) → gRPC microservice (internal)

Laravel은 BFF(Backend‑For‑Frontend) 역할을 합니다. 이 저장소는 바로 그 방식을 시연합니다.

목표

Vue 페이지에 사용자를 표시하되, Laravel을 통해 gRPC 서버를 호출하도록 합니다.

흐름

  1. Vue 페이지(UserShow.vue)가 GET /api/users/{id}를 호출합니다.
  2. Laravel API 라우트App\Services\UserGrpc를 실행합니다.
  3. UserGrpc는 생성된 gRPC 클라이언트를 사용해 UserService.GetUser를 호출합니다.
  4. Node gRPC 서버가 사용자 데이터를 반환합니다(데모에서는 Laravel이 사용하는 동일한 SQLite DB를 읽습니다).

폴더 레이아웃 (하이라이트)

proto/user/v1/user.proto          → contract
generated/App/Grpc/...           → generated PHP stubs (client classes)
grpc-server/                     → demo gRPC server (Node)
app/Services/UserGrpc.php         → Laravel gRPC client wrapper
resources/js/Pages/UserShow.vue  → Vue / Inertia page

새 프로젝트의 경우, Laravel의 Vue 스타터 키트(Inertia)가 훌륭한 기반이 됩니다.

1️⃣ 계약 정의 (proto/user/v1/user.proto)

syntax = "proto3";

package user.v1;

service UserService {
  rpc GetUser (GetUserRequest) returns (GetUserResponse);
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  string id   = 1;
  string name = 2;
}

.proto 파일은 단일 진실의 원천입니다.

2️⃣ PHP 클라이언트 스텁 생성

buf는 Protobuf 워크플로와 생성 설정(buf.yaml, buf.gen.yaml)을 표준화합니다.

npx buf generate

생성된 파일은 generated/App/Grpc/... 아래에 위치합니다.

Tip: 많은 팀이 생성된 코드를 커밋하지 않습니다(CI에서 생성합니다).
데모와 빠른 설정을 위해 커밋해도 괜찮습니다.

Important: PHP 클라이언트 스텁을 생성해도 서버는 생성되지 않습니다. 여전히 gRPC 서버 구현이 필요합니다(Go, Node, Java, PHP, …). 이 저장소에서는 데모 서버가 grpc-server/ 아래에 있습니다.

3️⃣ Demo gRPC 서버 (Node)

cd grpc-server
npm install

Cannot find module 'dotenv' 혹은 Cannot find module 'better-sqlite3' 와 같은 오류가 발생하면, 누락된 패키지를 설치합니다:

npm install dotenv better-sqlite3 @grpc/grpc-js @grpc/proto-loader

grpc-server/server.js

require("dotenv").config();
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");

const PROTO_PATH = __dirname + "/../proto/user/v1/user.proto";
const packageDef = protoLoader.loadSync(PROTO_PATH, {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
});

const userProto = grpc.loadPackageDefinition(packageDef).user.v1;

// Demo handler
function GetUser(call, callback) {
  const { id } = call.request;

  // Minimal demo response
  callback(null, { id, name: `User #${id}` });
}

function main() {
  const server = new grpc.Server();
  server.addService(userProto.UserService.service, { GetUser });

  const addr = process.env.GRPC_ADDR || "0.0.0.0:50051";
  server.bindAsync(
    addr,
    grpc.ServerCredentials.createInsecure(),
    () => {
      console.log(`gRPC server listening on ${addr}`);
      server.start();
    }
  );
}

main();

서버를 시작합니다:

node server.js

Laravel에서 Connection refused 가 발생한다면, gRPC 서버가 실행되지 않았거나 예상한 주소/포트에서 수신 대기하고 있지 않은 것입니다.

4️⃣ Laravel gRPC 클라이언트 래퍼 (app/Services/UserGrpc.php)

client = new UserServiceClient(
            env('USER_SVC_ADDR', '127.0.0.1:50051'),
            ['credentials' => ChannelCredentials::createInsecure()]
        );
    }

    public function getUser(string $id): array
    {
        $req = new GetUserRequest();
        $req->setId($id);

        /** @var \App\Grpc\User\V1\GetUserResponse $resp */
        [$resp, $status] = $this->client->GetUser($req)->wait();

        if ($status->code !== STATUS_OK) {
            throw new \RuntimeException($status->details, $status->code);
        }

        return [
            'id'   => $resp->getId(),
            'name' => $resp->getName(),
        ];
    }
}

일반적인 문제: Grpc\ChannelCredentials를 찾을 수 없음

이는 gRPC PHP 확장이 현재 실행 중인 PHP에 활성화되지 않았음을 의미합니다 (CLI와 FPM이 서로 다를 수 있음).

php -m | grep grpc   # "grpc"가 목록에 표시되어야 합니다
php --ini            # 로드된 php.ini 파일을 확인합니다

올바른 php.ini에 확장을 활성화하십시오:

extension=grpc.so

5️⃣ 클래식 JSON API 엔드포인트 노출

routes/api.php

use Illuminate\Support\Facades\Route;
use App\Services\UserGrpc;

Route::get('/users/{id}', function (string $id, UserGrpc $userGrpc) {
    return response()->json($userGrpc->getUser($id));
});

routes/web.php (Inertia 페이지 라우트)

use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get('/users/{id}', function (string $id) {
    return Inertia::render('UserShow', [
        'id' => $id,
    ]);
});

6️⃣ Vue 페이지 (resources/js/Pages/UserShow.vue)

Inertia‑friendly한 깔끔한 버전은 props를 사용합니다 (Vue Router는 필요하지 않음).

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";

type User = { id: string; name: string };

const props = defineProps<{ id: string }>();
const id = computed(() => props.id);

const loading = ref(true);
const error = ref<string | null>(null);
const user = ref<User | null>(null);

onMounted(async () => {
  try {
    const resp = await fetch(`/api/users/${id.value}`);
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    user.value = await resp.json();
  } catch (e: any) {
    error.value = e.message;
  } finally {
    loading.value = false;
  }
});
</script>

<template>
  <div>
    <h2>User {{ id }}</h2>

    <div v-if="loading">Loading…</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <div v-else>
      <p><strong>ID:</strong> {{ user?.id }}</p>
      <p><strong>Name:</strong> {{ user?.name }}</p>
    </div>
  </div>
</template>

요약

단계수행 내용
1.proto 계약을 작성합니다.
2PHP 클라이언트 스텁을 생성합니다 (buf generate).
3gRPC 서버를 실행합니다 (Node 데모).
4생성된 클라이언트를 Laravel 서비스(UserGrpc)에 래핑합니다.
5서비스를 호출하는 JSON API 엔드포인트를 노출합니다.
6Inertia/Vue 페이지가 JSON 엔드포인트를 가져옵니다.

이 설정을 통해 얻을 수 있는 장점:

  • 강력한 타입을 서비스 전반에 걸쳐 제공합니다 (Protobuf 덕분).
  • 효율적인 바이너리 전송을 백엔드 내부에서 사용합니다 (HTTP/2 + protobuf).
  • 깨끗한 BFF가 브라우저를 gRPC‑Web 복잡성으로부터 보호합니다.

즐거운 코딩 되세요! 🚀

추가 Vue 예제 (대체 구현)

<script setup lang="ts">
import { ref, onMounted } from "vue";

const id = ref(/* route param or similar */);
const user = ref<{ id: string; name: string }>({ id: "", name: "" });
const loading = ref(true);
const error = ref<string | null>(null);

onMounted(async () => {
  try {
    const res = await fetch(`/api/users/${encodeURIComponent(id.value)}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    user.value = await res.json();
  } catch (e: any) {
    error.value = e?.message ?? "Unknown error";
  } finally {
    loading.value = false;
  }
});
</script>

<template>
  <div>
    <h2>User</h2>

    <div v-if="loading">Loading…</div>
    <div v-else-if="error">{{ error }}</div>
    <div v-else>
      <p><strong>ID:</strong> {{ user.id }}</p>
      <p><strong>Name:</strong> {{ user.name }}</p>
    </div>
  </div>
</template>

서비스 실행

터미널 1 — gRPC 서버

cd grpc-server
node server.js

터미널 2 — Laravel + Vite

composer install
cp .env.example .env
php artisan key:generate

npm install
npm run dev

php artisan serve

이 설정을 사용할 때

  • 내부 서비스 간에 타입이 지정된 계약을 원한다.
  • 다양한 언어로 된 여러 백엔드가 있다.
  • 팀 간에 공유되는 안정적인 인터페이스(proto)가 필요하다.
  • 성능과 스트리밍 기능을 원한다(gRPC는 스트리밍을 지원한다).

앱이 작은 모놀리식이고 공개 API만이 유일한 관심사라면, REST가 여전히 더 간단할 수 있다.

Back to Blog

관련 글

더 보기 »

DataBlock와 실제 API 탐색

GitHub와 Packagist 데이터를 사용하여 Symfony와 Laravel을 비교합니다. 첫 번째 기사 “Handling Nested PHP Arrays Using DataBlock”에서 우리는 DataBlock을 …