인앱브라우저에서 OAuth·결제·본인인증이 깨졌다 — 환경 분기 레이어로 풀어낸 이야기
처음 마주친 상황
서비스 외부에서 들어오는 트래픽을 살펴보니 인스타그램·카카오톡 같은 SNS 인앱브라우저(앱 내부에 내장된 WebView 기반 브라우저)를 거쳐 들어오는 비율이 꽤 컸다. 문제는 그 환경에서 핵심 플로우 세 개 — 소셜 로그인, 본인인증, 결제 — 가 전부 정상적으로 동작하지 않았다는 거였다. 가입과 결제 단계에서 사용자가 그대로 이탈하는 게 보였고, 이걸 해결하지 않으면 마케팅 유입을 아무리 늘려도 의미가 없었다.
각 플로우가 어떻게 깨지는지 정리해보면 다음과 같았다.
| 플로우 | 사용 라이브러리 | 인앱브라우저에서의 이슈 |
|---|---|---|
| 소셜 로그인 | Kakao OAuth, Google OAuth | Google이 인앱 WebView를 OAuth 차단 정책으로 거부 (disallowed_useragent) |
| 본인인증 | NICE 본인인증 (AUTHNICE) | window.open으로 띄운 팝업이 인앱브라우저에서 차단 |
| 결제 | NICEPay (window.AUTHNICE.requestPay) | 결제창 콜백 후 부모 윈도우 통신 단절 |
세 가지 다 원인은 다르지만, 공통점은 "정상 브라우저를 전제로 만들어진 SDK가 인앱브라우저에서 그 전제를 잃는다"는 것이었다.
내가 짠 3단계 레이어 전략
문제를 한꺼번에 풀려고 하니 코드가 너무 더러워졌다. 그래서 책임을 세 개 레이어로 분리했다 — (1) 환경을 알아내는 레이어, (2) 알아낸 환경을 외부 브라우저로 끌어내는 레이어, (3) 끌어내지 못하는 경우 UI 레벨에서 우회시키는 레이어.
환경 감지 레이어 — User-Agent 분류기
먼저 "지금 사용자가 어떤 브라우저에 있는가"를 한 번에 판별하는 훅을 만들었다. 위치는 src/contexts/global/hooks/components/useCheckBrowser.ts와 useBrowserAlert.ts.
navigator.userAgent 문자열을 정규식으로 매칭해서 8종 인앱브라우저를 분류했다.
- 매칭 키워드:
fbav | fban | instagram | kakaotalk | naver | line | slack | twitter | micromessenger
분류 결과는 단순히 컴포넌트 로컬 상태에 두지 않고, Global State에 browser: 'KAKAO' | 'INSTA' | null 형태로 끌어올렸다. 그래야 앱 어디서든 "지금 이 사용자는 인스타 인앱이다"를 한 줄로 분기할 수 있었다.
한 가지 신경 쓴 부분은 Chrome과 Safari를 정밀하게 식별하는 거였다. UA 문자열에 chrome이 들어 있다고 무조건 Chrome은 아니다. 삼성 인터넷 브라우저나 Edge도 chrome 문자열을 포함하기 때문에 그대로 두면 false positive(잘못된 양성 판정)가 난다. 그래서 이중 negative 매칭으로 걸렀다 — /chrome/이 있고 /safari/도 있고 edge나 samsung은 없을 때만 진짜 Chrome으로 판정.
OS별 외부 브라우저 강제 진입 — Instagram 우회
Google OAuth는 정책상 인앱 WebView를 아예 차단한다. 즉 인앱 안에서 트릭을 쓰는 건 불가능하고, 사용자를 외부 브라우저로 끌어내는 게 유일한 답이었다. OS별로 방식이 달라서 이렇게 분기했다.
// Android: Chrome Intent 스킴
intent://${host}${pathname}?redirected=true#Intent;scheme=https;package=com.android.chrome;end;
// iOS: redirected 플래그 + 리렌더로 Universal Link/Safari 유도
url.searchParams.set('redirected', 'true');
window.location.href = url.toString();Android는 Chrome Intent 스킴(intent://...)을 통해 명시적으로 Chrome 앱을 띄울 수 있다. iOS는 그런 명시적 스킴이 없어서, 같은 URL에 redirected=true 쿼리만 붙여 다시 이동시키면 OS가 Universal Link 또는 Safari로 빠지도록 유도하는 방식이다.
여기서 두 가지를 신경 썼다.
redirected=true쿼리는 무한 리다이렉트 루프 방지용이다. 외부 브라우저로 빠진 다음에도 우리 페이지가 다시 자기 자신으로 리다이렉트하면 무한 루프가 된다. 쿼리가 이미 붙어 있으면 한 번 처리한 것으로 간주하고 스킵.window.opener체크로 본인인증 팝업 컨텍스트에서는 리다이렉트를 스킵했다. 결제·인증 플로우 도중에 외부 브라우저로 강제 이동하면 인증 세션이 통째로 깨지기 때문이다.
UI 레벨 비활성화 + 우회 안내 — Graceful Degradation
위 두 단계로도 못 잡는 케이스가 있다. 특히 카카오톡 인앱은 OAuth는 그럭저럭 통과되는데 Google만 정책상 막히는 식의 부분 실패가 있었다. 그래서 UI 레벨에서 마지막 안전장치를 넣었다.
src/components/SocialButton/SocialButton.tsx:26
const isDisabled = data.social === 'google' && !!state.browser;Google 로그인 버튼만 선택적으로 비활성화하고(Kakao는 인앱에서도 정상 동작하므로 그대로 둠), 1회성 권장 브라우저 안내 토스트를 띄웠다. 토스트는 localStorage로 노출 여부를 기록해 같은 사용자에게 반복 노출되지 않도록 했다.
본인인증·결제 — 듀얼 채널 통신 패턴
소셜 로그인 다음으로 험난했던 게 본인인증과 결제였다. 관련 파일은 src/hooks/useNiceAuth.tsx, src/pages/nice/auth.tsx, src/pages/api/nice_callback.ts 세 개에 걸쳐 있다.
NICE 본인인증는 왜 POST 콜백인가
먼저 NICE 본인인증의 데이터 흐름부터 정리하면 다음과 같다.
콜백이 POST로 오는 게 처음엔 어색했다. 보통 OAuth 콜백처럼 GET으로 와야 자연스러워 보였는데, 알아보니 POST로 받는 데에는 분명한 이유가 있었다.
(1) 페이로드 크기 제한
enc_data는 AES 암호화된 개인정보 블록이다. 평문이 아니라 base64로 인코딩된 긴 문자열이라 수 KB까지 커진다. URL/GET 방식은 브라우저(2KB~8KB)·서버·CDN마다 길이 제한이 다르기 때문에, 일정 크기를 넘으면 414 URI Too Long으로 떨어진다. POST body는 사실상 제한이 없다.
(2) 보안 — 가장 중요한 이유
GET으로 받으면 암호화된 개인정보가 다음 경로로 줄줄 새어나간다.
- 브라우저 History에 영구 저장
- 서버 access log에 그대로 기록
- 다음 페이지로 이동할 때 Referer 헤더로 외부 도메인에 유출
- CDN/프록시 캐시에 저장될 가능성
POST body는 이 모든 채널에 노출되지 않는다. 한국 본인인증·PG는 법적으로 개인정보 보호 의무가 있어서 GET 콜백을 거의 안 쓰는 이유가 여기 있다.
(3) 한국 PG·인증사의 관행
KG이니시스, KCP, 토스, NICE 등 국내 PG/인증은 거의 다 HTML form auto-submit POST 방식이다. 과거 ActiveX·IE 호환성 시절부터 굳어진 패턴이 지금까지 유지된 것 같다.
그런데 코드에는 GET도 받는 분기를 같이 넣어뒀다.
const data = req.method === 'POST' ? req.body : req.query;이유는 모바일 브라우저 분기 때문이었다. 일부 모바일 환경, 특히 WebView에서 form POST가 cross-origin 제약에 걸리는 케이스에서는 NICE가 GET redirect로 콜백을 떨어뜨리는 경우가 있었다. 두 케이스를 한 핸들러에서 모두 받아서 클라이언트 페이지(/nice/auth)로 query string을 통일시켜 redirect하는 방식으로, 클라이언트는 진입점 한 곳만 처리하면 되도록 단순화했다.
듀얼 채널 콜백 — 팝업 vs 리다이렉트
본인인증 콜백을 부모 페이지로 다시 가져오는 방식은 두 갈래로 갈라졌다. 정상 브라우저에서는 팝업+postMessage(다른 창과 안전하게 메시지를 주고받는 브라우저 API), 인앱브라우저에서는 같은 창 리다이렉트+sessionStorage. 왜 두 채널이 필요한지가 이 작업의 핵심이었다.
정상 브라우저 — 팝업 + postMessage 패턴
먼저 정상 브라우저에서의 흐름.
핵심 메커니즘은 다음과 같다.
window.open으로 팝업을 띄우고 form의target속성으로 폼 submit을 팝업으로 라우팅- 팝업과 부모는 다른 origin(NICE 도메인 vs 우리 도메인)을 거쳐도, same-origin으로 돌아오면
window.opener참조가 유지됨 postMessage는 cross-origin도 통과하는 안전한 메시징 채널이다. 부모는event.source !== self체크로 자기 자신이 보낸 메시지를 필터링
이 방식의 강점은 부모 페이지 상태가 100% 보존된다는 점이다. 사용자가 작성하던 폼 입력값, 스크롤 위치, 라우터 상태가 그대로 유지되고, 인증 실패해도 그 자리에서 바로 재시도할 수 있다.
인앱브라우저 — 같은 창 리다이렉트 + sessionStorage 복원
문제는 인앱브라우저에서 이 패턴이 통째로 깨진다는 거였다. 카카오톡 WebView, 인스타그램 WebView 등에서 window.open이 망가지는 케이스를 정리하면 세 가지였다.
- 팝업이 무시됨 — 같은 탭에서 열리거나 아예 안 열림
- 별개 WebView로 열려서
window.opener가 null — 부모 참조가 끊겨postMessage불가 window.close()가 동작 안 함 — 팝업이 닫히지 않아 사용자가 수동으로 닫아야 함
postMessage 채널이 죽은 상태에서도 인증 결과를 부모에 전달할 수 있어야 했다. 그래서 같은 창에서 이동하고, 상태를 sessionStorage로 옮겨놓는 패턴을 짰다.
여기서 저장소를 sessionStorage로 고른 이유는 라이프사이클 때문이었다.
| 저장소 | 라이프사이클 | 적합성 |
|---|---|---|
localStorage | 영구 (사용자가 지우기 전까지) | 인증 끝나도 남아 있음, 보안 리스크 |
cookie | 만료 시간까지 / 모든 요청에 자동 첨부 | 서버로 불필요하게 전송, 크기 제한 4KB |
sessionStorage | 탭이 살아있는 동안만 | 인증 세션과 정확히 일치, 탭 닫으면 자동 폐기 |
| URL query | 페이지 이동 시 명시적 전달만 | 폼값 같은 큰 데이터 부적합 |
리다이렉트 시퀀스에서 sessionStorage가 살아남는 이유는, 같은 origin·같은 탭이면 location.href 이동 후에도 sessionStorage가 유지되기 때문이다. NICE 도메인을 거치는 동안에는 잠시 우리 storage에 접근할 수 없지만, 콜백이 다시 우리 도메인으로 돌아오는 순간 storage가 그대로 살아 있다.
마지막으로 신경 쓴 건 URL query와 sessionStorage의 역할 분리였다.
- sessionStorage: 사용자 입력 데이터(폼값, 리턴 경로) — 우리 서비스 내부 상태
- URL query: 인증 결과(enc_data 등) — NICE에서 받은 외부 데이터
두 채널을 분리한 이유는 신뢰 경계가 다르기 때문이다. 외부에서 온 데이터는 URL로 명시적으로 전달해서 검증·로깅하기 쉽게 만들고, 내부 상태는 storage에 두어 외부에 노출되지 않도록 분리.
핵심 코드 위치
작업이 끝나고 나서 어디에 무엇이 있는지 정리해두는 표.
| 역할 | 파일 |
|---|---|
| 인앱브라우저 분류 (Global State 갱신) | src/contexts/global/hooks/components/useCheckBrowser.ts |
| Instagram → Chrome/Safari 강제 진입 + 권장 브라우저 알림 | src/contexts/global/hooks/components/useBrowserAlert.ts:1-67 |
| Google OAuth 버튼 비활성화 분기 | src/components/SocialButton/SocialButton.tsx:26 |
| OAuth URL 생성 (state 인코딩) | src/utils/social.ts |
| OAuth 콜백 처리 | src/pages/social/callback.tsx |
| NICE 본인인증 훅 (팝업 + postMessage) | src/hooks/useNiceAuth.tsx |
| NICE 콜백 페이지 (듀얼 채널 분기) | src/pages/nice/auth.tsx:21-29 |
| NICE 콜백 API Route (POST/GET 정규화) | src/pages/api/nice_callback.ts |
| NICEPay 결제 SDK 호출 | src/hooks/useNicePayments.tsx |
| sessionStorage 헬퍼 (nice/payment/social-token) | src/utils/web-storage/ |
한 줄로 압축한 패턴
이번 작업의 본질을 한 줄로 정리하면 이렇다.
팝업+postMessage 패턴이 의존하는 두 전제(
window.open동작,window.opener유지)가 인앱브라우저에서 깨지므로, 같은 채널을 잃어도 동등한 결과를 만들 수 있도록sessionStorage(상태 보존) + URL query(외부 데이터 전달) + window.opener 분기로 재구성한 패턴.
작업을 마치고
이 작업을 하면서 가장 인상 깊었던 건, "정상 브라우저"라는 게 생각보다 좁은 가정이었다는 점이다. 평소에는 Chrome·Safari만 신경 쓰면 됐지만, 마케팅 유입의 절반 이상이 SNS 인앱을 통해 들어오는 순간 그 가정 자체가 흔들렸다. SDK들은 정상 브라우저에서 잘 도는 코드를 던져줄 뿐, 그 너머는 결국 우리가 환경을 직접 분기해서 채워 넣어야 했다.
그리고 이번에 깨달은 건 "라이브러리가 의존하는 전제가 무엇인가"를 분해해서 보는 시야가 중요하다는 것이었다. NICE 팝업 패턴은 window.open이 동작하고 window.opener가 유지된다는 두 전제 위에 서 있었고, 그 전제가 깨지는 환경에서는 같은 결과(부모 윈도우로 인증 데이터 전달)를 다른 채널(sessionStorage + URL query)로 재구성해야 했다. 단순히 "에러를 잡는다"가 아니라, 원래 패턴이 무엇을 가정하고 있었는지 → 그 가정 중 무엇이 깨졌는지 → 깨진 부분을 어떤 다른 채널로 대체할 수 있는지 순서로 생각하니 코드가 깔끔하게 정리됐다.
다음에 비슷한 환경 호환성 이슈를 만나면, 처음부터 "정상 케이스"와 "깨진 케이스"를 따로 그려놓고 어디서 어떤 채널을 잃는지 매핑하는 것부터 시작할 것 같다.