JavaScript URL 비교와 정규화: 같은 페이지인데 다른 URL?
같은 페이지인데 URL 비교가 false? Fragment, trailing slash, query parameter 순서 차이로 인한 문제와 URL 정규화 해결책을 코드 예시와 함께 설명합니다.
1. 문제 상황
증상: URL 비교가 간헐적으로 실패
두 URL이 같은 페이지를 가리키는지 비교하는 기능을 구현했습니다. 로직은 간단했습니다:
// 단순 문자열 비교
if (url1 === url2) {
console.log('같은 페이지입니다');
}
문제: 분명히 같은 페이지인데 비교가 false를 반환하는 경우가 발생했습니다.
실패 케이스들
테스트 중 발견한 문제 상황:
// 케이스 1: Fragment(해시) 차이
'https://example.com/page'
'https://example.com/page#section'
// → 다른 URL로 판단되지만, 실제로는 같은 문서
// 케이스 2: Trailing Slash 차이
'https://example.com/page/'
'https://example.com/page'
// → 대부분의 서버에서 같은 리소스
// 케이스 3: Query Parameter 순서 차이
'https://example.com?b=2&a=1'
'https://example.com?a=1&b=2'
// → 논리적으로 동일한 요청
// 케이스 4: 프로토콜 차이 (일부 상황)
'http://example.com/page'
'https://example.com/page'
// → 같은 콘텐츠를 가리킬 수 있음
영향
- URL 비교 로직 신뢰도 저하
- 캐시 키 중복 저장
- 방문 기록 중복 등록
- 링크 중복 제거 실패
2. 원인 분석
URL의 구조 이해하기
URL은 여러 컴포넌트로 구성되어 있습니다:
https://user:[email protected]:8080/path/page?query=1&sort=asc#section
└─┬─┘ └───┬───┘ └────┬────┘└─┬─┘└───┬────┘└──────┬──────┘└──┬───┘
protocol auth hostname port pathname search hash
└────────┬────────┘
origin
JavaScript의 URL 객체로 각 부분에 접근할 수 있습니다:
const u = new URL('https://example.com:8080/page?sort=asc#intro');
u.protocol // "https:"
u.hostname // "example.com"
u.port // "8080"
u.pathname // "/page"
u.search // "?sort=asc"
u.hash // "#intro"
u.origin // "https://example.com:8080"
u.href // 전체 URL
왜 같은 페이지인데 URL이 다를까?
1. Fragment(Hash)의 특성
// Fragment는 서버로 전송되지 않음!
// 아래 세 URL은 서버 관점에서 완전히 동일
"https://example.com/page"
"https://example.com/page#section1"
"https://example.com/page#section2"
Fragment는 클라이언트 측 앵커 역할만 하므로, 같은 문서를 가리킵니다. 하지만 문자열 비교에서는 다른 URL로 판단됩니다.
2. Trailing Slash의 모호함
// 많은 웹서버가 이 둘을 동일하게 처리
"https://example.com/page/" // trailing slash 있음
"https://example.com/page" // trailing slash 없음
// 일부 서버는 리다이렉트하기도 함
// → 일반적으로는 같은 리소스로 간주하는 것이 안전
3. Query Parameter 순서
// URL 스펙상 query parameter 순서는 의미가 없음
"https://example.com?a=1&b=2"
"https://example.com?b=2&a=1"
// 그러나 문자열로 비교하면 다름
"?a=1&b=2" !== "?b=2&a=1" // true (다르다고 판단!)
3. 해결 방법: URL 정규화
핵심 아이디어
비교 전에 양쪽 URL을 동일한 형태로 정규화한 후 비교합니다.
기본 정규화 함수
function normalizeUrl(url) {
try {
const u = new URL(url);
// 1. Fragment(해시) 제거 - 같은 문서를 가리킴
u.hash = '';
// 2. Trailing slash 정규화 (루트 경로 제외)
if (u.pathname.length > 1 && u.pathname.endsWith('/')) {
u.pathname = u.pathname.slice(0, -1);
}
// 3. Query parameter 정렬 - 순서 무관하게 일관된 형태로
const params = new URLSearchParams(u.search);
const sortedParams = new URLSearchParams(
[...params.entries()].sort()
);
u.search = sortedParams.toString();
return u.href;
} catch (e) {
// 유효하지 않은 URL은 원본 반환
return url;
}
}
안전한 URL 비교 함수
function urlsMatch(url1, url2) {
// null/undefined 체크
if (!url1 || !url2) return false;
// 정규화 후 비교
return normalizeUrl(url1) === normalizeUrl(url2);
}
정규화 동작 예시
// 테스트
normalizeUrl("https://example.com/page#section")
// → "https://example.com/page"
normalizeUrl("https://example.com/page/")
// → "https://example.com/page"
normalizeUrl("https://example.com?b=2&a=1")
// → "https://example.com/?a=1&b=2"
normalizeUrl("https://example.com/page?z=3&a=1#hash")
// → "https://example.com/page?a=1&z=3"
// 비교 결과
urlsMatch(
"https://example.com/page#intro",
"https://example.com/page/"
)
// → true (같은 페이지로 인식)
4. 고급 정규화 옵션
실무에서는 추가적인 정규화가 필요할 수 있습니다.
호스트명 정규화 (www 제거)
function normalizeUrl(url, options = {}) {
try {
const u = new URL(url);
// www 제거 (선택적)
if (options.removeWww && u.hostname.startsWith('www.')) {
u.hostname = u.hostname.slice(4);
}
u.hash = '';
if (u.pathname.length > 1 && u.pathname.endsWith('/')) {
u.pathname = u.pathname.slice(0, -1);
}
const params = new URLSearchParams(u.search);
const sortedParams = new URLSearchParams([...params.entries()].sort());
u.search = sortedParams.toString();
return u.href;
} catch (e) {
return url;
}
}
// 사용
normalizeUrl("https://www.example.com/page", { removeWww: true })
// → "https://example.com/page"
추적 파라미터 제거
마케팅/추적용 파라미터를 제거하면 더 정확한 비교가 가능합니다:
const TRACKING_PARAMS = [
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'fbclid', 'gclid', 'ref', 'source', 'mc_eid'
];
function normalizeUrl(url, options = {}) {
try {
const u = new URL(url);
u.hash = '';
if (u.pathname.length > 1 && u.pathname.endsWith('/')) {
u.pathname = u.pathname.slice(0, -1);
}
const params = new URLSearchParams(u.search);
// 추적 파라미터 제거 (선택적)
if (options.removeTracking) {
TRACKING_PARAMS.forEach(param => params.delete(param));
}
const sortedParams = new URLSearchParams([...params.entries()].sort());
u.search = sortedParams.toString();
return u.href;
} catch (e) {
return url;
}
}
// 사용
normalizeUrl(
"https://example.com/page?id=123&utm_source=google&utm_medium=cpc",
{ removeTracking: true }
)
// → "https://example.com/page?id=123"
경로 디코딩 정규화
URL 인코딩 차이를 해결합니다:
function normalizeUrl(url) {
try {
const u = new URL(url);
u.hash = '';
// 경로 디코딩 후 재인코딩 (일관된 인코딩)
u.pathname = u.pathname
.split('/')
.map(segment => encodeURIComponent(decodeURIComponent(segment)))
.join('/');
if (u.pathname.length > 1 && u.pathname.endsWith('/')) {
u.pathname = u.pathname.slice(0, -1);
}
const params = new URLSearchParams(u.search);
const sortedParams = new URLSearchParams([...params.entries()].sort());
u.search = sortedParams.toString();
return u.href;
} catch (e) {
return url;
}
}
// "%20"과 "+" 차이 해결
normalizeUrl("https://example.com/hello%20world")
// → "https://example.com/hello%20world"
완전한 정규화 함수
const TRACKING_PARAMS = [
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'fbclid', 'gclid', 'ref', 'source'
];
function normalizeUrl(url, options = {}) {
const {
removeHash = true,
removeTrailingSlash = true,
sortParams = true,
removeTracking = false,
removeWww = false,
lowercasePath = false
} = options;
try {
const u = new URL(url);
// Hash 제거
if (removeHash) {
u.hash = '';
}
// www 제거
if (removeWww && u.hostname.startsWith('www.')) {
u.hostname = u.hostname.slice(4);
}
// 경로 정규화
if (lowercasePath) {
u.pathname = u.pathname.toLowerCase();
}
// Trailing slash 제거
if (removeTrailingSlash && u.pathname.length > 1 && u.pathname.endsWith('/')) {
u.pathname = u.pathname.slice(0, -1);
}
// Query 파라미터 처리
const params = new URLSearchParams(u.search);
if (removeTracking) {
TRACKING_PARAMS.forEach(p => params.delete(p));
}
if (sortParams) {
const sorted = new URLSearchParams([...params.entries()].sort());
u.search = sorted.toString();
}
return u.href;
} catch (e) {
return url;
}
}
// 유틸리티 함수
function urlsMatch(url1, url2, options = {}) {
if (!url1 || !url2) return false;
return normalizeUrl(url1, options) === normalizeUrl(url2, options);
}
5. 활용 예시
캐시 키 생성
const cache = new Map();
function getCachedData(url) {
const normalizedKey = normalizeUrl(url);
return cache.get(normalizedKey);
}
function setCachedData(url, data) {
const normalizedKey = normalizeUrl(url);
cache.set(normalizedKey, data);
}
// 같은 캐시 히트
setCachedData('https://example.com/api?b=2&a=1', { data: 'response' });
getCachedData('https://example.com/api?a=1&b=2'); // { data: 'response' }
중복 링크 제거
function deduplicateUrls(urls) {
const seen = new Set();
return urls.filter(url => {
const normalized = normalizeUrl(url);
if (seen.has(normalized)) {
return false;
}
seen.add(normalized);
return true;
});
}
// 사용
const links = [
'https://example.com/page',
'https://example.com/page#section',
'https://example.com/page/',
'https://example.com/other'
];
deduplicateUrls(links);
// → ['https://example.com/page', 'https://example.com/other']
방문 기록 비교
function hasVisited(currentUrl, history) {
return history.some(visitedUrl =>
urlsMatch(currentUrl, visitedUrl)
);
}
const visitHistory = [
'https://example.com/page',
'https://example.com/about'
];
hasVisited('https://example.com/page#contact', visitHistory); // true
hasVisited('https://example.com/blog', visitHistory); // false
6. 핵심 개념 정리
URL 정규화 체크리스트
| 항목 | 처리 방법 | 필수 여부 |
|---|---|---|
| Fragment(#) | 제거 | 필수 |
| Trailing slash | 제거 (루트 제외) | 필수 |
| Query 순서 | 알파벳 정렬 | 필수 |
| www 접두사 | 상황에 따라 제거 | 선택 |
| 추적 파라미터 | 상황에 따라 제거 | 선택 |
| 경로 대소문자 | 상황에 따라 소문자화 | 선택 |
언제 URL 정규화가 필요한가?
| 상황 | 정규화 필요 | 이유 |
|---|---|---|
| 방문 기록 비교 | O | Fragment, slash 차이 |
| 캐시 키 생성 | O | 동일 리소스 중복 방지 |
| 중복 링크 제거 | O | 같은 페이지 여러 형태 |
| API 엔드포인트 비교 | 상황에 따라 | Query 순서가 의미 있을 수도 |
| 보안 검증 (CORS 등) | X | 정확한 비교 필요 |
7. 베스트 프랙티스
DO (해야 할 것)
URL객체로 파싱하여 컴포넌트별 접근- 유효하지 않은 URL 예외 처리 (
try-catch) - null/undefined 체크 먼저 수행
- 정규화 수준을 use case에 맞게 조정
DON'T (하지 말아야 할 것)
// 나쁜 패턴 1: 직접 문자열 비교
if (url1 === url2) { }
// 나쁜 패턴 2: includes로 부분 매칭
if (url1.includes(url2)) { }
// 나쁜 패턴 3: 정규식으로 무리한 파싱
if (/example\.com\/page/.test(url)) { }
// 좋은 패턴: URL 객체 + 정규화
if (urlsMatch(url1, url2)) { }
테스트 케이스 예시
describe('urlsMatch', () => {
test('fragment 차이 무시', () => {
expect(urlsMatch(
'https://example.com/page',
'https://example.com/page#section'
)).toBe(true);
});
test('trailing slash 차이 무시', () => {
expect(urlsMatch(
'https://example.com/page/',
'https://example.com/page'
)).toBe(true);
});
test('query 순서 차이 무시', () => {
expect(urlsMatch(
'https://example.com?a=1&b=2',
'https://example.com?b=2&a=1'
)).toBe(true);
});
test('루트 경로 trailing slash 유지', () => {
expect(urlsMatch(
'https://example.com/',
'https://example.com/'
)).toBe(true);
});
test('다른 페이지는 false', () => {
expect(urlsMatch(
'https://example.com/page1',
'https://example.com/page2'
)).toBe(false);
});
test('null 처리', () => {
expect(urlsMatch(null, 'https://example.com')).toBe(false);
expect(urlsMatch('https://example.com', null)).toBe(false);
});
});
8. FAQ
Q: URL 정규화를 하면 성능에 영향이 있나요?
A: URL 객체 생성과 문자열 처리 비용이 있지만, 대부분의 경우 무시할 수 있는 수준입니다. 필요하다면 메모이제이션을 적용할 수 있습니다:
const normalizeCache = new Map();
function normalizeUrl(url) {
if (normalizeCache.has(url)) {
return normalizeCache.get(url);
}
// ... 정규화 로직
normalizeCache.set(url, result);
return result;
}
Q: Query parameter 순서가 의미 있는 API도 있지 않나요?
A: 맞습니다. 일부 API는 파라미터 순서에 의미를 부여하기도 합니다. 이런 경우에는 query 정렬을 생략해야 합니다:
normalizeUrl(url, { sortParams: false });
Q: 국제화 도메인(IDN)은 어떻게 처리하나요?
A: URL 객체가 자동으로 Punycode 변환을 처리합니다:
const u = new URL('https://한글.com');
console.log(u.hostname); // "xn--bj0bj06e.com"
Q: 대소문자 구분은 어떻게 하나요?
A: URL의 hostname은 대소문자를 구분하지 않지만, pathname은 서버에 따라 다릅니다. URL 객체는 hostname을 자동으로 소문자화합니다:
// hostname은 자동 소문자화
new URL('https://EXAMPLE.COM').hostname // "example.com"
// pathname은 유지됨
new URL('https://example.com/Page').pathname // "/Page"
필요하다면 lowercasePath: true 옵션으로 경로도 소문자화할 수 있습니다.
9. 참고 자료
시리즈 목차
- JavaScript URL 비교와 정규화 ← 현재 글
- Web Crypto API로 안전한 해싱 구현하기
- CSS 변수와 다크 모드 구현하기
- 크롬 확장 프로젝트 구조 정리하기