Rhymix Image Access Pipeline
서울파인트리클럽 홈페이지 운영 개편에서는 동아리 행사 사진이 게시글과 위젯, 검색 결과에 함께 노출된다. 동아리원의 초상권과 개인정보를 보호하기 위해, 공개 홈페이지를 방문한 비회원에게는 얼굴이 선명한 사진을 그대로 노출하지 않기로 했다.
CSS의 filter: blur()만 적용하면 개발자 도구에서 속성을 지우거나 원본 URL을 열어 우회할 수 있다. 내가 해결하려 한 문제는 허용된 회원 그룹에는 블러 없는 이미지를, 그 밖의 방문자에게는 서버에서 블러 처리한 이미지를 보내면서 URL 변조와 캐시 혼합으로 원본이 노출되지 않게 하는 것이었다.
게시판별 허용 그룹 설정
image_blur_privacy 모듈에서 게시판마다 기능 사용 여부, 블러 없이 볼 수 있는 Rhymix 회원 그룹과 블러 강도를 설정한다. 최고 관리자는 항상 허용하고, 로그인했더라도 선택된 그룹에 속하지 않으면 비회원과 같은 블러 정책을 적용한다. 여기서 말하는 ‘동아리 회원’은 구현상 하나의 로그인 상태가 아니라 게시판에 허용된 회원 그룹을 뜻한다.
첨부파일 다운로드 권한과 이미지 표시 정책은 분리했다. 글에서 블러 없는 이미지를 볼 수 있다는 사실이 첨부파일 다운로드 권한까지 부여하지는 않는다. 반대로 게시글과 파일의 현재 권한을 확인할 수 없으면 공개 가능한 이미지로 추정하지 않는다.
화면 효과 대신 이미지 URL 변환
Rhymix가 HTML을 브라우저에 보내기 전, 애드온이 이미지 후보를 찾아 이미지 최적화 모듈의 URL로 바꾼다. img[src]뿐 아니라 반응형 이미지의 srcset, source[srcset]과 video[poster]도 같은 정책을 거친다.
브라우저 → Cloudflare Worker → Deno Deploy → wsrv.nl → R2 원본
브라우저가 wsrv.nl을 직접 호출하면 url 쿼리와 응답의 canonical Link 헤더를 통해 원본 URL이 드러난다. 그래서 브라우저에는 암호화·서명된 Worker URL만 제공하고, Worker가 변환 결과에서 상위 서비스의 헤더를 제거해 이미지 바이트만 반환하도록 바꿨다.
Worker가 wsrv.nl을 직접 호출하면 Cloudflare Worker IP 대역이 차단돼 1106 오류가 발생했다. 그래서 공유 secret으로 인증한 Deno Deploy를 중계 계층으로 두었다. Deno는 허용한 원본 host만 wsrv.nl에 전달하고 변환 응답 캐시를 인증값별로 분리하며, Worker는 최종 응답을 다시 Edge Cache에 저장한다. wsrv.nl은 이 경로에서 WebP 변환과 resize, blur를 담당한다.
허용된 그룹에는 블러 값이 0인 파생본을, 그 밖의 방문자에게는 게시판에서 정한 블러 값이 들어간 파생본을 요청한다. 이미지 경로를 해석하지 못하거나 서명을 만들 수 없는 경우에는 원본 URL로 되돌아가지 않고 투명한 대체 이미지를 사용한다. 이미지 보호 과정에서 오류가 발생해도 원본 공개로 이어지지 않도록 실패 방향을 정했다.
블러 강도와 크기를 포함한 URL 서명
R2 객체 경로는 암호화하고, 파일 식별자와 블러 강도, 너비·높이, 공개 정책과 만료 시각을 HMAC 서명에 함께 묶었다. 방문자가 URL의 blur=50을 blur=0으로 바꾸면 서명이 맞지 않아 Worker가 요청을 거부한다.
이미 발급된 URL을 다른 화면에서 다시 사용하더라도 보호 수준을 낮출 수 없게 했다. 이미 만들어진 이미지 URL을 다시 가공하는 코드가 있더라도 기존보다 블러를 약하게 하거나 이미지 크기를 키우거나 비공개 URL의 만료 시각을 늘릴 수 없다. Worker는 서명과 만료 시각을 확인한 뒤에만 경로를 복호화하고 변환 요청을 전달한다.
회원별 결과의 캐시 분리
같은 게시글이라도 보는 사람에 따라 HTML 안의 이미지 URL이 다르다. 이런 응답에는 Cache-Control: private, no-store와 Vary: Cookie를 적용해 회원용 URL이 공용 페이지 캐시에 남지 않게 했다.
이미지 파생본의 캐시 정책도 구분했다. 정책상 공개 가능한 블러 파생본은 공개 캐시를 사용할 수 있지만, 허용 그룹에 보내는 블러 없는 이미지는 만료 시간이 짧은 비공개 서명 URL로 제공한다. 공개 캐시를 허용할 때는 문서 공개 상태, 비회원 열람 권한, 파일의 실제 대상과 다운로드 설정을 다시 확인한다.
비공개 URL은 사용자나 세션에 묶이지 않은 단기 bearer URL이므로 URL을 가진 사람은 만료 전까지 접근할 수 있다. 반대로 공개 블러 파생본은 1년 동안 immutable cache에 남아 한 번 배포된 결과를 즉시 회수할 수 없다.
위젯과 검색 결과의 정책 통합
초기에는 본문에만 정책을 적용했지만 위젯 썸네일과 통합 검색 결과가 별도의 URL을 만들면서 우회 경로가 생겼다. 이후 위젯과 검색 결과도 게시판 설정을 상속하도록 바꾸고, 설정값이 비어 있으면 블러를 해제하는 대신 원래 게시판의 정책을 따르게 했다.
소셜 공유에 쓰이는 OG와 Twitter 이미지 메타데이터도 Rhymix가 최종 <head>를 조립한 뒤 다시 검사한다. 공개 정책을 통과하지 못한 URL과 비공개 bearer URL, 잘못 연결된 원본 URL은 메타데이터에서 제거해 검색 엔진과 메신저 미리보기로 노출되지 않게 했다.
블러 값 0은 기본값이 아니라 특정 위젯이 공개용 미리보기를 의도적으로 만들 때만 쓰는 명시적 선택으로 남겼다. 이 경우에도 요청한 대상 종류와 번호, 게시판이 실제 파일 정보와 일치하는지 확인하지 못하면 원본을 제공하지 않는다. 속성의 따옴표가 없거나 중복된 HTML, srcset 후보가 여러 개인 경우도 회귀 검사에 포함했다.
검증
권한 그룹별 결과, src·srcset·poster 치환, 잘못된 HTML 속성, 이미지 경로 해석 실패와 캐시 헤더를 characterization 검사로 남겼다. Worker와 프록시에서는 서명 변조, 만료된 URL, 허용하지 않은 origin, redirect와 이미지가 아닌 응답을 거부하는 경우를 Node 테스트로 확인했다.
운영 환경에서는 로그인하지 않은 방문자, 허용 그룹 회원과 그 밖의 로그인 회원으로 나눠 본문·위젯·검색 결과를 확인했다. 다만 블러가 얼굴을 어느 정도 식별하기 어렵게 만드는지에 대한 사용자 실험이나 법적 기준 검증까지 수행한 것은 아니다.
공개 R2 origin 유지 판단
현재 R2 origin은 공개 상태다. 정상적인 페이지에서는 암호화·서명된 URL만 노출되므로 블러 값을 바꾸는 식의 우회는 막지만, 누군가 R2의 실제 객체 URL을 알아내면 origin에서 다시 권한을 검사하지는 않는다. 따라서 이 구조를 원본의 완전한 접근 통제로 설명할 수는 없다.
공개 origin을 비공개로 바꾸는 기능도 검토했지만 아직 적용하지 않았다. 이 저장소에는 노출될 경우 즉각적이고 중대한 피해가 예상되는 민감 자료를 두지 않는다. 현재는 실제 객체 URL이 알려질 가능성과 그 피해보다, Worker의 R2 binding과 단기 origin URL, 추가 배포·복구 절차를 운영하는 비용이 더 크다고 판단했다.
사진의 민감도가 높아지거나 실제 객체 URL 노출이 확인되고, 조직의 접근 정책이 강한 원본 기밀성을 요구하게 되면 이 판단은 달라진다. 그때는 R2 공개 접근을 끄고 Worker의 비공개 R2 binding을 통해서만 원본을 읽도록 전환할 계획이다.
현재 한계
CSS 배경 이미지와 임의의 data-* 지연 로딩 속성은 HTML 치환 대상이 아니어서 레이아웃에서 별도로 관리해야 한다. 이미지 변환에는 외부 서비스인 wsrv.nl을 사용하므로 원본 URL과 이미지 처리를 해당 서비스에 맡긴다는 신뢰 전제도 있다.
이 기능은 동아리원의 초상권과 개인정보를 보호하기 위해 공개 화면의 얼굴 노출을 줄이는 기술적 조치다. 다만 촬영·게시 동의나 삭제 요청 같은 운영 절차를 대신할 수 없으며, 공개 R2 origin을 유지하는 현재 구조에서 원본 접근까지 완전히 통제하지는 않는다.