본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 04. 23:06

_[pixagram](https://pixagram.com)을 위해 **브라우저에서 완전히 실행되는** [이진 SFW/NSFW 이미지 분류기](

요약

픽셀 아트 플랫폼 Pixagram을 위해 브라우저에서 실행되는 경량 이진 SFW/NSFW 이미지 분류기를 개발한 사례를 소개합니다. 기존 범용 모델의 한계를 극복하기 위해 픽셀 아트 특화 및 초경량 모델을 구축하는 과정을 다룹니다.

핵심 포인트

  • 픽셀 아트 특화 모델을 통한 분류 정확도 향상
  • 브라우저 온디바이스 실행으로 개인정보 보호 및 서버 비용 절감
  • 1MB 미만의 초경량 모델로 번들 내장 최적화
  • 범용 모델 대비 단순화된 이진 분류 구조 채택

pixagram을 위해 브라우저에서 완전히 실행되는 이진 SFW/NSFW 이미지 분류기를 출시한 현장 보고서입니다. 이 모델은 모든 기성 모델이 은밀하게 실수하는 단 한 가지, 즉 픽셀 아트 (pixel art)에 맞춰 조정되었습니다.

🎯 갈증 (The itch)

저는 **픽셀 아트 (pixel art)**를 위한 Web3 플랫폼을 운영하고 있습니다. 사람들은 스프라이트 (sprites)를 업로드하고, 민팅 (mint)하며, 공유합니다. 그리고 낯선 사람들이 이미지를 업로드하도록 허용하는 순간, 아주 오래된 문제 하나를 물려받게 됩니다. 바로 그 이미지들 중 일부는 모든 사람에게 보여져서는 안 된다는 점입니다.

가장 명백한 선택지는 NSFWJS입니다. 이는 매우 훌륭하고, 실전에서 검증되었으며, 브라우저에서 실행됩니다. 저도 그것을 시도해 보았습니다. 그러다 그것이 저의 콘텐츠에서 무엇을 하고 있는지 살펴보았고, 제가 아주 특정한 문제를 해결하기 위해 대형 해머를 사용하고 있다는 사실을 깨달았습니다.

"나에게 필요한 것은 사진에 대해 수천 가지를 알고 있는 모델이 아니었습니다. 스프라이트 (sprite)에 대해 정확히 두 가지만 알고 있는 모델이 필요했습니다."

그래서 저는 저만의 모델을 만들었습니다. 이것은 어떻게 만들었는지에 대한 이야기입니다. 즉, 좋은 결정들, 고장 난 모델을 거의 출시할 뻔했던 순간, 그리고 최종적으로 얻게 된 수치들에 대한 이야기입니다. 주니어 개발자도 전체 과정을 따라올 수 있고, 시니어 개발자도 여전히 이 실전 경험담이 유용하도록 작성했습니다. 전문 용어를 사용하는 곳 뒤에는 바로 🔰 쉽게 말하면 (In plain terms) 박스를 배치했습니다.

🤔 왜 그냥 NSFWJS를 사용하지 않았나요?

NSFWJS는 사진 및 드로잉 웹 이미지의 대규모 코퍼스 (corpus)로 학습된 MobileNetV2 백본 (backbone)을 중심으로 구축되었으며, 다섯 가지 카테고리(Drawing, Hentai, Neutral, Porn, Sexy)를 반환합니다. 이는 환상적인 범용 도구입니다. 하지만 세 가지 측면이 저와 맞지 않았습니다:

  • 사진 기반입니다. 제 콘텐츠는 픽셀 아트(Pixel art)입니다. 뚜렷한 경계선, 아주 작은 팔레트, 안티앨리어싱(Anti-aliasing)이 없는 형태죠. 사진으로부터 학습된 모델은 160×160 크기의 스프라이트(Sprite)를 실제로 본 적이 없습니다.
  • 제 문제 규모보다 너무 큽니다. 5개의 클래스와 약 350만 개(3.5M)의 파라미터(Parameter)를 가진 백본(Backbone)은, 단순히 '예/아니오'를 묻는 질문을 위해 수 메가바이트(MB)를 다운로드하게 만듭니다.
  • 저는 단 하나의 질문만 던집니다. "이것을 보여줘도 안전한가, 아닌가?"입니다. 제가 해석해야 하는 5방향 소프트맥스(Softmax)가 아니라, 단 하나의 임계값(Threshold)이면 충분합니다.

저는 제 번들(Bundle)에 내장할 수 있고, **온디바이스(On-device)**로 실행되며(업로드 없음, 서버 왕복 없음, 개인정보 문제 없음), 제가 실제로 제공하는 매체를 이해하도록 학습된 무언가를 원했습니다.

📋 실제로 필요했던 것

코드 한 줄을 쓰기 전에, 저는 사양(Spec)을 작성했습니다. 포스트잇 한 장에 들어갈 정도였죠:

  • 이진 분류 (Binary). sfwnsfw, 하나의 확률, 하나의 임계값.
  • 매우 작음 (Tiny). npm 패키지에 base64로 임베드(Embed)할 수 있을 만큼 작아야 함 — 이상적으로는 약 1 MB.
  • 빠름 (Fast). GPU가 없는 중급 노트북에서도 실시간에 가깝게 작동. 목표: 100 ms 미만.
  • 온디바이스 (On-device). WebAssembly를 통해 웹 워커(Web Worker)에서 실행. 브라우저 외부로 나가는 데이터 없음.
  • 픽셀 아트 인식 (Pixel-art-aware). 전처리(Preprocessing) 과정이 픽셀 그리드(Pixel grid)를 뭉개지 않고 존중해야 함.
  • 독립형 (Self-contained). npm install, import, classify() 호출. 사용자를 위한 별도의 모델 호스팅 단계 없음.

아래의 모든 내용은 이 목록을 바탕으로 진행되었습니다.

🧠 트윗 한 줄에 들어갈 만큼 적합한 두뇌 선택하기

저는 timm에서 MobileNetV4-conv-small-050을 선택했습니다. 이는 약 0.96M 개의 파라미터를 가집니다. NSFWJS가 의존하는 MobileNetV2보다 약 2.5배 더 가볍고, ImageNet 가중치(Weights)를 사용하여 즉시 사용할 수 있는 아키텍처 중 파라미터당 성능이 가장 강력한 것 중 하나입니다.

그다음 저는 비용을 더욱 낮췄습니다. 일반적인 224 대신 160×160 해상도로 학습 및 실행했습니다. 합성곱 신경망 (Convolutional Net)의 경우, 연산량은 픽셀 면적에 비례하여 증가하므로, 224에서 160으로 낮추면 작업량이 대략 절반으로 줄어듭니다. 이를 통해 모델은 이미지당 약 ~65 MFLOPs 수준에 도달했습니다. 스프라이트 (Sprite)에 대한 이진 결정 (Binary decision)을 내리기에는 해당 해상도로도 충분합니다.

🔰 쉽게 설명하자면: "백본 (Backbone)"은 모델에서 사전 학습된 이미지 이해 부분을 의미합니다. 수백만 장의 사진 (ImageNet)을 통해 이미 일반적인 시각 능력을 학습한 모델에서 시작한다는 것은, 처음부터 보는 법을 가르치는 대신 "이미 보고 있는 것들 중 어떤 것이 NSFW인가?"라는 _마지막 단계 (Last mile)_만을 가르치면 된다는 것을 의미합니다.

🔧 파이프라인 (중요하지만 지루한 부분)

전체 과정은 네 단계로 이루어지며, 각 단계는 출력을 다음 단계로 전달합니다:

  1. 학습 (Train): PyTorch/timm을 사용하여 두 개의 폴더로 구성된 데이터셋(nsfw/, sfw/)으로 백본을 미세 조정 (Fine-tune) 합니다.
  2. 내보내기 (Export): 브라우저에서 실행할 수 있는 휴대 가능한 모델 형식인 ONNX로 변환합니다.
  3. 양자화 (Quantize): 가중치 (Weights)를 32비트 부동 소수점 (32-bit floats)에서 8비트 정수 (8-bit integers)로 축소합니다.
  4. 임베드 (Embed): 양자화된 모델을 base64 형식으로 JS 번들 (JS bundle)에 직접 포함시켜, 별도의 fetch 과정이 필요 없도록 합니다.

런타임(Runtime)에는 onnxruntime-web을 사용하며, 이는 WebGPU를 사용할 수 있는 경우 해당 모델을 실행하고, 그렇지 않으면 CPU에서 **WebAssembly (WASM)**로 폴백 (Fallback) 합니다.

🔰 쉽게 설명하자면: WASM을 사용하면 브라우저가 컴파일된 코드를 네이티브에 가까운 속도로 실행할 수 있습니다. 이것이 신경망 (Neural network)이 탭을 과부하시키지 않고 클라이언트 측에서 실행될 수 있는 이유입니다.

이런 모델을 구축하려는 분들에게 제가 강조하고 싶은 점은 다음과 같습니다: 네 단계는 하나의 체인이며, 가장 약한 연결 고리가 정확도 (Accuracy)를 결정합니다. 저는 두 번이나 이 사실을 뼈아프게 배웠습니다. 한 번은 양자화 (Quantization)에서, 또 한 번은 전처리 (Preprocessing)에서였습니다.

🐇 양자화의 토끼굴 (내가 나 자신을 속일 뻔했던 곳)

저의 첫 번째 본능은 매우 _공격적_이었습니다. 8비트(8-bit)가 좋다면, **4비트 (Q4)**는 분명 더 좋을 것이라고 생각했죠. 크기는 절반이 되고, 블로그들은 2배의 속도 향상을 약속하며, 모두가 LLM을 4비트로 양자화(Quantization)하고 있으니까요. 하마터면 주말 내내 그것에 매달릴 뻔했습니다.

하지만 저는 ONNX Runtime에서 4비트가 실제로 무엇을 의미하는지, 그리고 제 모델에서는 어떻게 작동하는지 직접 확인해 보았습니다. 두 가지 사실이 저를 멈춰 세웠습니다.

  • ONNX Runtime의 4비트 경로는 가중치 전용(weight-only)이며 MatMul 연산에만 적용됩니다 (이를 MatMulNBits로 다시 작성합니다). 이는 트랜스포머(Transformers)를 위해 만들어진 도구입니다.
  • 제 모델의 연산을 계산해 보니, 46개의 컨볼루션(Convolution), 하나의 아주 작은 분류기 헤드(Classifier head), 그리고 _MatMul은 전무_했습니다. MobileNet은 거의 순수하게 컨볼루션으로 이루어져 있습니다.

그래서 확인 차원에서 4비트 양자화기(Quantizer)를 실행해 보았습니다. 결과는 성실하게 모든 노드를 건너뛰었고, 저에게 원래 크기의 100%인 파일을 돌려주었습니다. 양자화기가 건드릴 수 있는 것이 아무것도 없었기 때문에 아무 일도 일어나지 않은 것입니다.

"이미 캐시(Cache)에 들어가는 파일의 크기를 절반으로 줄이는 것은 속도를 빠르게 만드는 것이 아니라, 단지 크기를 줄이는 것뿐입니다. 이 둘은 같은 이득이 아닙니다. 4비트의 마법은 실재하지만, 그것은 제 모델과는 다른 종류의 모델에게나 실재하는 것입니다."

4비트의 근거가 되는 메모리 대역폭(Memory-bandwidth) 논리는 가중치가 빠른 메모리에 들어갈 수 없는 거대한 모델을 가정합니다. 제 모델은 약 1MB이며, CPU 캐시에 편안하게 머무를 수 있는 크기입니다. 그래서 저는 환상을 버리고, ONNX Runtime이 WASM/CPU 경로에 정확히 권장하는 uint8을 사용했습니다.

🔰 쉽게 말하자면: 양자화는 모델을 축소하고 속도를 높이기 위해 각 가중치를 더 적은 비트에 저장하는 방식입니다 (32비트 부동 소수점(float) → 8비트 정수(int)). 정밀도는 낮아지지만 파일 크기는 작아집니다. 핵심은 모델의 _정답_을 바꾸지 않으면서 이를 수행하는 것이며, 바로 이 지점에서 저는 나중에 큰 코를 다쳤습니다.

🎨 픽셀 아트(Pixel art)는 모든 규칙을 깨뜨립니다

도메인이 픽셀 아트일 때 아무도 경고해주지 않는 사실이 있습니다. 이미지를 어떻게 리사이즈(Resize)하느냐가 거의 다른 무엇보다 더 중요하다는 점입니다.

모든 모델은 고정된 입력 크기(Fixed input size)가 필요하므로, 모든 이미지는 리사이즈(Resize) 과정을 거칩니다. 사진의 경우, 픽셀을 부드럽게 혼합하는 기본 방식인 선형 보간 (Bilinear interpolation) 방식이면 충분합니다. 하지만 픽셀 아트(Pixel art)의 경우, 이 방식은 파괴적: 선명하고 의도적인 블록들을 흐릿한 뭉텅이로 만들어 버립니다. 스프라이트(Sprite)를 스프라이트답게 만드는 요소가 바로 선형 보간법이 가장 먼저 버리는 부분입니다.

픽셀 아트를 업스케일링(Upscaling)할 때 적합한 필터는 최근접 이웃 (Nearest-neighbor) ("pixelated") 방식입니다. 이 방식은 모든 블록을 선명하게 유지합니다.

하지만 여기에 함정이 있으며, 이는 모든 사람이 빠지는 함정입니다: 학습 시(Training time)의 리사이즈와 서빙 시(Serving time)의 리사이즈는 반드시 동일해야 합니다. 만약 선명한 최근접 이웃 이미지로 학습하고, 흐릿한 선형 보간 이미지로 서빙한다면, 모델은 학습했을 때와는 다른 분포(Distribution)를 보게 됩니다. 단순히 정확도가 조금 떨어지는 문제가 아니라, 당신은 학습시킨 모델과는 다른 모델을 실행하고 있는 것입니다.

"모델은 전처리(Preprocessing)만큼만 정직합니다. 선명한 픽셀로 학습시키고 흐릿한 픽셀로 서빙한다면, 당신은 실제로 테스트해 본 적도 없는 모델을 조용히 배포한 셈입니다."

그래서 저는 리사이즈 필터를 **단일 진실 공급원 (Single source of truth)**으로 만들었습니다. 학습 시점에 한 번 선택하면 (--interp nearest), 해당 설정이 모델 체크포인트(Model checkpoint)에 각인됩니다. 이후 익스포터(Exporter)가 이를 다시 읽어 전처리 설정(Preprocessing config)에 구워 넣고, 브라우저는 _그 설정_을 읽어 동일한 방식으로 리사이즈합니다. 하나의 결정이 전체 체인을 통해 관통되므로, 동기화가 어긋나는 것이 불가능합니다.

(주의해야 할 날카로운 지점 하나: 브라우저에서 더 깔끔해 보이는 createImageBitmap API가 리사이즈를 수행할 있지만, 그 품질 설정은 브라우저에 따라 다릅니다. Firefox는 역사적으로 "pixelated" 힌트를 무시해 왔습니다. 어디서나 보장된 최근접 이웃 방식을 사용하려면, 이미지 스무딩(Image smoothing)을 끈 (Off) 상태로 설정한 구식 <canvas>가 가장 신뢰할 수 있는 도구입니다.)

⚡ 브라우저에서 빠르고 (그리고 보이지 않게) 만들기

UI를 멈추게 만드는 분류기(Classifier)는 아무도 배포하지 않는 분류기입니다. 따라서 런타임(Runtime)은 방해되지 않도록 몇 가지 작업을 수행합니다:

  • 🧵 기본적으로 Web Worker 사용. 추론(Inference)은 백그라운드 스레드에서 실행되며, Worker를 사용할 수 없는 환경에서는 메인 스레드로 자동 폴백(Fall back)됩니다. 어떤 경우든 API는 동일합니다.
  • 📦 배칭 (Batching). 몇 밀리초(ms) 이내에 발생하는 여러 번의 classify() 호출은 하나의 추론으로 병합됩니다. 따라서 호출당 발생하는 고정 오버헤드를 전체 배치에 대해 한 번만 지불하면 됩니다.
  • 🎒 코드를 따라 이동하는 모델. 모델이 번들(Bundle) 내에 base64로 임베딩되어 있습니다. 네트워크 요청이 하나 줄어들며, 오프라인에서도 작동합니다.
  • 🖥️ GPU가 있으면 GPU, 없으면 CPU. 런타임은 WebGPU를 시도하고, 불가능할 경우 WASM으로 우아하게 폴백(Fall back)합니다. 따라서 uint8 모델이라도 GPU가 있는 기기에서는 GPU에 도달할 수 있습니다.

이 모든 것(작은 백본(Backbone), 160px, uint8, 오프스레드(Off-thread))의 결과로, CPU에서 약 ~65 ms 내외의 추론 속도를 달성했습니다. 이미지가 드롭되는 즉시 검사하기에 충분히 빠른 속도입니다.

🐛 나를 거의 무너뜨릴 뻔했던 버그: "NSFW라고 전혀 말하지 않아요"

이 부분은 너무 창피해서 거의 빼놓을 뻔했습니다. 하지만 이 포스트 전체에서 가장 유용한 내용이기도 하기에 여기에 적습니다.

모든 것이 연결되었습니다. 배포도 마쳤습니다. 그런데 그 어떤 것도 NSFW로 분류하지 않았습니다. 단 한 번도요. 명백한 NSFW 픽셀 아트조차 "안전함"으로 통과해 버렸습니다.

저의 첫 번째 가정은 최악의 상황이었습니다. 모델이 학습되지 않았거나, 제가 사용한 학습 데이터(사진)가 픽셀 아트에는 전혀 적용되지 않았다는 것이었습니다. 모델을 낭비한 셈이었죠. 하지만 무엇인가를 다시 학습시키기 전에, 저는 아주 작은 **정상성 검사 스크립트 (Sanity-check script)**를 작성했습니다. 이 프로젝트에서 보낸 시간 중 가장 가치 있는 한 시간이었습니다. 이 스크립트가 하는 일은 단순합니다. 일반 Python 환경에서 _동일한 이미지_에 대해, 정확히 동일한 전처리 과정을 거쳐 전정밀도 (Full-precision) 모델과 양자화 (Quantized) 모델을 실행하는 것입니다. 브라우저도, 캔버스(Canvas)도, 워커(Worker)도 없습니다. 그저 이것만 확인합니다: 정확히 어느 지점에서 답이 틀어지는가?

하나의 NSFW 스프라이트(Sprite)에 대해 실행해 보았습니다. 결과는 다음과 같았습니다:

nsfw_pixelart.png
  FP32 : P(nsfw)=0.973   ← 실제 모델은 확신을 가지고 정답을 맞힘
  UINT8: P(nsfw)=0.106   ← 배포된 모델은 확신을 가지고 오답을 냄

결국 원인이 밝혀졌습니다. Full-precision (전정밀도) 모델은 NSFW라고 97%의 확신을 가지고 정답을 맞혔습니다. 하지만 제가 실제로 배포했던 양자화 (Quantized) 모델은 그것이 _안전 (Safe)_하다고 89%의 확신을 가지고 있었습니다. 양자화가 정답을 뒤집어 버린 것입니다.

"FP32는 0.97이라고 말했습니다. 제가 출시한 버전은 0.11이라고 말했습니다. 모델이 틀린 것이 아니었습니다. 제가 배포한 모델이 제가 훈련시킨 모델과 다른 모델이었던 것입니다."

그 원인은 전형적이며, 이 계열의 네트워크들에 특화된 문제였습니다. MobileNet은 **Depthwise convolutions (깊이별 합성곱)**로 구축되는데, 여기서 각 채널은 자신만의 작은 필터를 가지며 채널마다 가중치 크기 (Weight magnitudes)가 매우 격하게 다릅니다. 저는 전체 가중치 텐서에 대해 단일 스케일을 사용하는 방식 (Per-tensor)으로 양자화를 진행했고, 이는 작은 크기의 채널들을 아무것도 없는 상태로 뭉개버렸습니다. 해결책은 Per-channel (채널별) 양자화입니다. 즉, 모든 채널에 각자의 스케일을 부여하는 것이며, 이는 단 하나의 플래그(--wasm-quant u8s8, 채널별 int8 가중치)로 해결됩니다.

🔰 쉽게 설명하자면: 모든 사람에게 하나의 볼륨 조절기를 설정하여 합창단을 압축한다고 상상해 보세요. 목소리가 큰 가수는 괜찮지만, 작은 소리의 가수는 사라져 버립니다. Per-channel 양자화는 각 가수에게 자신만의 조절기를 주는 것과 같습니다. MobileNet의 경우, 이것이 작동하는 모델과 망가진 모델을 가르는 차이입니다.

더 깊은 교훈은 양자화에 관한 것조차 아니었습니다. 그것은 바로 당신이 훈련시킨 모델과 당신이 배포하는 모델이 자동으로 같은 모델은 아니라는 점이었습니다. 내보내기 (Export)와 양자화는 동작을 조용히 변화시킬 수 있는 변환 과정입니다. 경계 지점에서 두 모델을 비교하는 30줄짜리 스크립트는 훈련 곡선을 아무리 오래 들여다보는 것보다 더 가치 있습니다.

🏆 결과

최종 결과물은 다음과 같습니다:

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0