소개

동료가 계약서 두 개의 개정본을 보내고 차이를 비교해 달라고 요청했습니다. 두 파일을 비교 서비스에 넣고 결과를 확인했을 때 모든 것이 정상적으로 보였습니다. 보이지 않은 부분은, 그 문서 중 하나에 URL을 가리키는 연결된 이미지가 포함되어 있었고, 파일이 열리는 순간 서버가 해당 호스트에 접속했다는 점입니다. 출력 결과에는 이런 일이 발생했는지 전혀 나타나지 않습니다.

이는 결함이 아니라 문서를 충실히 로드한다는 의미입니다. OOXML 파일은 패키지 내부가 아니라 웹 서버에 있는 이미지를 참조할 수 있으며, Word와 해당 문서를 올바르게 로드하는 모든 라이브러리는 그 참조를 해결합니다. GroupDocs.Comparison for .NET은 LoadOptions에 두 개의 속성을 제공하여 이를 제어할 수 있게 합니다: SkipExternalResources와 WhitelistedResources.

이 두 속성을 조합하면 세 가지 구성이 가능하며, 이 문서에서는 관대하게 기본값을 사용하는 경우, 모든 외부 리소스를 차단하는 경우, 그리고 명명된 참조만 허용하는 경우를 비교합니다. 마지막으로, 어떤 문서 소스에 어떤 구성을 선택해야 하는지와 이 설정이 작동하지 않는 것처럼 보이게 하는 두 가지 실수를 설명합니다.

💡 전체 작동 예제: block-external-resources-on-document-load-dotnet – 참조된 이미지를 직접 제공하고 모든 요청을 기록하는 실행 가능한 콘솔 프로젝트로, 각 설정이 어떻게 적용되는지 확인할 수 있습니다.

외부 참조가 숨겨지는 위치

설정을 선택하기 전에 무엇을 선택하고 있는지 알아두는 것이 좋습니다. .docx 파일은 두 가지 별도 위치에 외부 참조를 포함할 수 있으며, 두 경우 모두 문서 텍스트에 표시되지 않아 놓치기 쉽습니다.

  1. word/_rels/document.xml.rels 파일에 있는 관계(Relationship)로, TargetMode="External"과 절대 URL을 포함합니다. 이미지 자체는 본문에 그림(Drawing) 형태로 나타나며, 관계 ID를 통해 해당 URL을 가리키므로 URL 자체는 내용 근처에 나타나지 않습니다.

  2. 문서 본문에 있는 INCLUDEPICTURE 필드 코드로, 필드 명령어 안에 URL이 들어 있습니다. Word는 페이지가 렌더링될 때 이를 해결하고, 비교 라이브러리는 문서가 로드될 때 이를 해결합니다.

두 메커니즘 모두 아래에서 논의할 두 로드 옵션을 따릅니다. 문서는 이 중 하나 또는 둘 다를 사용할 수 있기 때문에, 관계 파일에서 참조를 발견했다고 해서 필드 코드에 동일한 참조가 없다는 보장은 없습니다.

접근 방식 1: 기본값 – 참조 해결

SkipExternalResources의 기본값은 false이므로, 별도 설정 없이 로드된 문서는 원격 참조가 해결됩니다.

LoadOptions loadOptions = new LoadOptions
{
    SkipExternalResources = false
};

using (Comparer comparer = new Comparer(sourcePath, loadOptions))
{
    comparer.Add(targetPath, loadOptions);
    comparer.Compare(outputPath);
}

이 방식은 가장 높은 충실도를 제공합니다. 비교되는 문서는 Word가 렌더링하는 방식과 동일하게 모든 참조된 내용을 포함합니다. 자체 애플리케이션이나 템플릿으로 만든 문서처럼 모든 참조 URL이 직접 관리하는 인프라를 가리키는 경우에 적합합니다 – 연결된 이미지가 누락되면 비교 결과가 실제보다 오해를 일으킬 수 있습니다.

단점은 모든 참조에 대해 네트워크 요청이 발생한다는 점입니다. 또한, 더 이상 유효하지 않은 URL이 있으면 로드 시간이 전체 연결 시도 시간만큼 늘어나게 됩니다.

접근 방식 2: 모든 외부 리소스 차단

다음 속성을 사용하면 해당 문서에 대한 원격 참조 해결을 완전히 끕니다.

LoadOptions loadOptions = new LoadOptions
{
    SkipExternalResources = true
};

using (Comparer comparer = new Comparer(sourcePath, loadOptions))
{
    comparer.Add(targetPath, loadOptions);
    comparer.Compare(outputPath);
}

요청이 전혀 발생하지 않습니다. 참조된 이미지는 결과에 나타나지 않으며, 이 점을 명확히 해야 합니다 – 다른 어떤 것도 변하지 않습니다. 이 설정은 로드되는 내용에만 영향을 미치며, 차이점을 찾는 방식에는 영향을 주지 않으므로 두 문서 사이의 텍스트 및 구조적 변경은 이전과 동일하게 감지됩니다. 단, 로드되지 않은 이미지 내부의 변화를 감지할 수는 없습니다.

이 구성은 직접 만든 문서가 아닌 경우(예: 웹 애플리케이션에 업로드된 사용자 파일, 이메일로 받은 파일, 빌드 에이전트에서 비교하는 파일 등) 기본 기준으로 삼아야 합니다. 다만, 실제로 필요했던 연결된 이미지도 모두 차단되므로 결과에 이미지가 없다는 사실만 표시될 뿐, 차단된 이유는 알 수 없습니다.

접근 방식 3: 명명된 참조 제외하고 모두 차단

세 번째 구성은 가장 세밀한 제어를 제공합니다. WhitelistedResources는 List<string>을 받아 SkipExternalResources가 true일 때만 참조됩니다.

LoadOptions loadOptions = new LoadOptions
{
    SkipExternalResources = true,
    WhitelistedResources = new List<string> { "includepicture-field.png" }
};

using (Comparer comparer = new Comparer(sourcePath, loadOptions))
{
    comparer.Add(targetPath, loadOptions);
    comparer.Compare(outputPath);
}

여기에 들어가는 값은 파일 이름이 아니라 URL 조각입니다. 각 조각은 참조 URL 전체에 대해 매치되며, 매치가 되면 해당 리소스는 허용됩니다. 따라서 "includepicture-field.png"는 스킴, 호스트, 경로가 무엇이든 상관없이 해당 문자열을 포함하는 모든 URL을 허용합니다. 이렇게 하면 개발 환경과 프로덕션 환경에서 동일한 화이트리스트를 재작성 없이 사용할 수 있습니다.

반대로, 너무 짧거나 일반적인 조각(logo.png 혹은 .png 등)은 의도하지 않은 참조까지 허용할 위험이 있습니다. 허용하려는 리소스를 정확히 식별할 수 있을 정도로 구체적인 조각을 선택하세요.

예시에서는 화이트리스트에 포함된 이미지는 로드되고, 화이트리스트에 포함되지 않은 두 번째 이미지는 차단됩니다. 요청 로그에는 관대한 기본값이 5개의 요청을 만든 반면, 여기서는 3개의 요청만 기록되고 화이트리스트에 포함된 파일만 이름이 표시됩니다.

어떤 구성을 사용해야 할까요?

문서가 어디에서 왔는지에 따라 설정을 맞추세요. 자체 애플리케이션이나 템플릿으로 만든 문서는 기본값을 유지해도 됩니다. 외부에서 들어온 모든 문서(사용자 업로드, 이메일 첨부, 제3자 파일 등)는 SkipExternalResources = true를 적용하세요. 신뢰할 수 있는 특정 참조가 반드시 해결되어야 할 경우에만 좁은 WhitelistedResources 조각을 추가합니다.

세 구성 비교

고려 사항 기본값 모두 차단 차단 + 화이트리스트
설정해야 할 속성 수 0 1 2
외부 요청 모든 참조 없음 화이트리스트에 포함된 것만
참조별 제어 없음 없음 가능
죽은 URL이 로드 시간에 미치는 영향 있음 없음 화이트리스트에 포함된 경우만
권장 대상 직접 만든 문서 외부에서 온 문서 신뢰할 수 있는 템플릿이 포함된 비신뢰 문서

결정은 성능보다 문서 출처에 기반합니다. 자체 시스템에서 만든 문서는 기본값을 유지하고, 외부에서 온 문서는 차단합니다. 특정 신뢰된 참조가 반드시 해결돼야 할 경우(예: 내부 URL에서 헤더 이미지를 가져오는 기업 템플릿) 화이트리스트를 사용하세요.

두 가지 실수

다음 두 경우는 동일한 증상을 보입니다: 옵션을 설정했지만 아무 일도 일어나지 않은 것처럼 보입니다.

스위치 없이 화이트리스트만 설정 – WhitelistedResources는 SkipExternalResources가 true일 때만 참조됩니다. 단독으로 설정하면 전혀 동작하지 않으며, 차단할 대상이 없기 때문에 예외를 만들 수 없습니다. 화이트리스트가 무시된 것처럼 보이면 먼저 이 점을 확인하세요.

소스에만 옵션을 적용 – 이것이 더 미묘한 실수입니다. 로드 옵션은 하나의 문서가 어떻게 로드되는지를 정의합니다. Comparer 생성자는 소스 문서에 대한 옵션을 받으며, 각 Add() 호출은 해당 타깃 문서에 대한 옵션을 받습니다.

using (Comparer comparer = new Comparer(sourcePath, loadOptions))
{
    comparer.Add(targetPath, loadOptions);
    comparer.Compare(outputPath);
}

생성자에만 옵션을 전달하고 Add() 호출에 전달하지 않으면, 소스 문서는 보호되지만 모든 타깃 문서는 여전히 외부 참조를 가져옵니다. 비교는 성공하고 결과는 그럴듯해 보이지만, 절반 이상의 문서는 여전히 네트워크에 접속합니다. 소스와 타깃이 서로 다른 처리를 필요로 할 경우, 각각 별도의 LoadOptions 인스턴스를 전달해야 합니다 – 바로 이 때문에 API가 문서별 옵션을 받도록 설계되었습니다.

실제로 동작했는지 확인하는 방법

차단된 리소스는 거의 흔적을 남기지 않습니다. 결과 문서에 이미지가 없으면 원래 이미지가 없었던 문서와 구분하기 어렵습니다. 따라서 설정이 적용됐는지 확인하려면 서버 측 로그를 확인하는 것이 가장 좋습니다.

예시에서는 의도적으로 작은 HTTP 리스너를 루프백 포트에 띄우고, 데모 문서가 그 포트를 가리키도록 만든 뒤, 모든 요청을 기록하고 비교당마다 요청 수를 출력합니다. 5 → 0 → 3과 같이 변화를 확인할 수 있습니다. 실제 문서 소스에 대해 네트워크 트레이스를 수행해도 동일한 신뢰성을 얻을 수 있습니다.

결론

세 가지 구성, 하나의 선택 규칙: 직접 만든 문서는 기본값을 유지하고, 그 외 모든 경우에는 SkipExternalResources = true를 설정하며, 특정 신뢰된 참조가 필요할 때만 좁은 URL 조각을 화이트리스트에 추가합니다.

그런 다음 SkipExternalResources = true 없이 화이트리스트만 사용하거나 옵션을 Comparer 생성자에만 전달하고 Add()에 전달하지 않은 두 가지 실수를 점검하고, 출력 파일이 아니라 서빙 측 로그를 통해 설정이 적용됐는지 확인하세요.

추가 자료