Electron 에서 Color Picker 만들기

Electron 에서 Color Picker 만들기

두유노리모트? 두유노프로토파이? 두유노일렉트론? 지난 12월 프로토파이에 합류하고 3.10.0 버젼 릴리즈, 그리고 3.10.1 버젼 핫픽스를 앞 둔 시점에서 기억에 남는 개발 과정을 기록해본다.

Protopie Studio

프로토파이 스튜디오는 Electron 으로 개발된 데스크탑 어플리케이션으로 주로 디자이너들이 Sketch, Adobe XD, Figma 와 같은 UI 툴로 디자인을 한 후, 시연 혹은 개발 전달 목적으로 인터렉션을 입히는 툴이다. 국내 국외 가리지 않고 생각보다 많은 곳에서 쓰이고 있고, 툴의 완성도 또한 훌륭하다. 사실 면접 보러 갈때는 "아니 이게 되겠어~?" 하는 생각이 많았는데 들어고 보니 "이게 되네?" 로 바뀌었다.

프로토파이란 무엇인가? 두둥

뭐 이런 툴입니다.

Color Palette

시드의 프로토파이 스튜디오 파티로 합류한 뒤 첫번째 맡게 된 프로젝트가 Color Picker 를 개발하는 것이었다. UI 를 보고서는 "흠.. 할만하겠는데" 했고, 스펙을 보고서는 "흠.. 되겠지?" 했다. 여튼 아래와 같은 녀석을 만드는 것이 었고, 3.10.0 버젼 기준 아마도 버그 없이 잘 작동하고 있는 것 같다.

3.10.0 버전 부터 변경된 컬러 피커

프론트엔드 개발은 많이 했지만 이렇게 색상을 적극적으로 다루어 본적은 없었고, 게다가 이 툴은 디자이너 분들이 사용하는 툴이다. 어느정도의 버그야 수용범위 안에 들어갈 수 있겠지만, 그냥 동작하니까 됐지 하는 수준은 당연히 부족하다. 우선 대략적인 UI 를 구성해두고 각 항목에 대한 개발을 시작했다.

Hue Spectrum
Hue Spectrum

정확한 명칭을 잘 몰라서 나는 이걸 컬러 스펙트럼이라 부르고 있었다. (실제로는 Hue Spectrum) 이 스펙트럼은 왼쪽과 오른쪽 가장자리가 모두 빨간색이고, 무지개와 같은 색상 분포를 보인다. 아 이건 rgb(0, 0, 0) 부터 rgb(255, 255, 255) 를 표현한... 그럴리가 없잖아. 잘은 모르겠지만 뭔가 원색만을 모아놓은 것 같다. 좋았어. 하나도 이해하지 못했군. 시작부터 끝판왕이다.

사실 이러한 형태의 컬러피커는 HSB (혹은 HSV) 컬러 팔레트라고 불린다. 웹에서의 색상은 각각 0~255 의 값을 가지는 세 숫자인 RGB 와 이를 16진수로 변환한 HEX (혹은 RGB Hex) 를 기본으로 만들어진다. 그런데 왜 포토샵이나 대부분의 툴에서는 RGB 로 색상을 만들수도 있지만, 이런 팔레트는 HSB 를 기본으로 만들어질까?

Toss 의 플랫폼 디자이너 heeyeun 님의 설명을 들어보자.


어릴 때 접하는 미술 도구로부터 우리는 12색 사인펜의 모든 색을 다 섞으면 검은색이 된다는 것을 직감적으로 알고 있다. 이렇게 섞어서 검은색이 되는 것을 감산 혼합(減算 混合)이라고 한다. 반대로 빛은 아무 빛이 없을 때가 검은색이며 모든 빛을 더하면 흰색이 된다. 이를 가산 혼합(加算 混合)이라고 한다.

  • 빛의 삼원색 = 가산 혼합의 삼원색 = Red, Green, Blue = RGB
  • 색의 삼원색 = 감산 혼합의 삼원색 = Cyan, Magenta, Yellow = CMY

그러므로 8 bits 체계에서 왜 검은색이 (0, 0, 0)이고 흰색이 (255, 255, 255)인지 알 수 있다. 삼원색이 하나도 없는 상태가 검은색이고, 삼원색이 모두 최대로 있는 상태가 흰색이기 때문이다.

그렇다면 아래 RGB 값들은 색으로 표현하면 무슨 색일까?

  1. RGB(255, 0, 0)
  2. RGB(0, 255, 0)
  3. RGB(0, 0, 255)
  4. RGB(23, 185, 79)

HSB의 탄생

1, 2, 3번은 쉽게 답을 맞혔을 것이다. 1번은 Red가 최대고, Green과 Blue는 없는 상태이므로 당연히 빨간색일 것이다. 2, 3번도 같은 식으로 각각 초록, 파랑이라고 답할 수 있다. 반면 4번은 무슨 색인지 유추해내기 어렵다. 이렇게 RGB 값은 직관적으로 이해하기 어려우므로 HSB라는 호환체계가 1970년경에 고안되었다. 그럼 다시 4번 문제로 돌아가서 RGB(23, 185, 79)를 HSB로 변환해 보면 HSB(141, 88, 73)이다. 바로 명 채도가 약간 낮은 초록색이라는 것을 알 수 있다.

heeyeun | RGB와 HSB 에서 발췌


즉 실제 표현은 RGB 형태로 표현되겠지만 이는 색상을 선택할때 직관적이지 못하고 따라서 HSB 와 같은 방식이 만들어졌다는 것이다. 좋았어. 그러면 저 스펙트럼을 실제로 어떻게 표현해야 할까?

Hue
Hue Color Wheel

HSB 는 RGB 의 단점을 보완하기 위해 만들어진 것이다. 때문에 Hue 또한 0˚ 인  Red, 120˚ Green, 240˚ 를 Blue 를 기반으로 만들어졌다. 그래서 60˚ 는 R과 G 값이 맥스인 #FFFF00 인 Yellow 180˚ 는 #00FFFF 인 Cyan, 270˚ 는 #FF00FF 인 Magenta 가 된다. 그러면 30˚ 는 #FF8000 이 되고 90˚ 는 #80FF00 이 각각 된다. 이렇게 한 이유는 빛의 삼원색을 생각하면 당연한 것인데 두 가지 색상씩 페어로 조절하지 않으면 백색이나 검정색이 되어버린다. 뭔소린지

Hue Color Wheel Visualization

이를 좀 더 알기쉽게 그래프 형태로 표시하면 각각의 삼원색 RGB 가 위 그림과 같은 형태를 띄면서 순환한다. 그럼 이것을 어떻게 linear gradient 상으로 표시하느냐 하면,

.hsb {
  width: 100%;
  height: 3rem;
  background: linear-gradient(
    to right, 
    #FF0000 0%,
    #00FF00 33%,
    #0000FF 66%,
    #FF0000 100%
  )
}

우선 각 중심 값을 linear-gradient 로 표시해준다. 그러면 다음과 같은 형태가 나온다.

Linear Gradient 로 기본 스펙트럼을 표시해본 것

뭔가 비슷하긴 한데 다르다. 이것은 0˚ 와 120˚ 의 중간 값이 R 과 G 가 함께 변하기 때문에 yellow, cyan, magenta 가 되어야 하는 부분이 다르게 표시되기 때문이다. Hue 의 목표는 원색을 표시하는 것이 목적인데, RGB 가 함께 변해버리면 채도와 명도가 함께 달라져 버린다.

개발자 포지션이라고 했잖아!
빛의 삼원색

즉 아래와 같이 표시하는 것이 명도와 채도의 변화없이 원색만을 정확하게 렌더링 할 수 있다.

.hsb {
  width: 100%;
  height: 3rem;
  background: linear-gradient(
    to right, 
    #FF0000 0%,
    #FFFF00 16%,
    #00FF00 33%,
    #00FFFF 50%,
    #0000FF 66%,
    #FF00FF 82%,
    #FF0000 100%
  )
}

결과는?

제대로 렌더링된 Hue

0˚ 에서 360˚ 까지 제대로 된 색상의 분포를 보인다. 이제 Hue 를 만들었다.

Saturation & Brightness

HSB 의 S 와 B 는 비교적 간단하다. Saturation 은 채도로 얼마나 원색에 가깝게 표시하는 지를 의미한다. Brightness 는 명도로 밝기를 뜻한다.

왼쪽으로 갈수록 채도가, 아래로 갈 수록 명도가 낮아진다.

Hue 에 해당하는 색상을 우선 background-color 로 지정한다.

<div class="hue"></div>

<style>
.hue {
  position: relative;
  background-color: #F00;
  height: 20rem;
}
</style>
시뻘건레드

이 위에  Saturation 에 해당하는 레이어를 표시한다.  Saturation 은 색의 재현도를 뜻함으로 흰색으로 블렌딩 해준다.

<div class="hue">
  <div class="saturation"></div>
</div>

<style>
...
.saturation {
  position: absolute;
  width: 100%;
  height: 100%;
  background: linear-gradient(
    to left,
    #FFF0 0%,
    #FFFF 100%
  )
}
</style>
레드에 Saturation 표현을 위해 흰색을 블랜딩 한 상태

Brightness 는 명도 즉 밝기를, 어두움의 정도를  뜻함으로 검정색을 블렌딩 해준다. 단 오른쪽에서 왼쪽인 가로방향으로는 이미 채도가 적용되어 있으니, 위에서 아래로 새로방향으로 밝기를 적용해준다.

<div class="hue">
  <div class="brightness"></div>
</div>

<style>
.brightness {
  position: absolute;
  width: 100%;
  height: 100%;
  background: linear-gradient(
    to bottom,
    #0000 0%,
    #000F 100%
  )
}
</style>
Brightness 는 검정색을 그라디언트로 블랜딩 해준다.

각각 S 와 B 가 적용된 두 색상을 적용한다. 하지만 우리는 실제로 색상에 채도와 밝기를 적용한 것이 아니므로 Saturation 을 먼저 적용하고 이 위에 Brightness 를 적용해야 제대로 색상이 적용된다. 반대로 순서로 div 를 배치하면 흰색이 나중에 div 에 채워지기 때문에 Brightness 가 무시되는 효과가 나타난다.

<div class="hue">
  <div class="saturation"></div>
  <div class="brightness"></div>
</div>
이것이 HSB 컬러 피커

이제 우리가 흔히 볼 수 있는 그것이 나타났다. 그러면 이 값을 어떻게 해석해야 할까?

오른쪽 최상단 모서리는 Hue 가 0˚ Saturation 이 100%, Brightness 가 100% 이다.  계산은 귀찮으니 chroma-js 라이브러리를 써서 검증해보자.

const chroma = require('chroma-js')
console.log(chroma.hsv(0, 100, 100).hex());

> '#ff0000'

왼쪽 최상단 모서리는 Hue 가 Hue 가 0˚ Saturation 이 0%, Brightness 가 100% 이다.  측 채도가 없기 때문에, 그냥 흰색이 되어버린다. (투명이 아니다.) 그리고 Brightness 가 0% 인 피커의 하단은 당연히 모두 검정색이다. 계산식은 Hue 의 각도에 따라 RGB 값을 각각 계산해줘야 하기 때문에, 다소 복잡한데 이를 적기에는 여백이 부족하여 링크로 대신한다. 아는데 안하는거임 진짜임

Opacity

나머지는 비교적 간단한데 Opacity 의 경우 linear-gradient 를 활용해  투명도의 변화로 표시해준다.

.opacity {
  height: 3rem;
  background: linear-gradient(
    to right,
    #F000 0%,
    #F00F 100%
  )
}
그냥 두면 허여멀건해 지기 때문에 SVG 를 깔아줬다

물론 실제 스튜디오에서는 투명인지 구분을 하기 어려우니 svg 로 투명격자(?) 를 만들어서 배경에 깔아주었다. 나머지 UI 구현은 그냥 만들면 되는거니 생략한다. 아니 개발 블로그 라더니 색 이론만 수십줄...

소스코드 - HSB

Color Picker (Spoid)

Color Picker 역시 여러가지 명칭으로 불린다. 스포이드라고 하기도 하고, Color Picker 라고 하기도 하고, 여기에서는 Color Picker 로 통일하겠다.

Color Picker

처음에 이걸 보고 음 오케이 다른 애들도 다들 만들었으니까 나도 어떻게든 할 수 있겠지. 라는 생각으로 접근했다. 우선 크롬에 설치가능한 익스텐션들의 소스코드를 살펴보았다. 브라우저 상의 한 점의 색상을 알기 위해서는 무엇을 해야할까? 아니 그게 가능하긴 한가? 이런 생각을 하며 리서치를 시작했다.

오픈소스 컬러 피커들을 확인한 고난의 행군 결과 브라우저 자체에서 한 점의 색상을 알 수 있는 방법은 없으나, canvas element 상에서는 가능하다는 사실을 알아내었다. 고마워요 오픈소서들... 왜 다른 태그에서는 안되고 canvas 에서는 가능할까? 모든 종류의 차별에 반대합니다 어떻게보면 당연한 것인데 canvas 는 그림을 그리기 위한 dom element 이기 때문이다. 예를 들어 div 는 0x0 위치에 색상을 표현할 수 있는 방법이 없지만, canvas 는 2d context 를 만들어 drawing 하는 것이 가능하다.

<canvas id="canvas" width="500" height="500"> </canvas>

<script type="text/javascript>
	const cv = document.querySelector("#canvas");
	const ctx = cv.getContext("2d");
	ctx.fillStyle = "red";
	ctx.fillRect(10, 10, 1, 1);
</script>

그렇다면 저 위치의 pixel 의 색상을 얻는 방법이 있을까? 있다.

console.log(ctx.getImageData(10, 10, 1, 1));
> ImageData {data:Uint8ClampedArray, constructor:Object}

오케이 많이 왔어. 그런데.. 저 data 는 무엇일까?

data {
  0: 255,
  1: 0,
  2: 0,
  3: 255,
}

어라 뭔가 형태가 RGBA 같은데? 실제로 red 를 fillStyle 로 넣어줬으니 rgba(255, 0, 0, 255) 로 확인해보면 빨간색이 많다. 흠, Uint8ClampedArray 는 일단 넘어가도록 하자. MDN 에서 CanvasRenderingContext2D.getImageData  문서를 읽어보면 답이 나온다.

Return Value
An [ImageData](https://developer.mozilla.org/en-US/docs/Web/API/ImageData) object containing the image data for the rectangle of the canvas specified. The coordinates of the rectangle's top-left corner are(sx, sy), while the coordinates of the bottom corner are(sx + sw, sy + sh).

설명이 뭔소린지 모르겠다. 그냥 코드로 찍어보자.

const cv = document.querySelector("#canvas");
const ctx = cv.getContext("2d");
ctx.fillStyle = "rgba(255, 0, 0, 255)";
ctx.fillRect(10, 10, 1, 1);
ctx.fillStyle = "rgba(0, 0, 255, 255)";
ctx.fillRect(11, 10, 1, 1);
ctx.fillStyle = "rgba(0, 255, 0, 255)";
ctx.fillRect(10, 11, 1, 1);

위 코드를 이용해 좁쌀보다도 작은 1px 짜리 점을 아래 처럼 그린다.

ctx.getImageData(10, 10, 2, 2);

x 10, y 10 부터 2px 씩 이미지 데이터를 가져와, 결과를 확인해본다.

data {
  0: 255
  1: 0
  2: 0
  3: 255
  4: 0
  5: 0
  6: 255
  7: 255
  8: 0
  9: 255
  10: 0
  11: 255
  12: 0
  13: 0
  14: 0
  15: 0
}

data 에는 총 16 개의 숫자가 나오는데 보기 좋게 3차원 array 형태로 고쳐보면 다음과 같다.

[
  [[255, 0, 0, 255], [0, 0, 255, 255]],
  [[0, 255, 0, 255], [0, 0, 0, 0]]
],

getImageData API 는 X,Y 지점에서부터 오른쪽으로 1px 씩 옮겨가며 해당하는 지점의 rgba 데이터를 1차원 배열의 형태로 리턴하는 것을 알게 된다.

HTML Standard 문서를 확인하면

If source is not specified, then initialize the data attribute of imageData to a new Uint8ClampedArray object. The Uint8ClampedArray object must use a new Canvas Pixel ArrayBuffer for its storage, and must have a zero start offset and a length equal to the length of its storage, in bytes. The Canvas Pixel ArrayBuffer must have the correct size to store rows × pixelsPerRow pixels.

getImageData 의 리턴 타입은 Canvas Pixel ArrayBuffer 이고 컴퓨터 그래픽스 쪽에 지식이 부족하다보니 이유는 정확히 알 수 없으나, array of array 보다 계산하고 저장하기 수월하여 이런한 형식을 취하지 않았나 싶다. 어쨋든 canvas 위에 그려진 녀석들은 pixel data 는 읽을 수 있다는 것을 확인할 수 있었다.

오케이 쉽네! 좋았어!

왜죠? 왜인거죠? 왜 div 죠?

좋았어 그러면 이 dom 을 이미지로 바꾸면 되겠다. html2canvas 라는 라이브러리를 사용해서 dom 을 이미지로 만들고 이걸 다시 canvas 의 2d context 에 그리고 마우스가 가리키는 포지션의 pixel 데이터를 가져오면 되겠군! 잘된다! ... 아? html2canvas 라이브러리에도 문제가 있었으니 canvas 와 svg 를 포함하고 있는 div 는 제대로 캡쳐하지 못했다. canvas 내부의 svg 들을 병합해면 되긴 하는데.. .

이 지점에서 뛰쳐나가고 싶었다. "죄송합니다. 저 그냥 집에 갈게요... 월급은 안주셔도 됩니다." 이 말이 목구멍 까지 왔다가, 그래 어차피 망한거 하루만 더 해보자.. 하루만..

Electron 그래 이건 일렉트론 이잖아? 쳉쟈오 씨가 뭔가 해두지 않았을까?  수많은 삽질 끝에 electron 에는 renderer 의 window 에서 화면을 캡쳐할 수 있는 API 가 있다는 것을 알아내었다. 빙고! BrowserWindow.webContents.capturePage API 를 사용하면 되겠구나.

capturePage API 는 비동기 함수지만 renderer 가 블락되는 약간의 문제가 있지만, 심각할 정도는 아니었다. 전체 화면을 캡쳐하며 이를 canvas 의 2dcontext 로 만들고 화면의  pageX 와 pageY 에 해당하는 좌표의 pixel 데이터를 읽어 색을 재현하는 방식으로 구현하니, 보기 좋다 하였다.

행복. 끝.

일 줄 알았다.

소스코드 - CANVAS

Mosaic

컬러피커 모자이크 (?)

컬러 팔레트의 마지막 스펙 모자이크. 이것도 딱히 명칭이 없어서 내맘대로 모자이크라고 명명했다. 그렇게 생겼으니까. 어쨋든 저 모자이크의 기능은 마우스가 가리키는 지점의 색 을 정확하게 지정할 수 있도록 확대하여 보여주는 것이다. 그래서 유저가 작은 픽셀의 색이라도 놓치지 않고, 특히 기본적으로 브라우저가 안티 얼라이징 시키는 부분의 색을 피하여 정확하게 색을 선택하게 해주는 기능을 한다.

처음엔 역시 캡쳐 부분을 확대해서 보여줘야 하나 하는 생각을 했다. 그런데 그럼 몇퍼센트로 확대를 해야 하지? 그러면 옆 픽셀들이 제대로 표현이 될까?

사실 이 부분은 크게 고민하지 않았고, 마우스 포인터가 가리키는 지점의 위 아래 를 한꺼번에 가져와서 마치 확대한 것처럼 보이되, 실제로는 픽셀들을 재현하는 방식이 훨씬 효율적이라고 생각했다. 딱히 어려운 내용은 아니니 패스한다.

이러한 UI 를 만들 떄 주의할 점 중 하나가 마우스 포인터를 따라다니게 하는 것이다. 위의 영상에서도 볼 수 있지만, 프로토파이 스튜디오 만의 독특한 점이 있는데, 마우스 옵션 키 (윈도우에서는 알트) 를 누르면 모자이크가 지워지고 실제 밑 화면을 볼수 있다는 것이다. 이것도 단순하게 알트를 눌렀을때의 상태를 기억해서 마우스 포인터를 crosshair 로 바꾸었다.

그런데 기본적으로 맥은 (윈도우도 비슷할 것 같다) 부드러운 렌더링을 위해 잔상 효과를 사용하고 있는데 때문에 crosshair 를 중심으로 마우스가 꿀렁거리는 효과가 나타나 버렸다. (말로 설명하긴 어렵지만, 뭔가 이상하게 흔들리는 느낌이었다.) 때문에 이역시 cursor: crosshair; 를 사용하지 않고, svg 로 화면에 그려준느 방식으로 다시 만들었다.

원 모어 띵

이러한 복잡한 절차를 거쳐 컬러 팔레트를 개발하고 테스트를 하는 중이었다. 그때 또다시 알 수 없는 버그가 발생했다. 이름하야 COLOR PROFILE.

다 잘되었다. 캡쳐도 잘되고~ UI 도 미려하고~ (헤헷), 버그도 팡팡 터져나오고 약간의 렉은 있지만 무시할 수 있는 수준이었다. 그런데 붙여 넣은 이미지를 캡쳐하면 모니터에 따라 분명히 같은 색상을 찍었는데 모니터 color profile 을 바꿈에 따라 spoid 로 찍은 값이 달라졌다.

그래 이제 집에 가자. 나는 최선을 다했어. 이제 괜찮아.


정말 다양한 리서치 와 삽질 끝에 컬러 프로파일 문제라는 걸 찾아냈고 다행히도 electron 은 옵션을 통해 강제로 프로필을 고정할 수 있었다.

app.commandLine.appendSwitch('force-color-profile', 'srgb');

원인은 크롬의 경우 sRGB 프로파일이 아닌 경우에 렌더링 시 원본 색상을 sRGB 로 변환하기 때문이다. 이쯤 되니까 멘탈이 너덜너덜 해져서 정확하게 언급되어 있는 문서 링크도 기억이 잘 나지 않는다. 어쨋든 문제는 크로미움의 컬러 프로파일 핸들링 방식 때문 이었고, 강제로 sRGB 로 맞추어 주자 해결되었다.

후기

이 당시에 전 직장에서 좋지 않은 형태로 퇴사한 후유증이 남아있었고 때문에 적응하는데 애를 먹었었다. 컬러 팔레트를 개발하면서 더 힘들었던 점도 아마 이러한 부분이 작용하지 않은가 싶다. 여전히 사고를 치지만 벌써 프로토파이에서도 5개월 정도가 지났고, 아직도 적응하는 과정이라고 생각하지만 어쨋든 최고의 팀과 좋은 생활을 이어나가고 있다.

레퍼런스

스튜디오 씨드 채용 공