생성된 이미지에 우리만의 서명을 새기는 법: K-Pattern Watermark Service
이미지 한 장을 서버에 던지면, 눈으로는 전혀 구분할 수 없는 PNG 한 장이 돌아온다. 픽셀 값은 미세하게 바뀌었지만 사람 눈에는 원본과 완전히 같다. 그런데 이 이미지를 다시 서버에 던지면 이렇게 응답한다.
{"present": true, "payload": "MU:DAM"}
"MU:DAM" — 이 문자열이 박혀 있으면, 그 이미지는 우리 서비스를 거쳐 나온 것이라는 뜻이다. 이걸 만드는 게 이번 공모전(문화체육관광 인공지능 데이터 공모전) 프로젝트의 마지막 퍼즐 조각이었다.
무엇을 만들었나
k-pattern-watermark-service는 이미지 한 장을 받아서 비가시적 워터마크를 심거나, 반대로 그 워터마크가 있는지 읽어내는 일만 하는 FastAPI 서버다. 엔드포인트는 딱 세 개.
GET /health
POST /watermark/embed # PNG in → 워터마크 심은 PNG out
POST /watermark/decode # PNG in → {"present": bool, "payload": str|null}
이 서비스는 외부에 열려 있지 않다. 호스트 포트에 바인딩하지 않고 k-pattern-internal이라는 Docker 네트워크 안에서만 접근 가능하며, 우리 팀의 Spring Boot 백엔드(k-pattern-studio)만 이 서비스를 호출한다. 사용자가 이미지를 업로드하면 → Spring Boot가 처리한 뒤 → 이 서비스에 던져서 워터마크를 심고 → 그 결과를 최종 산출물로 내보내는 구조다.
왜 이게 필요한가
공모전 주제 자체가 AI로 이미지를 생성/가공하는 서비스였다. 문제는 AI 생성 이미지가 쏟아지기 시작하면 "이게 우리 서비스에서 나온 결과물이 맞는지"를 증명할 방법이 없다는 것이다. 캡처, 재업로드, 파일명 변경 같은 일상적인 조작만 거쳐도 메타데이터는 다 날아간다.
여기서 흔히 두 가지를 헷갈린다.
- AI 생성 여부 판별 — 이 이미지가 AI가 만든 것인지 아닌지 감별하는 것
- 출처 증명 — 이 이미지가 "우리 서비스"에서 나온 것인지 증명하는 것
우리가 필요한 건 후자였다. 그래서 접근도 단순하게 잡았다. 기존에 이미지에 어떤 워터마크가 있었는지는 전혀 신경 쓰지 않는다. 남의 워터마크를 감지하거나 검증하는 기능은 없다. 오직 "이 이미지는 우리를 거쳐 갔다"는 사실 하나만 이미지 전체 픽셀에 새겨 넣고, 나중에 그 사실 하나만 다시 읽어낸다.
워터마크 로직: 왜 TrustMark인가
이미지 워터마킹이라고 하면 흔히 떠올리는 건 구석에 반투명 로고를 박아넣는 방식이다. 그건 크롭 한 번, 리사이즈 한 번이면 날아간다. 우리가 원한 건 그 반대였다 — 눈에 보이지 않으면서, 이미지 전체에 걸쳐 있어서, 웬만한 변형에도 살아남는 방식.
그래서 Adobe가 만든 오픈소스 라이브러리 TrustMark를 골랐다. 동작 원리는 이렇다.
from PIL import Image
from trustmark import TrustMark
SIGNATURE = "MU:DAM"
_trustmark = TrustMark(model_type="Q")
def embed_signature(image: Image.Image) -> Image.Image:
return _trustmark.encode(image, SIGNATURE)
def decode_signature(image: Image.Image) -> tuple[bool, str | None]:
wm_secret, wm_present, _wm_schema = _trustmark.decode(image)
if wm_present and wm_secret == SIGNATURE:
return True, wm_secret
return False, None
핵심 결정 두 가지가 여기 있다.
1. model_type="Q" — 강인성보다 화질을 택했다. TrustMark는 워터마크가 얼마나 잘 버티는지(강인성)와 원본 화질을 얼마나 유지하는지 사이에서 모델을 고를 수 있다. 우리는 "출처 증명"이 목적이지 "어떻게든 훼손해도 살아남기"가 목적이 아니었다. 그래서 시각적 손상을 최소화하는 Q(Quality) 모델을 택했다.
2. 페이로드는 이미지마다 다른 게 아니라 고정 문자열이다. 이미지 하나하나에 고유 ID를 심는 대신, 모든 이미지에 똑같은 "MU:DAM" 하나만 심는다. "이 이미지가 몇 번 작업물인지"까지는 필요 없고, "우리 서비스 산출물인지 아닌지"만 증명하면 됐기 때문이다. TrustMark의 100비트 페이로드 한도 안에서 한 문자열만 쓰니 여유도 충분하다.
decode_signature에서 wm_present뿐 아니라 wm_secret == SIGNATURE까지 같이 확인하는 것도 의도적이다. 다른 워터마크(다른 서비스가 심었을 수도 있는)가 있는 것과, 우리 서명이 있는 것을 구분해야 하기 때문이다.
아키텍처: 얇은 레이어를 겹겹이
구조는 의도적으로 단순하게 잡았다.
app/watermark.py → TrustMark를 감싼 순수 함수 2개
app/main.py → FastAPI 엔드포인트, 그 함수들을 호출만 함
app/watermark.py는 FastAPI를 전혀 모른다. HTTP 요청도, JSON도 모르고 그냥 PIL.Image를 받아서 PIL.Image를 반환하거나, (bool, str | None)을 반환하는 함수 두 개뿐이다. 이렇게 나눈 이유는 단순하다 — 워터마크 로직 자체를 독립적으로 테스트하고 싶었고, 나중에 FastAPI가 아닌 다른 방식(배치 처리 스크립트 등)으로 재사용할 여지를 남기고 싶었다.
의존성 관리는 uv로 통일했다. Docker 이미지도 python:3.12-slim 위에 uv sync로 패키지를 설치하고, k-pattern-studio-ai라는 이름의 컨테이너로 k-pattern-internal 네트워크에만 연결한다. 통신 규약은 Spring Boot 쪽 TrustMarkWatermarkClient와 정확히 맞춰뒀다.
POST /watermark/embed 요청: raw PNG 바이트 응답: raw PNG 바이트 (200)
POST /watermark/decode 요청: raw PNG 바이트 응답: {"present", "payload"} JSON (200)
실패 시 (400/500 공통) {"code": str, "message": str}
{"code", "message"} 스키마는 Spring Boot 쪽 ErrorResponse DTO와 필드명을 똑같이 맞췄다 — 클라이언트가 별도 변환 없이 그대로 역직렬화하게 만들기 위해서다.
코드 리뷰를 거치며 드러난 문제들
이 서비스는 "작동한다"와 "내부망 배포 환경에서 안전하게 작동한다" 사이에 생각보다 간극이 컸다. 실제로 개발 중 여러 차례 심층 리뷰를 거치면서 아래 문제들이 잡혔다.
모델 다운로드 시점이 배포 전제와 충돌했다. TrustMark는 처음 인스턴스를 만들 때 사전학습 모델을 인터넷에서 내려받는다. 문제는 이 서비스가 내부망 전용이라 배포 서버가 아웃바운드 네트워크조차 막혀 있을 수 있다는 것 — 그러면 컨테이너가 뜨지도 못한다. 그래서 모델 다운로드를 런타임이 아니라 Docker 빌드 타임으로 옮겼다.
RUN uv run python -c "from trustmark import TrustMark; TrustMark(model_type='Q')"
빌드 서버는 인터넷이 되고, 배포 서버는 안 될 수도 있다는 전제를 명확히 나눈 것이다.
동기 코드가 이벤트 루프를 막고 있었다. FastAPI 엔드포인트를 async def로 선언해놓고 그 안에서 CPU 바운드 작업(이미지 디코딩, 워터마크 연산, PNG 인코딩)을 그대로 돌리면 동시 요청이 들어올 때 이벤트 루프 전체가 막힌다. run_in_threadpool로 파이프라인 전체를 스레드풀에 위임하고 나서야 제대로 해결됐다 — 처음엔 TrustMark 호출만 옮겼다가, 재검토 과정에서 "PNG 디코딩/인코딩도 여전히 이벤트 루프 위에 있다"는 걸 다시 지적받고 파이프라인 전체를 옮겼다.
손상된 이미지가 서버 오류로 잘못 분류되고 있었다. 완전히 이상한 바이트("not a real image" 같은)는 UnidentifiedImageError로 잘 잡히지만, 헤더는 멀쩡한데 데이터 스트림만 잘린 PNG는 OSError를 던진다. 이걸 놓치면 클라이언트가 잘못 보낸 입력이 500 WATERMARK_SERVICE_ERROR(서버 오류)로 응답돼버린다 — 명백히 400이어야 할 상황이 서버 탓이 되는 것이다. 여기에 더해 지나치게 큰 이미지(decompression bomb)도 별도로 걸러야 했다.
except (
UnidentifiedImageError,
OSError,
Image.DecompressionBombWarning,
Image.DecompressionBombError,
) as exc:
raise InvalidImageError("이미지를 디코딩할 수 없습니다.") from exc
이 목록은 처음부터 이렇게 완성된 게 아니라, 리뷰를 거듭하며 하나씩 추가됐다. DecompressionBombError는 OSError가 아니라 Exception 직속이라 별도로 잡아줘야 한다는 것도 실제로 재현해보고서야 확실해졌다.
마무리
결과적으로 이 서비스가 하는 일은 한 줄로 요약된다 — 이미지를 받아서, 티 안 나게 우리 서명을 심고, 나중에 그 서명이 있는지 확인해준다. 로직 자체는 단순하지만, "내부망 전용이라 네트워크를 못 쓸 수도 있다", "동시 요청이 들어올 수 있다", "클라이언트가 이상한 입력을 보낼 수 있다" 같은 배포 환경의 현실적인 제약을 하나씩 맞춰나가는 과정이 실제 작업의 대부분이었다.
첫 댓글을 남겨보세요.