가져오기·내보내기 - CSV 로 주고받기
이 장에서 배우는 것
앞 장에서 번호로 지출을 찾아 수정하고 삭제했다. 이제 가계부 밖으로 기록을 꺼내고, 다른 곳에서 만든 기록을 가계부 안으로 가져온다. 파일을 읽었다는 사실만으로 가져오기가 끝나지는 않는다. 열 이름이 다르면 뜻을 맞춰야 하고, 같은 파일을 두 번 읽어도 기록이 늘어나지 않아야 한다. 읽지 못한 줄은 이유와 함께 알려 주어야 한다.
이번 장에서는 쉼표로 값을 구분하는 파일 형식인 CSV를 다룬다. 쉼표가 들어간 메모도 있으므로 글자를 직접 나누는 대신 Python에 포함된 csv 모듈을 사용한다.
- 가계부 기록을 CSV 파일로 내보낸다.
- 서로 다른 열 이름을 가계부의 항목 이름에 대응시킨다.
- 날짜·금액·분류·메모가 같은 기록은 중복으로 건너뛴다.
- 잘못된 기록의 줄 번호와 이유를 보고한다.
- 실행 결과와 재실행 결과를 비교해 AI가 만든 코드를 확인한다.
이번 장에서 만들 것
가계부에는 커피값 한 건이 들어 있다. 여기에 거래일, 금액, 분류, 내용이라는 열을 가진 CSV를 가져온다. 파일에는 새 지출 두 건, 이미 있는 지출, 파일 안에서 반복된 지출, 잘못된 기록 세 건이 섞여 있다. 프로그램은 정상 기록 두 건만 추가하고, 중복 두 건과 오류 세 건을 각각 센다.
가져온 뒤에는 기록을 JSON 파일에 저장하고 다시 읽는다. JSON은 앞 장에서 사용한 저장 형식이다. 이어서 CSV를 내보내고, 방금 가져온 파일을 한 번 더 읽는다. 두 번째에는 추가 건수가 0이어야 한다. 날짜나 금액이 잘못된 줄은 다시 읽어도 오류로 남는다.
프로그램은 사용자 입력을 기다리지 않는다. main.py가 임시 폴더를 만들고 예제 파일을 직접 쓴 뒤 시연을 마친다. 임시 폴더는 실행 중에만 존재하고 종료하면 정리된다. 따라서 이번 예제는 개인 파일을 수정하지 않으며, 매번 같은 출력으로 확인할 수 있다.
| 파일의 열 이름 | 내부 이름 | 뜻 |
|---|---|---|
| 거래일 | date | YYYY-MM-DD 형식의 날짜 |
| 금액 | amount | 0보다 큰 정수 금액 |
| 분류 | category | 식비, 교통, 생활 중 하나 |
| 내용 | memo | 지출 메모, 빈 문자열 허용 |
따라하기
아래 요청문은 같은 작업 대화에서 차례로 보낸다. AI의 답은 매번 다를 수 있다. 각 단계의 답변 요지는 정답 문장이 아니라 확인할 방향이다. 중간 코드의 함수 이름이 달라도 동작을 비교한다. 마지막에는 이 장의 완성 코드로 main.py를 맞추어 실행한다.
터미널은 명령을 입력해 프로그램을 실행하는 창이다. 실행 명령은 인용문으로 표시한다. 코드의 변경 전후를 비교한 내용을 diff라고 한다. AI가 코드를 바꿀 때마다 열 이름, 중복 기준, 저장 위치가 요청과 일치하는지 변경 부분을 읽는다.
단계 1. 실행할 범위부터 고정한다
① 보낼 요청문
혼자 쓰는 가계부의 CSV 가져오기·내보내기를 main.py 한 파일로 만들어 줘. Python 3.12 이상 표준 라이브러리만 사용해. 사용자 입력이나 네트워크 호출 없이 python3 main.py로 실행하고 끝나야 해. tempfile.TemporaryDirectory 안에 필요한 파일을 직접 만들고, 실행할 때마다 같은 결과를 출력해. 우선 번호 1, 날짜 2026-10-01, 금액 4500, 분류 식비, 메모 커피인 기록 한 건을 JSON으로 저장하고 다시 읽는 코드부터 작성해.
② AI가 돌려줄 내용의 요지
임시 폴더 안에서 JSON 저장과 읽기를 수행한다. 기존 기록에는 id, date, amount, category, memo 항목을 둔다. 실행이 끝나면 임시 폴더를 정리한다.
③ 직접 확인할 것
python3 -W error -m py_compile main.py
python3 main.py
첫 명령은 프로그램의 문법을 검사하고 경고를 오류로 취급한다. 성공하면 별도 출력 없이 끝난다. 두 번째 명령에서는 다시 읽은 기록이 한 건인지 확인한다. AI에게 임시 폴더의 경로를 출력하지 않도록 요청한다. 폴더 이름은 실행마다 달라질 수 있기 때문이다.
④ 체크포인트
개인 폴더에 데이터 파일을 만들지 않고, 다시 읽은 커피 기록의 금액이 4500이면 다음으로 간다.
단계 2. 쉼표가 들어간 메모를 내보낸다
① 보낼 요청문
기존 코드에 CSV 내보내기 함수를 추가해. 열 순서는 id,date,amount,category,memo로 고정해. csv.DictWriter를 쓰고 파일은 encoding="utf-8-sig", newline=""로 열어. 출력 줄바꿈은 "\n"으로 고정해. 메모에 쉼표가 들어 있어도 한 칸으로 저장되는지 임시 기록으로 확인해. 테스트용 임시 기록은 실제 가계부 목록에는 추가하지 마.
② AI가 돌려줄 내용의 요지
열 이름을 먼저 쓰고 각 기록을 행으로 쓴다. 쉼표를 포함한 메모는 csv 모듈이 큰따옴표로 감싼다. 파일을 다시 읽으면 원래 메모를 얻는다.
③ 직접 확인할 것
python3 main.py
임시 기록의 메모를 ‘버스, 환승’으로 두었다면 파일에서는 큰따옴표로 감싸져야 한다. 다시 읽은 메모에는 큰따옴표가 붙지 않아야 한다. 파일의 글자 표현과 읽은 값은 구별해서 확인한다. utf-8-sig는 글자 인코딩인 UTF-8로 저장하면서 파일 맨 앞에 인코딩 표식을 붙인다. 가져올 때도 같은 설정을 쓰면 이 표식이 첫 열 이름에 섞이지 않는다.
④ 체크포인트
메모 속 쉼표가 열을 늘리지 않고, 내보낸 파일의 열 순서가 고정되어 있으면 다음으로 간다.
단계 3. 다른 열 이름을 연결한다
① 보낼 요청문
가져오기에는 csv.DictReader를 사용해. 내부 이름에서 파일의 열 이름으로 이어지는 대응표를 받아서 읽어. 대응표는 date→거래일, amount→금액, category→분류, memo→내용이야. 파일의 열 순서가 달라도 이름으로 찾아야 해. 필요한 열이 없거나 열 이름이 중복되면 추가하지 말고 오류를 반환해. 파일의 기존 번호는 가져오지 말고 새 번호를 붙일 준비를 해.
② AI가 돌려줄 내용의 요지
열 이름을 검사한 다음 대응표로 값을 꺼낸다. 파일의 열 위치를 숫자로 기억할 필요가 없다. 가계부에서 사용할 번호는 가져온 뒤 정한다.
③ 직접 확인할 것
python3 main.py
임시 확인에서는 열 순서와 각 행의 값 순서를 함께 바꾸어도 같은 기록을 얻어야 한다. ‘내용’을 ‘적요’로 바꾸었다면 대응표도 바꾸어야 읽힌다. 열 이름을 자동으로 추측하도록 바뀌지 않았는지 diff를 읽는다. 이 예제에서는 열 이름을 정확히 대응시키는 쪽을 선택한다.
④ 체크포인트
파일의 열 순서에 의존하지 않고, 필요한 열이 없으면 조용히 빈 값을 넣지 않는다면 다음으로 간다.
단계 4. 잘못된 기록을 줄 번호로 보고한다
① 보낼 요청문
가져온 값의 앞뒤 공백을 제거하고 검사해. 날짜는 실제로 존재하는 YYYY-MM-DD 날짜, 금액은 0보다 큰 ASCII 숫자 정수, 분류는 식비·교통·생활만 허용해. 메모는 빈 문자열을 허용하지만, 메모 칸 자체가 빠진 행은 오류로 처리해. 열보다 값이 많은 행도 오류로 처리해. 오류 행은 건너뛰고 reader.line_num으로 줄 번호와 이유를 모아. strict=True로 읽고 CSV 구문 오류가 나면 그 위치를 보고하고 읽기를 멈춰.
② AI가 돌려줄 내용의 요지
형식과 값을 검사한 기록만 추가 후보로 넘긴다. 값 검사에서 실패한 행은 다음 행으로 넘어간다. 따옴표 구조가 깨져 다음 기록의 경계를 알기 어려우면 읽기를 중단한다.
③ 직접 확인할 것
python3 main.py
최종 예제에서는 4줄의 음수 금액, 5줄의 허용하지 않은 분류, 7줄의 빠진 메모 칸이 보고된다. 빈 메모와 빠진 칸을 같은 것으로 처리하지 않았는지 확인한다. 쉼표 뒤에 빈 값이 있으면 빈 문자열이고, 행이 그 앞에서 끝나면 필요한 칸이 없는 것이다.
④ 체크포인트
오류 이유를 보고 원본 파일의 해당 줄을 찾을 수 있으면 다음으로 간다. 여러 줄 메모가 있는 파일에서는 line_num이 해당 기록을 읽고 난 마지막 물리적 줄 번호라는 점도 기억한다.
단계 5. 같은 기록을 다시 넣지 않는다
① 보낼 요청문
중복 기준은 검사와 공백 정리가 끝난 date, amount, category, memo 네 값의 조합으로 해. id는 중복 기준에서 제외해. 기존 기록과 같은 행뿐 아니라 이번 파일에서 먼저 추가한 행과 같은 행도 건너뛰어. 중복은 오류와 따로 세어. 번호는 현재 가장 큰 id 다음부터, 실제 추가한 기록에만 붙여.
② AI가 돌려줄 내용의 요지
이미 확인한 네 값의 조합을 집합에 보관한다. 새 기록을 추가할 때 집합도 바로 갱신한다. 기존 번호에 빈자리가 있어도 가장 큰 번호 다음을 사용한다.
③ 직접 확인할 것
python3 main.py
커피 기록은 기존 기록과 같아서 중복이다. 버스 기록은 처음에는 추가되고 파일 안에서 다시 나오면 중복이다. 최종 결과는 추가 2건, 중복 2건, 오류 3건이어야 한다. 잘못된 기록이 먼저 집합에 들어가 정상 기록을 막지 않도록 검사 순서도 읽는다.
④ 체크포인트
기존 목록과 파일 내부 양쪽에서 중복을 막고, 새 기록 번호가 2와 3이면 다음으로 간다.
단계 6. 같은 파일을 두 번 가져온다
① 보낼 요청문
첫 가져오기 뒤 JSON으로 저장하고 다시 읽어. 그 목록을 CSV로 내보낸 다음, 같은 가져오기 파일을 두 번째로 읽어. 두 번째 결과는 추가 0건, 중복 4건, 오류 3건이어야 해. 내보낸 CSV를 다시 읽어 기록 수가 3이고 두 번째 기록의 메모가 버스, 환승인지 assert로 확인해. assert는 조건이 거짓이면 실행을 멈추는 확인문이야.
② AI가 돌려줄 내용의 요지
저장 후 다시 읽은 데이터로도 중복을 판별한다. 재가져오기는 목록을 늘리지 않는다. CSV에서 메모를 다시 읽어 쉼표가 보존되었는지 확인한다.
③ 직접 확인할 것
python3 main.py
재가져오기에서 중복이 4건인 이유는 읽은 정상 행이 네 개이기 때문이다. 서로 다른 기록 수를 세는 것이 아니라 건너뛴 행 수를 센다. 처음의 커피 한 행, 버스 두 행, 점심 한 행이 모두 기존 기록과 겹친다.
④ 체크포인트
저장과 다시 읽기를 거쳐도 결과가 유지되고 확인문이 모두 통과하면 다음으로 간다.
단계 7. 완성본의 변경과 출력을 확인한다
① 보낼 요청문
지금까지의 코드를 한 파일로 정리해. 시연 자료는 커피 중복, 버스 추가, 음수 금액, 잘못된 분류, 버스 중복, 메모 칸 누락, 점심 추가 순서로 만들어. 임시 확인용 출력은 제거하고 첫 결과, 오류 목록, 내보낸 CSV, 두 번째 결과, 확인 통과 메시지만 출력해. 변경한 부분과 확인한 조건을 설명해. 외부 패키지는 추가하지 마.
② AI가 돌려줄 내용의 요지
입력 자료와 출력 순서를 고정한 완성본을 제공한다. 열 대응, 오류 처리, 중복 검사, 저장 후 재가져오기 부분을 설명한다. 실제 실행 여부와 예상만 한 내용을 구분한다.
③ 직접 확인할 것
python3 -W error -m py_compile main.py
python3 main.py
아래 완성 코드를 main.py에 넣고 두 명령을 실행한다. ‘확인 통과’ 한 줄만 보지 말고 전체 출력을 실행 결과와 대조한다. AI가 실행했다고 답하더라도 자신의 환경에서 다시 실행한다. diff에서는 중복 키에 번호가 들어갔는지, 예외를 숨기는 코드가 생겼는지, 임시 폴더 밖으로 저장 위치가 바뀌었는지 확인한다.
④ 체크포인트
경고 없이 문법 검사를 마치고 전체 출력이 일치하면 이번 장의 시연이 끝난다.
완성 코드
다음은 앞 장의 파일 없이 실행할 수 있는 main.py 전체다. 앞 장에서 사용한 기록 번호와 JSON 저장 방식을 포함한다. 검색·수정·삭제의 대화형 메뉴는 이번 시연에 필요하지 않아 넣지 않는다. 문자열을 파일처럼 읽는 StringIO는 내보낸 내용을 다시 검사할 때 사용한다.
import csv
import io
import json
import tempfile
from datetime import date
from pathlib import Path
FIELDS = ["id", "date", "amount", "category", "memo"]
CATEGORIES = {"식비", "교통", "생활"}
COLUMN_MAP = {
"date": "거래일",
"amount": "금액",
"category": "분류",
"memo": "내용",
}
def save_records(path, records):
path.write_text(
json.dumps(records, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def load_records(path):
return json.loads(path.read_text(encoding="utf-8"))
def record_key(record):
return tuple(record[name] for name in FIELDS[1:])
def validate_row(row, column_map):
if None in row:
raise ValueError("열보다 값이 많음")
if any(row[source] is None for source in column_map.values()):
raise ValueError("필요한 칸이 빠짐")
values = {
name: row[source].strip()
for name, source in column_map.items()
}
try:
parsed_date = date.fromisoformat(values["date"])
except ValueError:
raise ValueError("날짜 형식 또는 값 오류") from None
if parsed_date.isoformat() != values["date"]:
raise ValueError("날짜 형식 또는 값 오류")
amount = values["amount"]
if not amount.isascii() or not amount.isdecimal():
raise ValueError("금액은 양의 정수여야 함")
try:
number = int(amount)
except ValueError:
raise ValueError("금액을 정수로 변환할 수 없음") from None
if number <= 0:
raise ValueError("금액은 양의 정수여야 함")
if values["category"] not in CATEGORIES:
raise ValueError("허용하지 않은 분류")
values["amount"] = number
return values
def import_csv(path, records, column_map):
added = 0
duplicates = 0
errors = []
seen = {record_key(record) for record in records}
next_id = max((record["id"] for record in records), default=0) + 1
with path.open(encoding="utf-8-sig", newline="") as file:
reader = csv.DictReader(file, strict=True)
try:
headers = reader.fieldnames or []
if (
len(headers) != len(set(headers))
or not set(column_map.values()).issubset(headers)
):
return 0, 0, ["헤더: 필요한 열 누락 또는 열 이름 중복"]
for row in reader:
try:
record = validate_row(row, column_map)
except ValueError as error:
errors.append(f"{reader.line_num}줄: {error}")
continue
key = record_key(record)
if key in seen:
duplicates += 1
continue
record["id"] = next_id
records.append(record)
seen.add(key)
next_id += 1
added += 1
except csv.Error:
errors.append(f"{reader.line_num}줄: CSV 구문 오류, 읽기 중단")
return added, duplicates, errors
def export_csv(path, records):
with path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(
file, fieldnames=FIELDS, lineterminator="\n"
)
writer.writeheader()
writer.writerows(records)
def make_sample(path):
rows = [
["2026-10-01", "4500", "식비", "커피"],
["2026-10-02", "1500", "교통", "버스, 환승"],
["2026-10-03", "-500", "식비", "잘못된 금액"],
["2026-10-03", "3000", "오락", "잘못된 분류"],
["2026-10-02", "1500", "교통", "버스, 환승"],
["2026-10-04", "2000", "생활"],
["2026-10-05", "9000", "식비", "점심"],
]
with path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file, lineterminator="\n")
writer.writerow(COLUMN_MAP.values())
writer.writerows(rows)
def main():
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
store = root / "records.json"
source = root / "incoming.csv"
target = root / "export.csv"
initial = [
{
"id": 1,
"date": "2026-10-01",
"amount": 4500,
"category": "식비",
"memo": "커피",
}
]
save_records(store, initial)
records = load_records(store)
make_sample(source)
added, duplicates, errors = import_csv(
source, records, COLUMN_MAP
)
assert (added, duplicates, len(errors)) == (2, 2, 3)
print(
f"첫 가져오기: 추가 {added}건, "
f"중복 {duplicates}건, 오류 {len(errors)}건"
)
for error in errors:
print(error)
save_records(store, records)
records = load_records(store)
assert [record["id"] for record in records] == [1, 2, 3]
export_csv(target, records)
exported = target.read_text(encoding="utf-8-sig")
restored = list(csv.DictReader(io.StringIO(exported)))
assert len(restored) == 3
assert restored[1]["memo"] == "버스, 환승"
assert int(restored[1]["amount"]) == 1500
print("내보낸 CSV:")
print(exported, end="")
before = [record.copy() for record in records]
added, duplicates, errors = import_csv(
source, records, COLUMN_MAP
)
assert (added, duplicates, len(errors)) == (0, 4, 3)
assert records == before
print(
f"두 번째 가져오기: 추가 {added}건, "
f"중복 {duplicates}건, 오류 {len(errors)}건"
)
print("확인 통과: CSV 읽기와 재가져오기")
if __name__ == "__main__":
main()
줄별 해설
가져오는 모듈과 상수. import로 시작하는 줄은 이번 프로그램에 필요한 도구를 불러온다. FIELDS는 내보내기 열 순서다. CATEGORIES는 허용할 분류를 모은 집합이다. 집합은 같은 값을 여러 번 보관하지 않으며, 어떤 값이 들어 있는지 확인할 때 사용한다. COLUMN_MAP은 내부 이름을 주면 파일에서 읽을 열 이름을 알려 주는 사전이다.
save_records와 load_records. 저장 함수는 기록 목록을 JSON 문자열로 바꾸어 파일에 쓴다. ensure_ascii=False는 한글을 사람이 읽는 글자로 남긴다. 읽기 함수는 JSON 문자열을 다시 목록과 사전으로 바꾼다. 여기서는 프로그램이 만든 JSON을 다시 읽는다. 외부에서 전달받은 JSON까지 검사하는 기능은 포함하지 않는다.
record_key. FIELDS[1:]은 첫 항목인 id를 제외한 항목들이다. 이 순서로 네 값을 묶어 튜플을 만든다. 튜플은 여러 값을 순서대로 묶은 형태다. 기록 번호가 달라도 네 값이 같으면 같은 중복 키가 된다.
validate_row의 앞부분. DictReader는 헤더보다 값이 많은 행의 남는 값을 None이라는 특별한 키 아래에 둔다. 필요한 값이 모자라면 해당 열의 값이 None이 된다. 두 경우를 먼저 걸러야 뒤에서 strip을 안전하게 호출할 수 있다. strip은 문자열 앞뒤의 공백을 제거한다. 메모 내부의 쉼표나 띄어쓰기는 지우지 않는다.
날짜 검사. date.fromisoformat은 문자열을 날짜로 바꾼다. 존재하지 않는 날짜는 ValueError라는 예외를 발생시킨다. 예외는 정상 작업을 이어 가기 어려운 상황을 알리는 신호다. 변환 후 isoformat의 결과와 원문도 비교하여 ‘20261001’처럼 변환은 가능하지만 이번 규칙과 다른 표기를 거부한다.
금액과 분류 검사. isascii와 isdecimal을 함께 사용하여 0부터 9까지의 숫자로 적힌 금액인지 확인한다. 따라서 쉼표가 있는 ‘1,500’, 소수, 음수는 받지 않는다. int로 바꾼 뒤에는 0도 거부한다. 매우 긴 숫자라서 변환이 실패하는 경우에도 행 오류로 보고한다. 분류는 허용 목록에 들어 있는지 확인한다.
import_csv의 준비. seen은 이미 있는 기록의 중복 키를 모은 집합이다. next_id는 현재 가장 큰 번호보다 1 크다. 기록 세 개 중 번호 2를 지웠다고 해서 목록 길이에 1을 더해 번호를 정하면 기존 번호와 겹칠 수 있으므로 최댓값을 쓴다.
헤더 검사와 행 반복. fieldnames로 열 이름을 읽고, 중복된 이름이나 필수 열의 누락을 확인한다. 추가 열은 허용하되 사용하지 않는다. for row in reader가 한 기록씩 읽는다. 값 검사 실패는 오류 목록에 넣고 continue로 다음 기록으로 넘어간다.
추가 순서. 검사에 통과한 기록의 키가 seen에 있으면 중복 수만 올린다. 없으면 번호를 붙여 records에 추가하고 seen도 갱신한다. 이 갱신 덕분에 같은 파일 안의 중복도 걸러진다. 번호는 실제로 추가했을 때만 증가한다.
CSV 구문 오류 처리. 따옴표가 닫히지 않는 등의 구문 오류는 바깥쪽 except csv.Error에서 처리한다. 이때는 읽기를 멈추고, 그 전에 추가한 정상 기록은 유지한다. 즉, 이 프로그램은 읽을 수 있었던 정상 기록을 부분적으로 반영하는 정책이다. 파일 전체를 취소하는 정책과 다르므로 요청문과 실제 코드를 함께 확인해야 한다.
내보내기와 시연. DictWriter는 FIELDS 순서로 값을 쓴다. newline=""는 csv 모듈에 줄바꿈 처리를 맡기는 설정이고, lineterminator는 쓸 줄바꿈 문자를 고정한다. make_sample은 의도적으로 잘못된 행까지 담는다. main의 assert들은 건수, 번호, 쉼표가 들어간 메모, 재가져오기 전후의 목록을 확인한다. 이번 시연에서는 assert가 생략되는 실행 옵션을 사용하지 않는다.
실행 결과
main.py가 있는 폴더의 터미널에서 차례로 실행한다.
python3 -W error -m py_compile main.py
python3 main.py
첫 명령은 성공하면 출력 없이 끝난다. 두 번째 명령의 예상 출력은 다음과 같다. CSV 헤더가 1줄이므로 첫 데이터는 2줄부터 시작한다.
첫 가져오기: 추가 2건, 중복 2건, 오류 3건
4줄: 금액은 양의 정수여야 함
5줄: 허용하지 않은 분류
7줄: 필요한 칸이 빠짐
내보낸 CSV:
id,date,amount,category,memo
1,2026-10-01,4500,식비,커피
2,2026-10-02,1500,교통,"버스, 환승"
3,2026-10-05,9000,식비,점심
두 번째 가져오기: 추가 0건, 중복 4건, 오류 3건
확인 통과: CSV 읽기와 재가져오기
내보내기 파일은 id,date,amount,category,memo라는 열 이름을 쓴다. 이 파일을 가져오려면 date→date, amount→amount, category→category, memo→memo라는 대응표를 사용한다. id 열은 읽지 않고 현재 가계부에서 번호를 새로 붙인다. 빈 가계부에서는 세 기록이 추가되고, 같은 기록이 있는 가계부에서는 중복으로 처리된다.
이 예제의 중복 기준에는 한계가 있다. 같은 날 같은 금액으로 같은 분류와 메모를 가진 지출을 실제로 두 번 했다면 두 번째 기록도 중복으로 판단한다. 여기서는 재가져오기 방지를 우선한다. 실제 사용에 맞추려면 거래를 구별할 별도 값이 필요한지 먼저 결정해야 한다.
실무에서 자주 틀리는 것
쉼표로 문자열을 직접 나눈다
다음 잘못된 방식은 따옴표 안의 쉼표도 나누어 네 칸을 만든다. 짧은 예제는 각각 독립해서 실행할 수 있다.
line = '2026-10-02,1500,"버스, 환승"'
parts = line.split(",")
assert len(parts) == 4
print("잘못 나눈 칸 수:", len(parts))
csv.reader로 읽으면 메모를 포함해 세 칸을 얻는다.
import csv
line = '2026-10-02,1500,"버스, 환승"'
parts = next(csv.reader([line]))
assert parts == ["2026-10-02", "1500", "버스, 환승"]
print("올바른 칸 수:", len(parts))
빈 메모와 빠진 메모 칸을 합친다
다음 방식은 빈 문자열과 None을 모두 ‘값 없음’으로 취급한다. 메모를 비워 두어도 된다는 규칙을 지킬 수 없다.
rows = [{"내용": ""}, {"내용": None}]
results = [bool(row["내용"]) for row in rows]
assert results == [False, False]
print(results)
None인지 직접 비교하면 빠진 칸만 골라낼 수 있다. None은 빈 문자열과 다른 값이다.
rows = [{"내용": ""}, {"내용": None}]
results = [row["내용"] is None for row in rows]
assert results == [False, True]
print(results)
처음에 만든 중복 목록을 갱신하지 않는다
기존 기록만 확인하고 새 값을 집합에 넣지 않으면 한 파일 안의 반복을 놓친다.
seen = set()
added = []
for key in ["버스", "버스"]:
if key not in seen:
added.append(key)
assert len(added) == 2
print("잘못 추가한 수:", len(added))
추가한 직후 집합을 갱신한다. 완성 코드에서는 단어 대신 날짜·금액·분류·메모의 조합을 사용한다.
seen = set()
added = []
for key in ["버스", "버스"]:
if key not in seen:
added.append(key)
seen.add(key)
assert len(added) == 1
print("올바르게 추가한 수:", len(added))
이 세 가지를 수정할 때도 코드 모양만 보고 끝내지 않는다. 완성본을 다시 실행하여 첫 가져오기와 재가져오기 건수가 유지되는지 확인하고, diff에서 관련 없는 저장 코드까지 바뀌지 않았는지 읽는다.
한눈에 보기
| 순서 | 확인 내용 | 실패하거나 겹치면 | 통과하면 |
|---|---|---|---|
| 1 | 헤더의 필수 열과 중복 이름 | 가져오기 중단 | 기록 읽기 |
| 2 | 칸 수, 날짜, 금액, 분류 | 줄 번호와 오류 보고 | 중복 키 만들기 |
| 3 | 기존 기록과 파일 내부 중복 | 중복 건수 증가 | 새 번호로 추가 |
| 4 | 다시 읽은 파일과 재가져오기 | 결과와 변경 코드 점검 | 시연 확인 완료 |
값 오류는 해당 기록을 건너뛰고 계속 읽는다. CSV 구문 오류는 기록 경계를 신뢰하기 어려우므로 읽기를 멈춘다. 둘 다 오류 목록에 남지만 처리 범위가 다르다. 가져오기 결과에는 추가 수만 쓰지 말고 중복 수와 오류 수도 함께 보여 준다.
연습 문제
- 가져올 파일의 ‘내용’ 열 이름을 ‘적요’로 바꾸어라. 대응표도 바꾸고, 완성본과 같은 출력이 나오는지 확인하라.
- make_sample의 마지막에 날짜 2026-10-06, 금액 2000, 분류 생활, 빈 메모인 행을 추가하라. 첫 가져오기와 두 번째 가져오기의 건수를 예상하고 확인문도 수정하라.
- 내보낸 CSV를 빈 목록으로 가져오도록 별도 확인을 추가하라. 내부 이름과 같은 열 이름을 쓰는 대응표를 만들고, 가져온 메모와 금액이 유지되는지 확인하라.
- 마지막 행 대신 닫히지 않은 큰따옴표가 들어간 기록을 파일 끝에 붙인다고 하자. 읽기를 멈춘 뒤에도 앞서 추가한 기록이 남는 이유를 설명하라. 전체 취소가 필요하다면 어느 목록에 먼저 추가해야 할지도 설명하라.
정답과 해설
COLUMN_MAP에서 memo에 대응하는 값을 ‘적요’로 바꾼다. make_sample은 COLUMN_MAP.values()로 헤더를 만들므로 예제 파일의 열 이름도 함께 바뀐다. 날짜·금액·분류·메모의 내용은 바뀌지 않아 예상 출력도 같다. 실제 외부 파일이라면 그 파일의 헤더가 정말 ‘적요’인지 별도로 확인해야 한다.
추가할 행은 네 번째 값으로 빈 문자열을 포함해야 한다. 첫 결과는 추가 3건, 중복 2건, 오류 3건이다. 최종 기록은 네 건이고 번호는 1, 2, 3, 4다. 두 번째 결과는 추가 0건, 중복 5건, 오류 3건이다. 건수 확인문, 번호 확인문, 내보내기 기록 수 확인문을 함께 바꾼다. 행을 세 값만으로 만들면 빈 메모가 아니라 칸 누락 오류가 된다.
date, amount, category, memo 각각을 같은 이름의 파일 열에 대응시킨다. 빈 목록에 target 파일을 가져오면 추가 3건, 중복 0건, 오류 0건이다. 두 번째 기록의 메모는 ‘버스, 환승’, 금액은 정수 1500이어야 한다. 내보낸 id는 사용하지 않고 빈 목록에서 새로 번호를 붙인다. 이번 자료에서는 번호가 같아 보이지만 번호를 보존하는 기능을 만든 것은 아니다.
import_csv는 정상 기록을 발견할 때마다 전달받은 records에 바로 추가한다. csv.Error를 처리하는 부분에는 이미 추가한 기록을 되돌리는 코드가 없으므로 앞선 결과가 남는다. 전체 취소가 필요하다면 원본과 분리한 임시 목록에 먼저 추가하고, 끝까지 허용할 수 있는 상태인지 확인한 뒤 원본에 반영해야 한다. 값 오류까지 전체 취소 사유로 삼을지도 요청문에 명시해야 한다.