Devin.KR

예외와 부작용

100분 안팎

학습 목표

잘못된 입력이 기존 목록을 바꾸지 않도록 처리하고 실패 경로를 추적합니다.

개념

실패를 반환해도 상태는 따로 확인합니다

AI 코드가 EMPTY_TITLE을 반환하더라도 그 전에 목록에 항목을 넣었다면 계약은 실패합니다. 신입에게 오류 처리 검토를 맡길 때는 오류 메시지와 전체 상태를 같이 가져오게 합니다. 이번 레슨에서는 앞서 분리한 제목 검사, 생성, 조회, 완료를 apply에서 연결합니다. 함수가 실패한 순간 어떤 줄을 건너뛰고 누가 오류를 결과로 바꾸는지 추적합니다.

부작용은 반환값 외에 호출자가 관찰할 수 있는 변화를 말합니다. 입력 목록 수정, 파일 쓰기, 네트워크 요청, 화면 출력 등이 해당합니다. 이 프로젝트의 참조 모델은 함수에 상태와 명령을 주고 결과와 새 상태를 받는 방식입니다. 외부 저장소, 현재 시각, 전역 카운터를 쓰지 않습니다. 같은 정상 초기 상태와 같은 명령이면 같은 결과를 만들고 입력 객체를 변경하지 않는 구조를 목표로 합니다.

모델의 상태는 id가 1부터 연속으로 주어진 정상 항목 배열입니다. 입력 명령은 add, list, complete이며 제목과 완료 id는 잘못된 값일 수 있습니다. 초기 상태가 깨졌을 때 자동 복구하는 기능은 이번 계약에 없습니다. 형식이 다른 초기 상태나 JSON 자체의 문법 오류는 참조 모델의 수용 범위와 구분합니다. 전제를 숨기지 않아야 테스트 통과의 의미를 과장하지 않습니다.

raise와 except 사이의 경로를 읽습니다

normalize_title은 성공하면 정리한 문자열을 반환하고 실패하면 ValueError에 업무 오류 코드를 넣어 발생시킵니다. raise를 만나면 그 함수의 나머지 줄과 호출자의 아직 진행하지 않은 줄을 실행하지 않고 처리할 except를 찾습니다. add_task가 이 예외를 잡지 않으므로 apply의 except ValueError까지 전파됩니다. apply는 ok:false와 오류 코드를 반환하고 원본의 복사 상태를 함께 돌려줍니다.

이번 작은 모델은 명시적으로 ValueError를 업무 오류 전달에 사용합니다. 더 큰 프로그램에서 라이브러리의 ValueError와 섞일 수 있다면 별도 업무 예외를 사용하는 편이 구분하기 쉽습니다. 여기서는 숫자 변환 등 다른 ValueError 발생 연산을 추가하지 않습니다. except Exception으로 모든 오류를 업무 실패처럼 바꾸면 함수 이름 오타나 필드 누락 결함까지 숨길 수 있습니다. 예상한 예외 범위를 유지합니다.

add_task는 normalize_title 호출을 먼저 합니다. 이 호출이 성공한 뒤 번호와 항목을 만들고 복사된 목록에 연결합니다. 빈 제목이 실패하면 번호를 발급하는 줄에 도달하지 않습니다. 번호는 성공 상태 길이에서 구하므로 실패 후 유효한 생성이 와도 번호를 건너뛰지 않습니다. 전역 next_id를 검증 전에 올리는 구현과 비교하면 실패의 부작용을 왜 따로 보는지 알 수 있습니다.

complete_task는 type(task_id) is int와 양수 조건을 검사합니다. Python에서는 isinstance(True, int)가 참이므로 그 검사만 사용하면 boolean id를 허용합니다. JSON true는 Python True로 해석되어 정수 키 1과 혼동될 수 있습니다. 명세는 boolean을 제외하므로 정확한 int 형식을 확인합니다. 문자열 “1”, 실수 1.0, 0, 음수도 INVALID_ID이며 양의 정수이지만 없는 번호는 NOT_FOUND입니다.

완료 대상이 존재하면 모든 항목을 복사한 by_id에서 done을 True로 바꾸고 새 상태를 반환합니다. 이미 True인 항목에 다시 True를 넣어도 성공이며 상태 값은 같습니다. 없는 번호를 조회하는 동안에는 원본을 변경하지 않습니다. 완료를 취소하는 기능이나 반복 완료를 오류로 바꾸는 선택을 추가하지 않습니다. 오류 코드와 재완료 정책은 앞 명세의 계약을 따릅니다.

복사와 불변 조건을 실제로 대조합니다

copy_state는 바깥 리스트와 안쪽 항목 딕셔너리를 모두 새로 만듭니다. tasks[:]나 list(tasks)는 리스트만 복사하므로 안쪽 done을 바꾸면 원본도 바뀔 수 있습니다. 이 프로젝트의 필드는 원시 값뿐이라 dict(task)로 항목을 복사하면 충분합니다. 새로운 중첩 메모 필드를 넣는다면 그 복사 규칙도 다시 설계해야 합니다. 복사 함수가 있다는 사실보다 복사 범위를 확인합니다.

apply는 결과와 상태의 두 값을 튜플로 반환합니다. result, state = apply(state, command)는 이를 풀어서 다음 명령에 새 상태를 넘깁니다. 성공 응답의 task를 나중에 수정해도 반환 state가 영향을 받지 않도록 task도 별도 복사합니다. 조회 결과와 상태도 분리합니다. 원본 불변 검사는 호출 전후 값 비교와 반환 객체 수정 후 비교를 함께 수행해야 공유 문제까지 발견합니다.

브라우저 실습은 initial과 commands를 받고 results, state, original을 출력합니다. starter의 apply는 완료 전에 원본의 done을 바꾸고 잘못된 제목도 추가하는 결함을 갖고 있습니다. 제공된 다른 함수와 입력·출력 코드는 유지하며 apply를 완성합니다. result와 state 반환 구조를 지키고 임시 추적 출력은 제거합니다. 실패 뒤 정상 명령을 이어 실행하는 테스트로 번호와 상태가 보존되는지 봅니다.

미션 starter는 앞 모듈 solution의 spec.md, cases.json, 문서 검사기를 이어받습니다. reference.py의 normalize_title, add_task, list_tasks, complete_task, apply를 완성하고 test_reference.py는 유지합니다. bash check.sh는 먼저 기존 문서 계약을 확인하고 unittest로 모든 사례의 결과, 이후 전체 상태, 원본 불변과 복사 독립성을 검사합니다. 서버나 외부 패키지가 필요하지 않습니다.

AssertionError가 나오면 테스트 이름과 기대값·실제값을 읽습니다. C07 결과만 맞고 상태 비교가 실패했다면 없는 번호 처리 도중 항목을 수정했는지 추적합니다. C11이 성공이라면 boolean 검사 위치를 봅니다. 예기치 않은 KeyError나 AttributeError는 업무 오류로 바꾸기 전에 해당 함수와 입력을 확인합니다. JSONDecodeError와 FileNotFoundError는 사례 파일 읽기 단계의 문제이며 모델의 업무 실패가 아닙니다.

미션 trace.md에는 C03의 apply → add_task → normalize_title → ValueError → apply except 흐름과 C08의 정상 완료 흐름을 씁니다. 각 경계의 입력, 반환 또는 예외, 실행하지 않은 줄, 원본과 새 상태를 기록합니다. Python 참조 모델을 통과했어도 Java 앱이나 HTTP API 구현은 아직 미확인입니다. 실제 확인 범위와 다음 단계의 번역 작업을 evidence.md에 분리합니다.

레슨을 마치면 정상 종료했다는 말에 더해 어떤 명령이 어떤 결과를 냈고 상태가 어떻게 변했는지 설명할 수 있어야 합니다. AI에 다시 질문할 때도 “오류가 납니다” 대신 초기 상태, 명령, 기대 오류, 실제 상태를 전달할 수 있습니다. 예외 문법의 다양한 변형은 더 읽기에 맡기며 지금은 예상한 실패를 드러내고 원본을 지키는 실행 흐름을 직접 확인합니다.

따라하기

boolean 식별자를 구분합니다

두 조건의 차이를 읽고 어떤 조건이 명세에 맞는지 설명합니다.

for task_id in [True, 1, "1", 0, 1.0]:
    print(repr(task_id), isinstance(task_id, int), type(task_id) is int and task_id > 0)

실행 결과

True True False
1 True True
'1' False False
0 True False
1.0 False False

실패 경로와 성공 경로를 추적합니다

C03에서 예외가 어떤 호출을 지나가는지, C08에서 수정되는 객체가 무엇인지 적습니다.

def normalize_title(title):
    if not isinstance(title, str):
        raise ValueError("INVALID_TITLE_TYPE")
    clean = title.strip()
    if clean == "":
        raise ValueError("EMPTY_TITLE")
    if len(clean) > 40:
        raise ValueError("TITLE_TOO_LONG")
    return clean


def copy_state(tasks):
    return [dict(task) for task in tasks]


def add_task(tasks, title):
    clean = normalize_title(title)
    task = {"id": len(tasks) + 1, "title": clean, "done": False}
    return task, copy_state(tasks) + [dict(task)]


def list_tasks(tasks):
    return sorted(copy_state(tasks), key=lambda task: task["id"])


def complete_task(tasks, task_id):
    if type(task_id) is not int or task_id <= 0:
        raise ValueError("INVALID_ID")
    by_id = {task["id"]: dict(task) for task in tasks}
    if task_id not in by_id:
        raise ValueError("NOT_FOUND")
    by_id[task_id]["done"] = True
    task = dict(by_id[task_id])
    return task, [by_id[key] for key in sorted(by_id)]


def apply(tasks, command):
    try:
        op = command.get("op")
        if op == "add":
            task, state = add_task(tasks, command.get("title"))
            return {"ok": True, "task": task}, state
        if op == "list":
            return {"ok": True, "tasks": list_tasks(tasks)}, copy_state(tasks)
        if op == "complete":
            task, state = complete_task(tasks, command.get("id"))
            return {"ok": True, "task": task}, state
        raise ValueError("INVALID_OPERATION")
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}, copy_state(tasks)

original = [{"id": 1, "title": "복습", "done": False}]
for command in [{"op": "add", "title": " "}, {"op": "complete", "id": 1}]:
    result, state = apply(original, command)
    print(result)
    print(original[0]["done"], state[0]["done"])

실행 결과

{'ok': False, 'error': 'EMPTY_TITLE'}
False False
{'ok': True, 'task': {'id': 1, 'title': '복습', 'done': True}}
False True

실패가 다음 번호를 소비하지 않는지 봅니다

첫 실패에서 실행하지 않은 줄과 두 번째 성공의 번호를 기록합니다.

def normalize_title(title):
    if not isinstance(title, str):
        raise ValueError("INVALID_TITLE_TYPE")
    clean = title.strip()
    if clean == "":
        raise ValueError("EMPTY_TITLE")
    if len(clean) > 40:
        raise ValueError("TITLE_TOO_LONG")
    return clean


def copy_state(tasks):
    return [dict(task) for task in tasks]


def add_task(tasks, title):
    clean = normalize_title(title)
    task = {"id": len(tasks) + 1, "title": clean, "done": False}
    return task, copy_state(tasks) + [dict(task)]


def list_tasks(tasks):
    return sorted(copy_state(tasks), key=lambda task: task["id"])


def complete_task(tasks, task_id):
    if type(task_id) is not int or task_id <= 0:
        raise ValueError("INVALID_ID")
    by_id = {task["id"]: dict(task) for task in tasks}
    if task_id not in by_id:
        raise ValueError("NOT_FOUND")
    by_id[task_id]["done"] = True
    task = dict(by_id[task_id])
    return task, [by_id[key] for key in sorted(by_id)]


def apply(tasks, command):
    try:
        op = command.get("op")
        if op == "add":
            task, state = add_task(tasks, command.get("title"))
            return {"ok": True, "task": task}, state
        if op == "list":
            return {"ok": True, "tasks": list_tasks(tasks)}, copy_state(tasks)
        if op == "complete":
            task, state = complete_task(tasks, command.get("id"))
            return {"ok": True, "task": task}, state
        raise ValueError("INVALID_OPERATION")
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}, copy_state(tasks)

state = []
for title in [" ", "복습"]:
    result, state = apply(state, {"op": "add", "title": title})
    print(result)
print(len(state))

실행 결과

{'ok': False, 'error': 'EMPTY_TITLE'}
{'ok': True, 'task': {'id': 1, 'title': '복습', 'done': False}}
1

브라우저 실습과 미션에 연결합니다

starter에서 지정한 함수를 수정한 뒤 정상·경계·실패 테스트를 실행합니다. 실패 입력을 손으로 추적하고 기대값 대신 구현을 고칩니다. 미션 starter.zip의 reference.py를 완성하고 폴더에서 bash check.sh를 실행합니다. trace.md와 evidence.md에 호출 흐름 및 실제 검증 범위를 기록합니다.

확인 문제

실습

initial은 1부터 연속된 정상 항목 배열입니다. commands는 add, list, complete 배열입니다. apply만 수정하여 준비된 add_task, list_tasks, complete_task, copy_state를 연결합니다. 성공은 task 또는 tasks 결과와 새 상태, ValueError는 ok:false와 error 및 원본과 같은 복사 상태를 반환합니다. 문자열 제목은 strip 뒤 1~40자, 완료 id는 bool 제외 양의 정수입니다. 실패 후 생성도 번호를 소비하지 않고 재완료는 성공입니다. results, state, original 출력 모양을 유지합니다.

모범 답안
import json
import sys

def normalize_title(title):
    if not isinstance(title, str):
        raise ValueError("INVALID_TITLE_TYPE")
    clean = title.strip()
    if clean == "":
        raise ValueError("EMPTY_TITLE")
    if len(clean) > 40:
        raise ValueError("TITLE_TOO_LONG")
    return clean


def copy_state(tasks):
    return [dict(task) for task in tasks]


def add_task(tasks, title):
    clean = normalize_title(title)
    task = {"id": len(tasks) + 1, "title": clean, "done": False}
    return task, copy_state(tasks) + [dict(task)]


def list_tasks(tasks):
    return sorted(copy_state(tasks), key=lambda task: task["id"])


def complete_task(tasks, task_id):
    if type(task_id) is not int or task_id <= 0:
        raise ValueError("INVALID_ID")
    by_id = {task["id"]: dict(task) for task in tasks}
    if task_id not in by_id:
        raise ValueError("NOT_FOUND")
    by_id[task_id]["done"] = True
    task = dict(by_id[task_id])
    return task, [by_id[key] for key in sorted(by_id)]


def apply(tasks, command):
    try:
        op = command.get("op")
        if op == "add":
            task, state = add_task(tasks, command.get("title"))
            return {"ok": True, "task": task}, state
        if op == "list":
            return {"ok": True, "tasks": list_tasks(tasks)}, copy_state(tasks)
        if op == "complete":
            task, state = complete_task(tasks, command.get("id"))
            return {"ok": True, "task": task}, state
        raise ValueError("INVALID_OPERATION")
    except ValueError as exc:
        return {"ok": False, "error": str(exc)}, copy_state(tasks)

payload = json.load(sys.stdin)
original = payload["initial"]
state = copy_state(original)
results = []
for command in payload["commands"]:
    result, state = apply(state, command)
    results.append(result)
print(json.dumps({"results": results, "state": state, "original": original}, ensure_ascii=False))

더 읽기

면접 질문

  • AI가 만든 코드가 실행될 때 추가로 확인할 내용을 설명해 주시면 됩니다.