YH

/

디바이스와 스케일러블 UI

들어가며

이전 회사에서 작업을 하던 도중 모바일 디바이스와 관련된 수정사항을 요청 받았다. 캔버스를 사용한 애니메이션의 해상도가 데스크탑 디스플레이와 다르게 너무 낮게 렌더링 되고 있다는 것이었다.

문제를 해결하고 나니 이전에는 생각해보지 못했던 사항이 원인이었다. 그래서 이에 대해 사내에 정리했던 사실을 바탕으로 블로그에도 한 번 정리하고 가면 좋겠다는 생각이 들었다.

문제?

처음에 문제를 접했을 때, 도대체 어떤 부분에서 시작해야할지 가늠이 안왔다. 코드 자체에 문제가 있는걸까? 아니면 특정 디바이스 혹은 브라우저에서만 나타나는 특이사항인가? 리소스 자체에 문제가 있는건가?

하나 하나 테스트를 해나가다보니 모바일 디바이스라는 점을 제외하면 특별하게 문제점을 찾을 수 없었다.

문제가 발생했던 첫 부분을 짚고 넘어가면 실마리가 보이지 않을까라는 생각에 해당 단어에 대해서 생각해보기로 했다.

렌더링이 흐리게 보이는 이유가 뭘까? 해상도가 낮다는 의미가 뭘까?

해상도

해상도라는 의미에 대해서 조금 정확하게 짚고 넘어가야 할 필요가 있었다. 디스플레이 같이 하드웨어 지식이 부족했던 나로써는, 해상도라는 의미가 디스플레이의 가로 * 세로 의 총 픽셀 수를 의미하는 말이라고만 막연하게 생각하고 있었다.

그렇다면 문제가되는 모바일 디바이스에서만 이런 상황이 발생하는 현상이, 모바일 디바이스에서 사용된 픽셀의 수가 적어서 나타나는 현상이라고 생각해 디스플레이에 대한 정보를 알아보았다.

ppi example

하지만 실제로 검색을 통해 알아봐도, 아이폰 16pro 기준 거의 QHD (2560 * 1440) 에 근접하는 디스플레이 해상도를 가지고 있었기 때문에 원하는 답을 얻을 수는 없었다.

그런데 익숙하지 않은 한 단어 (PPI) 가 모바일 해상도를 정의하는 데 따라오는 걸 발견할 수 있었다.

PPI (Pixel Per Inch)

PPI 라는 것은 Pixel Per Inch 를 줄인말로, 디스플레이의 1인치 면적당 얼마나 많은 픽셀이 밀집해 있느냐에 대한 말을 의미한다.

이를 기반으로 해상도에 대해 사전적 정의를 찾아보니 의미가 명확해 지기 시작했다.

해상도 (resolution)

하나의 이미지 안에 몇 개의 픽셀을 포함하는가에 대한 척도로 전체 픽셀의 수. 단위로는 dpi(dot per inch)를 사용한다. 예를 들어 150dpi일 경우 가로, 세로 1인치 안에 150개의 픽셀이 들어 있다. 따라서 1인치 안에 들어가는 픽셀의 수가 많으면 많을수록 선명한 이미지의 고화질을 구현할 수 있다.

[네이버 지식백과] 해상도 [resolution, 解像度] (색채용어사전, 2007., 박연선, 국립국어원)

엄밀히 말하면 DPI 는 인쇄물에 PPI 가 디스플레이에 사용되는 용어이기 때문에, PPI 에 따라 총 픽셀 수는 비슷하더라도 그래픽 품질이 다르게 나타날 수 있다는 것이었다.

즉, 해상도가 낮다는 문제 상황의 의미는 이렇게 바꿔서 정의할 수 있었다.

"디스플레이 상에서 그래픽을 렌더링 하는데 사용된 픽셀의 수(PPI)가 부족해 선명하지 않게 보인다."

기기별 PPI

이에 대해서 기기간 PPI 에 대한 정보를 찾아다보니 다음과 같은 자료를 찾을 수 있었다.

device example

각각의 개별적인 값들은 다를 수 있겠지만, 대개 디스플레이의 사이즈가 작아질 수록 PPI 값이 커지는 것을 확인할 수 있다.

일반적인 상황에서 디스플레이가 큰 데스크탑 모니터 같은 경우 사이즈가 크기 때문에 픽셀의 수를 압축할 이유가 크게 없지만, 모바일 디스플레이 같은 경우 작은 사이즈에 비슷한 픽셀 수를 유지하기 위해서는 높은 압축률을 사용해야 하는 것이다.



어떻게 접근할까?

해상도에 대한 의미와 문제 해결에 대한 단서는 얻었으나, 확실하게 문제상황과 연결시킬 핵심은 없었다. PPI 가 높다는 게 브라우저와 그리고 캔버스 요소에 어떤 영향을 미치기 때문에 문제상황이 발생한 것일까?

이에 대해서 좀 더 알아보아야 명확한 해결 방법을 생각해낼 수 있을 것이다.

브라우저와 캔버스

브라우저가 화면을 렌더링하고 스타일을 페인팅하는데에 기준이되는 단위도 역시 같은 px(픽셀)이다.

그런데 브라우저와 개발자가 사용하는 px 이 정확하게 디스플레이에 표현되는 물리적인 픽셀과 일치하는가에 대해서 생각해보아야한다.

웹 브라우저가 UI를 렌더링 하는 기준으로 사용하는 값은 물리적인 디바이스의 픽셀 값이 아니라 CSS 언어 스펙상에 정의된 단위인 1px 을 의미한다.

W3C CSS Values and Units Module Level 4 - px Unit 이 표준에 따르면, px 단위는 참조 픽셀(reference pixel)이라는 개념을 기반으로 정의된다.

이는 사용자에게 일관된 시각적 크기를 제공하기 위한 것으로, 특정 조건(예: 팔 길이에서 96dpi 디스플레이를 보는 경우)에서 1 물리적 픽셀에 대응할 수 있다고 설명하고 있다.

그런데 이 추상적인 개념의 CSS 픽셀단위가 실제 물리적인 픽셀 값에 매칭되기 위해서는 하나의 기준이 필요하다.

왜냐하면 PPI 값에 따라서, CSS 픽셀 하나가 차지해야하는 크기에 차이가 발생할 수 있기 때문이다.

Device Pixel Ratio

브라우저는 내부적으로 Device Pixel Ratio 라는 값을 통해 이 매칭을 해결하고 있다.

데스크탑 디스플레이 모니터와 같이 물리적으로 큰 환경에서는 같은 크기의 CSS 1px 을 표현하는데 물리적인 1 픽셀 정도에 매핑되고, 이보다 압축률이 높은 디스플레이의 경우 더 큰 픽셀을 소모하도록 표시하고 있는 것이다.

WebAPI 를 통해 현재 사용자가 브라우저를 사용하고 있는 디바이스의 DPR 정보를 확인할 수 있도록 값을 제공하고 있다.

다음과 같은 코드를 통해서 현재 내 디바이스의 DPR 를 확인해볼 수 있다.

if(!window.devicePixelRatio) {
  console.log('브라우저 환경이 아니거나, 지원되지 않는 환경입니다.')
}  else {
  console.log(`현재 디바이스의 DPR 은  ${window.devicePixelRatio} 입니다.`)
}
 
// 예상 출력 (브라우저 및 디바이스 환경에 따라 다름):
// 현재 디바이스의 DPR은 1 입니다. (일반적인 데스크탑 모니터의 경우)
// 현재 디바이스의 DPR은 2 혹은 3 입니다. (고PPI 모바일 디스플레이의 경우)
 

캔버스와 DPR

우리는 이러한 DPR 과 관련된 렌더링의 차이를 전혀 느끼지 못하고 스타일을 작성하고 웹페이지를 사용하고 있다. 왜냐하면 브라우저가 알아서 이러한 디스플레이에 따른 렌더링의 차이를 처리해주고 있기 때문이다.

그러나 <canvas> 를 통해서 화면을 렌더링 하기 위해서 Context 를 생성할때에는 이야기가 달라진다. <canvas>는 단순히 HTML에 존재하는 시각적 요소가 아니라, 개발자가 직접 픽셀 단위로 그림을 그릴 수 있는 '비트맵 캔버스' 또는 '드로잉 버퍼'의 역할을 수행하기 때문이다.

여기서 문제가 발생한다.

canvas의 width와 height 속성값이 명시적으로 설정되지 않거나, 단순히 width: 300px; height: 150px;로만 크기가 지정될 경우, 캔버스 내부 버퍼의 기본 해상도는 CSS 픽셀 크기에 맞춰지며, 이때 DPR은 기본적으로 고려되지 않는다.


예를 들어, CSS로 width: 300px; height: 150px;로 설정된 <canvas>가 있다고 가정해보자.

  • DPR이 1인 데스크탑 환경: 캔버스의 CSS 크기는 300 * 150 CSS 픽셀이고, 내부 버퍼도 300 * 150 물리 픽셀로 생성된다. 브라우저는 1:1로 매핑하여 선명하게 렌더링한다.
  • DPR이 2인 모바일 환경: 캔버스의 CSS 크기는 여전히 300x150 CSS 픽셀이다. 하지만 이 CSS 픽셀 공간은 실제 디스플레이에서 600 * 300 물리 픽셀 공간을 차지하게 된다. 문제는 캔버스 내부 버퍼는 여전히 300 * 150 물리 픽셀이라는 것이다.

결과적으로, 300x150 해상도의 이미지를 600x300 해상도의 공간에 강제로 확대하여 표시하게 된다. 이것은 우리가 저해상도 사진을 크게 확대했을 때 흐릿하게 보이는 현상과 정확히 일치한다.


문제를 해결하기

좋다. 문제 상황이 확실해졌다. 우리가 해결해야하는 문제는 다시 이렇게 정의 될 수 있다.

캔버스 애니메이션의 Context 가 DPR이 고려되지 않은채 설정되어 있기 때문에, 렌더링의 해상도가 떨어져 보인다.

해결해야할 부분이 확실해 졌기 때문에 이를 반영하는 부분은 어렵지 않다.

DPR 을 Context 에 반영하기

앞서 설명했듯이, DPR 은 물리적 픽셀을 얼마나 차지하게 만들어야하는가에 대한 값을 말한다.

그리고 우리는 이 DPR 값을 손쉽게 window.devicePixelRatio 를 통해 얻을 수 있다는 것도 안다.

window.devicePixelRatio API 는 다행히도 거의 baseline 에 가까운 크로스브라우징 지원률을 보여주고 있으니 곧바로 사용해도 큰 문제는 없다.

그러면 생성한 <canvas> 요소에 어떻게 이 값을 적용해야 하는 걸까? MDN 문서 에 이 구체적인 사항에 대해 설명하고 있는 부분이 있다.

요약하자면, Context의 API 에는 간단하게 스케일 값을 적용할 수 있는 메소드가 있다. 이를 통해 우리가 구한 DPR 값을 할당해 <canvas> 를 디바이스의 PPI 에 매칭할 수 있다.

물론 우리가 드로잉 하고자 하는 요소에 대해서도 이 DPR 값을 적용해주어야 한다.

스케일팩터

이 과정을 조금 큰 그림에서 볼 때, DPR 을 하나의 값으로 일관되게 관리하면 좋을 것 같다.

이 값을 스케일팩터(scale) 로 설정한 뒤 코드에 적용해보자.

다음은 이 과정을 예시로 간단하게 작성한 부분이다.

 
// 예시로 고정
const CANVAS_CSS_WIDTH = 400; 
const CANVAS_CSS_HEIGHT = 300; 
 
class MyCanvas {
  constructor(id, scale) {
    this.canvas = document.getElementById(id);
    this.ctx = this.canvas.getContext("2d");
    this.scale = scale;
  }
 
  init() {
    // 캔버스의 CSS 픽셀 크기를 설정 (HTML에서 명시적으로 지정되지 않았을 경우)
    this.canvas.style.width = `${CANVAS_CSS_WIDTH}px`;
    this.canvas.style.height = `${CANVAS_CSS_HEIGHT}px`;
 
    // 캔버스 내부의 실제 해상도(물리 픽셀)를 DPR에 맞춰 설정
    this.canvas.width = CANVAS_CSS_WIDTH * this.scale;
    this.canvas.height = CANVAS_CSS_HEIGHT * this.scale;
 
    this.ctx.scale(this.scale, this.scale);
    return this; // 체이닝을 위해 this 반환
  }
 
  draw() {
    // 초기화된 캔버스에 내용 그리기
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // 기존 내용 지우기
 
    // 예시 2: 사각형 그리기
    this.ctx.fillStyle = "red";
    this.ctx.fillRect(50, 80, 150, 100); // 50,80에 CSS 픽셀 150x100 크기 사각형
  }
}
 
const scale = window.devicePixelRatio;
const myCanvas = new MyCanvas("canvas", scale);
 
myCanvas.init().draw();

이렇게 scale 메소드를 통해서 좌표를 확장하고 캔버스의 크기를 늘려주게 되면, CSS px 값을 기준으로 디바이스 에 적절하도록 자동으로 렌더링을 적용할 수 있게 된다.

GOTCHA

이 부분은 사실 내가 회사 코드를 직접 수정하면서 겪었던 문제였긴 하지만, 혹시 몰라서 적어 본다.

스케일링을 적용할 때, 한 가지 주의해야할 점은 드로잉하고 있는 요소의 사이즈가 이미 스케일링 되어있는 상태인지 한 번 더 생각해보아야 한다.

이렇게 될 경우 더블 스케일링이 일어나 scale 메소드를 적용 하게 되면 요소가 갑자기 커져버리거나 위치를 잃어버리는 현상이 일어나게 된다.

나는 기존의 코드베이스에서 드로잉 값들이 스케일 과정을 거치지 않고 단순히 px 값들을 늘려서 해결하고 있었던 상황이라 해상도 문제는 그대로 발생하고, 스케일링을 적용하니 사이즈가 갑자기 튀어버렸었다.

그래서 이를 해결하기 위해 값들을 일일히 나누어주는 과정을 거쳐야했었다.


마치며

최근에 읽기 시작한 UI 시스템 블랙북 에서 해당 부분을 다룬 챕터가 있어 무언가 반가운 기분이 들었다.

UI 그래픽스와 렌더링 과정을 보다 낮은 레벨에서 설명해주는 이 책에서는 평소에 생각해보지 못한 UI 들의 렌더링 과정과 고려해야할 사항들에 대해 설명해주는데, 이렇게 디바이스의 PPI 부분 등 단순히 CSS 값 만이 아니라 디바이스 환경 자체에 적응하고 레이아웃을 조절할 수 있는 UI 를 스케일러블 UI 라고 말하고 있다.

웹 브라우저의 도움과 많은 부분에 대해서 모든 부분을 당연하게 생각하고 있었고, 앞으로는 웹과 브라우저에 종속되지 않고 한 단계 더 깊은 고민을 하도록 노력하자는 반성을 하며 마친다.