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. 참고 자료


시리즈 목차

  1. JavaScript URL 비교와 정규화 ← 현재 글
  2. Web Crypto API로 안전한 해싱 구현하기
  3. CSS 변수와 다크 모드 구현하기
  4. 크롬 확장 프로젝트 구조 정리하기