광고소재 대시보드 개발 기록 (1) VPN 대신 웹으로: 복사 캐시를 버리고 NAS 직송을 택하기까지

VPN을 켜야 파일을 받을 수 있던 대행사 MotherK는 광고 제작 일부를 외부 광고대행사와 함께 한다. 회사 디자인팀이 만든 소재 원본은 사내 NAS에 쌓이고, 대행사는 그 파일을 받아 광고를 집행하거나 2차 작업을 한다. 처음 이 일을 넘겨받았을 때 들은 설명은 짧았다. 대행사가 VPN으로 사내망에 붙어서 NAS 폴더를 직접 열고 파일을 복사해 간다는 것, 그리고 1GB가 넘는 파일이 많아서 느리다는 것이었다. 문제는 속도만이 아니었다. 외부 회사 직원에게 VPN 클라이언트를 깔게 하고 연결 방법을 안내하는 것부터가 일이었고, 접속하고 나서도 어떤 폴더가 이번 요청에 해당하는지는 사람이 따로 알려줘야 했다. 무엇을 만들어 달라는지(의뢰 내용)와 결과물에 대한 의견은 또 다른 채널로 오갔다. 파일, 요청, 피드백이 세 군데로 흩어져 있었던 셈이다. 목표는 단순하게 잡았다. 대행사가 웹에서 로그인만 하면 자기에게 배정된 소재를 미리 보고 바로 내려받을 수 있을 것. 그리고 어떤 경우에도 NAS 원본은 바뀌거나 지워지지 않을 것. 이 글은 그 시스템, 사내에서 "광고소재 대시보드"라고 부르는 웹앱을 어떻게 설계했고 처음 계획에서 무엇을 버렸는지에 대한 기록이다. 요구사항과 제약 처음 정리한 요구사항은 이랬다. VPN 없이 외부에서 로그인해 이미지·문서·PSD를 미리 보고 내려받는다. 광고소재 목록은 이미 사내에서 쓰고 있는 디자인 요청 DB(카페24 호스팅 MariaDB)를 그대로 원본으로 쓴다. 요청 건마다 NAS 폴더 경로가 UNC 형식으로 들어 있다. 사용자가 고른 항목만 사내망에서 가져오고, 가져온 사본은 한 달이 지나면 자동으로 지운다. 원본은 절대 지우면 안 된다. 제약도 분명했다. 서버는 외부 클라우드가 아니라 사내에 있는 리눅스 서버 한 대였고, 이미 사내 영상 관리 시스템이 같은 서버에서 돌고 있었다. 인바운드 포트를 열 생각은 없었다. 그리고 소재 DB는 다른 팀이 운영하는 외부 호스팅 DB라서 이쪽에서는 읽기만 해야 했다. 처음 그린 설계: 복사 큐와 30일 캐시 기획서 초안은 꽤 무거웠다. 대행사가 "내 작업공간으로 가져오기"를 누르면 복사 작업이 큐에 들어가고, 백그라운드 워커가 NAS에서 사내 서버의 캐시 디렉터리로 파일을 복사한다. 진행 상태는 대기/복사 중/완료/실패로 보여 주고, 실패하면 최대 3회 재시도한다. 매일 새벽 정리 배치가 복사된 지 30일이 지난 사본을 지우고, 관리자 화면에서 캐시 상한(기본 200GB), 동시 복사 수(기본 2), 보관 기간을 바꿀 수 있게 했다. 테이블도 copy_jobs , cache_items , settings 까지 그려 두었다. 사용자가 임의의 NAS 경로를 입력해 복사하는 "사용자 지정 복사"도 있었다. 이 설계의 전제는 "외부 사용자는 사내 서버 디스크에 복사된 사본만 받는다"였다. NAS를 외부 요청 경로에서 떼어 놓으면 안전하고 빠를 거라고 생각했다. 구현하면서 버린 것: NAS 직송으로 단순화 막상 첫 단계(NAS 읽기 + DB 연동 + 단일 파일 다운로드 PoC)를 만들어 보니 질문이 생겼다. 서버가 사내에 있으니 서버와 NAS 사이는 어차피 사내 LAN이다. 느린 구간은 대행사와 회사 사이, 즉 VPN 구간이었다. 캐시로 복사를 한 번 더 해도 외부로 나가는 경로는 똑같이 서버에서 출발한다. 오히려 "가져오기를 누르고 복사가 끝날 때까지 기다린 뒤 다시 다운로드"라는 두 단계가 생기고, 캐시 용량 관리와 정리 배치, 부분 복사본 폐기 같은 운영 거리가 따라온다. 그래서 복사 큐와 캐시를 통째로 버렸다. NAS를 서버에 읽기 전용( ro )으로 마운트하고, 다운로드 요청이 오면 그 마운트 경로에서 바로 내려준다. 사내에서는 이걸 "NAS 직송"이라고 불렀다. 원본 보호는 캐시 대신 두 겹으로 처리했다. 마운트 자체가 읽기 전용이라 코드가 실수해도 쓸 수 없고, 코드에는 NAS에 쓰는 경로가 아예 없다. "사용자 지정 복사"도 뺐다. 외부 사용자가 임의 경로를 입력할 수 있으면 화이트리스트를 아무리 잘 짜도 불안하다. 대신 관리자가 소재 건을 대행사 사용자에게 할당 하고, 일반 사용자는 할당받은 건의 폴더 하위만 볼 수 있게 했다. 권한 모델이 "경로 접두사 검사"에서 "할당 관계 + 경로 검사"로 바뀐 것이다. 최종 구조 노출 : Cloudflare Tunnel. 서버에서 cloudflared가 바깥으로 연결을 맺기 때문에 포트포워딩이나 공인 IP가 필요 없다. 같은 서버의 영상 시스템과 하나의 cloudflared 프로세스를 같이 쓰며 호스트명으로 나눠 라우팅한다. HTTPS도 엣지에서 끝난다. 앱 : Flask + Gunicorn(preload, 타임아웃 1200초)을 systemd 서비스로 띄우고 Restart=always 를 걸었다. 화면은 Jinja 템플릿과 바닐라 JS다. 데이터 : 소재 목록은 외부 MariaDB를 읽기만 하고 5분 동안 메모리에 캐시한다. 할당, 의뢰서, 메시지, 다운로드 로그, 첨부, 감사 로그 같은 이 서비스 고유의 데이터는 SQLite에 둔다. 규모가 작아서 별도 DB 서버를 둘 이유가 없었다. 파일 : NAS는 CIFS로 읽기 전용 마운트. 썸네일은 Pillow, PSD는 ImageMagick으로 첫 레이어를 뽑고, 영상은 ffmpeg로 1초 지점 프레임을 잘라 캐시한다. 경로를 믿지 않는 법 NAS 직송 구조에서 가장 신경 쓴 부분은 경로 검증이다. DB에 들어 있는 경로는 \\NAS\공유\팀\폴더 같은 윈도우 UNC 형식인데, 이걸 서버의 마운트 경로로 바꿔야 한다. 그런데 이 컬럼에는 UNC만 있는 게 아니었다. 웹 페이지 URL이 들어간 행도 있었고 비어 있는 행도 있었다. 그래서 변환 함수는 "UNC가 아니면 무조건 None"을 원칙으로 짰다. def unc_to_local(unc): s = str(unc or "").strip() if not s or "://" in s: # URL 등은 거부 return None p = s.replace("\\", "/") if not p.startswith("//"): return None host, share, rest = (p.lstrip("/").split("/", 2) + [None, None])[:3] if not rest or host.lower() != NAS_HOST.lower() or share != NAS_SHARE: return None return str(Path(NAS_MOUNT) / rest) def safe_under(base, target): b, t = Path(base).resolve(), Path(target).resolve() return str(t).startswith(str(b) + os.sep) or str(t) == str(b) 변환 결과는 다시 resolve() 로 실제 경로를 풀어서 마운트 아래에 있는지 확인한다. .. 이 섞인 경로가 들어와도 마운트 밖으로 나갈 수 없다. 그 다음 단계가 할당 검사다. 일반 사용자라면 자기에게 할당된 소재 건들의 폴더를 모두 같은 방식으로 풀어 놓고, 요청 경로가 그중 하나와 같거나 그 하위일 때만 통과시킨다. 미리보기, 썸네일, 다운로드, 폴더 목록 API가 모두 이 세 단계를 거친다. 시행착오 폴더 다운로드: 압축하지 않는 zip 소재 폴더 안에는 PSD, 고해상도 이미지, 영상이 섞여 있다. 폴더째 받고 싶다는 요청이 당연히 나왔고, 처음 떠오른 건 zip이었다. 그런데 이 파일들은 대부분 이미 압축된 포맷이거나 압축 효율이 낮은 대용량 바이너리라 deflate로 다시 압축해 봐야 CPU만 쓰고 크기는 거의 줄지 않는다. 그래서 ZIP_STORED , 즉 무압축으로 묶었다. 파일이 많아도 4GB를 넘을 수 있으니 allowZip64=True 를 켰다. tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False, dir=str(APP_DIR)) tmp.close() with zipfile.ZipFile(tmp.name, "w", zipfile.ZIP_STORED, allowZip64=True) as zf: for root, _, files in os.walk(p): for f in files: if _ignored(f): # Thumbs.db, .DS_Store, ~$임시파일 continue fp = Path(root) / f rel = str(fp.relative_to(p)).replace(os.sep, "/") if selected is not None and rel not in selected: continue # 의뢰서에서 고른 파일만 zf.write(fp, arcname=rel) @after_this_request def _cleanup(resp): try: os.unlink(tmp.name) except OSError: pass return resp 운영하면서 한 가지가 더 붙었다. NAS 폴더에는 이번 요청과 상관없는 시안이나 작업 파일도 같이 들어 있는 경우가 많았다. 그래서 관리자가 의뢰서를 쓸 때 NAS 파일 목록에서 체크박스로 대행사에게 보여 줄 파일만 고르게 했고, 소재 단위 zip은 그 선택 목록에 있는 파일만 담는다. zip 파일 이름은 폴더명이 아니라 의뢰서 제목으로 정했다. 대행사 입장에서는 폴더명보다 의뢰서 제목이 훨씬 알아보기 쉽다. Cloudflare 무료 플랜의 100MB 벽 기획 단계에서 이미 위험 항목에 적어 둔 문제였다. Cloudflare 무료 플랜에서 단일 응답이 100MB를 넘으면 어떻게 되는지 확인이 필요하다고. 실제로 운영해 보니 터널을 거치는 100MB 초과 다운로드가 거부되는 경우가 생겼다. 기획서에는 청크나 Range 요청으로 피하자고 적어 두었고 다운로드 응답도 Range를 받도록( send_file(..., conditional=True) ) 만들었지만, 그것만으로 일반 브라우저 다운로드가 한도 아래로 나뉘어 받아지지는 않았다. 결국 이 부분은 코드로 우회하지 않고 운영 규칙으로 정리했다. 100MB를 넘는 파일은 사내망 주소로 직접 받도록 안내하고, 트러블슈팅 문서에 증상과 대응을 적어 두었다. 완벽한 해결은 아니지만 유료 플랜이나 별도 파일 전송 인프라를 들이는 것보다 현실적인 선택이었다. PPT를 의뢰서 본문으로 넣다가 깨진 한글 의뢰서는 Quill 위지위그 에디터로 쓰는데, 사내 기획 담당자들은 이미 PPT로 기획안을 만들어 두는 경우가 많았다. 그래서 PPT를 올리면 슬라이드를 이미지로 바꿔 본문에 넣는 기능을 만들었다. LibreOffice headless로 PPT를 PDF로 바꾸고, ImageMagick으로 PDF를 150dpi PNG로 쪼갠 뒤 슬라이드마다 <img> 를 넣는다. 원본 PPT는 첨부파일에 자동 등록해서 대행사가 원본도 받을 수 있게 했다. 처음 변환 결과를 열어 보니 한글과 이모지가 깨져 나왔다. 서버가 영문 우분투라 한글 폰트와 컬러 이모지 폰트가 없었던 것이다. Noto CJK, 나눔, Noto Color Emoji 폰트를 설치했는데도 처음에는 그대로였다. LibreOffice가 사용자 프로필에 폰트 정보를 캐시해 두고 있었기 때문이다. 프로필 캐시를 지우고 나서야 정상적으로 렌더링됐다. 이 두 단계(폰트 설치, 프로필 캐시 삭제)는 재발할 때를 위해 운영 문서에 같이 적어 두었다. 결과 대행사는 이제 VPN 없이 웹에서 로그인해 "내 작업공간"에서 자기에게 할당된 소재를 본다. 카드마다 의뢰서, 첨부파일, NAS 파일 그리드, 의견 채팅이 한 화면에 모여 있고, 작업물은 같은 카드에 끌어다 놓아 올린다. 관리자 쪽에서는 소재 목록에서 할당 여부와 의뢰서 작성 여부를 행 색으로 구분해 보고, 대행사가 올린 작업물 중 최종소재로 지정한 것만 따로 모아 갤러리로 보거나 건별 zip으로 받는다. 알림도 양방향으로 붙였다. 대행사가 의견을 남기면 회사 담당자에게 네이버웍스 메시지가 가고, 회사가 할당·의뢰서 저장·메시지를 하면 대행사 사용자가 고른 이벤트에 한해 SMS가 간다. 다운로드, 의견, 할당은 모두 로그로 남아 관리자용 통계 페이지(월간 추이 12개월, 일별 활동 30일)와 감사 로그에 쌓인다. 회고 가장 잘한 결정은 기획서의 복사 큐와