AI 개발 · 실무
바이브 코딩 실전 예제 - 장마다 하나씩 완성하는 12가지 프로그램
퀴즈 프로그램 - 문제 은행 JSON 과 채점
JSON 문제 형식 정하기, seed 고정 섞기, 정답 비교 규칙(공백·대소문자), 오답 노트, 정해진 답안으로 자동 실행
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 템플릿과 명단을 합쳐 안내문을 만들었다면, 이번에는 문제와 정답을 데이터로 저장하고 답안을 채점하는 프로그램을 만든다. 결과물은 학습 모임이나 업무 교육에 사용할 수 있는 작은 퀴즈 프로그램이다. 문제 은행은 JSON(JavaScript Object Notation) 파일로 저장하며, 프로그램은 이를 읽어 문제를 섞고 채점한 뒤 오답 노트를 남긴다.
이번 예제는 키보드 입력을 기다리지 않는다. 미리 정한 답안을 사용해 끝까지 실행하므로 코드를 바꾼 뒤에도 같은 조건으로 결과를 비교할 수 있다. 화면에 표시하는 보고서는 문제 식별자 순으로 정렬한다. 문제를 푸는 순서와 결과를 검토하는 순서를 구분하는 설계다.
- 문제 은행의 필수 항목과 잘못된 데이터를 거부하는 규칙을 정한다.
- 고정된 시드(seed)로 문제 순서를 섞고 원본 데이터는 보존한다.
- 정답 비교에서 앞뒤 공백과 대소문자를 처리하는 범위를 정한다.
- 오답 노트를 JSON 파일로 저장하고 다시 읽어 내용을 검사한다.
- 정해진 답안으로 자동 실행하고 검사문과 변경 비교로 수정 내용을 확인한다.
문제 상황
학습 모임에서 매주 짧은 복습 퀴즈를 진행한다고 하자. 진행자는 문서에 적힌 문제를 복사해 순서를 바꾸고, 제출된 답안을 눈으로 확인한다. 답이 맞아도 대문자로 썼다는 이유로 틀렸다고 표시하거나, 정답 양옆에 붙은 공백을 놓치는 일이 생긴다. 지난번에 어떤 문제를 틀렸는지도 별도로 정리해야 한다.
문제를 프로그램 안에 직접 적으면 문구 하나를 고칠 때도 채점 코드를 건드리게 된다. 문제 은행을 파일로 분리하면 문항 수정과 프로그램 수정의 경계가 분명해진다. 다만 파일에 들어 있다는 이유만으로 데이터가 올바르다고 볼 수는 없다. 같은 식별자를 가진 문제가 두 개 있거나 정답 목록이 비어 있으면 채점 전에 찾아야 한다.
이 장에서는 네 문항을 담은 문제 은행을 임시 폴더에 만들고 다시 읽는다. 각 문제는 1점이며 부분 점수는 없다. 틀린 문제만 오답 노트에 넣고, 화면에는 전체 채점 결과와 오답 노트를 출력한다. 임시 폴더는 실행이 끝나면 지워진다. 독자가 준비해야 할 입력 파일은 없으며, 현재 폴더에도 파일을 남기지 않는다.
| 대상 | 규칙 | 확인할 결과 |
|---|---|---|
| 문제 은행 | 버전 1, 비어 있지 않은 문제 목록, 고유 식별자를 사용한다. | 잘못된 파일은 채점 전에 거부한다. |
| 문제 순서 | 시드 17로 복사본을 섞는다. | 같은 실행 환경과 입력에서 순서가 재현된다. |
| 정답 비교 | 앞뒤 공백과 대소문자를 무시하고 내부 공백은 보존한다. | 허용한 차이만 정답으로 인정한다. |
| 자동 실행 | 문제 식별자에 연결된 고정 답안을 사용한다. | 입력을 기다리지 않고 3점으로 끝난다. |
| 오답 노트 | 식별자, 질문, 제출 답안, 허용 정답을 저장한다. | 다시 읽은 내용이 저장 전과 같다. |
문제 형식과 정답 비교 규칙 정하기
문제 은행의 최상위 항목은 version과 questions다. 버전은 파일 형식을 구분하는 값이다. 나중에 배점이나 해설 항목을 추가하더라도 어떤 규칙으로 읽어야 할지 판단할 근거가 된다. 여기서는 정수 1만 허용한다. JSON의 참 값이 Python에서 True로 읽힌다는 점을 고려해 자료형까지 확인한다.
문제마다 id, question, answers를 둔다. 식별자는 문구나 출제 순서가 바뀌어도 같은 문제를 가리키는 이름이다. 질문 문장을 답안의 연결 기준으로 쓰면 문구를 다듬는 순간 기존 답안과 연결이 끊어질 수 있다. 식별자를 별도로 두면 이런 의존성을 줄일 수 있다.
허용 정답은 문자열 목록으로 저장한다. 숫자를 답으로 받는 문제도 "4"처럼 문자열을 사용한다. 입력은 보통 문자열로 들어오므로 정답도 같은 자료형으로 맞추는 편이 비교 규칙을 일관되게 유지하기 쉽다. 이번 프로그램은 숫자 표현을 계산하지 않으므로 "04"나 "4.0"을 "4"와 같다고 보지 않는다.
문자열을 비교하기 전에 형태를 맞추는 처리를 정규화(normalization)라고 한다. 여기서는 strip()으로 앞뒤 공백을 제거하고 casefold()로 대소문자 구분을 줄인다. 제출 답안과 허용 정답 양쪽에 같은 함수를 적용한다. 정답만 다듬거나 답안만 다듬으면 양쪽의 비교 조건이 달라진다.
공백을 모두 지우지는 않는다. 예를 들어 "data base"와 "database"는 이 장의 규칙에서 서로 다른 답이다. 띄어쓰기를 무시할지 여부는 문제의 의도에 달려 있다. 두 표현을 모두 인정하려면 허용 정답 목록에 각각 넣는다. 비교 함수가 문제 제작자의 판단까지 대신하도록 범위를 넓히지 않는다.
빈 답안도 규칙을 정해야 한다. 이 예제에서는 제출하지 않은 답안을 빈 문자열로 처리한다. 허용 정답에는 공백만 있는 문자열을 넣을 수 없으므로 빈 답안은 항상 오답이다. 따라서 미응답과 오답의 점수는 같지만, 오답 노트에서 제출 답안이 비어 있는지를 보고 미응답을 알아볼 수 있다.
파일 검사는 채점과 분리한다. JSON 문법이 맞는지는 json.loads()가 확인하지만, 정답 목록이 비어 있는지나 식별자가 중복되는지는 알지 못한다. 별도의 검사 함수가 업무 규칙을 확인해야 한다. 이번 형식은 필수 항목을 검사하고 추가 항목은 허용한다. 알 수 없는 항목까지 거부해야 하는 형식이라면 그 정책도 따로 구현해야 한다.
AI 요청문과 첫 결과 검토하기
요청문에는 구현할 기능뿐 아니라 실행이 끝나는 조건과 확인할 결과까지 넣는다. “퀴즈 프로그램을 만들어 달라”는 말만으로는 대화형 입력, 무작위 출제, 자동 채점 가운데 무엇을 우선할지 알기 어렵다. 이번 작업에서는 재실행할 수 있는 자동 검사가 우선이다.
Python 3.12 이상 표준 라이브러리만 사용하는 main.py를 작성하라. tempfile.TemporaryDirectory로 임시 폴더를 만들고, 버전과 네 문항이 있는 JSON 문제 은행을 UTF-8로 기록한 뒤 다시 읽어라. 각 문제에는 고유한 id, 질문 문자열, 비어 있지 않은 허용 정답 문자열 목록을 둬라. 잘못된 자료형, 빈 질문, 빈 정답, 중복 id를 채점 전에 거부하라.
문제 목록의 복사본을 random.Random(17)로 섞어라. 고정 답안은 id에 연결하고 input()을 사용하지 마라. 정답 비교는 양쪽 문자열의 앞뒤 공백과 대소문자를 무시하되 내부 공백을 유지하라. 각 문항은 1점이다. 결과와 오답 노트는 id 순으로 출력하고 오답 노트는 JSON으로 저장한 뒤 다시 읽어 검증하라. 경로와 현재 시각은 출력하지 마라. 정상 동작과 경계값을 확인하는 assert도 포함하라.
첫 결과를 검토할 때는 코드를 읽는 것과 작은 입력으로 확인하는 것을 함께 한다. 다음 표는 이 요청에 대한 초안에서 흔히 발견하는 결함과 그 결함을 드러내는 검사다. 초안이 실행된다는 사실만으로 채점 규칙까지 맞는 것은 아니다.
| 문제 | 발견 방법 | 고친 방향 |
|---|---|---|
| 공백을 전부 제거해 내부 공백까지 무시한다. | "data base"와 "database"를 비교하면 같다고 나온다. | strip()만 사용해 앞뒤 공백만 제거한다. |
| 정답 목록이 비어 있어도 파일을 받아들인다. | answers를 빈 목록으로 바꿔 읽는다. | 목록의 자료형과 원소, 비어 있는지를 함께 검사한다. |
| 문제와 별도 답안 목록을 위치로 연결한다. | 문제 순서를 바꾸면 다른 문제의 답안으로 채점된다. | 식별자를 키로 쓰는 답안 사전을 사용한다. |
| 기본 인코딩과 현재 작업 폴더에 의존한다. | 파일 접근 코드에서 인코딩 생략과 상대 경로를 찾는다. | 임시 폴더 아래 경로를 만들고 UTF-8을 명시한다. |
검사를 통과시키려고 기대값부터 고치면 결함을 숨길 수 있다. 먼저 요구사항에 적힌 규칙을 다시 읽고, 입력과 실제 결과가 왜 다른지 확인한다. 내부 공백을 보존하기로 했다면 정답 비교 함수를 고쳐야 한다. 정답으로 인정할 표현이 실제로 늘어난 경우에만 문제 은행의 정답 목록을 바꾼다.
고정된 실행으로 검사하고 수정하기
난수를 쓰는 목적은 문제를 매번 같은 위치에서 보지 않게 하는 데 있다. 그러나 수정 전후를 비교하는 동안에는 출제 순서가 재현되어야 원인을 찾기 쉽다. 따라서 이번에는 전역 난수 상태를 바꾸는 대신 random.Random(17)로 독립된 난수 생성기를 만든다.
shuffle()은 전달받은 목록 자체를 바꾼다. 원본 문제 은행을 그대로 넘기면 파일에서 읽은 순서가 사라진다. 먼저 목록을 복사하고 그 복사본을 섞는다. 문제 안의 문자열이나 정답 목록을 수정하지 않으므로 이 예제에는 바깥 목록의 얕은 복사로 충분하다.
같은 시드를 썼다는 이유만으로 서로 다른 Python 버전의 모든 섞기 결과까지 같다고 가정하지는 않는다. 검사에서는 같은 환경에서 같은 입력과 시드로 얻은 순서가 일치하는지 확인한다. 공개 보고서는 식별자 순으로 정렬하므로 내부 출제 순서에 영향을 받지 않는다. 출제 순서 자체를 장기간 보관해야 한다면 시드와 함께 실제 식별자 목록도 저장해야 한다.
검사는 세 층으로 나눈다. 먼저 정규화와 허용 정답 비교를 확인한다. 다음으로 빈 문제 목록, 중복 식별자, 빈 정답 같은 잘못된 문제 은행을 거부하는지 확인한다. 마지막으로 실제 문제 은행을 읽고 채점해 3점과 오답 식별자 q02가 나오는지 확인한다. 작은 함수 검사와 전체 실행 검사가 서로 다른 실수를 잡는다.
오답 노트도 저장 성공만 확인하지 않는다. 저장한 파일을 다시 읽고 메모리에서 만든 목록과 비교한다. 이 검사는 파일 경로와 직렬화 처리가 함께 작동하는지 확인한다. 다만 파일이 임시 폴더에 있으므로 장기 보관 기능을 검사하는 것은 아니다. 개인 학습용으로 확장할 때는 보관 위치와 덮어쓰기 정책을 추가해야 한다.
수정 후에는 변경 비교(diff)로 의도한 줄만 바뀌었는지 읽는다. 공백 처리 수정에 문제 문구 변경이나 점수 계산 변경이 섞여 있다면 이유를 확인한다. 검사 출력이 같아도 요구하지 않은 변경이 남아 있을 수 있기 때문이다. 아래 명령 예시는 터미널에서 실행할 명령이며 Python 코드가 아니다.
python3 -W error -m py_compile main.py
python3 main.py
git diff -- main.py
첫 명령은 경고를 오류로 취급해 구문을 검사하고, 두 번째 명령은 실제 동작을 확인한다. 마지막 명령은 버전 관리 중인 파일의 수정 내용을 보여 준다. 구문 검사는 점수가 맞는지 확인하지 못하고, 실행 결과 확인은 변경 의도를 대신 판단하지 못한다. 각각 확인하는 범위가 다르다.
완성 코드
다음 코드를 main.py에 저장한다. 예제 데이터도 이 파일 안에서 만든다. 문제 은행을 파일에 기록한 뒤 다시 읽으므로 JSON 입출력 과정까지 실제로 실행된다. 현재 시각이나 임시 폴더 이름은 결과에 포함하지 않는다.
import json
import random
import tempfile
from pathlib import Path
def normalize(text):
return text.strip().casefold()
def is_correct(submitted, accepted):
return normalize(submitted) in {normalize(item) for item in accepted}
def validate_bank(bank):
if not isinstance(bank, dict):
raise ValueError("문제 은행은 객체여야 한다.")
version = bank.get("version")
if type(version) is not int or version != 1:
raise ValueError("지원하는 버전은 정수 1이다.")
questions = bank.get("questions")
if not isinstance(questions, list) or not questions:
raise ValueError("문제 목록은 비어 있을 수 없다.")
seen = set()
for item in questions:
if not isinstance(item, dict):
raise ValueError("각 문제는 객체여야 한다.")
question_id = item.get("id")
if not isinstance(question_id, str) or not question_id.strip():
raise ValueError("문제 식별자는 빈 문자열일 수 없다.")
if question_id != question_id.strip() or question_id in seen:
raise ValueError("식별자의 앞뒤 공백이나 중복을 확인하라.")
seen.add(question_id)
question = item.get("question")
if not isinstance(question, str) or not question.strip():
raise ValueError("질문은 빈 문자열일 수 없다.")
answers = item.get("answers")
if not isinstance(answers, list) or not answers:
raise ValueError("허용 정답은 비어 있지 않은 목록이어야 한다.")
if any(not isinstance(a, str) or not a.strip() for a in answers):
raise ValueError("허용 정답은 비어 있지 않은 문자열이어야 한다.")
return questions
def load_bank(path):
bank = json.loads(path.read_text(encoding="utf-8"))
return validate_bank(bank)
def shuffled_questions(questions, seed):
copied = list(questions)
random.Random(seed).shuffle(copied)
return copied
def grade(questions, submissions):
rows = []
for item in questions:
submitted = submissions.get(item["id"], "")
rows.append({
"id": item["id"],
"question": item["question"],
"submitted": submitted,
"answers": list(item["answers"]),
"correct": is_correct(submitted, item["answers"]),
})
return sorted(rows, key=lambda row: row["id"])
def make_wrong_notes(rows):
return [
{key: row[key] for key in ("id", "question", "submitted", "answers")}
for row in rows if not row["correct"]
]
def run_checks():
assert normalize(" PyThOn ") == "python"
assert normalize("data base") != normalize("database")
assert is_correct(" JSON ", ["json"])
assert is_correct("파이썬", ["Python", "파이썬"])
assert not is_correct(" ", ["python"])
assert not is_correct("4.0", ["4"])
good = {"id": "x", "question": "연습 질문", "answers": ["답"]}
bad_banks = [
[],
{"version": True, "questions": [good]},
{"version": 1, "questions": []},
{"version": 1, "questions": [good, dict(good)]},
{"version": 1, "questions": [dict(good, id=" x")]},
{"version": 1, "questions": [dict(good, question=" ")]},
{"version": 1, "questions": [dict(good, answers=[])]},
{"version": 1, "questions": [dict(good, answers=[" "])]},
{"version": 1, "questions": [dict(good, answers=[4])]},
]
for bank in bad_banks:
try:
validate_bank(bank)
except ValueError:
pass
else:
raise AssertionError("잘못된 문제 은행을 허용했다.")
original = [{"id": str(i)} for i in range(6)]
before = [item["id"] for item in original]
first = shuffled_questions(original, 17)
second = shuffled_questions(original, 17)
assert [item["id"] for item in first] == [item["id"] for item in second]
assert sorted(item["id"] for item in first) == sorted(before)
assert [item["id"] for item in original] == before
assert not grade([good], {})[0]["correct"]
def main():
run_checks()
bank = {
"version": 1,
"questions": [
{"id": "q01", "question": "이 예제의 구현 언어는?",
"answers": ["Python", "파이썬"]},
{"id": "q02", "question": "이 예제의 문제 은행 파일 형식은?",
"answers": ["JSON"]},
{"id": "q03", "question": "2 + 2의 값은?",
"answers": ["4"]},
{"id": "q04", "question": "Python의 참 값을 영어로 쓰면?",
"answers": ["True"]},
],
}
submissions = {
"q01": " PyThOn ",
"q02": "JavaScript",
"q03": " 4 ",
"q04": "TRUE",
}
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
bank_path = root / "questions.json"
bank_path.write_text(
json.dumps(bank, ensure_ascii=False, indent=2),
encoding="utf-8",
)
questions = load_bank(bank_path)
ordered = shuffled_questions(questions, 17)
rows = grade(ordered, submissions)
score = sum(row["correct"] for row in rows)
wrong_notes = make_wrong_notes(rows)
assert score == 3
assert [note["id"] for note in wrong_notes] == ["q02"]
notes_path = root / "wrong_notes.json"
notes_path.write_text(
json.dumps(wrong_notes, ensure_ascii=False, indent=2),
encoding="utf-8",
)
restored = json.loads(notes_path.read_text(encoding="utf-8"))
assert restored == wrong_notes
print("검사: 통과")
print(f"총점: {score}/{len(rows)}")
print("채점 결과 (식별자 순)")
for row in rows:
label = "정답" if row["correct"] else "오답"
print(f"{row['id']}: {label}")
print(f"오답 노트: {len(restored)}개")
for note in restored:
accepted = ", ".join(note["answers"])
print(f"[{note['id']}] {note['question']}")
print(f"제출: {note['submitted']}")
print(f"허용 정답: {accepted}")
print("오답 노트 저장·재읽기: 일치")
if __name__ == "__main__":
main()
줄별 해설
import로 시작하는 부분은 JSON 변환, 난수 생성, 임시 폴더, 파일 경로에 필요한 모듈을 가져온다. 모두 표준 라이브러리다. Path를 사용하면 폴더와 파일 이름을 / 연산자로 연결할 수 있어 경로 구분자를 직접 붙일 필요가 없다.
normalize()의 한 줄이 채점 정책의 핵심이다. 앞뒤 공백 제거와 대소문자 처리를 이 함수에 모아 두면 검사와 실제 채점에서 같은 규칙을 재사용할 수 있다. is_correct()는 허용 정답들을 같은 방식으로 정규화한 집합에 제출 답안이 들어 있는지 확인한다. 허용 정답이 여러 개여도 하나만 일치하면 정답이다.
validate_bank()는 바깥 구조에서 안쪽 항목으로 내려가며 검사한다. 사전인지 확인한 다음 버전과 문제 목록을 읽고, 각 문제의 항목을 검사한다. 자료형을 먼저 확인하므로 목록이 와야 할 자리에 문자열이 들어왔을 때 엉뚱하게 한 글자씩 처리하는 일을 막는다. 오류 메시지는 잘못된 데이터의 종류를 알려 준다.
type(version) is not int는 의도적으로 엄격한 검사다. Python에서 불리언은 정수와 관련된 자료형이므로 단순히 값이 1인지 비교하면 True도 통과할 수 있다. 파일 형식 버전에서는 참과 숫자 1을 구분해야 한다. 반면 문자열과 목록 항목에는 isinstance()를 사용한다.
seen은 이미 나온 식별자를 보관하는 집합이다. 새 식별자를 만나면 앞뒤 공백과 중복 여부를 확인한 뒤 집합에 추가한다. 식별자의 공백은 자동으로 제거하지 않고 오류로 처리한다. 다른 데이터가 원래 식별자로 답안을 연결하고 있을 수 있기 때문이다. 사람이 읽는 답안과 데이터 연결에 쓰는 식별자는 다르게 다룬다.
load_bank()는 파일 읽기, JSON 해석, 업무 규칙 검사를 차례로 연결한다. JSON 문법 오류는 JSON 해석 단계에서 발생하고, 빈 정답 목록은 업무 규칙 검사에서 발생한다. 이 예제는 직접 생성한 파일을 사용하므로 오류를 화면용 메시지로 바꾸지 않고 그대로 드러낸다. 외부 파일을 받는 도구로 확장할 때는 이 경계에서 사용자 안내를 추가할 수 있다.
shuffled_questions()는 새 목록을 만든 뒤 그 목록만 섞는다. shuffle()의 반환값을 사용하지 않고 수정된 복사본을 반환한다. grade()는 섞인 순서대로 문제를 처리하되, 마지막 줄에서 보고서용 순서로 정렬한다. 고정 답안은 위치가 아니라 id로 찾으므로 출제 순서가 바뀌어도 답안 연결이 유지된다.
submissions.get(item["id"], "")는 답안이 없을 때 빈 문자열을 돌려준다. 이 함수의 답안 값은 문자열이라는 내부 약속을 따른다. 실제 사용자 파일에서 답안을 읽도록 바꾼다면 답안 사전에도 별도의 자료형 검사가 필요하다. 문제 은행 검사만으로 다른 입력까지 검증되지는 않는다.
make_wrong_notes()는 채점 결과 중 오답만 골라 필요한 네 항목을 복사한다. 정답 여부는 이미 오답만 모았다는 사실에 포함되므로 저장하지 않는다. 제출 답안은 정규화한 값 대신 원래 값을 남긴다. 사용자가 실제로 무엇을 썼는지 살펴보려면 원문이 필요하기 때문이다.
run_checks()는 정상 입력뿐 아니라 거부해야 하는 데이터도 확인한다. try에서 검사가 성공해 버리면 else로 들어가 오류를 낸다. 오류가 발생해야 하는 상황을 검사하는 구조다. 이어지는 섞기 검사는 재현성, 문제의 보존, 원본 순서 유지라는 서로 다른 조건을 확인한다.
main()에서는 파일을 쓰고 읽는 흐름을 한눈에 볼 수 있다. ensure_ascii=False는 JSON 파일에서 한글을 읽기 쉽게 남기고, encoding="utf-8"은 실제 파일 인코딩을 지정한다. 두 설정은 역할이 다르다. sum()은 정답 여부의 참 값을 1로 더해 총점을 계산한다.
마지막 조건문은 파일을 직접 실행했을 때만 main()을 호출한다. 검사 함수의 assert는 개발 중 확인 장치이며 외부 입력 검증을 대신하지 않는다. 최적화 옵션을 켜면 생략될 수 있으므로 문제 은행 검사는 ValueError를 명시적으로 발생시킨다. 이 장의 실행 명령에는 최적화 옵션을 사용하지 않는다.
실행 결과
터미널에서 파일이 있는 폴더로 이동한 뒤 다음 명령을 실행한다. 명령은 인용으로 표시하고, 그 아래에는 프로그램이 출력하는 내용만 적었다.
python3 main.py
검사: 통과
총점: 3/4
채점 결과 (식별자 순)
q01: 정답
q02: 오답
q03: 정답
q04: 정답
오답 노트: 1개
[q02] 이 예제의 문제 은행 파일 형식은?
제출: JavaScript
허용 정답: JSON
오답 노트 저장·재읽기: 일치
첫 번째 답안은 앞뒤 공백과 대소문자가 다르지만 정답이다. 세 번째 답안의 앞뒤 공백도 점수에 영향을 주지 않는다. 두 번째 답안은 허용 정답과 다른 문자열이므로 오답 노트에 남는다. 정해진 답안을 사용하므로 실행할 때마다 같은 점수와 보고서를 얻는다.
화면 출력만으로 섞기가 올바른지 판단하지는 않는다. 보고서가 정렬되어 있기 때문이다. 섞기의 재현성과 원본 보존은 run_checks()에서 확인하고, 보고서는 사람이 결과를 비교하기 쉽게 유지한다. 실행이 끝나면 두 JSON 파일과 임시 폴더가 함께 정리된다.
실무에서 자주 틀리는 것
공백을 모두 지워 다른 답을 같게 만들기
공백을 제거하면 채점이 관대해진다고 생각하기 쉽다. 그러나 단어 경계가 의미를 가진 문제에서는 서로 다른 답을 합칠 수 있다. 다음은 틀린 처리의 동작을 확인하는 독립 실행 코드다.
def normalize(text):
return "".join(text.split()).casefold()
assert normalize("data base") == normalize("database")
고친 코드는 앞뒤 공백만 지운다. 내부 공백 두 개와 한 개도 서로 다르게 유지한다. 그 차이까지 허용하려면 별도의 요구사항과 검사가 필요하다.
def normalize(text):
return text.strip().casefold()
assert normalize("data base") != normalize("database")
assert normalize(" Python ") == "python"
섞기 함수의 반환값을 문제 목록으로 사용하기
목록을 직접 바꾸는 함수가 새 목록을 반환한다고 가정하면 이후 반복문에서 문제가 생긴다. 다음 코드는 잘못된 대입의 결과가 None임을 확인한다.
import random
questions = ["q01", "q02", "q03"]
ordered = random.Random(17).shuffle(questions)
assert ordered is None
고친 코드는 복사와 섞기를 분리한다. 원본 순서도 확인한다. 원본을 보존해야 한다는 요구사항을 코드 옆 검사로 남기는 방식이다.
import random
questions = ["q01", "q02", "q03"]
ordered = list(questions)
random.Random(17).shuffle(ordered)
assert questions == ["q01", "q02", "q03"]
assert sorted(ordered) == questions
식별자가 중복돼도 사전에 넣으면 된다고 생각하기
문제를 사전으로 바꾸면 식별자로 찾기 편하지만, 중복 키는 앞의 값을 덮어쓴다. 중복이 자동으로 해결된 것이 아니라 문제 하나가 사라진 것이다.
questions = [
{"id": "q01", "question": "첫 질문"},
{"id": "q01", "question": "둘째 질문"},
]
by_id = {item["id"]: item for item in questions}
assert len(by_id) == 1
고친 코드는 변환 전에 중복 여부를 확인한다. 완성 코드에서는 같은 검사를 문제 은행을 읽는 단계에 배치한다.
questions = [
{"id": "q01", "question": "첫 질문"},
{"id": "q02", "question": "둘째 질문"},
]
ids = [item["id"] for item in questions]
if len(ids) != len(set(ids)):
raise ValueError("중복 식별자가 있다.")
by_id = {item["id"]: item for item in questions}
assert len(by_id) == 2
채점에 사용한 정규화 값으로 원래 답안을 덮어쓰기
비교하기 편하다는 이유로 제출 답안 자체를 바꾸면 오답 분석에 필요한 정보가 사라진다. 다음 코드는 원래의 공백과 대문자가 사라지는 상황을 보여 준다.
submitted = " JSonX "
submitted = submitted.strip().casefold()
assert submitted == "jsonx"
고친 코드는 원문과 비교용 값을 따로 둔다. 완성 코드도 정규화 함수의 반환값으로만 비교하고 원래 제출 답안을 저장한다.
submitted = " JSonX "
comparison = submitted.strip().casefold()
correct = comparison == "json"
assert not correct
assert submitted == " JSonX "
한눈에 보기
| 단계 | 선택한 방법 | 검사할 조건 |
|---|---|---|
| 문제 표현 | 버전과 문제 목록을 가진 JSON | 자료형, 빈 값, 식별자 중복 |
| 정답 표현 | 허용 문자열 목록 | 숫자 답도 문자열로 저장 |
| 정답 비교 | 양쪽에 strip과 casefold 적용 | 내부 공백과 원문은 보존 |
| 출제 순서 | 복사본과 독립 난수 생성기 | 같은 시드의 재현성, 원본 보존 |
| 답안 연결 | 문제 식별자를 키로 사용 | 순서가 바뀌어도 같은 문제에 연결 |
| 결과 정리 | 식별자 순으로 정렬 | 점수와 오답 목록을 반복 비교 |
| 파일 저장 | UTF-8 기록 후 다시 읽기 | 저장 전후 데이터 일치 |
이 프로그램의 경계는 분명하다. 문자열 일치 방식으로 채점하며, 서술형 답안의 의미를 판단하지 않는다. 실행 중에는 네 문제를 모두 채점하고 오답 한 개를 기록한다. 입력 데이터 검사, 비교 규칙, 결과 저장을 나눠 두었으므로 기능을 추가할 때 어느 부분을 고칠지 찾기 쉽다.
연습 문제
- 네 번째 문제의 고정 답안을 삭제하라. 예상 총점과 오답 식별자를 먼저 적고, 전체 실행 검사의 기대값을 수정하라. 미응답이 오답 노트에 어떤 값으로 남는지도 확인하라.
- 세 번째 문제에서
"4"와"넷"을 모두 정답으로 인정하라. 고정 답안을" 넷 "으로 바꾸고 총점이 유지되는지 검사하라. 비교 함수는 수정하지 마라. - 허용 정답 목록 안에서 정규화 결과가 중복되면 거부하도록 검사 함수를 확장하라.
["JSON", " json "]은 거부하고["Python", "파이썬"]은 허용해야 한다. - 오답 노트의 문제 식별자만 골라 다시 채점하는 복습 기능을 설계하라. 첫 실행은 현재 결과를 유지하고, 복습에는 별도의 고정 답안을 사용하라. 원래 점수와 복습 점수는 구분해서 출력하라.
확장 기능도 먼저 결과를 정한 뒤 AI에 요청한다. 마지막 문제를 확장할 때 사용할 요청문은 다음과 같다.
현재 main.py에 오답 복습을 추가하라. 첫 실행의 채점 결과와 오답 노트 저장 과정은 유지하라. 다시 읽은 오답 노트의 id 집합으로 원래 문제 은행을 필터링하고, 별도의 고정 복습 답안으로 채점하라. 오답 노트에 원래 문제 은행에 없는 id가 있으면 오류를 내라. 오답이 없으면 복습할 문제가 없다고 출력하라. 원래 점수와 복습 점수를 별도로 출력하고, 오답 없음과 알 수 없는 id에 대한 검사를 추가하라. 수정한 부분의 이유와 예상 출력도 설명하라.
정답과 해설
첫 번째 문제에서는 총점이 2/4가 되고 오답 식별자는 ["q02", "q04"]가 된다. grade()가 없는 답안을 빈 문자열로 처리하므로 네 번째 문제의 제출 답안은 ""로 저장된다. 전체 실행의 점수 검사와 오답 식별자 검사도 함께 바꿔야 한다. 검사를 지우는 대신 변경된 요구사항에 맞는 기대값을 적는다.
두 번째 문제에서는 세 번째 문항의 answers를 ["4", "넷"]으로 바꾸면 된다. 고정 답안 " 넷 "은 정규화 후 "넷"이 되어 허용 정답에 포함된다. 총점은 그대로 3/4다. 허용 표현을 문제 데이터에서 추가했으므로 다른 문제의 비교 정책은 바뀌지 않는다.
세 번째 문제의 핵심은 정규화한 정답 목록의 길이와 집합의 길이를 비교하는 것이다. 완성 코드의 정답 문자열 검사 다음에 같은 검사를 추가한다. 아래 코드는 추가할 규칙을 독립적으로 실행해 확인하는 예다.
def check_unique_answers(answers):
normalized = [answer.strip().casefold() for answer in answers]
if len(normalized) != len(set(normalized)):
raise ValueError("정규화 후 중복되는 허용 정답이 있다.")
check_unique_answers(["Python", "파이썬"])
try:
check_unique_answers(["JSON", " json "])
except ValueError:
pass
else:
raise AssertionError("중복 정답을 허용했다.")
네 번째 문제에서는 오답 노트를 새 문제 은행으로 취급하기보다 원래 문제를 고르는 기준으로 사용한다. 오답 노트의 질문과 정답은 당시 기록이며, 실제 복습에 사용할 문제는 원래 문제 은행에서 찾는다. 먼저 원래 식별자 집합과 오답 식별자 집합을 만들고, 오답 쪽에만 있는 식별자가 있으면 오류를 낸다.
복습 답안을 {"q02": " json "}으로 정하면 복습 점수는 1/1이다. 첫 실행의 3/4는 그대로 남는다. 오답이 없을 때는 빈 복습 목록을 처리하고 점수 비율 계산은 생략한다. 확장 뒤에도 원래 실행 결과, 새 경계값 검사, 변경 비교를 함께 확인한다. AI의 설명은 수정 의도를 파악하는 자료이며, 프로그램이 요구한 동작을 하는지는 실행과 검사로 판단한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.