Python 기본 문법: 변수·연산자·조건문·반복문과 CPython이 코드를 실행하는 방식
이 글의 핵심
Python 문법은 간단해 보이지만 빈 리스트가 False로 평가되거나 루프에 else가 붙는 등 다른 언어와 다른 규칙이 있습니다. 그런 차이를 예제로 짚은 뒤 dis 모듈로 바이트코드를 들여다보며 문법이 실제 실행으로 어떻게 이어지는지 연결하고, 들여쓰기 에러처럼 입문자가 자주 하는 실수도 정리합니다.
들어가며: Python 문법의 특징
Python은 가독성을 최우선으로 설계되었습니다. 들여쓰기로 블록을 구분하며, 간결한 문법으로 빠르게 배울 수 있습니다.
이 글에서 다루는 것:
- 변수와 타입
- 연산자 (산술, 비교, 논리)
- 조건문 (if/elif/else)
- 반복문 (for/while)
- 들여쓰기 규칙
변수와 타입
변수 선언: 타입 없이 바로 사용
Python은 동적 타입 언어(실행할 때 값에 따라 타입이 정해지는 방식)입니다. 변수 선언 시 타입을 명시하지 않아도 됩니다. 변수 이름은 값을 담아 두는 서랍 라벨이라고 생각하시면 됩니다. 같은 라벨에 처음에는 숫자를 넣었다가 나중에 문자열을 넣을 수 있지만, 읽는 사람을 위해 한 변수에는 한 종류의 의미를 유지하는 편이 좋습니다.
# 변수 선언 (타입 선언 없음)
name = "홍길동" # 문자열
age = 25 # 정수
height = 175.5 # 실수
is_student = True # 불린
print(name, age, height, is_student)
# 출력: 홍길동 25 175.5 True
# 변수 타입은 값에 따라 자동 결정
x = 10 # int
x = "Hello" # 이제 str (타입 변경 가능)
x = [1, 2, 3] # 이제 list
“타입이 바뀐다”는 표현은 정확히 말하면 변수가 아니라 이름이 다른 객체를 가리키게 되는 것입니다. Python에서 타입은 객체가 가지고 있고, 변수는 객체에 붙인 이름표일 뿐입니다. x = 10 다음 x = "Hello"를 실행하면 정수 객체 10은 그대로 있고(다른 이름이 없으면 나중에 회수됨) x라는 이름만 문자열 객체로 옮겨 갑니다. C++에서 int x가 메모리 4바이트짜리 칸을 뜻하는 것과 근본적으로 다른 모델이며, 이 차이가 뒤에서 나오는 is와 ==의 차이, 가변 객체를 공유하는 버그의 출발점이 됩니다.
다른 언어와 비교:
# Python: 타입 선언 없음
age = 25
# Java: 타입 선언 필수
# int age = 25;
# C++: 타입 선언 필수 (또는 auto)
# int age = 25;
# auto age = 25;
# TypeScript: 타입 선언 선택
# let age: number = 25;
# let age = 25; // 타입 추론
타입 확인과 변환
# 타입 확인
print(type(name)) # <class 'str'>
print(type(age)) # <class 'int'>
print(type(height)) # <class 'float'>
print(type(is_student)) # <class 'bool'>
# isinstance로 타입 체크
print(isinstance(age, int)) # True
print(isinstance(name, str)) # True
print(isinstance(height, int)) # False
# 타입 변환
x = "123"
print(type(x)) # <class 'str'>
y = int(x) # 문자열 → 정수
print(type(y)) # <class 'int'>
print(y) # 123
z = float(x) # 문자열 → 실수
print(z) # 123.0
# 변환 실패 시 에러
# int("abc") # ValueError: invalid literal for int()
# 안전한 변환
def safe_int(value, default=0):
try:
return int(value)
except ValueError:
return default
print(safe_int("123")) # 123
print(safe_int("abc")) # 0 (기본값)
여러 변수 동시 할당 (언패킹)
# 동시 할당 (튜플 언패킹)
x, y, z = 1, 2, 3
print(x, y, z) # 1 2 3
# 같은 값 할당
a = b = c = 0
print(a, b, c) # 0 0 0
# 값 교환 (Python의 강력한 기능!)
x, y = 10, 20
print(f"교환 전: x={x}, y={y}") # x=10, y=20
x, y = y, x # 한 줄로 교환!
print(f"교환 후: x={x}, y={y}") # x=20, y=10
# 다른 언어에서는 임시 변수 필요:
# temp = x
# x = y
# y = temp
# 리스트 언패킹
numbers = [1, 2, 3]
a, b, c = numbers
print(a, b, c) # 1 2 3
# 일부만 받기
first, *rest = [1, 2, 3, 4, 5]
print(first) # 1
print(rest) # [2, 3, 4, 5]
*beginning, last = [1, 2, 3, 4, 5]
print(beginning) # [1, 2, 3, 4]
print(last) # 5
x, y = y, x가 임시 변수 없이 동작하는 이유는 오른쪽 y, x가 먼저 전부 평가되어 새 튜플(구현상으로는 스택 위의 두 값)이 만들어진 다음, 왼쪽 이름들에 차례로 대입되기 때문입니다. 언패킹은 개수가 맞지 않으면 바로 실패합니다. a, b = [1, 2, 3]은 ValueError: too many values to unpack (expected 2), 반대로 값이 모자라면 not enough values to unpack이 납니다. 함수가 돌려주는 튜플의 길이가 바뀌었을 때 호출부에서 이 에러가 나는 경우가 많으므로, 개수가 가변적이면 *rest로 받는 편이 안전합니다.
a = b = c = 0 같은 연쇄 대입은 정수처럼 불변 객체에는 문제가 없지만, a = b = []처럼 가변 객체에 쓰면 세 이름이 같은 리스트 하나를 공유합니다. a.append(1) 후 b를 출력하면 [1]이 나와서 처음 보면 당황스럽습니다. 입문자가 가장 오래 헤매는 버그 중 하나이며, 가변 객체는 a, b = [], []처럼 따로 만들어야 합니다.
변수 명명 규칙 (PEP 8)
# ✅ 좋은 예 (snake_case)
user_name = "홍길동"
total_count = 100
MAX_SIZE = 1000 # 상수는 대문자 + 언더스코어
_private_var = 42 # 비공개 변수 (관례)
# ❌ 나쁜 예
userName = "홍길동" # camelCase (Python에서는 비권장)
TotalCount = 100 # PascalCase (클래스명에만 사용)
2nd_value = 10 # 숫자로 시작 (문법 에러)
my-var = 10 # 하이픈 사용 (문법 에러)
# 예약어는 변수명으로 사용 불가
# if = 10 # SyntaxError
# for = 20 # SyntaxError
# class = 30 # SyntaxError
# 예약어 확인
import keyword
print(keyword.kwlist)
# ['False', 'None', 'True', 'and', 'as', 'assert', ...]
타입 힌트 (Type Hints) - 선택사항
# 타입 힌트 (Python 3.5+)
name: str = "홍길동"
age: int = 25
height: float = 175.5
is_student: bool = True
# 함수 타입 힌트
def greet(name: str) -> str:
return f"안녕하세요, {name}님!"
result = greet("홍길동")
print(result) # 안녕하세요, 홍길동님!
# 주의: 타입 힌트는 강제가 아님 (런타임 체크 안 함)
result = greet(123) # 런타임 에러 없음 (mypy 같은 정적 검사 도구만 경고)
print(result) # 안녕하세요, 123님!
# mypy로 정적 타입 체크 가능
# pip install mypy
# mypy script.py
연산자
산술 연산자
a, b = 10, 3
print(a + b) # 13 (덧셈)
print(a - b) # 7 (뺄셈)
print(a * b) # 30 (곱셈)
print(a / b) # 3.3333333333333335 (나눗셈, 항상 float 반환)
print(a // b) # 3 (몫, floor division)
print(a % b) # 1 (나머지, modulo)
print(a ** b) # 1000 (거듭제곱, 10^3)
# 음수 나눗셈
print(-10 // 3) # -4 (내림)
print(-10 % 3) # 2
# 복합 할당 연산자
x = 10
x += 5 # x = x + 5
print(x) # 15
x -= 3 # x = x - 3
print(x) # 12
x *= 2 # x = x * 2
print(x) # 24
x //= 5 # x = x // 5
print(x) # 4
x **= 2 # x = x ** 2
print(x) # 16
Python vs 다른 언어:
# Python: / 는 항상 float
print(10 / 3) # 3.3333... (float)
print(10 // 3) # 3 (int)
# C++/Java: / 는 정수 나눗셈 (피연산자가 정수면)
# 10 / 3 → 3 (int)
# 10.0 / 3 → 3.333... (double)
# Python: ** 거듭제곱
print(2 ** 10) # 1024
# C++/Java: pow() 함수 사용
# pow(2, 10) → 1024
음수 나눗셈 결과(-10 // 3 == -4, -10 % 3 == 2)는 C++·Java 경험자가 가장 자주 걸리는 부분입니다. C++에서 -10 / 3은 0 방향으로 잘라 -3, -10 % 3은 -1입니다. Python은 //가 음의 무한대 방향으로 내림(floor)하고, (a // b) * b + a % b == a라는 관계를 지키도록 %의 결과가 항상 나누는 수와 같은 부호를 갖습니다. 덕분에 (i - 1) % n처럼 인덱스를 거꾸로 순환할 때 음수가 나오지 않아 편리하지만, C++ 알고리즘을 옮길 때는 결과가 달라질 수 있습니다. 0 방향 자르기가 필요하면 int(a / b)(부동소수점 오차 주의)나 math.trunc를 씁니다.
/가 항상 float를 돌려준다는 점도 조심해야 합니다. float는 64비트 부동소수점이라 큰 정수를 나누면 정밀도를 잃습니다. 10**20 / 3은 근삿값이 되고, 정수 연산 결과가 필요한 인덱스 계산이나 금액 계산에는 //나 decimal.Decimal을 써야 합니다. 반대로 Python 정수(int)는 크기 제한이 없어서 2 ** 100 같은 계산도 오버플로 없이 정확하게 됩니다.
비교 연산자
x, y = 5, 10
print(x == y) # False (같음)
print(x != y) # True (다름)
print(x < y) # True (작음)
print(x > y) # False (큼)
print(x <= y) # True (작거나 같음)
print(x >= y) # False (크거나 같음)
# 체이닝 비교 (Python의 독특한 기능)
x = 5
print(1 < x < 10) # True (1 < 5 < 10)
print(x == 5 == 5) # True
# 다른 언어에서는:
# (1 < x) && (x < 10) # Java/C++
논리 연산자
a, b = True, False
print(a and b) # False (AND)
print(a or b) # True (OR)
print(not a) # False (NOT)
# 실전 예제
age = 25
is_student = True
if age >= 18 and is_student:
print("성인 학생입니다")
# 단축 평가 (Short-circuit Evaluation)
x = 0
y = 10
# and: 첫 번째가 False면 두 번째 평가 안 함
result = (x != 0) and (y / x > 5) # x != 0이 False → y / x 평가 안 함 (에러 방지)
print(result) # False
# or: 첫 번째가 True면 두 번째 평가 안 함
result = (x == 0) or (y / x > 5) # x == 0이 True → y / x 평가 안 함
print(result) # True
# 기본값 설정에 활용
name = ""
display_name = name or "익명" # name이 빈 문자열(False)이면 "익명"
print(display_name) # 익명
Python의 and/or는 True/False가 아니라 피연산자 중 하나를 그대로 돌려준다는 점이 다른 언어와 다릅니다. name or "익명"은 name이 truthy면 name 자체를, 아니면 "익명"을 돌려줍니다. 이 관용구에는 함정이 있습니다. count = user_count or 10처럼 쓰면 사용자가 일부러 0을 넣었을 때도 0이 falsy라서 10으로 바뀝니다. 0, 빈 문자열, 빈 리스트가 정상적인 값일 수 있는 경우에는 count = 10 if user_count is None else user_count처럼 None만 명시적으로 검사해야 합니다.
멤버십 연산자 (in, not in)
# 리스트에서 검색
fruits = ["apple", "banana", "cherry"]
print("apple" in fruits) # True
print("grape" not in fruits) # True
# 문자열에서 검색
text = "Hello, Python!"
print("Python" in text) # True
print("Java" not in text) # True
# 딕셔너리에서 키 검색
user = {"name": "홍길동", "age": 25}
print("name" in user) # True (키 검색)
print("홍길동" in user) # False (값은 검색 안 됨)
print("홍길동" in user.values()) # True (값 검색)
# 범위 검색
print(5 in range(10)) # True (0~9)
print(15 in range(10)) # False
# 성능: in 연산자는 자료구조에 따라 다름
# list: O(n)
# set: O(1)
# dict: O(1) (키 검색)
비트 연산자
a, b = 5, 3 # 0b101, 0b011
print(a & b) # 1 (AND, 0b001)
print(a | b) # 7 (OR, 0b111)
print(a ^ b) # 6 (XOR, 0b110)
print(~a) # -6 (NOT, 2의 보수)
print(a << 1) # 10 (왼쪽 시프트, 0b1010)
print(a >> 1) # 2 (오른쪽 시프트, 0b010)
# 실전 활용: 플래그 관리
READ = 1 # 0b001
WRITE = 2 # 0b010
EXECUTE = 4 # 0b100
permission = READ | WRITE # 0b011 (읽기 + 쓰기)
print(permission & READ) # 1 (읽기 권한 있음)
print(permission & EXECUTE) # 0 (실행 권한 없음)
조건문
if 문
age = 20
if age >= 18:
print("성인입니다")
# 성인입니다
if-else 문
age = 15
if age >= 18:
print("성인입니다")
else:
print("미성년자입니다")
# 미성년자입니다
if-elif-else 문
score = 85
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
else:
grade = "F"
print(f"학점: {grade}") # 학점: B
중첩 if 문
age = 25
has_license = True
if age >= 18:
if has_license:
print("운전 가능")
else:
print("면허 필요")
else:
print("나이 미달")
삼항 연산자 (Ternary Operator)
Python의 조건 표현식으로 한 줄로 if-else를 작성할 수 있습니다.
age = 20
status = "성인" if age >= 18 else "미성년자"
print(status) # 성인
# 일반 if문과 비교
if age >= 18:
status = "성인"
else:
status = "미성년자"
# 중첩 삼항 연산자
score = 85
grade = "A" if score >= 90 else "B" if score >= 80 else "C" if score >= 70 else "F"
print(grade) # B
# 가독성을 위해 괄호 사용
grade = (
"A" if score >= 90 else
"B" if score >= 80 else
"C" if score >= 70 else
"F"
)
# 실전 활용
# 리스트에서 짝수만 2배
numbers = [1, 2, 3, 4, 5]
doubled = [x * 2 if x % 2 == 0 else x for x in numbers]
print(doubled) # [1, 4, 3, 8, 5]
Truthy와 Falsy 값
Python에서 조건문은 불린이 아닌 값도 평가할 수 있습니다.
# Falsy 값 (False로 평가)
# - False, None, 0, 0.0, "", [], {}, ()
# Truthy 값 (True로 평가)
# - 위를 제외한 모든 값
# 예시
if "":
print("실행 안 됨") # 빈 문자열은 False
if "Hello":
print("실행됨") # 비어있지 않은 문자열은 True
if []:
print("실행 안 됨") # 빈 리스트는 False
if [1, 2, 3]:
print("실행됨") # 비어있지 않은 리스트는 True
# 실전 활용: 빈 값 체크
data = []
if not data:
print("데이터가 비어있습니다")
# None 체크
value = None
if value is None:
print("값이 None입니다")
# is vs ==
# is: 객체 동일성 (같은 메모리 주소)
# ==: 값 동일성
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True (값이 같음)
print(a is b) # False (다른 객체)
is는 None, True/False 같은 싱글턴과 비교할 때만 쓰는 것이 원칙입니다. 숫자나 문자열에 is를 쓰면 CPython의 구현 세부 사항에 따라 결과가 달라집니다. CPython은 -5부터 256까지의 작은 정수 객체를 미리 만들어 재사용하므로 x = 256; y = 256; x is y는 True지만, 257부터는 상황(대화형 셸인지, 같은 코드 객체 안의 상수인지)에 따라 False가 나올 수 있습니다. 이런 코드는 테스트에서는 통과하다가 값이 커지면 조용히 틀리므로, Python 3.8부터는 리터럴과 is로 비교하면 SyntaxWarning: "is" with a literal을 띄워 줍니다.
if not data:처럼 빈 컬렉션을 falsy로 검사하는 것은 Python다운 관용구지만, data가 None일 수도 있고 빈 리스트일 수도 있는 경우에는 두 상황이 구분되지 않는다는 점을 알고 써야 합니다. “아직 불러오지 않음(None)“과 “불러왔는데 결과가 없음([])“을 다르게 처리해야 한다면 if data is None:을 먼저 검사합니다.
반복문
for 문: 반복 가능한 객체 순회
Python의 for 문은 반복 가능한 객체(iterable)를 순회합니다.
# 리스트 순회
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(fruit)
# 출력:
# apple
# banana
# cherry
# 인덱스와 값 동시 접근
for i, fruit in enumerate(fruits):
print(f"{i}: {fruit}")
# 0: apple
# 1: banana
# 2: cherry
# enumerate 시작 인덱스 변경
for i, fruit in enumerate(fruits, start=1):
print(f"{i}. {fruit}")
# 1. apple
# 2. banana
# 3. cherry
# range() 사용 (숫자 범위)
for i in range(5):
print(i, end=" ")
# 0 1 2 3 4
print()
# range(시작, 끝, 간격)
for i in range(1, 11, 2):
print(i, end=" ")
# 1 3 5 7 9
print()
# 역순
for i in range(10, 0, -1):
print(i, end=" ")
# 10 9 8 7 6 5 4 3 2 1
print()
# 문자열 순회
for char in "Python":
print(char, end=" ")
# P y t h o n
print()
# 딕셔너리 순회
user = {"name": "홍길동", "age": 25, "city": "서울"}
# 키만
for key in user:
print(key, end=" ")
# name age city
print()
# 키와 값
for key, value in user.items():
print(f"{key}: {value}")
# name: 홍길동
# age: 25
# city: 서울
while 문: 조건 기반 반복
# 기본 while
count = 0
while count < 5:
print(count, end=" ")
count += 1
# 0 1 2 3 4
print()
# while vs for 선택
# for: 반복 횟수가 정해진 경우
for i in range(5):
print(i)
# while: 조건이 만족될 때까지
count = 0
while count < 5:
print(count)
count += 1
# 무한 루프 (서버, 게임 루프 등)
while True:
user_input = input("종료하려면 'q' 입력: ")
if user_input == 'q':
break
print(f"입력: {user_input}")
# 실전 예시: 입력 검증
while True:
age = input("나이를 입력하세요 (1-120): ")
if age.isdigit() and 1 <= int(age) <= 120:
age = int(age)
break
print("올바른 나이를 입력하세요")
print(f"입력된 나이: {age}")
input()은 항상 문자열을 돌려주므로 숫자 비교 전에 변환이 필요합니다. 위 코드는 isdigit()으로 먼저 확인해 int()의 ValueError를 피했는데, isdigit()은 " 25"(앞 공백)나 "-3"에 False를 돌려주고, 반대로 "²" 같은 유니코드 위첨자 숫자에는 True를 돌려주면서 int()는 실패한다는 빈틈이 있습니다. 입력 형식이 다양할 수 있다면 앞의 safe_int처럼 try/except ValueError로 변환을 시도하는 편이 더 단단합니다. Python에서는 이렇게 “먼저 확인하고 실행(LBYL)“보다 “일단 시도하고 예외 처리(EAFP)“를 선호하는 문화가 있는데, 확인과 실행 사이의 조건이 어긋날 여지를 없애기 때문입니다.
break와 continue: 루프 제어
# break: 루프 즉시 종료
for i in range(10):
if i == 5:
break # i가 5일 때 루프 종료
print(i, end=" ")
# 0 1 2 3 4
print()
# continue: 현재 반복 건너뛰고 다음 반복으로
for i in range(10):
if i % 2 == 0:
continue # 짝수는 건너뛰기
print(i, end=" ")
# 1 3 5 7 9
print()
# 실전 예시: 소수 찾기
for num in range(2, 20):
is_prime = True
for i in range(2, int(num ** 0.5) + 1):
if num % i == 0:
is_prime = False
break # 약수 발견 시 더 이상 확인 불필요
if is_prime:
print(num, end=" ")
# 2 3 5 7 11 13 17 19
print()
# break vs continue 비교
for i in range(10):
if i == 5:
break # 5에서 루프 종료
print(i, end=" ")
# 0 1 2 3 4
print()
for i in range(10):
if i == 5:
continue # 5만 건너뛰고 계속
print(i, end=" ")
# 0 1 2 3 4 6 7 8 9
print()
else절: 루프 완료 시 실행 (Python의 독특한 기능)
# for-else: break 없이 정상 완료되면 else 실행
for i in range(5):
print(i, end=" ")
else:
print("\n루프 정상 완료")
# 0 1 2 3 4
# 루프 정상 완료
# break로 중단되면 else 실행 안 됨
for i in range(5):
if i == 3:
break
print(i, end=" ")
else:
print("\n루프 정상 완료") # 실행 안 됨
# 0 1 2
# 실전 활용: 검색
numbers = [1, 3, 5, 7, 9]
target = 4
for num in numbers:
if num == target:
print(f"{target} 찾음!")
break
else:
print(f"{target} 없음") # break 안 됨 → else 실행
# 4 없음
# while-else도 동일하게 동작
count = 0
while count < 5:
print(count, end=" ")
count += 1
else:
print("\nwhile 완료")
# 0 1 2 3 4
# while 완료
루프의 else는 이름 때문에 “루프가 한 번도 안 돌면 실행”으로 오해하기 쉽지만, 실제 의미는 “break 없이 끝났으면 실행”입니다. 빈 리스트를 순회해도 break가 없었으므로 else가 실행됩니다. 머릿속에서 else를 nobreak로 바꿔 읽으면 헷갈리지 않습니다. 검색 루프에서 “찾았음” 플래그 변수를 없애 주는 장점이 있지만, 팀원 중 이 문법을 모르는 사람이 많으면 오히려 읽기 어려워지므로 짧은 루프에서만 쓰거나 앞의 find_pair처럼 함수와 return으로 표현하는 편이 나을 때도 많습니다.
중첩 루프
# 구구단 (2중 루프)
for i in range(2, 10): # 2단부터 9단
for j in range(1, 10): # 1부터 9까지
print(f"{i} x {j} = {i*j}")
print() # 단 구분 빈 줄
# 2차원 리스트 순회
matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
]
for row in matrix:
for num in row:
print(num, end=" ")
print()
# 1 2 3
# 4 5 6
# 7 8 9
# 인덱스와 함께
for i, row in enumerate(matrix):
for j, num in enumerate(row):
print(f"[{i}][{j}] = {num}")
중첩 루프 탈출 (Python에는 레이블 없음)
# 방법 1: 플래그 사용
found = False
for i in range(5):
for j in range(5):
if i * j > 10:
found = True
break
if found:
break
# 방법 2: 함수로 감싸기 (권장)
def find_pair():
for i in range(5):
for j in range(5):
if i * j > 10:
return (i, j) # 즉시 반환
return None
result = find_pair()
print(result) # (3, 4)
# 방법 3: 예외 사용 (복잡한 경우)
class BreakLoop(Exception):
pass
try:
for i in range(5):
for j in range(5):
if i * j > 10:
raise BreakLoop
except BreakLoop:
pass
들여쓰기 규칙
들여쓰기의 중요성
Python은 들여쓰기로 블록을 구분합니다:
# ✅ 올바른 들여쓰기
if True:
print("Hello")
print("World")
# ❌ 들여쓰기 에러
if True:
print("Hello") # IndentationError!
들여쓰기 표준 (PEP 8)
# ✅ 스페이스 4개 (권장)
def my_function():
if True:
print("Hello")
# ❌ 탭 사용 (비권장)
def my_function():
if True:
print("Hello")
# ❌ 스페이스 2개 (비표준)
def my_function():
if True:
print("Hello")
혼합 사용 금지
# ❌ 탭과 스페이스 혼용
def my_function():
if True: # 스페이스 4개
print("Hello") # 탭 1개 → TabError!
Python 3는 탭과 스페이스를 섞어 들여쓰기 깊이가 모호해지면 TabError: inconsistent use of tabs and spaces in indentation으로 거부합니다. 곤란한 점은 에디터 화면에서는 둘이 똑같이 보인다는 것입니다. 다른 사람의 코드를 복사해 붙였더니 멀쩡해 보이는 줄에서 이 에러가 난다면 거의 항상 이 경우이고, 에디터의 “공백 문자 표시” 기능을 켜면 바로 보입니다. 에디터에서 “탭 키 입력 시 스페이스 4개 삽입”을 설정하고, black이나 ruff format 같은 포매터를 쓰면 이 문제 자체가 사라집니다.
CPython 내부: 바이트코드와 인터프리터 루프
앞 절까지는 문법을 다뤘습니다. 이 절에서는 같은 코드가 CPython(공식 구현) 안에서 어떻게 컴파일되고 실행되는지, 구현 관점에서 짚습니다. 다른 Python 구현(예: PyPy, Jython)은 단계나 최적화가 다를 수 있으나, 대부분의 운영·학습 환경은 CPython이므로 여기서는 CPython을 기준으로 설명합니다.
소스에서 바이트코드까지
CPython은 사용자가 작성한 .py 소스를 한 번에 기계어로 컴파일하지 않습니다. 대략 다음 순서를 거칩니다.
- 파싱·구문 분석: 소스 문자열을 토큰으로 나누고, 문법 규칙에 맞는지 확인합니다.
- AST(Abstract Syntax Tree): 구문 트리 형태로 프로그램 구조를 표현합니다.
- 컴파일: AST를
code객체(코드 객체)로 변환합니다. 이때 바이트코드(bytecode) 인스트럭션 시퀀스가 만들어집니다.
바이트코드는 CPU가 직접 실행하는 명령이 아니라, 가상 머신이 해석할 중간 표현입니다. 한 줄의 Python 문은 여러 바이트코드 연산으로 펼쳐질 수 있습니다.
확인 방법으로 표준 라이브러리 dis 모듈이 있습니다. 예를 들어 간단한 산술은 다음과 같이 펼쳐집니다.
import dis
def add_one(x):
return x + 1
dis.dis(add_one)
# 예시 출력(버전에 따라 세부 연산 코드·인자는 다를 수 있음):
# LOAD_FAST 0 (x)
# LOAD_CONST 1 (1)
# BINARY_OP 0 (+)
# RETURN_VALUE
dis.dis는 함수·메서드·코드 객체에 대해 연산자 이름, 인자(상수 인덱스·로컬 슬롯 번호 등) 를 사람이 읽을 수 있는 형태로 보여 줍니다. Python 버전이 바뀌면 연산 코드(opcode) 집합이 달라질 수 있으므로, 출력은 참고용으로 두며, 패턴(스택에 올리고, 연산하며, 반환한다)을 이해하는 것이 중요합니다.
평가 스택과 스택 머신 모델
CPython의 바이트코드 실행은 스택 머신(stack machine) 모델에 가깝습니다. 대부분의 연산은 평가 스택(eval stack) 위에서 이뤄집니다.
LOAD_FAST등: 로컬 변수·상수 값을 스택에 푸시- 이항 연산: 스택에서 두 개를 팝해 연산 후 결과를 푸시
RETURN_VALUE: 스택 꼭대기 값을 반환
조건문·반복문도 점프(jump) 연산과 조건부 점프로 제어 흐름을 표현합니다. 따라서 소스 레벨에서 보기엔 단순한 if 한 줄이, 바이트코드에서는 비교·점프·블록 경계가 분리되어 보이는 것이 자연스럽습니다.
인터프리터 루프(ceval)와 프레임
바이트코드를 실제로 한 줄씩 읽어 실행하는 핵심 루프는 CPython 소스 트리에서 Python/ceval.c 의 _PyEval_EvalFrameDefault 등으로 구현되어 있습니다(이름·세부 구조는 버전마다 조금씩 바뀔 수 있음). 흔히 이 부분을 ceval 루프 또는 인터프리터 루프라고 부릅니다.
실행 단위로 중요한 것이 frame 객체(프레임) 입니다. 함수 호출마다(및 특정 실행 컨텍스트마다) 프레임이 쌓이며, 여기에는 대략 다음 정보가 들어갑니다.
- 실행 중인 코드 객체(바이트코드·상수 테이블·자유 변수 정보 등)
- 로컬·글로벌 네임스페이스에 대한 참조
- instruction pointer(다음에 실행할 바이트코드 위치)
- 평가 스택 등 실행 상태
재귀 호출이 깊어지면 프레임이 많이 쌓여 스택 오버플로에 가까운 한계에 다가가는 이유도, 이 프레임 스택 모델과 연결해서 이해할 수 있습니다.
GIL과 바이트코드 실행(개념만)
CPython의 GIL(Global Interpreter Lock) 은 “한 시점에 한 스레드만 바이트코드를 실행한다”는 규칙을 둡니다(일부 I/O·C 확장 지점에서 해제될 수 있음). 따라서 CPU 바운드 작업을 스레드 여러 개로 나누어도 한 프로세스 안에서는 기대만큼 확장되지 않는 경우가 많습니다. 이는 문법과는 별개이지만, “Python 코드가 실제로 어떻게 도는가”를 논할 때 자주 따라붙는 주제이므로 이름만이라도 짚어 둡니다.
문법과 구현을 함께 보면 좋은 이유
dis로 바이트코드를 보면, “왜 이 문법이 이렇게 동작하나?”를 연산 순서 수준에서 설명할 수 있습니다.- 프레임·스택 개념은 이후 함수·클로저·예외(Python 함수: 매개변수, 반환값, 람다, 데코레이터 등)를 읽을 때 그대로 이어집니다.
- 성능을 다룰 때는 “한 줄이 저수준에서 얼마나 무거운가”를 대략 짐작하는 데 도움이 됩니다.
짝수 판별·합계·구구단·최댓값 예제
예제 1: 짝수/홀수 판별
number = int(input("숫자 입력: "))
if number % 2 == 0:
print(f"{number}는 짝수입니다")
else:
print(f"{number}는 홀수입니다")
예제 2: 1부터 N까지 합
n = 10
total = 0
for i in range(1, n + 1):
total += i
print(f"1부터 {n}까지의 합: {total}")
# 1부터 10까지의 합: 55
예제 3: 구구단
dan = int(input("단 입력: "))
for i in range(1, 10):
print(f"{dan} x {i} = {dan * i}")
예제 4: 최댓값 찾기
numbers = [3, 7, 2, 9, 1, 5]
max_num = numbers[0]
for num in numbers:
if num > max_num:
max_num = num
print(f"최댓값: {max_num}") # 최댓값: 9
IndentationError·range·TypeError: 입문자가 자주 막히는 지점
실수 1: 들여쓰기 에러
# ❌ IndentationError
if True:
print("Hello") # 들여쓰기 없음
# ✅ 수정
if True:
print("Hello") # 4칸 들여쓰기
# ❌ 불일치
if True:
print("Hello") # 4칸
print("World") # 6칸 (에러!)
# ✅ 일관성 유지
if True:
print("Hello") # 4칸
print("World") # 4칸
실수 2: range() 범위 착각
# ❌ 1~10이 아님!
for i in range(10):
print(i) # 0~9 (10개)
# ✅ 1~10
for i in range(1, 11):
print(i) # 1~10 (10개)
# range(n): 0부터 n-1까지
# range(a, b): a부터 b-1까지
# range(a, b, step): a부터 b-1까지 step 간격
# 예시
print(list(range(5))) # [0, 1, 2, 3, 4]
print(list(range(1, 6))) # [1, 2, 3, 4, 5]
print(list(range(0, 10, 2))) # [0, 2, 4, 6, 8]
실수 3: 무한 루프
# ❌ 무한 루프 (Ctrl+C로 강제 종료)
while True:
print("Hello") # 영원히 실행!
# ✅ 탈출 조건 추가
count = 0
while True:
print("Hello")
count += 1
if count >= 5:
break
# ✅ 조건식 사용 (더 명확)
count = 0
while count < 5:
print("Hello")
count += 1
실수 4: 변수 타입 혼동
# ❌ 문자열 + 정수
age = 25
# print("나이: " + age) # TypeError: can only concatenate str
# ✅ 타입 변환
print("나이: " + str(age)) # 나이: 25
# ✅ f-string 사용 (권장)
print(f"나이: {age}") # 나이: 25
# ❌ 정수 나눗셈 착각
result = 10 / 3
print(type(result)) # <class 'float'> (항상 float!)
# ✅ 정수 나눗셈
result = 10 // 3
print(type(result)) # <class 'int'>
실수 5: 리스트 인덱스 범위 초과
numbers = [1, 2, 3]
# ❌ IndexError
# print(numbers[3]) # 인덱스는 0, 1, 2만 존재
# ✅ 범위 체크
if len(numbers) > 3:
print(numbers[3])
else:
print("인덱스 범위 초과")
# ✅ 예외 처리
try:
print(numbers[3])
except IndexError:
print("인덱스 범위 초과")
연습 문제: FizzBuzz·소수 판별·역순 출력
문제 1: FizzBuzz
1부터 30까지 출력하되, 3의 배수는 “Fizz”, 5의 배수는 “Buzz”, 15의 배수는 “FizzBuzz” 출력
정답 보기
for i in range(1, 31):
if i % 15 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
문제 2: 소수 판별
주어진 숫자가 소수인지 판별하세요.
정답 보기
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
print(is_prime(7)) # True
print(is_prime(10)) # False
문제 3: 리스트 역순 출력
리스트를 역순으로 출력하세요 (reversed() 사용 금지).
정답 보기
numbers = [1, 2, 3, 4, 5]
# 방법 1: 인덱스 역순
for i in range(len(numbers) - 1, -1, -1):
print(numbers[i], end=" ")
# 5 4 3 2 1
# 방법 2: 슬라이싱
for num in numbers[::-1]:
print(num, end=" ")
# 5 4 3 2 1
같이 보면 좋은 글
- Go 언어의 철학과 기본 문법: C++ 개발자의 첫인상
- Python 환경 설정 | Windows/Mac에서 Python 설치하고 시작하기
- Python 자료형
- 배열과 연결 리스트
- 정렬 문제 풀이
- Python 함수
자주 묻는 질문 (FAQ)
Q. “나이: ” + age처럼 문자열과 숫자를 더하면 왜 TypeError가 나나요?
A. Python은 문자열과 정수를 자동으로 변환해 이어 붙이지 않기 때문에 TypeError: can only concatenate str이 발생합니다. str(age)로 명시적으로 변환하거나, f"나이: {age}"처럼 f-string을 쓰는 것이 일반적인 해결책입니다. 비슷하게 헷갈리는 부분으로 /는 정수끼리 나눠도 항상 float를 반환하므로, 정수 몫이 필요하면 //를 써야 합니다.