식별자와 자료구조
90분 안팎
학습 목표
딕셔너리에 할 일을 저장하고 중복 식별자와 없는 식별자를 구분합니다.
개념
제목과 식별자는 다른 역할입니다
개인 학습 목록에 “복습”을 두 번 넣을 수 있습니다. 제목을 딕셔너리 키로 사용하면 두 번째 항목이 첫 번째 항목을 덮어써 계약을 어깁니다. 항목의 정체성은 id로 구분하고 title은 표시할 내용으로 유지합니다. 이 레슨에서는 정수 id를 키로, 항목 딕셔너리를 값으로 가진 저장 구조를 읽습니다. 같은 제목과 같은 식별자는 다른 상황이라는 판단이 목표입니다.
리스트는 순서대로 항목을 담고 딕셔너리는 키로 값을 찾습니다. 앞 모듈의 initial과 expectedState는 리스트여서 전체 순서를 드러냅니다. 내부 검색에는 딕셔너리를 사용할 수 있습니다. {task["id"]: dict(task) for task in initial}은 각 항목의 id를 키로 쓰는 딕셔너리 컴프리헨션입니다. 입력은 고유한 양의 정수 id를 가진 정상 상태라고 전제합니다.
한 딕셔너리에서 같은 키에 다시 대입하면 기존 값이 바뀝니다. 그러므로 학습용 insert_task는 대입 전에 task_id in tasks로 존재를 확인합니다. 존재하면 DUPLICATE_ID를 결과로 반환하고 상태를 보존합니다. 프로젝트의 add는 번호를 자체 발급하므로 사용자가 id를 지정하지 않습니다. DUPLICATE_ID는 내부 저장 구조를 읽기 위한 이번 실습의 오류이며 생성 API에 새 입력 필드를 추가하지 않습니다.
없는 식별자를 찾는 것과 중복 식별자를 넣는 것은 원인이 다릅니다. 조회 대상이 없으면 NOT_FOUND이고 이미 존재하는 키로 삽입하면 DUPLICATE_ID입니다. 같은 title을 다른 id로 삽입하는 경우는 성공입니다. 값이 비슷해 보인다는 이유로 중복 제목을 거절하지 않습니다. 오류 코드를 읽을 때 어느 연산의 어떤 조건인지 함께 기록합니다.
키를 확인하고 복사한 구조를 수정합니다
find_task는 task_id가 딕셔너리에 있는지 확인한 뒤 항목을 반환합니다. tasks[task_id]는 실제 값을 꺼내는 표현이고 task_id in tasks는 키 존재 여부입니다. 딕셔너리를 for로 반복하면 기본적으로 키를 얻습니다. 값 전체를 보고 싶으면 values를, 키와 값을 같이 보고 싶으면 items를 사용합니다. 변수 이름만 task라고 지었다고 반복 대상이 항목으로 바뀌지는 않습니다.
존재 검사 없이 tasks[9]를 읽으면 9가 없을 때 KeyError가 발생합니다. 기대한 없음 결과를 주려면 존재 검사와 실패 반환을 먼저 둡니다. get은 없는 키에 기본값을 돌려주는 대안입니다. 이번 항목은 None이 될 수 없으므로 get 뒤 None 여부로 확인할 수도 있지만 계약과 검사 조건이 눈에 보이는 in 방식을 연습합니다. 없는 항목에 빈 딕셔너리를 대신 반환하면 성공처럼 오해할 수 있습니다.
insert_task가 성공하면 기존 딕셔너리의 모든 항목을 dict(value)로 복사하고 새 항목도 dict(task)로 복사해 저장합니다. tasks.copy만 하면 바깥 딕셔너리는 새 것이지만 안쪽 항목은 공유됩니다. 완료 기능이 내부 done을 수정할 때 원본 상태까지 바뀌는 위험이 있습니다. 현재 항목 필드는 정수, 문자열, boolean이므로 항목 딕셔너리의 얕은 복사로 필요한 분리가 됩니다.
중첩 구조에 리스트나 딕셔너리가 더 들어가면 dict(task)만으로 그 안쪽까지 분리되지 않습니다. 이 프로젝트의 상태 모양에 맞춘 복사와 모든 데이터에 통하는 복사를 혼동하지 않습니다. 필요하면 표준 라이브러리 deepcopy를 사용하지만 이번 모델은 각 항목을 새 딕셔너리로 만들어 어떤 필드가 분리되는지 명시적으로 보여 줍니다. 원본 보존 여부는 결과를 수정해 보는 테스트로도 확인합니다.
실패에서 tasks를 그대로 반환하는 이번 부분 함수는 실패 순간 원본을 바꾸지 않습니다. 그러나 그 반환 상태를 나중에 수정하면 원본과 공유되어 있습니다. 미션의 apply는 실패 때도 copy_state를 반환해서 반환 후 수정에 대한 독립성을 보완합니다. 실패 시 값이 같다는 조건과 객체 공유가 없다는 조건은 다르므로 두 가지를 따로 확인합니다.
저장 순서와 조회 순서를 구분합니다
딕셔너리는 삽입 순서를 유지하지만 프로젝트의 조회 계약은 id 오름차순입니다. 외부에서 2번을 먼저 넣고 1번을 나중에 넣은 내부 구조에서도 sorted(state)의 키 순서로 항목을 꺼내면 번호순으로 보여 줄 수 있습니다. 구현의 현재 우연한 순서에 기대지 않고 계약을 코드에서 드러내는 선택입니다. sorted는 새 정렬 결과를 만들며 원래 딕셔너리를 재배열하지 않습니다.
JSON 객체의 키는 문자열입니다. 내부에서 정수 키 딕셔너리를 만든 뒤 JSON으로 바로 출력하면 번호 키가 문자열로 바뀝니다. 따라서 실습 결과는 tasks 배열로 변환해 내보냅니다. 각 항목 안의 id는 정수로 유지됩니다. 내부 저장 방식과 외부 출력 모양이 같은 것이라고 생각하지 않고 변환하는 위치를 확인합니다. 미션도 외부 상태는 항목 배열을 사용합니다.
실습 입력은 initial 배열과 commands 배열입니다. commands의 insert에는 정상 task가, find에는 양의 정수 id가 들어옵니다. 잘못된 형식의 id 검증은 다음 레슨에서 연결합니다. insert와 find를 순서대로 수행하고 각 결과를 results에 쌓은 뒤 번호순 tasks를 출력합니다. 입력 commands에 없는 연산이나 깨진 초기 상태는 이번 연습의 지원 범위 밖입니다.
starter는 동일 id를 덮어쓰고 조회 때 빈 항목을 성공으로 반환합니다. 존재 검사 위치와 실패 반환을 수정합니다. 딕셔너리를 검색하는 한 줄만 고쳐서 종료하지 말고 중복 시 전체 목록이 같고 다른 번호의 같은 제목은 유지되는지 확인합니다. 빈 초기 목록과 빈 명령 배열도 정상 출력이 있어야 합니다. 경계 사례는 실제 항목이 있는 상황과 함께 비교합니다.
KeyError 메시지의 숫자는 찾지 못한 키입니다. 이를 단순히 try로 감춘 뒤 ok:true를 주면 없는 항목을 성공으로 바꿉니다. TypeError가 “not subscriptable”이라고 하면 task가 항목 딕셔너리가 아니라 정수 키인지 반복문을 살펴봅니다. 결과가 하나 줄었다면 중복 키 대입이나 제목을 키로 쓴 곳을 먼저 찾습니다. 예외 없이 실행되어도 항목 손실은 계약 실패입니다.
검토할 때는 저장 구조를 작은 표로 그립니다. 왼쪽 열에는 정수 키, 오른쪽 열에는 id·title·done을 적고 각 명령 뒤 바뀐 칸을 표시합니다. 없는 키 조회는 바뀐 칸이 없고 중복 삽입도 바뀐 칸이 없습니다. 다른 키 삽입은 새 행만 생깁니다. 이 표와 출력 tasks를 맞춰 보면 AI 코드가 제목 중복과 식별자 중복을 혼동했는지 스스로 설명할 수 있습니다.
따라하기
제목이 같아도 다른 번호를 보존합니다
키와 제목을 표로 구분합니다.
tasks = {1: {"id": 1, "title": "복습"}, 2: {"id": 2, "title": "복습"}}
print(len(tasks))
print(1 in tasks, 9 in tasks)실행 결과
2 True False
중복과 없는 번호를 구분합니다
대입하기 전 검사하는 줄을 찾고 실패 시 바뀐 칸이 없는지 확인합니다.
import json
import sys
def insert_task(tasks, task):
task_id = task["id"]
if task_id in tasks:
return {"ok": False, "error": "DUPLICATE_ID"}, tasks
copied = {key: dict(value) for key, value in tasks.items()}
copied[task_id] = dict(task)
return {"ok": True, "task": dict(task)}, copied
def find_task(tasks, task_id):
if task_id not in tasks:
return {"ok": False, "error": "NOT_FOUND"}
return {"ok": True, "task": dict(tasks[task_id])}
state = {1: {"id": 1, "title": "복습", "done": False}}
result, new_state = insert_task(state, {"id": 1, "title": "정리", "done": False})
print(result["error"], new_state == state)
print(find_task(state, 9)["error"])실행 결과
DUPLICATE_ID True NOT_FOUND
번호순 외부 배열을 만듭니다
입력 삽입 순서와 출력 계약 순서를 대조합니다.
import json
state = {2: {"id": 2, "title": "정리"}, 1: {"id": 1, "title": "복습"}}
print(json.dumps([state[key] for key in sorted(state)], ensure_ascii=False))실행 결과
[{"id": 1, "title": "복습"}, {"id": 2, "title": "정리"}]
브라우저 실습과 미션에 연결합니다
starter에서 지정한 함수를 수정한 뒤 정상·경계·실패 테스트를 실행합니다. 실패 입력을 손으로 추적하고 기대값 대신 구현을 고칩니다. 추적 표를 보관하고 다음 레슨의 함수 경계에서 사용합니다.
확인 문제
실습
initial은 고유 양의 정수 id를 가진 항목 배열입니다. commands에는 insert(정상 task) 또는 find(양의 정수 id)만 들어옵니다. insert_task는 중복 id이면 DUPLICATE_ID와 기존 상태, 성공이면 새 항목 결과와 복사한 새 상태를 반환합니다. find_task는 없는 번호면 NOT_FOUND, 존재하면 항목 복사본을 반환합니다. 같은 제목·다른 id는 허용합니다. 두 함수만 수정합니다. results와 id 오름차순 tasks를 출력합니다. 사용자 id 지정 삽입은 내부 구조 연습이며 프로젝트 add API는 번호를 자체 발급합니다.
모범 답안
import json
import sys
def insert_task(tasks, task):
task_id = task["id"]
if task_id in tasks:
return {"ok": False, "error": "DUPLICATE_ID"}, tasks
copied = {key: dict(value) for key, value in tasks.items()}
copied[task_id] = dict(task)
return {"ok": True, "task": dict(task)}, copied
def find_task(tasks, task_id):
if task_id not in tasks:
return {"ok": False, "error": "NOT_FOUND"}
return {"ok": True, "task": dict(tasks[task_id])}
payload = json.load(sys.stdin)
state = {task["id"]: dict(task) for task in payload["initial"]}
results = []
for command in payload["commands"]:
if command["op"] == "insert":
result, state = insert_task(state, command["task"])
else:
result = find_task(state, command["id"])
results.append(result)
print(json.dumps({"results": results, "tasks": [state[key] for key in sorted(state)]}, ensure_ascii=False))
더 읽기
면접 질문
- AI가 만든 코드가 실행될 때 추가로 확인할 내용을 설명해 주시면 됩니다.