Devin.KR

작은 게임 - 숫자 야구를 명세부터 테스트까지

개발자KR 조회 0

이 장에서 배우는 것

작은 게임은 프로그램의 경계를 연습하기에 좋은 소재다. 규칙을 몇 문장으로 설명할 수 있어도 코드로 옮기려면 여러 결정을 내려야 한다. 첫 자리에 0을 허용할지, 잘못 입력한 답도 기회로 셀지, 마지막 기회에 맞히면 성공인지부터 정해야 한다. 이 결정이 빠지면 코드가 실행되더라도 서로 다른 게임을 만들게 된다.

이번에는 숫자 야구 한 판을 완성한다. 사람이 키보드를 입력하는 대신 정해진 입력 목록을 차례로 공급하므로 실행하면 기다리지 않고 끝난다. 난수의 시작값도 고정하여 같은 환경에서 같은 정답과 진행 기록을 얻는다. 자동 진행은 게임을 덜 만드는 방법이 아니라 입력부터 종료까지 한꺼번에 검사하는 방법이다.

  • 게임 규칙을 입력 조건, 판정 기준, 종료 조건으로 나누어 명세로 적는다.
  • 스트라이크와 볼을 계산하는 함수에서 출력과 진행 상태를 분리한다.
  • 시드(seed)를 고정한 난수 생성기로 재현 가능한 정답을 만든다.
  • 판정 함수 검사표와 정해진 입력 순서로 계산과 한 판 진행을 각각 확인한다.
  • AI가 만든 코드의 수정 내용을 실행 결과와 변경 차이로 검토한다.

문제 상황

학교 동아리 모임에서 잠깐 실행할 숫자 맞히기 게임이 필요하다고 하자. 세 자리 정답을 정하고 참가자가 숫자를 제시하면 맞힌 정도를 알려 준다. 정답과 숫자와 위치가 모두 같으면 스트라이크, 숫자는 들어 있지만 위치가 다르면 볼이다. 참가자는 이 단서를 보고 다음 답을 고른다.

모임에서 쓸 프로그램이라고 해서 곧바로 입력 대기부터 만들 필요는 없다. 먼저 진행자가 준비한 입력 순서로 한 판이 제대로 끝나는지 확인해야 한다. 잘못된 입력 때문에 기회가 줄거나, 마지막 기회에 맞혔는데 실패로 표시되는 문제는 실제 진행을 시작한 뒤 발견하면 곤란하다.

앞 장에서 파일의 크기와 해시를 서로 다른 판단 단계로 나누었듯이, 여기서도 입력 검사와 정답 판정을 구분한다. 다만 이번 프로그램은 파일을 읽거나 쓰지 않는다. 필요한 자료는 코드 안의 검사표와 입력 목록뿐이며, Python 3.12 이상 표준 라이브러리만 사용한다.

게임 규칙을 검사 가능한 명세로 적는다

“숫자 야구를 만들어 달라”는 요청만으로는 규칙이 충분하지 않다. 자릿수와 중복 허용 여부가 바뀌면 판정식의 전제도 바뀐다. 이번에는 세 자리 문자열을 사용하고, 각 자리에는 서로 다른 영문 반각 숫자 0부터 9까지를 넣는다. 첫 자리가 0이어도 유효하다. 따라서 012는 정수 12와 구분되는 정상적인 답이다.

입력 양끝의 공백은 진행 함수에서 제거한다. 공백을 제거한 뒤 길이, 허용 문자, 중복 여부를 순서대로 검사한다. 잘못된 입력은 이유를 출력하고 다음 입력으로 넘어간다. 유효한 추측만 기회를 사용하며, 최대 세 번까지 판정한다. 세 번째 추측에서 맞혀도 성공이다.

한 판의 동작을 결정하는 요구사항
항목이번 프로그램의 규칙확인할 예
정답과 추측서로 다른 반각 숫자 세 개의 문자열012는 허용하고 112는 거부한다
입력 정리양끝 공백만 제거한다공백을 두른 140은 140으로 판정한다
스트라이크숫자와 위치가 모두 같은 자리 수104와 140은 1스트라이크다
볼공통 숫자 수에서 스트라이크 수를 뺀 값104와 140은 2볼이다
기회유효한 추측만 최대 세 번 센다잘못된 입력 세 개는 기회를 쓰지 않는다
종료성공, 기회 소진, 입력 소진을 구분한다성공 뒤에 남은 입력은 읽지 않는다

정답은 고정 시드로 생성한다. 실제 게임에서 언제나 같은 정답을 쓰려는 목적은 아니다. 개발 중에는 같은 상황을 다시 만들어 수정 전후를 비교하기 쉽도록 하는 것이다. 이번 장의 기본 실행은 재현 가능한 시연이므로 시드를 바꾸지 않는다.

요청문에는 기능뿐 아니라 검증 방식도 넣는다. AI가 알아서 규칙을 정하도록 두지 않고, 무엇을 확인해야 작업이 끝나는지 함께 적는다.

Python 3.12 이상 표준 라이브러리만 사용하는 숫자 야구를 main.py 한 파일로 작성하라. 실행하면 입력을 기다리지 않고 준비된 입력 목록으로 한 판을 진행한 뒤 종료해야 한다.

정답과 추측은 서로 다른 반각 숫자 세 개의 문자열이며 첫 자리 0을 허용한다. 입력 양끝의 공백을 제거하고 길이, 허용 문자, 중복을 검사하라. 잘못된 입력은 기회를 쓰지 않는다. 유효한 추측은 세 번까지이며 마지막 기회의 정답도 성공이다.

검증 함수, 판정 함수, 정답 생성 함수, 진행 함수를 분리하라. 판정 함수는 화면 출력이나 난수 생성 없이 두 문자열을 받아 스트라이크 수와 볼 수를 반환해야 한다. 독립된 난수 생성기에 시드 42를 사용하고, 숫자 문자열 0123456789에서 세 개를 중복 없이 뽑아 정답을 만들어라.

판정 함수 검사표와 잘못된 입력 검사, 한 판의 종료 조건 검사를 포함하라. 실행할 때 검사 통과 요약과 자동 진행 기록을 출력하라. 코드 작성 뒤에는 계산한 예상값과 실제 실행 결과를 대조하라.

판정 함수와 입출력을 분리한다

프로그램의 중심은 두 문자열을 받아 두 정수를 돌려주는 판정 함수다. 함수 안에서 입력을 받거나 결과를 출력하지 않으면 검사표의 각 행을 그대로 함수에 넣을 수 있다. 같은 입력에는 같은 결과가 나오므로 실패한 사례를 따로 떼어 확인하기도 쉽다.

스트라이크는 같은 위치끼리 비교하여 센다. 볼은 두 문자열에 공통으로 들어 있는 숫자의 수에서 스트라이크를 빼서 구한다. 이 계산에는 각 문자열의 숫자가 중복되지 않는다는 전제가 있다. 따라서 집합으로 바꾸기 전에 유효성을 확인해야 한다.

판정 함수는 검증된 두 문자열을 받아 스트라이크와 볼만 반환한다

정답이 104이고 추측이 140이면 첫 자리의 1만 위치가 같다. 공통 숫자는 1, 0, 4의 세 개이고 그중 하나가 스트라이크이므로 볼은 두 개다. 공통 숫자 세 개를 곧바로 볼이라고 세면 스트라이크에 해당하는 숫자까지 중복해서 계산한다.

입력 검증도 판정 함수의 계약에 포함한다. 진행 함수가 먼저 검사하더라도 판정 함수는 전달받은 두 문자열을 다시 검증한다. 함수가 나중에 다른 곳에서 호출될 때도 잘못된 값을 조용히 계산하지 않게 하기 위해서다. 사용자에게 보여 줄 오류 문구는 검증 함수가 만들고, 진행 함수가 그 문구를 출력한다.

아래 표는 AI의 1차 결과를 검토할 때 흔히 드러나는 문제를 구체화한 것이다. 그럴듯한 성공 화면 하나로는 확인하기 어려우므로 각 문제마다 발견할 입력이나 검사 방법을 붙인다.

AI 1차 결과에서 발견한 문제와 수정 기준
문제발견 방법고친 방향
입력을 정수로 바꾸어 첫 자리 0이 사라진다012를 판정 함수에 전달한다정답과 추측을 끝까지 문자열로 유지한다
공통 숫자를 모두 볼로 계산한다104와 104의 기대값을 3, 0으로 둔다공통 숫자 수에서 스트라이크를 뺀다
숫자 검사만 하여 전각 숫자도 허용한다123을 입력 검증에 전달한다문자열 0123456789에 속하는지 확인한다
입력을 읽을 때마다 기회를 증가시킨다잘못된 입력 세 개 뒤에 정상 입력을 둔다검증을 통과한 뒤에만 횟수를 증가시킨다
기회 소진을 먼저 검사해 마지막 정답을 실패 처리한다세 번째 유효 입력을 정답으로 둔다정답 여부를 먼저 확인한 뒤 기회 소진을 검사한다

수정을 요청할 때도 “버그를 고쳐 달라”로 끝내지 않는다. 재현 조건과 기대 결과를 제시하면 수정 범위를 좁힐 수 있다.

104와 104를 판정하면 볼이 0이어야 한다. 공통 숫자를 세는 코드에서 스트라이크를 빼도록 수정하라. 입력은 정수로 바꾸지 말고, 012를 정상 입력으로 유지하라. 또한 잘못된 입력 뒤에는 시도 횟수가 증가하지 않게 하라. 세 번째 유효 입력이 정답인 진행 검사를 추가하고, 수정한 함수와 검사 항목의 변경 차이를 보여 달라.

검사표와 자동 진행으로 수정 결과를 확인한다

판정 함수 검사표는 정답을 맞히는 사례만 모으지 않는다. 위치만 바뀐 경우, 일부만 겹치는 경우, 하나도 겹치지 않는 경우를 나누어 넣는다. 기대값은 검사 대상 함수를 호출해 만들지 않고 사람이 규칙을 따라 적는다. 같은 계산식을 복사해 기대값을 만들면 양쪽이 함께 틀려도 검사가 통과할 수 있다.

판정 함수에 넣을 입력과 사람이 계산한 기대값
정답추측스트라이크볼
10410430
10414012
10401502
10456700
01220103
01201320
01203410
01212003

계산 검사가 통과한 뒤에는 진행 규칙을 검사한다. 정답을 104로 직접 전달하고, 잘못된 입력 세 개와 정상 입력 세 개, 성공 뒤의 여분 입력을 준비한다. 검사에서는 읽은 원본 입력을 별도 목록에 기록한다. 읽은 개수가 여섯 개인지 확인하면 성공 뒤의 일곱 번째 입력을 소비하지 않았다는 점까지 알 수 있다.

진행 함수에는 입력 목록과 출력 함수를 전달한다. 기본 출력 함수는 print지만 검사할 때는 리스트의 append를 전달하여 화면 대신 목록에 기록한다. 이 구조는 별도 도구 없이도 마지막 메시지와 결과를 확인할 수 있게 한다.

잘못된 입력은 기회를 유지하고 유효한 입력만 성공 또는 기회 소진으로 이어진다

입력 목록이 먼저 끝나는 상황도 따로 검사한다. 입력 소진은 기회를 모두 쓴 것과 다르다. 아직 기회가 남아 있어도 공급할 입력이 없으면 이번 자동 진행은 중단된다. 진행 함수는 성공, 실패, 중단을 서로 다른 반환값으로 알려 준다.

검사에는 명시적인 조건문과 AssertionError를 사용한다. 실행 옵션에 따라 생략될 수 있는 assert 문 대신 항상 실행되는 비교를 두기 위해서다. 예상값과 실제값이 다르면 이름을 포함한 오류가 발생하고 시연을 시작하지 않는다. 자동 검사 통과는 적어 둔 사례가 맞았다는 뜻이며, 모든 가능한 상황을 증명했다는 뜻은 아니다.

저장한 뒤에는 터미널에서 먼저 문법과 경고를 확인하고 프로그램을 실행한다. 명령은 다음 순서로 입력한다.

python3 -W error -m py_compile main.py
python3 -W error main.py

첫 명령은 정상이라면 아무것도 출력하지 않는다. 두 번째 명령의 출력은 아래 실행 결과와 대조한다. 버전 관리에 기록하기 전에는 git diff로 판정식, 횟수 증가 위치, 종료 검사 순서를 확인한다. AI가 오류를 고치면서 입력 규칙이나 출력 형식까지 바꾸지 않았는지도 읽는다. 여기서 제시하는 예상 출력 역시 독자의 환경에서 직접 실행하여 확인할 대상이다.

완성 코드

다음 내용을 main.py로 저장한다. 기본 실행은 검사를 마친 뒤 고정 시드의 정답으로 한 판을 자동 진행한다. 검사에 쓰는 정답은 직접 지정하므로 난수 생성 결과와 판정 함수의 옳고 그름을 별도로 확인할 수 있다.

import random
from collections.abc import Callable, Iterable

DIGITS = "0123456789"
WIDTH = 3
LIMIT = 3
SEED = 42


def validate_number(value: str) -> None:
    if len(value) != WIDTH:
        raise ValueError("세 자리여야 한다.")
    if any(char not in DIGITS for char in value):
        raise ValueError("0~9의 반각 숫자만 허용한다.")
    if len(set(value)) != WIDTH:
        raise ValueError("같은 숫자를 반복할 수 없다.")


def judge(answer: str, guess: str) -> tuple[int, int]:
    validate_number(answer)
    validate_number(guess)
    strikes = sum(a == g for a, g in zip(answer, guess))
    common = len(set(answer) & set(guess))
    return strikes, common - strikes


def make_answer(seed: int) -> str:
    rng = random.Random(seed)
    return "".join(rng.sample(DIGITS, WIDTH))


def play(
    answer: str,
    inputs: Iterable[str],
    emit: Callable[[str], None] = print,
) -> str:
    validate_number(answer)
    used = 0
    for raw in inputs:
        guess = raw.strip()
        emit(f"입력: {raw!r}")
        try:
            validate_number(guess)
        except ValueError as error:
            emit(f"무효: {error}")
            continue

        used += 1
        strikes, balls = judge(answer, guess)
        emit(f"{used}회: {strikes}스트라이크 {balls}볼")
        if strikes == WIDTH:
            emit(f"성공: {used}회 만에 정답 {answer}")
            return "win"
        if used == LIMIT:
            emit(f"실패: 기회를 모두 썼다. 정답 {answer}")
            return "lose"

    emit(f"중단: 입력이 끝났다. 사용한 기회 {used}회")
    return "incomplete"


def check_equal(name: str, actual: object, expected: object) -> None:
    if actual != expected:
        raise AssertionError(
            f"{name}: 기대 {expected!r}, 실제 {actual!r}"
        )


def run_checks() -> None:
    cases = [
        ("104", "104", (3, 0)),
        ("104", "140", (1, 2)),
        ("104", "015", (0, 2)),
        ("104", "567", (0, 0)),
        ("012", "201", (0, 3)),
        ("012", "013", (2, 0)),
        ("012", "034", (1, 0)),
        ("012", "120", (0, 3)),
    ]
    for answer, guess, expected in cases:
        check_equal(f"판정 {answer}/{guess}", judge(answer, guess), expected)

    invalid = ["", "12", "1234", "11a", "112", "123", "1 2"]
    for value in invalid:
        try:
            validate_number(value)
        except ValueError:
            pass
        else:
            raise AssertionError(f"잘못된 입력을 허용했다: {value!r}")

    lines: list[str] = []
    consumed: list[str] = []

    def recorded_inputs() -> Iterable[str]:
        for raw in ["12", "11a", "112", " 140 ", "015", "104", "999"]:
            consumed.append(raw)
            yield raw

    result = play("104", recorded_inputs(), lines.append)
    check_equal("마지막 기회 성공", result, "win")
    check_equal("성공 메시지", lines[-1], "성공: 3회 만에 정답 104")
    check_equal("성공 뒤 입력 미소비", len(consumed), 6)

    lines.clear()
    result = play("104", ["567", "238", "690", "104"], lines.append)
    check_equal("기회 소진", result, "lose")
    check_equal("실패 메시지", lines[-1], "실패: 기회를 모두 썼다. 정답 104")

    lines.clear()
    result = play("104", ["567"], lines.append)
    check_equal("입력 소진", result, "incomplete")
    check_equal("중단 메시지", lines[-1], "중단: 입력이 끝났다. 사용한 기회 1회")

    answer = make_answer(SEED)
    validate_number(answer)
    check_equal("같은 시드 재현", make_answer(SEED), answer)
    check_equal("시연 정답", answer, "104")
    print(f"검사 통과: 판정 {len(cases)}건, 무효 입력 {len(invalid)}건")
    print("진행 검사 통과: 마지막 기회 성공, 기회 소진, 입력 소진")
    print("종료 검사 통과: 성공 뒤 입력 미소비")
    print("난수 검사 통과: 같은 시드 재현, 시연 정답 104")


def main() -> None:
    run_checks()
    print("숫자 야구 자동 진행")
    print(f"규칙: 중복 없는 {WIDTH}자리, 첫 자리 0 허용, 기회 {LIMIT}회")
    print(f"고정 시드: {SEED}")
    inputs = ["12", "11a", "112", " 140 ", "015", "104", "999"]
    play(make_answer(SEED), inputs)


if __name__ == "__main__":
    main()

줄별 해설

가져오기 부분에서는 random과 함수 인수의 형태를 표현할 자료형만 사용한다. DIGITS는 허용 문자를 한곳에 모은 상수다. WIDTH, LIMIT, SEED는 각각 자릿수, 유효 추측 횟수, 난수 시작값을 나타낸다. 상수로 모았다고 해서 자릿수만 바꾸면 시연까지 모두 자동으로 바뀌는 것은 아니다. 검사표와 입력 목록도 함께 검토해야 한다.

validate_number는 성공하면 값을 반환하지 않고 끝난다. 실패하면 ValueError를 발생시킨다. 검증 순서도 출력의 일부다. 11a는 중복된 1도 포함하지만 허용 문자 검사에서 먼저 걸리므로 반각 숫자만 허용한다는 안내를 받는다. 여러 조건을 동시에 어기는 입력에서는 어느 이유를 먼저 알려 줄지 일관되게 정해 둔다.

judge의 첫 두 호출은 정답과 추측이 같은 규칙을 지키는지 확인한다. zip은 같은 위치의 문자를 짝짓고, 비교 결과의 참과 거짓을 sum으로 더하면 스트라이크 수가 된다. 검증에서 두 문자열의 길이를 모두 세 자리로 제한하므로 길이가 달라 일부 자리만 비교되는 문제를 피한다.

common을 계산하는 줄의 집합 교집합은 양쪽에 있는 숫자 종류를 구한다. 입력에 중복이 없으므로 종류의 수가 공통 숫자의 수와 같다. 반환하는 튜플의 순서는 스트라이크, 볼이다. 호출하는 쪽도 같은 순서로 이름을 붙여 받는다.

make_answer는 random.Random으로 독립된 생성기를 만든다. 프로그램 다른 곳에서 난수를 사용하더라도 이 생성 과정의 상태와 섞이지 않는다. sample은 중복 없이 세 문자를 뽑고 join은 그 결과를 문자열로 합친다. 생성기는 호출할 때마다 같은 시드로 새로 만들어지므로 이번 함수는 같은 인수에 같은 정답을 돌려준다.

play의 inputs는 문자열을 순서대로 공급할 수 있는 대상이다. 목록뿐 아니라 검사에서 만든 생성기도 받을 수 있다. emit은 문자열 하나를 받아 처리하는 함수다. 기본값을 print로 두어 평소에는 화면에 출력하고, 검사에서는 lines.append로 기록한다. 입력과 출력을 인수로 전달하면서 진행 규칙 자체는 그대로 유지한다.

for 문 안의 raw는 공백을 포함한 원본이고 guess는 양끝 공백을 제거한 값이다. 출력에는 원본의 표현을 사용하여 어떤 입력이 들어왔는지 남긴다. 검증 실패 후 continue를 실행하므로 used를 증가시키는 줄에는 도달하지 않는다. 따라서 잘못된 입력은 기회를 쓰지 않는다.

판정 직후에는 성공부터 확인한다. 성공하면 즉시 반환하므로 남은 입력은 읽지 않는다. 그다음에 횟수를 확인하여 실패를 반환한다. 반복문이 정상적으로 끝나면 공급된 입력을 모두 사용한 것이므로 중단을 알린다. 세 종료 경로를 각각 반환값으로 표현하여 화면 문구만 분석하지 않아도 결과를 알 수 있다.

run_checks는 계산, 검증, 진행, 난수 생성의 순서로 확인한다. 판정 검사에는 정답 생성기를 사용하지 않는다. 반대로 마지막 난수 검사에서는 시연 입력이 기대하는 정답 104가 생성되는지도 확인한다. 다른 실행 환경으로 옮겨 정답 생성 결과가 달라지면 검사에서 먼저 멈춘다. 고정 시드가 관련 함수의 모든 구현 변경까지 보장한다고 가정하지 않는다.

마지막 조건문은 이 파일을 직접 실행했을 때만 main을 호출한다. 나중에 다른 Python 파일에서 judge를 가져와 검사하더라도 자동 진행 화면이 함께 출력되는 일을 막는다. 이번 완성본은 한 파일로 충분하지만 함수의 사용 범위는 분리해 둔 셈이다.

실행 결과

파일을 저장한 폴더에서 다음 명령을 실행한다. 별도 입력 파일이나 추가 설치는 필요하지 않다.

python3 main.py

예상 출력은 다음과 같다. 입력 기록의 따옴표는 원본 문자열의 표현이며, 공백을 두른 입력도 그대로 보인다.

검사 통과: 판정 8건, 무효 입력 7건
진행 검사 통과: 마지막 기회 성공, 기회 소진, 입력 소진
종료 검사 통과: 성공 뒤 입력 미소비
난수 검사 통과: 같은 시드 재현, 시연 정답 104
숫자 야구 자동 진행
규칙: 중복 없는 3자리, 첫 자리 0 허용, 기회 3회
고정 시드: 42
입력: '12'
무효: 세 자리여야 한다.
입력: '11a'
무효: 0~9의 반각 숫자만 허용한다.
입력: '112'
무효: 같은 숫자를 반복할 수 없다.
입력: ' 140 '
1회: 1스트라이크 2볼
입력: '015'
2회: 0스트라이크 2볼
입력: '104'
3회: 3스트라이크 0볼
성공: 3회 만에 정답 104

처음 세 입력은 모두 무효이므로 140이 첫 번째 유효 추측이다. 015는 정답에 있는 0과 1을 다른 위치에 놓았으므로 0스트라이크 2볼이다. 세 번째 유효 추측 104에서 성공한다. 목록에 남아 있는 999가 출력되지 않는 것도 확인할 결과다.

실무에서 자주 틀리는 것

문자열 식별값을 정수로 바꾼다

다음 코드는 첫 자리 0을 없앤다. 숫자로만 이루어졌더라도 자리 자체에 의미가 있다면 문자열로 다뤄야 한다. 각 예시는 그대로 실행할 수 있으며, 첫 예시에서는 의도한 세 자리 문자열이 사라지는 모습을 확인한다.

raw = "012"
guess = str(int(raw))
print(guess)

고친 코드는 양끝 공백만 제거한다. 실제 유효성 검사는 완성 코드의 검증 함수에 맡긴다.

raw = "012"
guess = raw.strip()
print(guess)

집합으로 바꾼 뒤 중복을 확인하려 한다

다음 코드는 중복 입력을 받은 상태에서 공통 숫자만 센다. 집합은 같은 원소를 하나로 합치므로 입력에 1이 두 번 있었다는 정보가 사라진다. 이 결과를 게임 판정에 사용하면 입력 규칙 위반을 놓친다.

answer = "104"
guess = "112"
common = len(set(answer) & set(guess))
print(common)

고친 코드는 집합을 계산에 쓰기 전에 입력 규칙을 확인한다. 실제 프로그램에서는 이 검사를 validate_number로 모아 중복 작성을 피한다.

guess = "112"
if len(guess) != 3:
    print("세 자리여야 한다.")
elif any(char not in "0123456789" for char in guess):
    print("반각 숫자만 허용한다.")
elif len(set(guess)) != 3:
    print("같은 숫자를 반복할 수 없다.")
else:
    print("유효한 입력이다.")

마지막 기회의 성공을 실패보다 늦게 확인한다

아래 코드에서는 세 번째 추측이 정답이어도 실패 분기에 먼저 들어간다. 비교 연산자 하나만 확인해서는 찾기 어렵고, 조건문의 순서를 함께 봐야 한다.

used = 3
limit = 3
strikes = 3
if used == limit:
    print("실패")
elif strikes == 3:
    print("성공")

고친 코드는 정답 여부를 먼저 확인한다. 실제 진행 함수에서는 각 분기에서 반환하여 이후 입력을 소비하지 않는다.

used = 3
limit = 3
strikes = 3
if strikes == 3:
    print("성공")
elif used == limit:
    print("실패")

기대값을 검사 대상 함수로 만든다

다음 검사는 계산이 틀려도 실제값과 기대값이 함께 틀릴 수 있다. 같은 함수를 두 번 호출했다는 사실만으로 규칙을 확인할 수는 없다.

def wrong_judge(answer, guess):
    return 0, len(set(answer) & set(guess))


expected = wrong_judge("104", "104")
actual = wrong_judge("104", "104")
print(actual == expected)

고친 검사는 사람이 계산한 기대값을 둔다. 실행하면 False가 나오므로 잘못된 판정을 잡았음을 알 수 있다. 완성 코드에서는 이런 불일치를 발견하면 오류를 발생시켜 실행을 멈춘다.

def wrong_judge(answer, guess):
    return 0, len(set(answer) & set(guess))


expected = (3, 0)
actual = wrong_judge("104", "104")
print(actual == expected)

한눈에 보기

규칙과 구현, 검사가 연결되는 지점
관심사구현 위치확인 방법
입력의 모양validate_number길이, 문자, 중복 위반을 거부한다
스트라이크와 볼judge독립적으로 계산한 검사표와 비교한다
재현 가능한 정답make_answer같은 시드의 결과와 시연 정답을 확인한다
기회 사용play의 검증 다음 줄무효 입력 뒤에도 첫 유효 추측이 1회다
종료 조건play의 세 반환 경로성공, 실패, 중단을 따로 실행한다
수정 검토검사 실행과 변경 차이 확인판정식과 분기 순서가 명세에 맞는지 읽는다

이 프로그램에서 얻을 중요한 습관은 규칙을 코드보다 먼저 구체화하는 일이다. 판정 함수가 맞는지와 한 판이 올바르게 진행되는지는 서로 다른 질문이다. 검사표는 첫 질문에 답하고, 정해진 입력 순서와 종료 검사는 두 번째 질문에 답한다. AI의 설명을 읽는 데서 멈추지 않고 이 두 결과를 직접 확인해야 한다.

연습 문제

다음 요청문을 바탕으로 하나씩 확장한다. 확장 후에도 기본 실행이 입력을 기다리지 않고 끝나도록 유지하고, 예상 출력이 바뀌면 함께 갱신한다.

  1. AI에게 다음과 같이 요청하라.

    유효 추측 한 번 뒤에 입력이 끝나는 기존 검사에 더해, 빈 입력 목록과 무효 입력만 있는 목록의 중단 검사를 추가하라. 두 경우 모두 사용한 기회가 0회인지 확인하라. 기존 시연 입력과 판정 규칙은 유지하라.

  2. AI에게 다음과 같이 요청하라.

    판정 함수에 전달하는 정답이 잘못된 경우도 검사하라. 정답 001과 정답 123을 정상 추측 104와 함께 전달했을 때 ValueError가 발생하는지 확인하라. 함수가 두 인수를 모두 검증한다는 계약을 유지하라.

  3. AI에게 다음과 같이 요청하라.

    입력 목록의 순서를 바꾸어 첫 유효 추측에서 성공하는 별도 진행 검사를 추가하라. 성공 뒤의 입력은 소비되지 않아야 한다. 반환값, 성공 메시지, 소비한 입력 개수를 각각 확인하고 기본 시연은 유지하라.

  4. AI에게 다음과 같이 요청하라.

    유효한 추측의 입력값과 판정 결과를 진행 기록에 남기는 기능을 추가하라. 기존 반환값과 출력 함수 인수의 사용 방식을 유지하면서 선택적인 기록 목록을 받도록 하라. 무효 입력은 기록에서 제외하고, 실패한 세 번의 기록이 순서대로 남는지 검사하라.

정답과 해설

  1. 빈 목록은 반복문 본문을 한 번도 실행하지 않는다. 무효 입력만 있는 목록은 continue로 다음 입력에 넘어가므로 횟수를 증가시키지 않는다. 두 경우 모두 반환값은 incomplete이고 마지막 문구는 “중단: 입력이 끝났다. 사용한 기회 0회”여야 한다. 기존의 유효 추측 한 번 뒤 중단 검사도 남겨 두어 0회와 1회를 구분한다.

  2. judge를 호출하는 부분을 try와 except ValueError로 감싸고, 오류 없이 끝나는 경우 검사 실패를 발생시킨다. 정답 001은 중복 검사에서, 정답 123은 허용 문자 검사에서 거부되어야 한다. 추측만 검사하는 코드로 바꾸었을 때 새 검사가 실패하는지 확인하면 검사 의도도 분명해진다.

  3. 정답 104에 입력 목록 104, 567을 전달하면 첫 입력에서 성공한다. 기대 반환값은 win이고 마지막 메시지는 “성공: 1회 만에 정답 104”다. 입력을 공급하는 생성기에 소비 기록을 붙여 개수가 1인지 확인한다. 출력에 567이 없다는 사실과 실제로 두 번째 값을 읽지 않았다는 사실을 구분하는 검사다.

  4. 선택 인수의 기본값은 None으로 두고, 기록 목록이 전달된 경우에만 유효 추측의 판정 뒤에 항목을 추가한다. 기본값으로 빈 리스트를 두면 여러 호출이 같은 목록을 공유할 수 있으므로 피한다. 정답 104에 567, 238, 690을 넣으면 각 결과는 0스트라이크 0볼, 0스트라이크 0볼, 0스트라이크 1볼이다. 세 번째 결과를 기록한 뒤 실패로 종료해야 하며, 뒤에 붙인 정답은 기록에 없어야 한다.

책의 마지막 프로그램도 짧은 명세, 작은 함수, 명시적인 기대값, 재현 가능한 실행으로 완성했다. 새로운 문제를 만났을 때는 먼저 규칙을 적고, AI가 제안한 구현을 그 규칙에 비추어 실행한다. 수정한 뒤에는 검사를 다시 돌리고 변경 차이를 읽는다. 완성 여부를 판단하는 근거는 그럴듯한 코드의 모양이 아니라 요구사항과 일치하는 동작이다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.