Python 파일 처리: 텍스트·CSV·JSON 읽고 쓰기
이 글의 핵심
Windows에서 만든 CSV를 읽다가 UnicodeDecodeError가 나거나 한글이 깨지는 문제는 대부분 encoding 인자를 생략해서 생깁니다. with 문으로 파일을 확실히 닫는 습관, CSV를 쓸 때 newline=''이 필요한 이유, 문자열 경로 대신 pathlib를 쓰는 장점을 짧은 패턴으로 정리합니다.
들어가며
파일 읽기/쓰기는 데이터 저장, 로그 기록, 설정 관리 등 실무에서 필수입니다.
Python에서 파일을 다룰 때 알아 둘 기본 구분은 텍스트 모드와 바이너리 모드입니다. 기본값인 텍스트 모드('r', 'w')는 디스크의 바이트를 지정한 인코딩으로 해석해 str로 돌려주고, 줄바꿈 문자도 운영체제에 맞게 변환합니다. 이미지, 압축 파일, PDF처럼 문자가 아닌 데이터는 'rb', 'wb'처럼 b를 붙인 바이너리 모드로 열어 bytes로 다뤄야 합니다. 텍스트 모드로 이미지를 읽으면 UnicodeDecodeError가 나거나 내용이 손상됩니다.
이 글은 표준 라이브러리만으로 텍스트, CSV, JSON을 다루는 방법에 집중합니다. 대용량 표 데이터를 분석해야 한다면 pandas가 더 편하지만, 스크립트 몇 줄로 끝나는 작업이나 의존성을 늘리기 어려운 환경에서는 표준 라이브러리가 충분하고 동작도 예측하기 쉽습니다.
텍스트 파일 읽고 쓰기
파일 읽기
디스크에 있는 텍스트 파일은 책장에 꽂힌 공책과 비슷합니다. open으로 한 번 펼치면 줄 단위로 읽거나 한꺼번에 읽을 수 있으며, encoding='utf-8'을 빼먹으면 한글이 깨질 수 있으므로 습관처럼 적어 두는 것이 좋습니다.
# 방법 1: 전체 읽기
with open('data.txt', 'r', encoding='utf-8') as f:
content = f.read()
print(content)
# 방법 2: 줄 단위 읽기
with open('data.txt', 'r', encoding='utf-8') as f:
lines = f.readlines()
for line in lines:
print(line.strip())
# 방법 3: 반복문 (메모리 효율적)
with open('data.txt', 'r', encoding='utf-8') as f:
for line in f:
print(line.strip())
세 방법의 차이는 메모리 사용량입니다. read()는 파일 전체를 하나의 문자열로, readlines()는 모든 줄을 담은 리스트로 한 번에 메모리에 올립니다. 반면 파일 객체를 for로 직접 순회하는 방법 3은 내부 버퍼를 쓰면서 한 번에 한 줄씩만 읽으므로, 수 GB짜리 로그 파일도 일정한 메모리로 처리할 수 있습니다. 파일 크기를 예측할 수 없다면 방법 3이 기본 선택입니다.
각 줄에는 끝의 줄바꿈 문자 \n이 포함되어 있어서 strip()으로 제거했습니다. strip()은 줄 앞뒤의 공백과 탭까지 모두 지우므로, 들여쓰기가 의미 있는 파일이라면 line.rstrip('\n')처럼 줄바꿈만 지우는 편이 안전합니다. 또 파일 객체는 한 번 끝까지 읽으면 커서가 끝에 가 있어서, 같은 with 블록 안에서 f.read()를 두 번 호출하면 두 번째는 빈 문자열이 나옵니다. 다시 읽으려면 f.seek(0)으로 처음으로 돌아가야 합니다.
파일 쓰기
# 덮어쓰기 (w)
with open('output.txt', 'w', encoding='utf-8') as f:
f.write("첫 번째 줄\n")
f.write("두 번째 줄\n")
# 추가 (a)
with open('output.txt', 'a', encoding='utf-8') as f:
f.write("세 번째 줄\n")
# 여러 줄 쓰기
lines = ["라인 1\n", "라인 2\n", "라인 3\n"]
with open('output.txt', 'w', encoding='utf-8') as f:
f.writelines(lines)
'w' 모드는 파일을 여는 순간 기존 내용을 비웁니다. 쓰기 전에 예외가 나도 이미 내용은 사라진 뒤라서, 중요한 파일을 'w'로 열었다가 코드 오류로 빈 파일만 남는 사고가 생깁니다. 기존 파일이 있으면 실패해야 하는 경우에는 'x' 모드를 쓰면 FileExistsError가 나서 덮어쓰기를 막을 수 있습니다.
write()와 writelines()는 줄바꿈을 자동으로 붙이지 않습니다. 이름과 달리 writelines()는 리스트의 문자열을 그대로 이어 붙일 뿐이므로, 각 요소에 \n이 없으면 모든 줄이 한 줄로 합쳐집니다. print("첫 번째 줄", file=f)처럼 print에 파일 객체를 넘기면 줄바꿈이 자동으로 붙고 숫자도 문자열로 변환해 주어 편합니다. 쓴 내용은 버퍼에 쌓였다가 파일을 닫을 때 디스크에 기록되므로, 다른 프로세스가 즉시 읽어야 한다면 f.flush()를 호출합니다.
csv 모듈로 읽고 쓰기
CSV 읽기
import csv
# 방법 1: 리스트로 읽기
with open('data.csv', 'r', encoding='utf-8') as f:
reader = csv.reader(f)
header = next(reader) # 첫 줄 (헤더)
for row in reader:
print(row)
# 방법 2: 딕셔너리로 읽기 (권장)
with open('data.csv', 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
print(row['name'], row['age'])
CSV를 line.split(',')로 직접 나누지 않고 csv 모듈을 쓰는 이유는 따옴표 처리 때문입니다. "서울, 강남구",25처럼 값 안에 쉼표가 있으면 따옴표로 감싸는 것이 CSV 규칙인데, 단순 split은 이것을 세 칸으로 잘라 버립니다. 값 안에 줄바꿈이나 따옴표가 들어간 경우도 csv 모듈이 올바르게 처리합니다.
DictReader는 첫 줄을 헤더로 보고 각 행을 {'name': ..., 'age': ...} 딕셔너리로 돌려주므로, 열 순서가 바뀌어도 코드가 깨지지 않아 권장됩니다. 주의할 점은 모든 값이 문자열이라는 것입니다. row['age']는 '25'이므로 계산하려면 int(row['age'])로 변환해야 하고, 빈 칸은 ''로 들어와 int('')에서 ValueError가 납니다.
Excel에서 “CSV UTF-8”로 저장한 파일을 읽을 때 자주 겪는 문제가 있습니다. 파일 맨 앞에 BOM()이 붙어 있어서, encoding='utf-8'로 읽으면 첫 번째 헤더가 'name'이 아니라 'name'이 되고 row['name']에서 KeyError: 'name'이 납니다. 출력해 보면 BOM이 보이지 않아 원인을 찾기 어렵습니다. encoding='utf-8-sig'로 열면 BOM이 있으면 제거하고 없으면 그냥 읽으므로, Excel에서 온 파일에는 이 인코딩을 쓰는 것이 안전합니다. 한국어 Windows의 Excel이 기본 형식으로 저장한 CSV는 UTF-8이 아니라 cp949인 경우가 많아, 이때는 UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb0처럼 에러가 납니다.
CSV 쓰기
import csv
# 방법 1: 리스트로 쓰기
data = [
['이름', '나이', '도시'],
['철수', 25, '서울'],
['영희', 30, '부산']
]
with open('output.csv', 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerows(data)
# 방법 2: 딕셔너리로 쓰기
data = [
{'name': '철수', 'age': 25, 'city': '서울'},
{'name': '영희', 'age': 30, 'city': '부산'}
]
with open('output.csv', 'w', newline='', encoding='utf-8') as f:
fieldnames = ['name', 'age', 'city']
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(data)
newline=''을 빠뜨리면 Windows에서 행 사이에 빈 줄이 하나씩 끼어드는 결과가 나옵니다. csv 모듈은 CSV 규칙에 따라 행 끝에 \r\n을 직접 쓰는데, 텍스트 모드가 여기서 \n을 다시 \r\n으로 변환해 \r\r\n이 되기 때문입니다. 읽을 때도 값 안의 줄바꿈을 올바르게 처리하려면 newline=''을 주는 것이 공식 문서의 권장입니다. DictWriter는 fieldnames에 없는 키가 딕셔너리에 있으면 ValueError: dict contains fields not in fieldnames를 내므로, 무시하고 싶다면 extrasaction='ignore'를 넘깁니다.
만든 CSV를 한국어 Excel에서 열었을 때 한글이 깨진다면 파일은 정상이고 Excel이 인코딩을 잘못 추측한 것입니다. 쓸 때 encoding='utf-8-sig'로 BOM을 붙여 주면 Excel이 UTF-8로 인식합니다. 다른 프로그램이 읽을 파일이라면 BOM이 오히려 문제가 될 수 있으니 받는 쪽에 맞춰 고릅니다.
json 모듈로 읽고 쓰기
JSON 읽기
import json
# 파일에서 읽기
with open('data.json', 'r', encoding='utf-8') as f:
data = json.load(f)
print(data)
# 문자열에서 읽기
json_str = '{"name": "철수", "age": 25}'
data = json.loads(json_str)
print(data['name']) # 철수
json.load는 파일 객체를, json.loads는 문자열을 받습니다(끝의 s가 string). JSON 객체는 딕셔너리, 배열은 리스트, true/false/null은 True/False/None으로 바뀝니다. 파일이 비어 있거나 중간에 잘려 있으면 json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)이 납니다. 이 에러는 JSON 문법 오류뿐 아니라 API가 HTML 에러 페이지를 돌려줬을 때도 똑같이 나오므로, 원인을 찾을 때는 실제로 받은 내용을 먼저 출력해 보는 것이 빠릅니다. JSON은 주석과 마지막 쉼표를 허용하지 않아서, 사람이 손으로 고친 설정 파일에서 이 에러가 자주 납니다.
JSON 쓰기
import json
data = {
"name": "철수",
"age": 25,
"hobbies": ["독서", "영화", "운동"],
"address": {
"city": "서울",
"district": "강남구"
}
}
# 파일로 쓰기
with open('output.json', 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# 문자열로 변환
json_str = json.dumps(data, ensure_ascii=False, indent=2)
print(json_str)
ensure_ascii=False가 없으면 한글이 "철수" 같은 유니코드 이스케이프로 저장됩니다. 데이터로는 같은 값이라 다시 읽으면 복원되지만, 사람이 파일을 열어 볼 때는 읽을 수 없습니다. 이 옵션을 쓰면 실제 한글 문자가 저장되므로, 파일을 여는 open()에도 반드시 encoding='utf-8'을 함께 지정해야 합니다. indent=2는 사람이 읽기 좋게 줄바꿈과 들여쓰기를 넣으며, 네트워크로 보낼 데이터라면 생략해서 크기를 줄입니다.
JSON이 표현할 수 있는 타입은 제한되어 있어서 datetime, set, Decimal, 사용자 정의 클래스 객체를 그대로 넣으면 TypeError: Object of type datetime is not JSON serializable이 납니다. 날짜는 dt.isoformat()으로 문자열로 바꿔 넣거나, json.dump(data, f, default=str)처럼 default 함수를 지정해 변환 방법을 알려줍니다. 또 딕셔너리 키가 숫자여도 JSON에서는 문자열 키로 저장되므로, {1: 'a'}를 저장했다 읽으면 {'1': 'a'}가 되어 data[1]에서 KeyError가 난다는 점도 알아 둘 만합니다.
pathlib으로 경로 다루기
pathlib 사용
from pathlib import Path
# 현재 디렉토리
current = Path.cwd()
print(current)
# 경로 결합
data_dir = Path('data')
file_path = data_dir / 'users.json'
print(file_path) # data/users.json
# 파일 존재 확인
if file_path.exists():
print("파일 있음")
# 디렉토리 생성
data_dir.mkdir(exist_ok=True)
# 파일 읽기/쓰기
file_path.write_text("Hello, World!", encoding='utf-8')
content = file_path.read_text(encoding='utf-8')
print(content)
# 파일 정보
print(file_path.name) # users.json
print(file_path.stem) # users
print(file_path.suffix) # .json
print(file_path.parent) # data
pathlib은 경로를 문자열이 아닌 객체로 다룹니다. / 연산자로 경로를 이어 붙이면 운영체제에 맞는 구분자가 쓰이므로, 문자열 결합으로 'data' + '\\' + 'users.json'처럼 쓰던 코드보다 이식성이 좋고 읽기도 쉽습니다. open()을 비롯한 표준 라이브러리 대부분이 Path 객체를 그대로 받습니다. write_text와 read_text는 파일을 열고 닫는 과정을 한 줄로 줄인 편의 메서드로, 작은 파일에 적합합니다.
mkdir(exist_ok=True)는 폴더가 이미 있어도 에러를 내지 않지만, 중간 폴더가 없으면 FileNotFoundError가 납니다. data/2026/logs처럼 여러 단계를 한 번에 만들려면 parents=True를 함께 넘겨야 합니다. 예제는 write_text 전에 mkdir을 호출해 두었기 때문에 문제가 없지만, 순서가 바뀌면 FileNotFoundError가 나므로 실제로는 파일을 쓰기 전에 file_path.parent.mkdir(parents=True, exist_ok=True)를 호출하는 패턴이 가장 안전합니다.
상대 경로 Path('data')는 스크립트 파일 위치가 아니라 현재 작업 디렉토리 기준입니다. 같은 스크립트를 프로젝트 폴더에서 실행하면 되고 다른 폴더에서 python scripts/run.py로 실행하면 FileNotFoundError가 나는 것이 입문자가 가장 자주 겪는 파일 경로 문제입니다. 스크립트 옆의 파일을 가리키려면 Path(__file__).resolve().parent / 'data'처럼 스크립트 위치를 기준으로 경로를 만듭니다. cron이나 작업 스케줄러로 실행할 때는 작업 디렉토리가 홈 폴더나 시스템 폴더가 되는 경우가 많아 이 방식이 필수입니다.
로그 분석과 설정 파일 관리 예제
로그 파일 분석
from collections import Counter
from pathlib import Path
def analyze_log(log_file):
"""로그 파일에서 에러 통계"""
error_counts = Counter()
with open(log_file, 'r', encoding='utf-8') as f:
for line in f:
if 'ERROR' in line:
error_type = line.split(':')[1].strip()
error_counts[error_type] += 1
return error_counts
# 사용
errors = analyze_log('app.log')
for error, count in errors.most_common(5):
print(f"{error}: {count}회")
파일을 한 줄씩 순회하므로 로그가 아무리 커도 메모리 사용량은 거의 일정하고, Counter가 에러 종류별 개수를 세어 most_common(5)로 상위 다섯 개를 돌려줍니다.
이 함수는 로그 형식이 ERROR: DatabaseError 같은 모양이라고 가정합니다. 실제 로그에는 보통 2026-09-24 10:15:30 ERROR: DatabaseError처럼 시각이 앞에 붙는데, 이 경우 split(':')[1]은 에러 종류가 아니라 분(minute)인 '15'를 돌려줍니다. 에러 없이 엉뚱한 통계가 나오므로 알아채기 어려운 버그입니다. 또 ERROR 문자열은 있지만 콜론이 없는 줄에서는 IndexError로 전체 분석이 멈춥니다. 실무에서는 line.split('ERROR:', 1)[1].split()[0]처럼 기준 문자열 뒤를 자르거나, 정규식 re.search(r'ERROR:\s*(\w+)', line)으로 패턴이 맞는 줄만 처리하는 편이 안전합니다. 운영 로그에는 깨진 바이트가 섞여 있는 경우도 있어서, open(..., errors='replace')로 열면 디코딩 에러 한 번에 분석 전체가 중단되는 것을 막을 수 있습니다.
설정 파일 관리
import json
from pathlib import Path
class Config:
def __init__(self, config_file='config.json'):
self.config_file = Path(config_file)
self.data = self.load()
def load(self):
if self.config_file.exists():
with open(self.config_file, 'r', encoding='utf-8') as f:
return json.load(f)
return {}
def save(self):
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(self.data, f, ensure_ascii=False, indent=2)
def get(self, key, default=None):
return self.data.get(key, default)
def set(self, key, value):
self.data[key] = value
self.save()
# 사용
config = Config()
config.set('database_url', 'localhost:5432')
print(config.get('database_url'))
파일이 없으면 빈 설정으로 시작하고, set을 호출할 때마다 즉시 저장하는 단순한 설정 관리 클래스입니다. 작은 도구에는 충분하지만 두 가지 약점이 있습니다.
첫째, save()가 'w' 모드로 파일을 열기 때문에, 저장 도중 프로그램이 죽거나 json.dump가 직렬화할 수 없는 값을 만나 예외를 던지면 설정 파일이 비거나 반쯤 쓰인 상태로 남습니다. 다음 실행에서는 load()가 JSONDecodeError로 실패해 프로그램이 아예 시작되지 않습니다. 안전하게 하려면 같은 폴더의 임시 파일에 먼저 쓴 뒤 Path(tmp).replace(self.config_file)로 바꿔치기합니다. replace는 같은 파일 시스템 안에서 원자적으로 동작해, 파일이 항상 이전 내용이나 새 내용 중 하나로만 존재합니다.
둘째, 데이터베이스 주소는 괜찮지만 비밀번호나 API 키를 이런 JSON 파일에 두고 Git에 커밋하면 유출로 이어집니다. 비밀 값은 환경 변수로 받고, 설정 파일은 .gitignore에 넣어 두는 것이 기본입니다. 여러 값을 연달아 바꿀 때마다 파일을 다시 쓰는 것이 부담스럽다면 set에서는 메모리만 바꾸고 save를 따로 호출하게 설계하는 편이 낫습니다.
with 문·인코딩·경로 확인 (한눈에 보는 패턴)
파일은 열었다가 닫지 않으면 자원이 새는 창문과 같아서, with로 열면 블록을 빠져나올 때 자동으로 닫힙니다. Windows·Mac 혼용 환경에서는 encoding='utf-8'을 명시하는 것이 깨짐을 막는 데 도움이 됩니다.
# ✅ with 문 사용 (자동으로 파일 닫힘)
with open('file.txt', 'r') as f:
content = f.read()
# ❌ 수동으로 닫기 (예외 발생 시 문제)
f = open('file.txt', 'r')
content = f.read()
f.close()
# ✅ encoding 명시
with open('file.txt', 'r', encoding='utf-8') as f:
pass
# ✅ 파일 존재 확인
from pathlib import Path
if Path('file.txt').exists():
# 파일 처리
pass
수동으로 닫는 방식이 위험한 이유는 f.read()에서 예외가 나면 f.close()까지 도달하지 못하기 때문입니다. CPython은 참조가 사라진 파일 객체를 결국 닫아 주지만 그 시점이 보장되지 않고, Windows에서는 열려 있는 파일을 다른 프로그램이 지우거나 옮길 수 없어서 “파일이 다른 프로세스에서 사용 중” 에러로 이어집니다. 반복문 안에서 파일을 열고 닫지 않으면 결국 OSError: [Errno 24] Too many open files가 납니다. with 문은 블록을 어떻게 빠져나가든 close()를 호출하므로 이 문제가 원천적으로 없습니다.
인코딩을 생략했을 때의 기본값은 Python 버전과 운영체제에 따라 다릅니다. Linux·macOS는 대부분 UTF-8이지만, 한국어 Windows에서는 cp949가 기본이라 UTF-8로 저장된 파일을 읽으면 에러가 납니다. python -X utf8 옵션이나 PYTHONUTF8=1 환경 변수로 UTF-8 모드를 켤 수 있고, 향후 버전에서는 UTF-8 모드가 기본값이 될 예정이지만(PEP 686), 여러 환경에서 도는 코드라면 지금처럼 매번 encoding을 적는 것이 가장 확실합니다.
exists()로 먼저 확인하는 방식은 읽기 쉽지만, 확인과 열기 사이에 다른 프로세스가 파일을 지울 수 있어 완벽하지 않습니다. 파이썬에서는 “먼저 시도하고 실패를 처리한다”는 방식(EAFP)이 관용적이라, try: open(...) 후 except FileNotFoundError:로 처리하는 코드도 자주 씁니다.
파일 처리 요약
- 텍스트 파일:
with open(..., encoding='utf-8')로 읽고 쓰기 - CSV:
csv모듈의DictReader/DictWriter로 표 형태 처리 - JSON:
json.load/json.dump로 설정·API 응답 저장 - 경로:
pathlib.Path로 OS 차이를 줄이고 가독성 확보 - 로그·대용량: 줄 단위 순회로 메모리 부담 줄이기
이어서 읽기
같이 보면 좋은 글
- Python 시리즈 전체 보기
- Python 환경 설정 | Windows/Mac에서 Python 설치하고 시작하기
- Python 기본 문법
- Python 자료형
- Python 예외 처리
- Python REST API | Flask/Django로 API 서버 만들기
- Python 웹 앱 배포
자주 묻는 질문 (FAQ)
Q. 파일을 읽을 때 한글이 깨지거나 UnicodeDecodeError가 나면 어떻게 하나요?
A. open()에 인코딩을 지정하지 않으면 운영체제의 기본 인코딩이 쓰이는데, Windows와 Mac처럼 환경마다 기본값이 달라 같은 파일이 한쪽에서만 깨질 수 있습니다. 읽기와 쓰기 모두 encoding='utf-8'을 명시하는 습관을 들이면 대부분 해결됩니다. 파일이 실제로 다른 인코딩으로 저장되어 있다면 그 인코딩을 확인해 지정해야 하며, 파일은 with 문으로 열어야 예외가 나도 자동으로 닫힙니다.