공유 참조와 소유권
하나의 가변 게시글을 여러 객체가 공유할 때 발생하는 부수 효과를 재현하고 복사·소유권·불변성 중 적합한 해결책을 선택합니다.
공유 참조는 두 코드 경로가 같은 객체 상태를 읽고 쓸 수 있게 합니다.
변경이 협력의 목적이라면 유용하지만, 한쪽의 지역적 수정이 다른 쪽의 결과까지 바꾸면 사이드 이펙트가 됩니다.
문제의 핵심은 참조를 복사했다는 사실보다 누가 변경 권한을 가지는지 드러나지 않는 데 있습니다.
미리보기 보정과 원본 상태 변경
다음 프로그램은 순위 미리보기용으로 조회 수에 5를 더합니다.
DailyReport와 PreviewService가 같은 MutableEntry를 공유하므로 보고서의 조회 수도 함께 바뀝니다.
public final class SharedEntrySideEffect {
public static void main(String[] args) {
MutableEntry entry = new MutableEntry("object", 40);
DailyReport report = new DailyReport(entry);
PreviewService preview = new PreviewService(entry);
preview.applyPreviewBoost(5);
System.out.println("stored=" + report.totalViewCount());
System.out.println("preview=" + preview.previewViewCount());
}
private static final class MutableEntry {
private final String title;
private int viewCount;
MutableEntry(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
void addViewCount(int extra) { viewCount += extra; }
int viewCount() { return viewCount; }
}
private record DailyReport(MutableEntry entry) {
int totalViewCount() { return entry.viewCount(); }
}
private record PreviewService(MutableEntry entry) {
void applyPreviewBoost(int extra) { entry.addViewCount(extra); }
int previewViewCount() { return entry.viewCount(); }
}
}stored=45
preview=45보고서는 조회 수 40의 원본을 보존해야 했지만 미리보기 보정이 저장 값까지 변경했습니다.
메서드 이름만 보면 PreviewService 내부 계산처럼 보이지만 실제로는 외부가 소유한 객체를 수정합니다.
이런 숨은 쓰기는 호출 순서에 따라 결과를 달라지게 하고 테스트를 서로 간섭시킵니다.
객체 복사와 영향 분리
한쪽이 독립적으로 수정해야 한다면 값이 같은 새 객체를 전달할 수 있습니다.
복사 위치를 조립점에 두면 소유권 전환이 코드에 보입니다.
public final class SeparatedMutableEntries {
public static void main(String[] args) {
MutableEntry stored = new MutableEntry("object", 40);
MutableEntry previewEntry = stored.copy();
DailyReport report = new DailyReport(stored);
PreviewService preview = new PreviewService(previewEntry);
preview.applyPreviewBoost(5);
System.out.println("stored=" + report.totalViewCount());
System.out.println("preview=" + preview.previewViewCount());
}
private static final class MutableEntry {
private final String title;
private int viewCount;
MutableEntry(String title, int viewCount) {
this.title = title;
this.viewCount = viewCount;
}
MutableEntry copy() { return new MutableEntry(title, viewCount); }
void addViewCount(int extra) { viewCount += extra; }
int viewCount() { return viewCount; }
}
private record DailyReport(MutableEntry entry) {
int totalViewCount() { return entry.viewCount(); }
}
private record PreviewService(MutableEntry entry) {
void applyPreviewBoost(int extra) { entry.addViewCount(extra); }
int previewViewCount() { return entry.viewCount(); }
}
}stored=40
preview=45해결은 되었지만 모든 전달 지점에서 복사를 기억해야 합니다.
중첩된 가변 목록까지 독립이어야 한다면 얕은 복사로는 부족합니다.
복사 정책이 여러 곳에 흩어지면 새 필드가 추가됐을 때 일부 복사본이 누락될 수 있습니다.
참조 공유의 언어적 한계
자바는 객체 참조를 다른 변수에 대입하거나 여러 생성자에 전달하는 일을 허용합니다.
특정 클래스의 인스턴스가 공유되지 않도록 컴파일러에 일반 규칙을 선언할 수는 없습니다.
호출자가 같은 객체를 두 번 전달할 수도 있고 컬렉션이 동일 인스턴스를 여러 위치에 보관할 수도 있습니다.
따라서 방어 방향은 세 가지입니다.
- 공유해도 바뀌지 않도록 객체를 불변으로 만듭니다.
- 변경 가능한 객체의 소유자를 하나로 정하고 외부에는 읽기 전용 값만 노출합니다.
- 독립 변경이 필요한 경계에서 명시적으로 복사합니다.
무조건 복사하면 비용과 정체성 문제가 생길 수 있습니다.
회원, 주문처럼 한 실체의 최신 상태를 공유해야 하는 객체는 소유 서비스가 변경을 통제하는 편이 낫습니다.
날짜, 금액, 게시글 스냅숏처럼 값 의미가 강한 객체는 불변 설계가 자연스럽습니다.
Board의 변경 권한
가변 항목을 외부에 그대로 반환하지 않고, Board가 수정 명령을 받아 내부 상태를 관리할 수 있습니다.
조회 결과는 변경 메서드가 없는 불변 View로 변환합니다.
public final class OwnedBoard {
public static void main(String[] args) {
Board board = new Board();
long id = board.add("references", 40);
board.reviseViewCount(id, 55);
EntryView[] snapshot = board.snapshot();
for (EntryView entry : snapshot) System.out.println(entry);
System.out.println("total=" + board.totalViewCount());
}
private record EntryView(long id, String title, int viewCount) { }
private static final class MutableEntry {
private final long id;
private final String title;
private int viewCount;
MutableEntry(long id, String title, int viewCount) {
this.id = id;
this.title = title;
this.viewCount = viewCount;
}
}
private static final class Board {
private final MutableEntry[] entries = new MutableEntry[10];
private int size;
private long nextId = 1;
long add(String title, int viewCount) {
validateViewCount(viewCount);
long id = nextId++;
if (size == entries.length) throw new IllegalStateException("full");
entries[size++] = new MutableEntry(id, title, viewCount);
return id;
}
void reviseViewCount(long id, int viewCount) {
validateViewCount(viewCount);
for (int index = 0; index < size; index++) {
MutableEntry entry = entries[index];
if (entry.id == id) {
entry.viewCount = viewCount;
return;
}
}
throw new IllegalArgumentException("id=" + id);
}
EntryView[] snapshot() {
EntryView[] result = new EntryView[size];
for (int index = 0; index < size; index++) {
MutableEntry entry = entries[index];
result[index] = new EntryView(entry.id, entry.title, entry.viewCount);
}
return result;
}
int totalViewCount() {
int total = 0;
for (int index = 0; index < size; index++) {
total += entries[index].viewCount;
}
return total;
}
private static void validateViewCount(int viewCount) {
if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
}
}
}EntryView[id=1, title=references, viewCount=55]
total=55외부는 MutableEntry 참조를 얻지 못합니다.
변경은 id와 새 값을 받는 Board 메서드에서 검증된 뒤 적용됩니다.
snapshot()은 현재 상태를 새 record 배열로 복제하므로 이후 Board 상태가 바뀌어도 이미 받은 조회 결과는 흔들리지 않습니다.
공유 참조 해결 기준
사이드 이펙트가 보인다고 모든 클래스를 불변으로 바꾸지는 않습니다.
변화의 목적과 관찰 범위를 먼저 적습니다.
- 같은 실체의 최신 상태를 여러 기능이 봐야 하는가?
- 변경 명령을 한 소유 객체로 모을 수 있는가?
- 과거 상태 보존이 업무 요구인가?
- 내부에 배열·목록 같은 가변 자원이 있는가?
- 복사 비용보다 예측 가능성이 더 중요한가?
스냅숏과 설정 값은 불변 값으로, 수명 동안 계속 변하는 집계는 단일 소유자로, 임시 편집본은 명시적 복사로 다루는 식으로 조합할 수 있습니다.
공유 여부가 아니라 공유된 객체에 어떤 변경 권한이 열려 있는지가 판단의 중심입니다.
연습 문제
생성자에 전달된 String[]과 외부 반환값을 바꿔도 내부 태그가 바뀌지 않는 BoardNote를 만드세요.
생성 시점과 조회 시점 모두 배열을 복사해야 합니다.
해설 보기
public final class DefensiveTagCopyExercise {
public static void main(String[] args) {
String[] source = {"java", "object"};
BoardNote note = new BoardNote("sharing", source);
source[0] = "leak";
String[] view = note.tags();
view[1] = "changed";
String[] safe = note.tags();
System.out.println("source=" + source[0] + "," + source[1]);
System.out.println("note=" + safe[0] + "," + safe[1]);
}
private static final class BoardNote {
private final String text;
private final String[] tags;
BoardNote(String text, String[] tags) {
this.text = text;
this.tags = tags.clone();
}
String[] tags() {
return tags.clone();
}
}
}source=leak,object
note=java,object생성자는 입력 배열을 복사하고 tags()도 새 배열을 반환합니다.
문자열 자체는 불변이므로 배열 구조를 양쪽 경계에서 복사하면 외부 대입이 내부 태그에 닿지 않습니다.
요소가 가변 객체라면 요소도 별도로 복사해야 합니다.