Canvas API를 이용한 노란우산공제 가입서비스 자필 서명 이미지 생성 모듈 구현기
2023년 04월 13일 한국신용데이터 기업 기술블로그에 게재한 내용입니다. 원본 확인하러 가기
안녕하세요, 저는 한국신용데이터의 뱅킹팀 웹 프론트엔드 개발 담당 이원국(Lee)입니다.
저희 캐시노트 뱅킹팀은 소상공인 금융 환경을 혁신적으로 개선하기 위해 끊임없이 고민하고 노력합니다. 이번에는 노란우산공제라는 소상공인 대상 공제 상품을 비대면으로 간편 가입할 수 있는 서비스를 런칭했습니다.
이번 프로젝트를 통해 많은 것을 공부하고 배워나갔습니다. 그중 서비스 가입 퍼널 중 전자 서명 입력 모듈이 특히 흥미로웠기에 기술 블로그를 통해 공유드립니다.
하나의 포스트로 정리하기엔 내용이 많아 두 편에 나누어 설명드리고자 합니다. 이번 파트에선 Canvas API를 사용하여 픽셀 데이터를 만들고, 분석, 편집하여 가공하는 과정을, 다음 파트에선 Broadcast Channel API를 사용해서 다른 웹뷰 컨텍스트끼리 데이터를 공유하는 과정을 설명하겠습니다.
아래 명시된 코드는 과정을 설명하기 위해 절차적인 단계로 재구성 되어 실제 구현된 코드와 다소 차이가 있습니다. copy and paste만으론 정상적인 작동을 보장할 수 없습니다.
무엇을 만들었나요?
캐시노트 앱을 사용하는 모바일 기기에서 고객이 쉽게 청약서와 청약 관련 제반 문서에 자필 서명을 할 수 있도록 돕는 디지털 자필 서명 모듈을 만들었습니다.
왜 만들었나요?
실제 협약 기관에서 공제 상품을 청약하는 과정을 디지털화 및 비대면, 간소화하여 더 많은 사장님들이 공제 혜택을 드리기 위해 만들어졌습니다.
저희 캐시노트에서는 유저가 입력한 청약 정보와, 홈택스 간편 인증 모듈을 통해 취합된 정보를 통해 전자 문서를 생성하고, 서명란에 자필 서명을 합성하여 PDF 및 바이너리 이미지 데이터로써 청약서 양식을 완성시켜 청약 과정에서의 고객 경험을 개선하고 있습니다.
어떻게 만들었나요?
JavaScript의 Canvas API를 사용하여 사용자가 서명을 그릴 수 있도록 하고, alpha값을 가지는 PNG 이미지를 추출하여 바이너리 데이터를 안전하게 난독화하여 서버에 올려보내어, 청약 서류에서 합성하는 프로세스로 구성되어 있습니다.
Canvas API를 통해 그림을 그리는 것은 간단했지만, 유효한 서명을 어떻게 판단할지, 엣지 케이스는 없을지, 어떻게 하면 서명 절차를 좀 더 feasible하게 만 들 수 있는지가 고민이었습니다.
배울 수 있었던 것들
- Canvas API를 활용한 픽셀 이미지 분석, 편집 및 가공
- Uint8ClampedArray를 비롯한 Typed Array 자료형에 대한 이해
- Broadcast Channel API를 이용한 웹뷰 컨텍스트간 통신
브라우저에서 그림을 그리는 방법들 🎨
웹 프론트엔드의 무대는 브라우저입니다. 브라우저에서 그림을 그리는 방법은 아래와 같습니다.
SVG
SVG는 도형을 그리는 벡터 API입니다. 그려진 각 도형은 이벤트를 바인딩할 수 있는 객체로 존재합니다. 벡터 방식이기 때문에 래스터화 된 캔버스보다 확대했을때도 품질이 유지됩니다.
CSS
문서의 DOM 요소들을 스타일링합니다. HTML, JS와 함께 웹에서 빼먹고 이야기할 수 없는 필수 요소입니다. DOM 요소를 이용하거나 가상(pseudo) 요소를 시각 적으로 꾸미는 역할을 합니다. 캔버스 영역에는 스타일을 바인딩할 수 있는 객체가 없기 때문에 Canvas API 내부적으로 CSS 스타일을 활용할 수는 없습니다.
DOM animation
CSS 또는 JavaScript를 사용해서 엘리먼트를 그리거나 이동하는 등 DOM Manipulation을 통해 애니메이션을 구현할 수 있습니다. 경우에 따라 Canvas로 그리는 것보다 더 부드러운 애니메이션을 얻을 수 있지만 이는 구현하는 브라우저마다 다릅니다.
Canvas API
Canvas는 Flash나 Java와 같은 플러그인을 사용하지 않고 웹 브라우저에서 개발자가 원하는 요소를 그릴 수 있도록 하기 위해 만들어졌습니다. 우리가 잘 아는 Apple에서 대시보드 위젯용으로 만들었고, 이후 모든 브라우저 벤더에서 채택하여 현재는 HTML5 공식 사양 중 하나가 되었습니다.
언제 Canvas API를 사용해야 할까요?
Canvas는 다른 방법보다 저수준으로 이미지를 제어하고, 메모리를 덜 차지하지만, 구현체를 만들기 위해 일반적으로 더 많은 코드를 작성하고 관리해야 합니다.
어도비 일러스트레이터 등을 사용한 벡터 기반의 기존 도형이 있는 경우 SVG를 사용합니다. 애니메이션을 적용하는 큰 정적 영역이 있거나, 3D 변형을 사용하는 경우 CSS 또는 DOM 애니메이션을 사용합니다.
차트, 그래프, 동적 다이어그램, 게임 등을 구현하기 위해선 캔버스는 좋은 선택이 될 수 있습니다. 또한 캔버스에서 벡터, 객체를 사용할 수 있도록 많은 라이브러리가 존재합니다. OpenGL을 사용한 WebGL도 캔버스를 활용하고 있습니다.
캔버스는 앞서 언급했듯이 이미 HTML 공식 사용이 된지 오래인지라, 브라우저 하위 호환성은 걱정하지 않아도 된다고 볼 수 있습니다.
같은 Canvas API에서도 사용하려는 스펙에 따라 호환성에 차이가 있을 수 있습니다.
Canvas 호환성 (출처: https://caniuse.com/canvas)
Canvas API를 사용한 이유
개발에 앞서 요구된 스펙과 해결해야 하는 문제를 정의하는 것이 가장 중요하다고 생각합니다. 서명 기능에 요구되는 스펙은 아래와 같이 정리할 수 있습니다.
- BottomSheet(또는 Drawer)에서 올라온 서명 패드에 유저가 자유롭게 자필 서명할 수 있어야 한다.
- 성능이 낮은 스마트폰에서도 충분히 작동 가능할 만큼 가벼운 성능이어야 한다.
- 유저가 식별 가능할 정도의 서명을 그렸는지 판단할 수 있어야 한다.
- 만들어진 서명의 여백을 잘라내고(cropping) 일관된 크기의 이미지 데이터로 리사이즈해서 서버에 보낼 수 있도록 한다.
위 내용을 다시 정리하면 아래와 같습니다.
- 인터랙션을 통해 그림 그리는 모듈
- 무거운 라이브러리 등에 의존하지 않는 library-agonstic
- 서명 유효성을 클라이언트 사이드에서 판단하기 위해 이미지를 분석할 수 있어야 한다.
- 크롭 및 리사이즈가 용이할 것
제가 아는 지식 내에선 Canvas API를 활용하는 방법이 가장 쉽고 빠를 것으로 생각되었습니다.
서명 모듈 만들기 ⚙️
요구된 기능 명세
- 입력된 정보를 서버에서 PDF로 구성하고, 청약서의 서명란에 고객의 자필 서명이 들어가야 한다.
- 고객이 자필 서명을 하기 전 제공된 정보와 서비스 모듈을 통해 취합된 정보를 토대로 작 성된 전자 청약 문서를 확인시켜야 한다.
- 전자 청약 문서의 정보가 이상이 없다면 클라이언트의 입력을 통해 서명 이미지가 합성된 전자 청약 문서 완성본을 확인시켜준다.
- 청약서가 정상적으로 작성되고, 처리될 수 있도록 유효한 서명을 정의하고, 정의된 기준에 부합하는 서명을 얻기 위해 유효성 검증이 필요했다.
- 전자 청약 문서는 별도의 URL이 아닌 암호화된 통신을 통해 직렬화된 binary 문자열 데이터를 Payload로부터 받아 화면에 렌더링하게 된다.
어떻게 만들었나?
- 클라이언트 단말의 터치를 통한 자필 서명 입력은 HTML 표준 스펙인 CANVAS API를 사용했다.
- CANVAS API에서 Pixel Manipulation을 위해 제공된 인터페이스를 사용하여 서명 이미지의 배경 대비 전경 비율을 알 수 있었고, 이로써 유효한 서명인지 판별할 수 있었다.
- 청약서 이미지를 HTTP 통신을 통해 binary로 받아 화면에 렌더링하는 과정은 prefetch, in-memory caching을 사용하기 위해 react query를 사용했고, 바이너리 형식의 데이터를 주소값으로 참조할 수 있도록 Blob (Binary Large Object) 자료형과 관련된 인터페이스를 사용했다.
- 청약서 이미지를 새로운 웹뷰 컨텍스트를 띄워 렌더링하고, 백그라운드 컨텍스트와 메시지 또는 데이터를 직렬화(Serialization) 및 Parsing 과정 없이 처리하기 위해 Broadcast Channel API를 사용했다.
서명 이미지 처리 프로세스와 사용된 Canvas API 스펙
1. 초기화
1-1. 컴포넌트 위계 및 Prop 설정
- canvas element와 참조할 값과 메소드를 활용할 수 있는 컨텍스트(getContext 반환 객체)를 heap에 저장합니다.
- 캔버스 요소의 기본 속성과 설정 값을 초기화합니다.
interface Props {
fullyOpened: boolean;
renderId: string;
placeholder?: string;
opened: boolean;
onSubmit: (encodedSignature: string) => void;
}
export default function SignPad({
placeholder,
renderId,
fullyOpened,
opened,
onSubmit,
}: Props) {
const heap = React.useRef(new Map());
const [isDirty, setIsDirty] = React.useState(false);
// ...
}
Props
placeholder
- 서명 패드가 그려지기 전까지 서명 영역에 나타날 안내 문구입니다.renderId
- 각 서명 패드 컴포넌트의 키 값으로 사용되는 고유한 UUID 입니다.fullyOpened
- 컨테이너가 화면으로 나타나는 트랜지션이 끝나는 타이밍을 알려주는 플래그입니다.opened
- 서명 패드가 화면에 나타난 상태인지 여부를 나타냅니다.onSubmit
- 유저가 서명을 마치고 Submit 이벤트를 발생시켰을때 호출될 핸들러입니다.
Context
heap
- useRef를 사용하여 서명 패드의 Canvas DOM 요소와 해당 요소의 컨텍스트(ctx)를 저장하기 위해 사용합니다. Canvas 요소를 useRef로 바로 참조하지 않고 Map 컬렉션을 사용하는 이유는 첫째, 캔버스 요소와 캔버스 요소의 컨텍스트를 같은 위계의 프로퍼티로 관리하고 참조하기 위함이며, 둘째, 서명이 리셋될 때 오염되는 것을 막기 위해 명시적으로 clear 메소드를 사용하기 위함입니다. get, set, has, clear 등 Map 자료형을 다루기 위한 메서드가 더 직관적이어 보여서 사용한 점도 있습니다.isDirty
- 캔버스에 사용자의 입력이 있었는지 여부를 나타냅니다. 단 한번의 터치라도 인식되면 false가 됩니다.setIsDirty
- isDirty의 값을 변화시키는 세터입니다.
{
// ...
React.useEffect(() => {
// (1)
if (!opened && isDirty) {
setIsDirty(false);
}
}, [isDirty, opened]);
React.useEffect(() => {
// (2)
heap.current.clear();
const dom = document.getElementById(renderId) as HTMLCanvasElement;
heap.current.set("node", dom);
heap.current.set("ctx", dom.getContext("2d"));
}, [placeholder, renderId]);
// ...
}
- 서명 모듈이 닫히고 다시 열릴때마다 유저의 입력 여부를 초기화합니다. (1)
- renderId가 바뀌어 새로운 컴포넌트로 렌더링 될 때 heap 객체를 초기화합니다. (2)
heap.current 속 Map 객체의 프로퍼티를 지워줍니다.
- 새로운 UUID를 부여받아 replace된 Canvas DOM 요소를 찾아 dom 변수에 할당합니다.
- Map 객체의 node 프로퍼티에 dom을 설정합니다.
- Map 객체의 ctx 프로퍼티에 생성하여 등록된 Canvas 요소의 Context를 저장합니다.
1-2. 캔버스 노드 및 컨텍스트
{
// ...
React.useEffect(() => {
// (1)
const node = heap.current.get("node") as HTMLCanvasElement;
const ctx = heap.current.get("ctx") as CanvasRenderingContext2D;
if (!node || !fullyOpened) return;
// (2)
const standardWidth = window.innerWidth - 40;
const standardHeight = 180;
const scale = 2;
// (3)
node.style.width = `${standardWidth}px`;
node.style.height = `${standardHeight}px`;
node.width = standardWidth * scale;
node.height = standardHeight * scale;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.lineWidth = 4;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// (4)
if (!isDirty) {
ctx.font = "36px Pretendard Arial";
ctx.fillStyle = "#3F4A56";
ctx.fillText(
placeholder,
(node.width - node.width / 2) / 2,
node.height / 2
);
}
// (5)
node.addEventListener("touchstart", () => {
if (!isDirty) {
setIsDirty(true);
}
});
node.addEventListener("touchend", () => {
ctx.stroke();
ctx.beginPath();
});
node.addEventListener("touchmove", draw(scale));
}, [draw, fullyOpened, isDirty, placeholder, renderId]);
// ...
}
-
heap 객체에 등록된 Canvas 요소와 해당 요소의 Canvas Context를 참조합니다.
-
캔버스의 너비와 높이를 지정하고, 작은 화면에서 높은 해상도의 이미지를 산출하기 위해
scale
은 2배수로 설정합니다. -
캔버스 요소의 기본적인 스타일을 정의합니다. 캔버스는 두 방식의 사이즈 속성을 가집니다. 첫째는 캔버스의 픽셀 크기를 결정하는 Attribute로써 HTMLCanvasElement.width/height를 사용하며 HTMLElement.style.width/height는 css 스타일로 나타낸 디스플레이 크기입니다. 이 속성들을 이용하면 작은 화면에서도 높은 해상도의 이미지를 얻어낼 수 있습니다.
node.style.with
,node.style.height
에 앞서 지정한 너비를 할당합니다.node.width
,node.height
는 캔버스에 표시되는 픽셀 단위로 디스플레이 크기의 2배수로 지정하여 더 많은 픽셀을 그려 높은 해상도의 서명 이미지를 얻도록 합니다.
-
유저가 서명 모듈에 터치를 시작할 때 까지 이곳에 서명을 해달라는 Placeholder의 스타일을 정의합니다. 유저가 서명 영역에 터치를 시작하면 Placeholder는 사라집니다.
-
캔버스 요소에 터치 이벤트를 바인딩합니다. 위 코드엔 표시되지 않았지만 바인딩 된 이벤트들은 모듈이 사용되지 않는 시점에 clean-up 함수 호출을 통해 바인딩을 해제합니다.
touchstart
- 유저가 서명 영역에 터치를 시작하면 isDirty 플래그를true
로 변환합니다.touchend
- 유저의 터치 이벤트가 끝나는 좌표까지 선을 그려주고CanvasRenderingContext2D.beginPath()
메소드로 서브 패스(sub-path)를 초기화해줍니다.touchmove
- 실제 유저의 터치 경로가 선으로 그려지는 함수draw()
를 호출하는 부분입니다.draw()
함 수는 픽셀 단위 대비 디스플레이 단위 비례를 나타내는 상수scale
을 인자로 받고 있습니다.
2. 입력
2-1. 서명 그리기
유저의 터치 이벤트에 바인딩 될 draw()
함수와 캔버스를 비워주는 clear()
함수로 입력을 제어합니다.
const draw = React.useCallback(
(scale: number) => {
return ({ targetTouches }: TouchEvent) => {
const ctx = heap.current.get("ctx") as CanvasRenderingContext2D;
const bound = (
heap.current.get("node") as HTMLCanvasElement
).getBoundingClientRect();
ctx.lineTo(
(targetTouches[0].clientX - bound.left) * scale,
(targetTouches[0].clientY - bound.top) * scale
);
ctx.stroke();
};
},
[renderId]
);
renderId
가 변할 때마다 컨텍스트가 바뀌기 때문에 renderId
를 dependency로 하는 useCallback 훅을 사용했습니다. 계산이 많은 무거운 로직이 아닌 경우 Memorization이 오히려 Overhead라는 의견이 분분하지만 이 주제는 다음에 다뤄보겠습니다.
draw()
함수의 역할을 간단히 정리하면 아래와 같습니다.
touchmove
이벤트로부터 넘겨받은 TouchEvent 이벤트 객체로부터 인터랙션을 통해 입력된 좌표를 구합니다. 저는TouchEvent.targetTouches
라는 내장 프로퍼티를 참조했습니다. 물론 픽셀 스케일로 보정값을 산출해주어야 합니다.ctx.lineTo()
내장 함수에 입력받은 x, y축 상대 좌표값을 전달하여 그려지는 서명 라인 sub-path의 마지막 포인트를 지정합니다.ctx.stroke()
내장 함수를 사용하여 2단계에서 지정된 좌표까지 선을 그려줍니다.touchmove
이벤트가 발생될 때마다 위 단계가 계속해서 반복됩니다. (touchmove
이벤트는 100ms당 약 5~6번 디스패칭 됩니다 - 후술)