2025년 7월 15일 화요일

[2025-07-15] Mac, Window 파일 조합형식 차이 (Mac : NFD, Window: NFC)로 삽질한 썰!

Mac, Window 파일 조합형식 차이 (Mac : NFD, Window: NFC)

안녕하세요. 클스 입니다.


문제 상황

- 저는 회사에서 맥을 사용하고, 다른 사람은 윈도우를 사용합니다. 회사에 공유서버는 있어서 서로 공유서버를 통해 파일을 공유합니다.

- AI Bot를 만들고 있는데, 회사의 문서를 학습하여 질문에 답변을 제공하는 기능입니다.

- 문서(docx, pdf, txt)를 가져와서 Vector Store에 학습하는데, 문서가 자주 변하니 필요한 문서만 학습하도록 하기 위해, 로컬 폴더를 SQLite로 DB활르 했습니다.

- 폴더를 읽어 문서를 files 테이블에 넣고 이렇게 검색하니 잘 나왔습니다.

SELECT *

FROM vector_store_files vs

LEFT JOIN vector_store_files f ON vs.vs_id = f.vs_id

WHERE LOWER(f.file_name) LIKE '%취업%'

;


- 취업에 관련된 문서가 전부 나왔는데, 취업.doc 파일이 보이기에, 저는 맥에서 워드를 열어서 docx로 저장했습니다.


- 그리고 개발한 update_vector_store_file 을 이용해 취업.doc 파일을 취업.docx 파일로 교체해서 vector store에 추가했습니다. 물론 DB도 업데이트가 되었겠죠?


- 확인해보려고 아래 쿼리를 실행하니 결과가 나오지 않습니다. 파일이 안올라갔나? 내가 쿼리를 잘못 만들었나? 데이터 소스를 잘못 설정했나? 갖가지 고민을 했습니다.


SELECT *

FROM vector_store_files vs

LEFT JOIN vector_store_files f ON vs.vs_id = f.vs_id

WHERE LOWER(f.file_name) LIKE '%취업%'

;


- 그래서 docx 파일을 Remote Desktop으로 윈도우에 복사, 붙혀넣기를 해보니 ㅊ ㅜ ㅣ ㅇ ㅓ ㅂ.docx  두둥 

까맣게 잊고 있던 NFC, NFD 조립형식의 문제인걸 알았습니다.


- 아무생각없이 맥으로 열어서 저장하면 NFC --> NFD 형식으로 변경되어 겉으로 보기엔 같지만, 내부 저장되는 파일명은 달라지는 것이었습니다. 


해결 방법:


아래와 같이 체크하고, 변환하는 함수를 만들어, vector store에 올리기 전에 NFD -> NFC로 변환했습니다.

일관성만 유지하면 되는데.. 왜 NFC(윈도우)로 했느냐? 이유는 다수가 윈도우를 사용하기 때문입니다.


import unicodedata

#######################################################
# 유틸리티 함수들
#####################################################
def _check_normalization(text: str) -> str:
"""
주어진 문자열의 유니코드 정규화 형식을 판별합니다.

Args:
text (str): 확인할 문자열

Returns:
str: 'NFC', 'NFD', 또는 'Neither' 중 하나를 반환합니다.
ASCII 문자열처럼 NFC와 NFD 형식이 동일한 경우 'NFC'를 우선 반환합니다.
"""
if text == unicodedata.normalize('NFC', text):
return 'NFC'
elif text == unicodedata.normalize('NFD', text):
return 'NFD'
else:
return 'Neither'

def normalize_filenames_in_directory(root_dir: str, dry_run: bool = True):
"""
지정된 디렉토리와 그 하위의 모든 파일/폴더명 중 NFD 형식인 것을
NFC 형식으로 재귀적으로 변경합니다.

Args:
root_dir (str): 시작할 최상위 디렉토리 경로.
dry_run (bool): True이면 실제 변경 없이 시뮬레이션만 실행합니다.
"""
if not os.path.isdir(root_dir):
print(f"오류: '{root_dir}'는 유효한 디렉토리가 아닙니다.")
return

if dry_run:
print("--- [Dry Run] 모드로 실행합니다. 실제 파일명은 변경되지 않습니다. ---")
else:
print("--- [Live] 모드로 실행합니다. 실제 파일명이 변경됩니다. ---")
user_input = input(f"'{root_dir}' 내부의 모든 파일/폴더명을 변경합니다. 계속하시겠습니까? (yes/no): ")
if user_input.lower() != 'yes':
print("작업을 취소했습니다.")
return

# 깊이 우선 탐색을 위해 os.walk의 topdown=False 사용
# 이렇게 하면 하위 디렉토리부터 처리하여 경로 문제를 방지할 수 있습니다.
for dirpath, dirnames, filenames in os.walk(root_dir, topdown=False):

# 1. 파일명 변경
for filename in filenames:
# NFD 형식인지 확인
if filename != unicodedata.normalize('NFC', filename):
new_filename = unicodedata.normalize('NFC', filename)
old_path = os.path.join(dirpath, filename)
new_path = os.path.join(dirpath, new_filename)

# 이름 충돌 처리
counter = 1
base_name, extension = os.path.splitext(new_filename)
while os.path.exists(new_path):
new_filename_with_suffix = f"{base_name}_normalized_{counter}{extension}"
new_path = os.path.join(dirpath, new_filename_with_suffix)
counter += 1

print(f"[파일 변경] '{old_path}' -> '{new_path}'")
if not dry_run:
try:
os.rename(old_path, new_path)
except OSError as e:
print(f" ㄴ 오류: 파일명 변경 실패 - {e}")

# 2. 디렉토리명 변경
for dirname in dirnames:
if dirname != unicodedata.normalize('NFC', dirname):
new_dirname = unicodedata.normalize('NFC', dirname)
old_path = os.path.join(dirpath, dirname)
new_path = os.path.join(dirpath, new_dirname)

# 이름 충돌 처리
counter = 1
while os.path.exists(new_path):
new_path = os.path.join(dirpath, f"{new_dirname}_normalized_{counter}")
counter += 1

print(f"[폴더 변경] '{old_path}' -> '{new_path}'")
if not dry_run:
try:
os.rename(old_path, new_path)
except OSError as e:
print(f" ㄴ 오류: 폴더명 변경 실패 - {e}")

print("--- 모든 작업이 완료되었습니다. ---")




물론 일괄적으로 바꿀수도 있고, vector store에 파일을 올릴때 파일경로, 파일명을 변환해서 올려도 됩니다.

실제 적용은 이 방법으로 했습니다. 


많은 것을 머리에 담고 이런 상황을 사전에 인지하고 있었다면 쉽게 해결되었겠지만,

익숙함에 이런건 다 잊어 먹고 살아 가네요.


당연한 말이겠지만, 검색어를 nfc, nfd 두개를 만들어서 SQL에 or 로 둘다 조회하면 된다.


감사합니다.



라벨: , , , , ,