북마크 중복 정리 기준과 검토 구조
북마크 정리의 출발점은 삭제가 아니라 동일한 저장 주소 문자열의 확인이다. 비슷한 주소를 하나로 묶으면 에디션, 섹션, 경로, 메모의 차이가 먼저 사라질 수 있다. 첫 단계의 목적은 ‘중복 제거’보다 ‘동일 문자열 보고’에 한정한다.
프로젝트에는 두 매뉴얼 에디션과 여러 읽기 섹션의 북마크가 있다. 목표는 실수로 생성된 동일 항목의 확인이며, 동료가 저장한 참고 기록의 보존도 중요하다. 각 레코드에는 고유 ID, URL, 제목·폴더·메모가 포함될 수 있다.
손실형 비교 키의 문제
가져오기 스크립트에 다음과 같은 비교 키가 있다고 가정한다.
def lossy_key(address):
return address.split("?", 1)[0].split("#", 1)[0].rstrip("/")
이 코드는 문제 상황을 위한 예시다. 다음 네 주소는 모두 /manual이라는 동일한 키로 축약된다.
| 저장 주소 | 예시상 참고 의미 | 결과 키 |
|---|---|---|
/manual?edition=1#setup |
에디션 1, 설정 | /manual |
/manual?edition=2#setup |
에디션 2, 설정 | /manual |
/manual?edition=1#examples |
에디션 1, 예제 | /manual |
/manual/?edition=1#setup |
별도로 저장한 경로 변형 | /manual |
핵심은 주소의 차이가 비교 전에 사라진다는 점이다. 에디션, 섹션, 마지막 슬래시를 제거한 뒤 첫 레코드만 남기는 방식은 동등성 확인보다 정보 손실에 가깝다. 예외 추가보다 먼저 첫 버전의 판단 범위를 정한다.
정확한 문자열 그룹
첫 버전의 계약은 단순하다. 저장된 URL 문자열이 완전히 동일한 레코드 ID를 그룹으로 보고한다. 레코드 수정, 삭제 판정, 목적지 동일성 판단은 범위 밖이다.
def duplicate_groups(records: list[dict[str, str]]) -> list[tuple[str, ...]]:
"""동일 주소 문자열의 ID 그룹만 보고하고 입력 레코드는 수정하지 않는다."""
if not isinstance(records, list):
raise TypeError("records must be a list")
groups: dict[str, list[str]] = {}
seen_ids: set[str] = set()
for position, record in enumerate(records):
if not isinstance(record, dict):
raise TypeError(f"record {position} must be a dictionary")
record_id = record.get("id")
address = record.get("url")
for name, value in (("id", record_id), ("url", address)):
if not isinstance(value, str) or not value.strip():
raise ValueError(
f"record {position}: {name} must be nonblank text"
)
if record_id in seen_ids:
raise ValueError(f"record {position}: duplicate id")
seen_ids.add(record_id)
groups.setdefault(address, []).append(record_id)
return [tuple(ids) for ids in groups.values() if len(ids) > 1]
("a", "c")는 두 레코드의 저장 주소 문자열이 같다는 의미다. c의 삭제 가능성이나 a의 우선순위 정보는 없다. 보고서는 검토 대상이고, 병합 결정은 별도 업무다.
strip() 역시 비교용 변환이 아니다. 빈 값 검사를 위한 검증용 사용이며, 실제 그룹 키에는 원래 문자열이 그대로 들어간다. 공백 제거, 파라미터 삭제 같은 조치는 없다. 반복 ID도 오류 대상이다. 식별자 중복 상태에서는 검토 기록이 불명확하다.
중복성과 보존 가치
동일한 주소라도 저장 목적은 서로 다를 수 있다. 하나는 워크숍용, 다른 하나는 참고용일 수 있다. 폴더 위치와 개인 메모 역시 별도의 의미다. 따라서 동일 문자열 그룹은 삭제 명령이 아니라 확인 목록에 가깝다.
주소가 다르다면 더욱 신중한 접근이 필요하다. 같은 업무와 관련된다는 이유만으로 하나의 북마크로 합칠 근거는 부족하다. 예를 들어 주소온길 같은 참고 페이지와 그곳에서 선택한 특정 문서가 있다고 하자. 하나는 자료 선택용, 다른 하나는 특정 문서 재방문용일 수 있다. 이는 해당 사이트나 연결 대상의 실제 상태에 관한 판단이 아니다.
실제 검토에서는 목적, 내용, 접근 경로, 메모, 폴더, 사용 맥락을 확인한다. 최종 판단에는 원본 ID도 필요하다. 승인된 병합이나 보존 결정을 원래 항목과 연결할 수 있기 때문이다.
회귀 테스트 기준
함수와 테스트를 하나의 bookmark_report.py 파일에 배치하고, 사용하는 Python 환경에서 다음 명령을 기준으로 확인한다.
python bookmark_report.py -v
테스트 핵심은 네 가지다. 정확한 그룹 보고, 네 문자열의 구별, 입력 레코드의 불변성, 반복 ID의 거부다.
import unittest
from copy import deepcopy
class DuplicateTests(unittest.TestCase):
def test_exact_match(self):
rows = [
{"id": "a", "url": "/manual?edition=1#setup"},
{"id": "b", "url": "/manual?edition=2#setup"},
{"id": "c", "url": "/manual?edition=1#setup"},
]
self.assertEqual(duplicate_groups(rows), [("a", "c")])
def test_distinctions_survive(self):
addresses = [
"/manual?edition=1#setup",
"/manual?edition=2#setup",
"/manual?edition=1#examples",
"/manual/?edition=1#setup",
]
rows = [
{"id": str(i), "url": value}
for i, value in enumerate(addresses)
]
self.assertEqual(duplicate_groups(rows), [])
def test_input_is_unchanged(self):
rows = [
{"id": "a", "url": "/guide", "note": "Workshop"},
{"id": "b", "url": "/guide", "note": "Reference"},
]
before = deepcopy(rows)
duplicate_groups(rows)
self.assertEqual(rows, before)
def test_repeated_ids_are_rejected(self):
rows = [
{"id": "a", "url": "/one"},
{"id": "a", "url": "/two"},
]
with self.assertRaises(ValueError):
duplicate_groups(rows)
네 테스트의 통과는 해당 조건의 확인이며, 모든 형식과 예외의 보장은 아니다. lossy_key(address) 적용 시 구별 테스트의 실패가 예상된다. 네 주소가 하나의 그룹으로 들어가기 때문이다.
확장 비교의 별도 단계
정확한 일치와 해석이 필요한 유사 후보는 같은 ‘중복’ 목록에 섞지 않는 편이 좋다. 대소문자, 마지막 슬래시, 추적 파라미터, 인코딩 차이 등에 관한 별도 규칙을 검토한다면 각 규칙마다 ‘일치해야 하는 사례’와 ‘분리되어야 하는 사례’를 함께 마련한다. 보고서에는 규칙명과 근거도 남긴다.
실제 데이터는 복사본에서 시작한다. 원본 내보내기 파일을 보존하고, 소규모 샘플 검토 후 승인된 병합만 기록한다. 후보가 많다는 이유만으로 자동 삭제를 추가하지 않는다.
입력 경계도 중요하다. 여기의 테스트는 하나의 사이트에 속한 상대 경로를 사용한다. 여러 출처의 가져오기 자료라면 각 주소의 기준 출처를 함께 보존해야 한다. 서로 다른 사이트의 /manual 같은 상대 경로를 동일 목적지로 취급해서는 안 된다. 실제 시스템에서는 주소 준비와 중복 보고를 분리하는 편이 안전하다.
세 가지 확인 사항
정확히 같은 주소라면 중복인가?
아니다. 같은 문자열이라는 사실만 확인한다. 메모, 폴더, 사용 목적, 팀 내 역할에 따른 별도 검토가 필요하다.
비슷한 주소는 먼저 정규화해야 하는가?
첫 단계에서는 아니다. 원본을 유지하고, 넓은 비교 규칙은 별도 후보 단계에서 시험한다. 새 규칙이 기존 정확 일치 결과를 대체해서는 안 된다.
페이지의 작동 여부까지 확인하는가?
아니다. 함수의 입력은 저장된 레코드뿐이다. 접속 가능성, 리디렉션, 로그인, 콘텐츠 상태는 별도 확인 영역이다.
초기 배포 기준
첫 배포의 목표는 가장 작은 북마크 목록이 아니다. 검토자가 같은 문자열과 다른 문자열을 구분할 수 있는 상태다. 복사한 데이터셋에서 정확 일치 보고서를 먼저 실행하고, 한 그룹을 처음부터 끝까지 검토한 뒤에야 병합 규칙을 설계한다.
정리 작업의 기준은 삭제량보다 구분의 정확성이다. 원본 ID, 저장 주소, 메모, 사용 목적의 보존은 이후 결정의 근거다. 정확한 문자열 비교라는 작은 범위에서 시작하면 결과의 설명 가능성도 높아진다. 그 다음 단계에서 넓은 비교 규칙, 검토 승인, 병합 기록, 필요할 경우 실제 삭제를 추가한다.

