F-1: 타입 힌트
목차 29
안녕하세요, 홍순구 튜터입니다. 지난 시간엔 콘솔에만 살던 데이터를 파일과 JSON으로 영속화했어요. 프로그램을 껐다 켜도 회원 정보가 남는 걸 처음 경험했죠. 그 마지막에 우리가 만든 함수 하나를 다시 떠올려 볼게요.
def save_members(members, path):
...
이 함수, members에는 뭘 넘기고 path에는 뭘 넘겨야 할까요? 이름만 봐선 "회원 목록 같고, 경로 같고…" 짐작만 할 뿐이에요. 누군가 실수로 members 자리에 숫자 하나를 넘겨도 파이썬은 군말 없이 받아버려요. 그러다 한참 뒤 엉뚱한 곳에서 프로그램이 터지죠.
오늘은 이 함수에, 그리고 우리 도메인 모델에 "이건 회원 목록이고, 이건 문자열 경로예요"라고 또박또박 적어주는 법을 배워요. 이걸 타입 힌트(type hint), 즉 "타입을 알려주는 힌트"라고 불러요. 적어두면 사람도 한눈에 알고, VS Code가 잘못된 값을 넘기는 순간 빨간 줄로 미리 잡아줘요. 모던 파이썬(카테고리 F)의 첫걸음이에요.
오늘의 여정 — 코드에 타입 라벨 붙이기
지난 시간 (E-2) 파일·JSON 으로 데이터를 영속화했다
│
▼
Step 1~2 변수와 함수에 타입 적기 name: str / def f(x: int) -> str
Step 3~4 런타임 비강제 + Pyright 틀린 타입을 넣어도 실행은 됨 / 에디터가 빨간 줄
Step 5~6 내장 제네릭 + X | None list[int] · dict[str, int] · str | None
Step 7~8 우리 도메인 모델에 타입 Member · save_members 에 라벨 달기
💡 오늘 수업의 핵심 — "타입 힌트는 코드에 붙이는 안전벨트, 실수는 Pyright가 미리 잡아준다"
🎯 학습 목표
- 변수와 함수에 타입 힌트(
name: str,def f(x: int) -> str)를 붙일 수 있어요. - 타입 힌트가 런타임에 강제되지 않는다는 점을 이해하고, Pyright(VS Code Pylance)가 실수를 미리 잡아주는 걸 경험해요.
- 내장 제네릭(
list[int])과X | None으로 우리 도메인 모델에 또박또박 타입을 입혀요.
Step 1: "이름표만으론 모자라요 — 왜 타입 힌트?"
지난 카테고리에서 우리는 변수에 이름을 붙였어요. name, followers처럼요. 그런데 이름은 "이 값이 무엇인지"는 알려줘도 "어떤 종류인지"는 안 알려줘요. followers가 숫자일까요, 글자일까요? 이름만 봐선 짐작이죠.
타입 힌트는 이름표 옆에 작은 메모를 붙이는 거예요. 변수 이름 뒤에 콜론(:)을 찍고 종류를 적어요.
# src/modern/f1_intro.py
# 1) 변수 어노테이션 — 이름표에 "이런 종류가 들어가요" 라고 적어둔다
name: str = "minji"
followers: int = 12000
is_influencer: bool = True
print(name, followers, is_influencer) # minji 12000 True
name: str은 "name에는 문자열(str)이 들어가요"라는 메모예요. 이렇게 변수에 타입을 적는 걸 어노테이션(annotation), 우리말로 "주석을 단다"는 뜻이에요. A-1에서 배운 int·str·bool이 그대로 쓰여요.
그럼 타입을 안 적으면 어떻게 될까요? 파이썬은 동적 타입(dynamic typing) 언어라, 무엇이든 받아줘요. 좋게 보면 자유롭고, 나쁘게 보면 실수를 안 막아줘요.
# 2) 동적 타입의 함정 — 타입을 안 적으면 무엇이든 들어간다
def greet(name):
return f"안녕하세요 @{name}"
print(greet("minji")) # 안녕하세요 @minji
print(greet(12000)) # 안녕하세요 @12000 ← 이름 자리에 숫자를 줘도 파이썬은 막지 않는다
greet는 이름을 받아야 하는데, 숫자 12000을 넘겨도 군말 없이 동작해요. "안녕하세요 @12000"이라는 이상한 인사가 나오죠. 작은 함수라 눈에 보이지만, 함수가 수십 개로 커지면 이런 실수는 한참 뒤에야 들통나요. 타입 힌트는 바로 이런 실수를 처음부터 막아주는 안전벨트예요.
💡 한 줄 정리
변수 뒤에 : 타입을 붙여 "이 이름표엔 이런 종류가 들어가요"라고 적어두면, 사람도 도구도 한눈에 알아봐요.
🙋 학생 질문 — "튜터님, 타입을 안 적어도 잘 돌아가는데 왜 굳이 적어요?"
좋은 질문이에요. 맞아요, 안 적어도 파이썬은 잘 돌아가요. 그런데 코드는 "한 번 쓰고 버리는 글"이 아니라 "여러 번 다시 읽는 글"이에요. 한 달 뒤의 나, 혹은 같이 일하는 동료가 save_members(members, path)를 봤을 때, 타입이 없으면 "members가 뭐였더라?" 하고 함수 속을 다시 뒤져야 해요. 타입 힌트가 있으면 그 한 줄만 보고 바로 알죠. 그리고 다음 Step에서 볼 텐데, 타입을 적어두면 VS Code가 잘못된 값을 넘기는 순간 빨간 줄로 잡아줘요. 안 적으면 그 도움도 못 받고요. "지금 조금 더 적어서 나중에 훨씬 덜 헤매는" 거래라고 생각하면 좋아요.
Step 2: "함수에 타입 적기 — def f(x: int) -> str"
변수에 타입을 적었으니, 이제 함수예요. 함수는 입구(매개변수)로 값을 받아 출구(반환값)로 값을 돌려줘요. 자판기를 떠올려 보세요. 입구엔 동전(어떤 종류)을 넣고, 출구로 음료(어떤 종류)가 나오죠. 그 두 곳에 라벨을 붙이는 거예요.
매개변수는 변수처럼 이름: 타입으로, 반환 타입은 화살표 -> 뒤에 적어요.
# src/modern/f1_func.py
def make_hashtag(word: str) -> str:
"""단어를 받아(str) 해시태그 문자열을(str) 돌려준다."""
return "#" + word
def add_likes(a: int, b: int) -> int:
return a + b
def is_popular(likes: int) -> bool:
return likes >= 1000
def make_hashtag(word: str) -> str 한 줄을 뜯어볼게요.
def make_hashtag(word: str) -> str:
│ │
입구: 받는 값 출구: 돌려주는 값
(str 글자) (str 글자)
word: str은 "이 함수는 문자열을 받아요", -> str은 "문자열을 돌려줘요"예요. add_likes는 정수 둘을 받아 정수를 돌려주니 (a: int, b: int) -> int, is_popular는 정수를 받아 참/거짓을 돌려주니 (likes: int) -> bool이죠. 함수 한 줄만 봐도 "뭘 넣고 뭐가 나오는지"가 다 보여요.
print(make_hashtag("여행")) # #여행
print(add_likes(1240, 8500)) # 9740
print(is_popular(1240)) # True
print(is_popular(320)) # False
C-1에서 만든 함수들 기억나시죠? 그땐 타입 없이 def make_hashtag(word):라고 썼어요. 오늘부터는 입구와 출구에 라벨을 달아 더 또렷한 함수를 만들어요.
💡 한 줄 정리
함수는 매개변수에 : 타입, 반환값에 -> 타입을 붙여요. 함수 한 줄만 봐도 입구와 출구의 종류가 한눈에 보여요.
🙋 학생 질문 — "튜터님, 반환값이 없는 함수는 -> 에 뭘 적어요?"
돌려주는 값이 없는 함수, 예를 들어 화면에 출력만 하고 끝나는 함수는 사실 None을 돌려줘요(C-1에서 return이 없으면 None이라고 배웠죠). 그래서 -> None이라고 적어요. 예를 들어 def print_card(member) -> None:처럼요. "이 함수는 일을 하지만 돌려주는 값은 없어요"라는 뜻이에요. Step 7에서 save_members에 -> None을 붙이는 걸 직접 보게 될 거예요.
Step 3: "타입 힌트는 안전벨트지 경찰이 아니에요 — 런타임 비강제"
여기서 아주 중요한 사실 하나. 타입 힌트를 적어도 파이썬은 실행할 때 그 타입을 검사하지 않아요. 틀린 타입을 넘겨도 코드가 그냥 돌아가요. 이걸 "런타임에 강제되지 않는다(런타임 비강제)"고 해요.
# src/modern/f1_runtime.py
def describe(count: int) -> str:
return f"게시물 {count}개"
# count 에 int 라고 적었지만, 문자열을 넘겨도 파이썬은 막지 않고 그냥 동작한다
print(describe(42)) # 게시물 42개
print(describe("많은")) # 게시물 많은개 ← 타입 힌트는 런타임에 강제되지 않는다!
describe는 count: int라고 분명히 적었어요. 그런데 문자열 "많은"을 넘겨도 에러 없이 "게시물 많은개"가 나와요. 파이썬이 타입 힌트를 보고 막아주는 게 아니에요.
그럼 타입 힌트는 어디로 갈까요? 실행에 영향을 주는 게 아니라, 함수에 메모처럼 붙어 있을 뿐이에요. __annotations__라는 곳에 적혀 있죠.
# 타입 힌트는 __annotations__ 에 메모처럼 보관될 뿐이다
print(describe.__annotations__) # {'count': <class 'int'>, 'return': <class 'str'>}
비유하자면 타입 힌트는 안전벨트예요. 안전벨트를 안 맨다고 차가 안 출발하진 않아요(경찰이 일일이 막진 않죠). 하지만 사고가 나면 안전벨트가 여러분을 지켜줘요. 타입 힌트도 똑같아요. 실행을 막진 않지만, 다음 Step에서 볼 도구가 사고를 미리 알려줘요.
⚠️ 그래서 타입 힌트는 "런타임 검증"이 아니에요. "값이 진짜 그 타입인지 실행 중에 검사하고 막는" 일은 이 과목 범위 밖의 다른 도구가 해요. 지금은 "타입 힌트는 실행을 막지 않는 메모"라는 것만 분명히 기억하면 돼요.
💡 한 줄 정리
타입 힌트는 런타임에 강제되지 않아요. 틀린 타입을 넘겨도 실행은 되고, 힌트는 __annotations__에 메모로만 남아요. 안전벨트지 경찰이 아니에요.
🙋 학생 질문 — "튜터님, 실행을 막지도 않는데 그럼 무슨 소용이에요?"
실행을 막진 않지만, 두 가지 큰 도움을 줘요. 첫째는 사람을 위한 문서예요. 함수 한 줄만 보고 "아, 정수를 받아 문자열을 돌려주는구나" 하고 바로 알죠. 둘째는 다음 Step에서 볼 Pyright 같은 도구를 위한 거예요. 이 도구가 타입 힌트를 읽고, 여러분이 코드를 실행하기도 전에 "여기 타입이 안 맞아요" 하고 빨간 줄로 알려줘요. 즉 타입 힌트는 "실행 시간"이 아니라 "코드를 쓰는 시간"에 여러분을 돕는 장치예요. 그래서 처음부터 습관처럼 적어두면 좋아요.
Step 4: "그럼 누가 잡아주나 — Pyright(Pylance) 빨간 줄"
파이썬이 막아주지 않는다면, 누가 타입 실수를 잡아줄까요? VS Code에 깔린 Pylance가 해줘요. Pylance 안에는 Pyright라는 타입 체커(type checker), 즉 "타입을 검사하는 도구"가 들어 있어요. 우리가 A-0에서 VS Code에 Python 확장을 깔 때 Pylance도 같이 깔렸어요. 그러니 추가 설치는 0이에요.
Pyright는 맞춤법 검사기와 비슷해요. 워드에서 오타를 치면 빨간 물결 줄이 그어지죠? Pyright도 타입이 안 맞는 곳에 빨간 물결 줄을 그어줘요. 코드를 실행하기도 전에요.
# src/modern/f1_pyright_demo.py
def make_hashtag(word: str) -> str:
return "#" + word
# 올바른 호출 — Pyright 가 아무 말 없다
tag = make_hashtag("여행")
print(tag) # #여행
make_hashtag("여행")은 문자열을 넘겼으니 Pyright가 아무 말 없어요. 그런데 만약 숫자를 넘기면?
# 이렇게 적으면 Pyright 가 즉시 빨간 줄로 알려준다
bad = make_hashtag(123)
make_hashtag(123)이라고 적는 순간 VS Code의 그 줄 아래에 빨간 물결이 생기고, 마우스를 올리면 이렇게 알려줘요.
Argument of type "Literal[123]" cannot be assigned to
parameter "word" of type "str" in function "make_hashtag"
"word는 str 자리인데 123(정수)을 넣었어요"라는 뜻이에요. 실행도 안 했는데 미리 잡아줬죠. Step 3에서 본 것처럼 실행 자체는 파이썬이 막지 않지만, Pyright가 그 전에 빨간 줄로 알려주는 거예요. 그래서 이런 틀린 줄은 코드에 남겨두지 않아요. Pyright의 빨간 줄을 보고 바로 고치면 되니까요.
🌟 Pyright는 설치가 0이라 입문자에게 가장 좋은 첫 타입 체커예요. VS Code에서 Python 파일을 열고 일부러 타입을 틀리게 적어보세요. 빨간 물결이 바로 뜨는 걸 직접 보면 "아, 이래서 타입을 적는구나" 하고 와닿을 거예요.
💡 한 줄 정리
VS Code의 Pylance(Pyright 내장)가 타입이 안 맞는 곳에 빨간 물결 줄을 그어줘요. 파이썬은 실행을 안 막지만, Pyright는 실행 전에 미리 잡아줘요. 설치는 0이고요.
🙋 학생 질문 — "튜터님, Pyright 말고 다른 타입 검사 도구도 있나요?"
네, 있어요. mypy라는 도구가 오래전부터 쓰였고, 요즘은 명령어로 mypy 파일.py처럼 돌려서 검사하기도 해요. 그 밖에도 새로운 도구들이 계속 나오고 있고요. 다만 입문 단계에선 Pyright(Pylance)가 가장 편해요. 따로 설치할 것도 없고, VS Code에서 코드를 쓰는 동안 실시간으로 빨간 줄을 그어주거든요. 다른 도구들은 보통 "다 쓴 다음 명령어로 한 번에 검사"하는 방식이라, 즉각적인 피드백 면에선 Pylance가 입문자에게 친절해요. 그래서 우리는 Pylance를 기본으로 쓸게요.
Step 5: "여러 개를 담을 땐 — 내장 제네릭 list[int]·dict[str, int]"
지금까지는 int·str처럼 값 하나의 타입을 적었어요. 그런데 리스트나 딕셔너리는 "그릇"이잖아요. 그릇이라고만 적으면 안에 뭐가 들었는지는 모르죠. 그래서 그릇 타입 뒤에 대괄호 []로 "안에 담길 종류"까지 적어요.
list list[int]
┌───────────┐ ┌─────────────────────┐
│ ? ? ? │ │ int int int │
└───────────┘ └─────────────────────┘
뭔가 담긴 리스트 정수가 담긴 리스트
# src/modern/f1_generics.py
likes: list[int] = [1240, 8500, 320]
hashtags: list[str] = ["여행", "사진", "제주"]
follower_count: dict[str, int] = {"minji": 12000, "jaehoon": 8500}
location: tuple[float, float] = (37.5, 127.0)
unique_tags: set[str] = {"여행", "사진", "여행"} # 중복은 자동으로 하나
하나씩 읽어볼게요. list[int]는 "정수가 담긴 리스트", list[str]는 "문자열이 담긴 리스트"예요. dict[str, int]는 "글자 열쇠에 숫자 값이 짝지어진 딕셔너리"예요(B-2의 {"minji": 12000} 모양이죠). tuple[float, float]는 "실수 두 개짜리 튜플"(위도·경도), set[str]는 "문자열 집합"이에요. 그릇 안까지 또박또박 적은 거죠.
이건 사실 D-4에서 이미 살짝 봤어요. Member의 interests: list[str] 기억나시죠? 그게 바로 "문자열이 담긴 리스트"라는 타입 힌트였어요. 오늘 그 정체를 제대로 배운 거예요.
함수에도 똑같이 써요.
def average_likes(likes: list[int]) -> float:
"""좋아요 수 목록(list[int])을 받아 평균(float)을 돌려준다."""
return sum(likes) / len(likes)
list[int]를 받아 float(나눗셈 결과라 실수)을 돌려준다고 한눈에 보이죠.
print(likes) # [1240, 8500, 320]
print(follower_count) # {'minji': 12000, 'jaehoon': 8500}
print(unique_tags) # {'여행', '사진'} — 순서는 매번 다를 수 있다 (set)
print(average_likes(likes)) # 3353.3333333333335
⚠️
set은 순서가 없어서 출력할 때마다{'여행', '사진'}의 순서가 달라질 수 있어요(B-3에서 봤죠). 종류와 개수만 같으면 정상이에요. 그리고list[int]같은 소문자 내장형 표기는 파이썬 3.9부터 쓸 수 있어요. 예전엔typing.List처럼 따로 불러와야 했는데, 지금은 그럴 필요가 없어요.
💡 한 줄 정리
리스트·딕셔너리 같은 그릇은 list[int]·dict[str, int]처럼 대괄호 안에 "담길 종류"까지 적어요. 그릇 안이 뭔지 한눈에 보여요.
🙋 학생 질문 — "튜터님, dict[str, int]에서 왜 두 개를 적어요?"
딕셔너리는 "열쇠(key)와 값(value)"이 짝을 이루는 그릇이잖아요(B-2에서 배웠죠). 그래서 종류도 두 개가 필요해요. dict[str, int]는 "열쇠는 문자열(str), 값은 정수(int)"라는 뜻이에요. 우리 예제 {"minji": 12000}을 보면 열쇠 "minji"는 글자, 값 12000은 숫자죠. 딱 dict[str, int]예요. 리스트는 담기는 게 한 종류라 list[int]처럼 하나만 적지만, 딕셔너리는 열쇠와 값 두 자리가 있어서 둘 다 적는 거예요.
Step 6: "있을 수도 없을 수도 — X | None"
B-2에서 딕셔너리의 get을 배웠어요. 열쇠가 있으면 값을, 없으면 None을 돌려줬죠. 이렇게 "있으면 어떤 값, 없으면 None"을 돌려주는 함수가 정말 많아요. 이럴 때 반환 타입을 어떻게 적을까요?
답은 세로 막대 |로 두 타입을 잇는 거예요. str | None은 "문자열이거나, 아니면 None"이라는 뜻이에요.
# src/modern/f1_optional.py
def find_bio(profiles: dict[str, str], username: str) -> str | None:
"""프로필 표에서 소개글을 찾는다. 없으면 None 을 돌려준다."""
return profiles.get(username)
profiles = {"minji": "여행을 사랑해요"}
print(find_bio(profiles, "minji")) # 여행을 사랑해요
print(find_bio(profiles, "unknown")) # None
find_bio의 반환 타입 str | None은 "소개글 문자열을 돌려주거나, 그 사람이 없으면 None을 돌려줘요"라고 솔직하게 밝힌 거예요. "minji"는 있으니 소개글이, "unknown"은 없으니 None이 나오죠.
이렇게 None일 수도 있는 값은, 쓰기 전에 "None이 아닌지" 확인하는 게 안전해요. C-1에서 배운 is None의 짝꿍, is not None을 써요.
# str | None 을 받았으면, 쓰기 전에 None 인지 확인하는 게 안전하다
bio = find_bio(profiles, "unknown")
if bio is not None:
print(bio.upper())
else:
print("소개글이 아직 없어요") # 이 줄이 출력된다
만약 이 확인 없이 곧장 bio.upper()를 호출하면, bio가 None일 때 에러가 나요(None엔 upper가 없으니까요). 그런데 반갑게도, 반환 타입을 str | None으로 적어두면 Pyright가 "여기 None일 수도 있는데 그냥 쓰네요?" 하고 빨간 줄로 미리 알려줘요. 타입 힌트와 Pyright가 손발을 맞추는 거죠.
🌟 예전엔 이런 "있을 수도 없을 수도"를
Optional[str]이라고 적었어요. 지금도 동작하지만, 파이썬 3.10부터는str | None이 더 짧고 읽기 쉬워서 이쪽을 권해요. "스트링 또는 논"이라고 그대로 읽히잖아요.
💡 한 줄 정리
값이 있을 수도 없을 수도 있으면 str | None처럼 |로 타입을 이어 적어요. 쓰기 전엔 is not None으로 확인하고, 빠뜨리면 Pyright가 잡아줘요.
🙋 학생 질문 — "튜터님, None을 안 돌려주고 그냥 빈 문자열을 돌려주면 안 되나요?"
그것도 한 방법이에요. 실제로 빈 문자열 ""을 "없음" 대신 쓰는 코드도 있어요. 다만 차이가 있어요. 빈 문자열은 "소개글이 있는데 그 내용이 비어 있다"는 뜻으로도 읽힐 수 있어요. 반면 None은 "그 사람 자체가 없다 / 소개글 자리가 아예 없다"를 분명히 구분해줘요. "값이 비어 있음"과 "값이 아예 없음"은 다른 상황이니까요. 그래서 "찾았는데 없을 수 있다"를 표현할 땐 None이 더 솔직해요. 그리고 str | None이라고 타입에 적어두면, 받는 쪽에서 "아, None이 올 수 있구나" 하고 미리 대비하게 돼요.
Step 7: "우리 도메인 모델에 타입 입히기"
이제 배운 걸 우리 인스타그램 도메인에 입혀볼게요. 먼저 Member예요. D-4에서 @dataclass로 만들 때 우리는 이미 필드에 타입을 적었어요. 오늘 보니 그게 다 타입 힌트였죠. 여기에 @property의 반환 타입까지 더 또박또박 적어줄게요.
# src/instagram/member.py
@dataclass
class Member:
username: str
followers: int = 0
bio: str = ""
interests: list[str] = field(default_factory=list)
@property
def handle(self) -> str:
return f"@{self.username}"
@property
def is_influencer(self) -> bool:
return self.followers >= 10000
username: str, followers: int, interests: list[str] — 필드마다 종류가 또렷하죠. 그리고 handle은 @를 붙인 문자열을 돌려주니 -> str, is_influencer는 참/거짓을 돌려주니 -> bool을 붙였어요. 계산된 속성도 "무슨 종류를 돌려주는지" 한눈에 보여요.
이제 오프닝에서 봤던 그 함수, save_members로 돌아가요. 지난 시간엔 타입이 없어 members와 path가 뭔지 몰랐죠. 라벨을 달아줄게요.
# src/instagram/storage.py
def save_members(members: list[Member], path: str | Path) -> None:
"""Member 목록을 JSON 파일로 저장한다."""
data = [asdict(m) for m in members]
text = json.dumps(data, ensure_ascii=False, indent=2)
Path(path).write_text(text, encoding="utf-8")
def load_members(path: str | Path) -> list[Member]:
"""JSON 파일을 읽어 Member 객체 목록으로 되살린다. 파일이 없으면 빈 목록."""
file = Path(path)
if not file.exists():
return []
data = json.loads(file.read_text(encoding="utf-8"))
return [Member(**d) for d in data]
이제 한 줄만 봐도 다 보여요. save_members(members: list[Member], path: str | Path) -> None — "회원 목록과, 문자열이나 Path 경로를 받아서, 돌려주는 값 없이 저장만 해요"라는 뜻이에요. path에 str | Path를 쓴 건, 경로를 "data/members.json"처럼 문자열로 줄 수도, E-2에서 배운 Path 객체로 줄 수도 있기 때문이에요(둘 다 받겠다는 거죠).
load_members는 -> list[Member]예요. "회원 목록을 돌려줘요"가 분명하죠. 이제 누군가 members 자리에 숫자를 넘기면, 실행하기도 전에 Pyright가 빨간 줄로 잡아줘요. 오프닝의 그 걱정이 사라진 거예요.
💡 한 줄 정리
도메인 모델의 필드·계산 속성·함수 시그니처에 타입을 입히면, 모델을 다루는 모든 곳에서 "뭐가 오가는지"가 또렷해지고 실수는 Pyright가 막아줘요.
🙋 학생 질문 — "튜터님, save_members의 -> None은 왜 적어요? 그냥 비워도 되잖아요?"
비워도 동작은 해요. 하지만 -> None을 적으면 "이 함수는 저장만 하고 돌려주는 값이 없어요"라는 의도가 분명해져요. 안 적으면 읽는 사람이 "혹시 뭔가 돌려주나? 저장 결과 같은 걸?" 하고 잠깐 헷갈릴 수 있어요. -> None은 "기대하지 마세요, 돌려줄 게 없어요"라는 친절한 안내예요. 그리고 만약 실수로 이 함수의 결과를 result = save_members(...)처럼 받아서 쓰려고 하면, Pyright가 "이건 None이에요, 쓸 게 없어요" 하고 알려주기도 해요. 작은 라벨 하나가 의도도 밝히고 실수도 막아주는 거죠.
Step 8: "종합 — 타입이 또박또박 붙은 피드 함수"
오늘 배운 걸 다 모아, Member 목록을 다루는 피드 함수 세 개를 타입을 입혀 만들어 볼게요. 변수 어노테이션, 함수 시그니처, 내장 제네릭, X | None — 전부 등장해요.
# src/modern/f1_capstone.py
from instagram.member import Member
def top_influencers(members: list[Member], min_followers: int = 10000) -> list[Member]:
"""팔로워가 기준 이상인 회원만 골라 돌려준다."""
return [m for m in members if m.followers >= min_followers]
def find_member(members: list[Member], username: str) -> Member | None:
"""username 으로 회원을 찾는다. 없으면 None."""
for m in members:
if m.username == username:
return m
return None
def follower_table(members: list[Member]) -> dict[str, int]:
"""{username: followers} 표를 만든다."""
return {m.username: m.followers for m in members}
세 함수의 시그니처만 읽어볼게요. top_influencers는 list[Member]를 받아 list[Member]를 돌려줘요(인플루언서만 골라낸 목록이죠). 기본값이 있는 min_followers: int = 10000은 C-1에서 배운 기본값 인자에 타입을 더한 거예요. find_member는 찾으면 Member를, 없으면 None을 돌려주니 -> Member | None이죠. follower_table은 회원 목록을 받아 dict[str, int](이름→팔로워 수 표)를 돌려줘요.
members = [
Member("minji", followers=12000),
Member("jaehoon", followers=8500),
Member("seungwoo", followers=320),
]
for m in top_influencers(members):
print(f" {m.handle} — 팔로워 {m.followers}")
# 출력: @minji — 팔로워 12000
print(follower_table(members))
# 출력: {'minji': 12000, 'jaehoon': 8500, 'seungwoo': 320}
top_influencers는 1만 이상인 minji만 골라냈고, follower_table은 이름과 팔로워 수를 딕셔너리로 묶었어요. 모든 함수에 타입이 붙어 있으니, 이 코드를 처음 보는 사람도 "뭐가 들어가고 뭐가 나오는지"를 한눈에 알아요. Pyright도 잘못된 호출을 다 잡아주고요.
🌟 같은 타입을 여러 곳에 반복해서 쓰면 이름을 붙여둘 수도 있어요(
type별칭이라고 해요). 지금은 "이런 것도 있구나" 정도만 알아두면 충분해요. 우리 단계에선list[Member]처럼 그대로 적는 게 가장 읽기 쉬워요.
💡 한 줄 정리
도메인 함수마다 입구·출구에 타입을 붙이면, 코드가 스스로를 설명하는 문서가 되고 Pyright가 실수를 지켜줘요. 이게 타입 힌트를 처음부터 습관으로 들이는 이유예요.
🙋 학생 질문 — "튜터님, 모든 함수에 타입을 다 붙여야 하나요? 귀찮은데요."
솔직히 말하면, 처음엔 조금 귀찮아요. 그런데 습관이 되면 오히려 더 빨라져요. 타입을 적으면서 "이 함수가 뭘 받아 뭘 돌려주지?"를 한 번 더 또렷이 생각하게 되거든요. 실무에선 보통 "함수의 입구와 출구(매개변수·반환값)"엔 타입을 붙이고, 함수 안에서 잠깐 쓰는 변수까지 일일이 다 붙이진 않아요(그건 Pyright가 알아서 짐작해줘요). 그러니 "공개되는 함수의 시그니처부터 챙긴다"고 생각하면 부담이 적어요. 그리고 한 번 붙여두면 나중에 그 함수를 쓸 때마다 Pyright가 도와주니, 들인 수고가 계속 돌아와요.
마무리
오늘은 코드에 타입 라벨을 붙이는 법을 배웠어요. 동적 타입이라 무엇이든 받아버리던 파이썬에, "이건 글자, 이건 숫자, 이건 회원 목록"이라고 또박또박 적어줬죠. 그리고 그 라벨을 Pyright가 읽고 실수를 미리 잡아주는 걸 봤어요.
오늘 배운 핵심 세 가지
- 💡 하나 — 변수엔
name: str, 함수엔def f(x: int) -> str처럼 타입 힌트를 붙여요. 이름표 옆에 "이런 종류가 들어가요"라는 메모를 다는 거예요. - 💡 둘 — 타입 힌트는 런타임에 강제되지 않아요(안전벨트지 경찰이 아니죠). 대신 VS Code의 Pylance(Pyright 내장)가 실행 전에 빨간 줄로 실수를 잡아줘요. 설치는 0이고요.
- 💡 셋 — 그릇엔
list[int]·dict[str, int]로 안까지 적고, 있을 수도 없을 수도 있으면str | None을 써요. 우리Member와save_members에 타입을 입혀 더 또렷한 도메인을 만들었어요.
다음 시간 예고
타입까지 또박또박 붙은 도메인이 생겼어요. 그런데 우리 피드가 게시물 백만 개라면 어떨까요? [m for m in members]처럼 전부 리스트에 담으면 메모리가 버거워요. 다음 시간(F-2)엔 값을 한 번에 다 만들지 않고 필요할 때 하나씩 꺼내 쓰는 제너레이터를 배워요. B-3에서 잠깐 본 () 제너레이터 표현식, 그 정체를 yield라는 새 도구로 제대로 파헤쳐요. 더불어 함수를 살짝 포장해 기능을 더하는 데코레이터(선물 포장 같은 거예요)도 만나고요. 모던 파이썬이 점점 깊어져요.
과제
오늘 배운 타입 힌트를 직접 손으로 붙여볼 차례예요. 막히면 교안의 해당 Step을 다시 펼쳐 보세요. VS Code에서 일부러 타입을 틀리게 적어 Pyright의 빨간 줄도 구경해보고요.
[기초] 프로필 카드 함수에 타입 붙이기
타입이 하나도 없는 아래 함수에 타입 힌트를 붙여 보세요.
def profile_card(username, followers):
return f"@{username} · 팔로워 {followers}명"
username은 글자,followers는 숫자, 돌려주는 값은 글자예요.- 매개변수에
: 타입, 반환값에-> 타입을 붙이면 돼요. - 다 붙인 뒤
profile_card("minji", 12000)을 호출하면@minji · 팔로워 12000명이 나오게 하세요.
힌트: 글자는 str, 숫자는 int예요. Step 2에서 make_hashtag에 타입을 붙인 모양을 떠올려 보세요.
[응용] 인플루언서 핸들 모으기 + 없을 때 None
Member 목록을 받아 인플루언서들의 핸들만 모으는 함수와, 이름으로 핸들 하나를 찾는 함수를 타입 힌트와 함께 만들어 보세요.
influencer_handles(members)—Member목록을 받아, 팔로워가 1만 이상인 회원들의handle(@이름)만list[str]로 돌려줘요.find_handle(members, username)— 이름이 맞는 회원의handle을 돌려주되, 그런 회원이 없으면None을 돌려줘요.- 두 함수의 매개변수와 반환값에 타입 힌트를 모두 붙이세요(
list[Member]·list[str]·str | None이 등장해요).
힌트: 거르기는 컴프리헨션([... for m in members if ...])으로, 찾기는 for로 돌다가 찾으면 return, 끝까지 못 찾으면 return None이에요. Step 6·8을 참고하세요.
[심화] 회원 저장소에 타입 입히고 잘못된 호출 찾기
타입이 없는 아래 두 함수에 타입 힌트를 붙이고, 그 아래 호출 세 개 중 Pyright가 빨간 줄을 그을 호출을 찾아 이유를 적어 보세요.
def count_influencers(members):
return len([m for m in members if m.is_influencer])
def average_followers(members):
return sum(m.followers for m in members) / len(members)
count_influencers는Member목록을 받아 인플루언서 수(정수)를,average_followers는 평균 팔로워 수(실수)를 돌려줘요.- 타입을 붙인 뒤, 아래 세 호출 중 Pyright가 잡아낼 것을 고르고 왜 그런지 설명하세요.
count_influencers(my_members)(단,my_members는list[Member])count_influencers(12000)average_followers(my_members)
힌트: 함수가 기대하는 매개변수 타입과, 실제로 넘긴 값의 타입을 견줘 보세요. Step 4에서 본 빨간 줄 메시지를 떠올리면 좋아요.
생각해볼 주제
1. 안 막아주는데 왜 적을까
타입 힌트는 런타임에 강제되지 않아요. 틀린 타입을 넘겨도 실행은 되죠. 그런데도 많은 개발자가 타입 힌트를 꼼꼼히 적어요. "실행을 막아주지도 않는데 왜 굳이 적는가"라는 질문에, 여러분이라면 어떻게 답하겠어요? 코드를 "쓰는 시간"과 "실행하는 시간"을 나눠서 생각해 보세요.
2. str | None이 알려주는 정직함
find_bio의 반환 타입을 str이라고만 적었다고 해봐요. 실제로는 None도 돌려주는데 말이죠. 이렇게 타입을 실제보다 좁게(혹은 틀리게) 적으면 어떤 일이 벌어질까요? 그걸 받아 쓰는 사람 입장에서 생각해 보세요. 반대로 str | None이라고 정직하게 적으면 받는 사람에게 어떤 도움이 될까요?
3. 동적 타입의 자유 vs 타입 힌트의 안전
파이썬은 타입을 안 적어도 되는 동적 타입 언어예요. 빠르게 코드를 끄적이기엔 정말 편하죠. 반면 타입 힌트는 적는 수고가 들어요. 작은 일회용 스크립트와, 여러 사람이 오래 함께 고치는 큰 프로그램 — 이 둘 중 어느 쪽에서 타입 힌트의 가치가 더 클까요? 그 이유를 "코드를 다시 읽는 횟수"와 연결해 생각해 보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안이에요. 먼저 스스로 끝까지 해본 뒤 펼쳐 보세요. 코드는 정답이 하나만 있는 게 아니니, 내 풀이와 견주며 "이렇게도 쓰는구나"를 챙기면 충분해요. 타입 힌트는 특히 VS Code에서 직접 빨간 줄을 보며 익히는 게 빨라요.
🎯 [과제 1 예시답안] 프로필 카드 함수에 타입 붙이기
채점 포인트
| 항목 | 확인 |
|---|---|
| 매개변수에 타입 | username: str, followers: int 둘 다 붙였는가 |
| 반환 타입 | -> str를 붙였는가 |
| 동작 보존 | 타입을 붙여도 출력은 그대로 @minji · 팔로워 12000명 |
| Pyright | 빨간 줄 없이 깨끗한가 |
풀이 예시
def profile_card(username: str, followers: int) -> str:
return f"@{username} · 팔로워 {followers}명"
print(profile_card("minji", 12000)) # @minji · 팔로워 12000명
실행 결과:
@minji · 팔로워 12000명
타입을 붙이기 전과 동작은 똑같아요(Step 3에서 배운 런타임 비강제죠). 달라진 건 "함수 한 줄만 봐도 글자 둘이 아니라 글자·숫자를 받는다"는 게 분명해졌다는 점이에요.
💡 튜터의 한마디 — 만약 호출을 profile_card(12000, "minji")처럼 순서를 바꿔 넘기면, 첫 인자 자리가 str인데 정수를 넣은 셈이라 Pyright가 빨간 줄을 그어요. 타입을 붙여두면 인자 순서 실수까지 잡아주는 거죠.
🎯 [과제 2 예시답안] 인플루언서 핸들 모으기 + 없을 때 None
채점 포인트
| 항목 | 확인 |
|---|---|
influencer_handles 시그니처 |
(members: list[Member]) -> list[str] |
| 거르기 | 컴프리헨션으로 followers >= 10000만 골라 handle 수집 |
find_handle 시그니처 |
(members: list[Member], username: str) -> str | None |
| 못 찾을 때 | 끝까지 못 찾으면 return None |
| 동작 | 인플루언서만 ['@minji'], 없는 이름은 None |
풀이 예시
from instagram.member import Member
def influencer_handles(members: list[Member]) -> list[str]:
return [m.handle for m in members if m.followers >= 10000]
def find_handle(members: list[Member], username: str) -> str | None:
for m in members:
if m.username == username:
return m.handle
return None
실행 결과:
members = [
Member("minji", followers=12000),
Member("jaehoon", followers=8500),
Member("seungwoo", followers=320),
]
print(influencer_handles(members)) # ['@minji']
print(find_handle(members, "jaehoon")) # @jaehoon
print(find_handle(members, "nobody")) # None
influencer_handles는 팔로워 1만 이상인 minji의 핸들만 골라 list[str]로 돌려줘요. find_handle은 찾으면 핸들 문자열을, "nobody"처럼 없으면 None을 돌려주니 반환 타입이 str | None이죠.
💡 튜터의 한마디 — find_handle의 결과를 받아 곧장 .upper()처럼 쓰려고 하면, 그 값이 None일 수도 있어서 Pyright가 "여기 None 가능성이 있어요" 하고 알려줘요(Step 6에서 봤죠). 받는 쪽에서 is not None으로 한 번 확인하고 쓰는 습관을 들이면 좋아요.
🎯 [과제 3 예시답안] 회원 저장소에 타입 입히고 잘못된 호출 찾기
채점 포인트
| 항목 | 확인 |
|---|---|
count_influencers 시그니처 |
(members: list[Member]) -> int |
average_followers 시그니처 |
(members: list[Member]) -> float |
| 반환 타입 구분 | 개수는 int, 평균(나눗셈)은 float |
| 잘못된 호출 식별 | count_influencers(12000)을 골랐는가 |
| 이유 설명 | "정수를 list[Member] 자리에 넘겨서"를 짚었는가 |
풀이 예시
from instagram.member import Member
def count_influencers(members: list[Member]) -> int:
return len([m for m in members if m.is_influencer])
def average_followers(members: list[Member]) -> float:
return sum(m.followers for m in members) / len(members)
세 호출 중 Pyright가 빨간 줄을 긋는 건 두 번째예요.
| 호출 | Pyright 판정 | 이유 |
|---|---|---|
count_influencers(my_members) |
✅ 통과 | my_members가 list[Member]라 매개변수와 맞음 |
count_influencers(12000) |
❌ 빨간 줄 | 정수를 list[Member] 자리에 넘김 |
average_followers(my_members) |
✅ 통과 | list[Member]를 제대로 넘김 |
count_influencers(12000)에 마우스를 올리면 이렇게 알려줘요.
Argument of type "Literal[12000]" cannot be assigned to
parameter "members" of type "list[Member]" in function "count_influencers"
"members는 list[Member] 자리인데 정수 12000을 넣었어요"라는 뜻이에요. Step 4의 그 빨간 줄 메시지와 같은 모양이죠.
💡 튜터의 한마디 — 개수는 int, 평균은 float로 반환 타입을 구분한 게 핵심이에요. 나눗셈 /은 결과가 항상 실수라(6940.0처럼) -> float가 맞아요. 반환 타입까지 정확히 적어두면, 이 함수의 결과를 받아 쓰는 쪽에서도 "아, 실수가 오는구나" 하고 미리 알 수 있어요.
🤔 [생각해볼 주제 1] 안 막아주는데 왜 적을까
문제 상황 요약
타입 힌트는 런타임에 강제되지 않아요. 틀린 타입을 넘겨도 실행은 되죠. 그런데도 많은 개발자가 타입 힌트를 꼼꼼히 적어요. "실행을 막아주지도 않는데 왜 적는가"를 생각해 보는 주제예요.
튜터의 가이드 및 해설
핵심은 "코드를 쓰는 시간"과 "실행하는 시간"을 나눠 보는 거예요. 타입 힌트가 일하는 무대는 실행 시간이 아니라 코드를 쓰고 읽는 시간이에요.
첫째, 사람을 위한 문서예요. save_members(members, path)만 보면 뭘 넘길지 막막하지만, save_members(members: list[Member], path: str | Path)는 한 줄로 다 말해줘요. 코드는 한 번 쓰고 버리는 게 아니라 여러 번 다시 읽혀요. 한 달 뒤의 나, 동료, 인수인계받는 사람 모두 그 한 줄 덕에 함수 속을 안 뒤져도 되죠.
둘째, 도구를 위한 신호예요. Pyright는 타입 힌트를 읽고 실행 전에 실수를 잡아줘요. 타입을 안 적으면 이 도움을 못 받아요. 즉 타입 힌트는 "실행을 막는 장치"가 아니라 "실행하기 전에 실수를 줄이는 장치"예요. 안전벨트가 사고를 막는 게 아니라 사고의 피해를 줄이듯, 타입 힌트도 버그가 실행까지 가기 전에 걸러내요.
💡 핵심을 한마디로
"타입 힌트는 실행 시간이 아니라 코드를 쓰고 읽는 시간에 일해요. 사람에겐 문서가 되고, Pyright에겐 실수를 미리 잡을 단서가 되죠. 안 막아주니까 의미 없는 게 아니라, 사고가 나기 전에 돕는 거예요."
🤔 [생각해볼 주제 2] str | None이 알려주는 정직함
문제 상황 요약
find_bio는 실제로 None도 돌려주는데, 반환 타입을 str이라고만 적으면 어떻게 될까요? 타입을 실제보다 좁게 적었을 때 받는 쪽에서 벌어지는 일을 생각해 보는 주제예요.
튜터의 가이드 및 해설
타입을 str이라고만 적으면, 그 함수를 쓰는 사람은 "여기선 항상 문자열이 오는구나" 하고 믿어요. 그래서 받자마자 bio.upper()처럼 문자열인 양 써요. 그런데 어쩌다 None이 돌아오는 순간, None엔 upper가 없으니 실행 중에 에러가 나죠. 더 나쁜 건, Pyright조차 못 잡아준다는 거예요. 타입이 str이라고 적혀 있으니 Pyright도 "문자열이네, 문제없어" 하고 넘어가거든요. 거짓 라벨이 도구의 눈까지 가린 셈이에요.
반대로 str | None이라고 정직하게 적으면, 받는 사람이 "아, None이 올 수 있구나" 하고 미리 대비해요. 그냥 bio.upper()를 쓰려고 하면 Pyright가 "None일 수 있는데요?" 하고 빨간 줄로 막아주고요. 타입을 정직하게 적는 건 "이 함수의 진짜 약속"을 밝히는 거예요. 좁게 적은 타입은 당장은 편해 보여도, 결국 받는 사람을 함정에 빠뜨려요.
💡 핵심을 한마디로
"타입은 함수가 받는 사람에게 하는 약속이에요.
None이 올 수 있는데str이라고만 적으면 거짓 약속이고, 그 거짓은 Pyright의 눈까지 가려요.str | None은 정직한 약속이라, 받는 쪽이 대비하고 도구도 지켜줘요."
🤔 [생각해볼 주제 3] 동적 타입의 자유 vs 타입 힌트의 안전
문제 상황 요약
파이썬은 타입을 안 적어도 되는 동적 타입 언어라 빠르게 끄적이기 편해요. 반면 타입 힌트는 적는 수고가 들죠. 작은 일회용 스크립트와 여러 사람이 오래 고치는 큰 프로그램 중 어디서 타입 힌트의 가치가 더 큰지 생각해 보는 주제예요.
튜터의 가이드 및 해설
판단의 기준은 "코드를 다시 읽는 횟수"예요. 타입 힌트의 비용은 한 번(적는 수고), 이득은 여러 번(읽을 때마다, 도구가 검사할 때마다)이에요. 그러니 다시 읽힐 일이 많을수록 이득이 비용을 크게 넘어서요.
한 번 돌리고 버릴 열 줄짜리 스크립트라면, 타입을 안 적어도 머릿속에 다 들어와요. 동적 타입의 자유를 그대로 누리는 게 빠르죠. 반면 여러 사람이 몇 달, 몇 년을 함께 고치는 큰 프로그램은 같은 함수가 수십 번 읽히고 호출돼요. 이럴 땐 타입 힌트가 매번 "뭘 넣고 뭐가 나오는지"를 알려주고, 누군가의 실수를 Pyright가 매번 잡아줘요. 적는 수고는 한 번이지만 그 도움은 코드가 사는 내내 이어지죠.
그래서 정답은 "둘 중 하나만"이 아니라 "상황에 맞게"예요. 다만 입문 단계에선 처음부터 타입을 적는 습관을 들이길 권해요. 작게 시작한 코드가 어느새 커지는 일이 흔하고, 습관은 나중에 갑자기 생기지 않거든요.
💡 핵심을 한마디로
"타입 힌트는 비용이 한 번, 이득이 여러 번이에요. 그래서 코드를 다시 읽는 횟수가 많을수록 가치가 커져요. 일회용 스크립트는 동적 타입의 자유를, 오래 함께 고치는 코드는 타입 힌트의 안전을 택하면 돼요."