git mv로 파일 이동·이름 변경하기: 일반 mv와의 차이, 히스토리 추적, 대량 이동
이 글의 핵심
Git은 이름 변경을 따로 기록하지 않고 커밋 사이의 내용 유사도로 추정합니다. 그래서 git mv와 mv + add의 결과는 같지만, 이동과 내용 수정을 한 커밋에 섞으면 rename으로 인식되지 않을 수 있습니다. 히스토리를 잃지 않는 이동 방법, log --follow와 blame, 대량 이동 스크립트, 대소문자 변경 같은 함정을 정리합니다.
Git에서 파일을 옮길 때 알아야 할 것
프로젝트 리팩토링이나 디렉터리 구조 개편을 할 때, 파일을 옮기거나 이름을 바꾸는 일은 흔합니다. 하지만 잘못 옮기면 Git 히스토리가 끊어진 것처럼 보여서 git blame이나 git log로 변경 이력을 추적하기 어려워집니다.
먼저 알아 둘 핵심은 Git이 이름 변경을 저장하지 않는다는 점입니다. Git의 커밋은 그 시점의 파일 트리 스냅샷일 뿐이라, “A가 B로 이름이 바뀌었다”는 정보는 어디에도 기록되지 않습니다. git status, git log, git diff가 보여 주는 “renamed”는 두 스냅샷을 비교할 때 사라진 파일과 새로 생긴 파일의 내용이 충분히 비슷하면 이름 변경으로 추정한 결과입니다. 이 사실 하나로 이 글의 거의 모든 주의사항이 설명됩니다.
이 글에서는 git mv 명령어를 사용해 파일 히스토리를 보존하면서 안전하게 파일을 관리하는 방법을 다룹니다.
헷갈리기 쉬운 것: mv + git add로도 이동이 가능하지만, git mv가 명시적이고 안전합니다. 특히 대량 파일 이동 시 실수를 줄일 수 있습니다.
실제로 겪은 사례를 하나 들면, 예전에 대형 리팩토링을 하면서 파일 수백 개를 옮기면서 그중 몇 개는 이동과 동시에 내용도 크게 손을 봤던 적이 있습니다. 뒤의 git mv vs mv + git add/rm에서 다루듯 git mv도 내부적으로는 결국 삭제+생성과 동일한 커밋을 만들기 때문에, Git의 rename 감지는 git mv를 썼는지 여부가 아니라 옛 파일과 새 파일의 내용이 얼마나 비슷한가(기본 유사도 임계값 50%)로만 판단합니다. 이동과 내용 수정을 같은 커밋에 섞었더니 유사도가 임계값 아래로 떨어져서 PR에 “삭제 + 새 파일”로 표시됐고, 리뷰어 입장에선 이게 이동인지 완전히 새로 작성한 파일인지 diff만 보고는 구분이 안 됐습니다. 그 뒤로는 이동 커밋과 내용 수정 커밋을 반드시 분리하는 습관이 붙었습니다 — 6번 항목의 “실수 1”이 이론이 아니라 실제로 리뷰를 어렵게 만드는 문제라는 걸 그때 체감했습니다.
일반 mv 대신 git mv를 쓰는 이유
일반 mv의 문제점
# ❌ 잘못된 방법: 일반 mv 사용
mv old-name.js new-name.js
git add new-name.js
git rm old-name.js
git commit -m "Rename file"
흔히 이렇게 하면 히스토리가 끊긴다고 설명하지만, 정확히는 결과 커밋이 git mv로 만든 커밋과 똑같습니다(git mv vs mv + git add/rm 참고). 문제는 명령 자체가 아니라 과정에서 생기는 실수입니다.
git rm을 빠뜨리면 옛 파일이 워킹 트리에서만 사라지고 스테이징에는 남아, 커밋 후에도 저장소에 옛 파일이 그대로 남음git add를 빠뜨리면 새 파일이 untracked로 남아 커밋에서 누락됨- 이동하면서 내용까지 크게 고치면 유사도가 떨어져 Code review에서 삭제 + 새 파일로 표시됨
git mv의 장점
# ✅ 올바른 방법: git mv 사용
git mv old-name.js new-name.js
git commit -m "Rename old-name.js to new-name.js"
장점:
- 삭제와 추가를 한 번에 스테이징해 누락 실수가 없음
- 내용을 바꾸지 않았으므로 Git이 유사도 100%의 이름 변경(rename)으로 인식
git log --follow로 이전 이름의 히스토리도 추적 가능- Code review 시 rename으로 표시되어 가독성 향상
이름 변경·디렉터리 이동 기본 사용법
파일 이름 변경
# 같은 디렉터리에서 이름만 변경
git mv README.md README_OLD.md
# 상태 확인
git status
# renamed: README.md -> README_OLD.md
파일을 다른 디렉터리로 이동
# 디렉터리 생성 (필요시)
mkdir src/utils
# 파일 이동
git mv helper.js src/utils/helper.js
# 여러 파일 한 번에 이동
git mv *.js src/
디렉터리째 이동
# 디렉터리 이름 변경
git mv old-folder/ new-folder/
# 디렉터리를 다른 위치로 이동
git mv components/ src/components/
영문 포스트를 en/ 폴더로 옮긴 구조 개편 사례
시나리오: 영문 포스트를 en/ 폴더로 정리
# 현재 구조
blog/
├── post-1-en.md
├── post-2-en.md
└── post-3.md
# 목표 구조
blog/
├── en/
│ ├── post-1.md
│ ├── post-2.md
└── post-3.md
자동화 스크립트
# move_en_posts.py
import os
import subprocess
blog_dir = 'blog/'
en_dir = os.path.join(blog_dir, 'en')
# en 폴더 생성
os.makedirs(en_dir, exist_ok=True)
# -en.md 파일 찾기
for filename in os.listdir(blog_dir):
if filename.endswith('-en.md'):
old_path = os.path.join(blog_dir, filename)
new_filename = filename.replace('-en.md', '.md')
new_path = os.path.join(en_dir, new_filename)
# git mv 실행
result = subprocess.run(
['git', 'mv', old_path, new_path],
capture_output=True,
text=True
)
if result.returncode == 0:
print(f'✅ Moved: {filename} -> en/{new_filename}')
else:
print(f'❌ Failed: {filename}')
print(f' Error: {result.stderr}')
print('\n커밋하기:')
print('git commit -m "refactor: move English posts to en/ folder"')
실행 결과
$ python move_en_posts.py
✅ Moved: post-1-en.md -> en/post-1.md
✅ Moved: post-2-en.md -> en/post-2.md
커밋하기:
git commit -m "refactor: move English posts to en/ folder"
$ git status
Changes to be committed:
renamed: blog/post-1-en.md -> blog/en/post-1.md
renamed: blog/post-2-en.md -> blog/en/post-2.md
대량 파일 이동은 단계별로
단계별 접근
# 1단계: 백업 브랜치 생성
git checkout -b refactor-file-structure
git checkout -b backup-before-refactor # 안전장치
# 2단계: 작은 단위로 이동 (리뷰 가능하게)
git mv src/components-old/* src/components/
git commit -m "refactor: move components to new structure"
# 3단계: 테스트 실행
npm test
# 4단계: 문제 없으면 다음 단계
git mv src/utils-old/* src/utils/
git commit -m "refactor: move utils to new structure"
주의사항
❌ 한 번에 모든 것을 옮기지 마세요
# 나쁜 예: 수백 개 파일을 한 커밋에
git mv src/* new-src/
git commit -m "refactor: restructure everything"
✅ 논리적 단위로 나누세요
# 좋은 예: 의미 있는 단위로 분리
git mv src/components/* src/ui/components/
git commit -m "refactor: move components to ui folder"
git mv src/api/* src/services/api/
git commit -m "refactor: move API modules to services"
git log —follow로 히스토리 추적하기
git log —follow 옵션
파일 이름이 변경되었을 때 이전 히스토리를 보려면:
# 이름 변경 후에도 전체 히스토리 확인
git log --follow src/utils/helper.js
# 누가, 언제, 왜 수정했는지 확인
git blame src/utils/helper.js
# 이름이 바뀐 커밋만 보기
git log --follow --diff-filter=R src/utils/helper.js
실제 사용 예
$ git log --follow --oneline src/en/nginx-guide.md
a1b2c3d (HEAD) docs: update nginx configuration
e4f5g6h refactor: move English posts to en/ folder # ← 이동 커밋
h7i8j9k docs: add nginx load balancing section
k0l1m2n docs: initial nginx guide
# 이동 전 파일명으로 추적됨!
--follow가 필요한 이유도 앞의 원리와 같습니다. git log <경로>는 기본적으로 그 경로 문자열이 바뀐 커밋만 찾기 때문에, 이동 커밋 이전에는 해당 경로에 파일이 없었던 것으로 보고 거기서 멈춥니다. --follow를 주면 커밋을 거슬러 올라가며 rename을 감지할 때마다 추적하는 경로를 옛 이름으로 바꿔 줍니다. 다만 --follow는 파일 하나에만 쓸 수 있고, 디렉터리 경로나 여러 파일을 주면 동작하지 않습니다. git blame은 별도 옵션 없이도 파일 단위 이름 변경을 따라가며, 코드 블록이 다른 파일로 옮겨진 경우까지 추적하려면 git blame -C를 씁니다. 매번 --follow를 입력하기 번거롭다면 git config --global log.follow true로 파일 하나를 지정했을 때 자동으로 켜지게 할 수 있습니다.
이동과 수정 동시 커밋·대소문자 변경·import 경로 실수
파일 이동과 내용 수정을 동시에
# ❌ 나쁜 예
git mv old.js new.js
# new.js 내용 수정
git commit -m "Rename and refactor old.js"
문제: Git이 이동인지 새 파일인지 헷갈림
Git의 rename 감지는 기본적으로 내용 유사도 50%를 기준으로 합니다. 파일을 옮기면서 절반 넘게 고치면 이동이 아니라 “삭제 + 새 파일”로 기록되고, 이후 git log --follow도 그 지점에서 추적을 멈춥니다. git diff -M30%처럼 임계값을 낮춰 보면 로컬에서는 rename으로 보이게 할 수 있지만, GitHub PR 화면이나 다른 사람의 기본 설정은 바꿀 수 없으므로 커밋을 나누는 것이 근본적인 해결책입니다.
# ✅ 좋은 예
git mv old.js new.js
git commit -m "Rename old.js to new.js"
# 별도 커밋으로 수정
# new.js 내용 수정
git commit -m "Refactor new.js logic"
대소문자만 변경 (case-only rename)
# ⚠️ 주의 (대소문자를 구분하지 않는 macOS/Windows 파일 시스템)
git mv README.md readme.md
# 오래된 Git 버전이나 일부 GUI 도구에서는 실패할 수 있음
해결법:
# ✅ 임시 이름 사용
git mv README.md temp.md
git mv temp.md readme.md
git commit -m "Rename README.md to lowercase"
대소문자 변경이 까다로운 이유는 macOS(APFS 기본 설정)와 Windows(NTFS) 파일 시스템이 README.md와 readme.md를 같은 파일로 취급하기 때문입니다. 이런 환경에서 Git은 core.ignorecase=true로 동작하므로, 탐색기나 Finder에서 이름만 바꾸면 Git은 변경 사항이 없다고 판단합니다. 그 상태로 push하면 저장소에는 옛 이름이 남고, 대소문자를 구분하는 Linux CI 서버에서 import './readme' 같은 경로가 “Module not found”로 실패합니다. 로컬 Mac에서는 잘 되는데 CI에서만 파일을 못 찾는다면 가장 먼저 의심할 원인입니다. 최근 Git은 git mv로 대소문자만 바꾸는 것을 처리하지만, 임시 이름을 거치는 방법은 어떤 환경에서도 안전합니다.
이동 후 import 경로 미수정
# 파일 이동
git mv src/utils.js src/lib/utils.js
# ❌ import 경로를 안 고침
// other-file.js
import { helper } from './utils'; // 404!
해결법: 이동 후 import 경로 일괄 수정
# 모든 import 경로 찾기
grep -r "from './utils'" src/
# 또는 IDE의 "Find and Replace" 사용
이 실수는 대규모 자동화 스크립트를 돌릴 때 특히 조심해야 합니다. 위쪽의 move_en_posts.py나 reorganize_project.py 같은 스크립트로 파일 수십~수백 개를 한 번에 옮기면, grep -r로 옛 경로를 찾는 것만으로는 상대 경로(../utils, ./helper 등)가 파일의 새 위치에 따라 제각각 다르게 계산돼야 한다는 걸 놓치기 쉽습니다. 저는 자동화 스크립트에 “이동”까지만 넣고 “import 경로 재계산”은 빼먹었다가, 빌드는 통과했는데(TypeScript가 일부 경로를 다른 유효한 모듈로 잘못 해석해버린 경우) 런타임에서야 엉뚱한 모듈이 로드되는 걸 발견한 적이 있습니다. 이동 스크립트를 짤 때는 이동 로직과 경로 재작성 로직을 같이 설계하고, 이동 직후 반드시 전체 빌드 + 타입체크를 돌려서 확인하는 걸 습관으로 삼는 게 안전합니다.
git mv vs mv + git add/rm
내부적으로는 동일
# 다음 두 가지는 결과적으로 같음
git mv old.js new.js
# = 다음과 동일
mv old.js new.js
git add new.js
git rm old.js
그럼에도 git mv를 쓰는 이유
- 명시적: 의도가 명확히 드러남
- 안전: 파일 존재 여부 자동 확인
- 원자적: 한 번에 처리되어 실수 방지
- 리뷰 친화적: PR에서 rename으로 표시
# GitHub PR에서
git mv old.js new.js
# → "renamed: old.js → new.js" (간결)
mv old.js new.js && git add . && git rm old.js
# → deleted old.js + created new.js (복잡)
Bash·Python 대량 이동 스크립트
Bash 스크립트
#!/bin/bash
# move_to_src.sh - 루트의 .js 파일을 src/로 이동
# src 디렉터리 생성
mkdir -p src
# .js 파일 찾아서 이동
for file in *.js; do
if [ -f "$file" ]; then
echo "Moving $file to src/"
git mv "$file" "src/$file"
fi
done
echo "Done! Review changes:"
git status
Python 스크립트 (더 복잡한 로직)
#!/usr/bin/env python3
# reorganize_project.py
import os
import subprocess
from pathlib import Path
def git_mv(old, new):
"""git mv 실행 및 에러 핸들링"""
# 부모 디렉터리 생성
os.makedirs(os.path.dirname(new), exist_ok=True)
result = subprocess.run(
['git', 'mv', old, new],
capture_output=True,
text=True
)
if result.returncode == 0:
return True, f"✅ {old} → {new}"
else:
return False, f"❌ {old}: {result.stderr.strip()}"
def main():
# 이동 규칙 정의
rules = {
'components': 'src/ui/components',
'utils': 'src/lib/utils',
'hooks': 'src/lib/hooks',
}
moved = []
failed = []
for old_dir, new_dir in rules.items():
if not os.path.exists(old_dir):
continue
for file in Path(old_dir).glob('**/*.js'):
old_path = str(file)
new_path = old_path.replace(old_dir, new_dir, 1)
success, msg = git_mv(old_path, new_path)
if success:
moved.append(msg)
else:
failed.append(msg)
# 결과 출력
print(f"\n✅ Successfully moved: {len(moved)}")
for msg in moved[:10]: # 처음 10개만
print(f" {msg}")
if len(moved) > 10:
print(f" ... and {len(moved) - 10} more")
if failed:
print(f"\n❌ Failed: {len(failed)}")
for msg in failed:
print(f" {msg}")
print("\n다음 단계:")
print(" 1. git status로 확인")
print(" 2. 테스트 실행")
print(" 3. git commit -m 'refactor: reorganize project structure'")
if __name__ == '__main__':
main()
실행 예
$ python reorganize_project.py
✅ Successfully moved: 45
✅ components/Button.js → src/ui/components/Button.js
✅ components/Input.js → src/ui/components/Input.js
... and 43 more
다음 단계:
1. git status로 확인
2. 테스트 실행
3. git commit -m 'refactor: reorganize project structure'
bad source·destination exists 같은 에러 대응
문제: “fatal: bad source”
$ git mv nonexistent.js new.js
fatal: bad source, source=nonexistent.js, destination=new.js
원인: 원본 파일이 존재하지 않음
해결:
# 파일 존재 확인
ls -la nonexistent.js
# Git 추적 여부 확인
git ls-files | grep nonexistent.js
문제: “destination exists”
$ git mv old.js new.js
fatal: destination exists, source=old.js, destination=new.js
해결:
# 강제로 덮어쓰기 (-f 옵션)
git mv -f old.js new.js
# 또는 대상 파일을 먼저 백업
git mv new.js new.js.bak
git mv old.js new.js
문제: 파일이 Git에 추적되지 않은 상태
$ git mv modified.js new.js
fatal: not under version control, source=modified.js
이 에러는 수정되었지만 스테이징하지 않은 파일이 아니라, 한 번도 git add된 적 없는(untracked) 파일에 git mv를 쓸 때 납니다. 이미 추적 중인 파일이라면 수정 사항이 있어도 git mv는 정상 동작하고, 수정 내용은 새 경로에 그대로 따라갑니다.
해결:
# 먼저 Git이 추적하게 만들기 (untracked 파일)
git add modified.js
git mv modified.js new.js
# 또는 아직 추적할 필요가 없는 파일이면 일반 mv 사용
문제: 대량 이동 후 rename이 감지되지 않음
수천 개 파일을 한 커밋에서 옮기면 git status나 git diff에서 “warning: exhaustive rename detection was skipped due to too many files.” 또는 “inexact rename detection was skipped due to too many files.” 경고와 함께 이동이 삭제 + 추가로 표시될 수 있습니다. rename 감지는 사라진 파일과 새 파일을 짝지어 내용을 비교해야 해서 비용이 크기 때문에, Git은 diff.renameLimit을 넘는 경우 정확한 비교를 건너뜁니다. 경고에 안내된 대로 git config diff.renameLimit 5000처럼 값을 올리면 로컬 표시는 해결되지만, 호스팅 서비스의 PR 화면에는 자체 한도가 있으므로 앞에서 권한 대로 커밋을 논리적 단위로 나누는 것이 리뷰에도 유리합니다.
이동 전·중·후 점검 목록
✅ 파일 이동 전
- 현재 브랜치가 최신인지 확인 (
git pull) - 작업 중인 변경사항 커밋 또는 stash
- 백업 브랜치 생성 (대규모 리팩토링 시)
- 이동 계획 문서화 (팀 공유)
✅ 이동 중
-
git mv명령어 사용 - 논리적 단위로 나눠서 이동
- 각 단계마다 커밋 메시지 명확히
- 테스트가 통과하는지 확인
✅ 이동 후
- Import 경로 수정
- 빌드/테스트 실행
-
git log --follow로 히스토리 확인 - PR에서 rename으로 표시되는지 확인
- 문서 (README 등) 업데이트
커밋 메시지 예시
# 단순 이동
git commit -m "refactor: move components to src/ui/"
# 이동 + 이유
git commit -m "refactor: move English posts to en/ folder
- Separate English and Korean content
- Simplify path detection logic
- Prepare for i18n structure"
# 대규모 리팩토링
git commit -m "refactor: reorganize project structure (1/3)
Part 1: Move UI components
- components/ → src/ui/components/
- 45 files affected
- All tests passing"
git mv 사용 요약
핵심 요약
| 명령어 | 용도 | 예시 |
|---|---|---|
git mv old new | 파일 이름 변경 | git mv README.md README_v2.md |
git mv file dir/ | 파일 이동 | git mv app.js src/app.js |
git mv dir1/ dir2/ | 디렉터리 이동 | git mv old/ new/ |
git log --follow | 이동 후 히스토리 추적 | git log --follow src/new.js |
기억할 점
- git mv 사용: 삭제·추가 누락 실수를 막는 가장 간단한 방법
- 이동과 수정 분리: 리뷰어를 배려
- 논리적 단위로 커밋: 되돌리기 쉽게
- 테스트 후 커밋: 빌드 깨짐 방지
같이 보면 좋은 글
- Git 기초 입문 [#1] — 설치·커밋·브랜치·원격 저장소 한 번에
- 팀 Git 워크플로우
- Git rebase interactive 사용법 | pick·squash·fixup·충돌 해결·실수 복구
- Git으로 협업하기 — 브랜치 전략