Skip to main content

109 posts tagged with "javascript"

View All Tags

· 4 min read

서비스워커로 Navigation Request 나 Static Assets 에 대한 리소스 캐시는 쉽다. (이전 포스팅 참조)

하지만 POST Request 에 대한 레퍼런스는 찾기 힘들어 결국 만들어버렸다. 복잡한 로직이지만 Request Body 를 SHA1로 해싱해 키로 IndexedDB 에 저장하고 그 키가 맞으면 꺼내주는 방식이다.

소스

// IndexedDB 는 Promisify 되어있지 않아서 라이브러리가 필요하다.
importScripts(
"https://cdn.jsdelivr.net/npm/localforage@1.7.3/dist/localforage.min.js"
);

// 캐시하고 싶은 POST 엔드포인트
const ENDPOINT = "https://your-domain/post-request";

const bin2Hex = (buffer) => {
let digest = "";
const dataView = new DataView(buffer);
for (let i = 0, len = dataView.byteLength; i < len; i += 4) {
let value = dataView.getUint32(i);
// hex 로 바꾸면 패딩비트 0 이 제거된다.
let hex = value.toString(16);
// uint32 는 4bytes 로 나온다.
let padding = "00000000";
// 패딩을 더해서 뒤에서 잘라준다.
let paddedValue = (padding + hex).slice(-padding.length);
digest += paddedValue;
}

return digest;
};

const postRequestFetchListener = (fetchEvent) => {
const requestUrl = fetchEvent.request.url;
const method = fetchEvent.request.method.toUpperCase();
// 맞는 엔드포인트인지 확인
if (!(method === "POST" && requestUrl === ENDPOINT)) {
return;
}

fetchEvent.respondWith(
fetchEvent.request
.clone()
.arrayBuffer()
.then((buffer) => {
const requestBody = String.fromCharCode.apply(
null,
new Uint8Array(buffer)
);
// request body 에 원하는 조건만 캐시처리할 수 있게 한다.
if (requestBody.includes("cache=1")) {
// 속도면에서 다른 해싱 알고리즘을 사용해도 무방하다.
return crypto.subtle.digest("SHA-1", buffer);
}

return Promise.reject();
})
.then((sha1Buffer) => {
const sha1Hash = bin2Hex(sha1Buffer);
console.log("SHA1 Hash => ", sha1Hash);

// IndexedDB 에서 캐시된 키를 찾는다.
return localforage.getItem(sha1Hash).then((cachedResponse) => {
if (cachedResponse) {
console.log("Cached repsonse => ", cachedResponse);
return new Response(cachedResponse, {
// 여기서 statusCode 를 304 로 내보내고 싶었으나, Body 를 반환할 수 없었다.
status: 200,
statusText: "OK",
headers: {
"Content-Length": cachedResponse.length,
"Content-Type": "application/json",
// 그래서 커스텀 헤더를 추가했다.
"X-SW-Cache-Hit": 1,
"X-SW-Cache-Type": "POST",
},
});
}

// 캐시된 데이터가 없을 경우 새로 요청한다.
return fetch(fetchEvent.request).then((response) => {
console.log("Fetching response => ", response.clone());

// 정상적일 경우만 IndexedDB 에 저장한다.
if (200 <= response.status && response.status < 400) {
// 이 작업은 비동기지만 굳이 기다리지 않아도 된다.
response
.clone()
.text()
.then((textResponse) => {
console.log("Caching response => ", textResponse);
return localforage.setItem(sha1Hash, textResponse);
});
}

return response;
});
});
})
.catch(() => fetch(fetchEvent.request))
);
};

// 리스너를 등록해준다.
self.addEventListener("fetch", postRequestFetchListener);

여담

  • WorkBox 를 사용할 수 있다면 CacheableResponse와 CacheFirst 정책으로 단번에 처리 가능할 것이다.
  • 굳이 해시를 키로 사용하지 않아도 된다. RequestBody 의 Serialize 를 키로 써도 된다. (만들면서 crypto 라이브러리를 사용해보고 싶었을 뿐)

· One min read

node 에서 즐겨쓰는 package.json import 방법은 아래와 같다.

import packageJson from "../package.json";
console.log(packageJson.version);

편안하게 잘 사용되는 로직인데 타입스크립트로 변경시에는 몇 가지 설정을 해줘야한다. 설명에 필요없는 설정은 생략했다.

tsconfig.json
{
"compilerOptions": {
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true
}
}

또는 tsc 실행시에 --esModuleInterop, --resolveJsonModule 옵션을 추가해 빌드해줘야한다.

참조

· 2 min read

3.0 버전이 되면서 HOC 기반으로 변경되며 rules를 상위 컴포넌트에서 확장하게 되었다. 문제는 typescript 기반에서 룰 전체 등록이 TS(7053) 에러를 발생시킨다.

import { ValidationProvider, ValidationObserver, extend } from "vee-validate";
import * as rules from "vee-validate/dist/rules";

Object.keys(rules).forEach((rule) => {
extend(rule, rules[rule]); // rules[rule] 에서 타입오류 발생
});
Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'typeof import(".../node_modules/vee-validate/dist/rules")'.
No index signature with a parameter of type 'string' was found on type 'typeof import(".../node_modules/vee-validate/dist/rules")'.ts(7053)

원인

import, export 의 모듈명은 string index 로 쳐지지 않아 발생한다.

해결방법

Object.entriesfor of를 사용해 타입에 안전하게 돌려주면 된다.

import { extend } from "vee-validate";
import * as rules from "vee-validate/dist/rules";

for (let [rule, validation] of Object.entries(rules)) {
extend(rule, {
...validation,
});
}

여담

  • 새로운 구문 (async/await, import/export)를 사용해 돌릴 땐 먼저 for of를 사용하는 습관을 들여야겠다.
  • 머지되어서 다음 사람의 삽질은 없을 듯 하다.

참조

· 2 min read

Docker 이미지로 생성된 후에 그 위에서 돌아간다. 자세한 개념은 시간날 때 추가 예정

레파지토리 토큰 발급

여기를 참조해 레파지토리의 secret 으로 등록한다.

서브모듈

themes directory 하위의 테마들은 각각의 repo를 가지고 있다. 이 테마들을 CI 중에 가져오기 위해선 서브모듈로 등록해주고 초기화시켜줘야한다.

# 서브모듈 테마 추가
git submodule add 테마깃경로 themes/테마명

# 싱크
git submodule update --init --remote

여기서 remote 옵션을 쓰지 않을 경우 최신 마스터를 pull 하지 않는다. 서브모듈을 쓰는 이유는 내가 관리하지 않기 위함이니 꼭 추가해주자.

소스

주석 없어도 하나하나가 무슨 느낌인지는 받아들여질 것 같다.

name: Node CI

on:
push:
branches:
- master

jobs:
build:
runs-on: ubuntu-latest

strategy:
matrix:
node: [12]

steps:
- uses: actions/checkout@master

- name: SETUP_NODE_${{ matrix.node }}
uses: actions/setup-node@master
with:
node-version: ${{ matrix.node }}

- name: BEFORE_INSTALL
run: npm i -g hexo workbox-cli

- name: BEFORE_SCRIPT
run: |
git config --global user.name 'gracefullight'
git config --global user.email 'gracefullight.dev@gmail.com'
sed -i "s/__GITHUB_TOKEN__/${{ secrets.HEXO_DEPLOY_TOKEN }}/" _config.yml

- name: THEME_INSTALL
run: |
git submodule update --init --remote --recursive

- name: NPM_INSTALL
run: npm install

- name: HEXO_CLEAN
run: hexo clean

- name: HEXO_GENERATE
run: hexo generate

- name: WORKBOX_BUILD
run: workbox injectManifest

- name: HEXO_DEPLOY
run: hexo deploy

GITHUB_TOKEN

secrets.GITHUB_TOKEN 은 예약된 토큰이다. 빌드 이미지에서 현재 repo를 접근하기 위한 토큰임을 알아두자.

결론

travis-ci 안녕

참조

· One min read

페이지를 가져온 뒤 css, image, font를 차단하면 더 빠른 DOM 액세스가 가능하다.

리소스 차단

// @types/puppeteer
import { launch, Browser, Request, Page } from "puppeteer";

const browser: Browser = await launch({
headless: true,
});

const page: Page = await browser.newPage();
await page.setRequestInterception(true);

page.on("request", (req: Request) => {
switch (req.resourceType()) {
case "stylesheet":
case "font":
case "image":
req.abort();
break;
default:
req.continue();
break;
}
});

await page.goto("URL");

· 2 min read

triple des 알고리즘으로 암호화하는 일은 요새는 드문데, 드물어서 그런지 구글링해도 아무 것도 나오지 않았다.

레거시 언어들에는 3des 암호화된 로직이 많은데, 포팅하면서 개발해야될 필요성이 생겼다.

암호화

  • nodejs 의 암호화 패키지는 crypto 안에 들어있다.
  • crypto.getCiphers(); 메소드로 사용할 수 있는 알고리즘을 확인할 수 있다.
    • ciphers.filter(cipher => cipher.includes('des'));
  • 3des 알고리즘은 des-ede3 로 시작한다.
  • 키는 24bytes 이며, iv 는 8bytes 다.

소스

const crypto = require("crypto");

class TripleDes {
// #iv;
// #key;

constructor(key, iv) {
this.key = key;
this.iv = iv;
}

getKey() {
return this.key.padEnd(24, String.fromCharCode(0));
}

getIv() {
return this.iv;
}

encrypt(plain, iv) {
if (!iv) {
iv = this.iv ? Buffer.from(this.iv, "hex") : crypto.randomBytes(8);
}

const cipher3des = crypto.createCipheriv(
"des-ede3-cfb8",
this.getKey(),
iv
);
let encrypted = cipher3des.update(plain, "utf8", "hex");
encrypted += cipher3des.final("hex");

this.iv = iv.toString("hex");
return encrypted;
}

decrypt(encrypted, iv) {
if (!iv) {
iv = this.iv;
}

const decipher3des = crypto.createDecipheriv(
"des-ede3-cfb8",
this.getKey(),
Buffer.from(iv, "hex")
);
let decrypted = decipher3des.update(encrypted, "hex", "utf8");
decrypted += decipher3des.final("utf8");

return decrypted;
}
}

module.exports = TripleDes;

위의 encrypt 메소드는 아래처럼 버퍼를 합친 후에 헥스로 바꾸는 것과 동일하다.

let encrypted = Buffer.concat([
cipher3des.update(plain, "utf8"),
cipher3des.final("utf8"),
]);

encrypted = encrypted.toString("hex");

사용법

const TripleDes = require("./TripleDes");

const tripleDes = new TripleDes("encryptionKey");
// 암호화
const encrypted = tripleDes.encrypt("yummy");
// 복호화
const decrypted = tripleDes.decrypt(encrypted);

참조

· 14 min read

리액티브 프로그래밍은 개인적으로 비동기 프로그래밍과 함수형 프로그래밍의 종착지라고 생각한다. Stage1 Draft로 제안되어 더 이상 피할 수 없는 Observable 을 알아보자.

정의

위키피디아에선 다음과 같이 정의되어있다.

reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change. 데이터 스트림과 변화의 전파에 중점을 둔 프로그래밍 패러다임

2011년 태초에 RX 의 개념을 만든 MS 문서에서는 Reactive eXtensions 다음과 같다.

Reactive Extensions (Rx) is a library for composing asynchronous and event-based programs using observable sequences and LINQ-style query operators. 옵져버블 시퀀스와 링큐 쿼리 연산자를 사용하는 비동기, 이벤트 기반 프로그래밍 라이브러리

조금 더 디테일하게 말하면 데이터스트림을 Pulling 방식의 이터레이터 패턴인 IEnumerable<T>/IEnumerator<T> 로 만들어 Pushing 방식의 옵저버 패턴인 IObservable<T>/IObserver<T>로 전파/구독하는 것이다.

Rx를 모두 이해한 뒤 이 정의를 보면 어쩜 이렇게 깔끔하게 한 줄로 이 내용을 다 담았을까? 란 생각이 드는데, 처음보는 입장에선 비동기인 건 알겠네 정도로만 이해가 되는 듯하다. 어려운 게 당연하다. 멀티쓰레드 프로그래밍을 처음 배울 때의 감정을 생각해보자.

용어

  • 비동기 프로그래밍: 이 문서를 볼 정도면 설명이 필요 없을 것 같다.
  • 함수형 프로그래밍: 함수를 FirstClass 로 취급해 파라미터, 리턴, 변수에 할당 가능하며 함수 합성이 가능하고 원하는 시점에 호출이 쉽다.
  • 데이터스트림: file stream, event stream, http stream 의 친구로 data 식 표현
  • 이터레이터: 설명 필요 없을 듯.
  • 옵져버: 디자인패턴 책의 옵져버패턴 단원 참조, 자바스크립트에선 이미 이벤트리스너가 옵져버 패턴이다.
  • 옵져버블: 특정 객체를 관찰하는 옵저버에게 여러 이벤트나 값을 전파하는 역할
  • 풀: 데이터를 받을지 결정
  • 푸쉬: 데이터를 보낼지 결정
  • 싱글: 하나의 값이나 이벤트를 다룸
  • 멀티플: 여러 개의 값이나 이벤트를 다룸

이 모든 용어를 하나의 표로 정리하면 다음과 같다.

싱글멀티플
함수이터레이터
푸쉬프로미스옵져버블

패턴

  • 옵져버블의 변수 스타일은 이름 뒤에 const click$ 처럼 $을 뒤에 붙혀주는게 정형화되어있다.
  • subscribenext, error, complete를 파라미터로 받는다.

마블 다이어그램

마블 다이어그램은 연산자를 쉽게 이해하기 위해, 옵져버블을 테스트하기위해 도식화된 다이어그램이다. 타이밍과 값의 변화를 한 눈에 파악할 수 있다.

map

  • 위에 줄은 input 이고 연산자를 만나고나면 아래 줄에서 output 이 어떻게 바뀌는지 보여진다.
  • 가로 줄은 하나의 옵져버블이다.
  • 줄의 | 파이프는 구독 완료를 나타낸다.

소스

생성

가장 간단한 옵져버블을 만들고 확인해보자.

const { Observable } = require("rxjs");

const test$ = Observable.create((observer) => {
console.log("create");
observer.next(1);
observer.next(2);
observer.complete();
console.log("done");
});

test$.subscribe(
(item) => {
console.log(item);
},
(error) => {},
() => {
console.log("complete");
}
);

/*
create
1
2
complete
done
*/

간단하지만 이터레이터이면서 구독가능하다는 걸 확인할 수 있다.

구독해제

옵져버블의 리턴함수로 구독해제 콜백을 지원한다, 콜백이 필요하지 않다면 unsubscribe() 를 호출해주기만 하면 된다.

const test$ = Observable.create((observer) => {
const interval = setInterval(() => {
console.log("test");
}, 1000);

return () => {
clearInterval(interval);
};
});

const subscription = test$.subscribe();
subscription.unsubscribe();

파이퍼블 연산자

Pipeable 연산자는 옵져버블 인스턴스를 pipe 함수 안에서 다룰 수 있는 연산자이다. 기본적으로 rxjs/operators 라이브러리 안에 들어있다.

const { map } = require("rxjs/operators");

const test$ = Observable.create((observer) => {
observer.next(1);
observer.next(2);
observer.complete();
});

test$.pipe(map((value) => value * 2)).subscribe((item) => console.log(item));

/*
2
4
*/

소스 옵져버블에서 발행된 값을 원하는대로 바꿀 수 있다.

연산자

of

args 순서대로 값을 반환한다.

from

  • Observable
  • Array
  • Promise
  • Iterable
  • String
  • ArrayLike

위 타입을 옵져버블로 변환해준다.

fromEvent

EventEmitter 클래스의 객체와 조합하거나 브라우저의 이벤트를 옵져버블로 바꿀 때 사용한다.

defer

팩토리 함수로 옵져버블을 생성한 후 구독한느 시점에 팩토리 함수를 호출해 이미 생성한 옵져버블을 리턴받아 구독한다.

from 과의 차이는 다름과 같다.

  • from
    • 프로미스 내부 구현부가 언제 실행되던지 상관 없을 때
    • 이미 실행 중이거나 완료한 프로미스를 옵져버블로 만들 때
  • defer
    • 옵져버블을 구독하는 시점에 프로미스를 생성해 구현부가 실행되어야할 때
    • 프로미스 객체 생성 시점이 구독하는 시점이여야할 때

range

범위 지정 후 그 값을 순서대로 발행한다. 반복문이 필요할 때 사용된다.

interval

ms 단위로 값을 발행한다.

timer

파라미터가 하나일 경우 ms 이후에 한 번 값을 발행하고, 두 개일 경우 ms 이후에 두번 째 파라미터만큼 주기적으로 값을 발행한다.

empty

값 발행 후 중간에 멈춰야하는 상황에 사용한다. 이 함수만 사용하지는 않고 다른 함수나 연산자와 조합해서 complete 함수를 호출해야 할 때 사용된다. 즉, 바로 구독을 완료해야될때 사용된다.

// 상수로 사용된다.
const { EMPTY } = require("rxjs");

never

아무 것도 하지 않고 옵져버블 생성이 필요할 때 사용된다.

// 상수로 사용된다.
const { NEVER } = require("rxjs");

throwError

옵져버블로 값을 발행하다가 에러를 발생시키고 종료해야하는 상황에 사용한다.

filter

주로 파이퍼블 연산자와 연결해서 사용된다.

const { filter } = require("rxjs/operators");
// 1~10 중 짝수 필터
range(1, 10)
.pipe(filter((x) => x % 2 === 0))
.subscribe((x) => console.log(x));

first

처음으로 일치하는 값을 발행한다. 두 번째 인자로 기본 값을 줄 수 있다.

last

마지막으로 일치하는 값을 발행한다. 두 번째 인자로 기본 값을 줄 수 있다.

take

정해진 갯수만큼 구독하고 구독을 해제한다. interval 과 같이 무한 반복이 실행되는 연산자와 같이 쓰면 된다.

const { take } = require("rxjs/operators");

interval(1000)
.pipe(take(3))
.subscribe((x) => console.log(x));

/*
0
1
2
*/

takeUntil

특정 이벤트가 발생할 때까지 옵져버블을 구독해야할 때 사용한다. 예시로 보는 게 빠르다.

interval(1000)
.pipe(
take(100),
takeUntil(fromEvent(document.querySelector("#btn"), "click"))
)
.subscribe((x) => console.log(x));

takeWhile

take 와 filter 가 합쳐진 연산자이다.

interval(1000)
.pipe(takeWhile((x) => x <= 10))
.subscribe((x) => console.log(x));

takeLast

Last 의 파라미터 수 만큼 저장해뒀다가 구독 완료시에 일괄적으로 발행한다. 발행하는 값이 [0, 2, 4, 6, 8, 10] 일 때 takeLast(3) 일 경우 내부에 저장된 값은 다음과 같다.

발행값내부배열
0[0]
2[0, 2]
4[0, 2, 4]
6[6, 2, 4]
8[6, 8, 4]
10[6, 8, 10]

skip

이름 그대로 n 개만큼의 발행을 건너 뛴다.

skipUntil

takeUntil 과의 반대로 옵져버블이 실행될 때까지 건너 뛴다.

const time = 1000;
interval(time)
.pipe(skipUntil(interval(time * 5)), take(2))
.subscribe((x) => console.log(x));

/*
4
5
*/

skipWhile

조건을 만족하지 않는 순간부터 값을 발행한다.

debounce

많이 사용하진 않지만, 디바운스할 옵져버블을 계산값을 리턴해주는 함수를 파라미터로 주면 된다.

debounceTime

로대쉬나 다른 라이브러리의 debounce 와 같다.

distinct

중복은 제거하고 발행한다. 값 비교에는 === 연산자가 사용된다.

of({ id: 1 }, { id: 1 }, { id: 2 }, { id: 3 })
.pipe(
distinct((data) => data.id),
map((data) => data.id)
)
.subscribe((x) => console.log(x));

/**
1
2
3
*/

두번째 파라미터로 flush 조건을 옵져버블로 넘겨 중복 조건을 초기화시킬 수 있다.

distinctUntilChanged

중복값이 연속으로 발행된 경우만 제거한다.

of(1, 2, 3, 3, 4, 1)
.pipe(distinctUntilChanged())
.subcribe((x) => console.log(x));

/**
1
2
3
4
1
*/

첫번째 파라미터로는 비교함수 (prev, next 를 파라미터로 받는)를 넣어 연속 비교조건을 변경할 수 있다.

두번째 파라미터로는 비교할 값 셀렉터를 변경해줄 수 있다.

of(
{ a: 1, b: 10 },
{ a: 1, b: 10 },
{ a: 2, b: 20 },
{ a: 3, b: 30 },
{ a: 3, b: 30 },
{ a: 2, b: 20 }
)
.pipe(distinctUntilChanged(null, (data) => data.a))
.subscribe((x) => console.log(x));

sample

이건 마블 다이어그램으로 이해하는 게 빠르다.

sample

notifier 옵져버블(x 옵져버블)이 발행되면 이전 최근 값을 발행한다. 값 c 처럼 소스 옵져버블에 새로운 값이 없을 경우 값을 중복으로 발행하지 않는다.

sampleTime

일정 간격 사이에 있는 최근 값을 발행한다.

pluck

기본 map 연산자는 뻔하기에 적지 않았다. map 처럼 동작하지만 소스 옵져버블에서 객체를 리턴할 때 객체의 property 를 뽑아낸다.

mergeMap

switchMap

concatMap

scan

partition

groupBy

buffer

bufferCount

window

windowCount

여담

  • RxJS 의 장점은 이벤트 구독을 취소하고, 모든 이벤트를 하나의 스트림으로 제어할 수 있는 것이라고 생각한다.
  • 이해하는데 상당한 기간이 소요되었지만, 웹에선 다음과 같은 상황에서 사용될 것 같다.
    • SPA
    • 에디터와 같이 많은 컴포넌트끼리 통신이 필요할 때
  • 이 상황에서도 앵귤러가 아닌 스토어 기반의 리액트, 뷰를 사용한다면 RxJS를 쓸거야 라고 보여주기 위해 사용하는 느낌이 든다.
  • 모두가 같이 가기엔 꽤 어려운 개념이다.
  • 싱글 스레드 기반의 백엔드에서 더 유용하게 쓰일 수 있을 듯
  • learn-rxjs의 예제를 분석하는 게 더 많은 도움이 될 것 같다.

· 2 min read

이 공격은 JSON.parseObject.deepCopy 를 사용하는 모든 로직에서 발생할 수 있다.

2019-04-17 에 발표된 이 취약점은 $.extends 구문 상에서 프로토타입 XSS 공격이 가능하다.

예시

var myObject = '{"myProperty" : "a", "__proto__" : { "isAdmin" : true } }';
var newObject = $.extend(true, {}, JSON.parse(myObject));
console.log(newObject.isAdmin); // true

var temp = {};
console.log(temp.isAdmin); // true

패치

취약점 패치는 아래 부분을 변경해준다.

jQuery.extend = jQuery.fn.extend = function () {
// 사이 로직 생략
for (; i < length; i++) {
if ((options = arguments[i]) != null) {
for (name in options) {
src = target[name];
copy = options[name];

// [AS-IS]
// if (target === copy) {
// [TO-BE]
if (name === "__proto__" || target === copy) {
continue;
}

if (
deep &&
copy &&
(jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)))
) {
if (copyIsArray) {
copyIsArray = false;
clone = src && jQuery.isArray(src) ? src : [];
} else {
clone = src && jQuery.isPlainObject(src) ? src : {};
}

target[name] = jQuery.extend(deep, clone, copy);
} else if (copy !== undefined) {
target[name] = copy;
}
}
}
}

return target;
};

· One min read

document.querySelectorAll('.class') 와 같이 엘레먼트를 받았는데, for문을 돌리면 오류가 발생한다.

해결

// 이 방법이나
let classes = Array.from(document.querySelectorAll(".class"));
// 이렇게 가능하다
classes = [...document.querySelectorAll(".class")];

// 구버전 브라우저를 지원해야한다면
classes = Array.prototype.slice.call(document.querySelectorAll(".class"));

여담

puppeteer 로 크롤링할 때 항상 헷갈린다. 삽질로 찾았는데 stackoverflow 에 정리된 게 있는 듯..

· 15 min read

PWA의 캐싱과 푸쉬 기능은 익히 알려져 있지만, 홈 설치에 대해선 제대로 되어있는 문서가 거의 없다. 기능은 기존 바로가기의 전체화면 버전이라 간단해보이지만 구현해보면 빙산의 일각인 걸 절실히 깨닫게 된다.

A2HS

Add To Home Screen, 홈 설치, 웹 앱 설치

사전지식

아래 사전지식이 없으면 이 문서가 읽기 힘들 수 있다.

  • es6
  • 서비스워커
  • 캐싱 전략

조건

홈 설치가 되려면 다음과 같은 조건이 필요하다. 크롬 측 설명인데 다른점이 많아서 하나씩 까보자.

  • HTTPS
  • 서비스워커 설치
  • start_url 이 오프라인 사용가능
  • 최소 192px 아이콘 지원
  • manifest.json 에 name 과 short_name 값 등록

HTTPS

HTTPS 를 통해 서비스가 제공되어야한다고 나오는데, 도메인 뿐아니라 페이지의 모든 리소스가 HTTPS 프로톨콜로 전송되어야한다.

서비스워커 설치

PWA 를 시작하는데 필수 요소니 자세한 설명은 생략

start_url 오프라인 사용가능

어느 캐싱전략을 사용해도 상관없으나 오프라인 모드로 연결이 되어야한다. 국내 서비스일 경우 크게 느리지 않으므로 Network First Strategy 로 캐싱하는 걸 추천한다. 국외 서비스일 경우 로드되는 리소스까지 캐싱전략을 세워야될 수 있다.

여기서 start_url 에 querystring 을 추가해 사용하는 경우가 대부분일텐데 캐싱되는 URL 은 querystring 이 없다는 걸 감안해야한다.

예를 들어보자.

{
// 보통 fetch request 로 접근되는 경로는 다음과 같다.
"start_url": "/",
// 검색 파라미터가 달려있다면 이런 모양일 것이다.
"start_url": "/?NaPm=",

// 하지만 manifest.json 에 들어있는 경로는 다음과 같을 것이다.
"start_url": "/?pwa=1&utm_source=pwa"
}

유저가 접근시 ['NaPm', 'pwa', 'utm_source'] 등의 파라미터를 제거해야 경로가 캐시처리되며 오프라인 접근이 될 것이다.

아이콘 지원

크롬에선 최소 192px 의 아이콘을 지원하라고 나와있지만 스플래시 스크린에서 사용되는 아이콘은 512px 이 우선이다. 게다가 엣지 등의 다른 브라우저는 512px 도 있어야한다.

큰 사이즈의 이미지가 있을 경우 설치시에 네이티브 코드에서 이미지를 리사이징해서 아이콘을 만드므로 512px 하나만 있어도 되지만 홈 설치 시간이 느려지는 원인이 되므로 최소 192px, 512px 두 개는 지원해주자.

여력이 된다면 더 많은 사이즈의 이미지를 지원해줘도 된다. 이미지 업로드 하는 페이지의 리사이징 비용과 이미지별 트래픽 비용도 추가되겠지만 항상 유저의 속도가 중요한 것 아닌가?

이미지가 많을 경우 아래 쪽에 언급할 compatible 기능에서 문제가 생길 수 있다.

앱 이름

name 속성은 스플래시 스크린에 사용되며, short_name 은 바로가기 앱 명에 사용된다. name 속성에 빈 값이 들어갈 경우 (빈 값엔 space 및 'ㄱ' 한자 1키도 포함된다.) short_name 으로 대체된다. 두 속성이 모두 빈 값일 경우 홈 설치가 비활성화 된다.

이 기능은 스펙에 명시되어 있어서 변경 불가능하다.

크롬에선 스플래시 스크린 하단에 앱 이름이 노출되며 기타 브라우저에선 중앙에 노출된다. 최근에 하단으로 변경된 것으로 보인다.

고려 대상

필수 조건에는 없지만 고려해야할 대상들은 아래와 같다.

  • background_color
  • theme_color
  • display
  • related_applications

background_color

이 속성은 스플래시 스크린의 배경색을 담당하는데, 배경색은 스플래시 스크린 상의 앱 이름 (name 속성)의 색상에 영향을 미친다.

앱 이름은 배경색이 거므스름한지의 여부에 따라 흰색 또는 검정색으로 노출된다. 이 공식은 contrast ratio 란 스펙으로 정의되어 있으며, 안드로이드 내부에 구현되어있는 소스는 다음과 같다.

private static final float CONTRAST_LIGHT_ITEM_THRESHOLD = 3f;

/**
* Calculates the contrast between the given color and white,
* using the algorithm provided by
* the WCAG v2 in http://www.w3.org/TR/WCAG20/#contrast-ratiodef.
*/
private static float getContrastForColor(int color) {
float bgR = Color.red(color) / 255f;
float bgG = Color.green(color) / 255f;
float bgB = Color.blue(color) / 255f;

bgR = (bgR < 0.03928f) ? bgR / 12.92f : (float) Math.pow((bgR + 0.055f) / 1.055f, 2.4f);
bgG = (bgG < 0.03928f) ? bgG / 12.92f : (float) Math.pow((bgG + 0.055f) / 1.055f, 2.4f);
bgB = (bgB < 0.03928f) ? bgB / 12.92f : (float) Math.pow((bgB + 0.055f) / 1.055f, 2.4f);

float bgL = 0.2126f * bgR + 0.7152f * bgG + 0.0722f * bgB;
return Math.abs((1.05f) / (bgL + 0.05f));
}

/**
* Check whether lighter or darker foreground elements (i.e. text, drawables etc.)
* should be used depending on the given background color.
* @param backgroundColor The background color value which is being queried.
* @return Whether light colored elements should be used.
*/
public static boolean shouldUseLightForegroundOnBackground(int backgroundColor) {
return getContrastForColor(backgroundColor) >= CONTRAST_LIGHT_ITEM_THRESHOLD;
}

theme_color

이 속성은 주소창의 색상 또는 standalone 모드 시의 상단 상태바의 색상으로 노출된다. 하지만 IOS Safari 에서는 ['black', 'black-translucent'] 두 가지 색상으로만 선택이 가능하다.

이 것도 위의 Contrast 알고리즘을 활용해 거므스름한 여부에 따라 설정하면 된다.

display

크롬 문서엔 standalone, fullscreen, minimal-ui 모두 A2HS 의 기능을 활용할 수 있다고 설명되어 있으나, 실제로는 앞의 두 속성만 가능하다. 삼성브라우저는 minimal-ui 속성을 지원하지 않는다.

앱을 가지고 있을 경우 앱 다운로드를 유도할 수 있는데, manifest 에 아래 속성만 넣으면 된다. id 는 apk 업로드할 때 그 것과 같다.

{
"prefer_related_applications": true,
"related_applications": [
{
"platform": "play",
"id": "com.google.samples.apps.iosched"
}
]
}

Install Banner

App Install Prompt 또는 각각 브라우저마다 불리는 용어가 다른 이 기능은 PWA 접속시 홈 화면에 추가하라는 유도 배너를 말한다. 이 기능은 브라우저별로 이벤트 및 노출 유무가 천차만별이다.

스펙

배너설치아이콘배너재노출일브라우저아이콘
Chrome노출-~3 months-
Firefox-노출-표시
Samsung노출노출~2 weeks표시
Edge노출-?표시
UC?-??
Opera노출-?표시
Whale?---

크롬 빼고는 지원되는 브라우저에서 홈 설치시에 브라우저 아이콘이 모두 노출된다. 이러면 웹 푸쉬를 받을 시에 원래 브라우저에 뱃지가 생긴다고 보면 된다.

배너를 닫을 시에 재노출 기간이 비상식적으로 길다. 배너는 prompt() 메소드로 강제 노출이 불가능하며, 버튼을 클릭하는 액션이 있어야만 한다. 재노출일을 초기화할 시에는 브라우저의 데이터를 모두 삭제해야한다. (서비스워커 삭제로 불가능, 계정 또는 브라우저에 저장되는 값으로 추정됨)

UC 브라우저는 문서도 있고 설치가 되었던 것 같은데 최근 설치가 되지 않는다. Whale 는 크로미움 기반이라 설치가 되었던 것 같은데 최근 설치가 되지 않는다.

IOS Safari

모바일계의 IE인 사파리는 Web App Manifest 기능을 아직 개발 중에 있다. 그리고 매우 불친절한 개발자 문서1불친절한 개발자 문서2만 가지고 있다.

사파리 버그

사파리에서만 나타나는 크리티컬한 버그가 몇 가지 있다.

OAuth 인증 불가

정확히 말하면 타 도메인 또는 새 창을 사용하는 SSO 와 OAuth 인증이 불가능하다. 사파리 자체 버그로 IOS 12.2.0 버전에 수정되었다.

제스처 사용 불가

뒤로가기 제스처 사용이 불가능하다. 이를 위해 하이브리드 웹앱처럼 하단 네비게이션 바를 만들어줄 수 있다. 이 버그도 IOS 12.2.0 버전에 수정되었다.

전용 속성

홈 화면 아이콘과 스플래시 스크린, 테마 색상을 meta, link 태그로 만들어줘야한다. 이를 위해 manifest.json 의 값을 가져와 canvas 를 통해 프론트에서 이미지를 만들어야한다.

<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="AppTitle" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-itunes-app" content="app-id=myAppStoreID" />
<link rel="apple-touch-icon" href="touch-icon-iphone.png" />
<link rel="apple-touch-startup-image" href="icon.png" />

Compatible

Chrome, Safari, Edge 등 호환성을 맞추다보면 머리에 쥐가 날지도 모른다. 이럴 때 브라우저별 호환성을 맞춰주는 google 에서 제공되는 라이브러리가 있다.

pwa.compat 으로 다음과 같이 로드하면 된다.

<link rel="manifest" href="manifest.json" />
<script async src="path/to/pwacompat.min.js"></script>

다음과 같은 호환성이 해결된다.

  • Safari 전용 속성
  • Safari 홈 화면 아이콘과 스플래시 이미지
  • Edge, IE 전용 속성
  • meta 태그로 추가되지 않은 manifest.json 속성

단점

manifest.json 과 icon 배열의 수만큼 request 수가 증가한다. static resource 이므로 HTTP Cache 로 해결 가능하다.

버그

삼성브라우저에서 앱 이름에 " 큰따옴표가 들어간 경우 설치가 실패한다. Manifest JSON 을 파싱하면서 브라우저 내에서 오류가 발생한다.

통계

홈 설치를 한 뒤에 유저가 얼마나 설치했을지 통계를 내는 것 또한 중요한데, beforeInstallPromptappInstalled 이벤트는 브라우저 호환성이 보장되지 않는다.

따라서 위 이벤트로 로깅하는 건 무의미하고, start_url 에 들어온 파라미터로 통계를 내면 된다.

테스트

크롬 개발자도구의 application > Manifest > Add to homescreen 으로 이벤트를 발생시킬 수 있다고 하나 잘 되진 않는다.

홈 설치 조건을 만족시킬 경우, 크롬 설정 아이콘에서 "앱 명 추가" 라는 설정 메뉴가 하나 더 보이는데 이 기능으로 조건을 만족하는지와 브라우저 PWA 정도는 테스트 가능하다.

모바일 디버깅용으론 eruda를 사용하자.

여담

IE7-8 호환성 맞추던 시절이 떠올랐다. 아직 픽스될 버그가 많으며, 안정될 때까지 최소 1년은 더 필요할 것으로 보인다. 추가되고 고쳐져야할 기능도 몇 가지 있다.

  • 브라우저 호환성 (주소창 옆 아이콘으로 통일될 듯)
  • 이벤트 호환성
  • manifest href 에 blob 형태 지원 (현재 json decode 하면서 경로 오류 발생)
  • manifest가 변경될 경우 업데이트알림 (이슈)

개인 홈페이지에 적용할 경우 Web App Manifest Generator 를 이용하는게 속 편하다.

Desktop PWATWA 는 다루지 않았다.

레퍼런스