
사용예시
- 사진이 한 곳에 모아진 폴더가 있음
- 다른 폴더에도 사진이 있는데 중복인지 확인/처리하고 싶을 때
폴더와 폴더를 비교하여 중복 파일을 제거하는 느낌보다는, 이미 취합된 사진 폴더를 중심으로 여타 폴더에 중복된 사진들이 있는지 검사 및 처리하는 용도에 가깝습니다.

백업이 여러 버전이 있을 때 병합하는 용도로 돌림. 스크립트 돌리면 달아서 Hash 값 비교해서 처리합니다. 스크립트 사용 출력 예시는 다음과 같습니다
[BASE] 인덱스 저장: ...cache.json (해시 1543, 캐시재사용 0, 실패 0)
[DEL] D:\photos\archive\IMG_0001.JPG ← 실제 삭제
[DRY] DELETE D:\photos\archive\IMG_0001.JPG ← dry-run
[결과]
스캔된 target 파일 : 842
해시 검증한 파일 : 831 (캐시 재사용 11)
삭제됨 : 217
유지(고유) : 614
Node.js v22 기반
/**
* dedupe_against_base.js
*
* base 폴더의 파일 목록(경로/수정일/생성일/크기/md5)을 JSON 캐시로 인덱싱한 뒤,
* target 폴더 내 파일을 재귀 탐색하여 base 와 MD5 가 일치하는 파일을 삭제합니다.
*
* 동작:
* 1) base 폴더를 재귀 탐색하여 모든 파일의 메타 + md5 를 계산하고
* JSON 캐시 파일(--cache)에 저장합니다.
* 2) 재실행 시 캐시가 존재하면 (path + size + mtimeMs) 가 일치하는 항목은
* 해시 재계산을 건너뜁니다. 누락된 항목만 추가 계산합니다.
* 3) target 폴더를 재귀 탐색하면서 각 파일의 md5 를 계산하고,
* base 의 md5 집합에 존재하면 해당 target 파일을 삭제합니다.
*
* 사용법:
* node scripts/dedupe_against_base.js --base <baseDir> --target <targetDir> [옵션]
*
* 옵션:
* --base <경로> 기준 폴더 (필수)
* --target <경로> 중복 제거 대상 폴더 (필수)
* --cache <경로> base 인덱스 JSON 경로
* (기본: ./cached/<base 경로>/cache.json)
* --target-cache <경로> target 인덱스 JSON 경로
* (기본: ./cached/<target 경로>/cache.json)
* --dry-run 실제 삭제 없이 시뮬레이션만 수행
* --no-cache base/target 캐시 사용 안 함 (모두 재해싱)
* --rebuild-cache 기존 캐시를 무시하고 새로 생성 (base/target 모두)
* --log <경로> 삭제된 파일 목록을 JSON 으로 기록
* --concurrency <N> 해시 동시 처리 개수 (기본: 8)
*
* 예:
* node scripts/dedupe_against_base.js --base ./merged --target "D:\photos\archive"
* node scripts/dedupe_against_base.js --base ./merged --target ./tmp --dry-run
*/
'use strict';
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const crypto = require('crypto');
// ── Argument parsing ───────────────────────────────────────────────────────────
function parseArgs(argv) {
const opts = {
base: null,
target: null,
cache: null,
targetCache: null,
dryRun: false,
noCache: false,
rebuildCache: false,
log: null,
concurrency: 8,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case '--base': opts.base = argv[++i]; break;
case '--target': opts.target = argv[++i]; break;
case '--cache': opts.cache = argv[++i]; break;
case '--target-cache': opts.targetCache = argv[++i]; break;
case '--dry-run': opts.dryRun = true; break;
case '--no-cache': opts.noCache = true; break;
case '--rebuild-cache': opts.rebuildCache = true; break;
case '--log': opts.log = argv[++i]; break;
case '--concurrency': opts.concurrency = parseInt(argv[++i], 10) || 8; break;
case '-h':
case '--help':
printHelpAndExit(0);
break;
default:
console.error(`알 수 없는 인자: ${a}`);
printHelpAndExit(1);
}
}
if (!opts.base) {
console.error('--base 는 필수입니다.');
printHelpAndExit(1);
}
opts.base = path.resolve(opts.base);
if (opts.target) opts.target = path.resolve(opts.target);
opts.cache = opts.cache
? path.resolve(opts.cache)
: defaultCachePathFor(opts.base);
if (opts.target && !opts.targetCache) {
opts.targetCache = defaultCachePathFor(opts.target);
} else if (opts.targetCache) {
opts.targetCache = path.resolve(opts.targetCache);
}
if (opts.log) opts.log = path.resolve(opts.log);
return opts;
}
function defaultCachePathFor(absPath) {
const parsed = path.parse(absPath);
let driveSeg = parsed.root.replace(/[:\\/]/g, '');
if (!driveSeg) driveSeg = '__unc__';
const rest = absPath.slice(parsed.root.length);
return path.join(__dirname, 'cached', driveSeg.toLowerCase(), rest, 'cache.json');
}
function printHelpAndExit(code) {
const help = `\n사용법:\n node scripts/dedupe_against_base.js --base <baseDir> --target <targetDir> [옵션]\n\n옵션:\n --base <경로> 기준 폴더 (필수)\n --target <경로> 중복 제거 대상 폴더 (필수)\n --cache <경로> base 인덱스 JSON 경로 (기본: <base>/_dedupe_cache.json)\n --dry-run 실제 삭제 없이 시뮬레이션만 수행\n --no-cache 캐시 사용 안 함\n --rebuild-cache 기존 캐시를 무시하고 새로 생성\n --log <경로> 삭제된 파일 목록 JSON 기록\n --concurrency <N> 해시 동시 처리 개수 (기본: 8)\n`;
console.log(help);
process.exit(code);
}
// ── Utils ───────────────────────────────────────────────────────────────────────
async function* walk(dir) {
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch (err) {
console.warn(`[WARN] 디렉터리 읽기 실패: ${dir} (${err.message})`);
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (entry.isFile()) {
yield full;
}
}
}
function md5OfFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('error', reject);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let idx = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const i = idx++;
if (i >= items.length) return;
results[i] = await worker(items[i], i);
}
});
await Promise.all(runners);
return results;
}
function loadCache(cachePath) {
if (!fs.existsSync(cachePath)) return null;
try {
const raw = fs.readFileSync(cachePath, 'utf8');
return JSON.parse(raw);
} catch (err) {
console.warn(`[WARN] 캐시 로드 실패 (${cachePath}): ${err.message}`);
return null;
}
}
function saveCache(cachePath, data) {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
const tmp = cachePath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
fs.renameSync(tmp, cachePath);
}
// ── Base indexing ──────────────────────────────────────────────────────────────
async function buildBaseIndex(opts) {
const { base, cache, noCache, rebuildCache, concurrency } = opts;
if (!fs.existsSync(base) || !fs.statSync(base).isDirectory()) {
throw new Error(`base 폴더가 존재하지 않습니다: ${base}`);
}
const prev = (noCache || rebuildCache) ? null : loadCache(cache);
const prevByPath = new Map();
if (prev && Array.isArray(prev.files)) {
for (const f of prev.files) prevByPath.set(f.path, f);
}
console.log(`[BASE] 스캔: ${base}`);
const files = [];
for await (const fp of walk(base)) {
if (path.resolve(fp) === path.resolve(cache)) continue;
files.push(fp);
}
console.log(`[BASE] 파일 ${files.length}개 발견`);
let hashed = 0, cached = 0, failed = 0;
const records = await mapWithConcurrency(files, concurrency, async (fp) => {
let stat;
try {
stat = await fsp.stat(fp);
} catch (err) {
failed++;
console.warn(`[WARN] stat 실패: ${fp} (${err.message})`);
return null;
}
const prevRec = prevByPath.get(fp);
if (prevRec
&& prevRec.size === stat.size
&& prevRec.mtimeMs === stat.mtimeMs
&& prevRec.md5) {
cached++;
return prevRec;
}
let md5;
try {
md5 = await md5OfFile(fp);
} catch (err) {
failed++;
console.warn(`[WARN] 해시 실패: ${fp} (${err.message})`);
return null;
}
hashed++;
if ((hashed + cached) % 200 === 0) {
console.log(`[BASE] 진행 ${hashed + cached}/${files.length} (해시 ${hashed}, 캐시 ${cached})`);
}
return {
path: fp,
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
birthtimeMs: stat.birthtimeMs,
mtime: new Date(stat.mtimeMs).toISOString(),
ctime: new Date(stat.ctimeMs).toISOString(),
birthtime: new Date(stat.birthtimeMs).toISOString(),
md5,
};
});
const valid = records.filter(Boolean);
const index = {
base,
generatedAt: new Date().toISOString(),
count: valid.length,
files: valid,
};
saveCache(cache, index);
console.log(`[BASE] 인덱스 저장: ${cache} (해시 ${hashed}, 캐시재사용 ${cached}, 실패 ${failed})`);
const md5Set = new Set();
const md5ToPaths = new Map();
for (const r of valid) {
md5Set.add(r.md5);
if (!md5ToPaths.has(r.md5)) md5ToPaths.set(r.md5, []);
md5ToPaths.get(r.md5).push(r.path);
}
return { md5Set, md5ToPaths };
}
// ── Target deduplication ───────────────────────────────────────────────────────
async function dedupeTarget(opts, baseIdx) {
const { target, dryRun, concurrency, base, targetCache, noCache, rebuildCache } = opts;
const { md5Set, md5ToPaths } = baseIdx;
if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
throw new Error(`target 폴더가 존재하지 않습니다: ${target}`);
}
const prev = (noCache || rebuildCache) ? null : loadCache(targetCache);
const prevByPath = new Map();
if (prev && Array.isArray(prev.files)) {
for (const f of prev.files) prevByPath.set(f.path, f);
}
console.log(`[TARGET] 스캔: ${target}`);
const files = [];
for await (const fp of walk(target)) {
if (path.resolve(fp) === path.resolve(targetCache)) continue;
files.push(fp);
}
console.log(`[TARGET] 파일 ${files.length}개 발견`);
const deletions = [];
const survivors = [];
let kept = 0, checked = 0, cachedHits = 0, failed = 0;
await mapWithConcurrency(files, concurrency, async (fp) => {
const rel = path.relative(base, fp);
const insideBase = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
if (insideBase) {
kept++;
return;
}
let stat;
try {
stat = await fsp.stat(fp);
} catch (err) {
failed++;
console.warn(`[WARN] stat 실패: ${fp} (${err.message})`);
return;
}
let md5;
const prevRec = prevByPath.get(fp);
if (prevRec
&& prevRec.size === stat.size
&& prevRec.mtimeMs === stat.mtimeMs
&& prevRec.md5) {
md5 = prevRec.md5;
cachedHits++;
} else {
try {
md5 = await md5OfFile(fp);
} catch (err) {
failed++;
console.warn(`[WARN] 해시 실패: ${fp} (${err.message})`);
return;
}
}
checked++;
if (checked % 200 === 0) {
console.log(`[TARGET] 진행 ${checked}/${files.length} (해시 ${checked - cachedHits}, 캐시 ${cachedHits}, 삭제 ${deletions.length})`);
}
const record = {
path: fp,
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
birthtimeMs: stat.birthtimeMs,
mtime: new Date(stat.mtimeMs).toISOString(),
ctime: new Date(stat.ctimeMs).toISOString(),
birthtime: new Date(stat.birthtimeMs).toISOString(),
md5,
};
if (!md5Set.has(md5)) {
kept++;
survivors.push(record);
return;
}
const matches = md5ToPaths.get(md5) || [];
if (dryRun) {
console.log(`[DRY] DELETE ${fp} (== ${matches[0]})`);
deletions.push({ path: fp, md5, matchedBase: matches });
survivors.push(record);
return;
}
try {
await fsp.unlink(fp);
console.log(`[DEL] ${fp}`);
deletions.push({ path: fp, md5, matchedBase: matches });
} catch (err) {
failed++;
console.warn(`[WARN] 삭제 실패: ${fp} (${err.message})`);
survivors.push(record);
}
});
try {
saveCache(targetCache, {
target,
generatedAt: new Date().toISOString(),
count: survivors.length,
files: survivors,
});
console.log(`[TARGET] 캐시 저장: ${targetCache}`);
} catch (err) {
console.warn(`[WARN] target 캐시 저장 실패: ${err.message}`);
}
console.log(`\n[결과]`);
console.log(` 스캔된 target 파일 : ${files.length}`);
console.log(` 해시 검증한 파일 : ${checked} (캐시 재사용 ${cachedHits})`);
console.log(` ${dryRun ? '삭제 예정' : '삭제됨'} : ${deletions.length}`);
console.log(` 유지(고유) : ${kept}`);
console.log(` 실패 : ${failed}`);
if (opts.log) {
fs.mkdirSync(path.dirname(opts.log), { recursive: true });
fs.writeFileSync(opts.log, JSON.stringify({
base, target, dryRun,
generatedAt: new Date().toISOString(),
deletions,
}, null, 2));
console.log(` 로그 저장 : ${opts.log}`);
}
}
// ── Main ───────────────────────────────────────────────────────────────────────
(async () => {
const opts = parseArgs(process.argv.slice(2));
console.log('옵션:', {
base: opts.base,
target: opts.target || '(standby — stdin 에서 입력대기)',
cache: opts.cache,
targetCache: opts.targetCache || '(target 입력 시 자동 결정)',
dryRun: opts.dryRun,
noCache: opts.noCache,
rebuildCache: opts.rebuildCache,
concurrency: opts.concurrency,
});
try {
const baseIdx = await buildBaseIndex(opts);
if (opts.target) {
await dedupeTarget(opts, baseIdx);
return;
}
await runInteractive(opts, baseIdx);
} catch (err) {
console.error(`[ERROR] ${err.message}`);
process.exit(1);
}
})();
async function runInteractive(opts, baseIdx) {
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: process.stdin.isTTY === true,
});
console.log('\n=== standby 모드 ===');
console.log('target 절대경로를 한 줄씩 입력하세요. (exit / quit / Ctrl+C 로 종료)');
console.log('명령: "!dry on", "!dry off", "!rebuild on", "!rebuild off", "!status"');
const prompt = () => {
process.stdout.write(`\n[base=${opts.base}] ${opts.dryRun ? '(DRY) ' : ''}target> `);
};
let busy = false;
const queue = [];
const processNext = async () => {
if (busy) return;
const line = queue.shift();
if (line === undefined) { prompt(); return; }
busy = true;
try {
await handleLine(line);
} catch (err) {
console.error(`[ERROR] ${err.message}`);
} finally {
busy = false;
processNext();
}
};
const handleLine = async (raw) => {
let input = raw.trim();
if ((input.startsWith('"') && input.endsWith('"'))
|| (input.startsWith("'") && input.endsWith("'"))) {
input = input.slice(1, -1);
}
if (!input) return;
if (input === 'exit' || input === 'quit') {
rl.close();
return;
}
if (input === '!status') {
console.log(`base=${opts.base}\ndryRun=${opts.dryRun}\nrebuildCache=${opts.rebuildCache}\nnoCache=${opts.noCache}`);
return;
}
if (input.startsWith('!dry ')) {
opts.dryRun = input.endsWith('on');
console.log(`dryRun = ${opts.dryRun}`);
return;
}
if (input.startsWith('!rebuild ')) {
opts.rebuildCache = input.endsWith('on');
console.log(`rebuildCache = ${opts.rebuildCache}`);
return;
}
const targetPath = path.resolve(input);
if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
console.error(`[SKIP] 폴더가 아니거나 존재하지 않음: ${targetPath}`);
return;
}
const jobOpts = Object.assign({}, opts, {
target: targetPath,
targetCache: defaultCachePathFor(targetPath),
});
const t0 = Date.now();
await dedupeTarget(jobOpts, baseIdx);
console.log(`[DONE] ${targetPath} (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
};
rl.on('line', (line) => {
queue.push(line);
processNext();
});
rl.on('close', () => {
console.log('\nstandby 종료.');
process.exit(0);
});
prompt();
}
