jhpka's blog

[Python] 문자열 연결 성능 비교: 이론 vs 실제

Admin User

핵심 정리

1. 세 가지 방법의 메커니즘

방법 1: s += str(i) (문자열 연결)

python
s = ""
for i in range(n):
    s += str(i)

내부 동작:

python
# 매 반복마다
1. m1 = str(i) 객체 생성
2. k2 = k1 + m1 (새 문자열 객체 생성)
3. m1, k1 refcount = 0 → 즉시 메모리 해제
4. s = k2 (s가 새 객체 가리킴)

특징:

  • 이론상 O(n²) 복잡도
  • 하지만 CPython 최적화: refcount=1이면 in-place 수정 시도
  • 결과적으로 O(n) ~ O(n log n) 정도로 동작
  • 메모리: 압도적 최소 (한 번에 큰 문자열 1~2개만)

방법 2: list + join (리스트 축적)

python
lst = []
for i in range(n):
    lst.append(str(i))
s = "".join(lst)

내부 동작:

python
1. r1 = str(0) 생성
2. lst.append(r1) - 리스트에 포인터 추가
3. r2 = str(1) 생성
4. lst.append(r2)
# ... n번 반복
5. lst = [r1, r2, ..., rn] (모든 객체 메모리에 존재)
6. s = "".join(lst) (최종 문자열 생성)
7. r1~rn은 lst가 참조 중이므로 유지됨

특징:

  • 함수 호출 n번 (append 오버헤드)
  • 리스트 resize 오버헤드
  • 메모리: n개 문자열 + 리스트 + 최종 문자열
  • 예전 교과서의 "권장 방법"이었으나 이제는 구식

방법 3: generator + join (제너레이터)

python
s = "".join(str(i) for i in range(n))

내부 동작:

python
# join이 내부적으로:
1. 제너레이터에서 하나씩 값을 가져옴
2. C 레벨에서 최적화된 문자열 연결
3. 중간 리스트 없이 직접 처리
4. 한 번에 필요한 메모리 할당 후 순차 처리

특징:

  • C 레벨 최적화
  • 함수 호출 오버헤드 최소
  • 중간 리스트 생성 안 함
  • 현대적 Best Practice

2. 실험 결과 요약

규모가장 빠른 방법시간메모리 패턴
n=10,000list+join0.0005초작은 차이
n=1,000,000generator0.61초메모리 격차 시작
n=10,000,000generator6.82초메모리 10배 차이

상세 비교 (n=10,000,000)

text
방법              시간        메모리         평가
--------------------------------------------------
generator        6.82초      621MB         ⭐ 최고
s += str(i)      9.24초      67MB          💾 메모리 최소
list + join      11.97초     621MB         ❌ 최악

3. CPython의 메모리 관리

Reference Counting

python
s = "hello"      # refcount = 1
s += " world"    # 기존 "hello" refcount = 0 → 즉시 해제
                 # 새로운 "hello world" refcount = 1

CPython은 refcount가 0이 되면 즉시 메모리 해제 (순환 참조 제외)

String Interning & Optimization

python
# 작은 문자열이나 refcount=1인 경우
s += "x"  # 때로는 새 객체 대신 기존 메모리 확장 시도
          # 이것이 += 가 예상보다 빠른 이유!

4. tracemalloc의 영향

python
# tracemalloc 없이 측정
s += str(i)  # 빠름

# tracemalloc.start() 후 측정
s += str(i)  # 상대적으로 느려짐
             # 이유: 모든 할당/해제를 추적하므로 오버헤드

교훈: 메모리 프로파일링 도구도 성능에 영향을 줌!


통찰과 교훈

1. "교과서는 틀렸다"

  • 예전부터 "문자열 연결은 list로 모아서 join 하라"고 배움
  • 하지만 generator가 모든 면에서 우월
  • list + join은 이제 안티패턴

2. "이론 vs 실제의 괴리"

  • 이론상 s += str(i)는 O(n²)
  • 하지만 CPython 최적화로 실제로는 O(n) ~ O(n log n)
  • 실측 없이는 확신할 수 없다!

3. "규모의 중요성"

text
n=10,000    → 차이 미미, 최적화가 지배적
n=1,000,000 → 진실이 드러나기 시작
n=10,000,000 → 명확한 승자 결정

작은 데이터로는 진실을 알 수 없다!

4. "메모리 vs 시간 트레이드오프"

text
generator:   빠르지만 메모리 많이 씀 (621MB)
s += str(i): 느리지만 메모리 적게 씀 (67MB)
  • 일반적으로는 시간 우선 (generator)
  • 메모리 제한 환경에서는 공간 우선 (+=)
  • 상황에 맞는 선택이 중요!

5. "측정의 함정"

  • tracemalloc 자체가 오버헤드 추가
  • 작은 n에서는 노이즈가 큼
  • 여러 방법으로 교차 검증 필요

6. "Python의 숨은 최적화"

  • CPython은 생각보다 똑똑함
  • String interning, in-place modification 등
  • "순진한 코드"도 때로는 잘 작동함
  • 하지만 언어 구현에 의존하면 위험 (PyPy, Jython 등에서는 다를 수 있음)

실전 가이드

상황별 추천

python
# 1. 일반적인 경우 (99%)
s = "".join(str(i) for i in iterable)
# ✅ 가장 빠름
# ✅ Pythonic
# ✅ 읽기 쉬움

# 2. 메모리가 극도로 제한적 (임베디드, IoT)
s = ""
for item in iterable:
    s += str(item)
# ✅ 메모리 10배 절약
# ⚠️ 35% 느림

# 3. list + join - 사용하지 마세요
lst = [str(i) for i in iterable]
s = "".join(lst)
# ❌ 시간도 느리고 메모리도 많이 씀
# ❌ generator의 하위호환

성능 측정 템플릿

python
import tracemalloc
import time

# 메모리 + 시간 측정
tracemalloc.start()
start = time.time()

# ... 코드 실행 ...

elapsed = time.time() - start
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()

print(f"시간: {elapsed:.4f}초")
print(f"피크 메모리: {peak / 1024:.2f} KB")

완전한 벤치마크 예제

python
import time
import tracemalloc

def benchmark_string_concat(n, method='generator'):
    """문자열 연결 방법 벤치마크"""
    tracemalloc.start()
    start_time = time.time()
    
    if method == 'generator':
        s = "".join(str(i) for i in range(n))
    elif method == 'plusequal':
        s = ""
        for i in range(n):
            s += str(i)
    elif method == 'list':
        lst = []
        for i in range(n):
            lst.append(str(i))
        s = "".join(lst)
    
    elapsed = time.time() - start_time
    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    
    return {
        'method': method,
        'time': elapsed,
        'peak_memory_mb': peak / 1024 / 1024,
        'length': len(s)
    }

# 실행
for method in ['generator', 'plusequal', 'list']:
    result = benchmark_string_concat(1000000, method)
    print(f"{result['method']:12} | "
          f"{result['time']:.4f}초 | "
          f"{result['peak_memory_mb']:.2f}MB")

메모리 효율성 분석

메모리 사용 패턴

python
# Generator: 점진적 메모리 증가
n=1,000,000
시작:    10MB
중간:   310MB
종료:   621MB
패턴:   선형 증가

# += : 매우 낮은 메모리
n=1,000,000
시작:     5MB
중간:    36MB
종료:    67MB
패턴:   최소 유지

# List: 급격한 메모리 증가
n=1,000,000
시작:    15MB
중간:   450MB
종료:   621MB
패턴:   계단식 증가 (리스트 resize)

메모리 제한 환경 전략

python
# 임베디드 시스템 (RAM < 100MB)
def memory_constrained_concat(iterable):
    result = ""
    for item in iterable:
        result += str(item)
        # 주기적으로 메모리 체크
        if len(result) % 10000 == 0:
            # 필요시 중간 저장
            pass
    return result

# 일반 시스템 (RAM > 1GB)
def normal_concat(iterable):
    return "".join(str(item) for item in iterable)

복잡도 비교

시간 복잡도

방법이론적실제 (CPython)이유
s += str(i)O(n²)O(n) ~ O(n log n)refcount 최적화
list + joinO(n)O(n) + overheadappend + join
generator + joinO(n)O(n)C 레벨 최적화

공간 복잡도

방법메모리 사용비고
s += str(i)O(1) ~ O(n)최종 문자열만 유지
list + joinO(n)n개 문자열 + 리스트
generator + joinO(n)최종 문자열 + 임시 버퍼

핵심 교훈

1. 항상 generator를 써라

python
"".join(expression for item in iterable)

2. 작은 데이터로 결론내지 마라

  • n을 충분히 크게 (100만 이상)

3. 이론과 실제는 다르다

  • 언어 구현의 최적화 고려
  • 실측 필수

4. 메모리와 시간은 트레이드오프

  • 상황에 맞는 선택

5. 측정 도구도 오버헤드가 있다

  • tracemalloc, profiler 등

6. 교과서를 의심하라

  • 언어는 진화함
  • 구식 지식 업데이트 필요

의사결정 플로우차트

text
문자열 연결이 필요한가?
    ↓
메모리가 극도로 제한적인가? (< 100MB)
    ↓ Yes                    ↓ No
    ↓                        ↓
s = ""                   s = "".join(
for x in items:              str(x) for x in items
    s += str(x)          )
    ↓                        ↓
35% 느리지만           가장 빠르고
메모리 10배 절약        Pythonic

마무리

이번 실험을 통해 배운 것

  • 이론 ≠ 실제를 체감
  • 규모의 중요성 인식
  • 현대적 Best Practice 발견
  • 측정의 중요성과 함정 이해

가장 큰 배움

"직접 측정하지 않으면 모른다. 그리고 충분히 큰 데이터로 측정해야 의미가 있다."

실전 적용 원칙

  1. 기본은 generator + join
  2. 특수한 경우만 다른 방법
  3. 항상 프로파일링으로 검증
  4. 메모리와 시간 둘 다 측정
  5. 언어 버전과 구현 확인
댓글을 불러오는 중...