1. "사용자들이 결제를 안 해요"
쇼핑몰을 런칭하고 첫 주, 데이터를 보는데 정말 이상한 현상이 있었습니다. "장바구니 담기" → "결제하기" 버튼 클릭률은 꽤 높은데, 실제 최종 결제 완료율(Conversion Rate)이 너무 낮았습니다. 이탈률이 거의 70%에 육박했죠. "아니, 결제하겠다고 마음먹고 버튼까지 누른 사람들이 왜 다 도망가는 거지?"
너무 답답해서 Hotjar(사용자 화면 녹화 도구)를 설치해서 사용자들의 행동을 훔쳐봤습니다. 그리고 저는 제 모니터를 부술 뻔했습니다.
- 사용자가 상품 페이지에서 [구매하기] 버튼을 클릭합니다.
- 로그인이 안 되어 있어서 [로그인 페이지]로 이동합니다. (여기까진 정상)
- 사용자가 아이디/비번을 입력하고 로그인 완료.
- 시스템이 사용자를 [메인 페이지(홈)]로 이동시킵니다. (?)
- 사용자: "어? 나 뭐 사려고 했지? 다시 찾아야 하나? 아 귀찮아."
- 이탈 (창 닫기)
세상에, 로그인 성공 후 원래 보려던 페이지(결제 페이지)로 보내주지 않고, 바보같이 무조건 홈(/)으로 보내버리고 있었던 겁니다.
이 사소한 디테일 하나가 매출의 30%를 날려먹고 있었습니다. 이건 기술적 버그가 아니라 UX의 재앙이었습니다.
2. 해결책 - "너 어디서 왔니?" (Query String의 마법)
이 문제를 해결하려면 로그인 페이지가 "사용자가 어디서 왔는지"를 기억해야 합니다. State 관리 라이브러리(Redux, Zustand)를 쓸 수도 있지만, 가장 확실하고 쉬운 방법은 URL을 이용하는 겁니다.
보통 redirect, callbackUrl, returnTo, next 같은 이름을 쿼리 스트링(Query String)으로 사용합니다.
1단계 - 로그인 페이지로 쫓아낼 때 '쪽지' 붙이기
사용자가 /checkout 페이지에 접근하려는데 로그인이 안 되어 있다면?
그냥 /login으로 쫓아내지 말고, 등 뒤에 "/checkout 에서 왔음"이라는 쪽지를 붙여서 보냅니다.
/* Next.js Middleware 또는 Protected Route 처리 */
export function middleware(request: NextRequest) {
const currentUser = request.cookies.get('currentUser');
// 1. 로그인이 안 되어 있고, 보호된 페이지에 접근하려 할 때
if (!currentUser && request.nextUrl.pathname.startsWith('/checkout')) {
const loginUrl = new URL('/login', request.url);
// 2. 현재 페이지 경로를 'redirect' 파라미터로 붙임
// 예: /login?redirect=/checkout
loginUrl.searchParams.set('redirect', request.nextUrl.pathname);
return Response.redirect(loginUrl);
}
}
이제 주소창은 https://myshop.com/login?redirect=/checkout 이렇게 바뀝니다.
2단계 - 로그인 성공 후 '쪽지' 확인하기
이제 로그인 페이지(Login Component)는 단순히 홈으로 이동(router.push('/'))하면 안 됩니다.
URL에 있는 redirect 값을 읽어서, 그곳으로 보내줘야 합니다.
/* Login Page Component */
import { useRouter, useSearchParams } from 'next/navigation';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
// 3. URL에서 'redirect' 값을 읽음. 없으면 기본값 홈('/')
const redirectUrl = searchParams.get('redirect') || '/';
const handleLogin = async (e) => {
e.preventDefault();
const result = await loginApi(email, password);
if (result.success) {
// 4. 원래 가려던 곳으로 이동!
router.push(redirectUrl);
}
};
// ...
}
이 간단한 코드 10줄을 배포하자마자, 결제 페이지 도달률이 2배로 뛰었습니다. 사용자의 흐름(Flow)을 끊지 않는 것이 비즈니스에 얼마나 큰 영향을 주는지 뼈저리게 느꼈습니다.
3. 근데 해커가 이걸 악용한다면? (보안 경고)
기분 좋게 "매출 올렸다!" 하고 퇴근하려는데, 보안팀 친구가 제 어깨를 잡았습니다. "야, 이거 Open Redirect 취약점 있는데? 너 큰일 나."
"오픈 리다이렉트? 그게 뭔데?"
해커가 이런 피싱 링크를 만들어서 사용자들에게 메일을 뿌린다고 상상해 보세요.
**이벤트 당첨! 로그인하고 확인하세요: **
https://my-shop.com/login?redirect=https://hacker-site.com
- 사용자는 앞부분의
my-shop.com도메인만 보고 "아, 공식 사이트네" 하고 안심합니다. - 링크를 클릭해서 로그인합니다.
- 로그인 성공! 제 코드는
redirect파라미터에 적힌https://hacker-site.com으로 사용자를 보냅니다. - 해커 사이트는 네이버나 구글 로그인 화면처럼 꾸며져 있습니다.
- 사용자는 "어? 로그인이 풀렸나?" 하고 다시 비번을 입력합니다.
- 계정 탈취 완료.
소름 돋지 않나요? 제 친절한 리다이렉트 기능이 해커의 하이패스가 될 수 있습니다. 실제로 구글, 페이스북 같은 대기업들도 초기에 이 취약점으로 곤욕을 치렀습니다.
4. 철통 방어 - "우리 집 식구만 들어와"
해결책은 간단합니다. "도메인 검증(Validation)"을 하면 됩니다. 리다이렉트 할 주소가 우리 사이트 내부(Internal Path)인지 확인하고, 외부 도메인이면 무조건 홈으로 보내버리는 겁니다.
안전한 리다이렉트 함수 만들기
const safeRedirect = (path: string) => {
// 1. 방어: "http"나 "https"로 시작하면 무조건 차단 (외부 도메인일 확률 99%)
if (path.startsWith('http:') || path.startsWith('https:')) {
console.warn(`[Security] 외부 리다이렉트 시도 차단: ${path}`);
return '/';
}
// 2. 방어: "/"로 시작하지 않으면 차단 (상대 경로 공격 방지)
if (!path.startsWith('/')) {
return '/';
}
// 3. 방어: "//"로 시작하는 경우도 차단 (Protocol Relative URL 공격)
// 예: //google.com 은 브라우저에서 http://google.com 으로 해석됨
if (path.startsWith('//')) {
return '/';
}
// 4. 방어: 제어문자(CRLF) 등이 포함된 경우 차단
if (/[\\x00-\\x1F\\x7F]/.test(path)) {
return '/';
}
return path; // 통과! 안심하고 보내도 됨.
};
// 사용 방법
router.push(safeRedirect(redirectUrl));
이제 해커가 ?redirect=https://hacker.com을 넣어도, 제 코드는 "어? 외부 주소네?" 하고 강제로 홈(/)으로 보내버립니다.
사용자는 안전하게 보호됩니다.
화이트리스트 (Whitelist) 뜯어보기
만약 우리 회사가 여러 도메인(shop.com, blog.shop.com)을 써서 외부 리다이렉트를 허용해야 한다면요?
그럴 땐 화이트리스트를 씁니다.
const ALLOWED_DOMAINS = ['myshop.com', 'blog.myshop.com'];
const isSafeDomain = (url) => {
try {
const parsedUrl = new URL(url);
return ALLOWED_DOMAINS.includes(parsedUrl.hostname);
} catch (e) {
return false; // URL 파싱 실패하면 무조건 차단
}
};
보안은 "막는 것"이 아니라 "허용할 것만 허용하는 것"입니다.
5. UX 디테일 - 사용자를 배려하는 몇 가지 팁
기능 구현은 끝났지만, UX를 좀 더 다듬어 볼까요?
1) POST 요청은 어떡하죠?
사용자가 '장바구니 담기(POST)'를 눌렀는데 로그인이 풀려있다면?
로그인하고 돌아오면 장바구니에 상품이 담겨있을까요? 아니요, 보통은 그냥 페이지 덩그러니 있겠죠.
이럴 땐, 장바구니 정보를 localStorage나 쿠키에 임시 저장했다가, 로그인 후 리다이렉트 되었을 때 복구해 주는 로직이 필요합니다. (이건 좀 복잡하니 다음 글에서 다루겠습니다.)
2) 너무 긴 URL 숨기기
?redirect= 뒤에 엄청 긴 URL이 붙으면 보기 싫을 수 있습니다.
이럴 땐 base64로 인코딩해서 넘기거나, 세션 스토리지에 저장하는 방법도 있습니다.
하지만 심플한 게 최고입니다. 저는 그냥 쿼리 스트링을 선호합니다.
3) 리다이렉트 루프(Loop) 방지
만약 login 페이지의 redirect 값이 다시 login 페이지라면?
사용자는 영원히 로그인 페이지를 맴돌게 됩니다.
if (path === '/login') return '/'; 같은 방어 코드를 한 줄 넣어주세요.
6. 마무리 - UX와 보안은 한 끝 차이
로그인 리다이렉트는 쇼핑몰, 커뮤니티, SaaS, 어드민 사이트 어디서든 필수 기능입니다. 하지만 잘못 구현하면 매출을 깎아먹거나(UX), 보안 구멍(Security)을 뚫어버릴 수 있는 양날의 검입니다.
오늘의 3줄 요약:
- 로그인 후엔 꼭 원래 있던 곳으로 돌려보내 주세요. (사용자가 감동합니다.)
- 쿼리 스트링(
?redirect=...)을 쓰면 구현이 쉽습니다. - 단, 필터링 없이 아무 데나 보내주면 해커 맛집이 됩니다. (
safeRedirect함수 필수!)
여러분의 로그인 페이지는 친절한가요? 아니면 사용자를 미아로 만들고 있나요?
지금 바로 F12를 눌러 확인해 보세요.