학습 자료

진짜 파일을 읽는다 — 인코딩 · 폴더 · 환경변수


Article

지금까지 문서를 코드 안에 문자열로 적어뒀다. 연습이라 그랬고, 실제로는 폴더에 파일이 쌓여 있다. 이번 강의에서 그 파일을 읽는다.

읽는 것 자체는 두 줄이면 끝난다. 그런데 한글 때문에 반드시 한 번은 깨진다. 그 자리를 미리 밟고 간다.

읽을 파일부터 만든다

남의 파일을 받아오지 않는다. 파이썬으로 직접 만든다.

import os

os.makedirs("docs", exist_ok=True)      # docs 폴더를 만든다. 이미 있어도 에러 안 남

files = {
    "backend.txt": "수업용 예제\n백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다.",
    "frontend.txt": "수업용 예제\n프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다. 리액트와 뷰가 많이 쓰인다.",
    "data.txt": "수업용 예제\n데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 데이터베이스와 파이프라인을 다루고 파이썬과 SQL 을 쓴다.",
}

for name, content in files.items():                          # 딕셔너리를 이름·값 쌍으로 돈다
    with open(f"docs/{name}", "w", encoding="utf-8") as f:   # "w" 는 쓰기
        f.write(content)

os.listdir("docs")
터미널
  • ['backend.txt', 'data.txt', 'frontend.txt']

파일 세 개가 생겼다. 코드를 뜯어보자.

파일 쓰기 한 줄씩
  1. 01

    import os

    폴더를 만들고 목록을 보는 기능은 os 라는 묶음에 들어 있다. 파이썬에 기본 포함이라 설치할 게 없다.

  2. 02

    os.makedirs("docs", exist_ok=True)

    exist_ok=True 가 없으면 폴더가 이미 있을 때 에러가 난다. 두 번째 실행에서 터지는 코드가 되면 곤란하다.

  3. 03

    files.items()

    딕셔너리를 돌면 이름만 나온다. .items() 를 붙여야 이름과 값을 짝으로 준다.

  4. 04

    with open(...) as f:

    with 를 쓰면 블록이 끝날 때 파일이 자동으로 닫힌다. 안 닫으면 내용이 끝까지 안 써지는 경우가 있다. 파일은 항상 `with` 으로 연다.

  5. 05

    encoding="utf-8"

    이번 강의의 핵심이다. 잠시 뒤에 이걸 빼면 어떻게 되는지 본다.

한 개 읽기, 그리고 한글이 깨지는 자리

with open("docs/backend.txt", "r", encoding="utf-8") as f:
    text = f.read()

text[:20]
터미널
  • '수업용 예제\n백엔드 개발자는 서버와 '

읽혔다. \n 은 줄바꿈이다. 이제 encoding 을 빼고 똑같이 해보자.

# 일부러 encoding 을 뺀다
with open("docs/backend.txt", "r") as f:
    text2 = f.read()

text2[:20]

Windows 한국어 환경이면 이렇게 된다.

터미널
  • 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 glob

paths = glob.glob("docs/*.txt")     # docs 폴더의 txt 파일 전부
paths
터미널
  • ['docs\\backend.txt', 'docs\\data.txt', 'docs\\frontend.txt']

* 는 「아무 글자나 몇 개든」이라는 뜻이다. *.txt 면 확장자가 txt 인 것 전부.

이제 전부 읽어서 리스트에 담는다.

def load_documents(folder="docs"):
    """폴더 안 txt 를 전부 읽어 (파일명, 내용) 목록으로 돌려준다."""
    result = []
    for path in sorted(glob.glob(f"{folder}/*.txt")):    # sorted 로 순서를 고정한다
        with open(path, "r", encoding="utf-8") as f:
            result.append({"source": os.path.basename(path), "text": f.read()})
    return result


docs = load_documents()
len(docs)
터미널
  • 3
docs[0]["source"]
docs[0]["text"][:25]
터미널
  • 'backend.txt'
  • '수업용 예제\n백엔드 개발자는 서버와 데이터베이'

앞 강의에서 만든 딕셔너리가 여기서 다시 나왔다. 조각마다 「어느 파일에서 왔는지」를 들고 다녀야 나중에 답의 근거를 보여줄 수 있다.

파일이 없을 때 — try / except

폴더 이름을 틀리면 어떻게 되나.

load_documents("doc")       # s 를 빠뜨렸다
터미널
  • []

에러가 아니라 빈 리스트가 나온다. 이게 더 위험하다. 프로그램은 멀쩡히 돌고, 검색 결과만 계속 0건이다. 원인을 찾느라 한참 헤맨다.

그러니 이런 건 알아차릴 수 있게 만든다.

def load_documents(folder="docs"):
    """폴더 안 txt 를 전부 읽는다. 한 건도 없으면 알려준다."""
    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


docs = load_documents("doc")
docs = load_documents()
터미널
  • [경고] 'doc' 에서 txt 파일을 못 찾았다. 경로를 확인한다.
  • 문서 3건을 읽었다 (폴더: docs)
방어 장치 셋
  1. 01

    if not paths:

    빈 리스트·빈 문자열·0 은 파이썬에서 거짓이다. if not paths 는 「비었으면」이라는 뜻.

  2. 02

    try / except UnicodeDecodeError

    try 안에서 그 에러가 나면 프로그램이 죽는 대신 except 로 넘어간다. 어떤 에러를 잡을지 이름을 적는다. 그냥 except: 로 다 잡으면 정작 봐야 할 문제까지 삼켜버린다.

  3. 03

    몇 건 읽었는지 찍는다

    한 줄이지만 이게 없으면 「0건인데 조용한 상태」를 알아챌 수 없다. 실무 코드에 로그가 많은 이유가 이것이다.

경로를 코드에 적지 않는다

지금 폴더 이름이 "docs" 로 코드에 박혀 있다. 다른 폴더를 쓰려면 코드를 고쳐야 한다.

이 과정에서 만드는 것은 「문서를 바꾸면 다른 서비스가 되는」 시스템이다. 직무 가이드를 넣으면 취업 상담이 되고, 안전 매뉴얼을 넣으면 설비 도우미가 된다. 그때마다 코드를 고치면 그건 다른 프로그램이지 같은 시스템이 아니다.

그래서 폴더 이름을 밖에서 받는다.

def load_documents(folder=None):
    """폴더 안 txt 를 전부 읽는다. folder 를 안 주면 환경변수 DOCS_DIR, 그것도 없으면 docs."""
    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["DOCS_DIR"] = "docs"     # 환경변수를 코드에서 임시로 정한다
load_documents()
터미널
  • 문서 3건을 읽었다 (폴더: docs)
폴더 이름을 정하는 순서
  1. 함수를 부를 때 준 값load_documents("other") — 가장 우선
  2. 환경변수 DOCS_DIR터미널이나 .env 에서 정한 값
  3. 기본값 docs아무것도 없을 때

파일로 나눈다 — import

여기까지 만든 함수들을 >>> 화면에 두면 나갈 때 다 사라진다. 파일로 저장하자.

search.py 라는 이름으로 아래를 저장한다. 메모장이든 VS Code 든 상관없다.

# 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 search

chunks = search.build_chunks()
len(chunks)
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 3
chunks[0]["source"]
터미널
  • 'backend.txt'

미리 보기 — 숫자 다발을 다루는 도구

여기까지는 글자를 셌다. 이 시리즈의 다음 단계에서는 글자를 숫자로 바꿔서 다룬다. 문장 하나가 숫자 384개가 되고, 두 문장이 비슷한지를 그 숫자들로 잰다.

숫자 384개를 파이썬 리스트로 다루면 느리고 코드도 길어진다. 그래서 numpy 라는 도구를 쓴다. 설치가 필요하므로 지금 실행할 필요는 없고, 모양만 봐둔다.

import numpy as np

a = np.array([1, 2, 3])
b = np.array([2, 4, 6])

a.shape          # 숫자가 몇 개짜리인가
np.dot(a, b)     # 두 다발을 곱해서 더한다 — 유사도 계산의 뼈대
터미널
  • (3,)
  • 28

.shape 는 숫자가 몇 개인지 알려준다. 문장을 임베딩하면 여기가 (384,) 로 나오는데, 그 384가 이 시리즈 내내 화면에 등장한다.

이번 강의에 나온 것

정리
쓴 것하는 일
open(path, "r", encoding="utf-8")읽기. encoding 을 절대 빼지 않는다
with open(...) as f:블록이 끝나면 자동으로 닫는다
f.read()파일 전체를 문자열로
glob.glob("docs/*.txt")조건에 맞는 파일 목록
os.path.basename(path)경로에서 파일 이름만
os.getenv("DOCS_DIR", "docs")환경변수를 읽는다. 없으면 기본값
if not paths:비었으면. 조용한 실패를 막는다
try / except 에러이름죽는 대신 넘어간다
import search다른 파일의 함수를 가져다 쓴다
"a b".strip()앞뒤 공백을 없앤다

스스로 해보기

확인 문제 셋
  1. 01

    내 파일을 하나 더 넣고 읽히게 만든다

    docs 폴더에 txt 를 하나 더 만든다. 내용은 아무거나 좋다. 첫 줄에 정체를 적고 utf-8 로 저장한다. build_chunks() 를 돌렸을 때 문서 4건이 나오면 성공이다.

    import search
    chunks = search.build_chunks()
    len(chunks)
  2. 02

    일부러 cp949 로 저장한 파일을 넣어본다

    메모장에서 「다른 이름으로 저장」 시 인코딩을 ANSI 로 고르면 된다. [건너뜀] 이 찍히고 나머지는 정상으로 읽히는지 본다. 한 파일 때문에 전체가 멈추지 않는 것이 이번 강의에서 만든 방어 장치다.

  3. 03

    DOCS_DIR 을 다른 폴더로 바꿔본다

    폴더를 하나 더 만들어 문서를 넣고 환경변수만 바꾼다. 코드를 한 줄도 안 고치고 다른 문서를 쓰는 것 — 이 구조가 뒤에서 계속 쓰인다.

    import os
    os.environ["DOCS_DIR"] = "docs2"
    search.load_documents()

정리하면

파일은 with open(..., encoding="utf-8") 으로 읽는다. 인코딩을 빼면 한글이 깨지거나 터진다. glob 으로 폴더 전체를 훑고, 조용한 실패를 막으려면 몇 건 읽었는지 찍어둔다. 그리고 폴더 경로는 코드에 박지 않고 DOCS_DIR 로 받는다. 문서를 바꿔 다른 서비스가 되게 하려면 이 구조가 처음부터 있어야 한다.

여기까지가 파이썬 문법이다. 다음 강의에서는 이 함수들을 다듬어 조각을 겹쳐 자르는 것을 만든다. 1강에서 본 「문장이 찢어지는 문제」를 실제로 푸는 자리다.

진짜 파일을 읽는다 — 인코딩 · 폴더 · 환경변수 — 디코드랩(DCODELAB)