유출을 막을 수 없다면 추적이라도: 디지털 상품에 워터마크 심기
들어가며
디지털 상품은 한 번 새면 끝난다. 구매자 한 명이 파일을 커뮤니티나 단톡방에 올리면, 그 뒤로는 아무도 살 이유가 없다. 실물과 달리 복제 비용이 0이기 때문이다.
나는 네이버 스마트스토어에서 카카오톡 테마(.ktheme), PPT 템플릿, 이미지와 PDF 같은 파일을 판다. 판매와 발송은 직접 만든 자동발송 시스템이 맡는다(자동발송 시스템 개발기). 이 글은 그중 유출 추적 워터마크만 다룬다.
먼저 인정하고 시작한다. 이건 DRM이 아니다. 암호학적 서명도 아니다. 파일에 심는 건 평문 텍스트다. 마음먹은 사람은 지울 수 있다. 그래도 만든 이유가 이 글의 전부다.
"막기"를 포기한 이유
기존에 할 수 있는 대응은 전부 한계가 있었다.
| 방법 | 한계 |
|---|---|
| 다운로드 횟수 제한, 링크 만료 | 이미 받아 간 파일이 퍼지는 건 못 막는다 |
| DRM | 카카오톡 테마나 PPT에는 적용할 수단이 사실상 없다. 있어도 개인 판매자가 감당할 비용이 아니다 |
| 눈에 보이는 워터마크 | 돈 주고 산 상품의 가치를 떨어뜨린다 |
그래서 목표를 바꿨다.
복제를 막는 것은 포기한다. 대신 유출된 파일을 손에 넣었을 때 어느 주문에서 나갔는지 알 수 있게 한다.
실질적으로는 억제 전략이다. "흘리면 누군지 알 수 있다"는 사실 자체가 유출 동기를 낮춘다. 그리고 실제로 유출이 생기면 대응할 근거가 된다.
무엇을, 언제 심나
다운로드하는 순간에 심는다
업로드할 때 미리 심어 둘 수는 없다. 워터마크 내용이 주문마다 다르기 때문이다. 원본 파일은 서버에 하나만 두고, 고객이 다운로드 버튼을 누르는 순간 메모리에서 복사본을 만들어 워터마크를 심어 내려준다.
대가: 다운로드할 때마다 CPU와 메모리를 쓴다(zip을 다시 압축한다). 파일이 수십 MB면 부담이다. 트래픽이 작은 개인 스토어라 감당할 수 있다고 봤다. 커지면 캐싱이나 사전 생성으로 바꿔야 한다.
담는 정보: 주문번호, 발급일, 스토어
[License / Order: 20260101-0001 / Date: 2026-01-01 / Store: 솔파니 스토어]- 주문번호: 이것만 있으면 DB에서 주문을 찾아 구매자를 확인할 수 있다.
- 발급일: 언제 받아 간 파일인지.
- 스토어: 이 시스템은 여러 판매자가 쓸 수 있게 만들었다. 인터넷 어딘가에서 파일이 발견됐을 때 "이게 어느 스토어에서 팔린 거야?"까지 워터마크만 보고 알 수 있어야 한다.
처음부터 이랬던 건 아니다. 처음에는 구매자 이름, 이메일, 전화번호까지 넣었다. 왜 뺐는지는 뒤에서 다룬다.
실패하면 원본을 그냥 보낸다
} catch (Exception e) {
log.error("Failed to inject watermark into theme file. Sending original file as fallback.", e);
return originalResource; // 추적보다 "고객이 상품을 받는 것"이 우선
}워터마크를 심다 실패했다고 다운로드를 막으면 돈을 낸 고객이 상품을 못 받는다. 추적은 부가 기능이고, 상품 전달이 본질이다.
대가도 있다. 추적할 수 없는 파일이 조용히 나간다. 로그는 남지만 아무도 안 본다. 이 관용이 뒤에 나올 사고를 오래 못 잡은 원인 중 하나였다.
포맷별로 심는 법
포맷마다 "파일이 깨지지 않으면서 텍스트를 숨길 수 있는 자리"가 다르다. 세 가지 전략을 썼다.
전략 A: 텍스트 파일의 주석으로 (카카오톡 테마)
카카오톡 테마는 실제로는 zip이다. 안에 테마 정보를 담은 순수 텍스트 CSS 파일이 들어 있다. 그 파일 끝에 CSS 주석으로 덧붙인다.
} else if (name.toLowerCase().endsWith(".css")) {
// 어떤 CSS 파서도 무시하는 주석(/* ... */)으로 파일 끝에 덧붙인다.
// 테마 렌더링에는 영향을 주지 않는다.
content = content + "\n/* " + watermarkText + " */\n";
zos.write(content.getBytes(StandardCharsets.UTF_8));
}CSS 주석은 파서가 무시하도록 표준에 정해져 있다. "아마 괜찮겠지"가 아니라 스펙상 안전한 자리를 고른 것이다.
전략 B: zip에 엔트리 하나 추가 (APK, PPT, PPTX)
이 포맷들도 zip이다. 기존 엔트리를 그대로 복사한 뒤, 끝에 워터마크 엔트리 하나를 추가한다. 이 포맷들은 모르는 엔트리를 무시한다.
// 기존 엔트리는 그대로 복사한 뒤, 마지막에 워터마크 엔트리 하나를 추가
zos.putNextEntry(new ZipEntry(WATERMARK_ENTRY_NAME));
zos.write(watermarkText.getBytes(StandardCharsets.UTF_8));알려진 한계: 서명된 APK는 엔트리를 추가하면 서명이 깨질 수 있다. 내가 파는 APK는 서명 검증이 필요한 배포 경로가 아니라 문제가 없었지만, 일반화할 수 있는 방법은 아니다.
전략 C: 파일 끝에 덧붙이기 (이미지, PDF)
// 원본 바이트를 그대로 쓴 뒤, 파일 끝에 워터마크 텍스트를 이어 붙인다.
baos.write(originalBytes);
baos.write(watermarkText.getBytes(StandardCharsets.UTF_8));PNG, JPG 뷰어와 PDF 리더는 파일 구조가 끝난 뒤에 붙은 바이트를 무시한다. 파일은 정상적으로 열리고 워터마크만 남는다. 오래된 기법이지만, 픽셀을 건드리지 않아서 화질 손상이 0이다. 고객이 산 상품의 품질을 해치지 않는다.
세 전략의 공통점
세 가지 모두 해당 포맷의 파서가 무시하도록 정해진 공간을 찾아 쓴다. 포맷을 해킹한 게 아니라 스펙이 허용한 틈을 쓴 것이다.
워터마크가 한 번도 들어가지 않았다
이 기능에서 가장 부끄러운 사고다.
증상: 유출 추적 기능을 다 만들고 배포했다. 그런데 실제로 다운로드한 .ktheme 파일을 열어 보니 워터마크가 없었다. 오류 로그도 없었다.
원인: 처음에는 카카오톡 테마의 메타데이터가 Theme.plist 같은 XML 파일에 들어 있다고 문서와 검색 결과를 보고 가정했다. 그래서 zip을 돌며 그 이름의 파일을 찾아 XML 태그를 바꾸는 코드를 짰다.
그런데 내가 실제로 팔고 있는 .ktheme 파일을 열어 보니, 그런 이름의 파일이 아예 없었다. 진짜 메타데이터는 CSS 파일에 있었다.
조건문은 한 번도 참이 되지 않았다. 코드는 "모든 엔트리를 그대로 복사"하는 else 분기만 타면서 원본과 똑같은 파일을 정상적으로 내보내고 있었다. 예외가 나지 않으니 로그도 없었다.
해결: CSS 엔트리를 따로 처리해서 주석으로 덧붙이는 분기를 추가했다(전략 A). XML 처리는 다른 형식의 테마가 있을 경우를 대비해 남겨 뒀다. 그리고 실제 구조를 흉내 낸 zip으로 심기부터 검출까지 끝까지 확인하는 테스트를 붙였다.
배운 것 세 가지:
- 스펙 문서보다 내가 파는 실제 파일을 먼저 열어 봤어야 했다. 확인에 3분이면 될 일이었다.
- 조용히 아무것도 안 하는 코드는 없는 기능과 같다. 예외가 안 나면 동작한다고 착각하게 된다. "워터마크를 넣을 자리를 못 찾았다"는 경고 로그가 있었어야 했다.
- 보안·추적 기능은 결과물을 직접 확인하지 않으면 믿을 수 없다. 다른 기능은 화면에 안 나오면 바로 안다. 이건 파일을 열어 보기 전까지 아무도 모른다.
유출된 파일에서 주문 찾기
관리자 화면에 유출 파일 추적기를 만들었다. 의심 가는 파일을 올리면 워터마크를 찾아 어느 주문에서 나갔는지 대조해 준다.
포맷을 가리지 않고 두 가지 방법으로 찾는다
심을 때는 세 가지 방법을 썼지만, 찾을 때는 두 가지로 충분하다. 어느 방법으로 심었든 결국 어딘가에 평문 텍스트로 있기 때문이다.
- zip으로 열리면 안의 엔트리를 하나씩 풀어서 찾는다.
- zip이 아니면(이미지, PDF) 원본 바이트를 그대로 훑는다.
ISO-8859-1로 바이트를 훑는 이유
여기가 가장 설명할 가치가 있는 부분이다.
private String extractUtf8Match(byte[] bytes) {
// ISO-8859-1은 바이트 1개 = 문자 1개로 1:1 대응이라, 어떤 바이트열도
// 손실 없이 문자열로 다룰 수 있다(정규식 위치가 바이트 오프셋과 정확히 일치).
String latin1 = new String(bytes, StandardCharsets.ISO_8859_1);
Matcher m = WATERMARK_PATTERN.matcher(latin1);
if (!m.find()) return null;
// 찾은 구간의 바이트만 다시 UTF-8로 디코딩 → 한글이 깨지지 않는다.
byte[] matchedBytes = Arrays.copyOfRange(bytes, m.start(), m.end());
return new String(matchedBytes, StandardCharsets.UTF_8);
}- 파일 전체를 UTF-8로 읽으면 문제가 생긴다. 이미지나 압축 데이터의 임의 바이트는 올바른 UTF-8이 아니라서 대체 문자로 뭉개지고, 길이가 달라진다. 그러면 정규식이 찾은 위치와 실제 바이트 위치가 어긋나서 원본을 정확히 잘라 낼 수 없다.
- ISO-8859-1은 0x00부터 0xFF까지 모든 바이트가 문자 하나에 1:1로 대응한다. 어떤 바이너리도 깨지지 않고, 문자 위치 = 바이트 위치가 성립한다.
- 워터마크의 틀(
[License / Order: … ])은 전부 ASCII라서 이 상태로도 정규식이 찾는다. 위치를 찾은 뒤 그 구간의 바이트만 UTF-8로 다시 읽으면, 스토어 이름 같은 한글도 온전히 복원된다.
요약하면, 바이너리에서 텍스트 패턴을 찾을 때 찾기용 인코딩과 읽기용 인코딩을 분리한다.
찾은 뒤에는 실제 주문과 대조한다
워터마크에서 주문번호를 꺼내 DB에서 주문을 찾는다. 결과는 세 가지다.
- 워터마크 없음
- 있지만 주문과 불일치 (없는 주문번호 → 조작 의심)
- 일치 (유출 주문 특정)
일치하면 구매자 정보는 파일이 아니라 주문 기록에서 꺼내 관리자에게 보여 준다.
이미 나간 파일도 계속 읽어야 한다
// 지금 심는 형식: 주문번호·발급일·스토어만
"\\[License / Order: (.*?) / Date: (.*?) / Store: (.*?)\\]"
// 예전 형식: 구매자 정보 포함. Store는 나중에 추가돼서 없어도 매칭돼야 한다
"\\[Licensed to: (.*?) \\((.*?)\\) / Phone: (.*?) / Order: (.*?) / Date: (.*?)(?: / Store: (.*?))?\\]"워터마크는 이미 고객 손에 나간 데이터다. 형식을 바꾸면 과거에 나간 파일을 영영 못 읽게 된다. 그래서 형식을 바꿀 때마다 옛 형식도 계속 읽는다. Store 항목을 추가했을 때도, 개인정보를 뺐을 때도 그렇게 했다. 로그 형식이나 파일 형식을 바꿀 때 늘 생각해야 하는 문제다.
다운로드 기록도 같이 남긴다
워터마크만으로는 "언제, 어디서 받아 갔나"를 알 수 없다. 그래서 다운로드할 때마다 IP와 User-Agent를 기록한다.
Cloudflare Tunnel 뒤에 있어서 request.getRemoteAddr()는 프록시의 IP만 준다. 그래서 X-Forwarded-For를 먼저 보고, 여러 개가 쉼표로 오면 첫 번째(원래 클라이언트)를 쓴다. 누구에게 발급됐나(워터마크)와 언제 어디서 받아 갔나(다운로드 기록)가 합쳐져야 실제로 대응할 수 있다.
할 수 있는 것과 없는 것
| 할 수 있는 것 | 할 수 없는 것 |
|---|---|
| 유출된 파일에서 주문 특정 | 복제 자체를 막기 |
| 구매자가 "나 아니다"라고 할 때 대조 | 워터마크를 지운 파일 추적 |
| 여러 스토어 중 어디서 팔린 건지 식별 | 워터마크 위조 방지 (서명 없음) |
| 언제·어디서 다운로드됐는지 확인 | 스크린샷, 재촬영, 재인코딩 대응 |
한계와 고민
암호학적으로 안전하지 않다
워터마크는 평문이다. 서명도 암호화도 없다. 마음먹은 사람은 파일을 열어 한 줄을 지우면 그만이다. 그래서 추적기도 "적힌 주문번호가 실제로 있는 주문인가"까지만 확인한다. 위변조 방지 장치가 아니라 대조 도구다.
그래도 이 선을 택한 이유가 있다. 강한 방어(서명, 암호화, 난독화)는 비용이 급격히 오른다. 그런데 유출하는 사람 대부분은 전문가가 아니라, 그냥 파일을 단톡방에 던지는 일반 구매자다. 그 층을 잡는 데는 평문 워터마크로 충분하고, 전문가를 막는 건 애초에 불가능하다. 위협 모델을 먼저 정하고, 거기에 맞는 강도를 고른 것이다.
처음엔 개인정보를 파일에 넣었다
처음 워터마크에는 구매자 이름, 이메일, 전화번호가 평문으로 들어 있었다. 즉 파일이 유출되면 구매자의 개인정보도 같이 유출됐다. 유출을 막으려고 만든 장치가, 유출이 일어나면 피해를 키우는 구조였다.
돌아보니 넣을 필요가 없는 정보였다. 추적기는 어차피 주문번호로 DB를 조회한다. 이름, 이메일, 전화번호가 파일에 없어도 대조는 된다.
그래서 주문번호, 발급일, 스토어만 남기도록 바꿨다. 주문번호에서 구매자로 이어지는 연결은 서버 DB에만 있다. 추적 능력은 그대로이고, 파일에는 개인정보가 없다. 이미 예전 형식으로 나간 파일은 되돌릴 수 없어서, 추적기가 예전 형식도 계속 읽도록 남겨 뒀다.
알리는 것이 맞다
구매자에게 "파일에 주문 식별 정보가 들어간다"고 알리는 게 맞다. 억제 효과 면에서도 알려야 효과가 있다. 몰래 심으면 억제는 안 되고, 유출된 뒤에 찾는 것만 된다.
마치며
완벽한 복제 방지는 불가능하다. 목표는 막는 것이 아니라, 누가 흘렸는지 알 수 있게 하는 것이었다.
그리고 그 선을 정하고 나니 적정한 강도가 보였다. 평문이면 충분했고, 개인정보는 필요 없었다. 위협 모델을 먼저 정하면, 얼마나 세게 막을지와 무엇을 담지 않을지가 같이 정해진다.