자바를 공부하다 보면 객체를 여러 변수에서 함께 가리키는 상황을 자주 만납니다.
처음에는 별문제 없어 보입니다. 하지만 객체 안의 값을 바꾸는 순간 예상하지 못한 곳까지 함께 바뀌는 일이 생길 수 있습니다.
이런 문제를 이해하려면 먼저 기본형과 참조형의 차이, 그리고 공유 참조를 알아야 합니다. 그다음에 왜 불변 객체가 필요한지 자연스럽게 이해할 수 있습니다.
이번 글에서는 불변 객체를 다음 흐름으로 정리합니다.
- 기본형과 참조형의 공유 차이
- 공유 참조가 만드는 사이드 이펙트
- 불변 객체가 사이드 이펙트를 막는 방식
- 불변 객체에서 값을 변경하는 패턴
withXxx()메서드가 의미하는 것String,Integer,LocalDate가 불변인 이유
기본형은 값을 복사합니다
자바의 데이터 타입은 크게 기본형과 참조형으로 나눌 수 있습니다.
기본형은 int, long, double, boolean처럼 값을 직접 다루는 타입입니다.
int a = 10;
int b = a;
System.out.println("a = " + a);
System.out.println("b = " + b);
b = 20;
System.out.println("a = " + a);
System.out.println("b = " + b);
실행 결과는 다음과 같습니다.
a = 10
b = 10
a = 10
b = 20
b = a를 실행하면 a가 가진 값 10이 b로 복사됩니다.
이후 b를 20으로 바꿔도 a는 그대로 10입니다.
기본형은 값을 복사하기 때문에 두 변수가 같은 값을 가지더라도 서로 영향을 주지 않습니다.
a ── 10
b ── 10
b를 20으로 바꾼 뒤
a ── 10
b ── 20
참조형은 객체의 위치를 복사합니다
참조형은 객체 자체가 아니라 객체를 가리키는 참조값을 변수에 담습니다.
예를 들어 배송지를 표현하는 클래스를 만들어보겠습니다.
class DeliveryAddress {
private String city;
public DeliveryAddress(String city) {
this.city = city;
}
public void changeCity(String city) {
this.city = city;
}
public String getCity() {
return city;
}
@Override
public String toString() {
return "DeliveryAddress{city='" + city + "'}";
}
}
이제 같은 객체를 두 변수가 함께 가리키게 해보겠습니다.
DeliveryAddress addressA = new DeliveryAddress("서울");
DeliveryAddress addressB = addressA;
System.out.println("addressA = " + addressA);
System.out.println("addressB = " + addressB);
addressB.changeCity("부산");
System.out.println("addressA = " + addressA);
System.out.println("addressB = " + addressB);
실행 결과는 다음과 같습니다.
addressA = DeliveryAddress{city='서울'}
addressB = DeliveryAddress{city='서울'}
addressA = DeliveryAddress{city='부산'}
addressB = DeliveryAddress{city='부산'}
addressB만 바꾼 것처럼 보이는데 addressA도 함께 바뀌었습니다.
이유는 두 변수가 같은 객체를 가리키기 때문입니다.
addressA ─┐
├─ DeliveryAddress{city='서울'}
addressB ─┘
addressB.changeCity("부산") 호출 후
addressA ─┐
├─ DeliveryAddress{city='부산'}
addressB ─┘
참조형에서 대입은 객체 전체를 복사하는 것이 아닙니다. 객체를 가리키는 참조값을 복사합니다.
그래서 여러 변수가 같은 객체를 함께 가리킬 수 있습니다.
공유 참조란?
공유 참조는 둘 이상의 변수가 같은 객체를 가리키는 상황입니다.
DeliveryAddress addressA = new DeliveryAddress("서울");
DeliveryAddress addressB = addressA;
위 코드에서 addressA와 addressB는 같은 DeliveryAddress 객체를 가리킵니다.
공유 참조 자체가 항상 나쁜 것은 아닙니다. 하나의 객체를 여러 곳에서 함께 사용해야 하는 경우도 많습니다.
문제는 공유된 객체의 내부 상태를 바꿀 수 있을 때 생깁니다.
한쪽에서 값을 바꿨는데, 같은 객체를 보고 있던 다른 쪽도 영향을 받습니다. 이 영향이 의도한 것이라면 괜찮지만, 의도하지 않았다면 버그가 됩니다.
사이드 이펙트란?
사이드 이펙트는 내가 의도한 직접 결과 외에 다른 곳에서 생기는 부수 효과를 말합니다.
예를 들어 addressB의 도시만 바꾸고 싶었습니다.
addressB.changeCity("부산");
그런데 addressA가 보는 도시까지 바뀌었습니다.
이때 addressA가 함께 바뀐 현상을 사이드 이펙트라고 볼 수 있습니다.
원래 의도
addressB만 부산으로 변경
실제 결과
addressA도 부산으로 보임
객체가 공유되고, 그 객체의 값을 바꿀 수 있으면 이런 일이 생길 수 있습니다.
객체를 따로 만들면 해결될까?
가장 단순한 해결책은 같은 객체를 공유하지 않는 것입니다.
DeliveryAddress addressA = new DeliveryAddress("서울");
DeliveryAddress addressB = new DeliveryAddress("서울");
addressB.changeCity("부산");
System.out.println("addressA = " + addressA);
System.out.println("addressB = " + addressB);
실행 결과는 다음과 같습니다.
addressA = DeliveryAddress{city='서울'}
addressB = DeliveryAddress{city='부산'}
이번에는 addressA와 addressB가 서로 다른 객체를 가리킵니다.
addressA ── DeliveryAddress{city='서울'}
addressB ── DeliveryAddress{city='부산'}
따라서 addressB를 바꿔도 addressA는 영향을 받지 않습니다.
하지만 이 방식만으로는 충분하지 않습니다.
참조형 변수는 언제든 같은 객체를 공유할 수 있습니다. 메서드 인자로 객체를 넘길 수도 있고, 생성자에서 같은 객체를 받을 수도 있습니다.
DeliveryAddress address = new DeliveryAddress("서울");
Member memberA = new Member("회원A", address);
Member memberB = new Member("회원B", address);
객체를 공유할 가능성을 완전히 없애기는 어렵습니다. 그래서 더 근본적인 해결책이 필요합니다.
진짜 원인은 값을 바꿀 수 있다는 점입니다
공유 참조가 문제처럼 보이지만, 더 직접적인 원인은 객체의 상태를 바꿀 수 있다는 점입니다.
같은 객체를 여러 곳에서 함께 가리키더라도 객체의 값이 절대 바뀌지 않는다면 사이드 이펙트가 생기기 어렵습니다.
공유 참조 + 변경 가능한 객체 = 사이드 이펙트 가능
공유 참조 + 변경 불가능한 객체 = 안전하게 공유 가능
그래서 등장하는 개념이 불변 객체입니다.
불변 객체란?
불변 객체는 한 번 생성된 뒤 내부 상태가 바뀌지 않는 객체입니다.
객체의 필드 값을 나중에 바꿀 수 없게 만들면 됩니다.
class ImmutableDeliveryAddress {
private final String city;
public ImmutableDeliveryAddress(String city) {
this.city = city;
}
public String getCity() {
return city;
}
@Override
public String toString() {
return "ImmutableDeliveryAddress{city='" + city + "'}";
}
}
불변 객체를 만들 때 중요한 점은 다음과 같습니다.
| 기준 | 설명 |
|---|---|
필드를 final로 둠 | 생성 후 다른 값으로 바꾸지 못하게 합니다. |
| setter를 만들지 않음 | 외부에서 값을 바꾸는 통로를 막습니다. |
| 생성자에서 값을 정함 | 객체 생성 시점에 필요한 값을 모두 넣습니다. |
이제 같은 객체를 공유해도 내부 값을 바꿀 방법이 없습니다.
ImmutableDeliveryAddress addressA = new ImmutableDeliveryAddress("서울");
ImmutableDeliveryAddress addressB = addressA;
System.out.println("addressA = " + addressA);
System.out.println("addressB = " + addressB);
addressA와 addressB는 같은 객체를 가리키지만, 이 객체의 도시는 바꿀 수 없습니다.
도시를 바꾸고 싶다면 기존 객체를 수정하는 것이 아니라 새 객체를 만들어야 합니다.
addressB = new ImmutableDeliveryAddress("부산");
System.out.println("addressA = " + addressA);
System.out.println("addressB = " + addressB);
실행 결과는 다음과 같습니다.
addressA = ImmutableDeliveryAddress{city='서울'}
addressB = ImmutableDeliveryAddress{city='부산'}
기존 addressA는 그대로입니다.
addressB만 새 객체를 가리키게 됩니다.
불변 객체는 공유해도 안전합니다
불변 객체의 핵심 장점은 공유해도 안전하다는 점입니다.
addressA ─┐
├─ ImmutableDeliveryAddress{city='서울'}
addressB ─┘
두 변수가 같은 객체를 가리켜도 괜찮습니다. 누구도 이 객체의 값을 바꿀 수 없기 때문입니다.
만약 다른 값이 필요하면 새 객체를 만들어 참조를 바꿉니다.
addressA ── ImmutableDeliveryAddress{city='서울'}
addressB ── ImmutableDeliveryAddress{city='부산'}
이 방식은 처음에는 객체를 더 많이 만드는 것처럼 보입니다. 하지만 예측 가능한 코드를 만드는 데 큰 도움이 됩니다.
회원과 배송지 예시로 보기
조금 더 실제 상황에 가까운 예시를 보겠습니다.
회원 두 명이 같은 배송지 객체를 공유한다고 해보겠습니다.
class Member {
private String name;
private DeliveryAddress address;
public Member(String name, DeliveryAddress address) {
this.name = name;
this.address = address;
}
public DeliveryAddress getAddress() {
return address;
}
public void changeAddress(DeliveryAddress address) {
this.address = address;
}
@Override
public String toString() {
return "Member{name='" + name + "', address=" + address + "}";
}
}
다음 코드를 보겠습니다.
DeliveryAddress address = new DeliveryAddress("서울");
Member memberA = new Member("회원A", address);
Member memberB = new Member("회원B", address);
memberB.getAddress().changeCity("부산");
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
실행 결과는 다음과 같습니다.
memberA = Member{name='회원A', address=DeliveryAddress{city='부산'}}
memberB = Member{name='회원B', address=DeliveryAddress{city='부산'}}
memberB의 배송지만 바꾸고 싶었는데 memberA의 배송지도 바뀌었습니다.
두 회원이 같은 변경 가능한 배송지 객체를 공유했기 때문입니다.
불변 배송지를 사용하면 어떻게 달라질까?
이번에는 회원이 불변 배송지를 사용하게 해보겠습니다.
class SafeMember {
private String name;
private ImmutableDeliveryAddress address;
public SafeMember(String name, ImmutableDeliveryAddress address) {
this.name = name;
this.address = address;
}
public ImmutableDeliveryAddress getAddress() {
return address;
}
public void changeAddress(ImmutableDeliveryAddress address) {
this.address = address;
}
@Override
public String toString() {
return "SafeMember{name='" + name + "', address=" + address + "}";
}
}
사용 코드는 다음과 같습니다.
ImmutableDeliveryAddress address = new ImmutableDeliveryAddress("서울");
SafeMember memberA = new SafeMember("회원A", address);
SafeMember memberB = new SafeMember("회원B", address);
memberB.changeAddress(new ImmutableDeliveryAddress("부산"));
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
실행 결과는 다음과 같습니다.
memberA = SafeMember{name='회원A', address=ImmutableDeliveryAddress{city='서울'}}
memberB = SafeMember{name='회원B', address=ImmutableDeliveryAddress{city='부산'}}
이번에는 memberA가 영향을 받지 않습니다.
ImmutableDeliveryAddress는 내부 값을 바꿀 수 없기 때문에, memberB의 배송지를 바꾸려면 새 객체를 넣어야 합니다.
그 결과 두 회원은 서로 다른 배송지 객체를 보게 됩니다.
불변 객체에서 값은 어떻게 변경할까?
불변 객체는 값을 바꿀 수 없습니다.
그렇다면 계산 결과가 필요한 경우에는 어떻게 해야 할까요? 기존 객체를 수정하지 말고, 계산 결과를 담은 새 객체를 반환하면 됩니다.
먼저 변경 가능한 포인트 객체를 보겠습니다.
class MutablePoint {
private int value;
public MutablePoint(int value) {
this.value = value;
}
public void add(int amount) {
value += amount;
}
public int getValue() {
return value;
}
}
사용 코드는 다음과 같습니다.
MutablePoint point = new MutablePoint(10);
point.add(20);
System.out.println(point.getValue());
실행 결과는 다음과 같습니다.
30
기존 객체의 값이 10에서 30으로 바뀌었습니다.
불변 객체는 새 객체를 반환합니다
이번에는 불변 포인트 객체를 만들어보겠습니다.
class ImmutablePoint {
private final int value;
public ImmutablePoint(int value) {
this.value = value;
}
public ImmutablePoint add(int amount) {
return new ImmutablePoint(value + amount);
}
public int getValue() {
return value;
}
}
add()는 기존 객체의 값을 바꾸지 않습니다.
대신 계산 결과를 담은 새 ImmutablePoint를 반환합니다.
ImmutablePoint point1 = new ImmutablePoint(10);
ImmutablePoint point2 = point1.add(20);
System.out.println("point1 = " + point1.getValue());
System.out.println("point2 = " + point2.getValue());
실행 결과는 다음과 같습니다.
point1 = 10
point2 = 30
point1은 그대로 10입니다.
point2가 새 결과인 30을 가집니다.
반환값을 놓치면 아무 일도 일어나지 않은 것처럼 보입니다
불변 객체에서 자주 하는 실수가 있습니다. 새 객체를 반환하는 메서드를 호출하고, 그 반환값을 받지 않는 것입니다.
ImmutablePoint point = new ImmutablePoint(10);
point.add(20);
System.out.println(point.getValue());
실행 결과는 다음과 같습니다.
10
add(20)이 실패한 것이 아닙니다.
새 ImmutablePoint(30) 객체가 만들어졌지만, 그 참조를 아무 변수에도 담지 않았기 때문에 사용할 수 없는 상태가 된 것입니다.
불변 객체의 변경 메서드는 보통 이렇게 사용합니다.
point = point.add(20);
또는 원본과 결과를 둘 다 보관합니다.
ImmutablePoint before = new ImmutablePoint(10);
ImmutablePoint after = before.add(20);
날짜 객체를 불변으로 바꾸기
날짜는 불변 객체 예시로 이해하기 좋습니다.
먼저 변경 가능한 날짜 클래스를 보겠습니다.
class MyDate {
private int year;
private int month;
private int day;
public MyDate(int year, int month, int day) {
this.year = year;
this.month = month;
this.day = day;
}
public void setYear(int year) {
this.year = year;
}
public void setMonth(int month) {
this.month = month;
}
public void setDay(int day) {
this.day = day;
}
@Override
public String toString() {
return year + "-" + month + "-" + day;
}
}
이 객체를 공유하면 한쪽 변경이 다른 쪽에도 보입니다.
MyDate date1 = new MyDate(2026, 7, 24);
MyDate date2 = date1;
date1.setYear(2027);
System.out.println("date1 = " + date1);
System.out.println("date2 = " + date2);
실행 결과는 다음과 같습니다.
date1 = 2027-7-24
date2 = 2027-7-24
date1만 바꾸려 했지만 date2도 같은 객체를 보므로 함께 바뀝니다.
withXxx 패턴
불변 날짜 객체는 setter를 만들지 않습니다. 대신 일부 값만 바꾼 새 객체를 반환하는 메서드를 둡니다.
class ImmutableMyDate {
private final int year;
private final int month;
private final int day;
public ImmutableMyDate(int year, int month, int day) {
this.year = year;
this.month = month;
this.day = day;
}
public ImmutableMyDate withYear(int newYear) {
return new ImmutableMyDate(newYear, month, day);
}
public ImmutableMyDate withMonth(int newMonth) {
return new ImmutableMyDate(year, newMonth, day);
}
public ImmutableMyDate withDay(int newDay) {
return new ImmutableMyDate(year, month, newDay);
}
@Override
public String toString() {
return year + "-" + month + "-" + day;
}
}
사용 코드는 다음과 같습니다.
ImmutableMyDate date1 = new ImmutableMyDate(2026, 7, 24);
ImmutableMyDate date2 = date1;
date1 = date1.withYear(2027);
System.out.println("date1 = " + date1);
System.out.println("date2 = " + date2);
실행 결과는 다음과 같습니다.
date1 = 2027-7-24
date2 = 2026-7-24
withYear()는 기존 객체의 year를 바꾸지 않습니다.
year만 다른 새 객체를 만들어 반환합니다.
with는 “기존 값을 바탕으로 일부 조건을 더한 새 객체”라는 느낌으로 이해하면 좋습니다.
date1.withYear(2027)
= 기존 date1의 month, day는 유지하고 year만 2027인 새 날짜
불변 객체를 만들 때 주의할 점
불변 객체를 만들 때 단순히 필드에 final만 붙이면 끝이라고 생각하면 위험합니다.
필드가 기본형이나 String처럼 이미 불변인 타입이면 비교적 단순합니다.
class UserName {
private final String value;
public UserName(String value) {
this.value = value;
}
}
하지만 필드가 변경 가능한 객체라면 더 조심해야 합니다.
class Team {
private final List<String> members;
public Team(List<String> members) {
this.members = members;
}
}
위 코드에서 members 변수 자체는 다른 리스트로 바뀌지 않습니다.
하지만 리스트 안의 내용은 바뀔 수 있습니다.
이 경우에는 방어적 복사나 수정 불가능한 컬렉션 같은 추가 처리가 필요합니다. 이 글에서는 기본 원리를 다루기 때문에, 우선은 “객체가 가진 내부 상태를 바꿀 통로를 막아야 한다” 정도로 기억하면 됩니다.
불변 객체가 필요한 이유
불변 객체는 제약이 있습니다. 값을 바꾸고 싶을 때마다 새 객체를 만들어야 합니다.
하지만 그 제약 덕분에 얻는 장점이 큽니다.
| 장점 | 설명 |
|---|---|
| 사이드 이펙트 감소 | 공유된 객체가 갑자기 바뀌는 일을 막을 수 있습니다. |
| 코드 추론이 쉬움 | 한 번 만든 값이 바뀌지 않으므로 흐름을 따라가기 쉽습니다. |
| 안전한 공유 | 여러 곳에서 같은 객체를 사용해도 값 변경 걱정이 줄어듭니다. |
| 동시성에 유리 | 여러 스레드가 함께 읽어도 값이 변하지 않아 안전성이 높습니다. |
| 값 타입에 적합 | 날짜, 금액, 이름처럼 하나의 값으로 보는 객체에 잘 맞습니다. |
불변 객체는 “무조건 모든 객체를 불변으로 만들자”는 뜻이 아닙니다.
회원 정보처럼 변경이 자연스러운 객체도 있습니다. 반대로 날짜, 금액, 좌표, 문자열처럼 값 자체를 표현하는 객체는 불변으로 만들면 안전합니다.
자바에서 자주 만나는 불변 타입
자바에는 이미 불변으로 설계된 타입이 많습니다.
대표적으로 다음과 같습니다.
| 타입 | 특징 |
|---|---|
String | 문자열 내용이 한 번 만들어지면 바뀌지 않습니다. |
Integer | 정수 값을 감싸는 래퍼 클래스입니다. |
Long | long 값을 감싸는 래퍼 클래스입니다. |
LocalDate | 날짜를 표현하는 불변 클래스입니다. |
LocalDateTime | 날짜와 시간을 표현하는 불변 클래스입니다. |
예를 들어 LocalDate는 날짜를 변경하는 것처럼 보이는 메서드를 제공합니다.
LocalDate today = LocalDate.of(2026, 7, 24);
LocalDate tomorrow = today.plusDays(1);
System.out.println(today);
System.out.println(tomorrow);
실행 결과는 다음과 같습니다.
2026-07-24
2026-07-25
plusDays(1)은 today를 바꾸지 않습니다.
하루 뒤 날짜를 가진 새 LocalDate를 반환합니다.
String도 마찬가지입니다.
String text = "java";
String upper = text.toUpperCase();
System.out.println(text);
System.out.println(upper);
실행 결과는 다음과 같습니다.
java
JAVA
이런 클래스를 제대로 이해하려면 불변 객체의 원리를 알아야 합니다.
언제 불변으로 만들면 좋을까?
처음부터 모든 클래스를 불변으로 만들 필요는 없습니다.
하지만 아래 조건에 해당하면 불변을 먼저 고려해볼 만합니다.
| 상황 | 이유 |
|---|---|
| 값 자체를 표현하는 객체 | 날짜, 금액, 좌표, 이름처럼 값으로 보는 객체는 불변이 잘 맞습니다. |
| 여러 곳에서 공유될 가능성이 큰 객체 | 공유 중 변경으로 생기는 사이드 이펙트를 줄일 수 있습니다. |
| 생성 후 바뀔 이유가 거의 없는 객체 | setter를 열어둘 필요가 없습니다. |
| Map의 key로 사용할 객체 | 중간에 값이 바뀌면 검색 문제가 생길 수 있습니다. |
| 멀티스레드에서 함께 읽는 객체 | 값이 바뀌지 않으므로 안전하게 공유하기 쉽습니다. |
반대로 상태 변화 자체가 핵심인 객체도 있습니다. 예를 들어 장바구니, 주문 진행 상태, 게임 캐릭터의 체력 같은 값은 변경이 자연스럽습니다.
중요한 것은 무조건 불변이 아니라, 바뀌어야 하는 값과 바뀌면 위험한 값을 구분하는 것입니다.
실수하기 쉬운 부분
불변 객체를 배울 때는 아래 부분을 자주 헷갈립니다.
| 실수 | 설명 |
|---|---|
| 참조형 대입이 객체 복사라고 생각함 | 실제로는 참조값이 복사됩니다. |
| 공유 참조 자체를 문제라고 생각함 | 진짜 문제는 공유된 객체의 상태 변경입니다. |
final만 붙이면 완전한 불변이라고 생각함 | 필드가 변경 가능한 객체라면 내부 값이 바뀔 수 있습니다. |
| 불변 객체의 메서드 반환값을 버림 | 새 객체를 반환하므로 반드시 받아야 합니다. |
| setter를 습관적으로 만듦 | 값 객체라면 setter가 없는 편이 더 안전할 수 있습니다. |
특히 “반환값을 받아야 한다”는 점은 꼭 기억해야 합니다.
date.withYear(2027); // 결과를 버림
date = date.withYear(2027); // 새 객체를 다시 저장
불변 객체는 기존 객체를 고치지 않고 새 객체를 돌려줍니다.
정리
기본형은 값을 복사합니다. 그래서 같은 값을 가져도 서로 영향을 주지 않습니다.
참조형은 객체를 가리키는 참조값을 복사합니다. 그래서 여러 변수가 같은 객체를 함께 가리킬 수 있습니다.
공유된 객체의 내부 값을 바꿀 수 있으면 사이드 이펙트가 생길 수 있습니다. 한쪽에서 바꾼 값이 다른 쪽에도 보이기 때문입니다.
불변 객체는 이 문제를 줄이는 방법입니다. 한 번 생성된 뒤 내부 상태를 바꿀 수 없게 만들고, 값이 달라져야 한다면 새 객체를 반환합니다.
핵심은 간단합니다.
변경 가능한 객체는 공유될 때 조심해야 합니다.
불변 객체는 공유되어도 값이 바뀌지 않아 안전합니다.
String, Integer, LocalDate 같은 자바의 대표 클래스들이 불변인 이유도 여기에 있습니다.
불변 객체는 코드를 조금 더 엄격하게 만듭니다. 하지만 그 대신 예측하기 쉽고 안전한 코드를 만드는 데 큰 도움이 됩니다.