Java 17 기준으로 불변 객체가 왜 중요한지, 방어적 복사로 캡슐화를 지키는 방법, record 활용과 얕은/깊은 복사 차이를 실무 관점에서 정리합니다.
도입 (문제 상황)
컬렉션이나 날짜 같은 값을 “그냥 getter로 꺼내줬는데”, 어느 순간부터 데이터가 몰래 바뀌어 버린 경험이 있으실 거예요. 특히 멀티스레드 환경이나 캐시, 이벤트 기반 처리에서는 이런 변경이 재현도 어렵고 원인 파악도 더 어렵습니다. 이럴 때 가장 강력한 방어막이 **불변 객체(Immutable Object)**와 **방어적 복사(Defensive Copy)**입니다.
핵심 개념: Java 불변 객체가 중요한 이유 + 방어적 복사

불변(Immutable)이 주는 실무적 이점
불변 객체는 “한 번 만들어지면 상태가 바뀌지 않는 객체”입니다. 이게 중요한 이유는 이론보다 실무에서 체감이 큽니다.
- 버그 표면적이 줄어듭니다: 객체가 바뀔 수 없으니, “누가 바꿨지?”를 추적하는 비용이 크게 줄어요.
- 스레드 안전성이 좋아집니다: 공유해도 변경 경쟁이 없어서 동기화 부담이 줄어듭니다.
- 캐시/맵 키로 안전합니다:
HashMap키로 쓰이는 객체가 변경되면 해시/equals 일관성이 깨질 수 있는데, 불변이면 이런 사고를 원천 차단합니다. - 도메인 모델이 읽기 쉬워집니다: 값이 변하지 않는다는 전제가 생겨 코드를 이해하기가 훨씬 쉬워요. “이 값은 생성 시 결정된다”는 계약이 생깁니다.
비유하자면, 불변 객체는 “봉인된 계약서”에 가깝습니다. 계약 내용을 누군가 연필로 고쳐 쓰지 못하게 막아두면, 분쟁(버그) 자체가 줄어드는 것과 비슷합니다.
방어적 복사(Defensive Copy)가 필요한 이유
문제는 “내 객체는 불변으로 만들었다고 생각했는데, **필드로 들고 있는 값이 가변(mutable)**이면” 불변이 깨질 수 있다는 점입니다.
대표적으로:
java.util.Date(가변)List,Map같은 컬렉션 (가변)- 배열
T[](가변)
이런 타입을 생성자나 getter에서 그대로 노출하면, 외부에서 참조를 통해 내부 상태를 바꿀 수 있습니다. 그래서 들어올 때(생성자) 한 번 복사하고, **나갈 때(getter)**도 한 번 복사하는 패턴을 씁니다.
얕은 복사 vs 깊은 복사: 언제 문제가 될까?
방어적 복사에서 자주 헷갈리는 지점이 “복사는 했는데 왜 또 바뀌지?”입니다. 그 이유는 얕은 복사/깊은 복사 차이 때문이에요.
| 구분 | 무엇을 복사하나요? | 장점 | 위험/주의점 | 예시 |
|---|---|---|---|---|
| 얕은 복사(Shallow Copy) | 컨테이너(리스트/배열)만 새로 만들고, 내부 요소 참조는 공유 | 빠르고 간단 | 요소가 가변이면 내부 변경이 전파됨 | new ArrayList<>(list) |
| 깊은 복사(Deep Copy) | 컨테이너 + 내부 요소까지 새로 복제 | 진짜 독립 상태 보장 | 비용/복잡도 증가, 요소 복제 규칙 필요 | 요소를 copy()로 복제 |
정리하면, 요소(내부 객체)까지 가변이면 깊은 복사를 고려해야 합니다. 반대로 요소가 String, Integer, record 같은 불변이면 얕은 복사만으로 충분한 경우가 많습니다.
record는 “얕은 불변”을 쉽게 만들어줍니다
Java 16+ (Java 17 LTS 포함)의 record는 “필드 + 생성자 + equals/hashCode/toString”을 값 객체(Value Object) 형태로 빠르게 만들 수 있게 해줍니다.
다만 record는 “필드 재할당이 불가”할 뿐, 필드가 가변 객체면 내부는 여전히 바뀔 수 있습니다.
그래서 record에서도 방어적 복사가 필요할 수 있고, 이때는 compact constructor와 accessor를 활용합니다.
flowchart LR
A["외부에서 생성자에 전달"] --> B["방어적 복사로 내부 저장"]
B --> C["객체 내부 상태 유지"]
C --> D["getter/accessor 호출"]
D --> E["방어적 복사로 외부 반환"]
불변을 지키려면 “들어올 때/나갈 때” 모두 복사하는 흐름이 핵심입니다.
코드 예제: record로 불변 + 방어적 복사 + 얕은/깊은 복사 비교 (Java 17)
아래 코드는 그대로 복붙해서 실행할 수 있는 단일 파일 예제입니다.
BadOrder는 불변처럼 보이지만 내부가 깨지는 케이스GoodOrderShallow는 “컬렉션 컨테이너”는 보호하지만, 요소가 가변이면 여전히 위험한 케이스GoodOrderDeep는 요소까지 복사해서 진짜 불변에 가깝게 만드는 케이스record에서 방어적 복사를 적용하는 방법(compact constructor + accessor override)도 함께 보여줍니다.
import java.util.ArrayList;
import java.util.List;
public class ImmutableDefensiveCopyDemo {
// 가변 요소: 내부 값이 바뀔 수 있음
static final class MutableItem {
private String name;
MutableItem(String name) {
this.name = name;
}
void rename(String newName) {
this.name = newName;
}
String name() {
return name;
}
// 깊은 복사를 위한 복제 메서드(실무에선 copy constructor/팩토리로도 구현)
MutableItem copy() {
return new MutableItem(this.name);
}
@Override
public String toString() {
return "MutableItem{name='%s'}".formatted(name);
}
}
// 1) 나쁜 예: 리스트 참조를 그대로 보관 + 그대로 노출
static final class BadOrder {
private final List<MutableItem> items;
BadOrder(List<MutableItem> items) {
this.items = items; // 방어적 복사 없음
}
List<MutableItem> getItems() {
return items; // 그대로 노출
}
}
// 2) 얕은 방어적 복사: 리스트 컨테이너는 보호하지만, 요소(가변)는 공유
static final class GoodOrderShallow {
private final List<MutableItem> items;
GoodOrderShallow(List<MutableItem> items) {
this.items = List.copyOf(items); // 불변 리스트로 감싸며 얕은 복사
}
List<MutableItem> getItems() {
return items; // 리스트 자체는 불변(구조 변경 불가)
}
}
// 3) 깊은 방어적 복사: 요소까지 복제해서 독립성 보장
static final class GoodOrderDeep {
private final List<MutableItem> items;
GoodOrderDeep(List<MutableItem> items) {
// 핵심: 내부 요소까지 복사
List<MutableItem> copied = new ArrayList<>(items.size());
for (MutableItem item : items) {
copied.add(item.copy());
}
this.items = List.copyOf(copied);
}
List<MutableItem> getItems() {
// 핵심: 나갈 때도 깊은 복사 (외부에서 요소를 바꿔도 내부는 안전)
List<MutableItem> copied = new ArrayList<>(items.size());
for (MutableItem item : items) {
copied.add(item.copy());
}
return List.copyOf(copied);
}
}
// 4) record에서도 방어적 복사가 필요할 수 있음(필드가 가변/컬렉션이면)
record OrderRecordShallow(List<MutableItem> items) {
public OrderRecordShallow {
// record의 compact constructor에서 방어적 복사
items = List.copyOf(items);
}
@Override
public List<MutableItem> items() {
// 얕은 방어적 복사(리스트 구조는 안전하지만 요소는 공유)
return items;
}
}
public static void main(String[] args) {
List<MutableItem> original = new ArrayList<>();
MutableItem apple = new MutableItem("apple");
original.add(apple);
System.out.println("=== BadOrder: 내부가 쉽게 깨짐 ===");
BadOrder bad = new BadOrder(original);
bad.getItems().add(new MutableItem("hacked")); // 외부에서 리스트 구조 변경
System.out.println("bad items = " + bad.getItems());
System.out.println("\n=== GoodOrderShallow: 리스트 구조는 보호, 요소 변경은 전파 ===");
GoodOrderShallow shallow = new GoodOrderShallow(original);
// shallow.getItems().add(...) 는 UnsupportedOperationException (구조 변경 불가)
apple.rename("green-apple"); // 요소를 바꾸면 내부에서도 보임(참조 공유)
System.out.println("shallow items = " + shallow.getItems());
System.out.println("\n=== GoodOrderDeep: 요소 변경도 전파되지 않음 ===");
MutableItem banana = new MutableItem("banana");
List<MutableItem> original2 = List.of(banana);
GoodOrderDeep deep = new GoodOrderDeep(original2);
banana.rename("brown-banana"); // 원본 요소 변경
System.out.println("deep items (after external rename) = " + deep.getItems());
// deep에서 받은 리스트/요소를 바꿔도 deep 내부는 안전
List<MutableItem> got = deep.getItems();
got.get(0).rename("hacked-banana");
System.out.println("deep items (after modifying returned copy) = " + deep.getItems());
System.out.println("\n=== record도 '필드가 가변이면' 얕은 불변 ===");
MutableItem peach = new MutableItem("peach");
OrderRecordShallow rec = new OrderRecordShallow(List.of(peach));
peach.rename("flat-peach");
System.out.println("record items = " + rec.items());
}
}
실무 팁
💡 실무에서는: “불변”의 기준을 먼저 정해두는 게 중요합니다
- 값 객체(예: 돈, 좌표, 기간)는 기본적으로 불변으로 두는 편이 유지보수에 유리합니다.
- 반대로 엔티티(예: 주문 상태 변경)는 변경이 본질이므로, “어디까지 불변으로 할지”를 섞어 쓰게 됩니다. 이때는 값 객체만이라도 불변으로 강제해 보세요. 도메인 모델이 훨씬 단단해집니다.
💡 실무에서는: 컬렉션 방어 전략을 “요소의 불변성” 기준으로 선택해 보세요
- 요소가
String,record, 불변 VO라면:List.copyOf(...)같은 얕은 방어적 복사로 충분한 경우가 많습니다. - 요소가 가변이거나 외부 라이브러리 타입이라면: 깊은 복사 또는 “가변 요소를 불변 타입으로 감싸기(랩핑)”를 고려해 보세요. 깊은 복사는 비용이 있으니, 변경 가능성이 실제로 있는 경로에만 적용하는 것도 방법입니다.
불변 객체는 “변경 가능성” 자체를 제거해 버그를 줄이고, 방어적 복사는 “참조 누수”를 막아 캡슐화를 지켜줍니다.
record는 값 객체를 빠르게 만들지만, 필드가 가변이면 방어적 복사가 여전히 필요합니다.
얕은/깊은 복사는 “요소가 불변인지”를 기준으로 선택해 보세요.
다음 글: [#30 Record, Sealed Class — 모던 Java 신기능]
'JAVA' 카테고리의 다른 글
| Java switch 패턴 매칭 & 향상된 문법 — switch 표현식부터 가드 패턴까지 (0) | 2026.02.27 |
|---|---|
| Java Record와 Sealed Class로 도메인 모델을 단단하게 만들기 (패턴 매칭까지) (0) | 2026.02.27 |
| Java 실무에서 자주 쓰는 디자인 패턴 5가지 (Strategy, Factory, Builder, Singleton, Observer) (0) | 2026.02.26 |
| Java SOLID 원칙 — 코드로 이해하기 (Before/After 예제와 과도한 적용의 함정) (0) | 2026.02.25 |
| Java 모던 동시성 — ExecutorService & CompletableFuture로 스레드 풀과 비동기 처리 정리 (0) | 2026.02.25 |