Laradock 에서 cron 스케쥴러 및 Laravel Horizon 설치

Cron scheduler 와 queue 를 사용하는 로직을 로컬에서 구현하기 위해서는 local 피씨에도 해당 인프라를 만들어 놓는 것이 필요한데, Laradock 의 기본 설정은 내가 원하는 설정 부분이 빠져있거나, 사소한 오류로 인하여, Out of box 동작이 되지 않았기 때문에, 이를 위한 로컬 셋팅 작업을 한 뒤, 이 글을 빌어 그 기록을 남겨둔다.

cron 스케쥴러

크론 스케쥴러는 workspace 컨테이너에서 동작하며, 아래와 같이 workspace/crontab/laradock 에 내용을 아래 샘플과 같이 수정한 뒤 컨테이너 빌드를 다시하면 동작했다. * * * * * laradock php /var/www/backend/artisan schedule:run >> /dev/null 2>&1 컨테이너 리빌드는 아래 명령을 사용하면 된다. docker-compose up -d --force-recreate --build workspace

Laravel Horizon

Laravel 다양한 queue 옵션들 중에, 이번 설정에서는 Redis 큐를 사용했다.
  1. Laravel Horizon 설치
공식 문서의 설명대로 아래 일련의 명령들을 사용하면 패키지 설정과 DB 설정이 마무리된다.
composer require laravel/horizon
php artisan horizon:install
php artisan queue:failed-table
php artisan migrate
  1. Laravel codebase 내의 설정
Redis connection 등의 구체적인 설정내용을 명시하는 아래의 설정 파일들을 수정해야 하는데, 그 내용은 공식 문서내에 설명이 상세하게 되어 있으므로 구체적 내용은 생략한다.
config/horizon.php
config/database.php
config/queue.php
.env
  1. Laradock 설정
php-worker 컨테이너에 관련한 디렉토리내의 모든 파일들을 수정했었어야만 했는데, 어느 부분을 어떻게 수정했는지를 일일이 설명하는 것이 오히려 한눈에 변경사항을 알아보기가 어려울 듯 해서, 현재 로컬 피씨 해당 디렉토리내의 working example 을 그대로 리스팅하였다. 3.1. Dockerfile
#
#--------------------------------------------------------------------------
# Image Setup
#--------------------------------------------------------------------------
#

ARG PHP_VERSION=${PHP_VERSION}
FROM php:${PHP_VERSION}-alpine

LABEL maintainer="Mahmoud Zalt <[email protected]>"

RUN apk --update add wget \
  curl \
  git \
  build-base \
  libmemcached-dev \
  libmcrypt-dev \
  libxml2-dev \
  zlib-dev \
  autoconf \
  cyrus-sasl-dev \
  libgsasl-dev \
  supervisor

RUN docker-php-ext-install mysqli mbstring pdo pdo_mysql tokenizer xml pcntl
RUN pecl channel-update pecl.php.net && pecl install memcached mcrypt-1.0.1 && docker-php-ext-enable memcached

# Install PostgreSQL drivers:
ARG INSTALL_PGSQL=false
RUN if [ ${INSTALL_PGSQL} = true ]; then \
    apk --update add postgresql-dev \
        && docker-php-ext-install pdo_pgsql \
;fi

# Install Redis
ARG INSTALL_PHPREDIS=false

RUN if [ ${INSTALL_PHPREDIS} = true ]; then \
    # Install Php Redis Extension
    printf "\n" | pecl install -o -f redis \
    &&  rm -rf /tmp/pear \
    &&  docker-php-ext-enable redis \
;fi

RUN rm /var/cache/apk/* \
    && mkdir -p /var/www

#
#--------------------------------------------------------------------------
# Optional Supervisord Configuration
#--------------------------------------------------------------------------
#
# Modify the ./supervisor.conf file to match your App's requirements.
# Make sure you rebuild your container with every change.
#

COPY supervisord.conf /etc/supervisord.conf

ENTRYPOINT ["/usr/bin/supervisord", "-n", "-c",  "/etc/supervisord.conf"]

#
#--------------------------------------------------------------------------
# Optional Software's Installation
#--------------------------------------------------------------------------
#
# If you need to modify this image, feel free to do it right here.
#
    # -- Your awesome modifications go here -- #

#
#--------------------------------------------------------------------------
# Check PHP version
#--------------------------------------------------------------------------
#

RUN php -v | head -n 1 | grep -q "PHP ${PHP_VERSION}."

#
#--------------------------------------------------------------------------
# Final Touch
#--------------------------------------------------------------------------
#

WORKDIR /etc/supervisor/conf.d
3.2. supervisord.conf
[supervisord]
nodaemon=true
[supervisorctl]
[inet_http_server]
port = 127.0.0.1:9001
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[include]
files = /etc/supervisor/conf.d/*.conf
3.3. supervisord.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/backend/artisan queue:work redis --sleep=3 --tries=3
autostart=true
autorestart=true
numprocs=4
redirect_stderr=true
3.4. supervisord.d/horizon.conf
[program:horizon]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/backend/artisan horizon
autostart=true
autorestart=true
;user=laradock
numprocs=1
redirect_stderr=true
;stdout_logfile=/etc/supervisor/conf.d/horizon.log
  1. docker-compose.yml
마지막으로 상위 폴더에 있는 docker-compose.yml 파일도 PHP Worker 관련 설정이 아래와 같이 수정되었다.
### PHP Worker ############################################
    php-worker:
      build:
        context: ./php-worker
        args:
          - PHP_VERSION=${PHP_VERSION}
          - INSTALL_PHPREDIS=${PHP_WORKER_INSTALL_PHPREDIS}
          - INSTALL_PGSQL=${PHP_WORKER_INSTALL_PGSQL}
      volumes:
        - ${APP_CODE_PATH_HOST}:${APP_CODE_PATH_CONTAINER}
        - ./php-worker/supervisord.d:/etc/supervisor/conf.d
      depends_on:
        - workspace
      extra_hosts:
        - "dockerhost:${DOCKER_HOST_IP}"
        - "demo.test:${HOST_IP_ADDRESS}"
        - "backend.test:${HOST_IP_ADDRESS}"
      networks:
        - backend
위 Cron 스케쥴러때와 마찬가지로 아래와 같은 컨테이너 리빌딩이 필요하다. docker-compose up -d --force-recreate --build php-worker 리빌딩이 끝나면 정상적으로 동작 하겠지만, 혹시나 supervisor 설정을 변경했을 경우, 아래의 명령으로 커맨드라인 접속 후 docker-compose exec php-worker ash supervisor 의 새로운 설정내용을 반영하도록 한다.
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*
이제 backend.test/horizon 에 접속하면, 위의 dashboard 가 출력되는 것을 확인할 수 있다. Yay!

Further readings

https://laravel.com/docs/5.7/queues https://laravel.com/docs/5.7/horizon
Laravel Queues & Horizon Dashboard
Posted by admin in Backend, 0 comments

Setting up Laravel project on DigitalOcean

목표

이 글은 D/O 의 Droplet 과 LaraSail 과 Deployer 를 이용하여, 몇가지 추가 설정을 해주면, Laravel 프로젝트를 배포할 수 있는 초간편 LEMP 환경을 구축할 수 있다는 내용을 소개한다.

DigitalOcean

Ubuntu 18.04 D/O Droplet 생성후, IP 주소를 가지고 D/O DNS 에 그 주소를 연결할 도메인을 등록한다. (편의상 example.com 으로 지칭하겠다.) 이전엔, Project 메뉴가 없었는데, Project 별로 관리할 수 있는 메뉴가 생겨서 같은 콘솔 메뉴아래에서 관리할 수 있어서 편하군. 이제 ssh 로 터미널 접속한다.
ssh [email protected]

LaraSail

Go to LaraSail and read the f… document there before you run the command below which is copied from the document. It all starts with;
curl -sL https://github.com/thedevdojo/larasail/archive/master.tar.gz | tar xz && source larasail-master/install
설치가 끝나면 이제 다음 커맨드를 실행한다.
larasail setup
LaraSail 을 이용하면 LEMP stack 을 한방에 (composer, php7.2, mysql, nginx) 설치할 수 있다. 몇분간의 설치가 진행된 다음 LaraSail 로고와 함께 종료 메시지가 보였다면, post_larasail.sh 라는 파일을 아래의 내용으로 만들고 실행하자. (Don’t forget to give it a 755 permission.)
위 스크립트는 기본 사용자인 larasail 을 SSH 접속 가능하게 만들며, 다음 단계에서 코드 배포를 위해 사용할 deployer 를 추가하고, Timezone, Locale 설정 등과 같은 house keeping chores 를 수행한다. (It definitely saved me some time. So you can expect the same as well.)

Deployer

Now, it’s time to take a look at Deployer unless you’ve been playing with it. Deployer 프로젝트는 git 을 이용하여 Laravel project 를 (Laravel Forge 를 비롯한 많은 deployment 서비스의 사용하지 않아도 쉽게 배포할 수 있도록 하는 alternative) 아직 젠킨스 같은 CI 셋업이 제대로 되지 않은 초기 toy project 단계에서 쉽고 빠르게 사용할 수 있는 유용한 뭐 그런. 대충 감 잡길.
  1. 로컬PC 에 dep 설치
curl -LO https://deployer.org/deployer.phar
mv deployer.phar /usr/local/bin/dep
chmod +x /usr/local/bin/dep
  1. 로컬PC 에 deployer 사용자 전용 RSA 키 생성
ssh-keygen -t rsa -b 4096 -f  ~/.ssh/github_rsa
또는
ssh-keygen -t rsa -b 4096 -f  ~/.ssh/bitbucket_rsa
와 같이 생성한다. 결국, 이 키를 사용하여 로컬PC <-> D/O 서버의 deployer 사용자 접속하는 것이다.
  1. Git 서버 등록
다음에는, D/O 서버의 deployer 사용자와 <-> GitHub 간의 접속을 위해서, /home/deployer/.ssh/id_rsa.pub RSA 키와 이전 2번단계에서 로컬PC 내에 생성한 RSA 키를 GitHub 에 등록한다. 등록법은 아래와 같고 두개를 모두 등록해주면 된다. GitHub 계정에 SSH키 등록에 관한 설명 이전 2번단계에서 로컬PC 에서 생성한 RSA 키는 아래의 명령으로 서버 deployer 사용자 .ssh 폴더의 authorized_keys 에도 추가해야만 ssh 접속이 가능해진다.
ssh-copy-id -i -f ~/.ssh/github_rsa.pub [email protected]
이제까지의 일련의 과정을 걸친 상태에서는 아래와 같은 명령으로 로컬PC 에서 D/O 서버의 deployer 사용자로 SSH 접속이 가능해야 한다.
ssh [email protected]  -i ~/.ssh/github_rsa
또한, 서버의 deployer 사용자로 접속시, 아래 명령으로 GitHub 과의 연결도 가능함을 확인 할 수 있어야 한다.
ssh -T [email protected]
정상적일때 아래와 비슷한 메시지를 볼 수 있을 것이다; Hi jinseokoh! You’ve successfully authenticated, but GitHub does not provide shell access.

Nginx

아래의 코드로 nginx sites-available 설정을 하고
sudo nano /etc/nginx/sites-available/example.com
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
certbot 을 이용하여, 아래의 명령으로 nginx 설정을 https 로 갱신한다.
sudo certbot --nginx -d example.com -d www.example.com
갱신 테스트를 해보려면 아래의 명령을 실행하여 결과를 확인한다.
sudo certbot renew --dry-run

MySQL

MySQL 은 LaraSail 스크립트가 이미 설치를 한 상태이므로, MySQL 의 root password 를 알기위해서는 아래 명령을 실행한다.
larasail mysqlpass
추가적으로 command line 혹은 Sequel Pro 와 같은 client 를 이용하여 아래의 계정을 생성한다.
CREATE DATABASE science DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'my-user'@'localhost' IDENTIFIED BY 'my-password';
GRANT ALL ON science.* TO 'my-user'@'localhost';
FLUSH PRIVILEGES;

Deployer

Local

작업 폴더로 이동후 아래의 명령을 입력하면
cd /Code/laravel-app
dep init -t Laravel
아래와 같은 메시지가 출력됨을 볼 수 있다. Successfully created: /Users/chuck/Code/blog/deploy.php 이제 IDE 를 열어서 위 파일을 수정한다. 1) Project name 2) Project repository
github 또는 bitbucket repo 주소
3) Host 정보
host('159.65.xxx.xxx')
    ->user('deployer')
    ->identityFile('~/.ssh/id_rsa')
    ->set('deploy_path', '/var/www/html/laravel');
4) 맨마지막 라인 주석처리
// before('deploy:symlink', 'artisan:migrate');
deploy 시에는 dep deploy 명령만 치면 된다. 단, git deploy 할 폴더가 deployer 권한으로 변경해야만 한다.
sudo chown -R deployer:www-data /var/www/html
첫번째 deploy 시 행해야 하는 몇가지 일상적인 작업 .env 설정 php artisan migrate php artisan key:generate php artisan config:cache 등의 작업을 수행후 https://example.com 으로 접속하여, 정상적으로 동작하는 지를 확인해본다. 이로서, Deployer 를 이용한 laravel 프로젝트 deploy 를 쉽게 하는 방법을 알아 보았데헷. 레퍼런스)
  • https://www.digitalocean.com/community/tutorials/how-to-install-linux-nginx-mysql-php-lemp-stack-ubuntu-18-04
  • https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-18-04
  • https://www.digitalocean.com/community/tutorials/automatically-deploy-laravel-applications-deployer-ubuntu
Posted by admin in Backend, 0 comments

Vue SPA from scratch using Laravel 5.7 backend

Yes, you were told it’ll be built from scratch. Here’s the thing. I will list step by step instructions to explain some of important updates whenever I make progress with this little toy project. So, feel free to check out this page later as this gets updated on a regular basis. hopely… though. Let’s start off with backend using the latest Laravel, which is 5.7.

Laravel installation

just for the record, I am using Laradock for the local development, which is not in the scope of this article.
composer create-project --prefer-dist laravel/laravel toy
then copy .env from .env.example and edit it to reflect your local development environment. then update composer.json to include;
"barryvdh/laravel-cors": "^0.11.2",
"laravel/socialite": "^3.0",
"spatie/laravel-query-builder": "^1.11",
"tymon/jwt-auth": "^1.0.0-rc.3"
then, go through the each of the followings one by one.
php artisan key:generate
php artisan jwt:secret
php artisan make:auth
php artisan migrate
php artisan vendor:publish --provider="Barryvdh\Cors\ServiceProvider"
php artisan vendor:publish --provider="Spatie\QueryBuilder\QueryBuilderServiceProvider" --tag="config"
update app/Http/Kernel.php to include;
protected $middleware = [
    // ...
    \Barryvdh\Cors\HandleCors::class,
];
Okay, so far the very basic of Laravel setup is completed. Thanks to Laravel, you can safely think the most of web development settings are in place and ready to go. But, as we are aiming at building a SPA. Let’s go further with tymon/jwt-auth package configuration for your API auth.

JWT configuration

update config/auth.php to change api guard
    'defaults' => [
            'guard' => 'api',
                // ...
        ],
        :
    'guards' => [
            // ...
        'api' => [
            'driver' => 'jwt',
            'provider' => 'users',
        ],
    ],

and add the following api routes; /routes/api.php
Route::group(['middleware' => 'auth:api'], function () {
    Route::post('logout', 'Auth\[email protected]');

    Route::get('/user', function (Request $request) {
        return $request->user();
    });
});

Route::group(['middleware' => 'guest:api'], function () {
    Route::post('login', 'Auth\[email protected]');
    Route::post('register', 'Auth\[email protected]');
    Route::post('password/email', 'Auth\[email protected]');
    Route::post('password/reset', 'Auth\[email protected]');
});
then, update User model to implement JWTSubject interface like so.
use Tymon\JWTAuth\Contracts\JWTSubject;
:

class User extends Authenticatable implements JWTSubject
:
    /**
     * @return int
     */
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    /**
     * @return array
     */
    public function getJWTCustomClaims()
    {
        return [];
    }
}
then, update the following 4 controllers for jwt guard;
  • app/Http/Controllers/ForgotPasswordController.php
  • app/Http/Controllers/LoginController.php
  • app/Http/Controllers/RegisterController.php
  • app/Http/Controllers/ResetPasswordController.php
then remove app/Http/Controllers/HomeController.php and app/Http/Controllers/Auth/VerificationController.php. and finally, update RedirectIfAuthenticated.php as follows;
if (Auth::guard($guard)->check()) {
    return response()->json(['error' => 'Already authenticated.'], 400);
}
Now that you will be able to create a valid JWT token with your login credentials. Let’s try out with Postman. As you can see in the picture below, I could get a valid token. Sweet.

Further back-end considerations

We can remove web routes entirely from this project or define a catch-all route as follows. It’s just upto you.
Route::get('{any}', function () {
    return view('index');
})->where('any', '(.*)');
With this basic back-end scaffolding, we can now move on to the front-end part.

Fron-end setup

As you’ve already noticed, this project will exploit some of the best-known VueJS projects including; Let’s start off with vue-cli first;
npm install -g @vue/cli
Posted by admin in Backend, 0 comments

laravel-vue-spa 프로젝트 따라잡기

laravel-vue-spa 따라잡기? 내친 김에 오늘은 Github 공개 프로젝트인 A Laravel-Vue SPA starter project template. 리포를 내려받고, 이 프로젝트의 프론트엔드 부분을 뜯어보고자 한다. 한때 VueJS 를 사용할 줄 안다고 생각했던 백앤드 개발자가 1년 정도의 프론트앤드 개발 공백기를 갖은다음 겪은 좌충우돌 경험기라고 보면 되겠다. 나는 아직 위 공개 프로젝트가 best practice 의 모델이 되는지 판단하기 어렵지만, 최소한 이 정도 코드 베이스를 읽고 이해할 수 있는 이해도를 갖추는 것이 이번 포스팅의 목표이자, 웹개발자로서의 편협적인 지적 한계를 벗어나기 위한 목표이기도 하다. 불과 1-2년전 Laravel 과 VueJS 로 프론트앤드 작업을 해봤었던 개발자로서, Webpack 으로 발단된 새로운 SPA 개발 flow 가, 한때 Laravel 의 뷰 단위로 VueJS 를 사용하여 웹을 개발했었던 flow 에 비하여, 중복된 concern 을 동시에 가져가야만 하는 심각한 a pain in the ass 로 변한 것 같다는 생각은 지울 수 가 없다. 이번 해 라라콘 20180726 Laracon 2018 7 Caleb Porzio 프리젠테이션을 보아도 나만 그렇게 느끼는 건 아닌듯 하지만, 소위 cool kids on the block 이 작성한 코드를 이해하지도 못한다는 사실은, 다시금 VueJS 학습에 대한 강한 동기를 마련해 주었다. 오늘은 어찌된 영문인지, Program with Erik 이란 유튜브 채널에서 VueJS 관련 방송을 하던 개발자가 5 Reasons Why You Shouldn’t Become A Software Engineer 라는 방송을 올렸던데, 비트코인 추락 속도마냥 엄청나게 빠른 속도로 1년전 기술이 도태되는 업종에서 일하는 공돌이 Ordeal 을 이야기한 내심은 잘 모르겠다. 어찌되었건, 넉넉하지 못한 시간을 쪼개서 항상 새로운 기술을 Radar 처럼 인지하고 있으면서 follow up 해야 하는 이 직업 상의 여유롭지 못함으로 인하여, 나 자신을 고지식하고 편협한 framework 속에 영원히 가두면서 살지 않았으면 좋겠다는 생각이 들었다.
  1. Google to the rescue!
Anyway, 현재의 나 자신은 ES6 문법도 많이 까먹었은 상태지만, Google 이 있으므로 큰 문제될 건 없을 것으로 본다. 일단 /resources 폴더의 index.blade.php 를 보니 HTML 의 scaffolding 을 라라벨 블레이드 엔진을 한번 통과하여 사용하고 있다. SPA 를 만들려고 했으면서, vue-cli 를 사용하지 않고, 굳이 이렇게 라라벨 디펜던시를 유지한건 좀 이상하지만, 문제의 핵심은 이것이 아니므로, 패스…
  1. JS 의 엔트리 포인트인 app.js
오케이 여기까진, 필요한 js 모듈들을 가져와서, Vue 인스턴스를 초기화 하는 거라는 건 알겠다. vue 를 제외하곤 로컬에 있는 디렉토리를 지정한 것이므로, ~/store 부터 한번 들어가 보겠다. 기본적으로 index.js 를 읽을거 같이 생겼으니, 오래 생각 안하고 해당 파일을 열어본다.
  1. /js/store/index.js
처음부터 이해가 잘 안되다보니, 우선, 몇가지 키워드로 구글링해서 찾은 아래 페이지 읽었다. VueJS Component Registration 오케이, require.context()webpack 을 이용해서, 기본으로 사용할 컴포넌트들을 global component 들로 레지스터를 하는 메서드라는 것 까지 알겠다. 그래도 드는 생각은 Duh… I still can’t get my head around the whole thing. 이때 문득, 이전에 버스 타고가다가 유투브로 본 7 Secret Patterns Vue Consultants Don’t Want You to Know – Chris Fritz 에서 들었던 내용이 떠올라 다시 보기로 한다. 요행히 어떤 사람이 이미 한글로 정리한 내용도 찾아진다. 하지만, 다시 보아도 직접적인 line by line 설명없이, 위의 코드를 이해하는데는 많은 도움이 되진 않았다. namespaced 옵션의 목적은 뭐고, require.context() 의 역할은 뭔지 등을 모르고서는, 파악이 되지 않아 또 다시 vuex 에 대한 구글링… 이번에 찾은 내용은 비디오 강좌였다. Vuex for Everyone 이라는 무료 비디오 강좌인데, 다행히… 원하는 내용을 커버하고 있었다. 근데, 이 사람 뭥미… 발음이 완전 인도사람 발음이라 뭔소리인지 잘 안들린다. 캡션이 제공되지 않았더라면… 흠… 나중에 찾아보니, Alex Kyriakidis 라는 그리스 사람이고, VueJS core 멤버란다. 가만 들어보니 인도 발음과 또 다르네 그려. 발음만 제외하면, 굉장히 핵심적인 내용을 명료하게 짚어주는 강의였기에, 게다가 무료. 추천 VueJS 컨텐츠 등극, 쾅… 어찌 어찌해서 보게된 이 비디오강의로 인하여, 시간상으론 상당한 detour 였으나, 그 강좌를 통해 vuex 사용법을 캐치업 하고 난 다음 다시 코드를 보니 store 코드들을 잘 이해할 수 있었다.
line #9 ~ #19 Vuex 에 필요한 state, getters, mutations, actionsnamespaced module 로 불러오겠다는 의미다.
또한, mutations 부 에서는 state 변화만 처리하는 것이 best practice 이므로, store/modules/auth.js 와 store/modules/lang.js 의 mutations 부 에서 사용하는 있는 Cookie.set(), Cookie.remove() 는 actions 으로 이전시키는 리팩터링이 필요하겠다는 생각에 까지 도달했다.
  1. /js/router/index.js
다음은 router 관련 코드 인데, store 보다 많이 길고, 조금 더 난해하다.
SPA 의 라우팅을 담당하는 로직 부인데, 일단, 낯선 ‘vue-meta’, ‘vue-router’, ‘vuex-router-sync 내용을 포함하고 있으니, 해당 내용을 follow up 하기 위해 아래의 문서들을 먼저 읽었다. vue-router 문서 vue-meta 문서 vue-router-sync 문서 라우터를 정의한 내용은 길기도 하거니와, 나 처럼 사전지식이 부족했던 사람들은 이해하기 쉽지 않을 것이므로, 상세히 line by line 설명을 적어보겠다.
line #11 ~ 12 글로벌 미들웨어, Vue Router 용어로는 Navigation Guards, 로 사용할 함수 컴포넌트 이름들 중 일부분이다.
라라벨 백그라운드를 갖고 있기에 라라벨에서 사용하는 미들웨어 용어를 사용한 듯 싶다. 어쨌든, 귀여운 시도다. 여기에서는 네이게이션 가드 함수들 중에서, app 내의 모든 페이지 전환시 공히 사용하는 글로벌 미들웨어 의 키값을 우선 정의해 놓았다. 로케일인증 이므로, 납득이 가는 설정이다.
line #15 ~ 17 middleware 폴더에 존재하는 모든 .js 를 읽어서, export default 함수들을 import 한다.
코드 마지막 부분 (line #191 ~ 199) 에 있는 resolveMiddleware() 를 사용해서, 마치 store/index.js 의 하위 modules 폴더 안에 존재하는 모든 component 모듈들을 Webpackrequire.context() API 를 사용하여 import 한 것처럼, 여기서는 router/index.js 의 하위 middleware 폴더 안에 존재하는 모든 export default 함수 모듈들을 import 한다.
line #19 ~ 23 이 앱에서 사용할 라우터 object를 생성한 다음, vuex 의 store.state.route 와 현재 $route 의 값을 sync 시키는 vue-router-sync 플러그인을 사용하기 위해, 해당 플러그인의 sync() API 를 호출한 다음, 생성된 라우터 object 를 default export 시켰다.
라우터 object 를 생성하기 위해서, 라인 #30 ~ #41 에 정의된 createRouter() 함수를 사용하는데, 이때 이 함수는, 또 다시 Scroll Behavior 를 정의하기 위한 scrollBehavior() 함수와, Global Before Guards 를 등록하기 위한 beforeEach 함수와, Global After Hooks 를 등록하기 위한 afterEach 함수 등 모두 3개의 함수를 내부에서 사용한다.
line #169 ~ 185 Scroll Behavior 함수
Vue Router 문서의 Scroll Behavior 부분을 읽어보면 이해가 어렵지 않다. savedPosition 이 있으면, 그 위치를 리턴하고, 이동할 to route object 들의 anchor 포지션이 설정되었으면 그 selector 이름을 리턴하고, to route object 의 컴포넌트를 Vue Router 의 router.getMatchedComponents API 을 호출하여 알아낸다음 가장 마지막 component object 에 (ex. /pages/settins/profile.vue 컴포넌트 object 처럼) scrollToTop property 가 false 로 설정되어 있다면, 빈 {} object 를 리턴하여, 스크롤 위치를 보존하도록 하고, 이도 저도 아닌 fallback 로직으로는 { x:0, y:0 } object 를 리턴하여, 무조건 최상단으로 scroll 시키겠다는 내용이다. 참고로, router.getMatchedComponents({ ...to }) 는 component 인스턴스가 아닌 definition/constructor 가 배열로 리턴되며, console.log() 으로 찍어보면 아래와 같은 내용을 볼 수 있다. 나처럼 그 값들이 궁금했다면, 참고하시길…
[
    { middleware: "auth", _compiled: true, __file: "resources/js/pages/settings/index.vue", computed: {…}, beforeCreate:[…], … },
    { scrollToTop: false, _compiled: true, __file: "resources/js/pages/settings/profile.vue", computed: {…}, created: …, data: …, metaInfo: …, methods: …, … }
]
line #50 ~ 77 beforeEach 함수
이 함수는 promise 를 리턴한다. (혹시, async + await 에 대한 내용이 익숙치 않다면, 이번 라라콘에서 CSS Grid 를 발표했었던 Wes Bos관련 freeCodeCamp talk 유튜브 비디오을 봐도 되겠다. async + await 을 한줄로 설명한다면, promise 처리를 synchronously 이해할 수 있는 코드로 작성하는 방법이다.) 이젠 너무 반복된 코드의 설명은 빼고 나머지 코드의 내용을 설명하면, 61번~63번 라인은 컴포넌트 definition 에 명시적으로 loading property 가 false 라는 값으로 설정된 경우가 아니라면, App.vue 컴포넌트의 mounted() life cycle 에서, Vue.$loading 으로 지정한, (다시말해 router.app.$loading 로 접근가능한) Loading.vue 컴포넌트의 start() 메서드를 실행시킨다. 66번 라인은 match 되는 컴포넌트에서 모든 필요한 middleware 를 이전에 설정한 globalMiddleware 배열과 merge 한 결과를 구한다. 즉 컴포넌트에 middleware object 가 있는 경우, middleware 라는 이름의 배열로 리턴한다. middlewares 라고 썼음 더 좋으련만 내 코드가 아니니 또 패스. 69번~76번 라인은 각각의 미들웨어를 호출하는 부분이다. 참고로, Global Guardnext() 함수 설명부를 보면 파이프라인의 다음번 hook 으로 이동하는 경우는 next() 함수 호출시 인자값이 없는 경우이다. 다시 말해 next() 의 인자가 undefined 인 경우만 next() 함수가 호출되어 다음 로직 이 진행된다. 이 점 유념하고, dynamic components 에 대한 이해를 높이기 위해 아래 VueJS 문서를 다시한번 정독했다. has anyone heard of RTFM? flow 는 callMiddleware 함수로 넘어간다. 아래에 나열한 4개의 인자와 함께.
  • middleware 배열
  • Global Guard 의 beforeEach 에서 전달 받은 to Route object
  • Global Guard 의 beforeEach 에서 전달 받은 from Route object
  • callback 함수
여기 저기서 똑같은 next() 이름의 함수로 호출해 대니, flow 를 따라가다가, 머리 속에 그린 로직이 mixed up 되는 존나 안읽히는 코드다. 뭐가 진짜 next() hook 인지 정신 똑바로 차리지 않으면 헷갈리니, 차라리 100번째 라인의 next 인자는 nextCb 으로, 110번째 라인의 next(…args) 는 nextCb(…args) 으로 바꾼다음 읽으면 낫다. 하는 일은 배열로 전달한 각각의 middleware 를 재귀호출 하는 것이다. 여기서 재밌는 점은, 미들웨어 처리를 다한다음에, 75번째 라인의 진짜 next() hook 을 호출하기 전에, args length 가 0 이면, 즉 정상적으로 미들웨어 패스가 되고 난 후라면, Vue 인스턴스에 전달한 App.vue 컴포넌트의 layout 을 설정한다. 이 때, 첫번째 component 즉, components[0] 에 layout 가 설정되어 있는 경우는 그 페이지 layout 으로 설정하며, 아닌 경우에는 default layout 으로 설정한다. 다시 말해, routes.js 에서 지정하는 최상위 component 에 layout 옵션을 사용하면 원하는 layout 설정이 가능하단 말이다. default 레이아웃 (default.vue) 은 NavBar 가 포함되어 있고, basic 레이아웃 (basic.vue) 은 네비게이션 바가 없는 웰컴 페이지와 같은 데서 사용한다. 재귀호출의 시각은 124 번 라인의 _next() 로 시작하는데, 이를 위해 미들웨어 배열의 아이템들을 역순으로 바꾸고 하나씩 pop() 하여, 원래 지정한 components 순서대로, 가드를 하나씩 하나씩 재귀 호출한다. VueJS 에서 어떤 Route 에 대한 multiple guards 를 natively 지원하지 않기때문에 이런 hacky 방법을 쓰는데, vue-router-multiguard 라는 npm 패키지를 사용하면, 좀 더 읽기 편한 코드를 만들 수 있을 듯 하다. 왜 이런식으로 빙빙 돌려서 재귀호출을 하는지는 vue-router 리포의 관련 쓰레드에서 좀 더 읽어 볼 수 있다. 간단히 설명하자면, 가드가 여러개 있을때는, 어떤 가드에서 next() 를 바로 호출해 버리면 안된다. 다른 가드들을 거치면서, next() hook 이 호출되면 안되는 상황이 올 수도 있기 때문이다. 그래서 모든 가드들을 패스한 후, next() 를 마지막에 한번만 호출해야 되는 것이다. app.js 의 Vue object 정의 맨 마지막에 들어간 App.vue 컴포넌트를 살펴본다. 기본 템플릿으론 loading 컴포넌트를 사용하고 라우터 가드들을 빠져나오면서 설정한 layout 으로 해당 layout 컴포넌트를 동적 로딩을 한다. 또한 둘다 공히 <child/> 컴포넌트를 사용하고 있다. Child.vue 파일을 보면 알 수 있듯이, 이 컴포넌트는 vue-router 에서 각 path 마다 매치되는 컴포넌트들을 보여주는 <route-view/> 컴포넌트를 제공하는 기본 템플릿라고 보면된다. 이후, vue-router 가 브라우저의 url 에 맞는 컴포넌트를 불러서 Child 컴포넌트의 <route-view> 안에 렌더링 시킨다. 크롬브라우저의 Vue.js devtools 익스텐션을 설치후 보면 위와 같은 계층적인 DOM 구조가 보인다. Yay! 나머지 주요 패키지로는 vue-i18n 과 vform 이 있는데, 로컬라이제이션과 form 패키지는 각각의 패키지 문서를 참고하기 바라면서 이 글을 마친다. 개인적으로, 로컬라이제이션 패키지와 폼밸리데이션은 다른 github 프로젝트들도 많이 있어서, monterail/vuelidate 프로젝트 등을 살펴보는 것도 필요해 보인다. 다음에는 vue-cli 와 vuetify.js 를 사용한 SPA 개발에 대하여 좀더 살펴보기로 하겠다. Happy Coding!
Posted by admin in Backend, 0 comments

Laravel 프로젝트에서 OpenAPI(Swagger) 사용하기

올해 초 라라벨 API 프로젝트 작업할 때, SDK 코드제너레이션 까지는 아니더라도, 적어도 문서화를 위하여 Swagger 사용을 고려했었다. 구글에서 laravel swagger 라는 키워드로 검색후 발견한 L5-Swagger 패키지를 받았었는데, 그 부실한 문서와 (물론, 그 패키지가 래핑하고 있는 swagger-php 패키지의 문서도 그리 좋은 편이 아니어서) 장황하고 지저분한 annotation block 를 보고난 다음, 더우기 annotation 에서, 배열을 위한 square bracket 사용이 불가하다는 내용을 보니, 라라벨과 Swagger 콤보의 조합은 별로 어울리지 않는다는 결론과 함께 충만했던 호기심이 단번에 사라지고 말았다. 결국, API 문서화를 위해서는 API Blueprint 나, Slate, apiDoc 등의 alternative 들을 사용하는 바가 낫겠다는 생각 이었는데… 세월은 흘러, 어느덧 2018 라라콘 비디오가 유튜브에 올라오는 시기가 되었으니… 두둥… TJ Miller 가 이번 라라콘에서 API 에 대하여 발표한 이 20180726 Laracon 2018 TJ Miller 프리젠테이션 을 보면, 다양한 API 핸즈온 경험들을 커버하는데, 여기에, OpenAPI 와 JSON Schema 에 대한 설명과, 그가 행하는 practice 를 이야기하는 부분이 나온다. 위의 그림에서처럼 기정의한 JSON Schema 를 레퍼런스 할 수 있다는 것과, 각각의 스키마들을 /schemas 폴더에 정리한 설명을 들어보니, 어라, 저런식으로 라라벨 프로젝트에서 Swagger(OpenAPI)를 사용하면 되겠구나 하는 아이디어를 얻게 되었다. 뿐만아니라, Speccy 프로젝트를 설명하면서 언급했던 Phil Sturgeon의 블로그를 보면, OpenAPI 와 관련된 더 많은 내용들을 읽어 볼 수도 있었다. 이 포스팅에서는 그 후, 회사 API 프로젝트에 Swagger 를 사용한 경험을 정리해 보기로 한다. 잠깐, Swagger(OpenAPI) 가 뭔데? 아직 마차에 타지 못한 사람들을 위해 짧은 설명을 첨부하자면, OpenAPI 는 JSON Schema 에 기반한 일종의 extension 으로, API 문서화 자동 생성, API 에 대한 다양한 언어를 위한 SDK 코드 자동 생성, Postman API 컬랙션 자동 생성 등의 다양한 API 개발 헬퍼 기능과 API 문서 자동생성 툴을 제공하는 소프트웨어 이다. OpenAPI 스펙은 .json 포맷 이나 .yaml 포맷으로 작성할 수 있는데, PHP 프로젝트에서 주석문 부분에 Doctrine annotation 을 추가하여, 해당 프로젝트용 OpenAPI 스펙을 자동으로 만들어주는 툴이 바로, swagger-php 이다. (참고로, OpenAPI v3 스펙 이전에는 OpenAPI 가 Swagger 로 불리였지만, v3 부터는 OpenAPI 로 이름을 바꿔었으므로, 이 둘을 interchangable 하게 불러도 헷갈려 하지 마시길.) Swagger v2 스펙은 JSON Schema draft v4 를 기반으로 하고, OpenAPI v3 스펙은 JSON Schema draft v5 를 기반하여 정의되었는데, 사실 OpenAPI 와 JSON Schema 는 약간 다른 목적을 두고, 독자적 body 로부터 독자 진화중이라서 그 둘 간 상이한 차이점이 존재하므로, 서로 다른 버젼 및 스펙간의 상호 컨버팅을 할 수 있도록 도와주는 프로젝트가 위에서 언급된 Speccy 이다. 어쨌든, 그럼 JSON Schema 는 도대체 뭐지? JSON Schema 는 JSON 을 위한 메타 데이터를 정의하기 위한 스펙으로 그 목적은 아래와 같이 정리할 수 있다.
  • API 의 데이터 포맷을 설명하는 메타 정보
  • 사람과 기계가 모두 clear 하게 이해할 수 있는 정보
  • JSON 구조의 validation
  • 클라이언트가 보내는 데이터 validation 가능 (*)
JSON Schema 의 재밌는 내용 중 하나인 클라이언트가 보내는 데이터 validation 을 살펴보면 JSON Schema 의 목적들 중 하나를 좀 더 잘 이해할 수 있으리라 본다. 아래는 user 를 정의하는 JSON Schema 파일의 예 이다.
{
  "$id": "http://example.com/schemas/user.json",
  "type": "object",
  "definitions": {},
  "$schema": "http://json-schema.org/draft-07/schema#",
  "properties": {
    "name": {
      "title": "Name",
      "type": "string",
      "description": "Users full name supporting unicode but no emojis.",
      "maxLength": 20
    },
    "email": {
      "title": "Email",
      "description": "Like a postal address but for computers.",
      "type": "string",
      "format": "email"
    },
    "date_of_birth": {
      "title": "Date Of Birth",
      "type": "string",
      "description": "Date of uses birth in the one and only date standard: ISO 8601.",
      "format": "date",
      "example": "1990–12–28"
    }
  },
  "required": [
    "name"
  ]
}
가령 위와 같은 user 스키마가 있다고 가정하면, 사용자가 registration form 을 작성할 때, 서버에 요청후 응답을 기다리는 것이 아니라,
{
  "name": "Lucrezia Nethersole",
  "email":"[email protected]",
  "date_of_birth": "2007–01–23"
}
해당 client 가 아래와 같은 header 를 통해서 user.json 의 JSON Schema 를 다운받은 다음, 굳이 서버에 request 를 보낸 뒤 서버가 validation 을 한 뒤 보내오는 422 에러를 리턴받지 않아도, 사용자 input 값에 대한 validation 수행이 가능하다. 따라서, 전체 form 입력값으로 기대하는 user 인풋값의 valid 한 형태를 client 가 미리 알 수 있기 때문에 불필요한 요청이 발생하지 않아도 되는 flow 가 만들어 질 수 있다.
Link: <http://example.com/schemas/user.json#>; rel="describedby"
OpenAPI 는 JSON Schema 의 이러한 semantic 유효성에 대한 정의 보다, 자동 documenation 생성, mocking proxy 생성, Postman collection 생성, SDK 코드 자동 생성 등에 포커스를 두는 목적을 갖고있다고 이해하면 된다. 여기까지의 설명으로, 그 배경과 차이점을 알았으리라 판단되므로, 이제부터는 회사 프로젝트에 OpenAPI 를 적용한 내용을 살펴보기로 하자. (참고로, 현재 라라벨 프로젝트는 v5.6 을 사용하고 있다.)

사용한 PHP 패키지

오래 전에도, 이 패키지를 본적이 있었는데, 그때만 하더라도 v2 스펙을 위한 @SWG\XXX annotation 들만 존재했었고 v3 스펙을 위한 @OA\XXX annotation 들이 존재하지 않았다. 뿐만아니라, 문서에는 모든 annotation 들이 어떻게 매칭되어 .json 이나 .yaml 파일로 생성되는지도 언급이 없었기에, 해당 패키지가 제공하는 Examples 폴더의 내용을 들여다 볼 수 밖에 없었는데, 이것이 이 패키지의 완성도가 상당히 낮다고 느껴지게 만드는 turn off 요소로 작용했었다. 지금 다시 보니, 많은 내용들을 그 예시들로 이해 할 수 있게 제공되고 있지만, 어쨌든, 아직도 문서가 좀 더 보강되었으면 하는 바램은 여전하다. 불행히도, TJ Miller 의 프리젠테이션으로부터 gotcha moment 을 갖기 전까지, (사실 면밀히 말해서, swagger-php 예제나, Swagger 레퍼런스를 제대로 읽어봤다면 기정의 스펙의 reference 기능을 알 수 있었겠지만), 주석문에 덕지덕지 떡칠된 annotation 에 대한 부담을 없앨 수 있는 대안이 있다는 사실을 알 지 못했었다. 참고로, swagger-php 패키지가 아직은 완벽하지 않은 듯 하여, yaml 파일을 생성하게 되면, Doctrine annotation 중 배열의 [] 을 위한 {} 컨버팅이 아직 완벽하지 않다. 일례로 아래의 annotation 은
     *    security={
     *        {"bearerAuth":{}}
     *    },
.json 형태의 output 생성시, 아래와 같이 정상적으로 생성되는 것에 반해
        "security": [
                {
                        "bearerAuth": []
                }
        ]
.yaml 형태로 output 생성시에는 아래처럼
      security:
        -
          bearerAuth: {  }
출력되고 마는 것을 미루어 보아 말이다. 따라서, yaml 파일 생성할 때, []{ } 로 출력되는 오류를 피하기 위한 workaround 로 sed 명령을 사용하여, 아래와 같이 어리버리한 { } 를 모두 [] 로 변경해주는 편법을 사용해야 한다.
./vendor/bin/openapi ./app | sed -E "s/(\{[ ]+\})/\[\]/" > openapi.yaml
그럼에도 불구하고, PHP 프로젝트 내의 주석문에다가 약간의 annotation 을 추가함으로써, API 문서를 위한 single source of truth 를 갖게 되고, 이를 기반으로 OpenAPI 스펙 생성이 가능하다는 점은 매우 매력적인 장점이라 할 수 있겠다.

컨트롤러에 추가한 annotations 들

그럼, 이번 API 프로젝트 내에서, 추가한 주요 annotations 들을 살펴보자. 일단, 모든 프로젝트에서 필수적으로 요구되는 info 메타 데이터부를 추가해야한다.
  • info 메타 데이터
    /**
     * @OA\Info(
     *     title="Search API",
     *     version="global-v2",
     *     x={
     *         "logo": {
     *             "url": "https://examples.com/logo.jpg",
     *             "altText": "Company Logo"
     *
     *        }
     *     }
     * )
     * @OA\Server(
     *     url="http://search.test",
     *     description="local"
     * )
     * @OA\SecurityScheme(
     *     type="http",
     *     scheme="bearer",
     *     in="header",
     *     securityScheme="bearerAuth"
     * )
     */
    public function __invoke()
    {
        return response()->json([
                    :
        ]);
    }
  • 각각의 API end-point 별 컨트롤러 주석부에 추가한 annotations
info 메타 데이터가 추가 되었다면, API 각각의 end-point 별로, 아래와 같은 형태의 annotation 을 코드내에 추가하여, swagger-php 가 픽업할 수 있도록 한다. 결국 이 annotation 들이, 하나의 API end-point 에 대한 상응하는 OpenAPI 스펙으로 변환된다. 아래의 예에서 보는 것과 같이, 기정의 스키마들은 response 를 위한 3개의 .json 파일과, 1 개의 dto 를 위한 .json 파일을 지정하여, annotation hell 을 막을 수 있었고, 이런 식으로 적용하여, 보다 깔끔한 스펙작성과 배열 [] 이 {} 로 컨버팅되는 문제를 막을 수 있었다. (참고로, 이 예제에서 보이는 application 에서는 Elasticsearch 을 사용하여 검색을 수행하고, 그때 필요한 검색조건을 DTO object 로 전달하는 내용이 있기 때문에, 모델 정의 .json 을 지정하지 않고, 필요한 검색 DTO 정의 .json 스펙을 사용했다.)
    /**
     * @OA\Post(
     *     path="/api/v2/type",
     *     tags={"Match"},
     *     summary="맞춤소개",
     *     description="설명부분 어쩌구 저쩌구",
     *     security={
     *         {"bearerAuth": {}}
     *     },
     *     operationId="matchType",
     *     @OA\Response(response=200,
     *         ref="schemas/responses/match-type.json"
     *     ),
     *     @OA\Response(response="401",
     *         ref="schemas/responses/error-auth-invalid.json"
     *     ),
     *     @OA\Response(response="422",
     *         ref="schemas/responses/error-unprocessable-entity.json"
     *     ),
     *     @OA\RequestBody(
     *         required=true,
     *         @OA\MediaType(
     *             mediaType="application/json",
     *             @OA\Schema(
     *                 @OA\Property(
     *                     description="MatchDto",
     *                     property="match_dto",
     *                     ref="schemas/match-dto.json"
     *                 ),
     *                 @OA\Property(
     *                     description="맞춤소개 타입",
     *                     property="match_type",
     *                     type="string",
     *                     enum={
     *                         "ugly",
     *                         "good-looking"
     *                     }
     *                 ),
     *                 type="object",
     *             )
     *         )
     *     )
     * )
     */
    public function match(MatchTypeRequest $request, Responder $responder)
    {
        :
    }
  • 기정의된 schemas\responses\match-type.json 의 내용
{
  "description": "성공 응답",
  "content": {
    "application/json": {
      "schema": {
        "properties": {
          "status": {
            "type": "integer"
          },
          "success": {
            "type": "boolean"
          },
          "data": {
            "type": "object"
          }
        },
        "type": "object",
        "example": {
          "status": 200,
          "success": true,
          "data": {
            "list": {
              "1100011": "CustomType",
              "1200021": "CustomType",
            },
            "updated_at": "2018-10-01 09:00:00"
          }
        }
      }
    }
  }
}
  • 기정의된 schemas\responses\error-auth-invalid.json 의 내용
{
  "description": "실패 응답 (인증실패)",
  "content": {
    "application/json": {
      "schema": {
        "properties": {
          "status": {
            "type": "integer"
          },
          "success": {
            "type": "boolean"
          },
          "error": {
            "type": "object"
          }
        },
        "type": "object",
        "example": {
          "status": 401,
          "success": false,
          "error": {
            "code": "auth_invalid",
            "message": "Unauthorized request."
          }
        }
      }
    }
  }
}
  • 기정의된 schemas\responses\error-unprocessable-entity.json 의 내용
{
  "description": "실패 응답 (처리불가)",
  "content": {
    "application/json": {
      "schema": {
        "properties": {
          "status": {
            "type": "integer"
          },
          "success": {
            "type": "boolean"
          },
          "error": {
            "type": "object"
          }
        },
        "type": "object",
        "example": {
          "status": 422,
          "success": false,
          "error": {
            "code": "unprocessable_entity",
            "message": "insufficient search result"
          }
        }
      }
    }
  }
}
  • 기정의된 schema\match-dto.json 의 내용 (상세 구현 내용은 중략)
{
  "properties": {
    "id": {
      "description": "아이디",
      "format": "int64",
      "type": "integer"
    },
    "age": {
      "description": "나이",
      "format": "int32",
      "type": "integer"
    },
    "timezone": {
      "description": "타임존",
      "type": "string",
      "nullable": true
    },
    "gender": {
      "description": "성별",
      "type": "string"
    },
    "gender_preference": {
      "description": "선호성별 리스트",
      "type": "array",
      "items": {
        "type": "string"
      }
    },
        :
    "priorities": {
      "description": "우선순위 리스트",
      "type": "array",
      "items": {
        "type": "string"
      }
    }
  },
  "required": [
    "id",
    "age",
        :
  ],
  "type": "object",
  "example": {
    "id": 1155,
    "age": 32,
    "timezone": "Asia/Seoul",
    "gender": "M",
    "gender_preference": [ "F" ],
        :
    "priorities": []
  }
}
위 와 같은 annotation 들을 API end-point 를 담당하는 controller 마다 추가하였다면, 문서를 생성할 준비가 끝나게 된다.

ReDoc-cli 을 사용한 문서 생성

ReDoc 은 yaml OpenAPI 스펙을 읽어서, 깔끔한 HTML 문서를 생성시켜주는 npm 프로젝트이며, 아래의 명령으로 redoc-cli 를 전역 설치한다.
npm i -g redoc-cli
위에서도 잠시 언급했지만, .yaml 파일을 생성할때는, swagger-php 를 이용하여, Laravel 프로젝트 root 에서, /app 폴더 하위에 기록된 모든 annotation 들을 파싱해서 OpenAPI 스펙인 openapi.yaml 파일을 만들도록 ./vendor/bin/openapi --output openapi.yaml ./app 이라는 명령을 주면 되지만, 배열 [] 이 {} 로 출력되는 오류로 인해, 아래처럼 workaround 를 사용했다.
./vendor/bin/openapi ./app | sed -E "s/(\{[ ]+\})/\[\]/" > openapi.yaml
openapi.yaml 파일이 생성된 후에는, redoc-cli 를 이용해서, 아래 명령으로 문서를 생성하고, 확인할 수 있다.
redoc-cli serve openapi.yaml --watch
위에서 처럼, –watch 플랙을 지정하면 openapi.yaml 파일이 바뀔 때마다, 다시 랜더링이 된다. 그런 다음 브라우저를 열고 localhost:8080 에 접속하면, 아래와 같이 예쁜 문서를 볼 수 있다.

Swagger UI 사용법

OpenAPI 스펙이 만들어졌다면, 이번엔 전통적인 Swagger-UI 프로젝트를 통해서도, 문서화된 내용을 만들어 볼 수 있는데, 여기서는 .json 로 생성한 스펙파일을 사용해보겠다. 우선 swagger-php cli 명령으로, 아래와 같이 openapi.json 스펙을 생성한다.
# json 파일 생성
./vendor/bin/openapi --output openapi.json ./app
이젠 docker 를 사용하여, swagger-ui 를 실행할 수 있는데, 다만, 아래의 ~/Code/search 의 경우, 현재 사용하는 PC 내의 로컬 폴더 path 이므로, 자신의 폴더 path 로 변경이 필요하겠다.
docker run -p 8081:8080 \
           -e SWAGGER_JSON=/search/openapi.json \
           -v ~/Code/search:/search \
           -v ~/Code/search/schemas:/usr/share/nginx/html/schemas \
           swaggerapi/swagger-ui
그런 다음 브라우저를 열고, localhost:8081 접속하면, Postman 처럼 실제로 Curl 호출을 해볼 수 있는 인터페이스까지 포함된, 문서를 얻을 수 있다. 이번에, 사내 스터디에서 발표할 자료를 만들면서 정리한 블로그 포스팅은 여기까지 이며, 비슷한 고민을 하던, php 코더들에게 도움이 되었으면 하는 바램이다. Happy Coding!
Posted by admin in Backend, 0 comments

MongoDB 의 선택과 마이그레이션

이 글은 지난 2주간 MySQL 에 존재했던 레코드 수가 5억건이 넘는 주요 관계 테이블을 NoSQL 로 마이그레이션하는 과정에서 겪었던 내용을 근거로 얻게된 경험과 인사이트를 공유하기 위해서 작성합니다.

NoSQL Database 의 선택

RDB 에 꾸준하게 쌓아왔던 관계 자료의 데이터량 증가로 인하여, 전체적인 쿼리 속도가 느려지고, 데이터베이스의 부하가 높아지는 것이 감지되어, 파티셔닝을 할지, 아니면 NoSQL 을 도입할지를 고민하다가, 현재 사용중인 인프라가 AWS 이었기에 AWS DynamoDB 로의 이전을 우선적으로 고려하였습니다. 하지만, NoSQL 의 경우, 종류도 다양하고 사용법도 많이 다르기 때문에, 해당 데이터베이스가 사용하려는 use case 에 가장 적합한 솔루션인지를 조사하기 시작했습니다. 유튜브에서 Rick Houlihan 의 AWS DynamoDB 400대 세션들을 들어보면 다양한 use-case 별로 best practice 를 설명하고 있기에, 해당 내용들을 들어보면 구현을 위한 큰 그림을 정립해 나가는데 많은 도움을 얻을 수 있습니다. 개인적으로는, 거의 약장수처럼 스무스하게 발표하는 그 아저씨의 능력, 참으로 부럽더군요. 어쨌거나, 그의 설명으로부터 얻을 수 있었던 인사이트는, NoSQL 의 문제점
  • 모든 NoSQL 은 horizontal 스케일링을 위해서, 여러개의 노드에 걸친 sharding 을 자동으로 하는 기능이 있는데, 그 해쉬 키가 evenly distributed 되지 않으면, 일부 sharding 으로만 hot 한 데이터가 몰리게 되고, 그런식으로 일부 노드에 주요 데이터가 집중되는 경우, 동일한 성능의 여러개의 노드 클러스터로 구성된 NoSQL DB 가 분산처리를 최적으로 수행하지 못해서, 결국 낮은 성능의 노드 인스턴스의 한계로 인한 비효율적으로 cluster 전체가 버벅거리고 Exception 을 쏟아내는 문제가 일어납니다.
  • 위의 문제와는 별개로, 일정한 term 동안 발생하는 요청의 수도, 위의 경우와 비슷하게 핫한 요청이 특정 노드에 집중될 수 있는데, 이 경우도 비슷한 문제가 발생합니다. 어떤 노드에 있는 자료에 대해서 집중적으로 쓰기/읽기 요청의 갯수가 순식 간에 올라가는 경우도 위의 경우처럼 결국 Exception 이 발생할 것이기 때문입니다.
  • 따라서, 아래 canonical use cases 를 정리한 것처럼, NoSQL 의 효율을 극대화 시키기 위해서는, 일정 클러스터 안에서 분산된 노드들을 가지고 운영하는 분산 DB 구조상, NoSQL 모든 노드의 data 를 쓰는 요청의 횟수와, data 를 읽는 요청의 횟수가, 시간대 별로 거의 유사한 패턴으로 읽어질 수 있도록 데이터를 잘 분산시켜놓고 운용하는 것입니다. (하지만, 이렇게 이론적으로 이상적인 구조에 비슷한 데이터를 갖는 설계가 현실적으로 어려운 것이 맹점.)
    • doing key-value lookups on well-distributed records
    • avoiding complex queries
    • most importantly, limiting hot keys
암튼, 이런 문제점은 best practice 를 따라하면서 최소화 하는 방향으로 진행하면 되겠지 싶었지만, AWS 에서 제공하는 레퍼런스 내용에 나오는 DynamoDB 의 구현방법이나 best practice 말고, 실제 DynamoDB 를 장기적으로 경험해본 사람들의 핸즈온 경험들이 궁금해 졌습니다. 좀더 구글링을 해보니, 아래에 리스팅한 글들에서처럼 생각보다 부정적인 경험자들의 의견들이 있었습니다. 종합해보면, DynamoDB 의 가장 큰 문제점들 중 하나는 throttle exception 발생 경우가 많아져서, throughput (RCU/WCU) 을 올리다 보면, 실제로 운용에 필요한 비용이 처음 생각했던 것 보다 많이 드는 문제점에 대한 지적입니다. 실제로 reddit 에서 Aurora DB 팀이, nosql 이라고 말하긴 했지만, DynamoDB 운용비보다 40% 정도 낮은 장점이 있다는 아래 글에 대한 언급이 있었는데, 고비용의 문제가 상당히 걸림돌로 작용하는 것으로 보입니다.
“The one thing that surprised me is that there are some customers who are moving their nosql workload to aurora. There are two reasons for that. One, it’s a lot easier to use Aurora because it’s mysql compatible compared to nosql because the interfaces and transaction characteristics are so different. What is also interesting is people also saved money because the IO cost is much lower. In no SQL if you have a large table it gets partitioned then the IO gets partitioned across all the table partitions that you have. And if you have one partition that is hot then you have to provision based on the IO requirement of the hot partition. In the case of Aurora we do automatic heat management so we don’t have this hot parition issue. Second we don’t charge based on provisioned IO. It’s only the IO that you use. And that actually saves a lot of money. In this particular case this is a big social company, interaction company, I cannot tell the name, and they reduced their operational costs by 40% by moving from nosql to Aurora”
DynamoDB 의 장점은 그냥 사용하면 되는 managed 서비스라지만, 이것 때문에 상당히 strict 한 throughput exception 을 감수하고, 익셉션이 잡힐때마다 큐를 써서 재시도를 하도록 만들던지, 돈을 많이 써서 널널하게 고사양으로 운용해야 한다고 생각하니, 별로 좋은 그림처럼 보이지 않았습니다. 그래서, 알아본 다른 대안이 MongoDB 입니다. 우선, NoSQL 중 가장 많은 사용자에게 선택받은 데이터베이스이면서, 최소한 앞서 언급한 DynamoDB 와 같은 strict 한 throughput exception 이 없기때문에 클러스트 업그레이드 시점을 그만큼 더 유연하게 가져갈 수 있을 겁니다. 하지만, AWS 가 제공하는 매니지드 서비스가 없어서 운용적으로 시간과 노력이 더 들어가지 않나 싶은 걱정때문에, 개인적인 선호도 우선 순위에서 DynamoDB 보다 낮았습니다. 그런데 AWS 서울 리젼에서도 이용할 수 있는 몽고DB 매니지드 서비스인 MongoDB Atlas 가 있더군요. TL;DR
DynamoDB MongoDB
a fully managed proprietary NoSQL open source NoSQL
1개 document 최대 size = 400KB 1개 document 최대 size = 16 MB
aggregation 미지원 (DynamoStream 과 Lambda 로 처리 또는 application level 에서 처리) 훌륭한 appregation framework 지원
예) SELECT COUNT GROUP BY 구현 (recursive 하게 여러 RCU 소비하면서 복잡하게 처리) 예) SELECT COUNT GROUP BY (간단한 처리 가능)
로컬용 stripped version 만 제공 로컬에 Docker 등으로 원하는 버젼 설치 가능
디비접속 클라이언트 애매 디비접속 클라이언트 MongoDB Compasss / Studio 3T 등 다양하게 존재
위와 같은 장점들을 나열해 보니 MongoDB 로의 전환이 당연하게 느껴졌습니다.

AWS RDS MySQL 에서 MongoDB 로의 마이그레이션

사용할 NoSQL 을 결정했기때문에, 5억개가 넘는 MySQL 의 레코드를 MongoDB 로 마이그레이션하기로 했습니다. 처음에는 마이그레이션을 수행할 application 프로그램을 작성해서 수행하도록 해봤지만, 레코드의 갯수가 워낙 많아서, 마이그레이션만 수일이 넘는 시간이 걸리게 됨을 알게 되었습니다. 그래서, mongoimport 를 사용하기로 결정을 바꾸고, 이를 위해서 우선적으로 MySQL 의 필요한 데이터들을 CSV 파일로 덤프하기로 했습니다. AWS RDS MySQL 의 경우, CSV 덤프를 할 수 없었기에, 스냅샷을 생성한 후 Migrate snapshot 액션 메뉴를 통해서 AuroraDB 로 마이그레이션을 수행한 다음, AuroraDB 로부터 CSV 파일을 생성하기로 했습니다. RDS 테이블 스키마가, NoSQL 의 테이블과 비슷한 형태라면, 아래와 같이 mysqldump 로 dump 파일을 생성한 후, CSV 변환을 생각해 볼 수 있겠으나, 조인 쿼리도 없이 단순하게 RDB 테이블을 NoSQL 테이블로 옮길 가능성은 거의 존재하지 않을 것이므로, AuroraDB 를 사용할 수 밖에 없을 듯 합니다.
mysqldump -h aurora.co8ookxfhjax.ap-northeast-2.rds.amazonaws.com -u admin -p --single-transaction --compress --routines --triggers database_name table_name > table_dump.sql
AuroraDB 에서 CSV 파일을 생성하는 방법은 아래의 레퍼런스에 잘 설명이 되어있습니다. Saving Data from an Amazon Aurora MySQL DB Cluster into Text Files in an Amazon S3 Bucket 위 메뉴얼에 따라, 아래 순서대로 작업을 수행했습니다.
  1. AWS S3 버킷 생성
  2. AWS RDB 스냅샷을 선택후 instance actions 에서 Aurora DB 로 migration 을 선택 (db.r4.xlarge)
  3. IAM 메뉴에서 Aurora 클러스터의 IAM 파라미터 변경
  4. Aurora DB 에 접속후, 아래 명령수행
GRANT SELECT INTO S3 ON . TO 'admin'@'%';
FLUSH PRIVILEGES;
USE database_name;
SELECT table_name.*, IFNULL(another_table_name.type, "Rating") AS rating_type, users.gender, users.created_at AS registered_at, ... FROM table_name LEFT JOIN users ON table_name.user_id = users.id LEFT JOIN another_table_name ON table_name.id = another_table_name.table_id INTO OUTFILE S3 's3-ap-northeast-2://my-data/sample_data' FIELDS TERMINATED BY ',' LINES TERMINATED BY 'n' OVERWRITE ON
AuroraDB 클러스터에 접속후 위의 명령을 내리니까, 약 3시간후 S3에 CSV 파일들이 6GB 단위 chunk 들 10개가 생성되어 있는 것을 확인 할 수 있었습니다.

AWS 에서 CloudFormation 으로 MongoDB 클러스터 생성하기

아래의 링크에서 볼 수 있듯, AWS 는 MongoDB 클러스터를 생성하기위한 CloudFormation 템플릿을 제공하므로, 우선적으로 아래 문서를 참고하여 MongoDB 클러스트를 설치했습니다. MongoDB on the AWS Cloud: Quick Start Reference Deployment 위 템플릿은 크게 2가지의 옵션을 지원하는데, 1번째 템플릿 옵션을 선택하면, MongoDB 클러스트를 위하여 새로운 VPC 를 생성하고 그 VPC 상에 MongoDB 클러스터를 생성해줍니다. 그리고, 해당 클러스터에 접속가능한 bastion 서버까지 자동으로 생성해줍니다. 그러므로, 해당 bastion 서버에 application 프로그램을 배포해야 합니다. 각자 선택은 자유겠지만, 제가 작업한 환경에서는 이미 VPC 에 설치된 EC2 인스턴스들이 있고, 그 인스턴스들에 application 프로그램도 배포되어 있는 상황이라서, 2번째 template 옵션을 선택했습니다. 2번째 옵션을 선택하면, MongoDB 클러스터를 생성할때 bastion 서버로 사용할 기존 EC2 인스턴스들의 subnet 정보를 입력해야하기 때문에, 앱 서버들이 위치한 region 과, subnet 정보를 미리 파악해 놓은 다음, 필요한 옵션 입력화면에서 그 내용들을 정확하게 입력해야만 합니다. (참고로, CloudFormation 의 기본 region 이 virginia 로 자동 변경됩니다. 그걸 눈치채지 못하고 생성했다가 접속이 안되서, 한참동안 뭥미? 했던 경험이 있네요.) 암튼, MongoDB 클러스터가 자신이 원하는 리젼과 subnet 상에 생성되었다면, bastion 서버에 접속후, 다시 몽고서버로 ssh 접속(ssh -i mykey.pem 몽고서버)을 하기 위해서, mykey.pem 도 해당 bastion 서버로 복사합니다. mykey.pem 파일을 bastion 서버에 복사했더라도, 아직은 몽고디비 클러스터로 ssh 접속이나 MongoDB 클러스터 27017 포트로 접속을 할 수 없습니다. 새로운 Security Group 을 생성하여, 아래의 테이블처럼 bastion 서버가 위치하는 주소의 range 를 등록하여 해당 포트 접속이 mongodb://몽고클러스터endpoint:27017 식으로 가능하게 열어줍니다.
규칙 TCP/UDP 포트 IP대역 Comment
Custom TCP Rule TCP 22 10.0.157.0/24 Bastion Private IP 주소에서 SSH 접속용
Custom TCP Rule TCP 27017 10.0.157.0/24 Bastion Private IP 주소에서 Mongo 접속용

AWS S3 의 CSV 파일을 MongoDB 로 import 하기

이전에 3시간가량의 작업으로 생성한 CSV 파일은 CloudFormation 으로 설치한 MongoDB 인스턴스에 ssh 접속 후, 그 인스턴스에 aws cli 를 설치한 다음, 아래 명령으로 리스트를 살펴봅니다. aws s3 ls s3://my-data 그 중 하나를 stdout 으로 덤프하려면 아래의 명령을 사용합니다. aws s3 cp s3://my-data/samples.part_00001 - mongoimport 로 보낼 스트림의 내용이 CSV 와 동일하지 않다면, awk 명령을 사용하여, 원하는 포맷으로 transform 합니다. 가령 예를 들면 아래와 같이 말이죠.
aws s3 cp s3://my-data/samples.part_00001 - | awk -F',' '{ printf("%d,%d,%d,%s,%s,%s,%s,%s,%s,%s,%s,%sn", $2,$3,$4,$5,$6,$12,($4 >= 67) ? "true" : "false",($7 == "N") ? "false" : "true",($8 == "N") ? "false" : "true",($9 == "Rating") ? "true" : "false",$10,$11)}'
스트림의 내용이 원하는 형태로 변경된 것을 확인 했다면, 아래의 예에서 처럼 mongoimport 의 옵션으로 필드와 타입을 지정합니다. 각 옵션에 대한 자세한 설명은 mongoimport 설명문서를 참조하시고요.
aws s3 cp s3://my-data/samples.part_00001 - | awk -F',' '{ printf("%d,%d,%d,%s,%s,%s,%s,%s,%s,%s,%s,%sn", $2,$3,$4,$5,$6,$12,($4 >= 67) ? "true" : "false",($7 == "N") ? "false" : "true",($8 == "N") ? "false" : "true",($9 == "Rating") ? "true" : "false",$10,$11)}' | mongoimport --host 100.100.100.100:27017 --ssl --username admin --password 'whatever!' --authenticationDatabase admin --db database_name --collection mydata --type CSV --fields "user_id.int32(),rater_id.int32(),score.int32(),created_at.date(2006-01-02 15:04:05),updated_at.date(2006-01-02 15:04:05),user_registered_at.date(2006-01-02 15:04:05),is_high.boolean(),is_retry.boolean(),is_unlock.boolean(),is_valid.boolean(),user_gender.string(),user_dob.date(2006-01-02)" --columnsHaveTypes --parseGrace skipRow --numInsertionWorkers 4
MongoDB 클러스터에 AWS m4large 인스턴스 (2CPUs, 8GB 메모리, EBS대역폭 450Mbps) 사용했는데, 10개의 터미널을 열고 동시에 import 를 진행하자 초기에는 정상적으로 동작하다가, 대략 1시간 이후에, 오랜시간동안 freezing 되면서 “Closed Explicitly” 라고, 접속이 자동으로 끊어지는 glitch 가 있었습니다. 하지만 동시에 처리하는 프로세스들의 수를 낮춰 다시 하니까 문제 없이 마이그레이션이 끝나더군요.

MongoDB Atlas 사용하기

MongoDB 와 MongoDB Atlas 에 대한 전반적인 첫 인상은 처음 사용하는 사용자에게도 상당히 user friendly 한 환경이라 느껴졌습니다. Atlas 의 경우 customer service 창으로 던진 질문들에도 응대를 잘 해줍니다. 현재는 M40 인스턴스 타입으로 AWS 서울 리젼에 클러스터를 생성하였습니다. MongoDB Atlas 의 UI 나 지원 문서들의 성숙도가 매우 높아서, 필요한 정보들을 힘들이지 않고 쉽게 찾아 볼 수 있었습니다. 몽고 클러스터 up and running 경험은 한마디로 정말 breeze 입니다. 접속할 AWS EC2 인스턴스의 Security Group 에 필요한 Whitelist IP 대역대를 안내에 따라서 추가하고, 사용하면 끝입니다. mongoimport 를 통해서 Atlas 클러스터로 옮기는 것도 문제가 전혀 없었고요. 이미 앞서 진행했던것과 비슷하게 아래의 임포트 명령을 10개의 터미널 창을 통해 실행시켜서, 몇시간 만에 마이그레이션을 끝낸 다음, index 를 설정하고, 같은 사양의 AWS RDS MySQL 인스턴스와의 속도 비교를 해보았습니다.
aws s3 cp s3://my-data/samples.part_00001 - | awk -F',' '{ printf("%d,%d,%d,%s,%s,%s,%s,%s,%s,%s,%s,%sn", $2,$3,$4,$5,$6,$12,($4 >= 67) ? "true" : "false",($7 == "N") ? "false" : "true",($8 == "N") ? "false" : "true",($9 == "Rating") ? "true" : "false",$10,$11)}' | mongoimport --host MyTest-shard-0/mytest-shard-00-00-fr24m.mongodb.net:27017,mytest-shard-00-01-fr24m.mongodb.net:27017,mytest-shard-00-02-fr24m.mongodb.net:27017 --ssl --username admin --password 'whatever!' --authenticationDatabase admin --db database_name --collection mydata --type CSV --fields "user_id.int32(),rater_id.int32(),score.int32(),created_at.date(2006-01-02 15:04:05),updated_at.date(2006-01-02 15:04:05),user_registered_at.date(2006-01-02 15:04:05),is_high.boolean(),is_retry.boolean(),is_unlock.boolean(),is_valid.boolean(),user_gender.string(),user_dob.date(2006-01-02)" --columnsHaveTypes --parseGrace skipRow --numInsertionWorkers 4

MongDB vs MySQL

아래의 조건으로 비교한 결과입니다.
  • 두 데이터베이스의 하드웨어는 모두 동일하게 r4.large (2CPU/15.25GB/160MB) 인스턴스 사용합니다.
  • 두 데이터베이스의 레코드수는 5억4천만개 정도로 동일합니다.
  • 조회하려는 자료에 두 데이터베이스 모두 인덱스를 걸어두었습니다.
  • MySQL 은 800GB, MongoDB 는 160GB 할당했습니다. (기본 IOPS 는 MySQL 에 유리)
  • MongoDB 테이블에 필요한 데이터가 모두 있는터라, 공평한 속도 비교를 위해 MySQL 도 조인 사용하지 않습니다.
  • 일정 범위에 속하는 사용자 아이디 1,000개에 대해서 loop 를 돌면서, 각각의 사용자에 할당된 최근 500개 점수의 합을 구하는 로직의 실행시간비교 테스트입니다.
  • MySQL 의 경우 각각의 사용자의 최근 레코드 500개를 가져와서 실제 합산은 application layer 에서 Laravel 이 수행하게 되고, MongoDB 의 합산은 몽고의 aggregation framework 에서 즉, 쿼리 레벨에서 처리합니다.
  • 똑같은 테스트를 연속해서 2번씩 돌리면서 시간을 측정하여, 캐쉬로 인한 감소된 실행시간도 비교합니다.
round MongoDB MySQL
#1 (1012999~1013999) 39.83 초, 4.61초 80.38 초, 14.25 초
#2 (1010999~1011999) 34.36 초, 4.79 초 105.34 초, 14.92 초
#3 (1000999~1001999) 33.06 초, 4.59 초 74.80 초, 15.06 초
#4 (990999~991999) 30.71 초, 3.80 초 60.60 초, 6.50 초
#5 (967900~968900) 38.30 초, 4.83 초 57.13 초, 14.11 초
#6 (933199~934199) 31.04 초, 3.67 초 84.38 초, 14.34 초
#7 (900999~901999) 39.91 초, 4.70 초 80.40 초, 15.40 초
#8 (840100~841100) 35.94 초, 4.52 초 71.26 초, 13.47 초
#9 (543210~544210) 47.01 초, 5.76 초 144.36 초, 14.71 초
#10 (443210~444210) 44.89 초, 5.21 초 117.21 초, 14.56 초
이렇게 직접 성능 비교를 해보니, 이번에 사용하는 use case 에 MongoDB 로의 마이그레이션이 나쁘지 않은 결정이라고 생각들었습니다. 다음 번 글에서는 MongoDB 의 강력한 aggregation framework 에 대하여 설명을 해보겠습니다.
Posted by admin in Backend, 1 comment