진짜 파일을 읽는다 — 인코딩 · 폴더 · 환경변수 (해설영상 제공 예정)
Article
지금까지는 문서를 코드 안에 문자열로 적어뒀다. 연습이라 그랬고, 실제로는 폴더에 파일이 쌓여 있다.
이번 강의에서 그 파일을 읽는작업을 진행한다.
읽는 것 자체는 두 줄이면 끝나지만 한글 인코딩 방식 문제 때문에 반드시 한 번은 화면이 깨지는 경험을 하게될 것이다.
이번 시간에는 해당 이슈사항을 자세히 알아보자.
읽을 파일부터 직접 생성
# import 는 "남이 만들어둔 기능 묶음을 가져다 쓰겠다"는 선언이다.
# os 는 운영체제의 기능을 제어하는 묶음이다 - 폴더 만들기, 파일 목록 보기, 환경변수 읽기.
# 파이썬에 기본 포함이라 따로 설치할 게 없다. 보통 파일 맨 위에 모아 적는 것이 관례다.
import os
# 점(.)은 "os 묶음 안의" 라는 뜻이다. os 에 든 makedirs 를 호출한다.
# exist_ok=True 가 없으면 폴더가 이미 있을 때 에러가 난다.
os.makedirs("docs", exist_ok=True)
files = {
"backend.txt": "수업용 예제\n백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.",
"frontend.txt": "수업용 예제\n프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다. 리액트와 뷰가 많이 쓰인다.",
"data.txt": "수업용 예제\n데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 데이터베이스와 파이프라인을 다루고 파이썬과 SQL 을 쓴다.",
}
# 딕셔너리를 그냥 돌면 이름만 나온다. .items() 를 붙여야 이름과 값을 짝으로 준다.
# 그래서 받는 이름도 두 개다 - name 에 파일명, content 에 내용.
for name, content in files.items():
# with 를 쓰면 이 블록이 끝날 때 파일이 자동으로 닫힌다.
# "docs/파일명" f-string 으로 경로를 만든다
# "w" 쓰기 모드. 파일이 있으면 내용을 지우고 새로 쓴다
# encoding= 오늘의 핵심. 잠시 뒤에 이걸 빼면 어떻게 되는지 본다
with open(f"docs/{name}", "w", encoding="utf-8") as f:
f.write(content) # f 는 열린 파일이다. 거기에 글자를 써 넣는다
print(sorted(os.listdir("docs"))) # 그 폴더에 뭐가 있는지 이름 목록으로 반환한다
- ['backend.txt', 'data.txt', 'frontend.txt']
ai-course 폴더 안에 docs 폴더가 생기고 그 안에 파일 세 개가 들어 있다. VS Code 왼쪽 탐색기에서 직접 열어 확인해본다.
- 01
import os
폴더를 만들고 목록을 보는 기능은
os라는 묶음에 들어 있다. 파이썬에 기본 포함이라 설치할 게 없다. - 02
os.makedirs("docs", exist_ok=True)
exist_ok=True가 없으면 폴더가 이미 있을 때 에러가 난다. 두 번째 실행에서 터지는 코드가 되면 곤란하다. - 03
files.items()
딕셔너리를 돌면 이름만 나온다.
.items()를 붙여야 이름과 값을 짝으로 준다. - 04
with open(...) as f:
with를 쓰면 블록이 끝날 때 파일이 자동으로 닫힌다. 안 닫으면 내용이 끝까지 안 써지는 경우가 있다. 파일은 항상 `with` 으로 연다. - 05
encoding="utf-8"
이번 강의의 핵심이다. 잠시 뒤에 이걸 빼면 어떻게 되는지 본다.
파일 한 개 읽기
# "r" 은 읽기 모드다. 쓸 때와 달리 파일 내용을 건드리지 않는다.
with open("docs/backend.txt", "r", encoding="utf-8") as f:
text = f.read() # 파일 전체를 문자열 하나로 읽어 온다
print(len(text))
print(text[:20]) # 2강의 슬라이싱. 앞 20글자만 확인
print(repr(text[:20])) # repr 로 감싸면 줄바꿈이 \n 으로 보인다
- 95
- 수업용 예제
- 백엔드 개발자는 서버와
- '수업용 예제\n백엔드 개발자는 서버와 '
앞 20글자를 찍었는데 두 줄로 나왔다. 안에 줄바꿈이 들어 있기 때문이다. repr() 로 감싸면 그게 \n 이라는 글자 하나로 보인다.
한글이 깨지는 자리
이제 encoding 을 빼고 똑같이 해본다. 한글이 깨지는 경우를 확인할 수 있다.
# 일부러 encoding 을 뺀다.
# 안 주면 파이썬이 운영체제 기본값을 쓰는데, 한국어 Windows 에서는 그게 cp949 다.
with open("docs/backend.txt", "r") as f:
text = f.read()
print(text[:20])
- Traceback (most recent call last):
- File "...\ai-course\42_인코딩에러.py", line 4, in <module>
- text = f.read()
- UnicodeDecodeError: 'cp949' codec can't decode byte 0xec in position 0: illegal multibyte sequence
utf-8
전 세계 표준. 우리가 저장한 방식
- 한글 한 글자를 3바이트로 적는다
- 한글·영어·이모지 다 담긴다
- 요즘 만들어지는 파일은 거의 이쪽
cp949
한국어 Windows 의 옛 기본값
- 한글 한 글자를 2바이트로 적는다
- utf-8 로 적힌 걸 이 방식으로 읽으면 어긋난다
- 메모장에서 만든 옛 파일이 이쪽인 경우가 있다
폴더 전체 읽기 — glob
만약 읽어야 할 파일이 많을때 일일이 모든 파일을 직접 읽는 건 상당히 비효율적이다. 이때 glob 패키지를 활용하면 특정 조건에 맞는 파일을 한번에 찾을 수 있다.
import os
# glob 은 "특정 규칙 파일명의 파일을 전부 찾아주는" 패키지이다.
# 이것도 파이썬의 기본 내장 기능이다.
import glob
# * 는 "아무 글자나 몇 개든" 이라는 뜻이다. docs 폴더의 txt 파일 전부.
# 패키지 이름과 함수 이름이 둘 다 glob 라 glob.glob 가 된다. 오타가 아니다.
paths = glob.glob("docs/*.txt")
print(paths)
print(paths[0]) # 하나만 찍으면 역슬래시가 하나로 보인다
# sorted 로 감싸면 순서가 운영체제와 상관없이 항상 같아진다
for path in sorted(paths):
print(os.path.basename(path)) # 경로에서 파일 이름만 떼어낸다
- ['docs\\backend.txt', 'docs\\data.txt', 'docs\\frontend.txt']
- docs\backend.txt
- backend.txt
- data.txt
- frontend.txt
조용한 에러발생을 막는다
폴더 이름을 틀리면 어떻게 될까. 에러가 아니라 빈 리스트가 나온다. 이게 더 위험하다. 프로그램은 멀쩡히 돌고 검색 결과만 계속 0건이라 실제 에러 원인을 찾느라 한참 헤맨다.
그러니 이런 이슈는 알아차릴 수 있게 미리 안전장치 만들어 두는게 좋다.
import os
import glob
def load_documents(folder=None):
"""폴더 안 txt 를 전부 읽는다. folder 를 안 주면 환경변수 DOCS_DIR, 그것도 없으면 docs."""
# A or B 는 "A 가 참이면 A, 아니면 B" 를 준다.
folder = folder or os.getenv("DOCS_DIR", "docs")
paths = sorted(glob.glob(f"{folder}/*.txt"))
# 빈 리스트·빈 문자열·0 은 파이썬에서 거짓이다. not paths 는 "비었으면" 이라는 뜻.
if not paths:
print(f"[경고] '{folder}' 에서 txt 파일을 못 찾았다.")
return [] # 여기서 함수가 끝난다. 아래는 실행되지 않는다
result = []
for path in paths:
# try 안에서 그 에러가 나면 프로그램이 죽는 대신 except 로 넘어간다.
# 잡을 에러 이름을 반드시 적는다 - 그냥 except: 로 다 잡으면
# 정작 봐야 할 문제까지 삼켜버린다.
try:
with open(path, "r", encoding="utf-8") as f:
result.append({"source": os.path.basename(path), "text": f.read()})
except UnicodeDecodeError:
print(f"[건너뜀] {path} - utf-8 이 아니다")
# 이 한 줄이 없으면 "0건인데 문제의 상태"를 알아챌 수 없다.
print(f"문서 {len(result)}건을 읽었다 (폴더: {folder})")
return result
docs = load_documents("doc") # 일부러 틀린 폴더 이름
docs = load_documents() # 제대로 된 이름
print(len(docs))
print(docs[0]["source"])
print(docs[0]["text"][:20])
- [경고] 'doc' 에서 txt 파일을 못 찾았다.
- 문서 3건을 읽었다 (폴더: docs)
- 3
- backend.txt
- 수업용 예제
- 백엔드 개발자는 서버와
3강에서 만든 딕셔너리가 여기서 다시 나왔다. 조각마다 「어느 파일에서 왔는지」를 들고 다녀야 나중에 답의 근거를 보여줄 수 있다.
- 01
if not paths:
빈 리스트·빈 문자열·0 은 파이썬에서 거짓이다.
if not paths는 「비었으면」이라는 뜻. 1강에서 본 그 성질이다. - 02
try / except UnicodeDecodeError
try안에서 그 에러가 나면 프로그램이 죽는 대신except로 넘어간다. 어떤 에러를 잡을지 이름을 적는다. 그냥except:로 다 잡으면 정작 봐야 할 문제까지 삼켜버린다. - 03
몇 건 읽었는지 찍는다
한 줄이지만 이게 없으면 「0건인데 조용한 상태」를 알아챌 수 없다. 실무 코드에 로그가 많은 이유가 이것이다.
- 04
sorted 로 감싼다
glob이 주는 순서는 운영체제마다 다를 수 있다. 순서가 매번 바뀌면 「3번 조각」이 실행할 때마다 다른 내용이 된다. 누가 돌려도 같아야 결과를 비교할 수 있다.
경로를 코드에 적지 않는다
방금 함수에 이미 들어가 있던 줄이다.
folder = folder or os.getenv("DOCS_DIR", "docs")
이 과정에서 만드는 것은 「문서를 바꾸면 다른 서비스가 되는」 시스템이다. 직무 가이드를 넣으면 취업 상담이 되고, 안전 매뉴얼을 넣으면 설비 도우미가 된다.
import os
import glob
def load_documents(folder=None):
"""폴더 안 txt 를 전부 읽는다."""
folder = folder or os.getenv("DOCS_DIR", "docs")
paths = sorted(glob.glob(f"{folder}/*.txt"))
if not paths:
print(f"[경고] '{folder}' 에서 txt 파일을 못 찾았다.")
return []
result = []
for path in paths:
try:
with open(path, "r", encoding="utf-8") as f:
result.append({"source": os.path.basename(path), "text": f.read()})
except UnicodeDecodeError:
print(f"[건너뜀] {path} - utf-8 이 아니다")
print(f"문서 {len(result)}건을 읽었다 (폴더: {folder})")
return result
# os.environ 은 환경변수가 담긴 딕셔너리다. 딕셔너리처럼 이름으로 넣고 꺼낸다.
# 원래는 터미널이나 .env 파일에서 정하지만, 지금은 확인용으로 코드에서 넣는다.
os.environ["DOCS_DIR"] = "docs"
load_documents() # 인자를 안 줬으니 DOCS_DIR 을 읽는다
- 문서 3건을 읽었다 (폴더: docs)
- 함수를 부를 때 준 값load_documents("other") — 가장 우선
- 환경변수 DOCS_DIR터미널이나 .env 에서 정한 값
- 기본값 docs아무것도 없을 때
파일로 나눈다 — import
지금까지는 파일마다 같은 함수를 다시 적었다. 이젠 함수를 범용적으로 등록하고 재사용해보겠다.
아래는 실행용이 아니라 보관용 파일이다. 만들어두기만 하고 직접 돌리지는 않는다. 번호를 안 붙이는 이유도 그때문이다.
# search.py - 문서를 읽고 조각으로 자른다
import os
import glob
def load_documents(folder=None):
"""폴더 안 txt 를 전부 읽는다."""
folder = folder or os.getenv("DOCS_DIR", "docs")
paths = sorted(glob.glob(f"{folder}/*.txt"))
if not paths:
print(f"[경고] '{folder}' 에서 txt 파일을 못 찾았다.")
return []
result = []
for path in paths:
try:
with open(path, "r", encoding="utf-8") as f:
result.append({"source": os.path.basename(path), "text": f.read()})
except UnicodeDecodeError:
print(f"[건너뜀] {path} - utf-8 이 아니다")
print(f"문서 {len(result)}건을 읽었다 (폴더: {folder})")
return result
def chunk_text(text, size=200):
"""긴 글을 size 글자씩 자른다."""
return [text[i:i + size] for i in range(0, len(text), size)]
def build_chunks(folder=None):
"""문서를 읽어 조각 목록으로 만든다. 조각마다 출처를 붙인다."""
chunks = []
for doc in load_documents(folder):
for piece in chunk_text(doc["text"]):
if piece.strip(): # 공백뿐인 조각은 버린다
chunks.append({"source": doc["source"], "text": piece})
return chunks
이제 다른 파일에서 불러다 쓴다.
# 내가 만든 파일도 import 로 가져온다. 파일 이름에서 .py 를 뗀 것이 패키지의 이름이다.
# 같은 폴더에 있으면 그냥 찾는다.
import search
# 점(.)은 여기서도 "그 패키지 안의" 라는 뜻이다. os.makedirs 와 같은 문법이다.
chunks = search.build_chunks()
print(len(chunks))
print(chunks[0]["source"])
print(chunks[0]["text"][:20])
- 문서 3건을 읽었다 (폴더: docs)
- 3
- backend.txt
- 수업용 예제
- 백엔드 개발자는 서버와
이게 파일이 서로를 아는 유일한 방법이다. 1강에서 「파일이 다르면 서로 모른다」고 했는데, import 가 그 예외다. 다만 아무 파일이나 되는 게 아니라 가져오는 쪽에서 이름을 적어야 한다.
미리 보기 — 숫자 다발을 다루는 도구
여기까지는 글자를 셌다. 다음 단계에서는 글자를 숫자로 바꿔서 다룬다. 문장 하나가 숫자 384개가 되고, 두 문장이 비슷한지를 그 숫자들로 잰다.
숫자 384개를 파이썬 리스트로 다루면 느리고 코드도 길어진다. 그래서 numpy 를 쓴다.
pip install numpy
# numpy 는 숫자 다발을 빠르게 계산해주는 묶음이다. 이건 기본 포함이 아니라 설치가 필요하다.
# as np 는 "이 묶음을 np 라는 짧은 이름으로 부르겠다"는 뜻이다.
import numpy as np
a = np.array([1, 2, 3]) # 파이썬 리스트를 numpy 배열로 바꾼다
b = np.array([2, 4, 6])
print(a.shape) # 숫자가 몇 개짜리인가. 괄호가 없다 - 함수가 아니라 값이다
print(np.dot(a, b)) # 자리끼리 곱해 전부 더한다 (1*2 + 2*4 + 3*6)
- (3,)
- 28
.shape 는 숫자가 몇 개인지 알려준다. 문장을 임베딩하면 여기가 (384,) 로 나오는데, 그 384가 이 시리즈 내내 화면에 등장한다.
이번 강의에 나온 것
| 쓴 것 | 하는 일 |
|---|---|
open(path, "r", encoding="utf-8") | 읽기. encoding 을 절대 빼지 않는다 |
with open(...) as f: | 블록이 끝나면 자동으로 닫는다 |
f.read() | 파일 전체를 문자열로 |
repr(x) | 안 보이는 글자까지 보여준다 |
glob.glob("docs/*.txt") | 조건에 맞는 파일 목록 |
os.path.basename(path) | 경로에서 파일 이름만 |
os.listdir(folder) | 폴더 안 이름 목록 |
os.getenv("DOCS_DIR", "docs") | 환경변수를 읽는다. 없으면 기본값 |
if not paths: | 비었으면. 조용한 실패를 막는다 |
try / except 에러이름 | 죽는 대신 넘어간다 |
import search | 다른 파일의 함수를 가져다 쓴다 |
"a b".strip() | 앞뒤 공백을 없앤다 |
스스로 해보기
- 01
내 파일을 하나 더 넣고 읽히게 만든다
ai-course/48_연습1.py. docs 폴더에 txt 를 하나 더 만든다. 내용은 아무거나 좋다. 문서 4건이 나오면 성공이다. - 02
일부러 cp949 로 저장한 파일을 넣어본다
ai-course/49_연습2.py.[건너뜀]이 찍히고 나머지는 정상으로 읽히는지 본다. 한 파일 때문에 전체가 멈추지 않는 것이 이번 강의에서 만든 방어 장치다. - 03
DOCS_DIR 을 다른 폴더로 바꿔본다
ai-course/50_연습3.py. 폴더를 하나 더 만들어 문서를 넣고 환경변수만 바꾼다. 코드를 한 줄도 안 고치고 다른 문서를 쓰는 것 — 이 구조가 뒤에서 계속 쓰인다.
1번 — 내 파일 추가충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
docs 폴더에 txt 를 하나 더 만들기만 하면 된다. search.py 는 한 줄도 안 고친다.
이게 `DOCS_DIR` 구조의 값어치다 — 지식을 바꾸는 데 코드가 필요 없다.
# 파이썬으로 만들어도 되고 메모장으로 만들어도 된다
with open("docs/mine.txt", "w", encoding="utf-8") as f:
f.write("수업용 예제\n내가 좋아하는 것은 영화와 산책이다. 주말에는 주로 걷는다.")
import search
chunks = search.build_chunks()
print(len(chunks))문서 4건을 읽었다 (폴더: docs) 와 4 가 나오면 성공이다.
메모장으로 만들 때는 저장할 때 인코딩을 UTF-8 로 골라야 한다. ANSI 로 저장하면 다음 문제에서 볼 일이 벌어진다.
2번 — cp949 파일을 넣으면충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
[건너뜀] 이 한 줄 찍히고 나머지는 정상으로 읽힌다. 이게 try / except 를 넣은 이유다.
# 일부러 cp949 로 저장한다
open("docs/legacy.txt", "wb").write(
"수업용 예제\n옛날 방식으로 저장한 파일이다.".encode("cp949"))
import search
search.load_documents()[건너뜀] docs\legacy.txt - utf-8 이 아니다 다음에 문서 4건을 읽었다 가 나온다.
한 파일 때문에 전체가 멈추지 않는다. except 가 없었다면 여기서 프로그램이 죽는다.
확인했으면 legacy.txt 는 지운다. 안 지우면 앞으로 계속 [건너뜀] 이 찍힌다.
3번 — DOCS_DIR 바꾸기충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
폴더를 하나 더 만들고 환경변수만 바꾼다. 코드는 손대지 않는다.
import os
os.makedirs("docs2", exist_ok=True)
with open("docs2/other.txt", "w", encoding="utf-8") as f:
f.write("수업용 예제\n다른 폴더의 문서다.")
os.environ["DOCS_DIR"] = "docs2"
import search
search.load_documents()문서 1건을 읽었다 (폴더: docs2)
환경변수는 이 파일이 도는 동안만 유효하다. 다음에 다른 파일을 돌리면 다시 docs 로 돌아간다. 파일 하나가 한 번의 실행이라는 게 여기서도 보인다.
정리하면
파일은 with open(..., encoding="utf-8") 으로 읽는다. 인코딩을 빼면 한글이 깨지거나 터진다. glob 으로 폴더 전체를 훑고, 조용한 실패를 막으려면 몇 건 읽었는지 찍어둔다. 그리고 폴더 경로는 코드에 박지 않고 DOCS_DIR 로 받는다. 문서를 바꿔 다른 서비스가 되게 하려면 이 구조가 처음부터 있어야 한다.
여기까지가 파이썬 문법이다. 파일을 읽을 줄 알면 이미 쓸 만한 것을 만들 수 있다. 다음 네 강의는 검색과 상관없이, 지금 배운 것만으로 폴더를 엑셀 보고서로 바꾸고 사진 수백 장을 한꺼번에 처리한다. 그러고 나서 조각을 겹쳐 자르는 것으로 돌아와, 2강에서 본 「문장이 찢어지는 문제」를 실제로 푼다.