
일전에 카페24 기반의 자사몰을 작업하면서 웹뷰에서의 로그인에 대해 다뤘었다. 앱에서 웹뷰를 통해 자사몰에 접근할 때 사용자가 별도의 로그인 절차를 거치지 않도록 하는 것이 목표였기에 다음과 같이 설계했다.
accessToken과 이동할 페이지 정보(path)를 쿼리 스트링에 담아 자사 웹사이트로 사용자를 보냄, 앱에서 들고 있던 accessToken 을 로컬 스토리지에 삽입accessToken 을 저장, mall_connect_type=app 파라미터를 확인하여 웹뷰 환경임을 인지, 전달받은 path와 언어(lang) 정보를 조합하여 최종 목적지인 자사몰 URL을 만들어 window.location.replace() mallConnectType이 app인 경우 자동으로 SSO 로그인 트리거(window.open)mallConnectType이 app이면서 accessToken을 자사 웹사이트에서 들고 있는 경우 로그인 시도 및 성공path가 있는 경우) 협의된 URL 스키마를 호출하여 로딩 인디케이터를 없애고 로그인된 path 경로 페이지 확인path가 없는 경우) 협의된 URL 스키마를 호출하여 로딩 인디케이터를 없애고 로그인된 메인 페이지 확인자사 웹사이트에서 최초 렌더링 시 다음과 같이 mallConnectType을 체크하고 loginProcessInWebview()을 실행한다.
useEffect(() => {
if (isMallConnectApp()) {
loginProcessInWebview();
}
}, []);
자사몰로 이동하는 3번까지의 로직을 loginProcessInWebview()에서 처리하는데 로직은 다음과 같다.
const loginProcessInWebview = () => {
const queryParams = new URLSearchParams(location.search);
const mallConnectType = queryParams.get('mall_connect_type');
const path = queryParams.get('path');
let lang = (queryParams.get('lang') || 'en').toLowerCase();
if (lang !== 'en' && lang !== 'ko') {
lang = 'en';
}
const MALL_BASE_URL = lang === 'ko' ? 'https://mall.shop/' : 'https://en.mall.shop/';
let newQueryParams = `?mall_connect_type=${mallConnectType}&lang=${lang}`;
if (path) {
newQueryParams += `&path=${path}`;
}
if (mallConnectType && lang) {
window.location.replace(`${MALL_BASE_URL}${path || ''}${newQueryParams}`);
}
};
이때의 내 가정은 path는 순수한 경로 문자열일 것이라는 점이었다. 즉 쿼리 문자열이 없는 값이 들어가는 것만 고려하다 보니 새로운 쿼리 문자열을 만들거나 파싱할 때 이슈에 대한 고려를 충분히 하진 못했다.
운영팀으로부터 특정 페이지로의 이동이 실패한다는 이슈가 확인됐다. 최초 접근했던 URL은 아래와 같았다.
https://live.website.tv/?mall_connect_type=app&lang=kr&path=product/search.html?banner_action=&keyword=fancast#mall
문제는 path 파라미터 값(product/search.html?keyword=fancast) 내부에 ?와 & 같은 URL 예약어(Reserved Characters)가 포함되어 있었다는 점이었다.
앱에서 이 주소로 요청해서 자사 웹사이트로 넘어오면 path를 판별 후 다음과 같은 주소를 조합해서 redirect 하게 된다.
https://mall.shop/?mall_connect_type=app&lang=ko&path=http://product/search.html?banner_action=&keyword=fancast#mall
URL에서 ?는 쿼리 문자열의 시작을, &는 각 매개변수를 구분하는 역할을 한다. 일단 쿼리 문자열의 시작을 알리는 ?가 두 개 있는 것도 불분명하지만, 인코딩되지 않은 & 문자가 path 값 내부에 있기 때문에 다음과 같이 잘못 해석된다.
mall_connect_type = applang = kopath = http://product/search.html?banner_action=keyword = fancast#mallhttps://mall.shop의 자사몰로 이동 후에 쿼리 문자열을 파싱하는 로직은 다음과 같다.
document.addEventListener('DOMContentLoaded', () => {
const queryParams = new URLSearchParams(window.location.search);
const mallConnectType = queryParams.get('mall_connect_type');
if(mallConnectType) {
localStorage.setItem('mallConnectType', mallConnectType);
}
const path = queryParams.get('path');
if(path) {
localStorage.setItem('path', path);
}
const lang = queryParams.get('lang');
const MALL_BASE_URL = lang === 'ko' ? 'https://mall.shop/' : 'https://en.mall.shop/';
if(mallConnectType && mallConnectType === 'app') {
MemberAction.snsLogin('sso', '%2F');
}
const saved_path = localStorage.getItem('path');
const saved_mall_connect_type = localStorage.getItem('mallConnectType');
if(saved_mall_connect_type && saved_path) {
window.location.replace(`${MALL_BASE_URL}${saved_path}`);
localStorage.removeItem('path');
}
});
그제야 다른 웹서비스의 로컬 스토리지를 살펴볼 때 인코딩된 값이 담겨 있던 기억이 떠올랐다. 아 그래서 인코딩했구나.
핫픽스를 위한 솔루션 자체는 간단했다. path 값을 자사몰로 전달하기 전에 encodeURIComponent()로 감싸서 인코딩하고, newQueryParams에 정적 ? 대신 path에 ?가 포함 여부를 체크해서 joiner 변수로 동적 삽입으로 변경했다.
const loginProcessInWebview = () => {
const queryParams = new URLSearchParams(location.search);
const mallConnectType = queryParams.get('mall_connect_type');
const decodedPath = queryParams.get('path');
let lang = (queryParams.get('lang') || 'en').toLowerCase();
if (lang !== 'en' && lang !== 'ko') {
lang = 'en';
}
const MALL_BASE_URL = lang === 'ko' ? 'https://mall.shop/' : 'https://en.mall.shop/';
let newQueryParams = `mall_connect_type=${mallConnectType}&lang=${lang}`;
const joiner = decodedPath && decodedPath.includes('?') ? '&' : '?';
if (mallConnectType && lang) {
let redirectUrl = `${MALL_BASE_URL}${decodedPath || ''}${joiner}${newQueryParams}`;
if (decodedPath) {
const encodedPath = encodeURIComponent(decodedPath);
redirectUrl += `&path=${encodedPath}`;
}
window.location.replace(redirectUrl);
}
};
그리고 자사몰에서 해당 쿼리 문자열을 받아서 path에 붙일 땐 인코딩된 문자열은 브라우저가 판별할 수 없기 때문에 decodeURIComponent()으로 감싸서 다시 디코딩한다.
document.addEventListener('DOMContentLoaded', () => {
const queryParams = new URLSearchParams(window.location.search);
const mallConnectType = queryParams.get('mall_connect_type');
if(mallConnectType) {
localStorage.setItem('mallConnectType', mallConnectType);
}
const path = queryParams.get('path');
if(path) {
localStorage.setItem('path', path);
}
const lang = queryParams.get('lang');
const MALL_BASE_URL = lang === 'ko' ? 'https://mall.shop/' : 'https://en.mall.shop/';
if(mallConnectType && mallConnectType === 'app') {
MemberAction.snsLogin('sso', '%2F');
}
const saved_path = localStorage.getItem('path');
const saved_mall_connect_type = localStorage.getItem('mallConnectType');
if(saved_mall_connect_type && saved_path) {
const decoded_path = decodeURIComponent(saved_path);
window.location.replace(`${MALL_BASE_URL}${decoded_path}`);
localStorage.removeItem('path');
}
});
그럼에도 불구하고 예상할 수 없는 리다이렉팅 동작이 생겼다. 도저히 로직 상으로는 생길 수 없는 동작이라 확인해 보니, 카페24는 로그인이 필요한 페이지에 비로그인 상태로 접근하면 자동으로 로그인 페이지로 리다이렉팅 시키면서 returnUrl 파라미터에 원래 가려던 주소를 담는 특징이 있었다.
기존 로직은 로그인 여부와 관계없이 path가 있으면 해당 경로로 바로 이동시켰기 때문에 이 returnUrl 메커니즘과 충돌하며 의도치 않은 페이지 이동을 생겼다.
애초에 로그인에 대한 고려 없이 path에 해당하는 건 URL의 path로 붙여서 보냈는데, 생각해 보면 어차피 쿼리 문자열로 보내고 로그인 처리 완료 후에 다시 path에 붙여서 리다이렉팅하기 때문에 로그인이 필요 없는 메인 페이지에 쿼리 문자열만 붙여서 보내면 될 일이었다.
// 기존 코드
const joiner = decodedPath && decodedPath.includes('?') ? '&' : '?';
if (mallConnectType && lang) {
let redirectUrl = `${MALL_BASE_URL}${decodedPath || ''}${joiner}${newQueryParams}`;
if (decodedPath) {
const encodedPath = encodeURIComponent(decodedPath);
redirectUrl += `&path=${encodedPath}`;
}
window.location.replace(redirectUrl);
}
// 수정 코드
const joiner = '?';
if (mallConnectType && lang) {
let redirectUrl = `${MALL_BASE_URL}${joiner}${newQueryParams}`;
if (decodedPath) {
const encodedPath = encodeURIComponent(decodedPath);
redirectUrl += `&path=${encodedPath}`;
}
window.location.replace(redirectUrl);
}
추가로 SSO 로그인을 트리거하기 위해 MemberAction.snsLogin('sso', '%2F'); 카페24 내장 함수를 사용하고 있는데, 이미 로그인이 되어 있는 상태에서 이 함수를 호출하게 되면 참조 에러가 뜨면서 그 하단의 스크립트가 실행되지 않는 이슈도 확인됐다. 카페24에서 제공하는 로그인을 확인할 만한 API가 있기는 하지만, 페이지 내에서 로그인 시점을 확인할 만한 기점을 잡기는 애매해서 try-catch로 감싸서 단순 참조 에러가 나더라도 하단의 스크립트가 정상적으로 동작하도록 수정했다.
// 기존 코드
if(mallConnectType && mallConnectType === 'app') {
MemberAction.snsLogin('sso', '%2F');
}
// 수정 코드
// 이미 로그인된 경우 MemberAction reference error 발생, try-catch로 감싸서 에러 방지
if(mallConnectType && mallConnectType === 'app') {
MemberAction.snsLogin('sso', '%2F');
try {
console.log('SSO 로그인 시도');
MemberAction.snsLogin('sso', '%2F');
} catch (error) {
console.log('MemberAction 시도 에러(이미 로그인된 상태로 추정). 다음 로직을 계속 실행', error);
}
}
그리고 딥링크 구성 시에 운영 쪽과 협의를 통해 lang을 붙이기로 했다 하더라도 lang이 누락된 케이스도 생겼다. 그런 경우 기존 코드상 영문몰로의 이동이 기본으로 잡혀 있기 때문에 국문몰과 영문몰을 별개로 처리하는 카페24 구성상 국문몰에서 로그인이 되었다 하더라도 영문몰로 이동하는 경우 추가적인 로그인 처리가 필요해진다. 자사 웹사이트에서의 리다이렉팅과 다르게 자사몰에서는 기존과 동일한 도메인으로 보내면 되기 때문에 window.location.origin을 가져와 lang이 없는 경우에 대한 예외 처리를 추가하였다.
// 기존 코드
const MALL_BASE_URL = lang === 'ko' ? 'https://mall.shop/' : 'https://en.mall.shop/';
// 수정 코드
let MALL_BASE_URL;
if(lang === 'ko') {
MALL_BASE_URL = 'https://mall.shop/';
} else if(lang === 'en') {
MALL_BASE_URL = 'https://en.mall.shop/';
} else {
MALL_BASE_URL = window.location.origin + '/';
}
사소한 코드라도 더 방어적으로 작성할 필요가 있었다. 단순히 쿼리 문자열을 받아오는 코드라도 여러 경우의 수가 있고 그 경우를 세심하게 고려해서 설계하는 것이 이후에 스스로 코드를 파악함에 있어서도 도움이 될 일이라는 생각이 들었다.
기본기는 역시 중요하다. URL 이런 모양으로 생겼지, 아 그래 뭐 URL도 인코딩해서 보내기도 하지,에서 끝나는 게 아니라 URL은 ASCII 문자열과 예약어로만 전송될 수 있고 어떤 이슈가 있는지 미리 인지하고 고려했다면 아마도 발생하지 않았을 이슈였다.
URL 구조에 대한 명확한 이해, 여기서 명확한 이해라는 건 단순히 도메인, 경로, 쿼리 문자열 수준이 아니라 RFC 3986을 한 번 정도는 훑어보고 어떤 예약어가 있는지 이해하는 것이 중요하다는 생각이 들었다. 예약 문자는 그 자체로 문법적인 의미가 있어서 인코딩이 필요하다. 애초에 인코딩에 대해 고려하려면 왜 하는지에 대한 이해가 필요한데, 바로 저 지점이 명확한 이해가 필요한 지점이라는 생각이 들었다. 구글이 공식 문서를 통해 URL 인코딩에 대해 설명한 페이지도 있어서 참고했다.
외부 서비스에 대한 이해도 필요하다. 외부 서비스의 내장 함수를 사용하고자 한다면 그 함수가 어떤 동작을 하는지, 어떤 상황에서 참조 에러가 날 수 있는지, 콘솔에 별도의 에러를 뱉고 있진 않은지 등 연동하는 서비스의 고유한 동작 방식을 충분히 파악하고 적용하는 태도가 중요하다.
이런 흐름으로 가다 보면 왜 URL에는 ASCII 문자열과 예약어만 사용하지?, 왜 인코딩은 퍼센트 인코딩을 사용하게 됐지?, URL을 주고받고 이동할 때 어떤 게 가장 효율적인 걸까?, 이런 질문들을 품게 되는데, 참 배울 게 많다 싶으시면서도 호기심을 자극하니 흥미롭기도 하고 그렇다. 관련된 내용은 다음에 한 번 다뤄보려고 한다.