시도 · 2026

화면 유출 추적

유출된 화면이 어느 기기에서 나왔는지 찾아내려던 기능입니다. 두 가지 방식으로 만들어 실제로 읽어 내기까지 했지만, AI 화질 개선을 넘지 못해 보류했습니다.

기간
2026년 9월 ~ 10월
어디에
김먼지 앱 베타
결과
보류
추적 대신 예방으로

하려던 것

김먼지 앱에는 밖으로 나가면 곤란한 화면이 있습니다. 채팅과 댓글, 멤버십 안내, 아직 공개하지 않은 일정 같은 것들입니다.

스크린샷은 운영체제 단에서 막아 두었습니다. 하지만 다른 폰으로 화면을 찍는 것까지는 막을 수 없습니다.

그래서 질문을 바꿨습니다. 유출을 막지 못한다면, 유출본이 돌기 시작했을 때 어느 기기에서 나왔는지라도 알 수 있을까. 기기마다 다른 자국을 사람 눈에는 보이지 않게 화면에 심는 것, 흔히 비가시 워터마크라고 부르는 방식입니다.

첫 번째 시도: 기기 번호를 글자로

앱에는 가입이 없어서, 서버는 기기마다 A3F2B1 같은 여섯 자리 익명 번호를 붙여 구분합니다. 관리 화면에서 기기 기록, 이용 제한, 신고를 볼 때 쓰는 바로 그 번호입니다.

가장 단순한 방법부터 해 봤습니다. 이 여섯 글자를 바탕과 거의 같은 색으로 화면 전체에 바둑판처럼 반복해 까는 것입니다. 화면에서는 안 보이고, 유출본의 대비를 끌어올리면 번호가 드러나기를 기대했습니다.

예시 번호 A3F2B1을 흐리게 깐 화면, 그 자국만 40배로 키운 것, 손실 압축 뒤 남은 자국
예시 번호로 다시 만든 그림입니다. 왼쪽은 자국을 깐 화면, 가운데는 자국만 40배로 키운 것, 오른쪽은 손실 압축 1회 뒤 남은 자국을 같은 배율로 본 것입니다. 실제 유출본에는 비교할 원본이 없어서 이보다 훨씬 알아보기 어렵습니다.

결과는 양쪽으로 다 실패였습니다.

읽히지 않았습니다. 유출 이미지는 대부분 손실 압축을 거칩니다. 메신저로 한 번만 보내도 0.6배로 줄고 JPEG로 다시 압축되는데, 이때 가는 글자 획부터 뭉개져서 사람도 읽는 도구도 번호를 알아보지 못했습니다.

그런데 보였습니다. 실제 폰에서 밝기를 올리고 보면 흐린 글자가 계속 눈에 걸렸습니다.

사람 눈은 글자를 가장 잘 찾아내고, 글자는 축소와 압축에 가장 약합니다. 숨기기도 어렵고 살아남기도 어려운, 이 일에 가장 나쁜 자국이었습니다.

두 번째 시도: 반복 무늬

글자를 버리고 반복되는 무늬로 바꿨습니다. 손실 압축은 미세한 고주파 성분부터 깎아 냅니다. 하지만 같은 무늬가 화면에 수십 번 반복되면, 그 조각들을 겹쳐 쌓아서 깎인 만큼을 되찾을 수 있습니다.

자국 타일 한 장, 그 일부를 확대한 것, 실제 세기로 깐 다크 카드
왼쪽은 기기 하나의 자국 타일(336×336 기기 픽셀)을 0~255로 키워 본 것, 가운데는 그 일부를 확대한 것입니다. 오른쪽이 실제 세기로, 다크 카드(코드값 29) 위에서 29~32 사이로만 움직입니다.

기기 번호를 시드로 난수를 돌려 사인파 6개를 고르고, 이를 합쳐 타일 한 장을 만듭니다. 이 6개의 조합이 기기의 지문입니다. 타일은 기기 픽셀 336으로 고정했습니다. 논리 픽셀로 두면 기기마다 화면 배율이 달라 유출본 속 주기가 제각각이 되지만, 기기 픽셀로 고정하면 읽는 쪽은 얼마나 줄었는지 하나만 찾으면 됩니다.

타일의 주파수 지도. 점선 고리와 여섯 개의 초록 네모
타일의 주파수 지도입니다. 점선 고리가 쓰는 대역, 초록 네모가 이 기기의 여섯 자리입니다. 붉은 십자는 가로세로로 곧은 파형으로, 쓰지 않습니다.

파형은 주파수 평면의 좁은 고리(물결 길이 14~21픽셀) 안에서만 고릅니다. 띠를 넓게 잡으면 굵기가 섞여 노이즈처럼 보이고, 고리 하나로 좁히면 한 가지 결의 천처럼 보입니다. 가로세로로 곧은 파형은 뺐습니다. 카드 모서리나 글줄처럼 화면 구조가 전부 곧아서, 그 방향에 잡음이 쌓이기 때문입니다. 빼고 나니 자국 없는 사진의 점수 바닥이 8.9에서 6.8로 내려갔습니다. 남는 방향은 482개이고, 그중 6개를 고르는 조합이 기기 하나를 가리킵니다.

세기는 0~255 중 ±1.5입니다. 투명도로 얹으면 바뀌는 양이 바탕 밝기에 비례해서, 어두운 화면에서는 반올림에 먹혀 사라졌습니다. 재 보니 자국이 닿은 화소가 4.4%뿐이었습니다. 그래서 바탕이 무엇이든 정확히 그만큼 더하거나 빼도록 했습니다.

자국 없는 화면과 자국 있는 화면을 나란히 놓은 것
같은 화면입니다. 두 장의 차이는 코드값 0~3이라 눈으로는 구분되지 않습니다.
두 화면의 차이를 70배로 키워 드러난 무늬
두 장의 차이만 뽑아 70배로 키우면 실제로 들어간 무늬가 보입니다. 가운데 빈 띠는 광고 자리입니다. 광고 위에는 아무것도 겹치면 안 돼서 비워 두었습니다.

읽어 내기

유출본은 여러 가공을 거쳐 돌아옵니다. 사람 눈에는 다 비슷해 보여서, 자국이 살아 있는지는 눈으로 알 수 없습니다.

원본, 손실 압축 1회, 2회, AI 화질 개선을 거친 같은 화면
원본, 손실 압축 1회, 2회, AI 화질 개선. 눈으로는 차이를 알 수 없습니다.

그래서 읽는 도구를 따로 만들었습니다. 먼저 유출본을 타일 주기로 접어서 겹칩니다. 칸이 18개면 대비가 √18, 약 4.2배 좋아집니다.

자국 있는 사진과 없는 사진을 각각 접어 쌓은 결과
타일 주기로 18칸을 접어 쌓은 것입니다. 자국 없는 화면도 결만 남기면 천처럼 보이기 때문에, 이 그림만으로는 판정하지 않습니다.

판정은 주파수에서 합니다. 접은 결과를 주파수 지도로 바꾼 뒤, 그 기기의 여섯 자리만 유독 밝은지를 봅니다. 점수는 여섯 자리 밝기의 기하평균을 고리 전체의 평균으로 나눈 값입니다.

세 가지 경우의 주파수 지도와 점수
자국 있는 사진에서 이 기기의 여섯 자리를 보면 점수 208, 같은 사진에서 남의 번호 자리를 보면 0.5, 자국 없는 사진은 0.8입니다.

여기서 기하평균이 결정적이었습니다. 산술평균이면 우연히 한 자리가 겹친 남의 번호도 점수가 올라, 진짜와 가짜의 차이가 1.5배뿐이었습니다. 기하평균으로 바꾸자 한 자리만 겹친 번호는 확 주저앉고 차이가 5~8배로 벌어졌습니다. 이 한 줄이 읽을 수 있느냐 없느냐를 갈랐습니다.

그 밖에 국소 대비 정규화로 화면의 70~86%만 덮인 상태의 점수를 12에서 292로 올렸고, 원근을 편 이미지 크기를 2400px로 묶어 다른 폰으로 찍은 사진도 탐색 범위 안에 들어오게 했습니다.

실제로는 후보가 수백 개입니다. 손실 압축을 두 번 거친 사진에서 후보 400개를 함께 채점해 보니, 진짜 기기가 69점으로 1등이었고 2등(4.6)과는 15배 차이가 났습니다.

후보 400개의 점수 분포. 진짜 기기 하나만 기준선 위에 있음
손실 압축 2회를 거친 사진에서 후보 400개를 채점한 결과입니다. 진짜 기기 하나만 판정 기준 20을 넘습니다.

얼마나 읽혔나

대체로 된다는 느낌만으로는 판단할 수 없어서, 유출본이 실제로 거칠 법한 경로 15가지를 만들어 모두 돌렸습니다. 점수가 20을 넘고 진짜 기기가 1등일 때만 읽힌 것으로 셌습니다.

  1. 밝기 +30% 드묾381 · 읽힘
  2. 받은 그대로 (PNG) 흔함367 · 읽힘
  3. 흑백 변환 드묾362 · 읽힘
  4. 약한 블러 작정하면341 · 읽힘
  5. 커뮤니티 업로드 (0.8배, JPEG 75) 흔함328 · 읽힘
  6. 위 절반만 자르기 흔함300 · 읽힘
  7. 손실 압축 1회 가장 흔함208 · 읽힘
  8. 자르기 + 손실 압축 1회 흔함175 · 읽힘
  9. 라이트 테마 + 손실 압축 1회 흔함156 · 읽힘
  10. 다른 폰으로 화면 촬영 흔함106 · 읽힘
  11. 손실 압축 2회 흔함70 · 읽힘
  12. AI 화질 개선 + 손실 압축 1회 요즘 폰 기본6.8 · 못 읽음
  13. AI 화질 개선 요즘 폰 기본5.9 · 못 읽음
  14. 가운데만 자르기 (화면의 22%) 흔함4.6 · 못 읽음
  15. 손실 압축 3회 가끔4.6 · 못 읽음
막대는 점수(로그 눈금), 점선은 판정 기준 20입니다. 흐린 막대가 못 읽은 경로입니다. 손실 압축 1회는 0.6배 축소, JPEG 품질 80, 4:2:0 크로마 서브샘플링을 한 번 거친 것입니다.

15가지 중 11가지에서 읽혔습니다. 흔한 경로만 추리면 11가지 중 8가지(73%)입니다. 못 읽은 넷 중 둘이 AI 화질 개선이고, 나머지는 손실 압축 3회와 화면 가운데만 작게 자른 경우입니다.

자르기에는 한계선이 있습니다. 접어 쌓을 칸이 4개는 있어야 해서, 화면의 40% 이상이 남아야 읽힙니다. 채팅 말풍선 몇 개만 잘라 올리는 흔한 경우는 대개 그보다 작습니다.

남은 화면 면적에 따른 점수 그래프
남은 화면 면적에 따른 점수입니다. 40%부터 판정 기준 20을 넘습니다.

다른 폰으로 화면을 찍은 사진도 읽혔습니다. 다만 네 귀퉁이를 사람이 짚어 원근을 펴 줘야 해서, 관리 화면에 그 도구가 따로 필요했고 거기까지는 만들지 못했습니다.

다른 폰으로 찍은 사진과 네 귀퉁이를 짚어 원근을 편 결과
다른 폰으로 찍은 사진(왼쪽)과 네 귀퉁이를 짚어 원근을 편 것(오른쪽)입니다. 펴고 나면 점수 106~584로 읽혔습니다.

안 보이게 만들기

가장 오래 걸린 부분입니다. 읽히게 만드는 것보다 안 보이게 만드는 것이 훨씬 어려웠습니다.

눈이 가장 예민한 굵기를 피했습니다. 사람 눈의 대비 민감도는 24~34픽셀 주기(약 3 cycles/degree)에서 가장 높습니다. 물결 길이를 14~21픽셀로 내리자 체감이 크게 줄었습니다.

다크 테마는 이기지 못했습니다. 같은 +3이 어두운 카드에서는 대비 10.3%, 밝은 카드에서는 1.2%입니다. 다크에서 라이트만큼 숨기려면 ±0.36이어야 하는데, 8비트 화면은 1보다 작게 바꿀 수 없습니다.

같은 세기의 자국을 다크 카드와 라이트 카드에 깐 것
같은 세기라도 다크에서 여덟 배 넘게 잘 보입니다. 두 카드 모두 왼쪽 절반에만 자국이 있습니다.

무늬보다 경계가 더 잘 보였습니다. 처음에는 글 카드 안쪽에만 깔았더니 카드 끝마다 선이 생겼고, 그 선이 무늬보다 훨씬 눈에 띄었습니다. 눈은 면보다 경계를 먼저 잡습니다. 그래서 화면 전체를 한 겹으로 덮었고, 덮는 면적이 70%에서 95% 가까이로 늘면서 읽는 쪽도 함께 좋아졌습니다. 광고 자리는 정책상 비워야 해서, 카드 테두리와 모서리 둥글기까지 정확히 맞춰 뚫어야 새 경계가 생기지 않았습니다.

카드 안쪽에만 깐 경우와 화면 한 겹으로 덮은 경우의 차이 그림
차이를 70배로 키운 그림입니다. 왼쪽은 카드 안쪽에만 깔았을 때, 오른쪽은 화면 한 겹에 광고 자리만 비운 것입니다.

비교 기준이 오염돼 있었습니다. 세기를 맞추던 몇 라운드 동안, 기준 스크린샷에 첫 번째 시도의 글자 자국이 그대로 남아 있었습니다. 무늬가 보인다고 판단한 것의 상당 부분이 사실은 옛 자국이었고, 기준을 새로 만들어 이틀 치 측정을 다시 했습니다.

무거워진 앱 고치기

자국을 화면 한 겹으로 키우자 앱이 눈에 띄게 버벅였습니다. 프로파일러로 재 보니 글자와 화면 넘김뿐인 안내 화면이 프레임마다 평균 3.5ms, 화면을 바꿀 때는 41~43ms씩 튀었습니다.

원인은 BlendMode.difference였습니다. Flutter의 렌더러(Impeller)에서 이 방식은 GPU가 한 번에 처리하지 못하는 고급 블렌드라서, 이미 그린 화면을 다시 읽어 오는 패스를 따로 돕니다. 카드 안쪽에 깔 때는 면적이 작아 티가 안 났지만, 화면 전체 × 매 프레임이 되자 그대로 드러났습니다.

하드웨어 블렌드인 BlendMode.plus로 바꾸자 그 비용이 사라졌고, 읽히는 정도는 거의 같았습니다. 탭을 바꿀 때 화면이 까매지던 버그까지 모두 해결했습니다. 성능은 보류한 이유가 아닙니다.

블렌드다크: 원본 / 압축 1회 / 2회라이트: 압축 1회 / 2회압축 2회 ÷ 바닥
difference371 / 209 / 77203 / 616.2×
plus367 / 208 / 70157 / 555.5×
plus로 바꿔도 읽히는 정도는 거의 같았습니다. 숫자는 점수이고, 바닥은 자국 없는 사진의 점수입니다.

보류한 이유

가장 큰 이유는 AI 화질 개선입니다. 요즘 폰 갤러리에 기본으로 들어 있는 사진 리마스터 같은 기능을 한 번만 거쳐도 자국이 사라집니다.

원본, 손실 압축 1회, AI 화질 개선 뒤의 화면과 각각의 주파수 지도
위는 원본, 손실 압축 1회, AI 화질 개선으로, 눈에는 셋 다 멀쩡합니다. 아래는 같은 자리의 주파수 지도입니다. AI 화질 개선 뒤에는 여섯 자리가 바탕에 묻혀 점수가 2.5로 떨어집니다.

이 기능은 흐린 디테일을 복원하는 게 아니라 다시 만들어 냅니다. 원본의 약한 고주파는 버리고, 모델이 이런 사진이라면 이런 질감일 것이라고 판단한 것으로 채웁니다. 자국은 바로 그 대역(14~21픽셀 주기)에 0~255 중 1~3이라는 세기로 들어 있으니, 모델 입장에서는 지워야 할 노이즈입니다.

세기를 올려 버티게 하면 이번에는 보입니다. ±2만 돼도 다크 테마에서 눈에 띕니다. 읽히는 선과 안 보이는 선이 겹치지 않았습니다.

그리고 이 기능은 성격상 대체로 된다가 통하지 않습니다. 목적이 겁을 주는 게 아니라 한 기기를 지목하는 것이라, 한 건을 못 읽으면 그 사람은 그냥 안전해집니다. 게다가 못 읽는 쪽이 AI 보정처럼 앞으로 늘어날 경로라서, 시간이 갈수록 나빠지는 숫자였습니다.

측정에 쓴 AI 화질 개선은 실제 모델이 아니라 같은 성질의 근사입니다. 약한 고주파를 지우고 2배로 키워 디테일을 다시 그린 뒤 원래 크기로 줄였습니다. 실제 기능은 이보다 더 공격적으로 질감을 새로 만듭니다.

지금은

보류해 두었습니다. 자국을 깔고 읽던 코드는 앱과 서버에서 걷어냈지만, 마지막으로 동작하던 상태는 기록으로 남겨 두어 언제든 다시 꺼낼 수 있습니다.

그동안은 예방으로 막습니다. 스크린샷은 계속 막고, 채팅과 댓글은 30일만 보이게 하고, 유출은 신고와 이용 제한으로 대응합니다. 안전하게 추적할 수 있는 방법이 있는지는 계속 고민하고 있습니다.

배운 것

기술

  • Flutter
  • Dart
  • Python
  • Spring Boot

자국은 Flutter 화면 위에 그렸고, 읽는 도구는 Python(numpy, Pillow)으로 만들어 서버에서 돌렸습니다.