TrustMark와 FastAPI로 AI 이미지에 비가시적 워터마크를 추가하기
이미지 한 장을 서버에 전송하면, 눈으로는 원본과 거의 구분되지 않는 PNG 한 장이 돌아온다.
픽셀 값은 미세하게 달라졌지만, 사람의 눈에는 무엇이 바뀌었는지 보이지 않는다. 그런데 이 이미지를 다시 검증 서버에 전송하면 다음과 같은 응답이 돌아온다.
{ "present": true, "payload": "MU:DAM" }
MU:DAM.
이 문자열이 검출됐다는 것은 이미지 안에 무담이 삽입한 비가시적 워터마크가 남아 있다는 의미다.
우리 팀은 문화데이터와 생성형 AI를 활용한 한국 전통문양 굿즈 디자인 플랫폼 무담(mudam)을 개발했고, 제4회 문화체육관광 인공지능·데이터 활용 공모전에서 문화데이터 우수사례 부문 장려상을 수상했다.
무담은 전통문양 데이터를 학습한 생성형 AI를 활용하여 사용자가 선택한 문양, 제품, 색상, 적용 방식, 밀도에 맞는 굿즈 디자인을 생성하는 웹 플랫폼이다. Stable Diffusion v1.5를 기반으로 문양 유형별 LoRA 모델을 학습했고, 약 3만 건의 전통문양 이미지와 유물 메타데이터를 수집한 뒤 중복·저화질 데이터를 제거하여 약 2만 7,500개의 정규화된 학습 데이터를 구성했다. 처음에는 모델이 원하는 문양을 얼마나 정확하게 생성하는지에 집중했다.
그런데 모델 개발이 어느 정도 마무리되고 공모전 제출 문서를 정리하면서 새로운 문제가 보이기 시작했다.
이 이미지가 무담의 모델로 만들어졌다는 사실은 어떻게 남길 수 있을까?
이미지는 서비스 안에 있을 때만 무담의 결과물로 보인다.
사용자가 이미지를 내려받고 다른 사이트에 다시 올리면 서비스 이름도, 생성 기록도, 모델 정보도 함께 이동하지 않는다. 파일 이름은 바꿀 수 있고, EXIF나 일반 메타데이터는 이미지 편집이나 플랫폼 업로드 과정에서 사라질 수 있다.
우리가 직접 학습한 모델로 이미지를 생성하더라도, 이미지가 서비스 밖으로 나가는 순간 다른 생성형 AI의 결과물과 구분할 방법이 없었다.
이 문제를 해결하기 위해 공모전 프로젝트의 마지막 단계에서 이미지 출처 표식용 비가시적 워터마크를 추가했다.
무담은 무엇을 만드는 서비스인가
무담은 전통문양 이미지를 단순히 검색하거나 내려받는 서비스가 아니다.
사용자가 원하는 굿즈 조건을 선택하면 전통문양이 제품 표면에 적용된 디자인 시안을 생성한다.
예를 들어 다음과 같은 입력을 받을 수 있다.
문양: 모란문 제품: 머그컵 적용 방식: 금박 문양 밀도: 70% 색상: 짙은 남색
이 입력은 제품별 프롬프트 템플릿과 결합되고, Stable Diffusion v1.5 기반 생성 모델과 LoRA 가중치를 통해 실제 굿즈에 문양이 적용된 형태의 이미지로 생성된다.
기획서에서 정의한 주요 대상은 굿즈 제작사, 중소 브랜드, 문화상품 기획자, 박물관 상품팀, 지역문화재단 등이다. 전통문양을 찾은 뒤 별도의 디자이너에게 제품 시안을 의뢰하는 과정을 줄이고, 상품 기획 초기 단계에서 여러 디자인을 빠르게 비교하도록 지원하는 것이 목표였다.
활용한 문화데이터는 다음과 같다.
| 데이터 | 제공 기관 | 활용 목적 |
|---|---|---|
| 전통문양조회서비스 | 한국문화정보원 | 인물문·동물문·식물문·자연산수문·기하문 등의 문양 분류 |
| 디자인문양 | 한국문화정보원 | 전통문양 이미지와 활용 사례 |
| 국립중앙박물관 유물정보 | 국립중앙박물관 | 유물 명칭, 시대, 재질, 크기와 문양의 출처 |
| 박물관 연계 유물정보 | 국립중앙박물관 외 여러 기관 | 문양의 원천 유물과 문화적 맥락 |
| 전통문화 상징사전 | 국가유산진흥원 | 문양의 형태, 유래와 상징 의미 |
| 한국 전통 문양 데이터 | AI Hub | 문양 이미지 인식과 생성 품질 보완 |
수집한 이미지는 해상도 정규화, 배경 정리, 중복 제거, 저화질 데이터 제거, 문양 유형 라벨링 과정을 거쳤다. 이미지와 함께 수집한 문양명, 시대, 원천 유물, 상징 의미 등의 메타데이터는 생성 프롬프트 구성에도 활용했다.
이렇게 만들어진 결과물은 무담의 핵심 자산이었다.
문제는 생성 이후였다.
이미지는 생성되는 순간보다 그 이후가 더 길다
이미지가 생성되는 과정은 우리가 통제할 수 있다.
flowchart LR
A[사용자 조건 입력] --> B[제품별 프롬프트 생성]
B --> C[Stable Diffusion + LoRA]
C --> D[전통문양 굿즈 이미지 생성]
D --> E[서비스 저장]
E --> F[사용자 다운로드]
하지만 사용자가 이미지를 내려받은 이후에는 통제할 수 없다.
이미지는 SNS에 올라갈 수도 있고, 메신저로 전달될 수도 있으며, 크기가 조정되거나 다른 포맷으로 변환될 수도 있다. 파일 이름과 메타데이터가 모두 제거된 채 다시 배포될 수도 있다.
눈에 보이는 로고를 이미지 모서리에 넣는 방법도 고려할 수 있다. 그러나 굿즈 디자인 이미지에 큰 로고를 덮으면 결과물 자체의 활용성이 떨어진다. 작은 로고는 잘라내기만 해도 사라진다.
파일의 EXIF나 일반 메타데이터에 서비스 이름을 기록하는 방법도 있다. 그러나 메타데이터는 이미지 편집, 화면 캡처, 메신저 전송 또는 플랫폼 재인코딩 과정에서 쉽게 제거될 수 있다.
필요했던 것은 다음 조건을 만족하는 표식이었다.
| 조건 | 설명 |
|---|---|
| 비가시성 | 사용자가 결과물을 볼 때 문양의 품질을 방해하지 않아야 한다. |
| 이미지 결합 | 별도 데이터 파일이 아니라 이미지 픽셀에 표식이 포함돼야 한다. |
| 기계 판독 | 서버에서 이미지의 출처 표식을 다시 읽을 수 있어야 한다. |
| 서비스 독립성 | 기존 Spring Boot 애플리케이션과 느슨하게 결합돼야 한다. |
| 장애 격리 | 워터마크 장애가 이미지 생성 전체 실패로 이어지면 안 된다. |
| 내부 전용 | 삽입 API가 외부 사용자에게 직접 노출되면 안 된다. |
이 기준을 바탕으로 Adobe의 오픈소스 이미지 워터마킹 기술인 TrustMark를 선택했다.
TrustMark는 이미지의 RGB 픽셀에 기계가 읽을 수 있는 식별자를 삽입한다. 로고를 이미지 위에 올리는 방식이 아니라 픽셀 수준에서 값을 미세하게 변경하며, 공식 구현은 삽입, 판독과 제거 기능을 제공한다.
워터마크를 Spring Boot 안에 직접 넣지 않은 이유
무담의 메인 웹 애플리케이션은 Kotlin과 Spring Boot로 구현돼 있다.
반면 TrustMark의 주 구현체는 Python과 PyTorch를 사용한다. 워터마크를 적용하기 위해 Spring Boot 애플리케이션 안에 Python 런타임과 딥러닝 관련 의존성을 억지로 섞는 것은 적절하지 않았다.
기능의 책임도 달랐다.
- Spring Boot는 사용자 요청, 생성 작업, 이미지 저장과 데이터베이스 상태를 관리한다.
- RunPod 서버는 GPU에서 이미지 생성 모델을 실행한다.
- 워터마크 서비스는 이미지 바이트를 받아 표식을 삽입하거나 판독한다.
그래서 워터마크 기능을 별도의 FastAPI 서비스로 분리했다.
flowchart LR
U[사용자] --> S[Spring Boot]
S --> R[RunPod Serverless]
R -->|생성 이미지| S
S --> W[FastAPI Watermark Service]
W -->|워터마크 PNG| S
S --> D[(이미지 파일·DB)]
S --> U
이 구조에서 Spring Boot는 구체적인 TrustMark 구현을 알 필요가 없다.
Spring Boot가 알아야 하는 계약은 두 개뿐이다.
POST /watermark/embed 요청: 이미지 바이트 응답: 워터마크가 삽입된 PNG 바이트
POST /watermark/decode
요청: 이미지 바이트
응답:
{
"present": true,
"payload": "MU:DAM"
}
워터마크 구현을 교체하더라도 Spring Boot 쪽에서는 동일한 API 계약을 유지할 수 있다.
고정 서명 MU:DAM을 이미지 안에 넣기
현재 워터마크 구현의 핵심 코드는 작다.
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]: watermark_secret, watermark_present, watermark_schema = ( trustmark.decode(image) ) if watermark_present and watermark_secret == SIGNATURE: return True, watermark_secret return False, None
실제 저장소 역시 모든 이미지에 고정된 MU:DAM 문자열을 삽입하고, 판독 결과가 정확히 같은 문자열일 때만 무담의 워터마크가 있다고 판단한다.
TrustMark에는 여러 모델 변형이 있다. 무담에서는 기본 모델인 Q를 사용했다.
초기 코드 주석에는 Q를 품질 우선 모델로 설명했지만, 공식 문서를 다시 확인하면 더 정확한 설명은 다음과 같다.
Q는 강인성과 시각적 품질의 균형을 제공하는 기본 모델이다.- 시각적 품질을 가장 우선하는 모델은
P다. - 서로 다른 모델 변형으로 삽입한 워터마크는 다른 변형의 디코더로 읽을 수 없다.
TrustMark 공식 문서도 대부분의 일반적인 사용 사례에는 Q의 기본 설정을 사용할 수 있다고 설명한다.
무담에서 Q를 선택한 이유는 여러 모델을 직접 벤치마크한 결과가 아니었다.
공모전 개발 기간과 팀원들의 일정이 제한된 상황에서, 범용적인 기본 모델을 우선 적용해 출처 표식 파이프라인을 완성하는 선택이었다.
이 부분은 과장하지 않기로 했다.
우리는 Q와 P를 대상으로 이미지별 PSNR, SSIM, 재압축 검출률을 비교하지 않았다. 따라서 “무담에 가장 적합한 모델을 실험으로 선정했다”고 말할 수는 없다.
현재 구현은 TrustMark의 기본 권장 구성을 실제 서비스 파이프라인에 적용한 첫 단계다.
FastAPI는 이미지 바이트만 받는다
처음에는 일반적인 파일 업로드 API처럼 multipart/form-data를 사용할 수도 있었다.
하지만 이 서비스의 호출자는 웹 브라우저가 아니라 Spring Boot 하나뿐이다. 파일 이름이나 추가 폼 데이터도 필요하지 않았다.
그래서 요청 본문에 이미지 바이트를 그대로 전달하도록 구성했다.
@app.post("/watermark/embed") async def embed(request: Request) -> Response: reject_if_content_length_exceeds_limit(request) image_bytes = await request.body() watermarked_png_bytes = await run_in_threadpool( embed_sync, image_bytes, ) return Response( content=watermarked_png_bytes, media_type="image/png", )
이미지는 Pillow를 통해 읽은 뒤 RGB로 변환한다.
def decode_image(image_bytes: bytes) -> Image.Image: if len(image_bytes) > MAX_UPLOAD_BYTES: raise InvalidImageError() try: return Image.open( io.BytesIO(image_bytes) ).convert("RGB") except ( UnidentifiedImageError, OSError, Image.DecompressionBombWarning, Image.DecompressionBombError, ) as exception: raise InvalidImageError() from exception
입력 파일은 Pillow가 읽을 수 있는 형식이라면 처리할 수 있지만, 워터마크 적용 결과는 항상 PNG로 다시 인코딩한다. 실제 관리자 업로드 경로에서는 PNG, JPEG와 WebP를 허용하고 있으며, 워터마크를 적용한 뒤 실제 파일 형식과 확장자가 어긋나지 않도록 최종 파일 이름도 .png로 변경한다.
무담에서는 투명 배경 이미지를 사용하지 않기 때문에 RGB 변환이 현재 서비스 요구사항에는 문제가 되지 않았다.
다만 일반적인 이미지 워터마크 서비스로 확장한다면 RGBA 이미지의 알파 채널을 별도로 보존해야 한다. TrustMark 자체는 RGB 픽셀을 대상으로 동작하므로 투명 PNG를 처리할 때는 알파 채널을 분리한 뒤, 워터마크 적용 후 다시 합쳐야 한다.
async def라고 모든 코드가 비동기는 아니다
FastAPI 엔드포인트를 async def로 작성했다고 해서 내부 작업까지 자동으로 비동기가 되는 것은 아니다.
다음 작업은 모두 CPU를 사용하거나 동기적으로 수행된다.
이미지 디코딩 → TrustMark 추론 → 워터마크 픽셀 생성 → PNG 인코딩
이를 이벤트 루프에서 그대로 실행하면 한 요청을 처리하는 동안 다른 비동기 요청이 대기할 수 있다.
그래서 이미지 처리 전체를 동기 함수로 묶고 FastAPI의 스레드풀에서 실행했다.
def embed_sync(image_bytes: bytes) -> bytes: original_image = decode_image(image_bytes) watermarked_image = embed_signature(original_image) output_buffer = io.BytesIO() watermarked_image.save( output_buffer, format="PNG", ) return output_buffer.getvalue()
watermarked_png_bytes = await run_in_threadpool( embed_sync, image_bytes, )
초기 구현에서 단순히 TrustMark 호출 부분만 분리했다면, 이미지 디코딩과 PNG 인코딩은 여전히 이벤트 루프를 점유했을 것이다.
리뷰 과정에서는 “워터마크 함수 하나”가 아니라 이미지 처리 파이프라인 전체가 동기 작업이라는 기준으로 범위를 다시 잡았다.
다만 이것으로 동시성 문제가 모두 해결된 것은 아니다.
현재 TrustMark 인스턴스는 프로세스 전역에서 하나만 생성된다. 여러 스레드가 같은 인스턴스를 동시에 사용할 때의 안전성을 별도로 부하 테스트하지 않았고, Uvicorn도 명시적인 다중 워커 설정 없이 실행된다. 따라서 현재 구조는 공모전 MVP의 내부 서비스로는 동작하지만, 요청량이 커지는 운영 환경에서는 다음 검증이 필요하다.
- TrustMark 인스턴스의 스레드 안전성 확인
- 동시 삽입·판독 부하 테스트
- 인스턴스 풀 또는 요청별 인스턴스 분리 검토
- 다중 프로세스 워커 구성
- CPU·GPU별 처리량 비교
- 요청 큐와 동시 처리 상한 적용
이미지 한 장이 처리되는 전체 과정
사용자가 이미지 생성을 요청한 뒤 결과가 저장되기까지의 실제 흐름은 다음과 같다.
sequenceDiagram
actor User as 사용자
participant Backend as Spring Boot
participant RunPod as RunPod AI Worker
participant Watermark as FastAPI Watermark
participant Storage as File Storage
participant Database as Database
User->>Backend: 문양·제품·색상·밀도 선택
Backend->>RunPod: 이미지 생성 요청
RunPod-->>Backend: 생성 이미지 Base64
Backend->>Storage: 원본 PNG 저장
Backend->>Watermark: POST /watermark/embed
Watermark-->>Backend: 워터마크 적용 PNG
alt 워터마크 적용 성공
Backend->>Storage: 임시 파일 저장
Backend->>Storage: 최종 파일로 원자적 교체
Backend->>Database: watermarked = true
else 워터마크 적용 실패
Backend->>Storage: 원본 이미지 유지
Backend->>Database: watermarked = false
end
Backend-->>User: 생성 완료
RunPod 모델 서버는 생성 결과를 PNG Base64로 반환한다. Spring Boot는 Base64를 디코딩해 파일로 저장하고, 그 파일을 다시 워터마크 서비스로 전달한다.
워터마크 서비스가 반환한 데이터를 기존 파일에 바로 덮어쓰지는 않았다.
먼저 같은 디렉터리에 임시 파일을 생성하고, 워터마크 결과를 임시 파일에 모두 쓴 뒤 원자적으로 최종 파일로 교체한다.
val temporaryPath = Files.createTempFile( originalPath.parent, finalFileName, ".tmp", ) Files.write( temporaryPath, watermarkedImageBytes, ) Files.move( temporaryPath, finalPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE, )
파일을 직접 덮어쓰는 중 프로세스가 종료되거나 디스크 오류가 발생하면 정상 원본까지 손상될 수 있다.
임시 파일을 이용하면 워터마크 결과가 완전히 기록되기 전까지 기존 파일을 유지할 수 있다. 실제 서비스 코드에서도 이 방식으로 파일 교체를 수행한다.
워터마크가 실패해도 생성된 이미지는 버리지 않는다
워터마크는 중요하지만, 이미지 생성의 본 기능은 아니다.
이미지 한 장을 생성하기 위해서는 외부 GPU 서버를 호출하고 Stable Diffusion 추론을 기다려야 한다. 생성에 이미 시간과 비용이 사용된 상태에서, 보조 기능인 워터마크 서버가 일시적으로 실패했다는 이유만으로 결과 전체를 폐기하는 것은 적절하지 않다고 판단했다.
그래서 다음 정책을 선택했다.
출처 표식도 중요하지만, 보조 기능의 장애 때문에 비용이 큰 AI 이미지 생성 결과까지 버려서는 안 된다고 판단했다.
워터마크가 성공하면 최종 PNG를 저장하고 watermarked=true를 기록한다.
실패하면 경고 로그를 남기고 원본 이미지를 유지한다.
val watermarkedFileName = watermarkService.applyWatermark(fileName) val finalFileName = watermarkedFileName ?: fileName image.generationStatus = GenerationStatus.COMPLETED image.filePath = finalFileName image.watermarked = watermarkedFileName != null
즉, 워터마크 서비스 장애와 이미지 생성 장애를 분리했다.
flowchart TD
A[AI 이미지 생성 성공] --> B{워터마크 적용 성공?}
B -->|예| C[워터마크 PNG 저장]
C --> D[watermarked = true]
B -->|아니오| E[원본 이미지 유지]
E --> F[watermarked = false]
D --> G[사용자에게 생성 완료]
F --> G
서비스 코드에서도 워터마크 삽입 실패는 예외를 외부로 다시 던지지 않고 null을 반환하며, 호출부는 원본 파일명을 사용한다.
이 결정은 단순한 예외 처리 방식보다 기능의 중요도를 구분하는 문제에 가까웠다.
- 핵심 기능: 사용자가 요청한 이미지를 생성한다.
- 부가 기능: 생성 이미지에 출처 표식을 삽입한다.
- 부가 기능 실패 시: 핵심 기능의 결과를 보존한다.
모든 단계를 하나의 성공·실패 상태로 묶는 것보다, 각 단계의 책임과 장애 영향을 분리하는 쪽이 실제 서비스 운영에 적합했다.
외부에 열려 있지 않은 내부 전용 서비스
워터마크 삽입 API를 외부에 공개할 이유는 없었다.
외부 사용자가 임의의 이미지에 MU:DAM 워터마크를 삽입할 수 있다면 출처 표식의 의미가 더 약해진다.
그래서 워터마크 컨테이너는 호스트 포트를 바인딩하지 않고 별도의 Docker 네트워크에만 연결했다.
Docker network: k-pattern-internal
Spring Boot 컨테이너와 워터마크 컨테이너가 같은 네트워크에 참여하며, Spring Boot만 컨테이너 이름과 내부 포트를 통해 워터마크 API를 호출한다.
flowchart LR
Internet((외부 인터넷))
User[사용자]
Backend[Spring Boot<br/>외부 포트 공개]
Watermark[FastAPI Watermark<br/>호스트 포트 미공개]
User --> Internet
Internet --> Backend
Backend -->|k-pattern-internal| Watermark
Internet -. 직접 접근 불가 .-> Watermark
워터마크 서비스의 배포 워크플로에서는 Docker 이미지를 배포 서버에 내려받은 뒤, 호스트 포트 없이 k-pattern-internal 네트워크에 컨테이너를 실행한다. Spring Boot 배포 설정도 같은 내부 네트워크에 참여하도록 구성돼 있다.
이 구조가 MU:DAM 문자열의 복제를 완전히 막는 것은 아니다.
TrustMark는 공개된 오픈소스이고, 현재 서명 문자열 역시 비밀 키가 아니다. 그러나 적어도 운영 서버의 삽입 엔드포인트를 누구나 호출할 수 있는 상태로 만들지는 않았다.
내부망 배포에서 발견한 모델 다운로드 문제
TrustMark의 모델 파일은 라이브러리 패키지 자체에 포함되지 않고, 처음 사용할 때 외부 저장소에서 내려받는다. 공식 저장소 역시 모델 파일이 용량 문제로 저장소에 포함되지 않으며 첫 실행 시 다운로드된다고 설명한다.
로컬에서는 문제가 없었다.
인터넷에 연결된 개발 환경에서 다음 코드를 처음 실행하면 필요한 모델을 자동으로 내려받을 수 있다.
TrustMark(model_type="Q")
하지만 실제 배포 후 컨테이너는 Docker 내부망에서 동작한다.
백엔드만 워터마크 서비스에 접근할 수 있도록 외부 통신을 제한한 상태에서, 컨테이너 시작 시 모델 다운로드가 필요하면 애플리케이션이 정상적으로 기동하지 못한다.
이를 해결하기 위해 모델 다운로드 시점을 런타임에서 이미지 빌드 시점으로 옮겼다.
RUN uv run python -c \ "from trustmark import TrustMark; TrustMark(model_type='Q')"
전체 흐름은 다음과 같다.
flowchart LR
A[GitHub Actions<br/>인터넷 접근 가능] --> B[Docker 이미지 빌드]
B --> C[TrustMark Q 모델 다운로드]
C --> D[모델을 포함한 이미지 생성]
D --> E[Docker Registry Push]
E --> F[배포 서버 Pull]
F --> G[내부망 컨테이너 실행]
G --> H[추가 다운로드 없이 기동]
Dockerfile에서는 TrustMark가 요구하는 시스템 라이브러리를 설치하고, Python 의존성을 동기화한 뒤, 모델을 빌드 과정에서 미리 초기화한다. 실행 시에는 의존성을 다시 동기화하지 않도록 uv run --no-sync를 사용한다.
이 경험을 통해 “개발 환경에서 실행된다”와 “배포 환경에서 독립적으로 기동된다”는 다른 조건이라는 점을 다시 확인했다.
특히 머신러닝 라이브러리는 코드 의존성 외에도 모델 가중치, 캐시, 시스템 라이브러리와 네트워크 접근에 의존할 수 있다.
컨테이너에 소스 코드와 Python 패키지만 넣는 것으로는 충분하지 않았다.
입력 검증은 파일 확장자 확인으로 끝나지 않았다
이미지 처리 API는 일반 JSON API보다 입력으로 인해 더 큰 메모리를 사용할 수 있다.
몇 MB짜리 압축 이미지가 디코딩된 뒤에는 훨씬 큰 픽셀 배열이 될 수 있다. 이미지 헤더에 비정상적으로 큰 해상도를 선언한 파일도 문제가 된다.
현재 구현에서는 한 요청의 최대 바이트 크기를 20MB로 제한한다.
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
Content-Length가 제한을 넘으면 요청 본문 전체를 읽기 전에 거부한다.
def reject_if_content_length_exceeds_limit( request: Request, ) -> None: content_length = request.headers.get( "content-length" ) if content_length is None: return try: declared_size = int(content_length) except ValueError: return if declared_size > MAX_UPLOAD_BYTES: raise InvalidImageError()
본문을 읽은 뒤에도 실제 바이트 길이를 다시 확인한다.
if len(image_bytes) > MAX_UPLOAD_BYTES: raise InvalidImageError()
이는 헤더만 믿지 않는 방어적 검증이다.
또한 Pillow의 DecompressionBombWarning을 예외로 승격하고, 다음 오류를 모두 잘못된 이미지 입력으로 처리한다.
UnidentifiedImageError OSError Image.DecompressionBombWarning Image.DecompressionBombError
손상된 이미지나 비정상적으로 큰 이미지는 내부 서버 오류인 500이 아니라 입력 오류인 400으로 반환한다. 실제 API 코드에는 본문 크기 제한, 손상된 이미지 처리, decompression bomb 방어와 공통 오류 응답이 구현돼 있다.
다만 현재 구현에도 남은 한계가 있다.
Content-Length 없이 chunked transfer encoding으로 전송되는 요청은 사전 크기 검사를 통과할 수 있고, request.body()가 전체 본문을 메모리에 올린 뒤에야 실제 길이를 확인한다.
현재 서비스는 외부에 노출되지 않고 Spring Boot 한 곳만 호출하기 때문에 이 위험을 제한적으로 수용했다.
외부 공개 API로 전환한다면 request.stream()을 이용해 읽은 바이트 수가 제한을 넘는 즉시 중단하는 스트리밍 처리가 필요하다.
오류 응답도 두 서비스의 계약이다
Spring Boot와 FastAPI가 분리되면서 성공 응답뿐 아니라 실패 응답도 계약으로 관리해야 했다.
FastAPI는 잘못된 이미지에 다음 형태로 응답한다.
{ "code": "INVALID_IMAGE", "message": "이미지를 디코딩할 수 없습니다." }
예상하지 못한 서버 오류는 다음과 같다.
{ "code": "WATERMARK_SERVICE_ERROR", "message": "워터마크 처리 중 오류가 발생했습니다." }
Spring Boot의 WebClient는 이 오류 본문을 기존 ErrorResponse 형식으로 역직렬화한다.
val remoteError = runCatching { exception.getResponseBodyAs( ErrorResponse::class.java ) }.getOrNull()
FastAPI가 임의의 문자열이나 HTML 오류 페이지를 반환하면 Spring Boot에서 일관된 오류 처리가 어려워진다.
두 애플리케이션이 같은 언어를 사용하지 않더라도, 오류 코드와 응답 스키마를 공유하면 호출부에서 원격 서비스의 세부 구현을 알 필요가 없다. 실제 클라이언트는 HTTP 오류, 타임아웃, 빈 응답과 역직렬화 실패를 모두 워터마크 서비스 오류로 통합한다.
테스트는 정상 입력보다 실패 조건이 더 많았다
가장 먼저 작성한 테스트는 단순했다.
원본 이미지 생성 → 워터마크 삽입 → 삽입 결과 판독 → MU:DAM 확인
def test_embed_then_decode_returns_signature(): original_image = make_test_image() watermarked_image = embed_signature( original_image ) present, payload = decode_signature( watermarked_image ) assert present is True assert payload == SIGNATURE
하지만 실제 서비스에 필요한 것은 정상 동작만이 아니었다.
최종적으로 다음 조건을 테스트했다.
| 구분 | 테스트 내용 |
|---|---|
| Health check | /health가 정상 상태를 반환하는지 |
| 정상 왕복 | 삽입한 이미지를 다시 판독했을 때 MU:DAM이 검출되는지 |
| 미삽입 이미지 | 일반 이미지에서 워터마크가 없다고 반환하는지 |
| 잘못된 바이트 | 이미지가 아닌 입력을 400으로 처리하는지 |
| 손상된 PNG | 헤더는 있지만 픽셀 데이터가 잘린 이미지를 처리하는지 |
| 업로드 크기 | 20MB 초과 본문을 거부하는지 |
| 조기 거부 | 큰 Content-Length를 본문 읽기 전에 거부하는지 |
| Decompression bomb | 비정상 해상도 선언을 안전하게 차단하는지 |
| 내부 오류 | 예상하지 못한 오류를 공통 500 스키마로 반환하는지 |
테스트용 decompression bomb 이미지는 실제로 수십 GB의 픽셀을 만들지 않았다.
PNG의 IHDR에 100000 × 100000 크기만 선언한 작은 바이트 배열을 생성하여 Pillow가 이미지 헤더를 확인하는 단계에서 DecompressionBombError를 발생시키도록 했다.
image_header_data = struct.pack( ">IIBBBBB", 100_000, 100_000, 8, 2, 0, 0, 0, )
이를 통해 테스트 환경의 메모리를 고갈시키지 않고 방어 로직을 검증할 수 있었다.
PR 기준으로 단위 테스트 2개와 API 테스트 14개, 총 16개의 테스트가 통과했다. Docker 빌드, 컨테이너 실행, health·embed·decode 요청에 대한 수동 검증도 함께 수행했다.
하지만 이 테스트가 워터마크의 모든 특성을 증명하지는 않는다.
현재 테스트에는 다음 항목이 없다.
- JPEG 재압축 이후 검출률
- 이미지 리사이즈 이후 검출률
- 크롭 이후 검출률
- 블러와 색상 변경 이후 검출률
- 화면 캡처 이후 검출률
- SNS나 메신저 재업로드 이후 검출률
- 원본 대비 PSNR·SSIM
- CPU 처리 시간
- 동시 요청 처리량
TrustMark 공식 자료는 JPEG 압축, 색상 변화, 블러, 온라인 재공유, 화면 캡처와 일부 크롭 같은 변형에 대응하도록 학습됐다고 설명한다. 그러나 이는 TrustMark 연구와 공식 구현의 특성이지, 무담의 이미지와 배포 환경을 대상으로 직접 측정한 결과는 아니다.
따라서 이 글에서는 “어떤 변형에도 워터마크가 유지된다”고 주장하지 않는다.
무담에서 검증한 사실은 다음 범위다.
워터마크를 삽입한 PNG에서
MU:DAM을 정상적으로 다시 판독할 수 있으며, 서비스 파이프라인과 배포 환경에서 삽입·검증 기능이 동작한다.
강인성을 말하려면 별도의 실험 데이터가 필요하다.
Claude Code는 구현자가 아니라 검토 도구에 가까웠다
워터마크 기능의 구조와 정책은 직접 결정했다.
- 별도 Python 서비스로 분리할 것
- Spring Boot만 내부망으로 호출할 것
MU:DAM고정 표식을 사용할 것- 워터마크 실패 시 원본 이미지를 유지할 것
- 성공 여부를 데이터베이스에 기록할 것
- 관리자만 임의의 이미지를 검증할 수 있게 할 것
구현과 리뷰 과정에서는 Claude Code를 보조 도구로 활용했다.
직접 구현한 코드의 검토를 요청하기도 했고, 일부 반복적인 코드와 테스트 작성에는 Claude Code의 도움을 받았다. 다만 AI가 제안한 내용을 그대로 반영하기보다 실제 오류가 발생할 수 있는지 코드와 테스트로 다시 확인했다.
리뷰 과정에서 특히 유효했던 지점은 다음과 같다.
1. 이벤트 루프 블로킹 범위
처음에는 TrustMark 호출만 무거운 작업으로 보기 쉬웠다.
리뷰를 통해 이미지 디코딩부터 PNG 인코딩까지 전체 파이프라인을 스레드풀로 보내야 한다는 점을 다시 확인했다.
2. 손상된 이미지의 예외 종류
Image.open()에서 모든 오류가 발생하는 것은 아니다.
파일 형식은 인식되지만 픽셀 데이터가 잘린 경우 .convert("RGB") 또는 실제 디코딩 단계에서 OSError가 발생할 수 있었다.
이를 별도 테스트로 재현했다.
3. Decompression bomb
파일 크기가 작다고 메모리 사용량도 작은 것은 아니다.
이미지 헤더에 선언된 픽셀 수를 확인하고, Pillow의 경고까지 예외로 전환했다.
4. 모델의 런타임 다운로드
로컬에서는 자동 다운로드가 편리했지만, 내부망 배포에서는 컨테이너 기동 실패의 원인이 됐다.
모델을 Docker 빌드 단계에서 내려받도록 변경했다.
5. 정상 응답처럼 보이는 잘못된 응답
워터마크 서버가 200을 반환하더라도 본문이 비어 있다면 원본 파일을 빈 파일로 덮어쓸 수 있다.
Spring Boot 클라이언트에서 빈 응답을 명시적으로 거부하도록 보완했다.
AI 코드 리뷰의 장점은 사람이 익숙한 정상 흐름 밖의 질문을 빠르게 많이 던질 수 있다는 점이었다.
하지만 “문제가 있을 수 있다”는 문장과 “문제가 재현됐다”는 사실은 다르다.
그래서 가능한 항목은 테스트를 작성하고, Docker 환경에서 직접 실행한 뒤 반영했다.
이번 작업에서 Claude Code는 코드를 대신 책임지는 도구가 아니라, 놓친 조건을 발견하기 위한 두 번째 시선으로 사용하는 편이 적절했다.
관리자 검증 도구 만들기
워터마크를 삽입하는 기능만으로는 운영자가 실제 결과를 확인하기 어렵다.
그래서 관리자 페이지에 별도의 검증 화면을 추가했다.
관리자 이미지 업로드 → Spring Boot 파일 검증 → FastAPI /watermark/decode 호출 → 워터마크 존재 여부와 payload 출력
검증에 성공하면 다음과 같이 표시된다.
워터마크가 존재합니다. 서명: MU:DAM
검출되지 않으면 다음 메시지를 보여준다.
워터마크가 존재하지 않습니다.
관리자 검증 화면은 PNG, JPG, JPEG와 WebP 입력을 허용하며, 확장자와 Content-Type을 모두 화이트리스트로 확인한 뒤 워터마크 서비스에 전달한다.
이 화면은 사용자에게 공개된 워터마크 판독기가 아니다.
현재는 관리자만 접근할 수 있으며, 운영 중 이미지가 무담에서 처리된 결과인지 확인하기 위한 내부 도구다.
현재 워터마크가 실제로 증명하는 것
현재 구현의 의미를 과장하면 안 된다.
MU:DAM 문자열이 검출됐다는 것은 다음을 의미한다.
이 이미지에는 무담에서 사용하는 TrustMark 형식의
MU:DAM표식이 들어 있다.
하지만 다음 사실까지 증명하지는 못한다.
- 무담 서버가 실제로 해당 이미지를 발급했는지
- 어떤 사용자가 생성했는지
- 어떤 프롬프트와 모델 버전으로 생성했는지
- 언제 생성됐는지
- 워터마크 삽입 이후 이미지가 수정되지 않았는지
- 다른 사람이 같은 문자열을 다시 삽입하지 않았는지
- 이미지의 저작권이 누구에게 있는지
- 이미지 내용이 사실인지
TrustMark는 비밀 키를 가진 주체만 읽을 수 있는 스테가노그래피 시스템이 아니다.
공개된 기술이기 때문에 누구나 판독기를 구현할 수 있고, 워터마크를 제거하거나 다른 이미지에 다시 삽입하는 것도 기술적으로 가능하다. TrustMark 공식 문서 역시 모든 비가시적 워터마크는 이미지 처리로 제거될 수 있으며, 위조 대응의 핵심은 워터마크 식별자를 신뢰할 수 있는 출처 시스템과 연결하는 것이라고 설명한다.
따라서 현재 무담의 구현을 “위변조 방지 시스템”이라고 단정하는 것은 정확하지 않다.
더 적절한 표현은 다음과 같다.
무담이 생성하거나 처리한 이미지에 서비스 식별 표식을 남기는 출처 표시의 첫 단계다.
대형 생성형 AI 서비스는 출처를 어떻게 남기고 있을까
2026년 현재 주요 생성형 AI 서비스는 한 가지 방식만 사용하기보다 여러 출처 신호를 함께 사용하는 방향으로 발전하고 있다.
대표적인 방식은 다음 세 가지다.
1. 파일 메타데이터
이미지 파일 안에 생성 도구, 발급자, 생성·수정 이력 등의 정보를 기록한다.
C2PA의 Content Credentials가 대표적인 표준이다.
C2PA는 출처 정보를 assertion과 claim으로 구성하고, 디지털 서명을 통해 정보가 변조됐는지를 검증한다. 다만 C2PA가 검증하는 것은 “서명된 출처 정보가 해당 파일과 연결돼 있고 변조되지 않았는가”이며, 이미지의 내용이 사실인지 또는 좋은 정보인지 자체적으로 판단하는 표준은 아니다.
장점은 워터마크보다 많은 정보를 제공할 수 있다는 것이다.
발급 기관 생성 도구 생성 날짜 AI 사용 여부 편집 작업 원본 자산
단점은 메타데이터를 지원하지 않는 플랫폼을 거치거나 화면 캡처를 하면 정보가 제거될 수 있다는 점이다.
2. 픽셀 기반 비가시적 워터마크
이미지 픽셀에 사람이 보기 어려운 신호를 삽입한다.
무담이 사용한 TrustMark와 Google의 SynthID가 이 범주에 해당한다.
Google은 SynthID를 이미지, 영상, 오디오와 텍스트에 적용하고 있다. 이미지의 경우 생성 시점에 비가시적 신호를 픽셀에 삽입하며, 크롭, 필터, 프레임 변경과 손실 압축 같은 일부 변형에도 신호가 유지되도록 설계했다고 설명한다.
메타데이터보다 많은 정보를 직접 담기는 어렵지만, 메타데이터가 사라진 뒤에도 일부 식별 신호가 남을 가능성이 있다.
3. 메타데이터와 워터마크의 결합
현재 가장 현실적인 접근은 둘 중 하나를 선택하는 것이 아니라 함께 사용하는 것이다.
OpenAI는 2026년 기준 ChatGPT, Codex와 API에서 생성한 이미지에 C2PA Content Credentials와 SynthID 워터마크를 함께 사용한다고 안내한다.
C2PA는 상세한 출처 정보를 제공하고, SynthID는 파일 메타데이터가 제거됐을 때도 남을 수 있는 추가 신호 역할을 한다. OpenAI의 검증 도구는 두 신호 중 하나 이상이 검출되는지 확인한다. 다만 OpenAI 역시 검증 결과가 이미지의 정확성, 무편집 상태, 법적 소유권 또는 올바른 맥락까지 증명하지는 않는다고 명시한다. Adobe Firefly는 생성 이미지에 Content Credentials를 자동으로 적용한다. 발급자, 발급 날짜, 사용한 애플리케이션, AI 도구와 작업 정보를 기록하며, 자격 증명의 사본을 클라우드에 저장해 파일에서 정보가 분리된 경우 복구할 수 있도록 한다.
이를 표로 비교하면 다음과 같다.
| 구분 | 무담 현재 구현 | OpenAI | Adobe Firefly | |
|---|---|---|---|---|
| 주요 방식 | TrustMark | SynthID | C2PA + SynthID | Content Credentials |
| 픽셀 워터마크 | 사용 | 사용 | 사용 | Durable Credentials 구성에서 활용 가능 |
| 서명된 메타데이터 | 없음 | 제품별 별도 구성 가능 | 사용 | 사용 |
| 이미지별 식별자 | 없음, 고정 MU:DAM |
내부 식별 신호 | 플랫폼 출처 신호 | Content Credential 식별 정보 |
| 발급 이력 저장 | 없음 | 서비스 내부 체계 | 서비스 검증 체계 | 파일 및 클라우드 |
| 공개 검증 | 관리자 전용 | Gemini·Detector | 공개 검증 도구 | Content Credentials Inspect |
| 제공 가능한 정보 | 무담 표식 존재 여부 | Google AI 생성·편집 신호 | OpenAI 생성 출처 신호 | 생성·편집 도구와 이력 |
| 현재 한계 | 고정 문자열 위조 가능 | 극단적인 변형에는 한계 | 신호 제거·저하 가능 | 메타데이터 제거 가능 |
Google도 SynthID가 모든 공격을 막는 완전한 해결책은 아니라고 설명하고 있으며, 메타데이터와 픽셀 워터마크를 상호 보완적으로 사용할 수 있다고 밝힌다.
결국 업계의 방향은 다음에 가깝다.
flowchart LR
A[AI 이미지 생성] --> B[서명된 C2PA 메타데이터]
A --> C[픽셀 기반 비가시적 워터마크]
A --> D[이미지 지문·해시]
B --> E[검증 시스템]
C --> E
D --> E
E --> F[출처와 변경 이력 확인]
워터마크 하나만으로 모든 것을 증명하려 하기보다, 서로 다른 실패 조건을 가진 출처 신호를 겹쳐 사용하는 것이다.
무담과 대형 서비스의 가장 큰 차이
무담과 대형 서비스의 차이는 워터마크 알고리즘의 이름보다 워터마크 뒤에 연결된 검증 체계에 있다.
현재 무담은 다음과 같다.
이미지 └─ TrustMark payload: MU:DAM
검증할 때는 MU:DAM 문자열이 있는지만 확인한다.
대형 출처 시스템은 다음과 같은 구조에 가깝다.
이미지 ├─ 픽셀 워터마크: 식별자 ├─ 서명된 C2PA 메타데이터 └─ 서버 저장 기록 ├─ 생성 모델 ├─ 생성 시간 ├─ 이미지 지문 ├─ 편집 이력 └─ 발급 기관
TrustMark 공식 문서도 워터마크의 식별자를 데이터베이스에 저장된 출처 정보의 조회 키로 사용하고, C2PA와 결합하는 방식을 권장한다. 이를 Durable Content Credential이라고 부른다. 현재 무담은 이 구조의 첫 번째 줄만 구현한 상태다.
시간이 더 있었다면 이미지별 식별자를 넣고 싶었다
처음부터 고정 문자열이 최종 구조라고 생각한 것은 아니었다.
이상적인 구조는 이미지마다 서로 다른 식별자를 발급하고, 그 식별자를 데이터베이스의 생성 기록과 연결하는 것이다.
그러나 공모전 개발 과정에서 팀원 모두가 학업과 다른 일정을 병행하고 있었고, 실제 개발에 집중할 수 있는 기간은 길지 않았다.
모델 학습, RunPod 배포, Spring Boot 연동, 웹 기능과 공모전 제출 문서를 동시에 준비해야 했다.
따라서 이번에는 다음 질문 하나에만 답하는 범위로 줄였다.
이 이미지에 무담이 사용하는 출처 표식이 있는가?
이 결정 덕분에 짧은 시간 안에 생성 파이프라인, 배포, 관리자 검증까지 완성할 수 있었다.
반면 다음 정보는 남기지 못했다.
- 이미지별 워터마크 ID
- 생성 요청 ID
- 생성 사용자
- 생성 모델 버전
- LoRA 버전
- 프롬프트 정보
- 생성 시간
- 원본 이미지 해시
- 발급 서버의 디지털 서명
이는 현재 구현의 가장 큰 아쉬움이다.
다음 버전에서는 어떻게 개선할 수 있을까
1단계: 이미지별 워터마크 ID 발급
모든 이미지에 같은 MU:DAM 문자열을 넣는 대신, 이미지마다 임의의 식별자를 생성한다.
TrustMark는 100비트의 원시 페이로드를 제공하며, 오류 정정 수준에 따라 실제 식별자에 사용할 수 있는 비트 수가 줄어든다. 따라서 128비트 UUID 전체를 문자열로 그대로 넣기보다, 워터마크 용량에 맞는 불투명한 식별자를 설계해야 한다.
예시는 다음과 같다.
watermarkId = 64~70bit random identifier
sequenceDiagram
participant Backend as Spring Boot
participant Database as Database
participant Watermark as Watermark Service
Backend->>Backend: watermarkId 생성
Backend->>Database: 생성 기록 저장
Backend->>Watermark: 이미지 + watermarkId
Watermark-->>Backend: 워터마크 적용 이미지
Backend->>Database: 이미지 해시·파일 경로 저장
데이터베이스에는 다음 정보를 저장할 수 있다.
| 필드 | 설명 |
|---|---|
watermark_id |
이미지에 삽입된 식별자 |
image_id |
무담 이미지 ID |
created_at |
생성 시각 |
model_version |
이미지 생성 모델 버전 |
lora_version |
사용한 LoRA 버전 |
image_sha256 |
원본 파일의 정확한 해시 |
perceptual_hash |
변형된 이미지와 유사도를 비교할 지문 |
generation_request_id |
생성 요청 추적 ID |
status |
발급·취소·삭제 상태 |
검증 과정에서는 워터마크를 읽는 것만으로 끝내지 않는다.
1. 워터마크 ID 판독 2. 데이터베이스에서 발급 기록 조회 3. 이미지 perceptual hash 비교 4. 모델·생성 시각·발급 상태 반환
이 구조라면 제3자가 유효한 워터마크 ID를 다른 이미지에 복사했을 때 이미지 지문이 일치하지 않는다는 사실을 확인할 수 있다.
TrustMark 공식 자료 역시 워터마크 위조 대응을 위해 출처 메타데이터에 저장된 이미지 지문과 실제 이미지의 지문을 비교하는 방식을 설명한다.
2단계: C2PA Content Credentials 적용
워터마크 ID는 정보를 조회하는 키로 사용하고, 상세한 생성 이력은 C2PA manifest로 제공할 수 있다.
{ "issuer": "MU:DAM", "createdAt": "2026-08-05T13:00:00Z", "generator": { "baseModel": "Stable Diffusion v1.5", "adaptation": "MU:DAM LoRA", "modelVersion": "..." }, "actions": [ "ai_generated", "watermark_embedded" ], "watermarkId": "..." }
실제 manifest는 서버 인증서로 서명한다.
검증자는 다음 두 가지를 확인한다.
- manifest의 서명이 무담이 신뢰하는 인증서로 만들어졌는가
- manifest가 현재 이미지와 변조 없이 연결돼 있는가
TrustMark 저장소에는 TrustMark 워터마크를 C2PA manifest에 표시하는 예제도 제공된다.
3단계: 내구성 있는 출처 기록
C2PA 메타데이터가 파일에서 제거되는 경우를 대비해 manifest 사본을 서버에 저장한다.
이미지에서 TrustMark ID를 판독하면 서버에서 해당 ID의 C2PA manifest를 다시 조회한다.
flowchart LR
A[검증 대상 이미지] --> B[TrustMark ID 판독]
B --> C[무담 Provenance API]
C --> D[(C2PA Manifest 저장소)]
D --> E[서명 검증]
A --> F[Perceptual Hash 계산]
E --> G{이미지 지문 일치?}
F --> G
G -->|일치| H[무담 발급 기록 확인]
G -->|불일치| I[복제·변형 가능성 경고]
이 구조가 완성되면 워터마크가 단순한 MU:DAM 문자열이 아니라 출처 기록을 찾아가기 위한 키가 된다.
4단계: 실제 이미지 변형 벤치마크
워터마크를 운영 기능으로 신뢰하려면 무담 이미지에 대한 자체 실험이 필요하다.
최소한 다음 조건을 테스트해야 한다.
| 변형 | 테스트 값 예시 |
|---|---|
| JPEG 압축 | 품질 95, 85, 75, 50 |
| 리사이즈 | 75%, 50%, 25% |
| 크롭 | 5%, 10%, 20%, 30% |
| 블러 | Gaussian blur 단계별 |
| 노이즈 | Gaussian noise 단계별 |
| 색상 | 밝기·대비·채도 변경 |
| 회전 | 1°, 3°, 5° |
| 재인코딩 | PNG → JPEG → WebP |
| 화면 캡처 | 데스크톱·모바일 캡처 |
| 플랫폼 | SNS·메신저 업로드 후 다운로드 |
| 인쇄 | 종이 또는 실제 굿즈 인쇄 후 촬영 |
각 조건에서 다음 지표를 수집한다.
워터마크 검출 성공률 페이로드 비트 정확도 PSNR SSIM 삽입 처리 시간 판독 처리 시간
모델 Q와 P, 워터마크 강도와 오류 정정 수준을 함께 비교해야 무담의 사용 환경에 적합한 설정을 선정할 수 있다.
5단계: 운영 관측성과 재시도
현재 데이터베이스에는 워터마크 성공 여부만 기록된다.
운영 규모가 커지면 다음 정보가 필요하다.
워터마크 처리 시간 실패 원인 재시도 횟수 워터마크 서비스 버전 TrustMark 모델 변형 입력·출력 파일 크기 회로 차단 상태
실패한 이미지에는 비동기 재시도 작업을 등록하고, 일정 횟수 이상 실패하면 관리자에게 알림을 보내는 구조로 확장할 수 있다.
워터마크는 저작권을 만들어주지 않는다
워터마크를 넣었다고 해서 이미지의 저작권 문제가 자동으로 해결되는 것은 아니다.
또한 워터마크가 검출된다는 이유만으로 이미지 내용이 사실이라는 의미도 아니다.
출처 표식이 제공하는 것은 “이 이미지가 어떤 시스템과 연결돼 있는가”에 대한 기술적 단서다.
출처 확인 ≠ 사실 확인 출처 확인 ≠ 저작권 소유 증명 출처 확인 ≠ 무편집 증명 AI 생성 표식 ≠ 이미지의 모든 픽셀이 AI 생성
OpenAI의 검증 안내도 출처 신호가 OpenAI 도구의 생성 여부를 확인하는 데 사용될 뿐, 이미지가 정확한지, 편집되지 않았는지, 법적으로 소유됐는지 또는 올바른 맥락에서 제시됐는지는 보증하지 않는다고 설명한다.
이 구분을 명확히 해야 워터마크가 실제 제공할 수 있는 가치와 제공할 수 없는 가치를 혼동하지 않는다.
처음에는 문자열 하나면 충분하다고 생각했다
처음 구현한 코드는 사실상 두 줄에 가까웠다.
watermarked_image = trustmark.encode( image, "MU:DAM", ) payload, present, schema = trustmark.decode( watermarked_image, )
하지만 이를 실제 서비스에 넣으려면 훨씬 많은 질문에 답해야 했다.
- 워터마크 기능은 어느 서비스의 책임인가
- 이미지 생성과 워터마크의 트랜잭션을 어떻게 분리할 것인가
- 워터마크가 실패하면 사용자 요청도 실패시킬 것인가
- PNG가 아닌 입력은 어떻게 처리할 것인가
- 이미지 처리로 이벤트 루프가 막히지 않는가
- 손상된 이미지와 서버 오류를 어떻게 구분할 것인가
- 큰 이미지가 메모리를 모두 사용하면 어떻게 할 것인가
- 내부망 환경에서 모델을 어떻게 준비할 것인가
- 다른 서비스가 이해할 수 있는 오류 계약은 무엇인가
- 운영자는 워터마크를 어떻게 검증할 것인가
- 현재 표식이 실제로 무엇을 증명하는가
결과적으로 가장 중요했던 코드는 TrustMark.encode() 한 줄이 아니었다.
그 한 줄을 기존 생성 파이프라인에 안전하게 연결하고, 실패했을 때 핵심 기능을 보호하며, 운영 환경에서 다시 확인할 수 있도록 만드는 코드였다.
생성형 AI의 결과물을 만든다는 것
무담 프로젝트를 시작했을 때 가장 큰 관심은 모델이었다.
전통문양을 얼마나 잘 학습할 수 있는지, 한국 문양의 특징을 유지하면서 제품에 자연스럽게 적용할 수 있는지, 사용자의 조건을 결과물에 얼마나 정확하게 반영할 수 있는지를 고민했다.
하지만 서비스를 완성해갈수록 모델 밖의 문제가 더 많이 보였다.
이미지는 생성된 뒤 저장되고, 다운로드되고, 공유되고, 변형된다.
모델이 좋은 이미지를 한 번 만들어내는 것만으로는 충분하지 않았다. 그 이미지가 서비스 밖으로 나간 뒤에도 어떤 과정을 거쳐 만들어졌는지 설명할 수 있어야 했다.
현재 무담의 MU:DAM 워터마크는 완성된 출처 증명 시스템이 아니다.
고정 문자열을 사용하기 때문에 암호학적인 발급 증명을 제공하지 못하고, 이미지별 생성 기록과도 연결돼 있지 않다. 실제 플랫폼 재공유나 압축 환경에서의 검출률도 아직 측정하지 않았다.
그럼에도 이번 구현에는 의미가 있었다.
모델이 이미지를 생성한다
에서 끝났던 파이프라인을 다음과 같이 확장했기 때문이다.
모델이 이미지를 생성한다 → 서비스의 출처 표식을 삽입한다 → 삽입 성공 여부를 기록한다 → 운영자가 다시 검증할 수 있다
공모전이라는 제한된 기간 안에서는 여기까지 구현했다.
다음 단계에서는 이미지별 식별자, 발급 데이터베이스, 이미지 지문, C2PA 서명과 실제 변형 벤치마크를 연결하고 싶다.
좋은 생성형 AI 서비스를 만드는 일은 모델의 출력 품질만 높이는 작업이 아니었다.
결과물이 생성된 이후의 생명주기까지 설계하는 일이었다.
그리고 무담의 이미지 안에 남긴 작은 문자열 MU:DAM은 그 고민을 시작한 첫 번째 표식이었다.
첫 댓글을 남겨보세요.