QR로 들어와 비회원도 참여하는 캠페인 응모 사이트
인쇄 QR로 시작하는 3개월짜리 레트로 헤리티지 응모 캠페인
인쇄된 QR 두 장 구분하기
1940년대부터 1990년대까지의 레트로 패키지를 다시 꺼낸 헤리티지 캠페인의 웹 응모 창구입니다. 인쇄물에 실린 QR 코드로 들어오는 페이지였습니다.
요구사항은 구체적이었습니다. 앱 없이 웹만으로 응모를 끝낼 것, 비회원도 이름과 연락처만으로 응모할 수 있을 것, 회원은 응모 1회당 20포인트가 쌓이고 그 포인트가 3차와 4차 추첨의 당첨 확률에 반영될 것, 한 사람이 하루 3번까지만 응모할 수 있을 것. 여기에 소셜 로그인 4종(네이버, 카카오, 페이스북, 구글), 한국어와 영어 두 언어, IE11 지원이 붙었습니다.
QR은 두 종류로 나갔습니다. 일반 소비자용과 파트너 채널(유통·제휴처)용을 따로 인쇄해서, 같은 캠페인 페이지를 보여주되 응모가 어느 쪽 QR에서 출발했는지는 서버에 남겨야 했습니다. 화면은 같고 꼬리표만 다릅니다. 이 요구가 뒤에서 이야기할 설계 대부분을 결정했습니다.
3개월만 도는 사이트, 서버 없이
Create React App으로 만든 SPA를 정적 호스팅에 올리고, 우리 쪽 서버는 따로 두지 않았습니다
3개월 뒤에 내릴 페이지였고, 트래픽은 사람들이 QR을 찍는 순간에만 뾰족하게 몰립니다. 서버 렌더링으로 얻을 게 거의 없었어요. 번들만 올려두면 스캔이 몰려도 앞단이 알아서 버텨줍니다. 그래도 캠페인 페이지가 검색에 잡히긴 해야 했습니다. 그래서 react-helmet으로 화면마다 메타 태그를 갈아끼우고(src/Utils/Meta.js), react-router-sitemap으로 public/sitemap.xml을 빌드 전에 생성하도록 package.json에 predeploy 스크립트를 걸었습니다.
로그인과 회원가입을 화면이 아니라 모달 안의 페이지 상태로 만들었습니다
이 사이트에서 로그인은 목적지가 아니라 응모 퍼널 중간에 끼어드는 방해물입니다. /login 같은 라우트로 빼면 사용자를 이벤트 페이지 밖으로 내보내는 셈이라, 돌아오는 길에서 이탈이 생깁니다. 그래서 popupState.login으로 모달을 띄우고, 그 안에서 popUpPage 값 1부터 7까지로 회원 유형 선택, 약관, 정보 입력, 완료 단계를 갈아끼웠어요. 대가는 분명합니다. 뒤로가기로 모달을 닫을 수 없고, 가입 3단계에 링크를 걸 수도 없어요. 캠페인 수명이 3개월인 페이지라 이 불편은 받아들였습니다.
access token은 메모리에만 두고, refresh token만 localStorage에 저장했습니다
loginSuccess는 받은 access를 axios.defaults.headers.common["Authorization"]에만 꽂습니다(src/Service/UserApi.js:91). 주소와 연락처를 다루는 응모 폼이라 access가 저장소에 남아 도는 상황은 피하고 싶었어요. 대신 새로고침 한 번이면 access가 통째로 사라집니다. 보호 화면이 빈 채로 남는 문제가 생기는데, 이건 뒤에서 자세히 다룹니다.

정적 번들 하나와 창구 네 개
번들은 CRA가 만든 정적 파일 한 덩어리입니다. 코드에 남은 s3.ap-northeast-2.amazonaws.com 참조를 보면 정적 파일과 영상·이미지가 같은 버킷에 올라가 앞단 캐시를 거쳐 내려갔습니다. 라우팅은 전부 브라우저에서 일어나고, 어떤 경로로 들어와도 서버는 index.html 하나만 돌려줍니다. /newtropepsicustomer와 /newtropepsipartner가 같은 Event 컴포넌트에 걸려 있는 것도 그래서 가능해요(src/Components/Router/MainRouter.js:59).
여기서는 QR 출처를 서버까지 옮기는 문제와 이 정적 번들 구성만 깊게 다룹니다. 브라우저가 직접 여는 네 개의 외부 창구(소셜 로그인, 주소 검색, 인스타그램 피드, 페이지뷰 수집)와 Context 세 개로 나눈 상태 관리, 다국어 번들링과 IE11 대응, 토큰 재발급 타이밍, 설정 하드코딩과 주소 스키마 문제는 각각 따로 다룰 가치가 있는 이야기라 이 글에서는 접습니다.

인쇄된 QR의 출처를 응모 완료까지 살려 보내기
보통은 쿼리스트링으로 넘깁니다. 파트너용 QR에 ?from=partner를 붙이고, 응모 payload를 만들 때 location.search에서 꺼내 쓰는 식이죠. 조금 더 신경 쓴다면 React Router의 location.state나 Context에 담아 퍼널 아래로 내려보냅니다. 이 캠페인에서는 둘 다 깨집니다. 네이버 로그인은 SDK 팝업이 아니라 nid.naver.com으로 전체 페이지가 이동했다가 /oAuth로 돌아오기 때문에 메모리 상태가 통째로 날아가고, 쿼리스트링은 그 왕복과 /quiz 이동을 거치면서 사라집니다. 퀴즈를 맞히고, 굿즈 팝업을 세 번 열고, 응모 폼까지 가는 동안 화면은 여러 번 언마운트됩니다.
출처를 라우트 매칭 순간에 브라우저 저장소로 옮겼습니다. Event 컴포넌트가 렌더될 때 match.url을 보고 파트너 경로면 localStorage.setItem("partner", true)를 찍습니다. 응모 payload를 만드는 지점에서는 exclusive: localStorage.getItem("partner") ? localStorage.getItem("partner") : false로 읽어 붙이고, 응모가 끝나 완료 화면이 뜨는 순간 localStorage.removeItem("partner")로 지웁니다. 회원 응모와 비회원 응모 두 경로 모두 같은 규칙을 씁니다.
전체 페이지 이동과 SPA 언마운트를 모두 견디는 저장소는 localStorage뿐이었습니다. 더 중요한 건 지우는 시점을 응모 성공 화면에 뒀다는 점이에요. 플래그의 수명을 세션이 아니라 응모 1건에 맞춰 뒀기 때문에, 파트너 QR로 들어와 한 번 응모한 사람이 이어서 두 번째 응모를 해도 그 건은 일반 응모로 집계됩니다. 인쇄물 한 장이 응모 한 건에 대응한다는 캠페인 쪽 정의와 코드의 수명이 같아진 셈입니다.
access 토큰이 아직 없는 순간에 마이페이지가 비지 않게 하기
마운트 시점에 한 번만 부르면, 새로고침 직후에는 access가 아직 없어서 401을 받고 화면이 로딩에 갇힙니다.
재발급 성공을 acc 상태로 올리고, 보호 화면들이 state.acc를 구독해 토큰이 늦게 도착하면 그때 스스로 다시 요청하게 했습니다. 로딩이 2초를 넘기면 다시 시도 버튼을 내놓습니다.
토큰이 먼저냐 화면이 먼저냐 하는 순서 싸움을 없앤 셈이라, 새로고침 뒤에도 빈 화면이 남지 않습니다. 자세한 경위는 따로 적을 만한 이야기라 여기서는 접습니다.
유저가 할 수 있는 일
브라우저 안의 정답, 죽은 master
가장 부끄러운 건 퀴즈입니다. 다섯 문제와 정답이 src/Components/Pages/Quiz.js:122의 AllQuizList 배열에 그대로 들어 있고, 채점도 브라우저가 합니다. 개발자 도구를 열 줄 아는 사람에게는 퀴즈가 아니라 클릭 한 번이에요. 실제 방어선은 서버가 돌려주는 daily events limit is over, 즉 하루 3회 제한 하나뿐입니다. 다시 만든다면 문제와 보기만 서버에서 받고, 정답 판정과 응모 자격 부여를 한 번의 서버 요청으로 묶겠습니다. 경품이 걸린 이벤트에서 정답을 클라이언트에 두는 건 편의가 아니라 실수였습니다.
브랜치 관리도 실패했습니다. master의 마지막 커밋은 develop을 머지한 것인데, 충돌이 해소되지 않은 채로 그대로 커밋됐어요. src/App.js, src/index.js, src/Service/UserApi.js, public/index.html, package.json에 <<<<<<< HEAD 마커가 남아 있어서 지금 master는 yarn build가 통과하지 않습니다. 원격에는 design, design2, design3, i18n, i18next, lang, modal, mv 같은 브랜치가 17개 남아 있고요. 빌드가 깨진 커밋을 기본 브랜치에 올릴 수 없게 막는 장치가 하나도 없었던 결과입니다. CI에서 빌드만 돌렸어도 잡혔을 문제예요.
















