bnbongbnbong
Back to blog
Pythonprogrammingsyntaxinterviewcoding test

생소하거나 헷갈리는 Python 문법들

Python·Posted 2026.04.27·9 min read

이 포스팅은 생소한 프로그래밍 언어 문법 시리즈의 첫 번째 편(Python)이다. 이후 Java, C 편이 이어질 예정이다.

도입 — Python 문법의 '간결함' 뒤에 숨은 함정들

Python을 5년 가까이 다뤄오면서 처음 보는 문법이 있었다. 정보처리기사 실기 시험을 세 번 치르면서 Python, C, Java의 정확한 문법 공부가 절실했는데, 친구들과 대화하다가 for ~ else ~ 문법이라는 게 Python에 존재한다는 것을 알게 됐다. 5년 동안 Python을 다루면서도 한 번도 본 적이 없는 문법이었기에 아직도 모르는 게 있을 수 있다는 사실이 적잖이 충격이었다. 그래서 기본기부터 다시 다져보자는 마음으로 이 글을 작성하게 됐다.

thumbnail /// caption 나는 아직 Python 알못 이었던 것... ///

정보처리기사 실기처럼 인터프리터 없이 코드의 출력을 예측해야 하는 상황에서는, 문법의 정확한 동작을 모르면 답을 맞힐 수가 없다. 이 글에서는 Python 3.8~3.13 범위에서 자주 혼동되거나 생소한 문법들을 하나씩 짚어보며, 각 문법마다 "이 코드의 출력은?" 형태의 예제를 먼저 보여준 뒤 정답과 함께 왜 그런 결과가 나오는지 설명한다.


가변 기본 인자(Mutable Default Argument)의 함정

Q. 다음 코드의 출력은?

def append_to(element, target=[]):
    target.append(element)
    return target

print(append_to(1))
print(append_to(2))
print(append_to(3))
정답 보기
[1]
[1, 2]
[1, 2, 3]

[1], [2], [3]이 각각 출력될 것 같지만, 실제로는 리스트가 계속 누적된다.

Python에서 함수의 기본 인자값은 함수가 **정의(define)**될 때 단 한 번만 평가된다. 따라서 target=[]의 빈 리스트 객체는 함수 호출마다 새로 생성되는 것이 아니라, 모든 호출이 같은 리스트 객체를 공유한다.

올바른 패턴은 None을 센티널(sentinel)로 사용하는 것이다:

def append_to(element, target=None):
    if target is None:
        target = []
    target.append(element)
    return target

이 함정은 dict, set 등 다른 가변(mutable) 객체를 기본 인자로 사용할 때도 동일하게 적용된다.


for/while/try 문의 else 절 — 언제 실행되는가

Python에서 elseif 문에만 붙는 것이 아니다. for, while, try 문에도 else 절을 붙일 수 있다.

Q. 다음 코드의 출력은?

for i in range(3):
    print(i, end=' ')
else:
    print("done")
정답 보기
0 1 2 done

for 문의 else 절은 반복이 break 없이 정상 종료되었을 때 실행된다. while 문도 마찬가지다. 이름이 else라서 "반복이 실행되지 않았을 때"로 오해하기 쉽지만, 실제로는 그 반대에 가깝다. "nobreak"라고 읽는 것이 더 직관적이다.

Q. 그렇다면 이 코드의 출력은?

for i in range(5):
    if i == 3:
        break
else:
    print("완료")

print("끝")
정답 보기

i == 3에서 break가 실행되므로 else 절은 건너뛰고, print("끝")만 실행된다.

Q. 반복 대상이 비어 있을 때는?

for i in []:
    print("반복")
else:
    print("else 실행")
정답 보기
else 실행

반복 대상이 비어 있어도 break가 발생하지 않았으므로 else가 실행된다. 이것이 else라는 이름이 혼동을 주는 대표적인 사례다.

try 문의 else

try 문의 else는 예외가 발생하지 않았을 때 실행되며, finally 앞에 위치한다.

try:
    result = 10 / 2
except ZeroDivisionError:
    print("0으로 나눌 수 없음")
else:
    print(f"결과: {result}")  # 예외 없으므로 실행됨
finally:
    print("항상 실행")

왈러스 연산자(:=) — 대입 표현식의 올바른 사용법

Python 3.8에서 PEP 572를 통해 도입된 왈러스 연산자(:=)는 표현식(expression) 안에서 변수에 값을 대입할 수 있게 해준다.

Q. 다음 코드의 출력은?

data = [1, 2, 3, 4, 5, 6]
result = [y for x in data if (y := x ** 2) > 10]
print(result)
정답 보기
[16, 25, 36]

x ** 2의 결과를 y에 대입하면서 동시에 > 10 조건을 검사한다. 조건을 만족하는 경우에만 y가 결과 리스트에 포함된다.

왈러스 연산자가 유용한 대표적인 패턴:

# while 루프에서 입력 처리
while (line := input(">>> ")) != "quit":
    print(f"입력: {line}")

# 정규식 매칭
import re
if (m := re.match(r"\d+", text)):
    print(m.group())

사용이 제한되는 위치

왈러스 연산자는 몇 가지 문맥에서 괄호가 필요하거나 사용이 금지된다:

  • 최상위 표현식 문(expression statement): x := 10처럼 단독으로 사용할 수 없다. 일반 대입문 x = 10을 사용해야 한다.
  • 함수의 기본 인자값에서 괄호 없이 사용: def f(a=b := 1):처럼 괄호 없이 사용하면 문법 오류가 발생한다. 단, def f(a=(b := 1)):처럼 괄호로 감싸면 허용된다.
  • lambda 본문에서 괄호 없이 사용: lambda: x := 1은 파싱 우선순위 문제로 문법 오류가 발생한다. lambda: (x := 1)처럼 괄호로 감싸야 한다.

언패킹 연산자 * 와 ** 의 다양한 쓰임

***는 곱셈·거듭제곱 연산자이기도 하지만, 언패킹(unpacking) 문맥에서는 완전히 다른 역할을 한다.

Q. 다음 코드의 출력은?

a, *b, c = [1, 2, 3, 4, 5]
print(a)
print(b)
print(c)
정답 보기
1
[2, 3, 4]
5

*b는 양 끝을 제외한 나머지 요소들을 리스트로 묶어서 받는다.

다양한 사용 위치

문맥 예시 설명
대입문 좌변 a, *b = [1, 2, 3] 나머지 요소를 리스트로 수집
함수 정의 def f(*args, **kwargs) 가변 위치/키워드 인자 수집
함수 호출 f(*list_a, **dict_b) 이터러블/딕셔너리 언패킹
리스트/딕셔너리 리터럴 [*a, *b], {**d1, **d2} 병합 (Python 3.5+)

Q. 다음 코드의 출력은?

def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

config = {"greeting": "안녕", "name": "철수"}
greet(**config)
정답 보기
안녕, 철수!

**config는 딕셔너리의 키-값 쌍을 키워드 인자로 풀어서 전달한다. 이때 딕셔너리의 키가 함수 매개변수 이름과 일치해야 한다.

Q. 다음 코드의 출력은?

first, *rest = "Python"
print(first)
print(rest)
정답 보기
P
['y', 't', 'h', 'o', 'n']

문자열도 이터러블이므로 언패킹이 가능하다. *rest는 나머지 문자들을 리스트로 수집한다(문자열이 아님에 주의).


리스트 컴프리헨션 vs 제너레이터 표현식 — 문법은 비슷하지만 동작은 다르다

Q. 다음 코드의 출력은?

list_comp = [i * 2 for i in range(5)]
gen_exp = (i * 2 for i in range(5))

print(type(list_comp).__name__)
print(type(gen_exp).__name__)
print(sum(gen_exp))
print(sum(gen_exp))
정답 보기
list
generator
20
0

리스트 컴프리헨션 []은 모든 요소를 즉시 메모리에 생성하지만, 제너레이터 표현식 ()은 **지연 평가(lazy evaluation)**로 요소를 하나씩 생성한다.

핵심적인 차이는 제너레이터가 한 번 소진되면 재사용할 수 없다는 점이다. 두 번째 sum(gen_exp) 호출 시 이미 소진된 제너레이터이므로 합계가 0이 된다.

메모리 관점에서, 큰 데이터를 처리할 때는 제너레이터 표현식이 유리하다:

# 리스트 컴프리헨션: 모든 요소를 메모리에 한꺼번에 생성
total = sum([x ** 2 for x in range(10_000_000)])

# 제너레이터 표현식: 요소를 하나씩 생성하여 메모리 효율적
total = sum(x ** 2 for x in range(10_000_000))

구조적 패턴 매칭(match-case) 기초와 주의점

Python 3.10에서 PEP 634를 통해 도입된 match-case 문은 값의 구조를 기반으로 분기하는 문법이다.

기본 사용법은 다른 언어의 switch-case와 유사하다:

def http_status(status):
    match status:
        case 200:
            return "OK"
        case 404:
            return "Not Found"
        case _:
            return "Unknown"

리터럴 값(200, 404)은 값 비교 패턴으로 동작한다. _는 모든 값에 매칭되는 와일드카드 패턴이다.

캡처 패턴의 함정

Q. 다음 코드의 출력은?

NOT_FOUND = 404
status = 200

match status:
    case NOT_FOUND:
        print("Not Found")
정답 보기
Not Found

놀랍게도 status200인데도 "Not Found"가 출력된다. case NOT_FOUND:status404인지 비교하는 것이 아니라, status의 값을 NOT_FOUND라는 새 변수에 **캡처(capture)**하는 것이다. 즉, 단독 이름(bare name)은 항상 캡처 패턴으로 해석되어, 어떤 값이든 매칭에 성공한다.

참고로, 이 캡처 패턴 뒤에 case _:와 같은 와일드카드 패턴을 추가하면, 캡처 패턴이 이미 모든 값을 잡아내므로 Python은 SyntaxError: name capture 'NOT_FOUND' makes remaining patterns unreachable를 발생시킨다.

상수 값과 비교하려면 점(dot) 표기법을 사용해야 한다:

from http import HTTPStatus

status = 200

match status:
    case HTTPStatus.NOT_FOUND:
        print("Not Found")
    case _:
        print("Other")

점이 포함된 이름(dotted name)은 캡처가 아닌 값 비교 패턴으로 해석된다. 혹은 직접 상수를 클래스나 모듈에 넣어서 사용할 수도 있다:

class Status:
    NOT_FOUND = 404
    OK = 200

match status:
    case Status.NOT_FOUND:
        print("Not Found")
    case Status.OK:
        print("OK")

구조 분해

match-case의 진짜 강점은 구조 분해(destructuring)에 있다:

point = (3, 4)

match point:
    case (0, 0):
        print("원점")
    case (x, 0):
        print(f"x축 위: {x}")
    case (0, y):
        print(f"y축 위: {y}")
    case (x, y):
        print(f"좌표: ({x}, {y})")

체이닝 비교(Chained Comparisons)와 is vs == 혼동

체이닝 비교

Python에서는 비교 연산자를 연속으로 체이닝할 수 있다.

Q. 다음 코드의 출력은?

x = 5
print(1 < x < 10)
print(10 > x > 3 > 1)
print(1 < x > 3)
정답 보기
True
True
True

1 < x < 101 < x and x < 10과 동일하게 동작한다. 체이닝은 어떤 비교 연산자든 조합할 수 있으며, 각 피연산자는 최대 한 번만 평가된다.

주의할 점은 직관적이지 않은 조합도 문법적으로 허용된다는 것이다:

print(1 == 1 in [1, 2])  # True (1 == 1 and 1 in [1, 2])
print(1 is 1 < 2)        # True (1 is 1 and 1 < 2)

inis도 비교 연산자이므로 체이닝에 포함될 수 있다.

is vs ==

Q. 다음 코드의 출력은?

a = 256
b = 256
print(a is b)

c = 257
d = 257
print(c is d)
정답 보기

이 결과는 구현체와 실행 환경에 따라 달라질 수 있다. CPython에서 일반적으로:

True

첫 번째 결과는 True이다. 두 번째 결과는 실행 환경에 따라 True 또는 False가 될 수 있다. 대화형 셸에서 한 줄씩 실행하면 False가 나오지만, 같은 코드 블록이나 스크립트 파일에서 실행하면 컴파일러 최적화로 인해 True가 나올 수도 있다.

==는 **값(value)**이 같은지, is는 **동일 객체(identity)**인지를 비교한다.

CPython은 -5부터 256까지의 정수를 내부적으로 캐싱(interning)하므로, 이 범위의 정수는 is로 비교해도 True가 나올 수 있다. 하지만 이는 CPython의 구현 세부사항이며, Python 언어 명세에서 보장하는 동작이 아니다.

원칙: 값 비교에는 항상 ==를 사용하고, isNone 비교(x is None) 등 싱글턴 객체 확인에만 사용하자.


global과 nonlocal — 스코프 규칙 정리

Q. 다음 코드의 출력은?

x = 10

def foo():
    print(x)
    x = 20

foo()
정답 보기
UnboundLocalError 발생

에러 메시지는 Python 버전에 따라 다르다. 3.10 이하에서는 local variable 'x' referenced before assignment, 3.11 이상에서는 cannot access local variable 'x' where it is not associated with a value과 유사한 형태로 출력된다.

함수 내부에 x = 20이라는 대입문이 있으므로, Python 컴파일러는 x지역 변수로 간주한다. 그런데 print(x) 시점에서는 아직 지역 변수 x에 값이 할당되지 않았으므로 UnboundLocalError가 발생한다.

global

global 키워드로 모듈 레벨의 변수를 명시적으로 참조할 수 있다:

x = 10

def foo():
    global x      # 모듈 레벨의 x를 사용
    print(x)      # 10
    x = 20

foo()
print(x)          # 20 (전역 변수가 변경됨)

nonlocal

nonlocal은 중첩 함수에서 바로 바깥 스코프의 변수를 참조할 때 사용한다:

def outer():
    count = 0
    def inner():
        nonlocal count
        count += 1
    inner()
    inner()
    print(count)  # 2

outer()

nonlocal이 없으면 inner() 안의 count += 1UnboundLocalError를 발생시킨다. count += 1count = count + 1과 동일하여 count를 지역 변수로 인식하기 때문이다.


자주 혼동되는 기타 문법 모음

슬라이싱과 출력 포맷

Q. 다음 코드의 출력은?

lst = list(range(10))
for c in lst[::-2]:
    print(c, end='A')
print()
정답 보기
9A7A5A3A1A

lst[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]이고, lst[::-2]는 끝에서부터 2칸씩 건너뛰며 역순으로 [9, 7, 5, 3, 1]을 만든다. print(c, end='A')는 줄바꿈 대신 'A'를 출력하므로 각 숫자 뒤에 A가 붙는다. 마지막 print()가 줄바꿈을 추가한다.

삼항 표현식(Conditional Expression)

Q. 다음 코드의 출력은?

x = 10
result = "짝수" if x % 2 == 0 else "홀수"
print(result)
정답 보기
짝수

Python의 삼항 표현식은 값1 if 조건 else 값2 형태다. 다른 언어의 조건 ? 값1 : 값2에 해당하지만 순서가 다르므로, 처음 접하면 혼동하기 쉽다.

중첩도 가능하지만 가독성이 크게 떨어지므로 권장하지 않는다:

# 가독성이 나쁜 예
grade = "A" if score >= 90 else "B" if score >= 80 else "C"

bool은 int의 서브클래스

Q. 다음 코드의 출력은?

print(True + True + False)
print(isinstance(True, int))
정답 보기
2
True

Python에서 boolint의 서브클래스이며, True1, False0과 동일한 정수 값을 갖는다. 따라서 산술 연산이 가능하다.

이로 인해 딕셔너리에서 True1을 키로 사용하면 같은 키로 취급된다:

d = {True: "bool", 1: "int"}
print(d)       # {True: 'int'}
print(len(d))  # 1

True == 1이고 hash(True) == hash(1)이므로 같은 키로 인식된다. 동일 키에 대해 나중에 대입한 값 "int"로 덮어써진다.

Ellipsis 리터럴 (...)

Q. 다음 코드는 에러가 발생할까?

def my_function():
    ...
정답 보기

에러 없이 정상 실행된다.

...Ellipsis라는 내장 상수로, pass와 유사하게 빈 함수나 클래스의 자리 표시자(placeholder)로 사용할 수 있다. 타입 힌트에서 Tuple[int, ...]처럼 가변 길이를 표현할 때도 사용된다.

단일 요소 튜플

Q. 다음 코드의 출력은?

a = (1)
b = (1,)
print(type(a).__name__)
print(type(b).__name__)
정답 보기
int
tuple

(1)은 단순히 괄호로 감싼 정수 1이다. 단일 요소 튜플을 만들려면 반드시 쉼표가 필요하다: (1,). 이 실수는 특히 함수에 튜플을 전달할 때 자주 발생한다.


정리 및 참고 자료

Python은 간결한 문법을 지향하지만, 그 간결함 속에 직관적이지 않은 동작이 적지 않다. 이번 내용을 요약하면 다음과 같다:

문법 핵심 포인트
가변 기본 인자 함수 정의 시 한 번만 생성, None 센티널 패턴 사용
for/while else break 없이 정상 종료 시 실행
try else 예외가 발생하지 않았을 때 실행
왈러스 연산자 := 표현식 안에서 대입, 사용 불가 위치 주의
언패킹 * ** 대입·함수 정의·호출·리터럴 등 다양한 문맥
리스트 컴프리헨션 vs 제너레이터 즉시 생성 vs 지연 평가, 제너레이터는 일회성
match-case 단독 이름은 캡처 패턴, 상수 비교는 점 표기법 필수
is vs == 값 비교는 ==, 동일 객체 확인만 is
global / nonlocal 대입문이 있으면 지역 변수로 간주됨

인터프리터 없이 코드를 읽어야 하는 상황뿐 아니라, 일상적인 코드 작성에서도 이런 문법의 정확한 동작을 알고 있으면 디버깅 시간을 크게 줄일 수 있다.

python_gosu /// caption Python 고수의 길은 멀고도 험하다... ///

다음 편에서는 Java의 헷갈리는 문법들을 다룰 예정이다.

참고 자료

Comments

powered by giscus