सदस्यता लें

파비콘의 색상이 배경 색상과 동일할 때 캔버스로 대응하기

카테고리
खाली
작성자
현우
작성시각
유사 도메인 검색 기능 중, 검색된 도메인의 파비콘이 노출되는 경우가 존재한다.
그러면서 가끔 파비콘이 정상적으로 로드되지 않는 것처럼 로고가 뜨지 않는 현상이 몇몇보였다.
그런데 인프라 수집 단에서 수집하는 서비스의 OS 설정 모드에 따른 파비콘이 다크모드에 대응되는 색상만 수집한다는 것을 알게되었다.
실제로, 다크모드로 전환해서 로고를 확인하게 되면 잘보인다. 사실 로고는 정상적으로 출력되지만 배경색과 파비콘의 로고색이 동일하여 보이지 않는 것처럼 노출되는 현상이었다. 인프라 파트에서는 시스템에 맞는 로고를 모두 가져올 수 있도록 수집 스크립트를 수정하면 되는 일이지만, 작업 시간이 조금 걸리기에 인프라 + 클라이언트의 수정 공수가 모두 들어가는 것보다 클라이언트에서 자체적으로 처리할 수 있는지에 대한 고민을 했다.

백그라운드 색상으로 처리해보기

CSS를 이용해 배경 색상을 줌으로 처리하는 것이 제일 단순한 방법이다. 파비콘이 서버에서 제공되면 백그라운드 이미지로 처리하면 무조건 보이게 된다. 그런데 이 방법에서 내가 생각하는 우려점은 아래와 같다.
1.
고정된 백그라운드 색상을 적용하게 되면, 어찌되었든 백그라운드 색상과 동일한 색상의 파비콘이 나오게 되면 똑같이 정사각형의 단색으로 나오게 될 것이다.
2.
투명 배경의 파비콘 아이콘으로 설정한 도메인들의 경우에는 강제로 백그라운드 색상이 적용되어 보이게 될 것이다.
이 방법은.. 가장 간단한 방법이긴하지만.. 브랜드 아이콘 디자인을 해치는 것 같아 절레절레.. 했다.

캔버스로 파비콘을 읽어 픽셀로 검토해보기

백그라운드 색상으로 처리하는 방법은 빠르게 처리할 수 있다는 장점이 있지만, 그다지 좋은 선택지는 아닌 것 같았다. 그래서 이미지는 Base64 형태로 제공이 되며, 파비콘을 캔버스 엘리먼트의 픽셀 단위로 검토하여 로고가 흰색인지 여부에 대한 판단을 하기로 했다. 만약 로고가 흰색이라면 클래스 네임을 추가해 로고의 색상을 반전시켜주면 되는 일이다.
코드는 컴포저블로 재사용 가능하도록 만들었고, 자세한 코드는 아래와 같다.
/**
 * base64 data URL 파비콘의 밝기와 내용물 유무를 분석합니다.
 *
 * @param {number} [threshold=240] - 밝기 기준값 (0~255). 이 값 이상이면 색 반전 적용.
 * @returns {{ analyze: (src: string) => Promise<{ isEmpty: boolean, needsInvert: boolean }> }}
 */

export function useFaviconBg(threshold = 240) {
  const cache = new Map();

  /**
   * @param {string} src - data:image/png;base64,... 형식의 URL
   * @returns {Promise<{ isEmpty: boolean, needsInvert: boolean }>}
   */
  const analyze = (src) => {
    if (!src) return Promise.resolve({ isEmpty: true, needsInvert: false });
    if (cache.has(src)) return Promise.resolve(cache.get(src));

    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => {
        try {
          const canvas = document.createElement('canvas');
          canvas.width = img.width || 16;
          canvas.height = img.height || 16;
          const ctx = canvas.getContext('2d');
          ctx.drawImage(img, 0, 0);

          const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
          const totalPixels = canvas.width * canvas.height;
          let total = 0;
          let count = 0;

          for (let i = 0; i < data.length; i += 4) {
            if (data[i + 3] < 10) continue; // 투명 픽셀 제외
            // perceived luminance
            total += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
            count++;
          }

          // 전체 픽셀의 2% 미만이 불투명하면 빈 이미지로 간주
          const isEmpty = count < totalPixels * 0.02;
          const needsInvert = !isEmpty && count > 0 && total / count >= threshold;

          const result = { isEmpty, needsInvert };
          cache.set(src, result);
          resolve(result);
        } catch {
          resolve({ isEmpty: false, needsInvert: false });
        }
      };
      img.onerror = () => resolve({ isEmpty: true, needsInvert: false });
      img.src = src;
    });
  };

  return { analyze };
}
해당 코드들은 차근차근 살펴보면 아래와 같은 흐름으로 진행이 된다.

캔버스의 픽셀 분석 흐름 확인해보기

analyze(src) 코드를 통해 해당 src 이미지가 빈 이미지인가, 흰색 이미지인가를 판단한다.

전체 흐름

전체적인 컴포저블의 흐름은 아래와 같이 흘러간다.
파비콘 src 입력
      │
      ▼
┌─────────────┐    없음
│  src 있음?  │ ─────────▶  { isEmpty: true, needsInvert: false }
└─────────────┘
      │ 있음
      ▼
┌─────────────┐    있음
│ 캐시 있음?  │ ─────────▶  캐시된 결과 즉시 반환
└─────────────┘
      │ 없음
      ▼
  이미지 로드
  Canvas에 그리기
  픽셀 데이터 추출
      │
      ▼
  픽셀 순회 (4개씩: R G B A)
      │
      ├─ Alpha < 10  →  투명 픽셀 → 건너뜀
      │
      └─ Alpha >= 10 →  불투명 픽셀 → 밝기 계산 후 누적
      │
      ▼
  isEmpty 판단
  needsInvert 판단
      │
      ▼
  캐시 저장 후 반환

useFaviconBg

파비콘 이미지를 분석해서 두 가지를 판단한다.
analyze(src)
  │
  ├─ 빈 이미지인가? (isEmpty)
  │
  └─ 흰색 이미지인가? (needsInvert)

isEmpty 플래그 판단

isEmpty 플래그는 내부적으로 아래와 같은 로직을 통해 판단한다.
전체 픽셀 수 (width × height)
        │
        × 0.02  (2%)
        │
        ▼
   기준 픽셀 수

불투명 픽셀 수  <  기준 픽셀 수
        │
        ▼
    isEmpty = true  →  el.hidden = true (화면에서 숨김)

예시 (16×16 파비콘)

전체 픽셀: 16 × 16 = 256개
기준:      256 × 0.02 = 5.12개

불투명 픽셀 0개  →  isEmpty ✓  (완전 투명 이미지)
불투명 픽셀 3개  →  isEmpty ✓  (노이즈 수준)
불투명 픽셀 6개  →  isEmpty ✗  (정상 이미지로 간주)

needInvert 판단 플로우

흰색을 감지하는 플로우이며, 픽셀 밝기는 RGB를 그대로 평균내지 않고, 사람 눈의 민감도 기준으로 가중치를 적용한다.
밝기 = 0.299 × R  +  0.587 × G  +  0.114 × B
         빨강 29%      초록 59%      파랑 11%
초록에 가중치가 높은 이유는 사람 눈이 초록빛에 가장 민감하기 때문이다.
모든 불투명 픽셀의 밝기 합계
        ÷
    불투명 픽셀 수
        │
        ▼
    평균 밝기

평균 밝기  >=  240 (threshold)
        │
        ▼
  needsInvert = true  →  filter: invert(1) 적용


밝기 기준 예시
  0 ──────────────────────────────────── 255
  │                          │           │
 검정                       240         흰색
                              ▲
                         여기 이상이면
                         반전 적용

컴포넌트에서의 최종 동작

कृपया कोड दर्ज करें

매 리스트마다 캔버스를 그리는데, 성능상 이슈는 없을까?

현재 리스트에서는 파비콘이 스크래핑되는 경우도 있고, 안되는 경우가 있어 100개의 리스트를 로드했을 때 100개 모두 파비콘을 가져오는 형태는 아니다. 그렇기 때문에 실제로 적용을 했을 때 성능 상의 이슈는 없었으나 100개의 파비콘이 존재하는 리스트를 로드할 때는 조금의 버벅임이 있을 수 있다.
그래서 캐싱 기능을 넣었다. 내부 로직에서 캐싱 처리되기 때문에 동일한 파비콘이 여러 행에 반복 출현해도 Canvas 분석은 딱 1번만 실행된다. 하지만 캐싱 기능이 올바른 해답은 아니다. 라이트, 다크모드의 파비콘이 스크래핑이 정상적으로 될때까지 사용자에게 파비콘이 가장 자연스럽게 노출될 수 있도록 할 수 있는 최선의 방법이라고 생각했다. 스크래핑 대응이 완료되면 해당 로직은 롤백해서 성능 상의 이슈는 다시 고쳐놓긴해야겠다,,
첫 번째 행:  analyze("data:image/png;base64,ABC...")  →  Canvas 분석 실행 → 캐시 저장
두 번째 행:  analyze("data:image/png;base64,ABC...")  →  캐시에서 즉시 반환 (분석 생략)
세 번째 행:  analyze("data:image/png;base64,ABC...")  →  캐시에서 즉시 반환 (분석 생략)