포트폴리오
프로젝트 목록
쌓이기 시작한 데이터, 읽을 수 없는 리포트 1편에서 이벤트 클릭과 구매 전환을 수집하는 구조를 만들었다. 몇 달이 지나 데이터가 쌓이자 이번에는 그것을 읽는 쪽이 문제가 됐다. 기간별 성과를 보여 주는 이벤트 리포트 화면은 초기에 급하게 만든 것이라, 화면과 엑셀 다운로드가 각자 따로 계산하고 있었고, 기간 처리도 제각각이었다. 비슷한 시기에 이벤트 페이지에 넣은 상품 슬라이더가 아이폰 인앱 웹뷰에서 통째로 사라진다는 제보도 들어왔다. 이 글은 시리즈 2편으로, 이벤트 리포트를 다시 쓴 과정과 이벤트 페이지 슬라이더가 사라졌던 원인을 추적한 과정을 다룬다. 리포트에 필요했던 것 시작일·종료일을 고르면 그 기간의 클릭, 전환, 전환 금액, 전환율, 객단가를 이벤트별로 본다. 제목·ID 검색, 진행중/종료 필터, 클릭·전환·금액·전환율·제목 순 정렬. 기간 추이 차트와 상위·하위 순위. 화면에서 본 숫자와 엑셀 숫자가 정확히 같아야 한다. 시행착오 1: 같은 쿼리가 세 벌 있었다 기존 리포트 파일을 열어 보니, 기간별로 클릭과 전환을 묶는 SELECT가 거의 같은 모양으로 세 군데에 복사돼 있었다. 엑셀 다운로드용, 화면 목록용, 요약용이다. 그중 하나에만 WHERE 클릭 > 0 조건이 붙어 있어서, 기간 안에 클릭이 없는 이벤트는 화면 어딘가에서는 보이고 어딘가에서는 빠졌다. 화면과 엑셀이 다르게 보였던 이유다. 조건도 일관되지 않았다. 클릭은 click_date BETWEEN ? AND ? , 전환은 DATE(converted_at) BETWEEN ? AND ? 로 걸려 있었다. 후자는 컬럼에 함수를 씌우는 바람에 converted_at 인덱스를 쓸 수 없었다. 입력값 검증도 없어서 날짜 파라미터에 아무 문자열이나 넣을 수 있었다. 그래서 계산을 report_events_data.php 하나로 뽑아냈다. 입력 정리( re_options ), 검색 조건( re_where ), 이벤트별 집계( re_events ), 정렬, 요약, 추이를 각각 함수로 만들고, 화면과 엑셀이 같은 함수를 호출한다. 기간 조건은 "시작일 이상, 종료일 다음날 미만"의 반열린 구간으로 바꿨다. 이렇게 하면 DATETIME 컬럼에도 함수를 씌우지 않고 종료일 하루를 온전히 포함할 수 있다. function re_events($pdo, $options) { [$where, $params] = re_where($options); $next = (new DateTimeImmutable($options['end_date'])) ->modify('+1 day')->format('Y-m-d'); $sql = "SELECT e.*, COALESCE(c.clicks,0) AS period_clicks, COALESCE(v.conversions,0) AS period_conversions, COALESCE(v.amount,0) AS period_amount FROM mkmall.mkmall_events e LEFT JOIN (SELECT event_id, COUNT(*) AS clicks FROM mkmall.mkmall_events_clicks WHERE click_date >= ? AND click_date < ? GROUP BY event_id) c ON c.event_id = e.id LEFT JOIN (SELECT event_id, COUNT(*) AS conversions, SUM(order_amount) AS amount FROM mkmall.mkmall_events_conversions WHERE converted_at >= ? AND converted_at < ? GROUP BY event_id) v ON v.event_id = e.id WHERE 1=1 $where"; $stmt = $pdo->prepare($sql); $stmt->execute(array_merge( [$options['start_date'], $next, $options['start_date'], $next], $params)); // 이후 전환율(클릭 0이면 null)과 객단가를 행마다 계산 } 입력은 Y-m-d 형식인지 엄격하게 검사하고, 시작일이 종료일보다 늦으면 400과 함께 안내 문구를 띄운다. 정렬·상태·탭·페이지 크기 같은 값은 허용 목록에 있을 때만 받아들인다. 전환율은 클릭이 0이면 0%가 아니라 값 없음(null)으로 두어, "클릭이 없어서 비율을 낼 수 없는 이벤트"와 "클릭은 있었지만 아무도 사지 않은 이벤트"가 구분되도록 했다. 추이 차트의 빈 날과 긴 기간 추이 차트도 손봤다. 예전에는 데이터가 있는 날짜만 점으로 찍혀서, 클릭이 없던 날은 선이 그냥 건너뛰었다. 이제는 기간 안의 모든 날짜를 미리 0으로 채운 버킷을 만들고 그 위에 결과를 더한다. 기간이 180일 이상이면 일 단위 대신 월 단위 버킷으로 바꿔 점이 수백 개로 늘어나지 않게 했다. 엑셀은 화면을 그대로 엑셀 다운로드도 같은 결과 배열을 쓰게 되면서 열 구성을 순위, 제목, 상태, 이미지 URL, 링크 URL, 클릭수, 전환수, 전환금액, 전환율, 객단가로 맞췄다. 2행에는 조회 기간과 검색어, 상태 필터를 적어 파일만 봐도 어떤 조건으로 뽑았는지 알 수 있게 했다. URL과 제목은 문자열 타입으로 명시해 엑셀이 숫자나 수식으로 오해하지 않게 했고, 전환율은 백분율 서식, 금액은 천 단위 서식을 걸었다. 헤더 행 고정과 자동 필터도 붙였다. 순위 화면에는 클릭·전환·매출 상위 10개와 함께, 클릭이 있는 이벤트 중 하위 10개를 따로 보여 준다. 내릴 배너를 고르는 데는 상위보다 이쪽이 더 자주 쓰였다. 같은 날 모바일에서 조회 버튼이 밀리는 배치도 한 번 더 손봤다. 시행착오 2: 아이폰에서만 사라지는 슬라이더 이벤트 페이지에는 사내 디자인 생성 도구로 만든 상품 슬라이더가 들어간다. 생성 도구가 HTML 조각과 런타임 스크립트( design-slider.js )를 내보내고, 운영자는 그것을 이벤트 상세에 붙인다. 스크립트는 Swiper 11 CSS를 붙이고 Swiper로 슬라이더를 초기화한다. 이 슬라이더가 PC와 일반 모바일 브라우저에서는 멀쩡한데, 아이폰 인앱 웹뷰에서는 영역 자체가 보이지 않았다. 원인은 두 가지가 겹쳐 있었다. Swiper 버전 충돌. 해당 이벤트 페이지는 다른 용도로 Swiper를 unpkg에서 버전 지정 없이 불러오고 있었다. 즉 최신인 14가 전역 window.Swiper 에 먼저 자리 잡는다. 런타임은 "이미 Swiper가 있고 버전이 11 이상이면 재사용"하도록 짜여 있었기 때문에, v11 CSS 위에서 v14 JS로 초기화가 일어났다. 지연 로딩 의존. 카페24는 상세 이미지에 src 를 넣지 않고 ec-data-src 만 남긴 뒤 자체 스크립트로 나중에 채운다. 일부 웹뷰에서는 이 지연 로딩이 늦거나 돌지 않아 이미지 높이가 0이 되고, overflow:hidden 안의 슬라이더가 통째로 접혔다. 첫 수정에서는 재사용 조건을 "11 이상"에서 "정확히 11"로 좁히고, 슬라이더 안 이미지는 초기화 전에 ec-data-src 를 src 로 옮겨 즉시 로드하게 했다. Swiper 로드가 실패하거나 8초 안에 오지 않거나 초기화 중 예외가 나면, 이미지라도 보이도록 가로 스크롤 목록으로 떨어지는 폴백을 넣었다. 높이 계산은 ResizeObserver 외에 이미지 load/error, 창 resize, 3초 뒤 최종 보정으로 이중화했다. 생성 HTML에 인라인으로 박혀 있던 Swiper CDN 스크립트도 빼서, 우리 코드가 페이지 전역 Swiper를 덮어쓰지 않게 했다. // 카페24 지연 로딩에 맡기지 않고 슬라이더 안 이미지는 즉시 로드 function forceLoadImages(root) { var imgs = root.querySelectorAll('img'); for (var i = 0; i < imgs.length; i++) { var img = imgs[i]; if (img.getAttribute('src')) continue; var src = img.getAttribute('ec-data-src') || img.getAttribute('data-src') || img.getAttribute('data-original'); if (!src) continue; img.setAttribute('src', src); img.removeAttribute('ec-data-src'); img.classList.remove('lazyload'); } } 그런데 이번엔 모든 슬라이더가 폴백으로 배포하고 이틀 뒤, 이번에는 웹뷰가 아닌 곳에서도 슬라이더 화살표가 동작하지 않고 스크롤바만 보였다. 폴백이 모든 페이지에서 켜지고 있었다. 정확히 11인지 판별하려고 Swiper.version 을 읽었는데, Swiper의 UMD 번들은 생성자에 version 을 노출하지 않았다. 11.x를 여러 버전 확인해 봐도 마찬가지였다. 판별 함수는 항상 false를 돌려줬고, 로드 결과도 null로 넘어가 폴백으로 빠졌다. 전역이 11인지 판별할 방법이 없다면 전역을 믿지 않는 수밖에 없었다. 재사용 판별을 아예 없애고, 항상 우리가 지정한 URL로 고정 버전(11.2.10)을 직접 받아 그 참조만 쓰도록 바꿨다. 부동 태그( @11 )도 패치 릴리스마다 동작이 바뀔 수 있어 CSS와 JS 모두 정확한 버전으로 고정했다. 받아 온 결과는 "함수이기만 하면" 그대로 쓴다. 카페24 CDN이 디자인 파일을 2시간가량 캐시하기 때문에, 스크립트 태그의 버전 쿼리도 함께 올려 배포했다. 결과 이벤트 리포트는 개요·추이·목록 탭으로 나뉘었고, 기본 조회 기간은 최근 30일이다. 검색·상태 필터·정렬·페이지 크기(20/50/100)를 지원하고, 화면과 엑셀이 같은 계산을 쓰기 때문에 두 숫자가 어긋나지 않는다. 이벤트 페이지 슬라이더는 다른 스크립트가 어떤 버전의 Swiper를 먼저 불러와도 자기 버전으로 동작하고, 그마저 실패하면 가로 스크롤로라도 상품을 보여 준다. 회고 계산은 한 곳에. 같은 SQL을 복사해 쓰면 조건 하나가 어긋나는 순간 화면과 파일이 서로 다른 말을 한다. 출력 형식(HTML, 엑셀)이 몇 개든 집계 함수는 하나여야 했다. 기간은 반열린 구간으로. BETWEEN 과 DATE() 조합은 편해 보이지만 인덱스와 경계값 모두에서 손해다. 처음부터 >= 시작 AND < 종료+1일 로 썼어야 했다. 남의 페이지에 들어가는 스크립트는 전역을 믿지 말 것. 공유 전역을 재사용하려던 최적화가 두 번의 장애를 만들었다. 버전 판별에 쓰려던 속성이 실제 번들에 있는지 확인하지 않은 채 조건을 바꾼 것이 두 번째 장애의 직접 원인이었다. 판별 로직을 넣을 때는 실제 배포 번들로 먼저 찍어 봤어야 했다. 폴백은 조용히 켜지면 안 된다. 폴백 덕분에 화면이 비지는 않았지만, 그 때문에 "모든 슬라이더가 폴백"이라는 상태를 이틀 동안 알아채지 못했다. 다시 한다면 폴백 진입 시 원인을 콘솔과 서버 로그에 남기겠다. 기간: 2026.02 ~ 2026.09 기술: PHP, MySQL, PhpSpreadsheet, JavaScript, Swiper 11, 카페24 스킨 관련 프로젝트: 마더케이몰 이벤트관리
프로젝트 목록
공개된 기록과 소식
프로필과 소개