Devin.KR
로그인

파이썬 파일 입출력 - with 문과 CSV JSON 읽고 쓰기 (파이썬 초급 7단원)

개발자 조회 1

이 단원에서 배우는 것

지금까지 재고 딕셔너리를 코드 안에 직접 적어 왔다. 문제가 두 가지다. 프로그램을 끄면 판매 기록이 사라지고, 재고를 고치려면 매번 파이썬 파일을 열어야 한다. 서점 직원은 파이썬을 모른다. 이번 단원에서 데이터를 코드 밖으로 꺼낸다. 6단원에서 배운 인코딩과 split이 여기서 바로 쓰인다.

  • with 문으로 파일을 안전하게 열고 닫는다.
  • csv 모듈로 재고표를 읽고 판매 기록을 쓴다.
  • json 모듈로 중첩된 데이터를 저장하고 한글을 그대로 남긴다.

왜 필요한가

메모리에 있는 값은 프로세스가 끝나면 전부 사라진다. 그래서 프로그램이 남겨야 할 것은 파일이나 데이터베이스로 나가야 한다. 여기까지는 당연한 이야기고, 실무에서 더 중요한 것은 코드와 데이터를 분리하면 일이 나뉜다는 점이다. 재고표가 CSV 파일이면 담당자가 엑셀로 고치고 개발자는 로직만 본다. 재고가 코드 안에 박혀 있으면 재고 수정도 개발자 일이 되고, 그때마다 배포가 필요해진다.

그리고 파일 다루기는 실패가 정상인 작업이다. 파일이 없을 수 있고, 권한이 없을 수 있고, 쓰는 도중에 디스크가 찰 수 있다. 그래서 파이썬은 파일을 여는 문법 자체에 "무슨 일이 있어도 닫는다"는 장치를 넣어 두었다. 그게 with다.

문법과 예제

with 문 — 왜 이 형태인가

파일을 여는 원래 방식은 이렇다.

f = open("memo.txt", "w", encoding="utf-8")
f.write("파이썬 개론 입고\n")
f.close()          # 이걸 빼먹으면?

문제는 두 가지다. 첫째, close()를 빼먹기 쉽다. 둘째, write()close() 사이에서 예외가 나면 close() 줄에는 영영 도달하지 못한다. 파이썬은 성능을 위해 쓴 내용을 잠시 메모리에 모아 두었다가 한꺼번에 내보내는데, 닫지 않으면 그 내용이 파일에 안 남는다. "분명 write 했는데 파일이 비어 있다"의 정체가 이것이다.

with open("memo.txt", "w", encoding="utf-8") as f:
    f.write("파이썬 개론 입고\n")
    f.write("리팩터링 품절\n")
# 블록을 벗어나는 순간 닫힌다. 중간에 예외가 나도 닫힌다.

파일을 열 때 반드시 두 가지를 명시한다. 모드와 인코딩이다.

모드파일이 없으면파일이 있으면
"r"읽기 (기본값)FileNotFoundError처음부터 읽는다
"w"쓰기새로 만든다내용을 전부 지운다
"a"이어 쓰기새로 만든다맨 뒤에 붙인다
"x"새로 만들기새로 만든다FileExistsError
"rb"/"wb"바이너리이미지·zip 등. encoding 없이 bytes로 주고받는다

텍스트 파일 읽기

with open("memo.txt", encoding="utf-8") as f:
    for line in f:
        print(repr(line))
# '파이썬 개론 입고\n'
# '리팩터링 품절\n'

for line in f는 파일을 한 줄씩 읽는다. 파일 전체를 메모리에 올리지 않으므로 1GB 로그 파일에도 그대로 쓸 수 있다. f.read()는 전부 한 번에 읽으므로 작은 파일에만 쓴다.

각 줄 끝에 줄바꿈 문자 \n이 붙어 있다는 점이 중요하다. 그대로 쓰면 int("3\n") 같은 데서 문제가 되니 rstrip("\n")이나 strip()으로 떼고 시작한다. write()는 반대로 줄바꿈을 자동으로 붙이지 않는다. print()에 익숙해진 상태에서 자주 빠뜨린다.

CSV — 재고표 읽고 쓰기

CSV는 콤마로 칸을 나눈 텍스트 파일이고 엑셀에서 바로 열린다. 아래 코드 맨 위의 import csv는 파이썬에 기본 포함된 기능 묶음을 불러오는 문장이다(자세한 동작은 8단원에서 다룬다). 6단원처럼 split(",")으로 직접 자를 수도 있지만 그러면 안 된다. 제목에 콤마가 들어가는 순간 무너지기 때문이다. 표준 라이브러리 csv 모듈이 따옴표 처리까지 알아서 한다.

import csv

rows = [
    ["title", "price", "stock"],
    ["파이썬 개론", 28000, 3],
    ["리팩터링", 33000, 0],
    ["SQL 첫걸음", 22000, 12],
]

with open("inventory.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f)
    writer.writerows(rows)
title,price,stock
파이썬 개론,28000,3
리팩터링,33000,0
SQL 첫걸음,22000,12

읽을 때는 DictReader를 권한다. 첫 줄을 열 이름으로 삼아 각 줄을 딕셔너리로 준다. row[0], row[2] 대신 row["stock"]이라고 쓸 수 있어서, 나중에 열 순서가 바뀌어도 코드가 안 깨진다.

import csv

inventory = {}
with open("inventory.csv", encoding="utf-8", newline="") as f:
    reader = csv.DictReader(f)
    for row in reader:
        inventory[row["title"]] = int(row["stock"])   # 2단원: 전부 문자열로 온다

print(inventory)   # {'파이썬 개론': 3, '리팩터링': 0, 'SQL 첫걸음': 12}

int()를 감싼 것을 눈여겨본다. CSV에서 읽은 값은 예외 없이 전부 문자열이다. 이걸 잊으면 2단원에서 본 "12" > 9 사고가 그대로 재현된다.

판매 기록은 DictWriter로 쓴다. 5단원의 함수로 묶어 두면 어디서든 부를 수 있다.

import csv

def save_sales(path, records):
    """records 는 title/qty/amount 키를 가진 딕셔너리의 리스트다."""
    with open(path, "w", encoding="utf-8", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=["title", "qty", "amount"])
        writer.writeheader()
        writer.writerows(records)


sales = [
    {"title": "파이썬 개론", "qty": 2, "amount": 56000},
    {"title": "리팩터링", "qty": 1, "amount": 33000},
]
save_sales("sales.csv", sales)

newline=""csv 모듈을 쓸 때 붙이는 고정 관용구다. 이유는 뒤에서 본다.

JSON — 구조가 있는 데이터

CSV는 표 하나만 담을 수 있다. 책마다 태그가 여러 개 붙는 3단원의 중첩 구조는 CSV로 표현하기 어렵다. 이럴 때 JSON을 쓴다. 파이썬의 딕셔너리·리스트와 모양이 거의 같아서 변환이 자연스럽다.

import json

books = {
    "파이썬 개론": {"price": 28000, "stock": 3, "tags": ["초급", "베스트"]},
    "리팩터링": {"price": 33000, "stock": 0, "tags": ["중급"]},
}

with open("books.json", "w", encoding="utf-8") as f:
    json.dump(books, f, ensure_ascii=False, indent=2)
{
  "파이썬 개론": {
    "price": 28000,
    "stock": 3,
    "tags": [
      "초급",
      "베스트"
    ]
  },
  ...
}

읽기는 json.load() 한 줄이다. 딕셔너리가 그대로 돌아온다.

with open("books.json", encoding="utf-8") as f:
    books = json.load(f)

print(books["파이썬 개론"]["tags"])    # ['초급', '베스트']
print(books["파이썬 개론"]["price"] + 1000)   # 29000  ← 숫자가 숫자로 온다

CSV와 결정적으로 다른 점이 마지막 줄이다. JSON은 숫자·불린·리스트의 타입을 유지한다. 반면 CSV는 전부 문자열이라 매번 int()를 걸어야 한다. 설정 파일이나 API 응답에 JSON을 쓰는 이유가 이것이다.

파일 말고 문자열과 주고받는 함수는 이름 끝에 s가 붙는다. 웹 API를 다룰 때 이쪽을 더 많이 쓴다.

text = json.dumps({"stock": 3})     # 딕셔너리 -> 문자열
data = json.loads(text)             # 문자열 -> 딕셔너리

실무에서 자주 틀리는 것

1. 이어 쓸 자리에 "w" 를 쓴다

판매 기록을 하루치씩 쌓으려고 만든 코드가 이렇게 생겼다면, 실행할 때마다 어제까지의 기록이 통째로 사라진다.

with open("sales.csv", "w", encoding="utf-8", newline="") as f:   # 매번 초기화된다
    ...

"w"여는 순간 파일을 0바이트로 만든다. 뒤에서 예외가 나서 아무것도 못 써도 원본은 이미 사라진 뒤다. 이어 붙일 것이면 "a"다. 다만 "a"writeheader()를 부르면 헤더가 중복해서 쌓이므로, 파일이 없을 때만 헤더를 쓰도록 나눈다.

import csv, os

def append_sale(path, record):
    is_new = not os.path.exists(path)
    with open(path, "a", encoding="utf-8", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=["title", "qty", "amount"])
        if is_new:
            writer.writeheader()
        writer.writerow(record)

중요한 파일을 덮어쓰는 코드를 짤 때는 "x" 모드도 기억해 둔다. 이미 있으면 예외를 내므로 실수로 날리는 일이 없다.

2. csvnewline="" 을 빠뜨린다

이걸 빠뜨리면 윈도우에서 만든 CSV의 모든 줄 사이에 빈 줄이 하나씩 들어간다. 맥과 리눅스에서는 멀쩡해서 개발자가 못 잡고, 엑셀로 여는 담당자만 이상하다고 한다.

원인은 줄바꿈이 두 번 변환되기 때문이다. csv 모듈이 줄 끝에 \r\n을 붙여 내보내는데, 파일 객체가 윈도우에서 다시 \n\r\n으로 바꿔 \r\r\n이 된다. newline=""은 파일 객체에게 "줄바꿈에 손대지 마라"고 지시한다. 읽을 때도 똑같이 붙인다. csv 모듈을 쓰는 open()에는 무조건 newline=""이라고 외워 두면 된다.

3. JSON 파일에 한글이 \ud30c 처럼 저장된다

json.dump(books, f)     # ensure_ascii 를 안 줬다

저장된 파일을 열어 보면 이렇게 되어 있다.

{"\ud30c\uc774\uc36c \uac1c\ub860": {"price": 28000, "stock": 3, ...

파일은 깨진 것이 아니고 다시 json.load()로 읽으면 한글이 정상으로 돌아온다. 하지만 사람이 열어 볼 수 없고, git 에서 무엇이 바뀌었는지 볼 수도 없다. ensure_ascii=False가 기본값이 아닌 것은 오래된 호환성 때문이므로, 한글 데이터를 저장할 때는 항상 ensure_ascii=False를 붙인다. 사람이 읽을 파일이면 indent=2도 같이 준다.

한편 JSON으로 저장할 수 없는 것들이 있다. 셋과 날짜가 대표적이다.

json.dumps({"tags": {"초급", "베스트"}})
# TypeError: Object of type set is not JSON serializable

셋은 list(tags)로, 날짜는 str(d)d.isoformat()으로 바꿔서 넣는다. 그리고 JSON의 키는 반드시 문자열이라, 정수를 키로 쓴 딕셔너리를 저장했다가 읽으면 키가 1에서 "1"로 바뀌어 돌아온다. 오류 없이 타입만 조용히 달라지므로 특히 조심한다.

4. 상대 경로를 쓰고 실행 위치를 잊는다

with open("inventory.csv", encoding="utf-8") as f:   # 어느 폴더의 파일인가?

"inventory.csv"파이썬 파일이 있는 폴더가 아니라 명령을 실행한 현재 폴더를 기준으로 찾는다. 그래서 프로젝트 폴더에서 python3 app/main.py로 돌리면 app/이 아니라 프로젝트 폴더에서 찾는다. 에디터에서는 되고 cron 에 걸면 FileNotFoundError가 나는 전형적인 원인이다. 1단원에서 예고한 함정이 이것이다.

확인은 os.getcwd()로 한다. 해결은 파이썬 파일 위치를 기준으로 경로를 만드는 것이다.

from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent    # 이 .py 파일이 있는 폴더
CSV_PATH = BASE_DIR / "inventory.csv"         # 슬래시로 경로를 잇는다

with open(CSV_PATH, encoding="utf-8", newline="") as f:
    ...

__file__은 실행 중인 파이썬 파일의 경로가 담긴 이름이다. pathlib은 윈도우의 역슬래시와 맥·리눅스의 슬래시 차이도 알아서 처리한다. 경로를 문자열로 이어 붙이는 대신 / 연산자를 쓰는 것이 요즘 방식이다.

스스로 확인하기

  1. inventory.csv를 읽어 재고 2권 이하인 책 제목을 출력한다. 5단원에서 만든 pick_reorder()를 재사용해 쓰면 어떻게 되는가?
  2. 다음 코드로 만든 파일이 비어 있는 경우가 있다. 왜인가?
    f = open("log.txt", "w", encoding="utf-8")
    f.write("판매 기록\n")
    # close 를 부르지 않았다
  3. CSV로 저장한 재고 수량과 JSON으로 저장한 재고 수량을 각각 읽어 stock + 1을 계산한다. 한쪽만 오류가 나는데 어느 쪽이고 왜인가?

정답

  1. 파일에서 딕셔너리를 만든 다음 그대로 넘기면 된다. 함수를 값 기준으로 만들어 뒀기 때문에 데이터가 코드에서 오든 파일에서 오든 상관이 없다. 5단원에서 화면 출력을 함수 밖으로 뺀 효과다.
    import csv
    
    inventory = {}
    with open("inventory.csv", encoding="utf-8", newline="") as f:
        for row in csv.DictReader(f):
            inventory[row["title"]] = int(row["stock"])
    
    print(pick_reorder(inventory))   # ['리팩터링']
  2. 파이썬은 write() 내용을 곧바로 디스크에 쓰지 않고 버퍼에 모아 둔다. close()(또는 with 블록 탈출) 시점에 남은 내용을 내보내는데, 그게 없으면 프로그램이 어떻게 끝나느냐에 따라 내용이 사라진다. 짧은 스크립트는 종료할 때 우연히 정리되어 멀쩡해 보이기도 해서 더 위험하다. with를 쓰면 이 문제가 아예 없다.
  3. CSV 쪽이다. row["stock"]은 문자열 "3"이라 "3" + 1이 되어 TypeError: can only concatenate str (not "int") to str가 난다. JSON은 숫자를 숫자로 복원하므로 그냥 계산된다. CSV를 읽는 코드에는 읽자마자 타입을 되돌리는 줄이 반드시 한 번 있어야 한다.

다음 단원에서는 여기까지 한 파일에 쌓인 코드를 여러 파일로 나눈다. 재고 함수, 영수증 함수, 실행 코드를 분리하고, 파이썬 파일 끝에 늘 보이는 if __name__ == "__main__":이 왜 필요한지를 마지막으로 다룬다.