학습 자료

진짜 파일을 읽는다 — 인코딩 · 폴더 · 환경변수 (해설영상 제공 예정)


Article

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

이번 강의에서 그 파일을 읽는작업을 진행한다.

읽는 것 자체는 두 줄이면 끝나지만 한글 인코딩 방식 문제 때문에 반드시 한 번은 화면이 깨지는 경험을 하게될 것이다.

이번 시간에는 해당 이슈사항을 자세히 알아보자.

읽을 파일부터 직접 생성

ai-course/40_파일만들기.py
# 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 왼쪽 탐색기에서 직접 열어 확인해본다.

파일 쓰기 한 줄씩
  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"

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

파일 한 개 읽기

ai-course/41_한개읽기.py
# "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 을 빼고 똑같이 해본다. 한글이 깨지는 경우를 확인할 수 있다.

ai-course/42_인코딩에러.py
# 일부러 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 패키지를 활용하면 특정 조건에 맞는 파일을 한번에 찾을 수 있다.

ai-course/43_glob.py
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건이라 실제 에러 원인을 찾느라 한참 헤맨다.

그러니 이런 이슈는 알아차릴 수 있게 미리 안전장치 만들어 두는게 좋다.

ai-course/44_방어.py
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강에서 만든 딕셔너리가 여기서 다시 나왔다. 조각마다 「어느 파일에서 왔는지」를 들고 다녀야 나중에 답의 근거를 보여줄 수 있다.

방어 장치 셋
  1. 01

    if not paths:

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

  2. 02

    try / except UnicodeDecodeError

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

  3. 03

    몇 건 읽었는지 찍는다

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

  4. 04

    sorted 로 감싼다

    glob 이 주는 순서는 운영체제마다 다를 수 있다. 순서가 매번 바뀌면 「3번 조각」이 실행할 때마다 다른 내용이 된다. 누가 돌려도 같아야 결과를 비교할 수 있다.

경로를 코드에 적지 않는다

방금 함수에 이미 들어가 있던 줄이다.

folder = folder or os.getenv("DOCS_DIR", "docs")

이 과정에서 만드는 것은 「문서를 바꾸면 다른 서비스가 되는」 시스템이다. 직무 가이드를 넣으면 취업 상담이 되고, 안전 매뉴얼을 넣으면 설비 도우미가 된다.

ai-course/45_환경변수.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


# os.environ 은 환경변수가 담긴 딕셔너리다. 딕셔너리처럼 이름으로 넣고 꺼낸다.
# 원래는 터미널이나 .env 파일에서 정하지만, 지금은 확인용으로 코드에서 넣는다.
os.environ["DOCS_DIR"] = "docs"

load_documents()                # 인자를 안 줬으니 DOCS_DIR 을 읽는다
터미널
  • 문서 3건을 읽었다 (폴더: docs)
폴더 이름을 정하는 순서
  1. 함수를 부를 때 준 값load_documents("other") — 가장 우선
  2. 환경변수 DOCS_DIR터미널이나 .env 에서 정한 값
  3. 기본값 docs아무것도 없을 때

파일로 나눈다 — import

지금까지는 파일마다 같은 함수를 다시 적었다. 이젠 함수를 범용적으로 등록하고 재사용해보겠다.

아래는 실행용이 아니라 보관용 파일이다. 만들어두기만 하고 직접 돌리지는 않는다. 번호를 안 붙이는 이유도 그때문이다.

ai-course/search.py
# 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

이제 다른 파일에서 불러다 쓴다.

ai-course/46_import.py
# 내가 만든 파일도 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
ai-course/47_numpy.py
# 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()앞뒤 공백을 없앤다

스스로 해보기

확인 문제 셋
  1. 01

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

    ai-course/48_연습1.py. docs 폴더에 txt 를 하나 더 만든다. 내용은 아무거나 좋다. 문서 4건이 나오면 성공이다.

  2. 02

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

    ai-course/49_연습2.py. [건너뜀] 이 찍히고 나머지는 정상으로 읽히는지 본다. 한 파일 때문에 전체가 멈추지 않는 것이 이번 강의에서 만든 방어 장치다.

  3. 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강에서 본 「문장이 찢어지는 문제」를 실제로 푼다.

Share
  • 파이썬
  • AI
  • RAG
  • 파일 입출력
  • 인코딩
  • 환경변수
  • 모듈
진짜 파일을 읽는다 — 인코딩 · 폴더 · 환경변수 (해설영상 제공 예정) — 디코드랩(DCODELAB)