SW/Python

Python Anti-Pattern 총정리: list concatenation 성능 함정부터 mutable default argument까지 한 번에

얇은생각 2025. 12. 19. 07:30
반응형

이번 주에도 무심코 썼을지 모르는 7가지 Python Anti-Patterns (그리고 깔끔한 해결책)

요약 한 줄: Python은 친절하지만, 겉보기에 예쁜 구문 속에 성능 폭탄과 미묘한 버그가 숨어 있어요. 아래 7가지만 손봐도 코드가 확 달라집니다.

 


Overview

Python은 읽기 쉽고 강력한 batteries-included 언어죠. 그만큼 한 줄로 많은 걸 해버리는 syntactic sugar가 많고, 그 달콤함이 때로는 time complexitycorrectness를 망가뜨리기도 합니다.
이 글에서는 현업 코드 리뷰에서 정말 자주 보는 7가지 anti-patterns를 깔끔히 정리했습니다.

각 항목마다

  • 왜 위험한지,
  • 어디서 자주 나타나는지,
  • 어떻게 고치면 좋은지,
  • 체크리스트로 습관화하는 팁
    까지 담았습니다. 잔소리가 아니라, 팀과 미래의 나를 편하게 만들자는 마음으로요. 🙂

 

time complexity

 


Pattern 1 — “예쁜 한 줄”이 숨기는 나쁜 time complexity

한눈에 보기 좋은 표현이 실제로는 O(n²) 을 만들어내는 경우가 많습니다.

 

리스트/문자열 이어 붙이기(반복)에서 터지는 비용

# ❌ loop 안에서 list concatenation
result = []
for x in range(n):
    result = result + [x]  # 매번 새 list 생성 + 복사 → 누적 O(n²)

 

해결: append / extend / 한 번만 더하기

# ✅ append: amortized O(1) → 전체 O(n)
result = []
for x in range(n):
    result.append(x)

# ✅ 범위면 아예 한 방에
result = list(range(n))

# ✅ 두 list를 한 번만 합칠 땐 +
merged = a + b

 

string도 마찬가지입니다.

# ❌ loop에서 +=
out = ""
for part in parts:
    out += part

# ✅ join
out = "".join(parts)

 

membership test를 잘못된 자료구조로

# ❌ list에 대한 in 검사 반복 → O(n) * 반복 = O(n²)
for item in candidates:
    if item in seen_list:
        ...
# ✅ set/dict로 바꾸기 → 평균 O(1)
seen = set(seen_list)
for item in candidates:
    if item in seen:
        ...

 

Quick check

  • 문자열을 모아서 하나로? → "".join(parts)
  • list가 커지는 루프? → append or comprehension
  • in 검사를 여러 번? → set/dict로 캐시

 

 


Pattern 2 — ==로 비교해야 할 걸 is로, 혹은 그 반대로

Python에는 두 질문이 있어요.

  • Equality: == → __eq__ 호출
  • Identity: is → 같은 객체인지(싱글턴 포함)

 

None 비교는 항상 identity를 씁니다.

# ❌
if value == None:
    ...

# ✅
if value is None:
    ...
# ✅
if value is not None:
    ...

 

왜냐고요? ==는 사용자 정의 클래스가 __eq__를 이상하게 구현해도 그대로 믿기 때문이죠.

Booleans의 경우엔 보통 truthiness가 가장 Pythonic 합니다.

# ✅
if flag:
    ...
if not flag:
    ...

 

is True/is False는 정말 그 singleton만 허용하고 싶을 때에만 드물게 사용하세요.

 

작은 데모:

class Weird:
    def __eq__(self, other):
        return True

x = Weird()
print(x == None)  # True (헉)
print(x is None)  # False (정상)

 

 


Pattern 3 — 쓸 수 있는데 안 쓰는 comprehension / generator expression

list/dict/set comprehension은 코드가 짧아질 뿐 아니라, 종종 단순 loop보다 빠르기도 합니다.

# ❌
squares = []
for num in numbers:
    squares.append(num * num)

# ✅
squares = [num * num for num in numbers]

# ✅ filter + transform
evens = [x for x in numbers if x % 2 == 0]

# ✅ dict comprehension
name_to_len = {name: len(name) for name in names}

# ✅ set comprehension
positives = {x for x in items if x > 0}

# ✅ nested (flatten)
flat = [item for row in matrix for item in row]

# ✅ generator expression (lazy)
total = sum(x * x for x in numbers)

# ✅ inline conditional
labels = ["positive" if x > 0 else "non-positive" for x in items]

읽기 어렵다고 느껴지면 과감히 두 줄로 나누면 됩니다. “짧게 쓰는 것”보다 “한눈에 이해되는 것”이 우선이에요.

 

 


Pattern 4 — context manager 없이 리소스 직접 열고 닫기

파일, 소켓, DB connection 같은 건 예외가 터져도 무조건 정리돼야 합니다. 그 일을 자동으로 해주는 게 with가 여는 context manager죠.

# ❌ 수동 open/close
f = open(filename, "r")
try:
    data = f.read()
finally:
    f.close()
# ✅ 안전하고 짧게
with open(filename, "r") as f:
    data = f.read()

 

여러 리소스도 한 줄에:

with open(src, "r") as a, open(dst, "w") as b:
    b.write(a.read())

 

직접 만들고 싶다면 __enter__/__exit__ 또는 contextlib를 쓰면 됩니다.

 

 


Pattern 5 — debugging에만 쓰던 print를 production에도 그대로

print는 즉석 실험용으로는 최고지만, 운영 환경에서는 timestamp, level, logger name, traceback 같은 정보가 생명줄입니다. logging을 기본값으로 합시다.

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(name)s %(levelname)s: %(message)s",
)
logger = logging.getLogger(__name__)

logger.debug("debug")
logger.info("service started")
logger.warning("low disk space")
try:
    1 / 0
except ZeroDivisionError:
    logger.exception("failure")  # traceback 자동 포함

 

파일에도 남기려면 handler만 추가:

file_handler = logging.FileHandler("app.log")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(
    "%(asctime)s %(name)s %(levelname)s: %(message)s"
))
logger.addHandler(file_handler)

 

Tip

  • 라이브러리/서비스는 무조건 logging
  • DEBUG/INFO/WARNING/ERROR/CRITICAL로 level 관리
  • except 안에서는 logger.exception(...)

 

 


 

 

Pattern 6 — 표준 라이브러리가 이미 해결한 걸 새로 만들기

필요할 때 꺼내 쓰라고 있는 배터리들, 꼭 써요.

 

개수 세기 → collections.Counter

# ❌ 수동 집계
freq = {}
for item in items:
    freq[item] = freq.get(item, 0) + 1

# ✅ Counter
from collections import Counter
freq = Counter(items)
top3 = freq.most_common(3)

 

 

경로/파일 작업 → pathlib

from pathlib import Path

base = Path("/data")
report = base / "reports" / "2025" / "summary.csv"

if report.exists() and report.is_file():
    print(report.suffix)  # .csv

 

 

그 밖에 즐겨 찾기:

  • itertools (lazy iteration, 조합)
  • functools (lru_cache, 고차 함수)
  • dataclasses (보일러플레이트 제거)

“내가 만드는 이 helper, 너무 기본적인데?” 싶으면 먼저 표준 라이브러리에서 이름을 찾아보세요.

 

 


Pattern 7 — 모두가 한 번쯤 당하는 mutable default argument

list/dict 같은 mutable 객체를 함수 default로 넣으면, 정의 시점 딱 한 번 만들어지고 모든 호출이 그 한 객체를 공유합니다.

# ❌
def add_item_bad(item, items=[]):
    items.append(item)
    return items

print(add_item_bad("apple"))   # ["apple"]
print(add_item_bad("banana"))  # ["apple", "banana"]  ← ??? 같은 list 재사용

 

해결: None을 기본값으로 두고 내부에서 생성

# ✅
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

class에서 dataclasses를 쓰면 default_factory=list를 사용하세요.

 

체크포인트

  • 기본값에 [], {}, set() 쓰지 않기
  • 기본값은 None → 내부에서 새로 만들기
  • dataclasses면 default_factory

 


Big-O 초간단 암기장

  • O(1): set/dict lookup(평균)
  • O(log n): binary search, heap
  • O(n): 한 번씩 훑기(append 기반)
  • O(n log n): 정렬 등
  • O(n²): 중첩 loop, 매번 복사하는 concatenation

정확한 숫자보다 상한을 빠르게 가늠하는 습관이 중요합니다.

 

 


Copy-Paste용 미니 레시피

Strings

out = "".join(parts)

 

Lists

result = []
for x in iterable:
    result.append(x)

# 또는
result = [f(x) for x in iterable if predicate(x)]

 

Membership

allowed = set(allowed_list)
if token in allowed:
    ...

 

None / Booleans

if value is None:
    ...
if value is not None:
    ...

if flag:
    ...

 

File handling

from pathlib import Path
p = Path("/tmp/example.txt")
with p.open("w") as f:
    f.write("hello")

 

Logging

import logging
logging.basicConfig(level=logging.INFO,
    format="%(asctime)s %(name)s %(levelname)s: %(message)s")
log = logging.getLogger(__name__)
log.info("service started")

 

Mutable defaults

def build(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

 

 


FAQ

Q. 왜 loop에서 list concatenation이 느리죠?
a = a + [x]는 매번 새 list를 만들고 복사합니다. 누적 작업량이 O(n²) 로 커져요. append나 comprehension을 쓰세요.

Q. None 비교는 == 대신 is를 써야 하나요?
네. ==는 __eq__에 속아 넘어갈 수 있습니다. is/is not이 정답.

Q. comprehension이 진짜 loop보다 빠른가요?
경우에 따라 그렇습니다. 내부 루프가 C 레벨로 돌아가고 attribute lookup이 줄어드는 이점이 있어요.

Q. with를 쓰면 close를 안 해도 되나요?
맞아요. 예외가 나도 context manager가 정리합니다.

Q. 운영 환경에선 print 대신 뭘 써요?
logging. timestamp/level/logger/traceback을 한 번에 챙길 수 있어요.

Q. list 빈도 세기를 간단히?
collections.Counter(items) 후 most_common().

Q. mutable default argument 버그 어떻게 피하죠?
기본값은 None, 내부에서 []/{}를 새로 만듭니다. dataclasses면 default_factory.

 

 


마무리

Python의 우아함은 때때로 오해를 부르기도 합니다. join이 +=를 이기고, set이 membership에서 빛나며, with가 예외 속에서도 리소스를 지킨다는 작은 원리들을 몸으로 익히면 코드가 훨씬 단단해져요.
PR에 작은 체크리스트를 붙여 보세요. 내일의 디버깅 시간을 오늘 줄일 수 있습니다.

반응형