반전의 반전: 다크모드에 맞는 이미지 필터를 찾아서
본 게시물은 다크모드로 읽기를 권합니다. (라이트모드에서는 인터랙티브 컴포넌트가 동작하지 않습니다)
3년 전쯤의 일이다. 한 학원 강사님에게서 외주 의뢰를 받았다.
"프로젝터로 수업하는데, 화이트톤 슬라이드를 다크톤으로 바꿔주세요."
처음에는 일이 단순하다 생각했다. CSS filter: invert(1) 한 줄이면 흰 배경은 검은 배경으로, 검은 글자는 흰 글자로 바뀔테니까. 그러나 실제로 적용해 보니, 시각적 의미가 왜곡되는 문제가 발생했다.
지구과학 슬라이드에서는 빨간색으로 표시한 N극이 청록색으로, 파란색으로 표시한 S극이 노란색으로 바뀌어 버렸다. 물리 수업 자료에서 파장 스펙트럼을 구분하던 색 역시 제자리를 잃고 뒤죽박죽이 되었다. 색이 정보를 담는 그림에서 단순한 반전은 정보의 구조를 왜곡해 버릴 수 있다.

내가 원했던 것은 단순한 negative가 아니라, 다크모드에서도 색의 의미가 유지되는 이미지였다.
아래 실험실에서 직접 확인해 보자. 해수면 온도 지도, 양성자-양성자 연쇄 반응, 대기 구성 다이어그램처럼 색이 의미를 갖는 과학 이미지를 골라, 방법을 바꿔가며 테마를 전환해 보자.
지도 이미지도 사정은 비슷하다. 공원을 나타내는 초록색이나 지하철 노선의 색(예: 2호선 초록, 3호선 주황)은 단순 반전만으로는 보색으로 치환되어, 사용자가 익숙하게 읽어내던 의미를 흐리게 만들 수 있다. 그러나 invert(1) hue-rotate(180deg)를 함께 적용하면, 배경의 명암은 뒤집되 색이 가진 시각적 의미는 대체로 유지된다.
요약
- 단순
invert()는 색상(Hue)까지 보색으로 바꿔서 색의 의미가 깨진다. - 해결책:
invert(1) hue-rotate(180deg)— 밝기는 반전하고 색상은 유지한다. hue-rotate()는 RGB 공간에서의 행렬 연산이다. HSL 변환이 아니다.- 더 정밀한 제어가 필요하면 SVG
feColorMatrix로 행렬 값을 직접 조정할 수 있다.
첫 번째 시도: invert()의 한계
CSS filter: invert(1)은 각 픽셀의 RGB 값을 1 - x로 바꾼다.
filter: invert(1);
/* R' = 1 - R, G' = 1 - G, B' = 1 - B */흰색(1, 1, 1)은 검은색(0, 0, 0)이 되고, 검은색은 흰색이 된다. 배경과 글자 색을 뒤집는 데는 괜찮아 보인다.
하지만 빨간색(1, 0, 0)은 어떻게 될까?
파란색(0, 0, 1)은?
invert()는 RGB 좌표계에서 원점 대칭에 해당하는 변환이다. 곧, 명암만 뒤집는 것이 아니라 색상(Hue)까지 보색으로 치환한다.
3가지 방법 비교
아래 인터랙티브 컴포넌트에서 슬라이더를 움직여 보자. 원본 vs invert를 비교하면 색의 역할이 어떻게 왜곡되는지 확인할 수 있고, 원본 vs invert+hueRotate를 비교하면 색 구분이 어느 정도 보존되는 모습을 확인할 수 있다.
해결책: invert() + hue-rotate()
그래서 정리해보면 다음과 같다.
- 원하는 것: 밝기는 뒤집되, 색상(Hue)은 유지
- invert()의 문제: 색상까지 보색으로 치환
이 문제를 해결하기 위해서는 hue-rotate(180deg)를 한 번 더 추가하면 된다.
filter: invert(1) hue-rotate(180deg);작동 원리:
invert(1): RGB를 보색으로 바꾼다 → 빨강이 청록으로hue-rotate(180deg): 색상(Hue)을 180도 회전시켜 다시 가깝게 되돌린다 → 청록이 다시 빨강에 가까워짐
결과적으로 밝기는 반전되고 색상은 대략 유지된다. 위의 실험실에서 invert + hueRotate를 고른 뒤 다크모드로 전환해 보면, 해수면 온도나 대기층을 구분하던 색이 큰 틀에서 제 기능을 하는 것을 확인할 수 있다.
이 블로그에서는 CSS filter 대신, SVG 필터 컴포넌트(SVGFilteredImage)를 구현해 사용한다.
SVGFilteredImage는 Next.js Image 컴포넌트를 감싼 뒤, SVG <filter> 요소를 생성하고 CSS filter: url(#id)로 연결한다.
- 고유 ID 생성:
useId()로 각 이미지마다 고유한 필터 ID를 만든다 - SVG 필터 정의: DOM에 숨겨진
<svg><defs><filter id="...">요소를 생성 - preset 렌더링:
preset="invert-hue-180"이면 다음 SVG primitives를 렌더링:<feComponentTransfer> <feFuncR type="linear" slope={-1} intercept={1} /> <feFuncG type="linear" slope={-1} intercept={1} /> <feFuncB type="linear" slope={-1} intercept={1} /> </feComponentTransfer> <feColorMatrix type="hueRotate" values="180" /> - CSS 연결:
<Image style={{ filter: 'url(#filter-id)' }} />로 SVG 필터를 적용
hue-rotate()의 내부: 어떻게 작동하는가
hue-rotate()라는 이름 때문에 HSL 색공간에서 H 값만 회전시키는 변환으로 오해하기 쉽지만, 실제로는 RGB 공간에서의 근사적인 행렬 연산으로 정의된다.
Filter Effects 스펙에 따르면, hue-rotate(θ)는 다음 행렬을 적용한다:
이 행렬은 휘도(luminance)—사람이 느끼는 밝기—를 대체로 보존하면서 색상 평면을 회전시키는 근사다. 즉, RGB→HSL→회전→RGB 같은 단계적 변환이 아니라, RGB 값에 직접 작용한다.
결과적으로:
- 채도가 낮은 색에서는 거의 정확하게 동작
- 채도가 높은 색에서는 미세하게 색상이 밀림
- 완전히 saturated된 원색(빨강, 초록, 파랑)에서 가장 큰 오차 발생
그래서 invert(1) hue-rotate(180deg) 조합은 ‘완벽한 색상 보존’이 아니라, 실용적인 수준의 ‘그럴듯한 근사’에 가깝다.
다만 실전에서는 이 정도 근사로도 대개 충분하다. 대부분의 이미지에서 채도가 극단적으로 높은 영역은 드물고, 작은 색상 왜곡은 쉽게 도드라지지 않는다. 위 실험실에서도 과학 이미지의 구분이 대체로 유지되는 모습을 확인할 수 있다.
더 정밀한 제어가 필요하다면: SVG feColorMatrix
hue-rotate(180deg)가 만족스럽지 않거나, 다른 종류의 색 변환이 필요하다면 이야기는 달라진다. 그때는 SVG feColorMatrix로 행렬을 직접 작성할 수 있다.
가장 빠르게 이해하는 방법은 값을 바꿔 가며 결과를 확인하는 것이다. 아래는 fecolormatrix.com를 참고해 만든 플레이그라운드로, 4×5 행렬을 즉시 시험해 볼 수 있다.
feColorMatrix playground
4×5(총 20개) 값을 바꾸면 바로 이미지에 적용된다. 값은 공백/쉼표로 구분 가능하다.
preset
matrix (4×5)
변환 없음 (단위행렬)
values (copy/paste)
입력하면 자동 적용됨

feColorMatrix: 색 변환은 행렬이다
SVG <feColorMatrix>는 각 픽셀의 [R, G, B, A, 1] 벡터에 4×5 행렬을 곱한다.
<filter id="my-filter">
<feColorMatrix type="matrix" values="
a b c d e
f g h i j
k l m n o
p q r s t
"/>
</filter>결과:
마지막 열(e, j, o, t)은 상수항이다. 이걸 이용하면 다양한 색 변환을 표현할 수 있다.
예시: 휘도 기반 반전 행렬
hue-rotate(180deg) 대신 휘도(luminance)를 직접 계산해 반전시키는 방법도 있다. 결과는 대체로 비슷하며, 상대 휘도 공식을 이용하면 다음과 같이 쓸 수 있다.
“휘도만 반전하고 색상 성분은 유지”하려면:
이를 행렬로 표현하면:
<feColorMatrix type="matrix" values="
0.5748 -1.4304 -0.1444 0 1
-0.4252 -0.4304 -0.1444 0 1
-0.4252 -1.4304 0.8556 0 1
0 0 0 1 0
"/>아래 인터랙티브 컴포넌트에서 RGB 값을 조절하면 휘도가 어떻게 계산되는지, 그리고 휘도 기반 반전이 어떤 방식으로 작동하는지 확인할 수 있다. 특히 초록색(G)의 가중치가 0.7152로 가장 크다는 점이 눈에 들어올 것이다.
휘도 계산 시각화
RGB 값을 조절하면 휘도가 어떻게 계산되는지, 그리고 휘도 기반 반전이 어떻게 작동하는지 확인할 수 있습니다.
RGB 값 조절
원본 색상
휘도 기반 반전 결과
초록색(G)의 가중치가 0.7152로 가장 높은 이유는 인간의 시각 시스템과 맞닿아 있다.
CIE 1931 Photopic Luminosity Function
충분히 밝은 조건(photopic vision)에서 인간의 눈은 초록색 파장에 가장 민감하다. CIE 1931에서 정의한 photopic luminosity function은 약 555nm(나노미터)에서 최고점을 가지며, 이는 초록-노랑 영역에 해당한다.
이 함수는 인간이 각 파장의 빛을 얼마나 밝게 인지하는지를 나타낸다. 같은 에너지를 가진 빛이라도 555nm 근처의 빛이 가장 밝게 느껴진다.
휘도 가중치의 유래
WCAG 2.1의 상대 휘도 공식에 사용되는 가중치(0.2126, 0.7152, 0.0722)는 CIE 1931 색공간에서 파생되었으며, ITU-R BT.709 표준에서도 동일하게 사용된다.
이 가중치는 각 RGB 채널이 인간이 인지하는 밝기(perceived brightness)에 기여하는 정도를 반영한다.
- 초록(G): 0.7152 (약 72%) — 가장 높은 기여도
- 빨강(R): 0.2126 (약 21%)
- 파랑(B): 0.0722 (약 7%) — 가장 낮은 기여도
+ 진화적 이유
인간의 눈이 초록색에 특히 민감한 이유는 진화적 적응으로 흔히 설명된다. 자연 환경에서 식물의 초록은 매우 흔했고, 이를 구분해 내는 능력이 생존에 유리했을 가능성이 크다.
결국 휘도 계산에서 초록색이 가장 큰 가중치를 갖는 것은, 사람이 실제로 느끼는 밝기와 어긋나지 않도록 설계된 결과라고 볼 수 있다. 같은 휘도로 계산되더라도 초록색 성분이 많을수록 더 밝게 느껴지는 경우가 잦다.
재밌게도 이 행렬은 invert(1) 뒤에 hue-rotate(180deg)를 적용한 합성 행렬과 사실상 같은 꼴이다. hue-rotate() 스펙에서 쓰는 가중치(0.213/0.715/0.072)를 쓰면 값이 더 단정해지고, 여기서는 WCAG 계수(0.2126/0.7152/0.0722)를 사용해 소수점 몇 자리만 달라진다.
hue-rotate(180deg)의 3×3 부분은 (sin 180°=0, cos 180°=-1)로 정리하면 이렇게 된다:
hueRotate(180) ≈
[ -0.574 1.43 0.144 0 0
0.426 0.43 0.144 0 0
0.426 1.43 -0.856 0 0
0 0 0 1 0 ]여기에 invert(1)(x ↦ 1-x)을 앞에 합성하면 상수항이 +1로 생기고, 계수들이 그대로 luma-invert 꼴로 나온다:
hueRotate(180) ∘ invert(1) =
[ 0.574 -1.43 -0.144 0 1
-0.426 -0.43 -0.144 0 1
-0.426 -1.43 0.856 0 1
0 0 0 1 0 ]여기서 (0.5748, -1.4304, …) 같은 값은 0.213 대신 0.2126 같은 조금 더 정밀한 계수를 쓴 버전이다.
color-interpolation-filters 주의사항
SVG 필터의 color-interpolation-filters 속성 기본값은 linearRGB다. 선형 RGB에서 연산하면 시각적으로 예상과 다른 결과가 나올 수 있다.
이 블로그에서는 예측 가능한 결과를 위해 sRGB로 고정한다.
<filter id={filterId} colorInterpolationFilters="sRGB">
{/* filter primitives */}
</filter>CSS filter 함수는 Filter Effects 스펙에 따라 sRGB에서 동작하므로 이 문제가 없다.
그래서 그 외주는 어떻게 되었나
그때의 외주는 filter: invert(1) hue-rotate(180deg); 한 줄로 정리되었다. N극은 빨간색, S극은 파란색으로 남은 채 배경만 어둡게 전환되었고, 슬라이드가 담고 있던 시각적 의미는 유지되었다.
오랜만에 당시의 기억을 더듬어, 색이 의미를 갖는 이미지를 다크모드에서 다루는 방법을 정리해 보았다. 다음에는 색을 다루는 다른 도구들(Oklab/Oklch 같은 색공간)을 다뤄볼까 한다.