포트폴리오
프로젝트 목록
만들고 나서부터 시작된 일 앞 글에서 광고소재 대시보드를 "NAS 직송" 구조로 만든 과정을 정리했다. 대행사가 VPN 없이 웹에서 할당받은 소재를 내려받고, 의뢰서와 의견을 한곳에서 주고받는 시스템이다. 기능이 돌아가기 시작한 뒤에는 다른 종류의 문제가 나왔다. 코드가 틀려서라기보다, 이 앱이 기대고 있는 바깥 것들이 생각만큼 얌전하지 않아서 생긴 문제들이었다. 이 앱은 세 가지 외부 의존성 위에 서 있다. 사내 NAS를 CIFS로 마운트한 파일시스템, 다른 팀이 운영하는 외부 호스팅 MariaDB의 디자인 요청 데이터, 그리고 다른 서비스들과 같이 쓰는 사내 서버 한 대다. 이번 글은 이 셋이 흔들릴 때 화면이 같이 흔들리지 않게 만든 과정과, 운영 중 요청이 늘면서 데이터 설계를 고친 이야기다. 외부 의존성을 대하는 원칙 정리해 보면 원칙은 하나였다. 바깥이 잠깐 실패해도 마지막으로 성공한 값을 보여 준다. 실시간 정확성이 조금 떨어지더라도, 대행사가 보는 화면에서 소재가 사라졌다 나타났다 하는 것보다는 낫다. 다만 이 값이 낡은 값이라는 사실은 숨기지 않는다. NAS 목록이 가끔 "없음"으로 깜빡이던 문제 소재 카드를 열면 NAS 파일 목록이 가끔 "찾을 수 없음"으로 나왔다가, 다시 열면 멀쩡히 나오는 일이 있었다. 경로도, 권한도 바뀐 게 없었다. 문제가 된 순간에는 Path.exists() 가 False를 돌려주거나 디렉터리를 읽다가 OSError 가 났다. 원인은 SMB 마운트였다. 네트워크 파일시스템은 아주 짧은 순간 응답이 끊기는 일이 있고, 그 순간에 걸린 요청은 파일이 없다고 판단한다. 처음 코드는 매 요청마다 디렉터리를 새로 읽었기 때문에 그 순간이 그대로 사용자 화면에 "없음"으로 찍혔다. 해결은 디렉터리 목록에 두 단계 캐시를 두는 것이었다. 60초 안의 결과는 신선한 값으로 바로 돌려준다. 60초가 지나면 다시 읽어 보고, 성공하면 캐시를 갱신한다. 다시 읽기가 실패했을 때 1시간 이내의 캐시가 있으면 그걸 돌려주되 stale 표시를 붙인다. 캐시조차 없을 때만 404다. NAS_LIST_TTL = 60 # 신선 응답 유효 기간(초) NAS_LIST_STALE_MAX = 3600 # 실패 시 낡은 캐시 허용 기간(초) def cached_list_dir(local_path, recursive=False): key = (str(local_path), bool(recursive)) now = time.time() with _NAS_LIST_LOCK: entry = _NAS_LIST_CACHE.get(key) if entry and now - entry["ts"] < NAS_LIST_TTL: return entry["items"], False try: items = list_dir(local_path, recursive) if Path(local_path).exists() else None except OSError: items = None if items is not None: with _NAS_LIST_LOCK: _NAS_LIST_CACHE[key] = {"ts": now, "items": items} return items, False if entry and now - entry["ts"] < NAS_LIST_STALE_MAX: return entry["items"], True # 실패했지만 최근 값으로 버틴다 return None, False API 응답에 stale 값을 같이 내려서 프론트가 필요하면 표시할 수 있게 했다. Gunicorn 워커가 여러 개라 캐시는 워커마다 따로 생기지만, 목적이 "순간적인 실패를 덮는 것"이라 공유 캐시까지는 필요 없다고 봤다. NAS 자체가 끊긴 경우는 다르게 다뤄야 해서, 마운트 지점이 아예 없으면 404 대신 503과 "NAS 미연결"을 돌려주고 관리자 시스템 탭에서 마운트 상태를 볼 수 있게 했다. 외부 DB가 응답하지 않을 때, 그리고 엉뚱한 값이 들어 있을 때 소재 목록의 원본은 카페24에 호스팅된 MariaDB다. 이 DB가 잠깐 응답하지 않으면 목록 화면 전체가 비었다. 여기도 같은 원칙을 썼다. 목록은 5분 동안 메모리에 캐시하고, 조회가 실패하면 예외를 로그로 남긴 뒤 마지막으로 받아 둔 목록을 그대로 돌려준다. 접속 타임아웃은 5초로 짧게 잡아서 DB가 멈췄을 때 요청이 오래 붙잡히지 않게 했다. DB가 복구되면 다음 캐시 만료 때 자연스럽게 새 값으로 바뀐다. 더 골치 아팠던 건 데이터 자체였다. 폴더 경로 컬럼에 UNC 경로 대신 사내 웹 페이지 URL이 들어 있는 요청이 있었고, 완료된 요청인데 경로가 비어 있는 경우도 있었다. 이 건들은 NAS 파일 카드가 늘 빈 채로 나왔다. 들여다보니 같은 테이블의 기획 위치 컬럼에 실제 NAS 경로가 들어 있었다. 남의 팀 DB라 고칠 수는 없으니, 읽어 온 직후에 보정 규칙을 넣었다. 경로 컬럼이 그 웹 페이지 URL이거나, 진행 상태가 완료인데 경로가 비어 있으면 기획 위치 값으로 대체한다. 바이트로 들어오는 문자열은 UTF-8로 먼저 풀고, 실패하면 CP949로 다시 푼다. 윈도우 인코딩으로 저장된 문자열이 섞여 있을 때를 대비한 처리다. 이 보정과 별개로, 경로를 마운트 경로로 바꾸는 함수는 URL이나 상대 경로를 무조건 거부하게 짜 두었다. 보정 규칙이 놓친 이상한 값이 와도 최소한 엉뚱한 곳을 열지는 않는다. 요청서가 이 앱 안으로 들어오면서 생긴 설계 수정 처음 이 앱은 디자인 요청 DB에 있는 건만 다뤘다. 그런데 그 DB를 거치지 않는 광고 소재 요청도 같은 화면에서 할당하고 주고받아야 했다. 그래서 앱 안에서 직접 요청서를 만드는 기능을 넣었다. 외부 DB의 요청 번호와 겹치지 않도록 앱 내부 요청에는 큰 기준값(9000000)을 더한 번호를 붙였고, 소재를 번호로 찾는 함수가 이 기준값을 보고 어느 저장소에서 읽을지 고른다. 덕분에 할당, 메시지, 다운로드 로그 같은 기존 테이블은 하나도 고치지 않고 두 종류의 요청을 같이 다룰 수 있었다. "영상(11)"을 그대로 저장한 실수 요청서에는 연·월·주차, 채널, 매체, 목표, 부서, 프로모션을 조합한 캠페인명이 자동으로 만들어진다. 여기에 광고 유형(영상, GIF 또는 이미지) 필드를 추가하면서 첫 구현은 선택지를 "영상(11)" , "GIF or 이미지(22)" 로 두었다. 화면에서는 이름과 코드를 같이 보여 주고 싶었기 때문이다. 그런데 이 문자열이 그대로 DB에 저장됐고, 캠페인명에는 유형 코드가 아예 들어가지 않았다. 캠페인명에 필요한 건 11 , 22 같은 코드였는데 표시용 문자열과 저장값을 한 덩어리로 만든 게 문제였다. 같은 날 바로 고쳤다. 선택지는 이름만 남기고, 코드는 이름에서 계산한다. 이미 저장된 "영상(11)" 같은 값이나 시트에서 들어오는 "11" 같은 값도 처리해야 했기 때문에, 어떤 형태로 들어와도 이름으로 정규화하는 함수를 먼저 두고 코드는 그 결과에서만 뽑았다. function requestTypeLabel(v) { const raw = String(v || '').trim(); if (!raw) return ''; if (raw === '11' || raw.includes('영상')) return '영상'; if (raw === '22' || raw.toLowerCase().includes('gif') || raw.includes('이미지')) return 'GIF or 이미지'; return raw; } function requestTypeCode(v) { const label = requestTypeLabel(v); if (label === '영상') return '11'; if (label === 'GIF or 이미지') return '22'; return ''; } // 캠페인명: 26년_7월_5주_11_채널_매체_부서_프로모션 const filters = [typeCode, channel, media, goal, dept, promo].filter(Boolean).join('_'); 유형 옆에는 물음표 버튼을 달아 "화면에는 이름, 캠페인명에는 코드만 들어간다"는 안내를 띄웠다. 쓰는 사람이 캠페인명에 숫자가 왜 붙는지 모르면 결국 손으로 고치게 되기 때문이다. 코드에 박아 둔 선택지 채널과 부서 선택지는 처음에 파이썬 상수로 넣어 두었다. 매체만 별도 테이블로 관리하고 있었다. 운영을 시작하자 채널과 부서도 바뀌었고, 그때마다 코드를 고쳐 배포해야 했다. 매체 관리 기능을 일반화해서 채널·매체·부서를 같은 구조의 옵션 테이블(이름, 정렬 순서, 사용 여부)로 옮기고, 요청서 화면의 각 선택 상자 옆에 "관리" 버튼을 붙였다. API도 /api/admin/manual_options/<kind> 하나로 묶었다. 테이블 이름은 사용자 입력이 아니라 허용된 kind 목록에서만 고르게 해서 SQL에 문자열을 끼워 넣어도 안전하게 했다. 마이그레이션은 단순하게 처리했다. 테이블이 비어 있을 때만 기존 상수 값을 정렬 순서 10 단위로 넣고, 테이블 조회가 실패하면 상수 값으로 대체한다. 이미 배포된 서버에서 재시작 한 번으로 옮겨 가야 했기 때문이다. 자동 요약표가 직접 쓴 본문을 덮어쓰던 문제 요청서를 저장하면 입력값을 표로 정리해 의뢰서 본문에 자동으로 넣는다. 처음 구현은 "본문이 비어 있거나 저장 버튼을 누르면 요약표 HTML로 본문을 채운다"였다. 그런데 담당자가 요약표 아래에 상세 설명을 덧붙여 쓴 뒤 다시 저장하면 본문 전체가 새 요약표로 바뀌면서 직접 쓴 내용이 사라졌다. 요약표에 data-manual-summary 표시를 붙여서 본문 안에서 자동 생성된 부분만 찾아 바꾸도록 고쳤다. 표시가 없는 예전 본문은 표 셀의 속성으로 옛 요약표를 찾아 교체한다. 본문이 정말 비어 있는지도 텍스트만 보지 않고 이미지, 표, 목록이 있는지까지 확인한다. 대행사 화면에서는 요약표를 뺀 나머지를 "요청 상세 내용"으로 따로 보여 준다. 서버를 같이 쓰는 데서 오는 문제 이 앱이 돌고 있는 사내 서버에는 다른 서비스도 여럿 있다. 그중 한 파일 서비스는 삭제된 대용량 임시 파일을 계속 열어 둔 채로 남는 경우가 있었다. 파일은 지워졌지만 프로세스가 핸들을 쥐고 있으니 공간이 돌아오지 않는다. 이 앱도 폴더 zip을 임시 파일로 만들었다가 지우는 구조라 남의 일이 아니었다. 그래서 디스크 감시 스크립트를 만들어 systemd 타이머로 5분마다 돌렸다. 루트 디스크 사용률이 85%를 넘으면 경고, 95%를 넘으면 위험 알림을 보낸다. lsof +L1 로 삭제됐지만 열려 있는 파일 중 5GB 이상을 찾아 알리고, 문제의 파일 서비스가 쥔 양이 10GB를 넘으면 그 서비스를 재시작한다. 알림과 재시작에는 1시간 쿨다운을 둬서 같은 문제로 알림이 쏟아지거나 재시작이 반복되지 않게 했다. 마지막 알림 시각은 상태 파일에 남긴다. 같은 시기에 접속 통로도 하나 더 만들었다. 일부 환경에서는 기존 주소로 접속이 잘 되지 않는다는 이야기가 있어서, 같은 앱으로 요청을 그대로 넘기는 작은 Flask 프록시를 별도 서비스로 띄우고 대체 호스트명을 연결했다. 응답은 256KB 단위로 흘려보내 큰 파일도 메모리에 다 올리지 않고, 앱이 보내는 리다이렉트 주소는 게이트웨이 주소로 바꿔 줘서 로그인 후에 원래 주소로 튀지 않게 했다. 상태 페이지와 헬스체크 경로도 따로 두었다. 결과 NAS가 순간적으로 흔들리거나 외부 DB가 잠깐 멈춰도 대행사 화면은 마지막으로 성공한 상태를 유지한다. 외부 DB에 있는 요청과 앱에서 직접 만든 요청이 같은 할당·메시지·다운로드 흐름을 탄다. 유형 코드가 들어간 캠페인명이 자동으로 만들어지고, 채널·매체·부서 선택지는 배포 없이 관리 화면에서 바꾼다. 서버 디스크는 5분마다 감시되고, 정해 둔 기준을 넘으면 알림이 온다. 회고 가장 크게 배운 건 "바깥은 늘 조금씩 실패한다"는 걸 처음부터 설계에 넣어야 한다는 점이다. NAS 목록 캐시도 외부 DB 캐시도 문제가 터진 뒤에 붙였다. 처음부터 외부 호출마다 "실패하면 무엇을 보여 줄 것인가"를 정해 두었다면 대행사가 깜빡이는 화면을 볼 일은 없었을 것이다. 유형 필드 실수는 작았지만 교훈은 분명했다. 화면에 보이는 문자열과 저장하는 값, 다른 곳에
프로젝트 목록
공개된 기록과 소식
프로필과 소개