diff old.csv new.csv는 엉뚱한 질문에 답합니다. 줄 단위로 비교하기 때문에 정렬 순서만 다른 파일은 "전부 바뀐 것"으로 나오고, 필드 하나만 고친 행은 변경이 아니라 삭제 한 줄과 추가 한 줄로 나옵니다.
필요한 것은 키 기준 비교입니다. 새로 생긴 행은 무엇인지, 사라진 행은 무엇인지, 양쪽에 모두 있는 행은 어떤 필드가 다른지 알아야 합니다. 질문이 세 가지이니 답도 세 가지가 필요합니다.
먼저 구조부터 확인하기
내용을 비교하기 전에 두 파일을 비교할 수 있는 상태인지 확인합니다.
head -n 1 old.csv new.csv
wc -l old.csv new.csv
비교 결과가 엉망이 되는 가장 흔한 원인은 헤더 변경입니다. 두 내보내기 사이에 열이 추가되거나 이름이 바뀌거나 순서가 달라졌다면, 그 점을 반영하기 전까지는 필드 단위 비교가 모두 의미가 없습니다. 두 번째 신호는 행 수입니다. 3행 차이는 평범한 한 주이고, 4만 행 차이라면 대개 한쪽 내보내기가 잘렸거나 필터링된 것입니다.
SQL로 하는 세 가지 질문 비교
먼저 키를 고릅니다. 두 파일에서 같은 레코드를 식별하는 열입니다. id, order_id, email 같은 열이 해당합니다. 키가 없다면 마지막 섹션을 보세요.
새 파일에만 있는 행:
duckdb -c "SELECT * FROM read_csv('new.csv') n WHERE n.id NOT IN (SELECT id FROM read_csv('old.csv'))"
이전 파일에만 있는 행:
duckdb -c "SELECT * FROM read_csv('old.csv') o WHERE o.id NOT IN (SELECT id FROM read_csv('new.csv'))"
양쪽에 있으면서 필드가 바뀐 행:
duckdb -c "SELECT o.id, o.status old_status, n.status new_status FROM read_csv('old.csv') o JOIN read_csv('new.csv') n USING (id) WHERE o.status IS DISTINCT FROM n.status"
중요한 점은 <> 대신 IS DISTINCT FROM을 쓰는 것입니다. <>는 어느 한쪽이 NULL이면 NULL을 반환하므로, 비어 있던 필드가 채워졌거나 채워져 있던 필드가 비워진 경우가 결과에서 조용히 빠집니다. 찾으려던 변경이 바로 그런 경우일 가능성이 가장 큽니다.
열 이름을 일일이 적지 않고 모든 열의 변경을 한 번에 잡으려면 이렇게 합니다.
duckdb -c "SELECT * FROM read_csv('old.csv') EXCEPT SELECT * FROM read_csv('new.csv')"
EXCEPT는 행 전체를 비교하고 순서를 무시하므로 diff보다 훨씬 나은 diff입니다. 행이 바뀌었다는 사실만 알려 주고 어느 필드인지는 알려 주지 않으니, 어떤 행을 볼지 알게 되면 위의 조인과 함께 쓰세요.
파일의 정렬 순서만 다를 때
내용이 같은지만 알면 된다면 정렬한 뒤 해시를 구합니다.
tail -n +2 old.csv | sort | md5sum
tail -n +2 new.csv | sort | md5sum
해시가 같으면 행 순서와 상관없이 내용이 같습니다. 해시가 다르면 무엇이 바뀌었는지는 알 수 없지만, 1초면 끝나고 "이게 같은 내보내기인가?"라는 의문이 풀립니다. 이 의문이 조사의 전부인 경우도 많습니다.
순서가 바뀌어도 견디는 줄 단위 diff는 다음과 같습니다.
diff <(sort old.csv) <(sort new.csv)
여전히 줄 단위라서 필드 하나의 변경은 한 쌍으로 보이지만, 적어도 정렬 순서가 출력을 뒤덮지는 않습니다.
전용 도구
csvdiff와 daff는 둘 다 키로 비교하며, 두 줄을 보여 주는 대신 "41번 행에서 status 열이 A에서 B로 바뀜"처럼 출력합니다. 자주 하는 작업이라면 둘 중 하나를 설치하세요. daff diff old.csv new.csv --output diff.html을 실행하면 색이 입혀진 표가 만들어져 한눈에 읽을 수 있으며, 위의 어떤 방법도 그렇게는 되지 않습니다.
Git은 파일을 버전 관리하고 있을 때 쓸 수 있습니다. .gitattributes에 CSV diff 드라이버를 지정하면 데이터 파일에 대한 git diff가 쓸모없는 것에서 쓸 만한 것으로 바뀌며, 고칠 곳은 두 줄뿐입니다.
Excel은 솔직히 이 작업에 서툽니다. 열마다 다른 시트로 VLOOKUP을 거는 흔한 방식은 작은 파일에서는 되지만, 큰 파일에서는 연산량이 제곱으로 늘고 #N/A로 가득한 표가 나와 읽기 어렵습니다. Power Query의 병합이 훨씬 낫지만 거의 아무도 쓰지 않습니다.
휴대폰에서 차이 읽기
현실에서 흔한 상황은 일주일 간격의 내보내기 두 개가 이메일에 와 있고, 알고 싶은 것이 "대조 보고서 작성"이 아니라 "무엇이 바뀌었나"인 경우입니다.
실용적인 방법은 diff가 아닙니다. 출처를 표시하는 열을 추가하고, 두 파일을 이어 붙인 뒤, 키로 정렬해서 쌍으로 읽습니다. 각 레코드의 두 버전이 이제 나란히 붙어 있으니 바뀐 필드가 옆에 놓여 보입니다. 수십 행 정도라면 어떤 도구보다 빠릅니다.
파일에 직접 SQL을 쓰는 것으로 비교는 끝납니다. 키 기준 조인과 IS DISTINCT FROM이 바뀐 필드를 찾고, SQL로 쓰기보다 말로 묻고 싶은 질문은 AI 어시스턴트가 처리합니다.
기능숫자, 날짜, 그리고 가짜 차이
비교하다 보면 실제로는 변경이 아닌 "변경"이 대량으로 나옵니다. 조사하기 전에 먼저 걸러 내세요.
- 끝에 붙은 공백.
"active"와"active "는 다른 값입니다. 공백이 의미를 갖는 경우가 아니라면trim()한 값으로 비교하세요. 공백이 의미를 갖는 경우는 거의 없습니다. - 숫자 서식.
1000,1,000,1000.00은 숫자 하나이면서 문자열 셋입니다. 비교 전에 양쪽을 숫자형으로 변환하세요. - 날짜 형식.
01/02/2026과2026-02-01의 차이, 그리고 더 나쁜 경우로 같은01/02/2026이 한 파일에서는 2월, 다른 파일에서는 1월을 뜻하는 경우가 있습니다. 텍스트로 비교하지 말고 양쪽을 날짜로 파싱하세요. - 부동소수점 오차.
0.1 + 0.2는 어떤 언어에서도0.3이 아닙니다. 소수를 비교하기 전에 의미 있는 자릿수로 반올림하세요. - 인코딩. 한 파일에서는
José, 다른 파일에서는José로 보이는 이름은 인코딩 차이이지 데이터 변경이 아닙니다.
이런 문제는 큰 파일에서 각각 수천 건의 차이를 만들어 내며, 비교 결과가 "전부 바뀐 것으로 표시"되는 것도 이 때문입니다.
비교가 수정으로 이어질 때, 예를 들어 열을 정규화하거나 소수를 반올림하거나 실제로 바뀐 행을 고칠 때는 수식 열이 모든 행에 적용되고, 결과는 CSV 안에 그대로 저장됩니다.
기능키가 없을 때
로그 내보내기 두 개, 거래 목록 두 개처럼 식별자가 없는 파일도 있습니다. 선택지는 둘이며 둘 다 타협입니다.
복합 키 만들기. 함께 한 행을 식별하는 열들을 조합합니다. 예를 들어 날짜, 금액, 설명입니다. 조합이 정말 고유하면 잘 작동하지만, 그렇지 않으면 조용히 실패합니다. 같은 날의 동일한 거래 두 건이 하나로 합쳐지고, 비교는 존재하지 않는 삭제를 보고합니다.
다중집합으로 비교하기. 각 파일에서 서로 다른 행 각각의 등장 횟수를 세고, 그 횟수를 비교합니다. 같은 행이 정당하게 여러 번 나타나고 의미 있는 변경이 몇 번 나타나느냐인 로그나 거래 데이터에는 이 방법이 맞습니다.
duckdb -c "SELECT *, count(*) n FROM read_csv('old.csv') GROUP BY ALL"
두 파일에 모두 실행해서 n 열을 비교하세요. 중복이 많은 데이터를 정확하게 처리하는 방법은 이것뿐입니다.