소개
후보 지문은 보관 자료 검색 비용을 줄이지만 내용이 동일함을 증명하지는 않습니다. 실제 바이트로 확인하고 각 중복 그룹의 모든 상대 경로를 유지하는 읽기 전용 검색기를 완성합니다.
이 실습은 이전 실습의 이진 읽기와 SHA-256 개념, Java 클래스, 리스트, 예외를 알고 있다고 가정합니다. 완성된 다이제스트와 새로운 갤러리 사본을 가지고 독립적으로 시작합니다. 그룹화는 직접 구현하며 디렉터리, 보고서, 실행 어댑터는 준비되어 있습니다. 저자 예상 시간은 55분입니다.
각 후보를 실제 바이트로 확인하기
이 단계에서는 후보 사실을 완전한 중복 그룹으로 바꿉니다. WebIDE를 열고 Terminal > New Terminal을 선택한 뒤 독립 작업 공간으로 이동하세요.
cd /home/labex/project/duplicates
./run.sh Sample
실제 PNG 디코더는 다른 이름 아래 같은 바이트가 있고, 같은 크기에서도 바이트가 다를 수 있음을 다시 보여 줍니다. 실행기는 매번 새로운 임시 빌드를 사용합니다. 컴파일러/JVM 자식 프로세스는 C.UTF-8과 명시적인 UTF-8 소스·텍스트 설정을 사용하며 터미널 설정은 바꾸지 않습니다.
공개 어댑터와 초기 구현을 읽으세요.
cat src/Digester.java src/Finder.java
Finder.find(Path root, Digester candidates)는 중복 그룹 리스트를 반환해야 하며, 각 그룹에는 root 기준의 모든 상대 경로가 들어갑니다. root.relativize(path)가 상대 경로를 만들고, root.resolve(relative)가 읽을 위치를 다시 찾습니다. sameBytes(left, right)는 이름이 아닌 내용을 비교합니다. 이 공개 진입점은 유지하세요. 내부 보조 처리나 컬렉션 구성은 달라도 됩니다.
아래 구현은 Map으로 후보 키를 가능한 그룹과 연결합니다. 키는 크기와 다이제스트를 조합합니다. containsKey는 키의 존재를 확인하고, put은 초기 값을 만들며, get은 값을 가져옵니다. 서로 다른 내용이 같은 후보 지문을 공유할 수 있으므로, 각 키는 바이트로 확인된 그룹 리스트를 담습니다. List<List<Path>>는 요소가 경로 리스트인 리스트를 뜻합니다.
각 파일을 해당 버킷의 기존 그룹마다 대표 경로와 비교합니다. 실제로 일치하면 새 경로를 추가하고, 그렇지 않으면 다른 하위 그룹을 만듭니다. placed는 현재 경로가 그룹에 들어갔는지를 기록합니다. break는 일치 후 안쪽 검색을 멈춰 같은 경로가 한 번만 추가되게 합니다. 경로가 두 개 이상인 그룹만 보고서에 넣습니다. 두 개에서 멈추지 마세요. 세 번째 복사본도 검토해야 할 독립 경로입니다.
sameBytes는 길이가 다르면 먼저 거부하고, 그다음 두 파일을 읽습니다. read()는 한 바이트를 0부터 255까지의 정수로 반환하고, 끝에서는 -1을 반환합니다. 다른 바이트가 나오면 false, 함께 끝에 도달하면 true를 반환합니다. 두 스트림은 try-with-resources로 닫습니다. 한 바이트씩 읽는 방식은 이 작은 교육 예제를 쉽게 이해하도록 돕습니다. 버퍼 비교나 전체 배열의 바이트 비교도 유효하지만 메모리와 성능의 절충점이 있습니다.
before 맵은 각 Path를 원래 메타데이터와 연결합니다. before.keySet()은 마지막 변경 검사에 쓸 경로 키를 반환하고, buckets.values()는 결과를 조립하기 위해 저장해 둔 후보 하위 그룹 리스트를 반환합니다. 준비된 속성 어댑터는 읽기 전 크기, 수정 시간, 파일 식별 정보를 기록하고 감지된 변경을 거부합니다. 트리를 잠그거나 고정하는 것은 아닙니다. 스캔 중 입력은 안정적으로 유지하세요. 동시 쓰기, 심볼릭 링크, 특수 파일은 지원하지 않습니다. 같은 파일의 서로 다른 하드 링크 경로를 포함해 모든 일반 파일 경로가 중요합니다. 파일 식별 정보는 변경 감지에만 쓰고, 경로를 버리는 데 쓰지 않습니다.
src/Finder.java를 교체합니다. 이전 실습처럼 따옴표로 감싼 here-document는 텍스트를 그대로 쓰며, 끝의 JAVA는 한 줄에 혼자 둡니다. >는 이 소스 파일만 교체합니다.
cat > src/Finder.java <<'JAVA'
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
public class Finder {
public static boolean sameBytes(Path left, Path right) throws IOException {
if (Files.size(left) != Files.size(right)) return false;
try (InputStream a = Files.newInputStream(left);
InputStream b = Files.newInputStream(right)) {
int value;
while ((value = a.read()) != -1) {
if (value != b.read()) return false;
}
return b.read() == -1;
}
}
public static List<List<Path>> find(Path root, Digester candidates) throws IOException {
Map<String, List<List<Path>>> buckets = new LinkedHashMap<>();
Map<Path, BasicFileAttributes> before = new LinkedHashMap<>();
for (Path path : Inputs.files(root)) {
BasicFileAttributes facts = Inputs.attributes(path);
before.put(path, facts);
String key = facts.size() + ":" + candidates.digest(path);
Inputs.unchanged(path, facts);
if (!buckets.containsKey(key)) buckets.put(key, new ArrayList<>());
List<List<Path>> groups = buckets.get(key);
boolean placed = false;
for (List<Path> group : groups) {
if (sameBytes(path, root.resolve(group.get(0)))) {
group.add(root.relativize(path));
placed = true;
break;
}
}
if (!placed) {
List<Path> group = new ArrayList<>();
group.add(root.relativize(path));
groups.add(group);
}
}
for (Path path : before.keySet()) Inputs.unchanged(path, before.get(path));
List<List<Path>> result = new ArrayList<>();
for (List<List<Path>> groups : buckets.values()) {
for (List<Path> group : groups) {
if (group.size() > 1) result.add(group);
}
}
return result;
}
}
JAVA
준비된 Main은 new Sha256()을 전달해 같은 find를 호출합니다. 테스트에서는 서로 다른 파일에도 같은 후보 값을 반환하는 다른 Digester를 전달할 수 있습니다. 이는 통제된 후보 충돌 테스트이며 실제 SHA-256 충돌이 아닙니다. 마지막 바이트 비교로 서로 다른 내용을 여전히 분리해야 합니다. 호출되지 않는 비교 메서드만 작성해서는 충분하지 않습니다.
스캔 루트 밖에 실제 보고서를 생성하세요.
./run.sh Main data/gallery reports/groups.tsv
예상 출력은 Scan complete: 3 duplicate groups입니다. 컴파일에 실패하면 src/Finder.java에서 지적한 행을 고치고 저장한 뒤 다시 실행하세요. 데이터를 바꾸기 전에 중괄호와 메서드 시그니처를 확인하세요.
전체 그룹을 읽고 변경 후 다시 실행하기
이 단계에서는 완성된 보고서를 읽고 현재 바이트에 따라 결과가 달라지는 것을 확인합니다. 같은 WebIDE 통합 터미널에서 /home/labex/project/duplicates를 유지하세요.
cat reports/groups.tsv
UTF-8 TSV 헤더는 GROUP, 그다음 PATH입니다. 각 행은 상대 경로 하나를 나타내며, 같은 그룹 라벨이 같은 내용을 연결합니다. 라벨 자체는 파일 식별 정보가 아니고 순서는 중요하지 않습니다. 빨간 그림 그룹에는 세 경로가 모두 있어야 하며, 두 빈 파일이 한 그룹, 마지막 버퍼가 일부만 채워지는 두 이진 파일이 다른 그룹을 이룹니다. 파란 그림과 이름은 같지만 내용이 다른 readme 파일은 중복이 아닙니다. Explorer의 duplicates/data/gallery에서 각 파일을 찾을 수 있으며, 입력은 삭제되지 않습니다.
갤러리 원본을 유지하고 work의 일회용 사본으로 변경을 시험합니다. mkdir -p는 필요할 때 연습 디렉터리를 만듭니다. cp는 파일을 새 경로에 복사합니다.
mkdir -p work/mini
cp data/gallery/red.png work/mini/first.png
cp data/gallery/copies/renamed.png work/mini/second.png
./run.sh Main work/mini reports/practice-before.tsv
중복 그룹 하나가 나와야 합니다. 이제 printf가 따옴표 안 텍스트와 줄바꿈을 쓰고, >는 연습용 두 번째 파일만 교체합니다. 더 이상 PNG가 아니므로 확장자는 내용의 증거가 아닙니다.
printf 'changed\n' > work/mini/second.png
./run.sh Main work/mini reports/practice-after.tsv
중복 그룹은 0개가 되어야 합니다. 보고서에는 여전히 헤더가 있습니다.
cat reports/practice-after.tsv
성공적인 빈 결과와 스캔 실패는 다릅니다. 다음 명령은 의도적으로 입력 트리 안에 보고서를 요청합니다. Scan failed와 종료 상태 2가 표시되고 새 입력 파일이 없어야 합니다.
./run.sh Main data/gallery data/gallery/report.tsv
출력 경로를 고쳐 갤러리 보고서를 다시 만드세요.
./run.sh Main data/gallery reports/groups.tsv
준비된 보고서 어댑터는 같은 디렉터리의 새 임시 파일에 쓴 뒤 스캔 성공 후 출력 경로를 교체합니다. 기존 하드 링크를 쓰기용으로 열지 않으므로 보고서 경로가 해당 링크를 통해 원본 바이트를 덮어쓸 수 없습니다. 프로세스나 시스템이 비정상 종료된 뒤의 영속성을 보장하는 것은 아닙니다. 읽기가 실패하면 명령도 실패하며, 이전 보고서는 현재 실행의 성공 증거가 아닙니다. 파일을 조용히 건너뛰고 성공을 알리면 안 됩니다.
최종 검사는 격리된 테스트 데이터에서 현재 소스를 실행하고 실제 갤러리 보고서를 읽습니다. 완전한 그룹, 현재 내용, 오류, 원본 보존을 검사합니다. 이전에 올바른 보고서가 있었다고 해서 이후 손상된 소스가 허용되지는 않습니다.
요약
크기와 SHA-256으로 후보를 좁히고 실제 바이트를 확인했으며 모든 중복 경로를 유지하고 실제 변경 후 보고서를 다시 만들었습니다. 그룹이 0개인 정상 결과와 스캔 실패의 의미도 구분했습니다.



