문자열 불변성과 메모리 재할당
이 토픽을 마치면
Python 문자열이 왜 불변(immutable)인지 이해하고, 문자열 연산이 메모리에서 어떻게 동작하는지 알며, 대량 문자열 처리 시 성능 함정을 피할 수 있습니다.
문자열은 수정할 수 없다
name = "Hello"name[0] = "h"# TypeError: 'str' object does not support item assignmentPython의 문자열은 불변(immutable) 객체입니다. 한 번 생성되면 내용을 바꿀 수 없습니다. "수정"처럼 보이는 모든 연산은 실제로 새 문자열을 생성합니다.
name = "Hello"print(id(name)) # 4385123456
name = name.lower()print(id(name)) # 4385123520 ← 다른 객체!id()는 객체의 메모리 주소를 반환합니다. lower() 후에 id가 바뀌었으므로, 기존 문자열을 수정한 것이 아니라 새 문자열을 만든 것입니다.
가변 vs 불변
| 타입 | 변경 가능 | 예시 |
|---|---|---|
str | 불변 | "hello" |
tuple | 불변 | (1, 2, 3) |
int | 불변 | 42 |
float | 불변 | 3.14 |
list | 가변 | [1, 2, 3] |
dict | 가변 | {"a": 1} |
set | 가변 | {1, 2, 3} |
# 리스트: 가변 — 내용을 직접 수정 가능items = [1, 2, 3]items[0] = 99print(items) # [99, 2, 3] — 같은 객체, 내용만 변경
# 문자열: 불변 — 수정 불가, 새 객체 생성text = "hello"new_text = text.replace("h", "H")print(text) # "hello" — 원본 그대로print(new_text) # "Hello" — 새 객체왜 불변으로 설계했는가
1. 해시 가능 — 딕셔너리 키로 사용
# 문자열은 딕셔너리 키로 사용 가능 (불변이므로)scores = {"alice": 90, "bob": 85}
# 리스트는 딕셔너리 키로 사용 불가 (가변이므로)# scores = {[1, 2]: "value"} # TypeError: unhashable type: 'list'딕셔너리는 키의 해시값으로 데이터를 찾습니다. 키가 변경되면 해시값도 변하므로, 데이터를 찾을 수 없게 됩니다. 불변 객체만 해시 가능합니다.
2. 안전성 — 공유해도 괜찮음
def greet(name): greeting = "Hello, " + name return greeting
original = "World"result = greet(original)print(original) # "World" — 함수가 original을 수정할 수 없음함수에 문자열을 전달해도, 함수 안에서 원본이 바뀔 걱정이 없습니다. 리스트는 함수 안에서 .append()로 원본이 수정될 수 있습니다.
3. 스레드 안전성
여러 스레드가 같은 문자열을 동시에 읽어도, 아무도 수정할 수 없으므로 락(lock)이 필요 없습니다.
문자열 인터닝
Python은 자주 쓰는 문자열을 재사용합니다. 이것을 인터닝(interning)이라고 합니다.
a = "hello"b = "hello"print(a is b) # True — 같은 객체를 공유!print(id(a) == id(b)) # True
c = "hello world!"d = "hello world!"print(c is d) # False or True — 구현에 따라 다름짧고 단순한 문자열(식별자 형태)은 인터닝됩니다. 같은 내용의 문자열이 여러 번 등장해도 메모리에 하나만 존재합니다. 이것은 불변이기 때문에 가능합니다 — 공유해도 누군가 수정할 수 없으니까요.
# is vs ==a = "hello"b = "hello"print(a == b) # True — 값이 같은가?print(a is b) # True — 같은 객체인가? (인터닝 때문)
# 문자열 비교는 항상 == 사용. is는 예측 불가능성능 함정 — 문자열 연결
# Bad: 루프에서 문자열 연결 — O(n²)result = ""for i in range(10000): result += str(i) + "," # 매번 새 문자열 생성!매 반복마다 새 문자열을 만들고, 이전 내용을 복사합니다. 10,000번 반복하면 약 5천만 번의 문자 복사가 발생합니다 (1 + 2 + 3 + ... + 10000).
# Good: join 사용 — O(n)result = ",".join(str(i) for i in range(10000))join()은 최종 크기를 미리 계산하고, 메모리를 한 번만 할당합니다. 복사 횟수가 O(n)으로 줄어듭니다.
성능 비교
import time
# 방법 1: += (느림)start = time.time()result = ""for i in range(100000): result += str(i)print(f"+=: {time.time() - start:.3f}s")
# 방법 2: join (빠름)start = time.time()result = "".join(str(i) for i in range(100000))print(f"join: {time.time() - start:.3f}s")
# 방법 3: io.StringIO (대량 텍스트)import iostart = time.time()buf = io.StringIO()for i in range(100000): buf.write(str(i))result = buf.getvalue()print(f"StringIO: {time.time() - start:.3f}s")
# 결과 예시:# +=: 0.852s# join: 0.031s (27배 빠름)# StringIO: 0.029s실전 패턴
문자열 "수정" 방법들
text = "Hello, World!"
# 대소문자text.upper() # "HELLO, WORLD!"text.lower() # "hello, world!"text.title() # "Hello, World!"
# 교체text.replace("World", "Python") # "Hello, Python!"
# 문자 단위 수정 (리스트 경유)chars = list(text) # ['H', 'e', 'l', 'l', 'o', ...]chars[0] = 'h'result = "".join(chars) # "hello, World!"모든 메서드가 새 문자열을 반환합니다. 원본은 변하지 않습니다.
f-string 조합
name = "Hoon"age = 30greeting = f"Hello, {name}! You are {age} years old."f-string은 내부적으로 한 번에 최종 문자열을 만듭니다. +로 여러 번 연결하는 것보다 효율적이고 가독성도 좋습니다.
다른 언어와의 비교
// Java — String은 불변, StringBuilder는 가변
String s = "Hello";
s = s + " World"; // 새 String 객체 생성
StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // 같은 객체에서 수정 (가변)
String result = sb.toString();// JavaScript — 문자열은 불변 (Python과 동일)
let s = "Hello";
s[0] = "h"; // 에러는 안 나지만 무시됨
console.log(s); // "Hello" (변경 안 됨)// C — char 배열은 가변
char s[] = "Hello";
s[0] = 'h'; // OK — "hello"로 변경됨대부분의 현대 언어(Python, Java, JavaScript, Go, C#)에서 문자열은 불변입니다. 이것은 안전성과 최적화를 위한 공통된 설계 결정입니다. Java의 StringBuilder, Go의 strings.Builder는 Python의 join()이나 io.StringIO와 같은 역할 — 가변 버퍼에서 조립한 뒤 최종 불변 문자열을 만드는 패턴입니다.
핵심 정리
| 개념 | 정리 |
|---|---|
| 불변(immutable) | 생성 후 내용 변경 불가. 모든 "수정"은 새 객체 생성 |
id() | 객체의 메모리 주소 확인 |
| 인터닝 | 같은 문자열은 메모리에서 재사용 (불변이라 가능) |
join() | 문자열 연결의 올바른 방법. += 루프는 O(n²) |
is vs == | is는 객체 동일성, ==는 값 동등성. 문자열은 == 사용 |
문자열 불변성은 Python의 설계 결정입니다. 딕셔너리 키로 사용 가능, 함수 간 안전한 전달, 인터닝을 통한 메모리 최적화 — 이 이점들이 "수정할 수 없다"는 제약을 상쇄합니다. 실무에서 기억할 것은 하나: 루프에서 +=로 문자열을 쌓지 말고 join()을 쓸 것.