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 소스를 한 번에 기계어로 컴파일하지 않습니다. 대략 다음 순서를 거칩니다.

  1. 파싱·구문 분석: 소스 문자열을 토큰으로 나누고, 문법 규칙에 맞는지 확인합니다.
  2. AST(Abstract Syntax Tree): 구문 트리 형태로 프로그램 구조를 표현합니다.
  3. 컴파일: 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

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. “나이: ” + age처럼 문자열과 숫자를 더하면 왜 TypeError가 나나요?

A. Python은 문자열과 정수를 자동으로 변환해 이어 붙이지 않기 때문에 TypeError: can only concatenate str이 발생합니다. str(age)로 명시적으로 변환하거나, f"나이: {age}"처럼 f-string을 쓰는 것이 일반적인 해결책입니다. 비슷하게 헷갈리는 부분으로 /는 정수끼리 나눠도 항상 float를 반환하므로, 정수 몫이 필요하면 //를 써야 합니다.