배경
안강 섬김 노인복지센터의 공식 홈페이지와 운영자 CMS 제작을 의뢰받았습니다. 요구사항 정리부터 정보 구조, UI, 백엔드, 데이터베이스, 인프라, 배포와 운영까지 전 과정을 혼자 수행했습니다.
주 이용자는 어르신 본인이 아니라 보호자입니다. 서비스 선택과 비용 판단을 보호자가 하기 때문에, 정보 구조를 그 기준으로 잡았습니다.
문제
- 보호자와 어르신이 장기요양 등급과 본인부담금을 이해하기 어려웠습니다. 제도 설명은 공단 자료에 있지만, "우리 어머니는 몇 등급이고 한 달에 얼마인가"에 답하지 못했습니다.
- 센터 활동 사진을 올리려면 이용자 얼굴 노출이 문제였습니다. 사진 기록은 기관 신뢰도에 중요하지만, 어르신 얼굴이 그대로 공개되면 안 됩니다. 매번 수동으로 가리는 것은 운영자가 감당할 수 없습니다.
아키텍처
사진 업로드는 브라우저에서 서버, R2까지 이어지는 파이프라인으로 처리합니다.
[관리자 사진 선택]
│ 3.5MB 초과 시 브라우저에서 최대 1920px 선압축
▼
face-api.js Tiny Face Detector (브라우저) ── 얼굴 좌표 감지
│ 파일 + 좌표
▼
서버 재검증 ── 세션 / 폴더 prefix / 크기 / 매직바이트 / 좌표 구조
│
▼
Sharp ── EXIF 회전 → WebP 변환 → 좌표를 리사이즈본 기준으로 변환·클램프 → 블러
│
├──▶ Cloudflare R2 : 원본 (비공개 보관)
└──▶ Cloudflare R2 : 블러본 (공개 게시판 노출)
파이프라인 설계
공개 사진 게시판에는 블러본만 노출하고 원본은 별도로 보관합니다. 원본을 지우지 않는 이유는 재처리 때문입니다. 자동 감지가 놓친 얼굴을 나중에 발견했을 때, 이미 블러된 이미지 위에 덧칠하면 화질이 계속 나빠집니다. 원본이 남아 있으면 좌표만 추가해 블러본을 다시 생성하면 됩니다.
그래서 관리자 화면에서 원본을 기준으로 추가 영역을 드래그 지정하면, 자동 감지 좌표와 수동 좌표를 합쳐 블러본을 재생성합니다.
설계 결정
얼굴 감지는 브라우저, 좌표 검증은 서버
얼굴 감지를 서버에서 하면 모델 로딩과 추론이 매 요청마다 발생해 서버리스 함수의 응답 시간과 비용이 함께 올라갑니다. 감지는 브라우저에서 face-api.js Tiny Face Detector로 수행합니다.
다만 클라이언트가 보낸 좌표를 그대로 믿으면, 좌표를 비우거나 조작해 블러 없는 이미지를 공개본으로 올릴 수 있습니다. 서버는 좌표 구조를 다시 검증하고, 리사이즈본 기준으로 변환하며 경계를 클램프합니다. 성능은 클라이언트에서 얻고 신뢰 경계는 서버에 두는 구조입니다.
MIME 선언 대신 매직바이트로 판별
업로드 파일의 Content-Type은 클라이언트가 자유롭게 선언할 수 있습니다. JPEG·PNG·WebP 매직바이트를 직접 확인해 판별합니다. 위조된 MIME과 SVG를 차단합니다.
관리자 쓰기를 RLS 대신 service role 서버 액션으로 분리
관리자 쓰기 권한을 Supabase RLS 정책으로 표현하면, 정책이 늘어날수록 실제 권한 경계를 읽어내기 어려워집니다. 공개 정책은 읽기와 문의 접수만 남기고, 관리자 쓰기는 전부 Server Action에서 service role로 처리했습니다. 브라우저가 직접 DB에 쓰는 경로 자체를 없앤 것입니다. 자동 RLS 함수의 외부 실행 권한도 회수했습니다.
실전 제약과 대응
Vercel 요청 본문 4.5MB 한도
- 최신 스마트폰 사진은 한 장에 4.5MB를 넘기기 쉽습니다. 서버리스 함수 본문 한도에 걸려 업로드가 실패했습니다.
- 3.5MB를 초과하는 이미지를 브라우저에서 최대 1920px의 WebP/JPEG로 먼저 압축하도록 했습니다 (
80f17f7). 게시판 표시 해상도를 고려하면 화질 손실이 사실상 없고, 업로드 시간도 함께 줄었습니다.
sharp 프리빌드에 HEVC 디코더가 없는 문제
- 아이폰 기본 설정으로 찍은 HEIC 사진을 업로드하면 서버에서 디코딩할 수 없었습니다. sharp 프리빌드 바이너리에 HEVC 디코더가 포함돼 있지 않기 때문입니다.
- 여기서 선택지는 두 가지였습니다. 조용히 실패시키거나, 명확히 거절하거나. JPG 변환 방법을 함께 안내하며 400으로 거절하도록 했습니다 (
d03cba3). 운영자가 원인을 모른 채 같은 사진을 반복 업로드하는 상황이 가장 나쁘다고 봤습니다.
트러블슈팅 기록
블러 좌표 변환의 가장자리·다중 얼굴 버그
- 증상 — 사진 가장자리에 있는 얼굴이 제대로 가려지지 않거나, 한 사진에 여러 명이 있을 때 일부만 블러됐습니다.
- 원인 — 좌표 변환 로직이 업로드 경로와 수동 블러 경로에 각각 복제돼 있었고, 리사이즈 비율 적용 후 이미지 경계를 넘어가는 영역을 클램프하지 않았습니다. 경계를 넘은 영역은 Sharp 합성 단계에서 무시돼 블러가 적용되지 않았습니다.
- 수정 — 좌표 변환을 공통 모듈(
blur-regions)로 추출하고, 경계 클램프와 다중 얼굴 합성을 고친 뒤 회귀 테스트로 고정했습니다 (0b41430).
프론트 구현 하이라이트
- 수동 블러 편집기 — 자동 감지가 놓친 영역을 관리자가 원본 위에서 드래그로 지정합니다. 지정 결과는 원본 기준 좌표로 저장돼 블러본 재생성에 쓰입니다.
- 본인부담금 계산기 — 등급과 이용 시간을 선택하면 예상 월 본인부담금을 계산합니다. 등급별 월 한도액과 방문요양 시간별 수가는 관리자가 화면에서 직접 수정할 수 있게 해, 수가가 바뀔 때마다 배포하지 않아도 되도록 했습니다.
실제 화면


운영에서 배운 것
이 프로젝트에서 처음 단위 테스트를 도입했습니다(착수 39일). 대상을 고른 기준은 커버리지가 아니라 "틀리면 사람이 다치는 로직"이었습니다.
blur-regions— 얼굴 좌표 비례 변환, 경계 클램프, 다중 얼굴 처리image-type— 매직바이트 판별, 위조 MIME·미지원 형식 차단escapeHtml,extractR2Key,requireSession— 인젝션 방어, 삭제 가능한 R2 key 추출, 관리자 세션 검증
블러 좌표 변환이 틀리면 이용자 얼굴이 그대로 공개됩니다. 파일 형식 검증이 뚫리면 공개 게시판이 임의 파일을 서빙합니다. 둘 다 눈으로 훑어서는 놓치기 쉽고, 놓쳤을 때 피해가 되돌릴 수 없는 종류였습니다.
여기서 얻은 감각 — 테스트는 코드 전체가 아니라 실패 비용이 큰 경계에 먼저 붙인다 — 이 다음 프로젝트의 테스트 우선순위로 그대로 이어졌습니다.