Multi-guards authentication with LIT (Laravel 8 Inertia Tailwind) stack (1/2)

Laravel 8 의 Fortify 는 headless Auth 모듈이며, 단 하나의 User 모델 만을 지원하기 때문에 multiple authentication 을 위하여 Trait 기반으로 작성된 laravel/ui 를 사용할 때 처럼 여러개의 사용자 모델을 만들 수 없다. 이 글에서는 api 가드(guard)가 사용하는 User 모델 이외에 web 가드(guard)를 사용하는 Member 모델을 생성하고 spatie/permissions 패키지를 이용하여 관리자, 파트너 로 명명된 각기 다른 사용자들의 로그인 처리를 어떻게 구현했는지 설명한다.

Understanding the Big Picture

아래의 Laravel 8 튜토리얼 비디오를 보면 Jetstream 을 이용한 multi-guards 구현에 대한 유사 use-case 를 설명한다. 차이점이라면 내 프로젝트 use-case 의 경우, User 모델은 api 가드를 사용하고, Member 모델은 web 가드를 사용하기에 인증에 필요한 유저모델이 2개인 반면, 이 튜토리얼의 경우 하나의 User 모델을 api 가드 와 web 가드에서 사용하고, role/permission 기능을 이용하여 authorization 관리를 한다는 점에서 차이가 있다. 또한, 나는 Inertia 사용했고, 이 튜토리얼은 Livewire 를 사용한다는 차이가 있지만, 큰 그림을 이해하는데 좋은 레퍼런스가 될 것 같아서 소개한다.

The use-case

위에서 언급한 내 프로젝트의 use-case 를 살펴보면 다음과 같다. 우선 User 모델을 사용하는 앱은 웹 접속이 필요없는 모바일 전용 앱이다. 사용자 인증은 tymondesigns/jwt-auth 를 사용한 api 가드를 사용한다. web 가드의 경우 Fortify 와 Jetstream 을 사용한 웹써비스를 위해서 만들었고, 이 web 가드는 별도의 Member 라는 모델을 사용한다.

  1. barun.test
  2. www.barun.test (redirect to barun.test)
  3. partner.barun.test
  4. admin.barun.test

1번 주소는 사용자 인증기능 없는 모바일 앱의 다운로드 홍보 페이지이고, 2번 주소는 단순히 DNS (Route 53) 에서 ALB 로 연결한 alias A 레코드를 사용해서, 1번 주소로 리다이렉션 되도록 만든 WWW 주소이며, 3번 주소는 파트너 회원(partner role 을 갖는 Member 회원) 전용 앱이고, 4번 주소는 관리자 회원 (admin role 을 갖는 Memeber) 전용 앱이다. 다시 말해 Member 회원은 role 에 따라, 파트너로 접속할 수 도, 어드민으로 접속할 수 도 있다.

Laravel Fortify

Fortify 는 사용자 등록에 필요한 로그인, 가입, 비밀번호 초기화, 이메일 확인, 비밀번호 갱신, 프로파일 정보 갱신, 2팩터인증 등 사용자 인증을 위한 백앤드 로직 패키지이다. 단 하나의 가드만 지원하여, 여러개의 모델을 사용할 수 없다. config/fortify.php 를 살펴보면 아래와 같다.

<?php

use App\Providers\RouteServiceProvider;
use Laravel\Fortify\Features;

return [

    'guard' => 'web',
    'passwords' => 'members',
    'username' => 'email',
    'email' => 'email',
    'home' => RouteServiceProvider::HOME,

    'prefix' => '',
    'domain' => null,

    'middleware' => ['web'],

    'limiters' => [],

    'views' => true, // register all the view routes

    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        Features::emailVerification(),
        Features::updateProfileInformation(),
        Features::updatePasswords(),
        // Features::twoFactorAuthentication([
        //     'confirmPassword' => true,
        // ]),
    ],

];

Fortify 를 설치하면 인증기능을 담당하는 일련의 Actions, Contracts, Http\Controllers, Http\Requests, Http\Responses, Rules 가 생성되는데, 라우터를 걸친 Controllers 가 Requests 를 받아 Actions 을 수행하여 Responses 를 리턴하는 일반적인 패턴이다.

Subdomain Routing

우리가 원하는 multi 인증을 위해서는 RouteServiceProvider 에서 subdomain 별로 각기 다른 Controllers 로 라우팅되도록 했다. 즉, admin.barun.test 로 접속하면 app/Http/Controllers/Admin/... 로 연결하고, partner.barun.test 로 접속하면 app/Http/Controllers/Partner/... 로 연결한다. RouteServiceProvider 는 아래처럼 설정했다.

참고로 config('app.domain') 은 http scheme 을 제외한 도메인명 즉, barun.test 을 값으로 갖는다.

public function boot()
    {
        $this->configureRateLimiting();

        $this->routes(function () {
            // api.barun.test
            Route::middleware('api')
                ->name('api.')
                ->domain('api.'.config('app.domain'))
                ->group(base_path('routes/api.php'));

            // admin.barun.test
            Route::middleware(['web', 'role:admin'])
                ->name('admin.')
                ->domain('admin.'.config('app.domain'))
                ->group(base_path('routes/web-admin.php'));

            // partner.barun.test
            Route::middleware('web')
                ->name('partner.')
                ->domain('partner.'.config('app.domain'))
                ->group(base_path('routes/web-partner.php'));

            // barun.test
            Route::middleware('web')
                ->group(base_path('routes/web-base.php'));
        });
    }

App\Http\Controllers\Admin 와 App\Http\Controllers\Partner 로 나뉘었기 때문에 Actions, Requests, Responses 들도 Admin 과 Partner 로 role 별 클래스를 만들어서 처리할 수 도 있고, 아니면 하나의 클래스에서 인증 회원의 role 을 체크하여 conditionally 처리할 수 도 있다. 하지만, Response 들은 대개 Action 의 결과를 인자로 받아 리턴되므로, 인자를 갖는 형태로 되어 있고, 인자(params)를 갖는 contextual binding 은 anti-pattern 으로 라라벨에서 지원을 하지 않으므로, 아래와 같이 바인딩 후,

$this->app->singleton(
    \Laravel\Fortify\Contracts\LoginResponse::class,
    \App\Http\Responses\LoginResponse::class
);

아래 처럼 실제 concrete class 의 메서드 안에서 컨디션을 체크하는 것이 Fortify 가 제공하는 코드 커스터마이징 코스트가 최소화될 수 있는 방법이다.

<?php

namespace App\Http\Responses;

use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\PasswordResetResponse as PasswordResetResponseContract;

class PasswordResetResponse implements PasswordResetResponseContract
{
    protected $status;

    public function __construct(string $status)
    {
        $this->status = $status;
    }

    public function toResponse($request)
    {
        if ($request->wantsJson()) {
            return new JsonResponse(['message' => trans($this->status)], 200);
        }

        if ($request->isAdmin()) {
            return redirect()
                ->route('admin.login')
                ->with('status', trans($this->status));
        }

        return redirect()
            ->route('partner.login')
            ->with('status', trans($this->status));
    }
}

따라서, 나의 최종적인 FortifyServiceProvider 의 모습은 아래와 같다.

class FortifyServiceProvider extends ServiceProvider
{
    public function register()
    {
        if (request()->isAdmin()) {
            config(['fortify.domain' => 'admin.'.config('app.domain')]);
        } else if (request()->isPartner()) {
            config(['fortify.domain' => 'partner.'.config('app.domain')]);
        }

        $this->registerFortifyResponsesForTwoDistinctRoles();
    }

    public function boot()
    {
        Fortify::ignoreRoutes();

        Fortify::createUsersUsing(CreateNewUser::class);
        Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
        Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
        Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);

        RateLimiter::for('admin.login', function (Request $request) {
            return Limit::perMinute(5)->by($request->email.$request->ip());
        });

        RateLimiter::for('partner.login', function (Request $request) {
            return Limit::perMinute(5)->by($request->email.$request->ip());
        });

        if (Features::enabled(Features::twoFactorAuthentication())) {
             RateLimiter::for('two-factor', function (Request $request) {
                 return Limit::perMinute(5)->by($request->session()->get('login.id'));
             });
        }
    }

    protected function registerFortifyResponsesForTwoDistinctRoles()
    {
        $this->app->singleton(
            LoginResponseContract::class,
            LoginResponse::class
        );
        $this->app->singleton(
            PasswordConfirmedResponseContract::class,
            PasswordConfirmedResponse::class
        );
        $this->app->singleton(
            PasswordResetResponseContract::class,
            PasswordResetResponse::class
        );
        $this->app->singleton(
            RegisterResponseContract::class,
            RegisterResponse::class
        );

        if (Features::enabled(Features::twoFactorAuthentication())) {
            $this->app->singleton(
                FailedTwoFactorLoginResponseContract::class,
                FailedTwoFactorLoginResponse::class
            );
            $this->app->singleton(
                TwoFactorLoginResponseContract::class,
                TwoFactorLoginResponse::class
            );
        }
    }
}

Laravel Sanctum vs JWT

stateful 세션을 사용하는 Sanctum 은 미들웨어를 사용하여 API 와 Web 에서 공히 사용할 수 있어서, 실시간으로 사용자의 어떤 디바이스만 로그아웃 시키는 섬세한 인증관리가 가능하지만, stateful 이란 태생적 한계와 Laravel 6 부터 계속 키워왔던 코드 베이스에, tymondesigns/jwt-auth 를 이용하여 stateless 인증을 사용했던 바 (물론, jwt-auth 패키지도 blacklist 를 위한 state 관리가 필요하지만…) , 굳이 프로덕션 모바일 앱과의 연동이 잘되고 있는 jwt 인증부를 Sanctum 으로 변경할 필요성을 못느꼈다. 어쨌든 현재 프로젝트에서 jwt 드라이버를 사용하는 api 가드는 User 모델을 사용하고 web 가드는 Member 모델을 사용하는 구조이다. 이해를 돕기위해 config/auth.php 를 보면 아래와 같다.

<?php

return [

    'defaults' => [
        'guard' => 'api',
        'passwords' => 'users',
    ],

    'guards' => [
        'api' => [
            'driver' => 'jwt',
            'provider' => 'users',
            'hash' => false,
        ],
        'web' => [
            'driver' => 'session',
            'provider' => 'members',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
        'members' => [
            'driver' => 'eloquent',
            'model' => App\Models\Member::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
        'members' => [
            'provider' => 'members',
            'table' => 'password_resets',
            'expire' => 30,
            'throttle' => 30,
        ],
    ],

    'password_timeout' => 10800,

];

Laravel Jetstream

Laravel Fortify 가 사용자 인증을 위한 백앤드 로직 패키지라면, Laravel Jetstream 은 사용자 인증을 위한 프론트앤드 스캐폴딩 패키지라 말할 수 있다. 프로트앤드 기술로는 Laravel 의 전통적인 Blade 템플릿 엔진과 미니멀 JS 프레임워크인 Alpine 을 사용하는 LiveWire 와 VueJS 를 사용하여 SPA 를 만들 수 있는 Inertia 가 있다. (물론 Inertia 는 프레임웍에 종속적이지 않아 React 나 Svelte 를 사용할 수 도 있지만 내 주력 프레임웍이 아니라서 패스.) 전자는 TALL 스택, 후자는 LIT 스택이라고도 불리운다.

나의 경우, VueJS 나 Nuxt 로 작업을 많이 해왔기에, 당연히 VueJS 패키지들을 자유롭게 import 할 수 있는 Inertia 에 좀더 매력을 느꼈다. 그에 반해 라라벨 커뮤니티에서만 사용하는 LiveWire 의 경우, 얼마나 많은 패키지를 쉽게 구할 수 있겠는가를 생각했을 때 답은 No brainer 이다. 물론 이런식의 dependency 를 갖는 모습에 반대하는 볼멘 목소리가 reddit 에 올라오기도 하지만, 개인적으론 매우 환영한다.

여담으로 Inertia 페이지를 가보면 Modern monolith 이라는 카피를 볼 수 있다. monolithic 아키텍쳐가 classic 하다(퇴물이라)고 회자될 때 전통적 all-in-one 식 개발 방식의 고수를 이끈 Jonathan Reinink 에게 경의를 표한다. 독립적인 프론트엔드 작업과 백앤드 API 작업을 동시에 해본 사람이라면 전통적인 SSR 방식의 빠른 초기 반응 속도의 장점 뿐만아니라, 하나의 코드 베이스로 동작하는 MVC 패턴의 생산성, 배포 및 모니터링 등 여러면에서 개이득이란 점 공감하리라.

Y’all know what KISS means. Keep it simple! suckerz…

어쨌든, 본론으로 들어가서, config/jetstream.php 을 보면 아래와 같다.

<?php

use Laravel\Jetstream\Features;

return [

    'stack' => 'inertia',

    'middleware' => ['web'],

    'features' => [
        Features::termsAndPrivacyPolicy(),
        Features::profilePhotos(),
        // Features::api(),
        // Features::teams(['invitations' => true]),
        Features::accountDeletion(),
    ],

    'profile_photo_disk' => 'public',

];

JetstreamServiceProvider 은 아래와 같은 설정으로 Inertia 를 통한 Vue 뷰들을 등록하는데, 관리자와 파트너는 완전히 다른 뷰를 사용하기에 resources/js/Pages 밑에 resources/js/Pages/Adminresources/js/Pages/Partner 를 생성하여 사용했다.

<?php

namespace App\Providers;

use App\Actions\Jetstream\DeleteUser;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\Member;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Http\Middleware\ShareInertiaData;

class JetstreamServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        $this->configurePermissions();

        Jetstream::useUserModel(Member::class);

        $this->bootInertiaViews();

        Jetstream::deleteUsersUsing(DeleteUser::class);
    }

    protected function configurePermissions()
    {
        Jetstream::defaultApiTokenPermissions(['read']);

        Jetstream::permissions([
            'create',
            'read',
            'update',
            'delete',
        ]);
    }

    protected function bootInertiaViews()
    {
        $kernel = $this->app->make(Kernel::class);

        $kernel->appendMiddlewareToGroup('web', ShareInertiaData::class);
        $kernel->appendToMiddlewarePriority(ShareInertiaData::class);

        if (class_exists(HandleInertiaRequests::class)) {
            $kernel->appendToMiddlewarePriority(HandleInertiaRequests::class);
        }

        Fortify::loginView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/Login", [
                'canResetPassword' => Route::has('password.request'),
                'status' => session('status'),
            ]);
        });

        Fortify::requestPasswordResetLinkView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/ForgotPassword", [
                'status' => session('status'),
            ]);
        });

        Fortify::resetPasswordView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/ResetPassword", [
                'email' => $request->input('email'),
                'token' => $request->route('token'),
            ]);
        });

        Fortify::registerView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/Register");
        });

        Fortify::verifyEmailView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/VerifyEmail", [
                'status' => session('status'),
            ]);
        });

        Fortify::twoFactorChallengeView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/TwoFactorChallenge");
        });

        Fortify::confirmPasswordView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/ConfirmPassword");
        });
    }
}

Providers

추가적으로 이번 작업을 위해 변경된 Service Provider 들을 살펴보자. 우선 AppServiceProvider 는 subdomain routing 시 요청하는 호스트를 체크하기 위해 아래와 같이 매크로를 설정했고

    public function register()
    {
        Request::macro('isAdmin', function () {
            return $this->getHost() === 'admin.'.config('app.domain');
        });

        Request::macro('isPartner', function () {
            return $this->getHost() === 'partner.'.config('app.domain');
        });
    }

RouteServiceProvider 는 아래처럼 설정했다. 위 내용에서 config('app.domain') 은 http scheme 을 제외한 도메인명barun.test 을 값으로 갖는다. 즉, admin.barun.test 냐 partner.barun.test 냐 아니면 그냥 barun.test 냐 에 따른 subdomain 라우팅을 설정하기 위함이다. config/app.php 에 아래와 같이 정의 되어 있다.

    'url' => env('APP_URL'),
    'domain' => preg_replace('/https?:\/\//', null, env('APP_URL')),

admin.barun.test 의 라우트들에는 role:admin 이라는 미들웨어가 있는데, 아래와 같이 정의된 미들웨어다.

class CheckRole
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  string $role
     * @return mixed
     */
    public function handle(Request $request, Closure $next, string $role)
    {
        $user = $request->user();
        if ($user) {
            if ($role === 'admin' &amp;&amp; $user->hasRole('partner')) {
                abort(403);
            }
        }

        return $next($request);
    }
}

admin 만 가능한 라우트를 partner 가 감히 접근하게 되면 403 오류페이지를 리턴하는 미들웨어이다. 말이 나온김에 authorization 패키지는 spatie/laravel-permission 를 사용했고 DB 시딩은 아래 처럼 처리했다.

class RolesAndPermissionsSeeder extends Seeder
{
    public function run(): void
    {
        // Reset cached roles and permissions
        app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();

        // create permissions
        Permission::create(['name' => 'create.templates', 'guard_name' => 'web']);
        Permission::create(['name' => 'update.templates', 'guard_name' => 'web']);
        Permission::create(['name' => 'delete.templates', 'guard_name' => 'web']);
        Permission::create(['name' => 'approve.templates', 'guard_name' => 'web']);

        // create roles
        Role::create(['name' => 'admin', 'guard_name' => 'web']);
        // gets all permissions via Gate::before rule; see AuthServiceProvider

        $partnerRole = Role::create(['name' => 'partner', 'guard_name' => 'web']);
        $partnerRole->givePermissionTo(['create.templates', 'update.templates', 'delete.templates']);
    }
}

다음에 계속…

Posted by admin in Backend, 0 comments

AWS 채팅앱 인프라 배포 (2/2)

🖥 Node.js Chatting App

라라벨 앱과는 달리 노드JS 앱은 Docker 컨테이너로 ECS/Fargate 으로 배포되며, Redis, DynamoDB 등의 인프라를 사용한다. 필요한 경우 읽지 않은 채팅메시지 푸시알림은 SQS 를 통해 라라벨앱으로 전달하여, 워커 환경에서 처리하도록 되어 있다.

📙 DynamoDB

chat-messages, chat-rooms, chat-users 테이블을 생성시 hash key 를 지정하고 default 옵션으로 생성한다. 생성후에는 Provisioned capacity 의 read units 과 writes units 을 10, 5 로 조정했다. (DynamoDB 는 특정 VPC 에 종속되지 않고 접근이 가능하다.)

📙 SQS

chat-queue 큐를 생성한다. (SQS 는 특정 VPC 에 종속되지 않고 접근이 가능하다.)

📙 VPC

default vpc 를 사용할 수 도 있지만, chat-vpc 라는 별도의 vpc 를 생성해야만 환경삭제시 편하다.

  • Name tag: chat-vpc
  • IPv4 CIDR block: 10.0.0.0/16
  • IPv6 CIDR block: no

📙 Subnets

ECS 클러스터 생성시 VPC 를 생성하는 옵션을 선택하는 경우, 자동으로 생성된다.
Name tag: chat-subnet-az1, chat-subnet-az2
VPC: chat-vpc
AZ: AZ1, AZ2, …
IPv4 CIDR block: 10.0.0.0/24, 10.0.1.0/24, …

📙 Redis

Redis 는 Subnet Group 을 지정해야하는데, 이것이 VPC 와 연결되어 있다. 다시 말해 채팅서버가 바라보는 Redis 를 라라벨서버가 같이 바라봐야 한다면 같은 VPC 에 생성해야하지만, 그렇지 않다면, 다른 컨디션을 고려할 필요없다. 아래의 옵션으로 생성했다.

Engine: Redis
Location: Amazon Cloud
Redis settings:

  • chat-redis
  • 6.x
  • 6379
  • default.redis6.x
  • cache.t2.small (1.55 GB)
  • # of replicas : 0
  • subnet group : create new
    • name : chat-subnet-group
    • vpc : chat-vpc
    • subnets : 위 단계에서 생성한 subnets
    • security groups :
    • chat-default-sg (자동으로 생성) : all
    • chat-redis-sg (수동으로 추가): 6379

📙 Internet Gateway

  • ECS 클러스터 생성시 VPC 를 생성하는 옵션을 선택하는 경우, 자동으로 생성됨.
  • name: chat-igw
  • VPC: chat-vpc

📙 Route Table

  • ECS 클러스터 생성시 VPC 를 생성하는 옵션을 선택하는 경우, 자동으로 생성됨.
  • VPC 에 자동으로 생성된 라우트를 수정 (Edit routes)
    • Routes
      • 탭 = 0.0.0.0/0
      • target = chat-igw
    • Subnet Associations
      • chat-subnet-az1
      • chat-subnet-az2

📙 Security Groups

  • chat-task-sg: allows 3210
  • chat-lb-sg: allows HTTP, HTTPS
  • chat-dynamodb-sg: allows DynamoDB
  • chat-redis-sg: allows Redis

📙 System Manager

Parameter Store 에 .env.production 에 지정한 환경변수들을 빠짐없이 입력한다.

📙 Load Balancer

Application Load Balancer

  • chat-load-balancer
  • internet-facing
  • ipv4
  • Listeners
    • HTTP/HTTPS (80/443 포트)
  • AZ
  • 가용 AZ 모두 선택

Security Settings

chat.lousydomain.com 에 대한 Certificate ACM 에서 설정후

Security Groups

  • chat-default
  • chat-lb-sg

Routing

  • Target group
    • name: lousy-chat-tg
    • type: IP
    • protocol: HTTP
    • port: 80
  • health check
    • HTTP
    • /health (depending on your implementation)

Register targets

  • intact

📙 ECR

  • Repository name : chat-server

📙 ECS

Create Cluster 를 선택하고 cluster template 에서 Networking only (Powered by AWS Fargate) 을 선택한다.

  • 클러스터명 : lousy-chat-cluster
  • CloudWatch Container Insights : Enable

📙 Task Definition

  • Task definition name: lousy-chat-task
  • Requires Compatibilities: Fargate
  • Task execution role : ecsTaskExecutionRole
  • Task size : 0.5G, 0.25 vCPU

Container Definitions

  • Container name: chat-server
  • image: 000000000000.dkr.ecr.ap-northeast-2.amazonaws.com/chat-server:latest
  • private repository authentication: intact (false)
  • Memory limits: intact (soft limit 128)
  • Port mappings: 3210 tcp
  • Health check: CMD-SHELL, curl -f http://localhost:3210/health || exit 1
  • Environment: intact (default values)
  • Environment variables: ASM Parameter Store 에 지정한 내용을 아래 스크린샷처럼 입력한다. 키값, valueFrom, arn (형식: arn:aws:ssm:ap-northeast-2:000000000000:parameter/chat-REGION)

📙 Service

Configure service

  • Launch type: Fargate
  • Task Definition
    • Family : lousy-chat-task
    • Revision : 1 (latest)
  • Platform version: LATEST
  • Cluster: lousy-chat-cluster
  • Service name: lousy-chat-service
  • Service type: REPLICA
  • Number of tasks: 1
  • Task Group: chat-task-group
  • Minimum healthy percent: 100
  • Maximum percent: 200

Deployments

  • Rolling update (set them as default)

Configure network

  • Cluster VPC: lousy-chat-vpc
  • Subnets: chat-subnets (ap-northeast-2a, ap-northeast-2b)
  • Security groups: chat-lb-sg (80/443), chat-task-sg (3210)
  • Auto-assign public IP: ENABLED
  • Health check grace period: 60
  • Load balancing
    • Load balancer type: Application Load Balancer
    • Load balancer name: lousy-chat-load-balancer
  • Container to load balance
    • Container name : port : lousy-container:3210
  • Production listener port: 80
    • Production listener protocol: HTTP
    • Target group name: lousy-chat-tg
    • 생성이후 https 로 리다이렉팅 룰 추가 예정
  • Auto Scaling
    • Minimum number of tasks: 1
    • Desired number of tasks: 1
    • Maximum number of tasks: 4
    • IAM role for Service Auto Scaling: ecsAutoscaleRole
  • Automatic task scaling policies
    • Scaling policy type: Target tracking
    • Policy name: scaling-cpu-policy
    • ECS service metric: ECSServiceAverageCPUUtilization
    • Target value: 75
    • rest intact

📙 Associate ACM SSL certificate with Application Load balancer

Refer) https://aws.amazon.com/premiumsupport/knowledge-center/associate-acm-certificate-alb-nlb

  • choose EC2 > Load balancer
  • add listener
  • default action
  • forward to chat-tg
  • group-level stickiness
    • enable up to 30 seconds
  • select certificate from ACM
  • update HTTP 80 traffics redirecting to HTTPS://#{host}:443/#{path}?#{query}

Route 53

  • Add a A record to lousy-chat-load-balancer

Posted by admin in Backend, 0 comments

AWS 채팅앱 인프라 배포 (1/2)

이 글은 2021년 2월2일 AWS 콘솔 페이지를 통해 배포하는 인프라 구성 노트다. MVP 용 채팅앱 인프라를 구성하면서 틈틈히 적은 내용이다. 구축하고자 하는 구성은 크게 라라벨 앱과 Node.js 앱으로 나뉜다.

🖥 Laravel App

라라벨 앱은 Elastic Beanstalk 의 Webserver 환경과 Worker 환경으로 배포되어, 전자는 앱을 위한 API 서버와 관리자 페이지 및 앱소개 페이지를 위해 사용되고, 후자는 라라벨 앱의 백그라운드 작업 및 채팅서버로부터 SQS 로 전달되는 푸시알림을 위한 큐워커로서 동작하는데 조만간 MySQL 과 Elasticsearch 와의 동기화 작업도 담당할 예정이다.

사용 인프라는, Elastic Beanstalk Worker, MySQL, Redis, SQS, DynamoDB, Elasticsearch(예정), S3, CloudFront, ACM, Route53, SNS, SMS, SES 등이다.

📙 RDS

DB instance size

  • db.t3.small

Storage

  • General Purpose (SSD)
  • 20 GiB

Connectivity

  • Default VPC
  • Public access : NO
  • VPC security group : default, RDB group

Database options

  • initial database name : wanderinghog

Monitoring

  • Audit log
  • Error log
  • General log
  • Slow query log

📙 Redis

라라벨 앱을 위한 1개 인스턴스 (cache.t2.micro 노드) 와 채팅 앱을 위한 1개 인스턴스 (cache.t2.small 노드) 를 생성한다. 주의할 점은 EB 환경과 같은 subnet group 선택후, Security groups 은 default security group 과 Redis 6379 port 를 위한 security group 을 지정한다. 다른 옵션들은 모두 leave as default.

📙 SQS

Worker 환경을 만들면서 Auto-generated queue 를 선택하면 Worker 환경을 위한 AWSEBWorkerQueue/AWSEBWorkerDeadLetterQueue 콤보가 자동으로 생성된다. 물론, 환경에 attach 된 queue 를 갖는 다는 것은 장점도 있지만 단점도 있다. 일단, 이름에 랜덤 스트링을 포함해 지저분한 건 차치하더라도, 행여나 환경 리빌딩이라도 하는 경우엔 .env 에 지정한 queue 이름과 달라져 오류를 만들어낼 수 밖에 없다. 따라서 수동으로 큐를 생성하는 것이 더 낫다. 이후 채팅 앱을 위한 queue 와 Elasticsearch 동기화를 위한 queue 각각 1개씩을 생성할 것이다. 생성후 Access policy (Permissions) 내용을 삭제하면, 아래와 같은 기본 퍼미션이 자동으로 만들어진다.

{
  "Version": "2012-10-17",
  "Id": "arn:aws:sqs:ap-northeast-2:206020602060:lousydomain-queue/SQSDefaultPolicy"
}

📙 S3

Private bucket for configuration files

production 용 .env 파일을 비롯하여, Google API 사용을 위하여 필요한 credentials 을 비공개로 저장하고 있다가 .ebextentions 배포 스크립트가 코드베이스의 특정위치에 복사하기 위한 파일들을 저장하는 버킷이 필요하다. 버킷 이름은 environment 라 명명했고 permissions 은 당연히 Block all public access 옵션을 선택했다.

Public bucket for Laravel app

라라벨앱에서 저장할 이미지 버킷의 objects 들은 누구나 볼 수 있어야 하기 때문에 퍼미션을 아래와 같은 설정했다.

Block public access options

Bucket policy 는 아래와 같고,

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::206020602060:user/admin"
            },
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::pub-images",
                "arn:aws:s3:::pub-images/*"
            ]
        }
    ]
}

아래 그림과 같이, ACL 에서 Everyone 에게 Read 퍼미션을 등록했다.

S3 Bucket ACL

📙 Elastic Beanstalk (Web server)

make sure all the scripts in .ebextensions and .platform are written correctly for the latest Elastic Beanstalk requirements.

우선, Select environment tier 항목에서 Web server environment 를 선택 후 Configure more options 버튼을 클릭한다. Presets 는 Auto scaling 을 위한 High availability 옵션을 선택한다.

Software

Document root 는 /public

Instance log streaming to CloudWatch Logs

  • enabled
  • 5 days retention
  • delete logs upon termination

Environment properties 에 아래 entries 추가

  • COMPOSER_HOME : /root
  • WORKER : false
  • TZ : Asia/Seoul

Instances

EC2 security groups

  • default security group
  • eb-80/22 : VPC security group

Capacity

Leave as default

Load balancer

Leave as default

Rolling updates and deployments

  • Deployment policy : Rolling
  • Batch size : 30%
  • Rolling based on Health
  • Batch size : 1
  • Minimum capacity : 1

Security

  • Virtual machine permissions : EC2 key pair : aws-eb

Monitoring

Health monitoring rule customization

  • Ignore application 4xx : enabled

Managed Updates

Weekly update window

  • Tuesday 19:00 UTC (04:00am)

Notifications

Network

Load balancer settings : set Visibility to Internal

Load balancer subnets (everything but ap-northeast-2b/2d, which has no support for t2.micro instance type)

  • ap-northeast-2a
  • ap-northeast-2c

Instance subnets

  • ap-northeast-2a
  • ap-northeast-2c

Database

leave as default

Tags

leave as default

📙 Elastic Beanstalk (Worker)

Note that I’d used Elastic Beanstalk Worker environment with this package (https://github.com/dusterio/laravel-aws-worker) for my earlier projects because of the less complexity. However, convenience comes with a price. With the approach, having multiple queue connections and dedicated workers must go through extra overheads. So, instead, I decided to go with supervisord this time. Here’s how.

이젠 Worker 환경을 셋업하는데, Select environment tier 항목에서 Worker environment 를 선택 후 Configure more options 버튼을 클릭한다. Presets 는 Auto scaling 을 위한 High availability 옵션을 선택한다.

Software

Document root 는 /public

Instance log streaming to CloudWatch Logs

  • enabled
  • 5 days retention
  • delete logs upon termination

Environment properties 에 아래 entries 추가

  • COMPOSER_HOME : /root
  • WORKER : true
  • TZ : Asia/Seoul

Instances

EC2 security groups (원칙적으로 Worker 의 경우 22 만 열면 된다. worker-daemon 에서 localhost:80 으로 요청을 보낸다. https://docs.aws.amazon.com/ko_kr/elasticbeanstalk/latest/dg/using-features-managing-env-tiers.html 또는 https://github.com/dusterio/laravel-aws-worker 참고)

  • default security group
  • eb-22 : VPC security group

Capacity

Leave as default

Worker

  • Worker 환경은 원칙적으로 웹서버 프로세스가 필요없지만, Elastic Beanstalk 의 경우, aws-sqsd 데몬 프로세스와 puma 를 사용하여 SQS 에서 읽은 아이템 내용을 localhost:80/path 로 POST 호출하여 전달하는 기능을 기본으로 제공한다. aws-sqsd 프로세스를 없애면, health 이상으로 보고 되기 때문에, aws-sqsd 가 사용하지 않는 빈 SQS 큐를 바라보도록 우회시켰다. 대신 다중 큐 커넥션처리를 위하여 supervisord 를 설치하여 사용한다. (이런 낭비를 막기위해선 결국 라라벨앱 역시 Docker image 를 만들어서 ECS/Fargate 로 운영하는 방법이 답인듯).
  • Worker queue : aws-sqsd 데몬 프로세스가 바라볼 큐를 지정하는 옵션이므로, lousy-void-queue 를 만들어 queue 를 지정한다. (SQS 의 경우, 이처럼 만들어만 놓고 사용하지 않는 경우 사용료가 발생하지 않는다.) aws-sqsd 우회가 목적이므로, HTTP path 는 /void 로, 최대 동시 접속 HTTP connections 을 1로 설정했다.

Rolling updates and deployments

Leave as default

Security

  • Virtual machine permissions : EC2 key pair : aws-eb

Monitoring

Health monitoring rule customization

  • Ignore application 4xx : enabled

Managed Updates

Weekly update window

  • Tuesday 19:00 UTC (04:00am)

Notifications

Network

Load balancer subnets (everything but ap-northeast-2b/2d, which has no support for t2.micro instance type)

  • ap-northeast-2a
  • ap-northeast-2c

Instance subnets

  • ap-northeast-2a
  • ap-northeast-2c

Database

leave as default

Tags

leave as default

Tip) 만일 라라벨 db:seed 명령중에 메모리 부족 오류가 난다면 커맨드라인 상에서 아래와 같이 swap 메모리를 2G 정도 잡아두고 재실행해볼 수 있겠다. (실제론 Worker 환경의 인스턴스를 t2-tiny (1G) 에서 t2-small (2G) 로 변경했다.)

sudo dd if=/dev/zero of=/swapfile bs=128M count=16
sudo chown root:root /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
sudo swapon -a

📙 Route53

MySQL, Redis, SQS 가 배포된 상태에서 동작되는 라라벨 앱을 확인할 수 있으면 도메인명을 연결할 차례이다. Route53 > Hosted zones 에 도메인명을 생성 후 NS 레코드에 나오는 4개의 name servers 정보를 godaddy 와 같은 domain name registrar 콘솔 페이지에 name servers 로 등록한다.

A record

EB 환경 셋업시 생성한 Load Balancer 를 지정하는 방법은 새로운 콘솔화면에서, Alias 스위치를 켜고 Alias to Elastic Beanstalk environment 를 선택하고 지역과 LB 이름을 셀렉션박스에서 고르도록 되어있다.

CNAME record

www.lousydomain.com to lousydomain.com

📙 Certificate Manager

ACM 의 역할은 자동으로 https certificate 를 DNS 에 연결할 Route53 레코드 자동생성 및 주기별 자동 갱신이다. Request a certificate 버튼을 누르고 이후 페이지에서 request a public certificate 을 선택 후 도메인명을 지정하면, Route53 에 CNAME 레코드를 자동으로 생성시킬 수 있다.

List 에 Imported type 의 server 라는 도메인이 나오는데, 이는 뭔가 모르겠다. In use? 가 Yes 던데…

📙 EC2

Load balancers

기본적으로 생성된 로드밸런서의 경우, HTTP 를 위한 80 포워딩 리스너 밖에 등록이 되어 있지 않으므로, HTTPS 를 위한 443 리스너 등록이 필요하다. 일단, 80 리스너는 HTTPS 로의 리다이렉팅, 443 리스너는 EB 타겟그룹으로의 포워딩을 지정하고, Security policy 에는 ELBSecurityPolicy-2016-08 옵션을 Default SSL certificate 에는 From ACM 과 도메인명을 선택한다.

아래의 이미지와 비슷한 셋팅이 이뤄졌으면 정상이다.

EC2 > Load balancer 설정화면

참고로 2번째 HTTPS 리스너의 경우 오랜지색 exclamation mark 가 있는데, 이는 security group 에 443 포트가 허용되어 있지 않다는 경고이므로, LB 의 SG 을 선택하여 수정한다.

📙 CloudFront

이제 CDN 설정한다. Create Distribution 버튼을 클릭하고 Get started 버튼을 클릭하면, 다양한 옵션들을 입력하게끔 되어있는데, 스크린샷으로 설명을 대처하겠다. 참고로 use-case 는 App 에 제공할 이미지 CDN 설정이다.

Origin Settings

Default Cache Behavior Settings

Distribution Settings

Tip) 깨알글씨를 읽어보면 알 수 있듯 cdn.lousydomain.com 와 같은 주소로 CloudFront 를 사용하고 싶다면 해당 도메인명과 Custom SSL certificate request 를 미동부 리젼(us-east-1) 의 ACM 에 해야만 하는 조건이 있다. 따라서, 다른 디테일 옵션들을 지정하기 전, 반드시 먼저 SSL certificate 신청을 완료하고 CloudFront 신청 페이지를 refresh 해야만 cdn.lousydomain.com 이라는 옵션이 Custom SSL Certificate 아래 입력박스안에 나타난다.

CloudFront Distributions 리스트에서 아이디를 클릭하면 General 탭에 정보가 나타나는데, d312cokiv00ltd.cloudfront.net 와 같은 값을 갖는 Domain Name 을 찾아 Route 53에서, cdn.lousydomain.com 에 대한 CNAME 레코드 값으로 반드시 등록해야 한다.

📙 Elasticsearch Service

At least for now, I’d like to spin up an AWS Elasticsearch cluster w/ the minimum resources.

  1. enter the domain name like kimchi
  2. don’t select custom domain cause it’s for aesthetic purposes only.
  3. specify a certain time for auto-tune
  4. choose t3.small as instance type
  5. choose public access
  6. disable find-grained access control
  7. select domain access policy
    1. select allow open access to the domain first
    2. then, select JSON defined access policy this way you will have a JSON template
    3. enter the admin user’s ARN in the JSON

will take 20 something mins to have the ES ready status on the console page.

📙 SES

Simple Email Service 는 이메일 발송만 할 것이고, 이메일수신은 ForwardEmail 서비스를 이용하여, 다른 계정으로 포워딩되도록 셋팅했다. 이를 위해선 MX 와 TXT 레코드를 Route53 에 설정해야하는데, 그 과정 설명은 해당 서비스 페이지에 상세한 설명이 나오므로 생략한다.

Identity Management

이메일 발송을 위해서는 도메인명과 이메일을 확인해야한다. Verify a New Domain 버튼을 클릭 후 도메인명을 입력한다. Generate DKIM Settings 를 선택하면, 자동으로 Route 53 에 레코드들을 자동으로 생성할 수 있다. 이렇게 도메인명을 확인했으면 발송 이메일의 주소를 입력하여 소유권을 확인한다.

Tip) 이메일 Notifications 셋업은 email forwarding 을 enable 시키도록 한다. 그러면 complaints, bounce 등에 대한 보고를 이메일로 받을 수 있다.

Email Sending

Sending Statistics 메뉴를 클릭후 Sandbox 모드를 프로덕션모드로 변경하는 요청을 하면 수시간내에 프로덕션 모드로 기본 1일 5만건의 발송을 quota 를 할당받을 수 있는데, 여기까지하면 이메일을 발송할 수 있다.

📙 SNS

SNS 의 경우, EB 환경 생성시 Notification 이메일 주소를 지정하면 자동 생성되며, endpoint 는 email 로 설정되어 있으므로 Request confirmation 버튼을 눌러서 수신 확인을 한다. 이 email 로 EB 환경 상태 변경시 마다 알림이 전달된다.

SNS 의 Push Notification 도 필요하므로 Firebase FCM 을 설정한 뒤, arn:aws:sns:ap-northeast-2:000000000666:app/GCM/lousy-domain 과 같은 arn 생성하여 라라벨앱에서 사용하도록 한다.

Posted by admin in Backend, 0 comments

Realtime broadcasting with Laravel and NuxtJS (4/4)

한동안 여러가지 프로젝트 작업들을 동시에 진행하는 상황이였기 때문에, 온갖 개인적인 SNS 활동과 블로깅을 완전히 멈춘지 꽤 오랜 시간이 지난 것 같다. 이번 주제글에 대한 결론을 작성하지 못했기 때문에 그동안에 작업 경험들을 근간으로 생각들을 정리해 본다. 세부 디테일을 정리하기 보단 insight 를 공유하고자 한다.

일단, 초기에 실시간 동적 변경을 위한 브로드캐스팅 기능에 포커스가 채팅기능으로 옮겨갔기 때문에, Laravel Echo 를 채팅구현에 이용할 수 있을까하는 고민을 해봤으나, 해당 패키지가 타게팅을 하는 유즈케이스가 채팅에 포커스를 두고 있다는 단점이다. Echo 는 일반적인 알림 기능이나 UI 변경시 사용하기 적합한 패키지이다. 또한 Flutter 를 사용하는 경우 아직 완벽한 package 가 없다는 단점도 존재한다.

호미로 밭을 갈 수 도 있겠으나, 보다 use-case 에 적합한 dedicated solution 을 사용하는 것이 상식적인 판단이란 말이다. 그리하여 커스텀 solution 을 직접 구현하기로 하였는데, AWS 을 사용하는 관계로, NodeJS 와 아래의 인프라를 사용하여 auto-scaling 이 가능한 채팅 서비스를 구축했고, 읽은 않은 메시지 push message 발송은 SQS 로 payload 를 전달하여, Laravel app 에서 SNS 을 이용하여 발송시키도록 했다.

  • DynamoDB
  • SQS
  • ECS/Fargate
  • ALB
  • ElastiCache
  • ACM

다음번엔 실제 운영중인 데이팅앱의 AWS 인프라 배포를 콘솔로 진행한 내용을 정리해보겠다.

Posted by admin in Backend, 0 comments

Realtime broadcasting with Laravel and NuxtJS (3/4)

A chat application using Laravel Echo server?

If you follow along, you’d know that making a chat application using Echo server is possible but, not quite ideal ’cause it’s not what it’s built for. It’s more of a general purpose socket.io server with some Laravel specific goodies like user authentication for private channel and notification channel scaffolding.

When you look into the Echo server’s “out of the box” event publishing feature, there’re 2 different ways of doing it.

First off, Redis PUBSUB using psubscribe() API

If you want to send an event this way, you need to broadcast(new LaravelEvent) on a Laravel server to publish it to the Redis channel specified in the LaravelEvent. You see it constantly involves 4 hop relays. 1) A Nuxt app calls Laravel API, 2) Laravel API dispatches the broadcast event via a queue, 3) Laravel worker retrieves it, publish it to Redis using Laravel’s default Redis configuration, 4) Redis client on Echo server receives it and Socket.io client on the same server handles the payload accordingly. You get the picture.

Secondly, HTTP call to Echo server

Like the following JS snippet, all it takes is an HTTP call and Socket.io client on Echo server handles it. That’s it. It is way simpler than the previous one as we don’t need the communication layers in the middle. Nice…

  async chat({ commit }, params) {
    const appId = 'bbbbccccddddeeee'
    const authKey = '33334444555566667777888899990000'
    const socketId = this.$echo.socketId()
    const roomId = params.room
    const adapter = this.$axios.create({
      baseURL: `http://localhost:6001`,
      headers: {
        common: {
          Authorization: authKey,
          Accept: 'application/json;charset=UTF-8',
          'Content-Type': 'application/json',
          'X-Socket-ID': socketId
        }
      }
    })

    await adapter.$post(`/apps/${appId}/events`, {
      channel: `private-chat.${roomId}`,
      name: 'App\\Events\\ChatMessageSent',
      data: {
        room: roomId,
        payload: params.payload
      },
      socket_id: socketId
    })
  },

I’m Sold.

Things that you have to go through to make use of MongoDB on AWS isn’t as smooth as you might think. So I’d rather settle down with the following snippet inside of docker-compose.yml within my local Laradock folder to play with DynamoDB this time. Even though I prefer MongoDB over DynamoDB, taking care of any kinda infra-structure myself would be a pain in the ass. The admin panel (https://github.com/aaronshaf/dynamodb-admin) that I’ve already installed on my 🍎Mac seems fine too.

### AWS DynamoDB Local #####################################
    dynamodb-local:
      image: amazon/dynamodb-local
      command: -jar DynamoDBLocal.jar -dbPath /var/opt/dynamodb/data -sharedDb
      volumes:
        - ${DATA_PATH_HOST}/dynamodb/data:/var/opt/dynamodb/data
      ports:
        - "8000:8000"
      networks:
        - backend

Now what?

Hold on, I could completely ditch the Echo or, partially tweak it to get the most out of it. I’ll have to take a close look at how things work in Echo server first. A couple of things that I want to address with it;

  • An efficient chat server preferably built on top of Laravel Echo server
  • All the bells and whistles of modern chat app features
    • user online status
    • previous message history (save/load data)
    • read flag of each message (read cursors?)
    • unread message count
    • user notification hook (if he/she misses it)
    • horizontal scalability

Posted by admin in Backend, 0 comments

Realtime broadcasting with Laravel and NuxtJS (2/4)

Local Setup

일단, Laravel 6.16 와 NuxtJS 2.11 를 사용한 “out of the box” Laravel 브로드캐스트 기능이 로컬 환경에서 작동하도록 설정 후, 전반적인 구현 디테일을 분석해 본다.

사용자가 브라우저로 접속할 frontend 주소는 localhost:3000 이고, Laradock 으로 설정한 backend API 는 amuse.test 라는 주소 값을 갖고 있으며, Echo 서버는 websockets.test:6001 로 접근가능한 구성이다. 아래의 그림을 참고하기 바란다. Laradock 환경에서, Echo 서버가 amuse.test 으로, 접근할 수 있도록 설정하는 것이 tricky 할 수 있는데 이는 이전 Laradock 관련 포스팅을 참고하기 바란다. (https://whatsupkorea.com/2018/05/26/laradock-with-muliple-projects)

Laravel 브로드캐스트 기능을 NuxtJS 를 사용하는 프로젝트에서 사용하기 위해서는, Laravel Echo 의 NuxtJS 용 wrapper 모듈 @nuxtjs/laravel-echo 을 설치 후, nuxt.config.js 파일을 아래와 같이 설정한다.

  :
  buildModules: [
    '@nuxtjs/dotenv',
    '@nuxtjs/eslint-module',
    '@nuxtjs/vuetify',
    [
      '@nuxtjs/laravel-echo',
      {
        broadcaster: 'socket.io',
        host: 'http://websockets.test:6001',
        plugins: ['~/plugins/echo.js'],
        // transports: ['websocket', 'polling'],
        authModule: true,
        connectOnLogin: true,
        disconnectOnLogout: true
      }
    ],
  ],
  :

plugins/echo.js 에는 아래와 같이 public 채널, Laravel notification 용 프라이빗 채널, 그리고 presence 채널을 위한 3개의 리스너를 추가했다. 각각의 채널은 use-case 별로 추가확장 할 수 있으므로, 가장 심플한 형태의 working example 로 보면 되겠다.

export default function({ $auth, $echo, store, app: { $toast } }) {
  $echo.channel('public').listen('PublicMessageSent', (event) => {
    console.log('data from public', event)
  })

  if ($auth.loggedIn) {
    $echo
      .private(`App.Models.User.${$auth.user.id}`)
      .notification((notification) => {
        console.log(notification)
        $toast.success(notification.message)
        store.commit('notifications/INCREASE_UNREAD_COUNT')
      })

    $echo
      .join('presence')
      .here((users) => {
        store.commit('online/SET_USERS', users)
      })
      .joining((user) => {
        store.commit('online/JOIN_USER', user)
      })
      .leaving((user) => {
        store.commit('online/LEAVE_USER', user)
      })
  }
}

2. Laravel Echo Server 를 위한 laravel-echo-server.json 파일의 내용은 아래와 같이 설정한다.

참고로, Laravel 의 config/database.php Redis 옵션의 prefix 값 즉, config('database.redis.options.prefix') 의 값이 app 내 사용한 채널명과 조합하여 사용되므로, 이를 frontend 에서도 동일한 이름으로 해당 채널을 지정하려면 databaseConfig.redis.keyPrefix 옵션을 같은 값으로 셋팅해주는 것이 좋다. 그러면, Redis 에 실제 설정된 키값에 상관없이 PHP 코드에서는 new PrivateChannel('chat'); 이라 사용하고, JS 코드에서는 Echo.private('chat').listen() 라고 사용하는 것이 가능하다.

{
  "authHost": "http://amuse.test",
  "authEndpoint": "/broadcasting/auth",
  "clients": [
    {
      "appId": "1111aaaa9999cccc",
      "key": "8888dddd2222bbbb4444ffff6666eeee"
    }
  ],
  "database": "redis",
  "databaseConfig": {
    "redis": {
      "port": "6379",
      "host": "redis",
      "keyPrefix": "laravel_database_"
    },
    "sqlite": {
      "databasePath": "/database/laravel-echo-server.sqlite"
    }
  },
  "devMode": true,
  "host": null,
  "port": "6001",
  "protocol": "http",
  "socketio": {},
  "secureOptions": 66666666,
  "sslCertPath": "",
  "sslKeyPath": "",
  "sslCertChainPath": "",
  "sslPassphrase": "",
  "subscribers": {
    "http": true,
    "redis": true
  },
  "apiOriginAllow": {
    "allowCors": true,
    "allowOrigin": "http://localhost:3000",
    "allowMethods": "GET, POST",
    "allowHeaders": "Origin, Content-Type, X-Auth-Token, X-Requested-With, Accept, Authorization, X-CSRF-TOKEN, X-Socket-Id"
  }
}

3. Laravel 7 이 아니라면, CORS 설정을 위해 "fruitcake/laravel-cors": "^1.0" 디펜던시를 수동으로 추가하고, config/cors.php를 아래와 같이 설정한다.

<?php

return [
    'paths' => ['*'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['*'],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => false,
    'max_age' => false,
    'supports_credentials' => false,
];

위의 설정으로 public, private, presence 채널 접속이 가능하며, Load balancer 를 Echo 서버 앞에 설정하는 구성이라면 HTTPS 를 통신을 위한 Echo 서버의 추가 설정 또한 필요없어진다. 이제 NuxtJS 앱에서, 동적으로 프라이빗 chat.{room id#} 채널을 listen() 하거나 leave() 하면서 웹소켓통신을 할 수 있도록 만들면, 비교적 수월하게 아래 그림과 같은 채팅서비스를 구현할 수 있다.

그럼 당장 Laravel Echo 기능을 이용한 채팅 서비스를 프로덕션에 적용해보자. 라고 할 줄 알았다면… Not so fast.

Posted by admin in Backend, 0 comments