불변 객체와 방어적 복사: 얕은 복사와 깊은 복사의 차이
들어가며
설정 객체를 함수에 넘겼을 뿐인데, 호출이 끝나고 보니 원본에 없던 필드가 생겨 있습니다. 복사본을 만들어 고쳤는데 원본까지 함께 바뀌는 일도 흔합니다.
둘 다 같은 객체를 여러 곳에서 함께 쓰고 있다는 사실을 놓쳐서 생기는 문제입니다. 이번 글에서는 참조 전달에서 시작해 얕은 복사와 깊은 복사, 불변 객체, 구조 공유까지 정리합니다.
1. 객체를 넘기면 참조가 갑니다
파이썬, 자바스크립트, 자바에서 객체를 함수에 넘기면 객체 전체가 복사되지 않습니다. 같은 객체를 가리키는 참조가 넘어갑니다. 그래서 함수 안에서 필드를 고치면 호출한 쪽의 객체가 바뀝니다.
const base = { retry: 3 };
function withTimeout(config) {
config.timeout = 30; // 받은 객체에 바로 적습니다
return config;
}
withTimeout(base);
console.log(base); // { retry: 3, timeout: 30 }호출한 쪽은 base 가 바뀔 거라고 예상하지 못합니다. 같은 base 를 다른 모듈이 쓰고 있다면 전혀 상관없는 곳에서 값이 달라집니다.
이를 막는 기법이 방어적 복사(defensive copy) 입니다. 함수나 모듈의 경계에서 복사본을 떠서, 고치는 일은 복사본에만 합니다.
function withTimeout(config) {
return { ...config, timeout: 30 }; // 새 객체를 만들어 돌려줍니다
}방어적 복사는 빌린 책에 바로 쓰지 않고 복사본에 메모하는 일이에요.
호출자가 모르는 사이 base 에 timeout 이 생긴다.
base 에는 retry 만 그대로 남는다.
방어적 복사가 필요한 경계는 두 곳입니다. 받은 객체를 그대로 필드에 담아 두면 넘긴 쪽이 나중에 고칠 수 있고, 내부 리스트를 getter 로 그대로 내주면 받은 쪽이 내부 상태를 고칠 수 있습니다.
2. 얕은 복사와 깊은 복사
위 예제의 전개 문법은 얕은 복사(shallow copy) 입니다. 겉 객체만 새로 만들고 안쪽 객체는 원본과 함께 씁니다. 파이썬의 dict(a), a.copy() 도 마찬가지입니다.
깊은 복사(deep copy) 는 안쪽 객체까지 전부 새로 만듭니다. 파이썬은 copy.deepcopy, 자바스크립트는 structuredClone 을 씁니다.
import copy
a = {"tags": ["x"]}
b = dict(a) # 얕은 복사
b["tags"].append("y")
print(a["tags"]) # ['x', 'y'] 원본도 바뀝니다
c = copy.deepcopy(a) # 깊은 복사
c["tags"].append("z")
print(a["tags"]) # ['x', 'y'] 그대로입니다표지만 새로 입힌 책은 속지가 같아서 한쪽에 쓴 메모가 다른 쪽에도 보여요.
고르는 기준은 데이터 모양과 크기입니다. 중첩이 없거나 안쪽을 고치지 않는다면 얕은 복사로 충분합니다. 안쪽까지 고쳐야 하면 깊은 복사를 쓰되, 객체 크기만큼 시간과 메모리가 든다는 점을 계산에 넣습니다.
JSON.parse(JSON.stringify(x)) 는 완전한 깊은 복사가 아닙니다. Date 는 문자열이 되고, 객체의 undefined 와 함수 필드는 빠지며, Map 과 Set 은 빈 객체가 됩니다. 순환 참조가 있으면 예외가 납니다. structuredClone 은 이런 타입을 대부분 지키지만, 함수가 들어 있으면 예외를 던지고 클래스 인스턴스의 프로토타입은 유지하지 않습니다.
3. 불변 객체와 얕은 동결
복사가 경계에서 하는 대응이라면, 불변 객체(immutable object) 는 만든 뒤 상태가 바뀌지 않게 하는 설계입니다. 파이썬의 str 과 자바의 String 이 대표적이고, 값을 바꾸는 듯한 연산은 새 객체를 돌려줍니다. 아무도 고칠 수 없으니 여러 곳에서 함께 봐도 안전합니다.
문제는 언어가 제공하는 동결 수단 대부분이 겉만 잠근다는 점입니다. 이를 얕은 동결이라고 부릅니다.
자물쇠를 바깥 문에만 달면 방 안 서랍은 여전히 열려 있어요.
| 수단 | 변수 재대입 | 겉 필드 수정 | 안쪽 값 수정 |
|---|---|---|---|
| constJS 변수 | |||
| freezeJS 객체 | 무관 | ||
| tuplePython | 무관 | 안에 든 list | |
| frozendataclass | 무관 | tags.append | |
| 재귀 동결직접 구현 | 무관 |
from dataclasses import dataclass
@dataclass(frozen=True)
class Job:
name: str
tags: list
job = Job("build", ["x"])
job.tags.append("y") # 통과합니다. 필드 재대입만 막습니다
job.name = "deploy" # FrozenInstanceError안쪽까지 지키려면 list 대신 tuple 처럼 불변 타입을 담습니다. 자바스크립트에서는 객체를 재귀로 돌면서 Object.freeze 를 거는 방법을 씁니다.
Object.freeze 된 객체에 값을 쓰면 strict mode 에서는 TypeError 가 나고, 그 밖에서는 조용히 무시됩니다. ES 모듈과 클래스 본문은 자동으로 strict mode 입니다.
4. 구조 공유로 복사 비용 줄이기
불변으로 설계하면 값을 바꿀 때마다 새 객체가 필요합니다. 그때마다 전체를 깊은 복사하면 비용이 너무 큽니다. 구조 공유(structural sharing) 는 바뀐 경로만 새로 만들고 나머지는 옛 객체를 그대로 참조합니다.
const state = {
user: { name: "Ann", prefs: { theme: "dark" } },
items: [1, 2, 3],
};
const next = { ...state, user: { ...state.user, name: "Bob" } };
next.user === state.user; // false 새 객체
next.user.prefs === state.user.prefs; // true 공유
next.items === state.items; // true 공유개정판을 낼 때 고친 쪽만 새로 찍고 나머지 쪽은 그대로 쓰는 일이에요.
공유한 부분을 아무도 고치지 않는다는 약속이 있어야 이 방식이 성립합니다. 대신 얻는 것도 있습니다. 참조만 비교해도 그 아래가 바뀌었는지 알 수 있어서, React 의 상태 갱신이 이 방식을 씁니다. 중첩이 깊어지면 Immer 같은 라이브러리가 경로 복사를 대신해 줍니다.
헷갈리기 쉬운 점
const는 불변이 아닙니다. 변수 재대입만 막습니다.const로 선언한 객체의 필드는 그대로 바꿀 수 있습니다.Object.freeze와tuple은 겉만 잠급니다.tuple안에 든 리스트에는append가 됩니다.- 전개 문법과
slice는 한 단계만 복사합니다. 중첩된 객체는 원본과 함께 씁니다. - JSON 왕복 복사는 순수 JSON 데이터에서만 안전합니다. 타입이 바뀌거나 필드가 빠져도 오류가 나지 않아 한참 뒤에 발견됩니다.
면접에서 자주 받는 질문
얕은 복사와 깊은 복사는 어떻게 고르나요?
얕은 복사는 안쪽 객체를 공유하고, 깊은 복사는 안쪽까지 새로 만듭니다. 안쪽을 고칠 일이 없으면 얕은 복사로 충분하고, 고쳐야 하면 깊은 복사를 씁니다. 깊은 복사는 비용이 크므로 고칠 범위만 복사하거나 불변으로 설계하는 방법을 먼저 검토합니다.
파이썬 기본 인자에 빈 리스트를 두면 왜 위험한가요?
기본값은 함수를 정의할 때 한 번만 만들어집니다. 호출끼리 같은 리스트를 공유하므로 값이 계속 쌓입니다. 기본값을 None 으로 두고 함수 안에서 새 리스트를 만듭니다.
불변 객체는 왜 스레드에 안전한가요?
쓰기가 없으니 여러 스레드가 락 없이 함께 읽을 수 있습니다. 다만 새 객체를 만들어 참조를 바꾸는 지점은 별도 동기화가 필요할 수 있습니다. 불변이 지켜 주는 것은 객체 내용이지 그 객체를 가리키는 변수가 아닙니다.
복사 비용이 크면 어떻게 하나요?
구조 공유를 쓰는 라이브러리로 바뀐 부분만 새로 만듭니다. 원본에 계산값을 붙이는 것이 목적이라면, 원본은 두고 별도 맵에 계산값을 담는 방법도 있습니다.
정리
- 객체를 넘기면 참조가 갑니다. 받은 쪽이 고치면 넘긴 쪽의 객체가 바뀝니다.
- 얕은 복사는 안쪽을 공유하고, 깊은 복사는 안쪽까지 새로 만드는 대신 크기만큼 비용을 씁니다.
const,Object.freeze,tuple은 겉만 잠급니다. 안쪽까지 지키려면 불변 타입으로 담습니다.
남이 준 데이터에는 적지 않고, 겉만 새것인 복사본의 안쪽은 아직 남의 것이라는 점만 기억해 두면 이런 버그는 대부분 피할 수 있습니다.