사툰사툰

내 컴포넌트가 갑자기 깨져 보여요 : SubPixel Rendering 이슈 해결하기
FRONTEND

내 컴포넌트가 갑자기 깨져 보여요 : SubPixel Rendering 이슈 해결하기

서브픽셀 렌더링 이슈로 인해 컴포넌트가 깨져 보이는 문제를 해결하는 방법을 다룹니다. 다양한 브라우저의 렌더링 방식과 CSS 최적화 기법을 통해 예측 가능한 결과를 얻는 방법을 설명합니다.

2025. 6. 30.

97

🚀 Overview

컴포넌트를 구현하다 보면 "내 컴퓨터에서는 발생하지 않는데 왜 특정 사용자에게는 깨져서 보일까?" 라는 상황을 종종 마주합니다. CSS를 아무리 뜯어봐도 마땅한 이유를 찾을 수 없어 결국 "브라우저 버그겠지"라며 넘어가거나, 급한 기능 개발에 밀려 "나중에 해결하자"고 미루곤 했습니다.

“선택된 옵션”의 Indicator가 묘하게 중앙 정렬이 맞지 않는다
“선택된 옵션”의 Indicator가 묘하게 중앙 정렬이 맞지 않는다

하지만 최근 Radix UI 기반의 RadioGroup 컴포넌트를 개발하던 중 특정 사용자 환경에서만 눈에 띠게 라디오 버튼의 내부 Indicator가 흐릿하게 보이거나 중앙에서 미묘하게 벗어나는 현상을 발견하였습니다. 도대체 왜 발생하는지 답답한 나머지 시간을 짬내서 파보기로 했습니다.

⚒️ 브라우저는 어떻게 소수점 픽셀을 다룰까?

“선택된 옵션”의 소수점 픽셀 크기
“선택된 옵션”의 소수점 픽셀 크기

위에서 살펴본 RadioGroup을 개발자 모드에서 크기를 확인해 보면, 다음과 같이 소수점 픽셀 형태의 값을 갖고 있는 것을 파악할 수 있습니다. 실제로 CSS 작성할 때 translate(-50%, -50%)과 같은 백분율 값을 사용하거나 calc(100% - 2rem)과 같은 동적 계산 값을 사용하곤 합니다. 이러한 값을 사용하게 되면 소수점 픽셀로 계산하는 상황이 자주 벌어집니다. 하지만 일반적으로 실제 화면에서는 픽셀을 소수점 단위로 렌더링할 수 없습니다. 이 때 브라우저에서는 어떻게 대응할까요?

💡 서브픽셀 렌더링 (Subpixel Rendering)

브라우저의 대응을 파악하기 전에 서브픽셀 렌더링 방식을 우선 이해해야 합니다. 모니터 해상도가 1920x1080 이라 가정할 때, 일반적인 렌더링 방식은 가로 화면을 1920개의 픽셀을 활용해 요소를 그립니다. 여기서 서브픽셀 렌더링 방식으로 전환한댜면 픽셀 내 R, G, B 색상을 서브픽셀로 사용하여 각각 제어할 수 있게 됩니다. 즉 1920x3=3840개의 픽셀을 사용해 세부적으로 요소를 배치할 수 있게 됩니다.

일반적인 픽셀 구조
픽셀 1: [R][G][B] | 픽셀 2: [R][G][B] | 픽셀 3: [R][G][B]
11.5px 위치에 렌더링한다면
픽셀 11: [R][■][■] | 픽셀 12: [■][G][B]
        (G,B 활성화)    (R만 활성화)

다만 이러한 렌더링 방식은 수평 방향에만 적용됩니다. LCD 모니터의 구조를 살펴보면 해답을 알 수 있습니다. 수평 방향으로 R, G, B가 가로로 나란히 배열된 반면 위아래 픽셀은 완전히 분리된 픽셀이기에 수직 방향에선 해당 방식을 사용할 수 없습니다.

표준 LCD 픽셀의 배열
┌─────┬─────┬─────┐
│  R  │  G  │  B  │ ← 수평으로 나란히 배열
└─────┴─────┴─────┘
│←── 1개 픽셀 ──→│
수직으로는 서브픽셀을 적용할 수 없다
┌─────┬─────┬─────┐
│  R  │  G  │  B  │ ← 픽셀 1 (픽셀이 서로 분리되어 있음)
├─────┼─────┼─────┤
│  R  │  G  │  B  │ ← 픽셀 2 (바로 아래)
├─────┼─────┼─────┤
│  R  │  G  │  B  │ ← 픽셀 3 (픽셀이 서로 분리되어 있음)
└─────┴─────┴─────┘

브라우저에서의 차이

브라우저가 소수점 픽셀 값을 처리하는 방법은 크게 4가지로 볼 수 있습니다.

  1. 소수점 값 없애기 (내림): 23px로 렌더링
  2. 소수점 값 올리기 (올림): 24px로 렌더링
  3. 안티앨리어싱: 23px로 렌더링하되, 24px 픽셀 위치를 더 연한 색으로 처리
  4. 서브픽셀 렌더링 : RGB 색상을 세부적으로 컨트롤하여 배치

보통 브라우저마다 각기 다른 렌더링 기법을 사용하므로 처리하는 절차는 제각각입니다.

크로미움 (Chrome, Opera, Edge)

CPU/GPU 레이어에 따라 다른 렌더링 방식을 사용한다
.normal-element {
  width: 23.9px; /* Root 레이어: SubPixel anti-aliasing */
}

.gpu-element {
  width: 23.9px;
  transform: translateZ(0); /* GPU 레이어: Grayscale anti-aliasing */
}

대표적으로 Blink 기반의 크롬은 적용된 스타일링에 따라 안티앨리어싱과 서브픽셀 렌더링을 적용합니다. 기본적으론 서브픽셀 렌더링 방식이지만 transform, opacity 와 같은 GPU 레이어를 활용하는 스타일링이 적용된다면 서브픽셀 단위 제어가 어려운 GPU에서는 안티앨리어링 방식으로 전환하여 정수 픽셀 단위로만 처리하게 됩니다.

수평 모니터 테스트
수평 모니터 테스트

왼쪽은 CPU 기반의 기본 레이어를 가진 컴포넌트입니다. widthheight 값만 부여하였습니다. 서브픽셀 렌더링을 하여 모양은 그대로 유지가 되는 대신 위치가 뒤틀려 보입니다 (완전한 중앙 정렬이 아닌 느낌입니다)

반면에 오른쪽은 GPU 기반의 레이어 내 안티앨리어싱을 사용합니다. 앞서 살펴본 컴포넌트에 transform를 적용하였습니다. 사진에선 크게 드러나지 않지만 컴포넌트 주위가 살짝 흐릿한 경계를 가지고 있습니다. (화면 비율을 줄여서 캡쳐하다보니 잘 보이지 않는 듯합니다 😂) 또한 서브픽셀 렌더링을 적용하지 않아 위아래로 모양이 늘어난 부분을 발견할 수 있습니다. 서브픽셀 렌더링을 하지 않을 경우 정수 값의 위치로만 렌더링할 수 있으므로 모양이 뒤틀린 대신 Blur 처리가 되었습니다.

안티앨리어싱 테스트
안티앨리어싱 테스트

왼쪽 방식은 margin으로 위치 조정을, 오른쪽은 transform으로 중앙 정렬하였습니다. 자세히 보면 왼쪽 픽셀은 블러 처리 없이 어느 정도 선명한 반면, 오른쪽은 안티앨리어싱이 적용되어 약간 흐릿합니다.

사파리 (Safari)

Webkit 기반의 사파리는 서브픽셀 렌더링 및 안티엘리어싱을 기본 렌더링 방식으로 사용합니다. (디자이너가 설정한 스타일을 최대한 결과물에 나타내기 위한 Apple의 디자인 철학하고도 연관이 있습니다.) 이로 인해 정확한 픽셀 단위를 요구하는 UI에서 약간 블러 처리가 되는 현상이 발생합니다.

파이어폭스 (Firefox)

파이어폭스는 서브픽셀 렌더링을 사용하지 않고 반올림 방식을 통해 정수 픽셀로 렌더링하도록 접근하여 다양한 OS에서 동일한 결과를 도출하도록 접근합니다.

운영체제에서의 차이

브라우저뿐만 아니라 운영체제도 서로 다른 렌더링 철학을 가지고 있습니다. Windows는 ClearType을, macOS는 Quartz를 사용합니다. 둘의 극명한 차이는 최적화 방식입니다. Windows는 화면 가독성을 최적화하는 방향성인 반면, Apple은 WYSIWYG이라는 인쇄 결과물과 화면 결과물이 동일하도록 맞추는 데 더 초점을 맞춥니다. 그렇다 보니 Mac에서는 굵기 등 스타일 차이가 적은 반면에 Windows는 좀 더 명확하다는 특징이 있습니다.

Apple이 자사 모니터 사용을 권장하는 이유도 여기에 있습니다. 통제된 하드웨어 환경에서 일관된 사용자 경험을 제공하려는 전략도 나름 추측해볼 수 있습니다.

그래서 컴포넌트는 왜 깨졌을까?

기존 RadioGroup의 Indicator는 다음과 같은 CSS를 사용했습니다.

.indicator {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width : 12px;
  height : 12px;
}

해당 방식은 대표적인 absolute를 사용한 중앙 정렬 방식입니다. 이 방식은 부모 요소의 크기(width, height 등)가 홀수일 때 소수점 계산을 야기합니다.

  1. 부모 컨테이너 크기가 23px일 때
  2. 자식 요소의 중앙 위치는 23px ÷ 2 = 11.5px로 계산
  3. translate(-50%, -50%)로 인한 추가 소수점 계산 발생
  4. 최종적으로 요소가 11.25px, 11.75px 같은 소수점 좌표에 위치

브라우저와 운영체제가 사용자마다 다르니 렌더링 결과를 예측하기 어려운 상황입니다. 이러한 예측 불가능한 상황을 만들지 않도록 서브픽셀 렌더링과 안티앨리어싱을 최소화해야 합니다.

💡 해결

1. 가변값 사용하지 않기

예측 불가능한 렌더링 결과를 방지하려면 고정된 값을 제공하거나 소수점 계산을 피하게 만드는 게 핵심입니다. 직관적인 방법은 2로 나누어 떨어지는 고정된 값을 부여하면 됩니다. 물론 브라우저 줌 상태나 모니터 해상도 영향으로 인해 완벽한 제어는 불가능하지만, 소수점 계산이 줄어드는 효과는 확실히 볼 수 있습니다.

2. CSS 계산 값 격리하기

그럼에도 불구하고 반응형을 고려해야 하거나 여러 복합적인 요인 (주변 컴포넌트나 레이아웃 등)에 의해서 소수점으로 계산되는 경우가 있습니다. 이를 방지하기 위해 CSS Containment를 활용합니다.

.radio-button {
  /**
   * HACK: SubPixel Rendering 이슈를 방지하기 위해 contain CSS를 추가
   * contain-style : 스타일 계산 격리
   * contain-layout : 독립적인 레이아웃 형성
   */
  contain: layout style;
}

CSS Containment 중 대표적인 키워드를 간추려보면 다음과 같습니다.

  • layout: 내부 레이아웃이 외부에 영향을 주지 않음
  • style: 스타일 변경이 다른 요소에 영향을 주지 않음
  • paint: 요소가 자신의 경계 밖으로 그려지지 않음

이를 통해 RadioGroup 컴포넌트의 렌더링을 다른 요소들로부터 영향을 받지 않도록 만들 수 있습니다.

3. 레이아웃 재계산 피하기

width, height, padding , margin 등 위치나 크기와 관련된 값을 사용하면 레이아웃을 계속 재계산하여 특정 상황에 소수점 값을 생성할 수 있습니다. 이를 방지하기 위해 레이아웃 재계산을 피하도록 Composite 단계만 거치도록 합니다.

Chakra UI의 RadioGroup CSS 일부
Chakra UI의 RadioGroup CSS 일부

Chakra UI는 이 문제를 transformscale 방식으로 해결합니다. width와 height 값은 부모와 맞추고, Indicator의 크기를 scale로만 줄이는 방식으로 해결합니다. 크기와 위치가 고정되어 있어 레이아웃 재계산을 피할 수 있다는 점이 가장 큰 장점입니다.

📜 결과

Indicator가 딱 알맞게 중앙정렬된 모습이다
Indicator가 딱 알맞게 중앙정렬된 모습이다

앞서 살펴봤던 3가지 방법을 적용한 결과입니다.

소수점 값이 사라졌다
소수점 값이 사라졌다

실제로 개발자 모드에서 크기를 확인해보면 소수점 값이 없어진 것을 확인할 수 있습니다. width/height 값을 2로 나누어 떨어지는 24px로 고정하였습니다.

contain CSS가 적용되지 않았을 때 리페인트
contain CSS가 적용되지 않았을 때 리페인트

기존에 contain CSS를 적용하지 않은 스타일링은 모든 RadioGroup에서 리페인트됩니다. 주변 컴포넌트에 영향을 받아 직접 레이아웃을 재계산하여 발생하는 상황입니다.

contain CSS를 적용하였을 때 리페인트
contain CSS를 적용하였을 때 리페인트

contain CSS를 적용한 이후 Radio 컴포넌트 자체만 리페인트됩니다. contain: layout를 통해 독립적인 레이아웃을 형성하므로 주변 레이아웃 재계산이 발생하지 않습니다. 또한 contain: style 로 스타일 계산을 격리하여 선택이 바뀔 때마다 RadioGroup 내부 스타일만 렌더링 하게 됩니다.

참고

#CSS#렌더링#브라우저#React#Composite#브라우저 렌더링#Layout#레이아웃#Subpixel Rendering#서브픽셀 렌더링#컴포넌트가 흐릿해요#소수점 계산#모니터#Windows#macOS