자료·리서치

CSV 참고문헌 목록에서 중복 DOI와 누락 DOI 확인하기

연구 참고문헌을 CSV로 관리하면서 DOI 문자열을 비교용으로 정규화하고, 중복 DOI와 DOI 누락 항목을 검토 파일로 작성합니다. 원본 CSV는 그대로 유지하며 DOI가 적혀 있다는 이유만으로 유효한 DOI라고 판단하지 않습니다.

목차 보기

이런 분께 맞아요스프레드시트나 CSV로 참고문헌을 관리하면서 중복 항목과 DOI 누락을 간단히 확인하려는 연구자를 위한 안내입니다.

준비할 것
  • Python 3.12와 해당 버전을 실행하는 터미널 명령이 준비되어 있어야 합니다.
  • UTF-8 CSV를 저장할 수 있는 텍스트 편집기 또는 스프레드시트 프로그램이 필요합니다.
  • 스크립트가 outputs 아래에 새 폴더를 만들 수 있는 작업 폴더가 필요합니다.
  • Python 표준 라이브러리인 csv와 pathlib만 사용합니다.

01DOI 검사가 무엇을 확인할 수 있는지 정하기

이 작업은 두 가지 실무 문제를 확인합니다. 참고문헌 행에 DOI 값이 없거나, 단순 텍스트 정규화 후 두 행의 DOI가 같은 경우입니다. DOI 등록기관에 접속하지 않으므로 DOI가 실제로 존재하는지, 정상적으로 연결되는지, 해당 논문에 속하는지까지 증명하지는 않습니다.

스크립트는 https://doi.org/와 doi: 같은 흔한 접두사를 제거하고 앞뒤 공백을 없앤 뒤 비교를 위해 나머지 DOI 문자열을 소문자로 바꿉니다. 원래 DOI 문자열은 출력 파일에 정규화된 값과 함께 그대로 보존합니다.

02합성 참고문헌 목록 만들기

아래 참고문헌 목록은 이 글을 위해 작성한 합성 데이터입니다. DOI 형태의 문자열도 예제용이며 실제 또는 연결 가능한 출판물이라고 제시하는 값이 아닙니다. references.csv로 저장하세요.

csv
ref_id,title,year,doi
R001,Synthetic bracket study,2024,10.0000/demo.alpha
R002,Synthetic vibration study,2025,10.0000/demo.beta
R003,Synthetic bracket study copy,2024,https://doi.org/10.0000/demo.alpha
R004,Synthetic fatigue note,2023,
R005,Synthetic surrogate study,2026,doi:10.0000/DEMO.GAMMA
R006,Synthetic optimization study,2026,10.0000/demo.delta

참고문헌 행은 6개입니다. DOI 문자열이 있는 행은 5개이고 R004 하나는 DOI가 없습니다. R001과 R003은 표기 방식은 다르지만 정규화하면 같은 값이 됩니다. R005는 대소문자와 doi: 접두사도 비교 전에 정리할 수 있음을 보여줍니다.

03예상 문제를 손으로 계산하기

ref_id원본 DOI정규화 DOI예상 문제
R00110.0000/demo.alpha10.0000/demo.alphaDUPLICATE_DOI
R00210.0000/demo.beta10.0000/demo.beta없음
R003https://doi.org/10.0000/demo.alpha10.0000/demo.alphaDUPLICATE_DOI
R004MISSING_DOI
R005doi:10.0000/DEMO.GAMMA10.0000/demo.gamma없음
R00610.0000/demo.delta10.0000/demo.delta없음

예상 결과는 참고문헌 6개, DOI 문자열이 있는 행 5개, DOI 누락 행 1개, 두 행이 포함된 중복 DOI 그룹 1개입니다. 정규화 후 비어 있지 않은 DOI 5개에는 서로 다른 값이 4개 있습니다.

04DOI 문자열을 정규화하고 검토 파일 만들기

다음 스크립트를 reference_list_check.py로 저장하세요. 전체 참고문헌의 정규화 사본과 검토가 필요한 행만 들어 있는 issues.csv를 별도로 작성합니다. 원본 references.csv는 읽기만 합니다.

python
import csv
from pathlib import Path

SOURCE = Path("references.csv")
OUTPUT_DIR = Path("outputs") / "reference_list_result"
NORMALIZED = OUTPUT_DIR / "normalized_references.csv"
ISSUES = OUTPUT_DIR / "issues.csv"
REQUIRED = {"ref_id", "title", "year", "doi"}


def normalize_doi(value: str) -> str:
    text = value.strip()
    lowered = text.lower()
    prefixes = (
        "https://doi.org/",
        "http://doi.org/",
        "doi:",
    )
    for prefix in prefixes:
        if lowered.startswith(prefix):
            text = text[len(prefix):].strip()
            break
    return text.lower()


def main() -> None:
    if not SOURCE.is_file():
        raise FileNotFoundError(f"Source CSV not found: {SOURCE}")
    if OUTPUT_DIR.exists():
        raise FileExistsError(f"Output folder already exists: {OUTPUT_DIR}")

    rows = []
    with SOURCE.open("r", encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream)
        if reader.fieldnames is None or not REQUIRED.issubset(reader.fieldnames):
            raise ValueError("CSV is missing a required column.")

        seen_ids = set()
        for row in reader:
            ref_id = row["ref_id"].strip()
            if not ref_id:
                raise ValueError("ref_id must not be blank.")
            if ref_id in seen_ids:
                raise ValueError(f"Duplicate ref_id: {ref_id}")
            seen_ids.add(ref_id)

            rows.append({
                "ref_id": ref_id,
                "title": row["title"],
                "year": row["year"],
                "doi": row["doi"],
                "normalized_doi": normalize_doi(row["doi"]),
            })

    by_doi = {}
    for row in rows:
        doi = row["normalized_doi"]
        if doi:
            by_doi.setdefault(doi, []).append(row["ref_id"])

    duplicate_dois = {
        doi: ids for doi, ids in by_doi.items() if len(ids) > 1
    }

    issue_rows = []
    for row in rows:
        doi = row["normalized_doi"]
        if not doi:
            issue_rows.append({
                "ref_id": row["ref_id"],
                "issue": "MISSING_DOI",
                "normalized_doi": "",
                "related_ref_ids": "",
            })
        elif doi in duplicate_dois:
            issue_rows.append({
                "ref_id": row["ref_id"],
                "issue": "DUPLICATE_DOI",
                "normalized_doi": doi,
                "related_ref_ids": ";".join(duplicate_dois[doi]),
            })

    OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir()

    normalized_fields = ["ref_id", "title", "year", "doi", "normalized_doi"]
    with NORMALIZED.open("x", encoding="utf-8", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=normalized_fields)
        writer.writeheader()
        writer.writerows(rows)

    issue_fields = ["ref_id", "issue", "normalized_doi", "related_ref_ids"]
    with ISSUES.open("x", encoding="utf-8", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=issue_fields)
        writer.writeheader()
        writer.writerows(issue_rows)

    missing_count = sum(row["issue"] == "MISSING_DOI" for row in issue_rows)
    print(f"References checked: {len(rows)}.")
    print(f"Missing DOI rows: {missing_count}.")
    print(f"Duplicate DOI groups: {len(duplicate_dois)}.")
    print(f"Issue rows: {len(issue_rows)}.")
    print(f"Output folder: {OUTPUT_DIR.as_posix()}")


if __name__ == "__main__":
    main()

05예상 검토 보고서와 비교하기

issues.csv에는 데이터 행이 3개 있어야 합니다. 정규화된 DOI가 중복되는 R001과 R003, 그리고 DOI가 없는 R004입니다.

csv
ref_id,issue,normalized_doi,related_ref_ids
R001,DUPLICATE_DOI,10.0000/demo.alpha,R001;R003
R003,DUPLICATE_DOI,10.0000/demo.alpha,R001;R003
R004,MISSING_DOI,,

아래 예상 콘솔 출력은 합성 목록과 스크립트를 바탕으로 손으로 도출한 결과이며 실제 실행 로그가 아닙니다.

text
References checked: 6.
Missing DOI rows: 1.
Duplicate DOI groups: 1.
Issue rows: 3.
Output folder: outputs/reference_list_result

06자동 삭제하지 말고 표시된 행을 검토하기

  • R001과 R003이 같은 DOI 문자열로 정규화되는지 확인합니다.
  • R004는 DOI가 없어도 normalized reference list에서 삭제되지 않는지 확인합니다.
  • 중복 DOI 행을 합치기 전에 논문 제목, 저자, 연도, 출판 정보를 확인합니다.
  • DOI 누락 행은 실제로 DOI가 존재하는 출판물인지 확인한 뒤 값을 추가합니다.
  • OUTPUT_DIR을 바꾸지 않고 다시 실행합니다. 이전 검토 결과를 교체하지 않고 FileExistsError로 중단되어야 합니다.
문제확인할 사항
같은 논문이 서로 다른 DOI 표기로 입력됨비교 전에 흔한 DOI URL과 doi: 접두사를 정규화하세요.
빈 DOI가 중복으로 처리됨DOI 누락 검사와 DOI 중복 그룹화를 분리하세요.
서로 다른 논문의 제목이 비슷함제목 유사성만으로 중복 참고문헌이라고 판단하지 마세요.
DOI처럼 보이는 문자열이 있음값이 존재한다는 사실만으로 유효성을 보장하지 않습니다. 중요한 참고문헌은 신뢰할 수 있는 메타데이터 출처에서 확인하세요.
기존 검토 결과가 덮어써짐이전 검토 기록을 유지할 수 있도록 새로운 출력 폴더를 사용하세요.

07DOI 기반 중복 검사의 한계 이해하기

DOI 메타데이터가 있는 경우 DOI 비교는 유용하지만 DOI가 없다고 해서 참고문헌이 잘못되었거나 불완전하다는 뜻은 아닙니다. 일부 출판물이나 문서 유형은 DOI가 없을 수 있으며, 오래된 자료는 다른 식별자나 수동 서지 확인이 필요할 수 있습니다.

이 스크립트는 문자열 정규화만 수행합니다. DOI 링크를 실제로 확인하거나 Crossref 같은 등록기관에 질의하지 않으며, 제목이나 저자 검증, 철회 여부 확인, 서로 다른 DOI가 같은 연구의 다른 버전인지 판정하는 작업도 하지 않습니다.

실제 연구 데이터베이스에서는 저자, 제목, 연도, 저널, 권호, 페이지, DOI, 자체 reference ID 등을 함께 관리하는 편이 좋습니다. 자동 중복 탐지는 참고문헌 삭제 명령이 아니라 검토 보조 수단으로 사용하세요.

실행·검증 기록

2026-09-20 · 예제 수동 검토 · 대상: Python 3.12 · 표준 라이브러리: csv, pathlib · 실행하지 않음

  • 합성 참고문헌 6개, DOI 문자열이 있는 행 5개, DOI 누락 행 1개를 수동으로 확인했습니다.
  • R001과 R003이 모두 10.0000/demo.alpha로 정규화됨을 수동으로 확인했습니다.
  • R005가 doi:10.0000/DEMO.GAMMA에서 10.0000/demo.gamma로 정규화됨을 확인했습니다.
  • 비어 있지 않은 DOI 5개에서 서로 다른 정규화 DOI가 4개임을 계산했습니다.
  • 예상 issue report가 중복 DOI 행 2개와 DOI 누락 행 1개로 구성됨을 수동으로 도출했습니다.
  • 중복 ref_id 검사, DOI 정규화, 누락과 중복의 별도 처리, 출력 충돌 방지, 원본 CSV 보존 로직을 코드로 검토했습니다.
검증 한계
  • 이 응답의 작성자는 코드를 실행하지 않았으며, CSV 출력 파일을 실제로 만들지 않았습니다.
  • 합성 DOI 형태 문자열은 등록 여부나 실제 연결 여부를 확인하지 않았으며 실제 출판물로 제시하지 않습니다.
  • DOI 유효성, 서지 메타데이터 정확성, 제목 유사성, 철회 여부, 관련 논문 버전은 시험하지 않았습니다.
  • 공식 문서 URL은 알려진 문서 위치를 사용했지만 실시간으로 접속해 확인하지 않았습니다.

사이트 전체 작성·검증 원칙

참고 출처

설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.