AI 개발 · 실무
바이브 코딩 실전 예제 - 장마다 하나씩 완성하는 12가지 프로그램
성적표 CSV 집계 리포트 - 평균·순위·동점 처리
csv 모듈로 읽기, 빈 칸·결시 처리, 동점 순위 규칙을 명세에 적기, AI 가 놓치는 동점·반올림, 텍스트 표 리포트 출력
개발자KR · 원고 갱신
이 장에서 배우는 것
성적표를 읽어 평균을 구하는 일은 짧은 반복문으로 끝날 것처럼 보인다. 그러나 빈 칸을 0점으로 바꾸거나, 반올림한 평균으로 순위를 매기면 계산은 실행되면서도 결과의 뜻은 달라진다. 이 장에서는 계산식을 작성하기 전에 성적을 어떻게 해석할지 정하고, 그 규칙을 검사하는 작은 프로그램을 완성한다.
완성 프로그램은 임시 폴더에 예제 CSV 파일을 만들고 다시 읽은 뒤 텍스트 표를 출력한다. Python 3.12 이상의 표준 라이브러리만 사용하며, 실행할 때 입력하거나 별도로 준비할 파일은 없다. 같은 코드로 실행하면 같은 결과가 나온다.
- csv 모듈로 열 이름을 확인하며 성적표를 읽는다.
- 숫자 0, 빈 칸, 결시를 서로 다른 상태로 처리한다.
- 동점 순위와 평균 반올림 규칙을 요청문에 명시한다.
- 작은 검사 데이터로 AI가 만든 계산과 정렬을 확인한다.
- 검증한 결과를 텍스트 표로 출력한다.
문제 상황
작은 학습 모임의 평가 담당자가 국어·영어·수학 점수를 모았다. 전달받은 CSV에는 아직 입력하지 않은 칸과 시험에 응시하지 않았다는 뜻의 ‘결시’가 섞여 있다. 담당자는 다음 모임에 공유할 평균·순위 리포트를 원한다. 이번 리포트에서는 세 과목의 점수가 모두 있는 사람만 집계하기로 했다.
이 결정은 빈 칸과 결시를 무시하고 남은 과목으로 평균을 낸다는 뜻이 아니다. 두 상태 모두 총점·평균·순위에서 제외하되, 입력 상태의 개수는 따로 알린다. 예를 들어 100점과 90점 사이에 빈 칸이 있다면 평균 95점을 부여하지 않는다. 반대로 0점은 응시 결과이므로 정상 점수로 계산한다.
동점도 합의가 필요하다. 공동 2위가 두 명이라면 다음 사람은 4위로 표시한다. 동점자의 출력 순서는 학번 오름차순으로 정한다. 이름은 식별자로 쓰지 않는다. 같은 이름을 가진 사람이 있을 수 있기 때문이다.
| 항목 | 이번 프로그램의 규칙 | 확인할 사례 |
|---|---|---|
| 입력 열 | 학번, 이름, 국어, 영어, 수학 순서로 고정한다. | 과목 열 누락은 오류다. |
| 유효 점수 | 0 이상 100 이하이며 소수 자리는 최대 세 자리다. | 0은 유효하고 100.001은 오류다. |
| 빈 칸과 결시 | 하나라도 있으면 그 학생의 집계를 제외한다. | 공백만 있는 칸도 빈 칸이다. |
| 평균 표시 | 세 과목 총점을 3으로 나누고 소수 둘째 자리까지 사사오입한다. | 80.005는 80.01로 표시한다. |
| 순위 | 반올림 전 총점으로 비교하며 공동 순위 뒤의 번호를 건너뛴다. | 1, 2, 2, 4가 된다. |
| 출력 순서 | 집계 대상은 총점 내림차순·학번 오름차순이다. 제외 대상은 마지막에 학번순으로 둔다. | 같은 점수라도 실행마다 순서가 바뀌지 않는다. |
요구사항에서 정하지 않은 내용을 AI가 알아서 채우면, 사람이 기대한 성적 처리 관행과 다른 프로그램이 나올 수 있다. 특히 ‘평균을 구하고 순위를 출력하라’라는 문장에는 빈 칸을 어떻게 취급할지, 무엇을 동점으로 볼지, 공동 순위 다음 번호가 무엇인지가 들어 있지 않다.
입력 상태와 계산값을 분리한다
CSV는 쉼표로 구분한 값(comma-separated values)을 저장하는 형식이다. 단순히 한 줄을 쉼표로 나누면 따옴표 안의 쉼표까지 열 구분자로 처리할 수 있다. 이 프로그램은 csv.DictReader를 사용해 각 행을 열 이름으로 읽는다. 파일은 newline=""로 열어 줄바꿈 처리를 csv 모듈에 맡긴다.
인코딩은 utf-8-sig로 정한다. 이 방식으로 읽으면 UTF-8 파일 앞에 붙는 바이트 순서 표식(byte order mark)을 처리할 수 있다. 표식이 첫 번째 열 이름에 붙어 ‘학번’ 비교가 실패하는 경우를 피한다. 다만 다른 인코딩으로 저장된 파일까지 자동 판별하는 기능은 아니다.
점수 변환은 세 갈래다. 앞뒤 공백을 제거한 값이 비어 있으면 빈 칸, ‘결시’라면 결시, 그 밖의 값은 숫자 후보로 해석한다. 숫자 후보가 잘못되었다면 제외 대상으로 조용히 넘기지 않고 입력 오류를 알린다. ‘결시’와 ‘결시함’은 서로 다른 문자열이다. 오타를 허용된 상태로 바꾸지 않아야 입력자가 수정할 기회를 얻는다.
숫자는 Decimal로 보관한다. 문자열에서 직접 만들면 입력한 십진 소수의 값을 유지하기 쉽다. 먼저 float로 바꾼 뒤 Decimal로 감싸면 이미 이진 부동소수점 변환을 거친 값이 들어간다. 이 장의 목적에는 문자열에서 바로 변환하는 편이 맞다.
Decimal이라고 해서 모든 연산이 무제한 정밀도로 실행되는 것은 아니다. 나눗셈 등은 정밀도 문맥의 영향을 받는다. 완성 코드에서는 평균을 표시하는 구간에 정밀도 28을 명시한다. 점수는 최대 세 자리 소수이고 과목은 세 개로 고정하므로, 순위 비교에 쓰는 총점은 작은 자릿수 안에서 정확하게 더할 수 있다.
학번은 숫자로 바꾸지 않고 네 자리 문자열로 제한한다. ‘0001’의 앞자리 0을 유지하고, 네 자리끼리의 문자열 정렬이 번호 순서와 같아지도록 하기 위해서다. 이름은 비어 있는지만 확인한다. 학번 중복은 오류지만 이름 중복은 허용한다.
순위 기준과 표시 자릿수를 따로 정한다
세 과목을 모두 응시한 사람끼리는 총점이 같으면 평균도 같다. 따라서 순위는 총점으로 정할 수 있다. 평균을 나눈 결과나 화면에 표시한 문자열을 다시 순위 계산에 넣을 필요가 없다. 계산용 총점과 표시용 평균을 분리하면 반올림이 순위를 바꾸는 일을 막는다.
예제에서 예솔의 총점은 240.010점이고 다온은 240.000점이다. 둘의 평균은 모두 80.00으로 표시되지만 동점은 아니다. 총점이 더 높은 예솔이 먼저다. 표시 자릿수 때문에 가려진 차이와 실제 동점을 구분하는 사례다.
순위 계산에는 정렬된 목록의 위치를 이용한다. 첫 번째 학생부터 1, 2, 3으로 위치를 세고, 직전 총점과 달라졌을 때만 현재 위치를 새 순위로 채택한다. 총점이 같으면 앞서 정한 순위를 그대로 쓴다. 이 방식에서는 위치가 2와 3인 두 학생이 모두 2위를 받고, 다음 위치인 4가 새 순위가 된다.
평균 표시는 ROUND_HALF_UP을 지정한다. 점수 범위가 음수가 아니므로 이 장에서 말하는 사사오입과 맞는다. Python의 기본 round는 정확히 중간인 값을 처리할 때 다른 규칙을 쓰므로, ‘소수 둘째 자리’라는 말만으로 원하는 반올림 방식이 정해지지는 않는다.
AI 요청문을 검사 가능한 약속으로 바꾼다
AI에게 보내는 첫 요청에는 입력·계산·오류·출력 규칙을 함께 적는다. 다음 요청문은 특정 제품 기능에 의존하지 않는다. 코드를 받는 것으로 작업을 끝내지 않고, 반례를 실행해 규칙이 지켜졌는지 확인하는 데 목적이 있다.
Python 3.12 이상 표준 라이브러리로 main.py 한 파일을 작성하라. tempfile.TemporaryDirectory 안에 예제 성적표 CSV를 만들고 csv.DictReader로 다시 읽어 텍스트 표를 출력하라. 네트워크나 사용자 입력은 사용하지 말라.
열은 학번, 이름, 국어, 영어, 수학 순서로 고정하라. 학번은 중복 없는 네 자리 ASCII 숫자 문자열이다. 점수는 0~100이며 소수 자리는 최대 세 자리다. 빈 칸과 결시는 구분해서 세고, 둘 중 하나라도 있는 학생은 총점·평균·순위에서 제외하라. 다른 잘못된 값은 행과 열을 알리는 오류로 처리하라.
점수는 문자열에서 Decimal로 변환하라. 세 과목 총점으로 순위를 정하고 공동 순위 뒤 번호를 건너뛰어라. 평균은 ROUND_HALF_UP으로 소수 둘째 자리까지 표시하라. 집계 대상은 총점 내림차순과 학번 오름차순, 제외 대상은 마지막에 학번순으로 출력하라. 동점·표시 평균만 같은 경우·0점·반올림·잘못된 입력을 검사하는 함수를 포함하라.
이 요청에 대한 1차 결과를 검토한다고 가정하자. 다음 표는 실제로 자주 등장하는 구현 형태를 반례와 연결한 검토 기록이다. 특정 도구에서 얻은 실제 대화 기록을 인용한 것은 아니다. 어떤 AI를 사용하더라도 같은 입력으로 확인할 수 있다.
| 문제 | 발견 방법 | 고친 방향 |
|---|---|---|
| 빈 문자열을 0으로 치환한다. | 100, 빈 칸, 90을 넣었더니 평균 63.33이 나온다. | 상태를 먼저 판별하고 해당 학생의 집계를 제외한다. |
| 정렬 후 위치를 그대로 순위로 쓴다. | 총점 255, 250, 250, 240.01에서 1, 2, 3, 4가 나온다. | 직전 총점과 같으면 직전 순위를 유지한다. |
| 표시 평균으로 동점을 판정한다. | 240.01점과 240점이 평균 80.00이라는 이유로 공동 순위가 된다. | 총점으로 비교하고 평균 문자열은 출력에만 쓴다. |
| 기본 반올림에 맡긴다. | 평균 80.005가 규칙대로 80.01인지 확인한다. | Decimal과 ROUND_HALF_UP을 명시한다. |
| 기본 인코딩으로 첫 열을 읽는다. | 표식이 있는 UTF-8 파일에서 ‘학번’ 열 검사가 실패한다. | utf-8-sig와 정확한 헤더 검사를 사용한다. |
수정 요청도 발견한 증상과 기대 결과를 연결한다. ‘순위를 제대로 고쳐라’보다 입력과 정답이 있는 요청이 검토하기 쉽다.
순위 검사에서 총점 255, 250, 250, 240.01, 240의 결과가 1, 2, 2, 4, 5가 되도록 수정하라. 마지막 두 학생은 표시 평균이 같아도 동점이 아니다. 빈 칸을 0으로 바꾸는 처리를 제거하고, 평균 80.005의 표시가 80.01인지 검사하라. 수정한 함수와 변경 이유를 설명하라.
수정본을 받으면 먼저 실행하고, 이어서 변경 차이(diff)를 읽는다. 점수 변환·정렬·평균 표시 함수에 필요한 수정이 들어갔는지 확인한다. 검사를 통과시키려고 기대 순위를 바꾸거나 잘못된 입력 검사를 삭제했다면 받아들이지 않는다. 검사도 구현과 함께 검토할 대상이다.
완성 코드의 검사는 출력 예시 확인에 그치지 않는다. 실제 CSV를 읽은 결과로 순위와 제외 상태를 확인하고, 별도의 잘못된 값이 ValueError를 일으키는지도 검사한다. assert를 사용하는 자체 검사는 일반 실행을 기준으로 한다. 최적화 옵션으로 검사를 생략한 실행을 검증 결과로 삼지 않는다.
완성 코드
다음 내용을 main.py로 저장한다. 파일을 만들고 읽는 동작은 임시 폴더 안에서 끝나며, 폴더 경로는 출력하지 않는다. 따라서 임시 경로가 매번 달라도 리포트 내용은 같다.
import csv
import tempfile
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, localcontext
from pathlib import Path
SUBJECTS = ("국어", "영어", "수학")
HEADERS = ("학번", "이름", *SUBJECTS)
SAMPLE_ROWS = (
("0001", "가온", "90", "80", "80"),
("0002", "나래", "84", "83", "83"),
("0003", "다온", "80", "80", "80"),
("0004", "라온", "100", "", "90"),
("0005", "마루", "결시", "70", "80"),
("0006", "바다", "85", "85", "85"),
("0007", "예솔", "80.01", "80", "80"),
)
@dataclass(frozen=True)
class Student:
student_id: str
name: str
scores: tuple[Decimal | None, ...]
blanks: int
absences: int
@property
def total(self) -> Decimal | None:
if any(score is None for score in self.scores):
return None
return sum(
(score for score in self.scores if score is not None),
Decimal("0"),
)
def parse_score(raw: str, location: str) -> tuple[Decimal | None, str]:
value = raw.strip()
if value == "":
return None, "blank"
if value == "결시":
return None, "absent"
try:
score = Decimal(value)
except InvalidOperation as exc:
raise ValueError(f"{location}: 숫자가 아님") from exc
if not score.is_finite():
raise ValueError(f"{location}: 유한한 점수가 아님")
if not Decimal("0") <= score <= Decimal("100"):
raise ValueError(f"{location}: 점수 범위 오류")
if score.as_tuple().exponent < -3:
raise ValueError(f"{location}: 소수 자리는 최대 세 자리")
return score, "score"
def read_students(path: Path) -> list[Student]:
students = []
seen_ids = set()
with path.open("r", encoding="utf-8-sig", newline="") as file:
reader = csv.DictReader(file)
if reader.fieldnames != list(HEADERS):
raise ValueError("헤더: 열 이름과 순서가 맞지 않음")
for row_number, row in enumerate(reader, start=2):
if None in row or any(value is None for value in row.values()):
raise ValueError(f"{row_number}행: 열 개수 오류")
student_id = row["학번"].strip()
name = row["이름"].strip()
if (
len(student_id) != 4
or not student_id.isascii()
or not student_id.isdigit()
):
raise ValueError(f"{row_number}행: 학번 형식 오류")
if student_id in seen_ids:
raise ValueError(f"{row_number}행: 학번 중복")
if not name:
raise ValueError(f"{row_number}행: 이름이 비어 있음")
scores = []
blanks = 0
absences = 0
for subject in SUBJECTS:
score, state = parse_score(
row[subject], f"{row_number}행 {subject}"
)
scores.append(score)
blanks += int(state == "blank")
absences += int(state == "absent")
students.append(
Student(student_id, name, tuple(scores), blanks, absences)
)
seen_ids.add(student_id)
return students
def rank_students(students: list[Student]) -> list[tuple[Student, int]]:
eligible = []
for student in students:
total = student.total
if total is not None:
eligible.append((student, total))
eligible.sort(key=lambda item: (-item[1], item[0].student_id))
ranked = []
previous_total = None
rank = 0
for position, (student, total) in enumerate(eligible, start=1):
if total != previous_total:
rank = position
ranked.append((student, rank))
previous_total = total
return ranked
def average_text(total: Decimal) -> str:
with localcontext() as context:
context.prec = 28
average = total / Decimal(len(SUBJECTS))
rounded = average.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"{rounded:.2f}"
def write_sample(path: Path) -> None:
with path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file)
writer.writerow(HEADERS)
writer.writerows(SAMPLE_ROWS)
def expect_value_error(action) -> None:
try:
action()
except ValueError:
return
raise AssertionError("ValueError가 발생해야 함")
def self_check(students: list[Student], folder: Path) -> None:
ranked = rank_students(students)
assert [(s.student_id, rank) for s, rank in ranked] == [
("0006", 1), ("0001", 2), ("0002", 2),
("0007", 4), ("0003", 5),
]
by_id = {student.student_id: student for student in students}
assert by_id["0004"].total is None
assert by_id["0005"].total is None
assert sum(student.blanks for student in students) == 1
assert sum(student.absences for student in students) == 1
assert average_text(Decimal("240.01")) == "80.00"
assert average_text(Decimal("240")) == "80.00"
assert average_text(Decimal("240.015")) == "80.01"
assert parse_score(" ", "검사") == (None, "blank")
assert parse_score(" 결시 ", "검사") == (None, "absent")
assert parse_score("100", "검사") == (Decimal("100"), "score")
zero = Student(
"0099", "검사",
(Decimal("0"), Decimal("0"), Decimal("0")), 0, 0,
)
assert zero.total == Decimal("0")
assert rank_students([zero]) == [(zero, 1)]
assert rank_students([]) == []
for value in ("-0.001", "100.001", "NaN", "Infinity", "오타", "1.0001"):
expect_value_error(lambda value=value: parse_score(value, "검사"))
bad_path = folder / "bad.csv"
bad_path.write_text("학번,이름,국어\n", encoding="utf-8")
expect_value_error(lambda: read_students(bad_path))
with bad_path.open("w", encoding="utf-8", newline="") as file:
writer = csv.writer(file)
writer.writerow(HEADERS)
writer.writerow(SAMPLE_ROWS[0])
writer.writerow(SAMPLE_ROWS[0])
expect_value_error(lambda: read_students(bad_path))
with bad_path.open("w", encoding="utf-8", newline="") as file:
writer = csv.writer(file)
writer.writerow(HEADERS)
writer.writerow(("0001", "가온", "90", "80"))
expect_value_error(lambda: read_students(bad_path))
def render_report(students: list[Student]) -> str:
ranked = rank_students(students)
excluded = sorted(
(student for student in students if student.total is None),
key=lambda student: student.student_id,
)
lines = [
"성적표 집계 리포트",
f"전체 {len(students)}명 / 집계 {len(ranked)}명 / 제외 {len(excluded)}명",
(
f"빈 칸 {sum(s.blanks for s in students)}개 / "
f"결시 {sum(s.absences for s in students)}개"
),
"",
"학번 | 이름 | 총점 | 평균 | 순위 | 상태",
"---- | ---- | ---- | ---- | ---- | ----",
]
for student, rank in ranked:
total = student.total
assert total is not None
lines.append(" | ".join((
student.student_id, student.name, f"{total:.3f}",
average_text(total), str(rank), "정상",
)))
for student in excluded:
lines.append(" | ".join((
student.student_id, student.name, "-", "-", "-", "미집계",
)))
return "\n".join(lines)
def main() -> None:
with tempfile.TemporaryDirectory() as directory:
folder = Path(directory)
path = folder / "grades.csv"
write_sample(path)
students = read_students(path)
self_check(students, folder)
print("자체 검사 통과")
print(render_report(students))
if __name__ == "__main__":
main()
줄별 해설
맨 위의 SUBJECTS와 HEADERS는 입력 구조의 기준이다. 과목 이름을 여러 함수에 따로 적지 않아 열 검사와 평균의 분모가 같은 목록을 참조한다. SAMPLE_ROWS에는 실제 동점, 평균 표시만 같은 두 사람, 빈 칸, 결시를 함께 넣었다. 정상 데이터만 보여 주는 예제보다 계산 규칙을 살피기 쉽다.
Student는 읽은 한 학생의 정보를 묶는다. frozen=True는 생성한 뒤 속성을 다시 대입하는 실수를 막는다. 점수에는 Decimal 또는 None이 들어간다. None만으로는 빈 칸과 결시를 구분할 수 없으므로 blanks와 absences에 각각 개수를 보관한다.
total 속성의 첫 조건은 점수가 하나라도 없으면 None을 반환한다. 이 문장이 있기 때문에 아래의 합계가 일부 점수의 합계로 쓰이지 않는다. 정상 학생의 합계는 Decimal("0")에서 시작한다. 총점 0은 None과 다른 값이므로 집계 대상에 포함된다.
parse_score는 공백 제거, 상태 판정, 숫자 변환, 범위 확인 순으로 진행한다. NaN이나 Infinity 같은 특수 숫자는 유한성 검사에서 거른다. 유한성을 확인한 뒤 대소 비교를 수행하므로 특수 값이 점수 범위 비교로 흘러가지 않는다. 마지막 검사는 입력 Decimal의 소수 자리가 세 자리를 넘는지 본다. 이 규칙에서는 1.0000처럼 뒤에 0을 더 붙인 표현도 거절한다.
read_students는 파일을 열자마자 헤더를 비교한다. 헤더가 맞지 않으면 행을 계산하기 전에 중단한다. DictReader는 열이 많으면 None 키에 여분 값을 넣고, 열이 적으면 빠진 열에 None을 넣는다. 두 경우를 먼저 검사해 점수 해석 전에 구조 오류를 알린다.
row_number는 헤더 다음 행을 2로 센 논리적 행 번호다. 이 예제의 각 레코드는 한 물리적 줄이므로 파일의 줄 번호와 일치한다. 따옴표 안 줄바꿈이 들어간 CSV까지 다룬다면 논리적 레코드 번호와 물리적 줄 번호를 구분해 오류 문구를 바꿀 수 있다.
rank_students는 총점이 None이 아닌 학생만 뽑는다. 정렬 키의 음수 총점은 높은 점수가 먼저 오도록 하고, 뒤의 학번은 동점자의 출력 순서를 고정한다. previous_total과 현재 총점이 달라지는 순간에만 rank를 position으로 바꾸는 부분이 공동 순위 규칙이다.
average_text는 순위를 모르는 함수다. 총점을 받아 평균 문자열만 만든다. 평균을 표시하는 책임과 순위를 정하는 책임을 나누면, 표시 자릿수를 바꾸는 수정이 순위 규칙까지 건드릴 가능성을 줄일 수 있다.
self_check는 읽기 결과와 계산 함수를 함께 검사한다. 240.015를 세 과목 총점으로 넘기는 검사는 평균 80.005의 반올림을 겨냥한다. 세 과목을 80.005점씩 받은 경우처럼 허용된 입력으로 만들 수 있는 총점이다. 이어서 헤더 오류, 학번 중복, 열 누락도 임시 파일로 확인한다.
render_report는 문자열을 모아 반환하고 main이 한 번에 출력한다. 제외 대상의 총점을 일부 점수 합계로 보여 주지 않고 모두 ‘-’로 표시한다. TemporaryDirectory의 범위를 벗어나면 예제 파일과 검사 파일이 함께 정리된다.
실행 결과
터미널에서 main.py가 있는 폴더로 이동한 뒤 다음 순서로 실행한다. 첫 명령은 경고를 오류로 취급하며 컴파일을 확인하고, 두 번째 명령은 자체 검사와 리포트 출력을 수행한다.
python3 -W error -m py_compile main.py
python3 main.py
첫 명령이 성공하면 별도 출력이 없다. 두 번째 명령의 예상 출력은 다음과 같다.
자체 검사 통과
성적표 집계 리포트
전체 7명 / 집계 5명 / 제외 2명
빈 칸 1개 / 결시 1개
학번 | 이름 | 총점 | 평균 | 순위 | 상태
---- | ---- | ---- | ---- | ---- | ----
0006 | 바다 | 255.000 | 85.00 | 1 | 정상
0001 | 가온 | 250.000 | 83.33 | 2 | 정상
0002 | 나래 | 250.000 | 83.33 | 2 | 정상
0007 | 예솔 | 240.010 | 80.00 | 4 | 정상
0003 | 다온 | 240.000 | 80.00 | 5 | 정상
0004 | 라온 | - | - | - | 미집계
0005 | 마루 | - | - | - | 미집계
가온과 나래는 총점이 같아 공동 2위다. 그다음 예솔은 4위다. 예솔과 다온의 표시 평균은 같지만 총점이 달라 4위와 5위로 나뉜다. 라온과 마루에게는 순위를 부여하지 않는다. 이 네 지점을 출력에서 직접 대조하면 핵심 규칙이 눈에 들어온다.
컴파일 확인만으로 계산의 뜻까지 검증되지는 않는다. 자체 검사를 통과해도 아직 검사하지 않은 입력이 있을 수 있다. 실행 결과 대조와 변경 차이 검토를 함께 수행하고, 새 규칙을 추가할 때는 그 규칙을 구별할 수 있는 입력도 추가한다.
실무에서 자주 틀리는 것
빈 칸을 편리한 기본값으로 바꾼다
빈 칸을 0으로 바꾸면 자료가 완성된 것처럼 보인다. 다음 두 코드는 각각 독립 실행되는 비교 예다. 앞 코드는 미입력 상태를 잃고, 뒤 코드는 상태를 유지한다.
from decimal import Decimal
raw = ""
score = Decimal(raw or "0")
assert score == Decimal("0")
from decimal import Decimal
raw = ""
value = raw.strip()
score = None if value == "" else Decimal(value)
assert score is None
실제 프로그램에서는 여기에 결시 판정과 잘못된 숫자 검사도 붙어야 한다. 부분 수정만 복사해 전체 입력 검증을 대체하지 않는다.
총점 0을 집계 대상에서 빼 버린다
값의 참·거짓으로 검사하면 유효한 0점도 빠진다. 누락을 None으로 표현했다면 None인지 직접 비교해야 한다.
from decimal import Decimal
total = Decimal("0")
eligible = bool(total)
assert eligible is False
from decimal import Decimal
total = Decimal("0")
eligible = total is not None
assert eligible is True
표시 문자열을 동점 판정에 쓴다
화면에 같은 값이 보인다는 이유로 같은 순위를 주면, 출력 형식이 계산 규칙을 바꾼다. 다음 예는 두 총점이 같은 평균 문자열을 만들 수 있음을 보여 준다.
from decimal import Decimal
left = Decimal("240.01")
right = Decimal("240")
same_rank = f"{left / 3:.2f}" == f"{right / 3:.2f}"
assert same_rank is True
from decimal import Decimal
left = Decimal("240.01")
right = Decimal("240")
same_rank = left == right
assert same_rank is False
공동 순위 뒤 번호를 건너뛰는 처리도 별도로 필요하다. 정렬 결과의 위치와 부여할 순위를 구분한 완성 코드의 반복문을 함께 확인한다.
반올림 방식을 생략한다
기본 동작이 명세와 같다고 추측하지 않는다. 다음 앞 예에서는 중간값을 가까운 짝수 쪽으로 보내는 규칙을 명시해 차이를 드러냈다. 뒤 예가 이번 성적표의 사사오입 규칙이다.
from decimal import Decimal, ROUND_HALF_EVEN
value = Decimal("80.005")
shown = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)
assert shown == Decimal("80.00")
from decimal import Decimal, ROUND_HALF_UP
value = Decimal("80.005")
shown = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
assert shown == Decimal("80.01")
평범한 숫자만 검사하면 두 방식의 차이가 드러나지 않는다. 반올림 경계값을 직접 넣어야 처리 규칙을 확인할 수 있다.
한눈에 보기
| 단계 | 선택한 방법 | 확인할 결과 |
|---|---|---|
| 파일 읽기 | DictReader, utf-8-sig, newline="" | 열 이름과 열 개수가 맞아야 한다. |
| 점수 해석 | 상태 판정 뒤 Decimal 변환 | 0점은 유효하고 빈 칸·결시는 구분된다. |
| 집계 대상 | 세 과목 모두 점수가 있어야 한다. | 일부 과목 평균을 순위에 넣지 않는다. |
| 순위 계산 | 총점 비교와 위치 기반 공동 순위 | 공동 2위 다음은 4위다. |
| 평균 표시 | ROUND_HALF_UP과 소수 두 자리 | 표시값이 같아도 순위는 다를 수 있다. |
| 검토 | 컴파일, 자체 검사, 출력 대조, 변경 차이 검토 | 실행 성공과 요구사항 충족을 함께 확인한다. |
완성 후 확장을 요청할 때도 먼저 바뀌는 규칙을 적는다. 아래 요청은 기본 동작을 유지하면서 실무 파일을 받을 수 있게 만드는 후속 과제다. 이번 완성 코드에는 포함하지 않는다.
현재 main.py를 확장하라. 인수가 없으면 기존 임시 예제를 실행하고, CSV 경로를 인수로 받으면 해당 파일을 읽어라. 원본 파일은 수정하지 말라. 입력 오류는 이해할 수 있는 문장으로 표준 오류에 출력하고 종료 상태 1을 반환하라. 정상 리포트와 순위·반올림 규칙은 유지하라. 경로에 공백이 있는 경우와 존재하지 않는 파일을 검사하라. 변경 차이를 검토할 수 있도록 수정 이유도 설명하라.
연습 문제
- 총점이 270, 270, 260, 250, 250인 다섯 학생의 순위를 적어라. 모든 학생이 동점인 경우와 집계 대상이 없는 경우의 검사도 추가하라.
- 과목 점수가 0, 빈 칸, 결시인 학생을 추가하면 총점·평균·순위와 상태 개수는 어떻게 바뀌는지 설명하라. 기존 학생들의 순위가 유지되는지도 확인하라.
- 과목별 유효 점수 평균을 리포트 아래에 추가하려 한다. 전체 순위에서 제외된 학생의 유효 점수를 과목 평균에 포함할지 먼저 정하고, AI에게 보낼 요청문을 작성하라.
- 이름은 같고 학번은 다른 두 학생을 추가하라. 반대로 이름은 다르고 학번이 같은 두 행을 넣으면 어떻게 처리해야 하는지 설명하고 검사하라.
정답과 해설
1. 순위는 1, 1, 3, 4, 4다. 처음 두 사람이 위치 1과 2를 차지하므로 다음 순위는 3이다. 모두 동점이면 모든 학생이 1위다. 동점자의 표시 순서는 학번으로 정한다. 집계 대상이 없으면 rank_students는 빈 목록을 반환한다. 리포트에서 전체 학생과 제외 학생 수를 표시하고, 평균이나 순위 계산 때문에 0으로 나누지 않아야 한다.
예를 들어 아래 검사는 완성 코드의 self_check 안에 추가할 수 있다. 같은 점수의 두 학생을 반대 학번 순서로 전달해 동점 순위와 정렬을 함께 확인한다.
tied = [
Student("0092", "검사", (Decimal("80"),) * 3, 0, 0),
Student("0091", "검사", (Decimal("80"),) * 3, 0, 0),
]
assert [
(student.student_id, rank)
for student, rank in rank_students(tied)
] == [("0091", 1), ("0092", 1)]
2. 새 학생의 총점·평균·순위는 모두 ‘-’다. 0점은 유효하지만 나머지 두 과목이 없으므로 집계에서 제외된다. 전체 인원과 제외 인원이 각각 1명 늘고 빈 칸과 결시 개수가 각각 1개 늘어난다. 집계 대상 목록은 같으므로 기존 순위는 바뀌지 않는다. 빈 칸 수는 제외 학생 수가 아니라 빈 셀 수라는 점도 구분한다.
3. 과목 평균의 모집단은 새로 합의해야 한다. 예를 들어 유효 점수가 있는 모든 학생을 해당 과목의 평균에 포함하기로 정할 수 있다. 이 경우 라온과 마루도 점수가 있는 과목에서는 기여한다. 반면 전체 순위는 기존처럼 세 과목이 완성된 학생만 대상으로 한다.
과목별 평균을 추가하라. 각 과목에 유효 점수가 있는 모든 학생을 그 과목 평균에 포함하고 빈 칸·결시는 제외하라. 전체 순위에서 제외된 학생의 유효 점수도 포함하라. 과목마다 포함 인원과 평균을 함께 표시하고, 유효 점수가 없으면 평균을 ‘-’로 표시하라. 사사오입 규칙은 기존과 같게 유지하라. 기존 순위가 유지되는 검사와 과목 전체가 빈 칸인 검사를 추가하라.
4. 이름이 같아도 학번이 다르면 서로 다른 학생으로 읽어야 한다. 같은 총점이라면 두 학번의 오름차순으로 출력한다. 학번이 같은 두 행은 이름과 무관하게 오류다. 완성 코드의 중복 검사에서는 앞 행과 동일한 행을 두 번 기록하지만, 두 번째 행의 이름만 바꾸어도 ValueError가 발생해야 한다. 이름을 식별자로 바꾸는 수정이 들어오지 않았는지 변경 차이에서도 확인한다.
다음 장에서는 폴더 정리기를 만든다. 이번에 계산 규칙을 먼저 고정했던 것처럼, 파일을 옮기는 작업에서도 실행 전에 무엇이 바뀔지 확인할 기준부터 세운다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.