DEV Community

jusoup
jusoup

Posted on

비슷한 URL이라고 해서 모두 중복된 북마크는 아닙니다

비슷한 저장 링크의 비교 기준

저장된 링크 두 개가 비슷해 보인다는 이유만으로 하나를 삭제하는 것은 적절하지 않다. 먼저 주소 문자열의 동일 여부, 프래그먼트 차이, 그 밖의 차이를 분리한 비교 보고서가 필요하다. 삭제나 병합은 비교 결과와 별개의 관리 판단이다.

프로젝트 참고 목록을 떠올려 보자. 두 항목의 이름이 모두 “설치 가이드”라도 실제 주소가 설정 부분과 제한 사항 부분을 각각 가리킬 수 있다. 버전 매개변수 하나만 다른 주소도 같은 범주로 단정하기 어렵다. 각 항목에 저장 이유와 메모가 있다는 전제라면, 목표는 가장 짧은 목록이 아니라 의미 있는 비교 후보의 선별이다.

비교 결과의 범위

세 가지 결과만 사용하면 판단의 과장이 줄어든다.

결과 의미 검토 방향
same-serialized-url 두 입력의 완전한 URL 직렬화가 동일한 상태 저장 메모리 비교 후 병합 여부 검토
fragment-variants 직렬화는 다르지만 프래그먼트 제거 후 동일한 상태 각 항목의 목적지와 섹션 확인
different-url-strings 프래그먼트만 제외한 비교에서도 차이가 남는 상태 추가 근거 전까지 별도 유지

어떤 결과도 “동일 콘텐츠”라는 의미는 아니다. 이 방식은 웹페이지 자체를 열지 않으며, 리디렉션이나 실제 화면도 확인하지 않는다. 특히 duplicate라는 표현보다 위 세 가지 명칭이 적합하다. 현재 코드가 확인하는 범위보다 강한 결론을 암시하지 않기 때문이다.

첫 번째 비교는 WHATWG URL Standard의 URL 직렬화 개념을 기준으로 한다. 두 번째 비교는 이 사례에서 정한 검토 규칙이다. 프래그먼트 제거는 저장된 북마크에서 해당 부분을 지우라는 지시가 아니라, 비교용 복사본에만 적용되는 임시 절차다.

원본과 비교용 객체

파일 이름은 review-link-pairs.mjs로 정한다. Node.js의 표준 URL 인터페이스를 이용하고, 입력 문자열과 완전한 직렬화 결과를 함께 보존한다.

function parseReference(value) {
  if (typeof value !== "string" || !/^https?:\/\//i.test(value)) {
    throw new TypeError("Expected an absolute HTTP(S) URL string.");
  }

  let url;
  try {
    url = new URL(value);
  } catch {
    throw new TypeError("Invalid URL.");
  }
  if (url.username || url.password) {
    throw new TypeError("URLs with credentials are outside this review.");
  }
  return url;
}

function reviewPair(left, right) {
  const a = parseReference(left);
  const b = parseReference(right);
  const serialized = [a.href, b.href];
  let decision;

  if (serialized[0] === serialized[1]) {
    decision = "same-serialized-url";
  } else {
    a.hash = "";
    b.hash = "";
    decision = a.href === b.href
      ? "fragment-variants"
      : "different-url-strings";
  }

  return { decision, originals: [left, right], serialized };
}
Enter fullscreen mode Exit fullscreen mode

변경 대상은 새로 생성된 URL 객체뿐이다. originals에는 처음 입력된 문자열이 남고, serialized에는 프래그먼트 비교 이전의 완전한 주소가 남는다. 따라서 원본 기록과 비교용 상태의 분리가 가능하다.

입력 범위도 명확하다. 문자열이 아닌 값, 명시적인 HTTP 또는 HTTPS 접두사가 없는 값, 파싱 실패 주소, 사용자 이름이나 비밀번호가 포함된 주소는 제외한다. 이는 이 예제의 범위 설정이며, 허용된 주소의 안전성을 보증하는 검사는 아니다.

테스트 기준

같은 파일에 테스트 블록을 추가하면 비교 정책의 경계가 선명해진다.

import assert from "node:assert/strict";

const root = "https://docs.invalid";
const cases = [
  ["https://DOCS.invalid:443/guide", `${root}/guide`,
    "same-serialized-url"],
  [`${root}/guide#setup`, `${root}/guide#limits`,
    "fragment-variants"],
  [`${root}/guide?v=1`, `${root}/guide?v=2`,
    "different-url-strings"],
  [`${root}/guide?a=1&b=2`, `${root}/guide?b=2&a=1`,
    "different-url-strings"],
  [`${root}/guide?x=1&x=2`, `${root}/guide?x=2&x=1`,
    "different-url-strings"],
  [`${root}/guide`, `${root}/guide/`,
    "different-url-strings"]
];

for (const [left, right, expected] of cases) {
  const result = reviewPair(left, right);
  assert.equal(result.decision, expected);
  assert.deepEqual(result.originals, [left, right]);
}
assert.throws(() => reviewPair("/guide", root), TypeError);
assert.throws(() => reviewPair(null, root), TypeError);
console.log("6 pair cases and 2 invalid-input cases passed.");
Enter fullscreen mode Exit fullscreen mode

첫 번째 사례는 호스트 대소문자와 HTTPS 기본 포트에 관한 URL 파서의 직렬화 결과를 보여준다. 나머지 사례는 이 검토 정책에서 유지하는 차이의 범위를 보여준다. 쿼리 매개변수의 순서, 반복 키의 순서, 마지막 슬래시는 임의로 정리하지 않는다.

쿼리 정규화는 첫 단계에서 제외하는 편이 좋다. URLSearchParams를 수정하면 쿼리의 직렬화나 인코딩 형태가 달라질 수 있다. 따라서 특정 변환이 필요하다면 별도의 규칙, 근거, 테스트가 필요하다.

메모와 저장 목적

fragment-variants 결과의 경우 각 저장 메모리가 핵심이다. 하나가 “초기 설정”이고 다른 하나가 “배포 전 알려진 제한 사항”이라면 두 항목은 서로 다른 활용 목적을 가진다. 주소가 비슷하다는 이유만으로 하나를 제거할 근거는 부족하다.

same-serialized-url 역시 즉시 병합 대상은 아니다. 한 기록에는 문제 해결 과정이나 특정 관찰이 남아 있을 수 있다. 레코드로 통합하더라도 두 기록의 유용한 메모리가 모두 남는지 먼저 확인해야 한다.

예를 들어 주소타임 링크모음 같은 저장 참고 자료도 표시 이름만으로 동일성을 판단하지 않는 편이 좋다. 실제 주소와 목적지, 저장 이유를 확인해야 한다. 이 링크 자체는 테스트 자료가 아니며, 다른 주소와의 동등성을 보여주는 근거도 아니다. 현재 콘텐츠와 관련성을 직접 확인한 뒤 목록 유지 여부를 결정한다.

병합 후보마다 원래 레코드의 식별자, 비교 결과, 최종 판단, 보존한 메모리를 기록하면 이후 검토 과정도 명확해진다. 판단이 끝나지 않은 경우에는 unresolved와 같은 상태도 남겨둘 수 있다. 조사 부족을 삭제 승인으로 바꾸지 않는 것이 핵심이다.

방법의 한계

이 방식은 문자열 비교 도구이지 콘텐츠 비교기, 접속 가능성 검사기, 보안 분석기가 아니다. 리디렉션, 소유권, 접근 권한, 브라우저 화면, 실제 페이지의 동일성은 범위 밖이다.

프래그먼트의 의미 역시 해석하지 않는다. #/setup과 #/limits도 이 규칙에서는 단순한 프래그먼트 변형으로 분류된다. 두 위치가 실제로 같은 내용을 가리킨다는 결론은 없다.

완전한 직렬화 결과가 입력 문자열과 문자 단위로 동일하다는 보장도 필요하지 않다. 그래서 원본 입력을 별도로 보존한다. 비교 결과만 남기고 원래 기록을 덮어쓰는 방식은 피한다.

자동화 전 확인

“직렬화가 같으면 북마크 하나를 삭제해도 되는가?”

아니다. 같은 주소라는 사실은 해당 파서의 비교 조건만 충족했다는 의미다. 폴더 목적, 저장 메모, 개인적인 참고 이유, 다른 메타데이터를 확인한 뒤 관리 결정을 내려야 한다.

“추적용처럼 보이는 매개변수는 무시해도 되는가?”

이름만으로 판단하지 않는다. 서비스의 구조와 사용 목적에 관한 근거가 있다면 매개변수에 한정한 변환 규칙을 별도로 만들 수 있다. 그 경우에도 원래 주소는 유지하고, 변환 전후의 차이를 테스트에 포함한다.

“파싱 실패는 어떻게 처리하는가?”

원본 기록은 그대로 두고 비교 불가 상태로 표시한다. 여러 레코드를 처리한다면 개별 레코드 단위의 오류 처리로 나머지 비교를 계속할 수 있다. 잘못된 주소 하나가 저장 메모리의 삭제나 대체 주소의 추측으로 이어져서는 안 된다.

최종 판단

비슷해 보이는 두 링크를 발견했다면 먼저 비교 결과를 기록하고, 그다음 각 항목의 저장 목적과 메모리를 확인한다. 동일한 직렬화, 프래그먼트 변형, 문자열 차이라는 세 가지 결과만으로도 후보 분류는 충분히 가능하다.

좋은 정리의 기준은 삭제된 링크의 숫자가 아니다. 필요한 차이를 보존하면서, 왜 병합했는지 또는 왜 별도로 남겼는지 설명할 수 있는 목록이다.

링크 비교의 첫 단계는 정리나 삭제가 아니라 구분이다. 주소의 차이와 콘텐츠의 차이를 혼동하지 않고, 코드가 확인한 범위와 사람이 추가로 판단해야 할 범위를 분리하는 것. 그 구분이 갖춰진 뒤에야 실제 정리 작업의 의미가 생긴다.

Top comments (0)