본문으로 건너뛰기

pm2 앱에서 git hook시에 nginx 502 gateway timeout 오류

· 약 1분

git pull을 hook으로 실행하는데 계속 nginx 502 gateway timeout 오류가 발생해 pm2 logs 앱 또는 nginx log를 계속 추적해도 별다른 에러 로그가 없었다. (exec: Internal Server error 라고만 적혀있었다.)

삽질 끝에 watch 속성을 사용하고 있는게 문제였다.

해결

ecosystem.config.js
module.exports = {
apps: [
{
name: "server",
script: "server.js",
env_production: {
NODE_ENV: "production",
},
watch: true,
ignore_watch: ["node_modules", ".git", "yarn.lock", "package-lock.json"],
exec_mode: "cluster",
instances: "max",
},
],
};

ignore_watch 속성에 .git 폴더와 다른 폴더들이 제대로 예외처리 되었는지 확인해보자.

nginx에 letsencrypt 인증서로 https 가장 빨리 적용하기

· 약 1분

centos7 기준

$ vi /etc/yum.repos.d/nginx.repo

[nginx]
name=nginx repo
baseurl=http://nginx.org/packages/centos/7/$basearch/
gpgcheck=0
enabled=1

$ yum makecache
$ yum install -y nginx

$ vi /etc/nginx/conf.d/default.conf
# server_name에 도메인 연결

# 문법 체크
$ nginx -t

# nginx 시작
$ systemctl enable nginx
$ systemctl start nginx

certbot 설치

$ yum install -y epel-release
$ yum install -y certbot-nginx

# 인증
$ certbot --nginx -d my.domain.com

ssl nginx 설정

기존 포스트 nginx 연동 탭 참조

renew 설정

$ crontab -e

1 0 1 * * certbot renew --nginx

$ systemctl restart crond

systemctl restart nginx 하면 완료

리액트 네이티브 윈도우 네트워크 설정 (react-native)

· 약 2분

리액트 네이티브로 개발한 앱을 Expo로 구동 중 network response timed out 에러 메세지가 보인다면 다음과 같이 해결하면 된다.

인바운드로 19000, 19001 포트를 열어줘야한다. 방화벽 > 고급 설정 > 인바운드 규칙 > 새 규칙 > 포트 에서 아래와 같이 설정해주고 이름은 react-native-expo 등 원하는 이름으로 규칙을 생성하자.

image from hexo

Hostname 설정

아이피 확인

ipconfig로 현재 Wifi가 설정된 내부 아이피 주소를 확인한다.

무선 LAN 어댑터 Wi-Fi:
연결별 DNS 접미사. . . . :
링크-로컬 IPv6 주소 . . . . :
IPv4 주소 . . . . . . . . . : 192.168.0.7
서브넷 마스크 . . . . . . . : 255.255.255.0
기본 게이트웨이 . . . . . . : 192.168.0.1

192.168.0.7 을 기억해 놓자.

설정 추가

REACT_NATIVE_PACKAGER_HOSTNAME 환경 변수를 설정해줘야한다.

Git Bash 환경에서는 다음과 같다.

export REACT_NATIVE_PACKAGER_HOSTNAME=192.168.0.7

실행

yarn start 후에 Expo 앱으로 QRCode를 찍으면 정상적으로 실행이 가능하다.

utf8 charset에서 emoji 필터링하기

· 약 2분

utf8 charset 에서는 이모지를 처리할 수 없다. utf8mb4 언어셋 소개 및 표현범위 포스팅에 따르면 이모지가 mysql 또는 maria 의 utf8 셋의 가변공간을 사용하려 들려 하는 문제인데, utf8mb4 셋으로 변경하면 해결 된다.

mysql 이거나 maria 의 charset 을 변경하기 힘들 때 text 를 모두 replace 치면 된다. 시작해보자

php

writing a simple removeEmoji function stackoverflow 글에 좋은 함수가 있다.

소스

<?php
// https://stackoverflow.com/questions/12807176/php-writing-a-simple-removeemoji-function
function removeEmoji($text) {
$clean_text = "";
// Match Emoticons
$regexEmoticons = '/[\x{1F600}-\x{1F64F}]/u';
$clean_text = preg_replace($regexEmoticons, '', $text);
// Match Miscellaneous Symbols and Pictographs
$regexSymbols = '/[\x{1F300}-\x{1F5FF}]/u';
$clean_text = preg_replace($regexSymbols, '', $clean_text);
// Match Transport And Map Symbols
$regexTransport = '/[\x{1F680}-\x{1F6FF}]/u';
$clean_text = preg_replace($regexTransport, '', $clean_text);
// Match Miscellaneous Symbols
$regexMisc = '/[\x{2600}-\x{26FF}]/u';
$clean_text = preg_replace($regexMisc, '', $clean_text);
// Match Dingbats
$regexDingbats = '/[\x{2700}-\x{27BF}]/u';
$clean_text = preg_replace($regexDingbats, '', $clean_text);
// Match Flags
$regexDingbats = '/[\x{1F1E6}-\x{1F1FF}]/u';
$clean_text = preg_replace($regexDingbats, '', $clean_text);
// Others
$regexDingbats = '/[\x{1F910}-\x{1F95E}]/u';
$clean_text = preg_replace($regexDingbats, '', $clean_text);
$regexDingbats = '/[\x{1F980}-\x{1F991}]/u';
$clean_text = preg_replace($regexDingbats, '', $clean_text);
$regexDingbats = '/[\x{1F9C0}]/u';
$clean_text = preg_replace($regexDingbats, '', $clean_text);
$regexDingbats = '/[\x{1F9F9}]/u';
$clean_text = preg_replace($regexDingbats, '', $clean_text);
return $clean_text;
}
<?php
$textWithEmoji = 'thumbs up👍👍';
$text = removeEmoji($textWithEmoji);

// text => thumbs up

node

emoji-regex 라이브러리를 쓰면 된다.

$ yarn add emoji-regex

소스

const emojiRegex = require("emoji-regex");

// const regex = emojiRegex()
const textWithEmoji = "thumbs up👍👍";
const text = textWithEmoji.replace(emojiRegex(), "");

// text => thumbs up

여담

이모지처리 (붙히고 제거하고), linkify 를 합친 통합 모듈이 있다면 다운로드 수가 좀 될 것 같은데.

Docker로 스웨거 설치 후 테마 설정하기 (swagger)

· 약 2분

Swagger UISwagger Editor를 Docker 로 띄워서 빠르게 사용해보자. Swagger Hub에서 작업하는건 한글 입력이 잘 되지 않는 경우가 있었고 OAS3를 사용할 겸 설치하는 게 편하다.

설치는 서버 하나만 있으면 충분하다.

Editor

$ docker run -d \
-p 7000:8080 \
--restart always \
--name swagger-editor \
swaggerapi/swagger-editor

UI

UI 에서는 API 문서를 상대경로로 사용하기 위해 볼륨 마운트를 했다. Docker for Windows에서 c 드라이브를 마운트하기 위해서는 아래처럼 //c/path 로 접근하면 된다.

$ docker run -d \
-p 9010:8080 \
-v //c/swagger-spec:/usr/share/nginx/html/spec \
--restart always \
--name swagger-ui \
swaggerapi/swagger-ui

테마 추가

테마는 나이스한 라이브러리를 사용하자. 두 컨테이너 모두 /usr/share/nginx/html/ 경로의 index.html을 열어 head 안에 넣어주면 된다.

docker exec로 쉘에 접근하는 건 다 하시리라 믿고 다만, 컨테이너에 bash 쉘이 없기에 기본 sh 쉘로 접근하면 된다.

index.html
<link
rel="stylesheet"
href="https://unpkg.com/[email protected]/themes/3.x/theme-material.css"
/>

여담

스웨거에 side menu 가 있는 버전이나 라이브러리가 있으면 좋을텐데, 누가 만들었을 것 같은데 아직 못 찾았다.

PWA - 서비스 워커 웹 캐싱 (Web Caching)

· 약 16분

Progressive Web App의 시대가 왔다. Client단에서 모든 static 파일을 브라우저에 캐시를 할 수 있고 웹을 앱처럼 오프라인에서 작업하게 할 수 있다. 그 첫번째로 캐싱에 대해 알아보자.

준비

Promise, Fetch, Worker 및 Javascript 의 실행 구조, DOM 에 대한 사전지식이 필히 있어야 한다. HTTP Cache 를 걸어봤다면 이해가 쉬울 듯 싶다.

ServiceWorker

서비스 워커는 브라우저가 백그라운드에서 실행하는 스크립트이며, 클라이언트에 설치되는 프록시다. 이 개념은 중요해서 외워야한다. 브라우저 백그라운드에서 네트워크를 가로채는 Thread 라고 보면 된다.

웹 캐싱 뿐만 아니라 백그라운드 동기화, 웹 푸쉬 등의 기능을 처리할 수 있다. 궁금하다면 여기를 들어가보자. (포스팅을 다 읽고 들어가보는 걸 추천한다.)

웹 캐싱은 CacheStorage를 사용한다. Sqlite 같은 클라이언트 데이터베이스인데, Key:value 로 구성된 데이터베이스라고 보면 된다.

주의

https 환경 또는 localhost 도메인에서만 이 기능을 사용할 수 있다.

브라우저별 지원상황은 다음과 같다 1803기준 Safari에도 Shipped가 되었다! 1712 기준 Safari가 아직도 Developement 상태인게 조금 아쉽다

세팅

sw.js란 파일을 public폴더(index.html이 있는)에 생성하자. 그리고 메인 script파일에 다음과 같이 service-worker를 불러오는 구문을 추가한다.

// navigator (브라우저)에 serviceWorker 기능이 있는지 확인
if ("serviceWorker" in navigator) {
// 서비스워커 설치시 DOM 블로킹을 막아준다.
window.addEventListener("load", function () {
// 서비스워커를 register 하면 promise를 반환한다.
navigator.serviceWorker
.register("/sw.js")
.then(() => {
console.log("서비스 워커가 등록되었다.");
})
.catch((error) => {
console.log(error);
});
});
}

개발자도구를 열고 Application > ServiceWorkers 탭으로 가면 설치가 된 것을 확인할 수 있다.

image from hexo

이렇게 뜨면 설치된 것이다.

Basic

서비스워커에서는 self 키워드로 자기 자신을 접근할 수 있다. 몇 가지 static 파일들을 캐싱처리 해보자. 모던 브라우저에서만 지원이 되므로 arrow function을 사용해도 된다.

sw.js
const PRE_CACHE_NAME = "캐시-스토리지1";
// 캐시하고 싶은 리소스
const urlsToCache = [
"/public/image/image1.png",
"/public/css/font-awesome.min.css",
];

// 서비스워커가 설치될 때
self.addEventListener("install", (event) => {
// 캐시 등록 이벤트가 끝날 때까지 기다려
event.waitUntil(
// '캐시-스토리지1'을 연다.
// @return {Promise} 연결된 Cache Database를 반환한다.
caches
.open(PRE_CACHE_NAME)
.then((cache) => {
console.log("캐시 디비와 연결됨");
// addAll 메소드로 내가 캐싱할 리소스를 다 넣어주자.
return cache.addAll(urlsToCache);
})
.then(() => {
// 설치 후에 바로 활성화 단계로 들어갈 수 있게 해준다.
return self.skipWaiting();
}),
);
});

이렇게 추가해주고 개발자도구의 Application 탭으로 가서 좌측 메뉴의 Cache Storage 를 새로고침 하면 방금 추가한 캐시-스토리지1에 내 이미지와 css가 등록된 걸 확인할 수 있다.

그리고 개발자도구의 Network 탭에서 호출하는 이미지의 size에 **(from ServiceWorker)**가 보인다.

Intermediate

내용이 업데이트 되야하는 페이지나 리소스에 대해서는 동적으로 캐싱 처리를 해야한다.

Dynamic caching

sw.js
const DYNAMIC_CACHE_NAME = "다이나믹-캐시-스토리지1";

// fetch event는 어딘가에서 리소스를 가져올 때 모두 실행된다.
// js를 가져오거나 이미지를 가져오거나 페이지를 가져오거나 등등
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// 캐시에 있으면 repsonse를 그대로 돌려준다.
if (response) {
return response;
}

// 여기서 request를 복사해준다.
// request는 스트림으로 fetch 당 한 번만 사용해야하기 때문이다.
// 근데 event.request로 받아도 실행은 된다
const fetchRequest = event.request.clone();

// if (response) return response 구문을 하나로 합칠 수도 있다.
// return response || fetch(fetchRequest)
return fetch(fetchRequest).then((response) => {
// 응답이 제대로 왔는지 체크한다.
// 구글 문서에는 다음과 같이 처리하라고 되어있는데
// 이 경우 Cross Site Request에 대해 캐싱 처리를 할 수가 없다.
// if(!response || response.status !== 200 || response.type !== 'basic') {
if (!response) {
return response;
}

// 응답은 꼭 복사 해줘야한다.
const responseToCache = response.clone();

// 캐시 스토리지를 열고 정말 캐싱을 해준다.
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});

// 여기서 response를 내보내줘야 캐싱 처리 후에 리소스를 반환한다.
return response;
});
}),
);
});

오래된 캐시 삭제

캐시 스토리지명을 바꿔 캐시의 버전을 올리면 기존 캐시 스토리지는 삭제해줘야한다.

sw.js
// 서비스 워커가 활성화 될 때
self.addEventListener("activate", (event) => {
// 영구적으로 가져갈 캐시 스트리지 화이트리스트
const cacheWhiteList = [PRE_CACHE_NAME, DYNAMIC_CACHE_NAME];

event.waitUntil(
// 캐시 스토리지의 모든 스토리지명을 가져온다.
caches.keys().then((cacheNames) => {
// 캐시를 삭제하는 건 Promise를 반환하므로 Promise.all을 사용해 끝날 시점을 잡아야한다.
return Promise.all(
// 이 결과는 [Promise, Promise...] 형태가 되시겠다.
cacheNames.map((cacheName) => {
// 각각의 캐시 스토리지명이 화이트 리스트와 같지 않을 경우
if (cacheWhitelist.indexOf(cacheName) === -1) {
// 캐시를 삭제하는 Promise를 배열에 추가한다.
return caches.delete(cacheName);
}
}),
);
}),
);

// activate 시에는 clients claim 메소드를 호출해서
// 브라우저에 대한 제어권을 가져와야한다.
return self.clients.claim();
});

결과적으로 DYNAMIC_CACHE_NAME이 아닌 캐시 스토리지는 삭제된다.

offline fallback

캐시된 페이지와 리소스는 오프라인에서도 접근이 가능하다. 여기서 오류가 발생하면 offline.html 같은 페이지로 떨어지게 할 수 있다. (마치 404 오류 페이지처럼)

sw.js
self.addEventListener("fetch", (event) => {
event.respondWith(
caches
.match(event.request)
.then((response) => {
// ...
})
.catch((error) => {
// 에러 발생시 캐시되어있는 offline.html로 이동시킨다.
return caches.open(CACHE_NAME).then((cache) => {
// 들어온 요청의 Accept 헤더가 text/html 을 포함하고 있다면 (페이지 요청이라면)
if (event.request.headers.get("accept").includes("text/html")) {
// 캐시된 offline fallback 페이지를 보여준다.
return cache.match("/offline.html");
}
});
}),
);
});

Advanced

서비스워커에 static, dynamic cache를 첨가했는데 소스가 너무 지저분하다. 직관적으로 만들어보자.

리팩토링

sw.js
(() => {
const STATIC_CACHE_NAME = "STATIC_CACHE_VERSION_1";
const DYNAMIC_CACHE_NAME = "DYNAMIC_CACHE_VERSION_1";

const WEB_CACHE = {
init() {
self.addEventListener("install", this.staticCacheStrategy.bind(this));
self.addEventListener("activate", this.deleteOldCache.bind(this));
self.addEventListener("fetch", this.dynamicCacheStrategy.bind(this));
},

staticCacheStrategy(event) {
// 스태틱 캐싱
},

deleteOldCache(event) {
// 캐시 삭제
},

dynamicCacheStrategy(event) {
// 다이나믹 캐싱
},
};

WEB_CACHE.init();
})();

IIFE를 사용해 좀 더 예쁘게 변했다.

cross domain request

서비스워커에서 fetch로 외부 리소스를 가지고오면 opaque response가 반환된다. cors 정책이 설정되어 있지 않아 아무 정보도 가지고 올 수 없는 건데, 이런 리소스만 골라서 캐싱처리를 하고 싶다면 request url이나 response content-type을 가지고 처리할 수 있다.

sw.js
const dynamicCacheStrategy = (event) => {
// 캐싱 처리하고 싶은 content-type
const cacheContentsTypes = [
"image/png",
"image/gif",
"image/jpeg",
"application/font-woff",
];

event.respondWith(
caches.match(event.request).then((response) => {
const fetchRequest = event.request.clone();

return (
response ||
fetch(fetchRequest)
.then((response) => {
if (!response) {
return response;
}

// response header에서 content-type을 가져와 비교한다.
// 아니면 request.url이 캐싱처리를 할 외부 url인지 확인한다.
if (
cacheContentsTypes.indexOf(
response.headers.get("content-type"),
) !== -1 ||
event.request.url.indexOf("external.url") !== -1
) {
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
cache.put(event.request, response.clone());
});
}

return response;
})
.catch((error) => console.log(error))
);
}),
);
};

WorkBox

이제 클라이언트 캐시가 어떻게 돌아가는지 확인했으니 구글에서 만든 멋진 라이브러리를 사용해보자.

간결하고 직관적인 문법으로 위의 구문들을 예쁘게 만들 수 있다.

Stratergy

그 전에 캐싱 전략을 알아야한다.

cacheFirst

image from hexo

캐시부터 요청하고 네트워크를 접근해 리소스를 보여준다.

오프라인을 우선적으로 보여주는 페이지에 적합하다.

cacheOnly

image from hexo

캐시에서만 가져온다.

static file들이 여기에 해당된다.

networkFirst

image from hexo

네트워크를 먼저 접근하고 오프라인일 경우 캐시를 가져온다. 이 방법은 연결이 원활하지 않거나 느린 경우 네트워크가 실패할 때까지 기다린 뒤 리소스가 보여지므로 UX에 좋지 않을 수 있다.

networkOnly

image from hexo

캐시가 필요없는 GET 메소드가 아닌 다른 메소드가 주로 여기에 해당된다.

staleWhileRevalidate

image from hexo

이 방식은 그림을 보고 이해하는게 빠르다. 캐시를 먼저 가져오고 다음 요청은 네트워크에서 요청된 리소스의 캐시를 반환한다.

주로 이 방식이 사용된다.

Cache then network

image from hexo

페이지가 두 개의 요청(캐시에 요청, 네트워크에 요청)을 동시에 하고 캐시된 데이터를 먼저 표시한 다음 네트워크 데이터가 도착하면 페이지를 업데이트를 한다. WorkBox에는 없는 strategy인데 networkFirst보다 UX상 좋다고 한다.

설치

ServiceWorker에서는 라우팅 기능을 사용하기 위해 구글 CDN에서 제공하는 스크립트를 사용하고 Pre-Cache를 정의하기 위해 로컬에서 workbox-cli를 추가해야한다.

importScript

importScript는 ServiceWorker 파일에서만 사용가능한 구문으로 importrequire와 같다고 보면 된다.

importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/3.2.0/workbox-sw.js",
);

workbox-cli

$ yarn global add workbox-cli

메소드

precache

프리캐시는 정적 자원들을 미리 캐싱처리해서 Cache Only Stratergy를 사용하는 것이다. 주로 이미지나 css, vendor-js 등을 여기에 담아준다.

workbox-cli를 설치하고 wizard 명령어를 실행하면 config 파일이 생성된다.

$ workbox wizard

설정을 입맛에 맞게 변경해주고 pre-cache할 파일 목록을 생성해주는 명령어를 실행하자

$ workbox injectManifest config.js

ServiceWorker에 workbox.precaching.precacheAndRoute([]) 구문이 있다면 목록이 들어가 있는 걸 확인할 수 있다.

workbox.precaching.precacheAndRoute([
{
url: "css/fonts/fontawesome/fontawesome-webfont.eot",
revision: "674f50d287a8c48dc19ba404d20fe713",
},
{
//...
},
]);

Routing

WorkBox를 사용하는 이유는 바로 이 라우팅에 있다. 위에서 많은 양의 라우팅 분기 코드로 캐싱을 처리했는데 WorkBox는 다음과 같이 간결해진다. 아래는 이 블로그에 사용하고 있는 코드이다.

importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/3.4.1/workbox-sw.js",
);

// importScripts 후 타이밍 차이로 인해 모듈을 못 불러오는 경우를 방지하기 위해
// 캐싱 정책 모듈 로드를 기다린다.
workbox.loadModule("workbox-strategies");

workbox.skipWaiting();
workbox.clientsClaim();

// accept 헤더에 text/html 값이 있으면 (html 페이지 요청일 경우)
// networkFirst 캐싱
workbox.routing.registerRoute((routeData) => {
return routeData.event.request.headers.get("accept").includes("text/html");
}, workbox.strategies.networkFirst());

// imgur 요청일 경우 cacheFirst 캐싱
workbox.routing.registerRoute(
/.*(?:imgur)\.com.*$/,
workbox.strategies.cacheFirst(),
);

// jsdelivr 요청일 경우 stateWhileRevalidate 캐싱
workbox.routing.registerRoute(
/.*(?:jsdelivr)\.net.*$/,
workbox.strategies.staleWhileRevalidate(),
);

// bootcss 요청일 경우 stateWhileRevalidate 캐싱
workbox.routing.registerRoute(
/.*(?:bootcss)\.com.*$/,
workbox.strategies.staleWhileRevalidate(),
);

코드가 너무 예뻐졌다. callback으로 Request의 모든 걸 캐치해 낼 수 있다. 더 구체적으로 쓰고 싶으면 문서를 참조하자

부가 기능

이 기능 말고도 다음과 같은 멋진 기능을 사용할 수 있다, 하지만 언제 쯤 써볼 수 있을지..

offline GA

오프라인에서도 Google Analytics 를 사용할 수 있다.

한 줄의 코드만 삽입해주면 된다.

workbox.googleAnalytics.initialize();

Webpack

웹팩에 WorkBox-Webpack-Plugin을 붙힐 수 있는데 아직 도전을 안 해봤다. 후술할 sw-precache를 굳이 뺄 이유가 없고, 레퍼런스도 워낙 많기에..

sw-precache

create-react-app이나 Vue-Cli@angular/cli 모두 클라이언트 캐싱에 WorkBox 대신 이 라이브러리를 사용하고 있다. (WorkBox에서 캐싱기능만 떼어낸 라이브러리라고 보면 된다)

기본 설정을 굳이 안 건들여도 되고, exact해서 사용 중이라면 옵션을 라이브러리를 한 번 쯤 봐주는 것도 나쁘지 않다.

참고

여담

  • 기술 정리하는 건 시간이 너무 오래 걸린다.
  • Redis, eTag, Vanish, SW 까지 사용하면 성능상에 이점은 있겠지만 키를 어떻게 관리해야될지가 중요할 듯

adonisjs 로그 포맷 변경하기 (custom log format)

· 약 2분

아도니스는 로깅 모듈로 winston 을 사용하는데 winston 은 로그를 json 포맷으로 출력하고 심지어 timezone 변수가 따로 나온다.

로그는 기본적으로 프로젝트 폴더 하위의 tmp 폴더에 생성된다 이 폴더가 없으면 로깅이 되지 않으므로 먼저 만들어주자

예를 들면 다음과 같다.

{"level":"info","message":"serving app on http://127.0.0.1:3333","timestamp":"2017-12-21T05:34:50.235Z"}
{"level":"info","message":"serving app on http://127.0.0.1:3333","timestamp":"2017-12-21T05:45:32.220Z"}

이걸 다음과 같이 바꿔보자

[2017-12-21 15:01:03.506] INFO serving app on http://127.0.0.1:3333

오늘 날짜를 구하기 위해 moment를 먼저 설치하자. winston 패키지는 @adonisjs/framework 패키지에 종속된다.

config/app.js
const moment = use("moment");
const { config } = use("winston");

module.exports = {
logger: {
transport: "file",
file: {
driver: "file",
name: "adonis-app",
// 파일명을 바꿔준다
filename: `adonis_${moment().format("YYYYMMDD")}.log`,
level: "debug",
// json 로그 포맷을 해제하고
json: false,
// 원하는 형태로 바꿔준다
formatter: ({ level, message, meta }) => {
const now = moment().format("YYYY-MM-DD HH:mm:ss.SSS");
// 로그 레벨에 색상을 추가하는 작업인데, 굳이 필요하진 않다
const logLevel = config.colorize(level, level.toUpperCase());
const formattedMeta =
meta && Object.keys(meta).length ? "\n\t" + JSON.stringify(meta) : "";

return `[${now}] ${logLevel} ${message || ""} ${formattedMeta}`;
},
},
},
};

버전

  • @adonis/framework: 4.0.28
  • winston: 2.4.0

왜 리덕스 사가(Redux-saga) 인가?

· 약 13분

redux, redux-thunk, redux-promise-middleware, redux-actions, redux-saga 머리가 뽀개질 지경이다. 결국엔 redux-saga를 써야만 했고 왜 saga로 수렴하게 되는지에 대한 삽질기다.

도대체 몇 개의 미들웨어 라이브러리를 파야하는지 화남을 포스팅했기에 오 프론트엔드를 꾸미는데는 리액트가 최고야 라고 생각하는 분들에겐 마음이 안들 수도 있다. 정신건강을 위해 mobx 사용을 추천드립니다.

redux

하위, 상위 컴포넌트에 데이터를 props로 넘겨주는게 너무 관리하기 힘들어서 선택한다. 하나의 Store(Object)에 SPA의 모든 데이터를 보관한다. 대안으로는 Event Bus Component를 만들면 되는데, 하나의 스토어를 갖는게 그게 그거라 거의 강제된다. redux를 적용하기에 앞서 기존 MVC 패턴에 쩌든 사상을 Flex(단방향) 패턴으로 바꾼다는게 거의 남북간 화합 수준이였다. 이 부분에 있어선 연습만이 답이다. 눈감고 코딩할 수 있을 만큼 예제 프로젝트를 반복해보자. 일단 몸에 익으면 action creator가 뭔지 왜 dispatch를 해야하고 reducer로 값을 처리해야 하는지가 뇌에서도 이해가 되는 느낌이였다. (counter 예제는 좋지 않다고 본다. shopping cart예제나 todo 예제가 더 이해하기 쉽다.)

redux-promise-middleware

redux에서 action의 payload를 비동기 데이터로 넘길 때를 알아서 처리해준다. 많은 포스팅이 있는데, 별 쓸모 없다. 실무에서 예제 포스팅을 따라할만한 간단한 비동기 처리 로직은 없다.

redux-thunk

하나의 action에서 여러 개의 다른 action을 호출하던지, action이 dispatch되는 걸 조작할 수 있어, 비동기 처리에도 사용한다. 멋진 라이브러리이지만 클로저 패턴을 사용해야하므로 소스가 지저분하다. (이게 깔끔하다고 생각하면 쓰면 된다. promise then promise then chaning도 깔끔하다고 생각하다면)

redux-actions

action, type, reducer의 반복되는 코드를 더 깔끔하게 구현하길 원한다면 이게 답일 수 있다. 하지만 이 미들웨어는 구문적인 편의성을 제공하는 것일 뿐 비동기를 처리할 수 없어서 promise, thunk 또는 여기서 소개할 saga가 강제된다. 여기를 참조하자.

rematch

1803 최근엔 이 모든 고민을 해결해주는 rematch 라는 라이브러리가 있다 async/await를 사용해 간결한 문법을 제공하는데 아직 삽질해보지 않아 소개정도만 하고 지나가겠다

redux-saga

어떤 action은 promise payload를 뱉고, 어떤 action은 dispatch로 다른 action을 호출하고 어떤 action은 plain object를 뱉고 이런 일관성없는 짓을 계속하다보면 정말 비효율적이라는 느낌을 받는다. 안 받으셨다면 해보시면 느껴질 것이다.

더 극단적으로 든 생각은 Vuex를 사용하면 그냥 mapGetters, mapActions 두 함수만으로 직관적이고 디자이너도 알아볼 법한 코드로 구현이 가능한데 왜 react를 해야되지? 란 의문이 계속 들었다.

Redux-saga를 접하고는 드디어 state 관리의 친구를 만난 느낌이였다. 그렇다면 saga는 뭘까? 한 줄 표현만 기억해보자.

saga란 action에 대한 listener이다. 음.. 액션 리스너구나. 이벤트 리스너같은 것이군.

설치를 하기 전에 reduxgenerator의 개념을 완벽히 이해했다고 가정한다.

설치

# yarn add redux react-redux
$ yarn add redux-saga

create-react-app으로 생성된 프로젝트로 폴더 구조를 다음과 같이 가져갔다.

.
├── public
└── src
├── components
├── containers
└── store
├── actions
├── reducers
├── sagas
└── types
├── package.json
├── App.js
└── index.js

연동

게시글을 가져오는 액션으로 시작해보자. 예시라 모든 코드를 해당 폴더의 index.js에 넣었다. 분리는 편하신대로 하면 되겠다.

빠르게 훑는 generator

  • iterable (돌리고 돌릴 수 있다)
  • 비동기든 동기든 간에 yield 구문으로 순차적 처리가 가능하다.

type

types/index.js
export const FETCH_BOARDS = "FETCH_BOARDS";
export const FETCH_BOARDS_FULFILLED = "FETCH_BOARDS_FULFILLED";
export const FETCH_BOARDS_REJECTED = "FETCH_BOARDS_REJECTED";

action

actions/index.js
export const fetchBoards = () => ({
type: FETCH_BOARDS,
});

// saga에서 호출하는 액션
export const fetchBoardsFulfilled = (boards) => ({
type: FETCH_BOARDS_FULFILLED,
payload: boards,
});

// saga에서 호출하는 액션
export const fetchBoardsRejected = (error) => ({
type: FETCH_BOARDS_REJECTED,
error,
});

action이 pure object만을 반환하는 것을 보고 있으면 아름답다는 생각이 저절로 든다.

reducer

reducers/index.js
const INITIAL_STATE = {
boards: [],
};

export default (state = INITIAL_STATE, { type, payload, error }) => {
switch (type) {
case FETCH_BOARDS_FULFILLED:
return {
...state,
boards: payload,
};
case FETCH_BOARDS_REJECTED:
return {
...state,
showError: true,
error,
};
default:
return state;
}
};

saga

saga는 action을 listen(watch)한다.

saga/index.js
import { call, spawn, put, takeEvery } from "redux-saga/effects";
import * as actions from "../actions";
import axios from "axios";

function* fetchBoardsSaga() {
// try catch finally 구문으로 오류 제어가 가능하다.
try {
// 이부분을 call 메소드를 이용해 테스트가 쉽게 바꿀 수 있다.
// (yeild를 사용하기 때문에 next 명령어로 반복 가능하므로)
// const { data } = yield call([axios, 'get'], '/boards')
const { data } = yield axios.get("/boards");
yield put(actions.fetchBoardsFulfilled(data));
} catch (error) {
yield put(actions.fetchBoardsRejected(error.response));
}
}

function* watchBoard() {
// type의 action이 실행되면 fetchBoardsSaga도 항상(Every) 실행한다
yield takeEvery(FETCH_BOARDS, fetchBoardsSaga);
}

// 모든 listener(watcher)를 하나로 묶어준다.
// rootReducer에 묶어주는 그것과 같다고 보면 된다.
export default function* root() {
yield spawn(watchBoard);
}

왜 watcher들을 spawn으로 묶어야하는지는 이슈에 나와있다. (여기엔 자동으로 saga가 재시작되는 패턴도 있는데, 아직 활용해본 적은 없다)

action => saga => action => reducer 로 연결되는 saga가 완성되었다.

store

스토어 담는건 각자의 취향이니 어떻게 연결하는지만 보면 된다.

store/index.js
import createSagaMiddleware, { END } from "redux-saga";
const saga = createSagaMiddleware();

export default function configureStore(initialState) {
const store = createStore(
rootReducer,
initialState,
compose(
applyMiddleware(...middlewares),
inDevelopment && window.devToolsExtension
? window.devToolsExtension()
: (f) => f,
),
);

store.runSaga = saga.run;
store.close = () => store.dispatch(END);

return store;
}
index.js
const store = configureStore();
store.runSaga(rootSaga);

메소드

all

yield 구문으로 기다리는 건 순차적으로 실행되기 때문에, 동시에 실행되고 전부 resolve되는 패턴이 필요하다면 all 메소드를 사용하면 된다. (Promise.all을 생각하면 쉽다)

export function* testSaga() {
// 기존 포스팅들에는 이렇게 사용하라는 구문이 많은데
// deprecated 경고를 뱉는다
const [response1, response2] = yield [asyncTask1(), asyncTask2()];

// 아래와 같이 사용하자
const [response3, response4] = yield all([asyncTask1(), asyncTask2()]);
}

call

Function.prototype.call() 함수와 같다.

takeEvery

모든 액션시마다 실행 된다. GET 메소드에 사용하자.

takeLatest

액션 호출시에 같은 액션이 실행 중이면 그 액션은 파기되고 마지막 호출만 실행된다. POST, PUT, DELETE 같은 리소스 변경 메소드에 사용하자.

put

액션을 호출한다. dispatch라고 보면 된다.

다시보기

비유를 통한 설명이 좋으면 이렇게 이해해도 된다. 회사에서 주로 푸드플라이로 서브웨이를 주문하는데 거기에 빗대어 보았다.

1. Board를 가져오는 타입 만들기

  • 서브웨이를 푸드플라이로 시킨다고 한다면
  • const 푸드플라이_서브웨이 = '푸드플라이_서브웨이' 처럼 컴퓨터가 알 수 있게 변수로 행동(액션)의 종류를 설정해주는 것
  • const 푸드플라이_서브웨이_주문성공 = '푸드플라이_서브웨이_주문성공' 처럼 주문이 완료됬을 때의 행동명도 만들 수 있다.

2. fetchBoards 액션 만들기

  • 푸드플라이에서 주문서를 넣는 행동과 같다.
  • 액션은 타입과 결과(payload)를 return하는 함수이다.
  • 굳이 payload를 안 써도 되는데, 그게 예쁘다.

3. fetchBoards 사가 만들기

  • 푸드플라이 서버에서 주문서를 처리하는 것
  • 서버에서 디비 값을 바꾸고 서브웨이로 주문을 밀어넣어주는 것
  • 마지막에 주문이 들어갔다는 다른 액션을 put으로 호출한다.

4. fetchBoards의 액션과 사가를 연결하기

  • 주문서를 넣으면 푸드플라이의 주문서 로직을 실행해야된다는 걸 컴퓨터에게 알려주는 부분
  • takeEvery로 감싸면 매번 주문서가 들어올 때마다 로직이 실행된다는 것
  • takeLatest로 감싸면 매번 주문서가 들어올 때마다 마지막에 들어온 것만 실행하는 것
  • take는 무한루프가 안 예쁘다.

5. fetchBoards 액션이 완료될 경우 발생하는 fetchBoardsFulfilled 액션의 리듀서를 만들기

  • 서브웨이에서 주문서를 받아서 서브웨이 멜트 만드는 준비를 하는 부분

6. 리듀서에서 초기 상태 값을 업데이트 해주기

  • 초기상태값은 서브웨이 멜트에 터키가 최소 2장, 햄 최소 2장 들어간다 같은 것
  • 업데이트는 서브웨이 멜트를 진짜 만드는 행동

7. 컴포넌트(클래스)를 리덕스 스토어와 연결

8. Boards Prop과 fetchBoards Action을 가져오기 (map...toProps 아실 거에요)

9. componentDidMount, onClick에서 fetchBoards 실행

10. Boards.map 등 DOM에 바인딩

여담

  • Saga API Docs엔 action을 기다리는 take 기능 등 많은 고급 기능이 있다.
  • supervisor 패턴으로 구성한 사람도 보이는데 아직 필요성을 잘 못 느끼겠다.
  • 처음부터 mobx로 시작했어도 쉬웠을텐데
  • saga에서 쓰는 개념들은 rx의 스트림과 비슷하게 느껴졌다.
  • 2020년에는 Redux hooks를 사용하면 된다.

잘 이해가 안 된다면 mobx를 이해하는 게 정신건강에 좋습니다.

Vue - Laravel Pagination 연동

· 약 2분

Laravel에서 paginate 메소드를 json으로 받았을 시에 데이터는 다음과 같다.

response
{
"current_page": 1,
"data": [{}, {}, {}],
"from": 1,
"last_page": 1,
"next_page_url": null,
"path": "https://example.com/ajax/list",
"per_page": 15,
"prev_page_url": null,
"to": 8,
"total": 8
}

이 데이터를 blade에서 links 메소드로 쉽게 사용할 수 있는 것 처럼 Vue에서도 그럴 수는 없을까?

Laravel Vue Pagination 패키지를 활용하면 된다. SPA일 경우에는 다운 받고 Usage 탭에 적힌대로 바로 사용하면 되지만, Multi Page일경우는 직접 컴포넌트를 가져와야한다.

<ul>
<li v-for="post in response.data" v-text="post.title"></li>
</ul>

<nav>
<pagination
:data="response"
:limit="3"
@pagination-change-page="fetchPosts"
/>
</nav>

<script>
new Vue({
data: {
response: {
data: [],
},
},

methods: {
fetchPosts: function (page) {
var vm = this;
axios
.get("url", {
params: {
page: page || 1,
},
})
.then(function (response) {
vm.response = response;
});
},
},
});
</script>

예외처리

Component 소스template 부분을 보면 nav로 감싸져있지 않기에 Laravel 기본 템플릿과 일치시키려면 nav 태그로 감싸주면 된다.

React-Router Code Splitting - 가장 쉬운 방법

· 약 2분

create-react-app으로 생성한 리액트 앱에 시작시 가져오는 컴포넌트 빼고는 비동기로 불러와야 메인이 가벼워진다.

검색해보면 AsyncComponent를 만들라는 게 보이는데, 더 쉬운 방법이 있다.

React-router 의 Code Splitting탭 에서 찾아볼 수 있는데, 아주 간결하게 컴포넌트를 불러 온다.

import React, { Component } from "react";
import Loadable from "react-loadable";
import Loading from "./Loading";

const LoadableComponent = Loadable({
loader: () => import("./Dashboard"),
/* 컴포넌트 로딩시 보여지는 로딩 컴포넌트 */
loading: Loading,
});

export default class LoadableDashboard extends Component {
render() {
return <LoadableComponent />;
}
}

너무 너무 쉽다. 진작에 이 정도 레벨의 추상화가 있었어야했다.

옵션

자세한 옵션 delay, timeout 등은 여기서 확인할 수 있다.