Mutable과 Immutable 객체
프로그래밍 언어를 공부하다 보면 값을 변경할 수 있냐, 없냐에 따라서 객체를 구분 짓기도 한다.
객체의 값을 변경할 수 있는 것을 Mutable(가변) 객체라고 하며 반대로 값을 변경하지 못하는 것을 Immutable(불변) 객체라고 한다.
내가 공부하는 자바 언어 외에도 여러 언어에서 사용되는 개념이기 때문에 확실히 이해하고 넘어가면 좋을 것 같다.
밑의 내용은 모두 자바 언어를 기준으로 설명한 내용이다.
Mutable(가변 객체)
먼저 Mutable(가변) 객체를 살펴보면 객체를 초기화 후 값을 변경할 수 있는 객체를 의미한다. 이러한 가변 객체는 객체가 생성된 후에 필드 혹은 상태와 같은 객체의 값을 변경할 수 있다.
이러한 객체의 가변성은 변경 가능한 내부 데이터라는 개념을 도입하여 객체의 수명 주기 동안 값과 속성을 변경할 수 있게 해 준다.
- 가변 객체의 장점
가변 객체의 장점은 바로 유연함이다.
객체를 상황에 따라서 객체가 가지고 있는 값을 빈번하게 수정해야 한다면 가변 객체로 생성하는 것이 더 좋을 수 있다.
하지만 이런 유연함이 다른 상황에서는 문제가 되기도 하는데 해당 내용은 불변 객체 설명에서 확인해 보자.
- 대표적인 가변 객체
자바에서 제공하는 가변 객체가 있는데 대표적인 것 몇 개만 살펴보자.
아래의 가변 객체들은 간단히만 살펴보고 이러한 객체들이 왜 가변 객체인지는 나중에 나오는 불변 객체를 공부해 보면 이해가 갈 것이다.
- StringBuilder
먼저 StringBuilder 클래스는 변경 가능한 문자열을 나타낸다.
// StringBuilder 객체 생성
StringBuilder sb = new StringBuilder();
sb.append("ab"); // 문자열 추가
sb.append("cd");
System.out.println(sb); // 결과: abcd
위의 예시 코드와 같이 객체를 생성하고 문자열을 추가하여 사용할 수 있다.
한 가지 미리 말해보면 String 클래스 즉, 문자열은 자바에서 불변 객체이다. 이러한 불변 객체를 가변 객체로 사용하는 것이 해당 StringBuilder 클래스가 된다.
- StringBuffer
StringBuffer 클래스도 StringBuilder와 마찬가지로 변경 가능한 문자열로 나타낸다.
// StringBuffer 객체 생성
StringBuffer buffer = new StringBuffer();
buffer.append("ab"); // 문자열 추가
buffer.append("cd");
System.out.println(buffer); // 결과: abcd
사용법을 보면 StringBuilder와 거의 똑같다. 하지만 내부적으로는 서로 다른데 StringBuffer는 동기화를 지원하여 멀티 스레드 환경에서도 안전하게 사용이 가능하지만, StringBuilder는 동기화를 지원하지 않는다.
반대로 동기화 작업이 있기 때문에 StringBuffer는 StringBuilder보다 성능 면에서 느리다는 특징을 가지고 있다.
- ArrayList
ArrayList도 가변 객체로 크기가 커지거나 작아질 수 있는 동적 배열을 나타내며 요소를 추가하고 제거할 수 있다.
public boolean add(E e) {
modCount++;
add(e, elementData, size);
return true;
}
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow();
elementData[s] = e;
size = s + 1;
}
위의 코드는 ArrayList에서 add 메서드를 보여주는 코드이다.
먼저 e라는 값을 add 하게 되면 add 메서드가 내부적으로 오버로딩된 add 메서드를 다시 호출하게 되는데 이때 초기에 ArrayList를 생성할 때 설정된 배열(elementData)을 같이 인자로 넘겨주게 된다.
가변 객체는 유연성을 제공하지만, 유연함으로 인해 개발자가 염두에 두어야 할 몇 가지 고려사항이 있다.
- 스레드 안정성
가변 객체는 멀티스레드 환경에서 스레드 안정성을 보장하기 위해 추가적인 동기화 메커니즘이 필요할 수 있다. 적절한 동기화가 없으면 중간에 값이 수정되면서 예상치 못한 동작이 발생할 수 있다.
- 코드 이해의 복잡성
가변 객체의 내부 상태를 수정할 수 있는 유연함은 코드의 복잡성을 높여 이해하기 어려워진다. 개발자는 특히 대규모 코드베이스에서 객체 상태의 잠재적인 변경에 대해 훨씬 더 많은 신경을 써야 한다.
- 상태 관리 과제
개발자는 객체의 무결성을 보장하고 의도치 않은 수정을 방지하기 위해 객체의 변경 사항을 추적하고 제어해야 한다.
- 성능
앞서 스레드 안정성과 이어지는 내용으로 스레드 안정성을 위해 진행하는 동기화로 인해 성능에 영향을 미칠 수 있다.
가변 객체는 새 객체를 할당하지 않고도 값을 변경할 수 있기 때문에 좋을 수도 있다. 하지만 값을 변경할 때마다 해당 객체에 대한 모든 참조가 변경 사항을 반영하게 된다는 것을 알고 있어야 한다.
Immutable(불변 객체)
불변 객체는 객체를 초기화 후에 값을 변경할 수 없는 객체를 의미한다. 이러한 불변 객체는 인스턴스화되면 값과 속성은 수명 내내 일정하게 유지된다.
대표적인 예로 int, long, float, double과 같은 기본 객체, 모든 레거시 클래스, Wrapper 클래스, String 클래스가 있다.
- 불변 객체의 장점
- 스레드 안전성
앞서 살펴봤던 가변 객체는 멀티 스레드 환경에서 스레드 세이프하지 않기 때문에 중간에 참조하고 있는 값이 변경되는 문제가 발생할 수 있다.
불변 객체의 상태는 생성 후 수정할 수 없으므로 명시적 동기화가 필요 없이 여러 스레드에서 안전하게 공유할 수 있다(스레드 세이프). 이를 통해 동시 프로그래밍이 간소화되고 경쟁 조건의 위험이 줄어든다.
- 예측 가능성 및 디버깅
불변 객체의 일정한 상태는 코드를 더 예측 가능하게 만든다. 가변 객체와는 달리 일단 생성되면 불변 객체의 값은 변경되지 않아 코드 동작에 대한 추론이 간소화된다.
- 캐싱 및 최적화를 용이하게 함
불변 객체는 쉽게 캐싱하고 재사용할 수 있다. 일단 생성되면 불변 객체의 상태는 변경되지 않으므로 효율적인 캐싱 전략이 가능하다.
개발자는 자바 애플리케이션에서 변경 불가능한 객체를 사용하여 더욱 견고하고 예측 가능하며 효율적인 시스템을 설계할 수 있다.
Immutable 객체 만드는 방법
상황에 따라 가변 객체를 불변 객체로 만들어야 될 수도 있다.
아래와 같이 name이라는 변수는 생성자를 통해서 초기화된 이후 setName() 메서드를 통해서 언제든지 객체가 변경될 수 있다.
public class Person {
private String name;
Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
이러한 변경이 가능한 가변 객체를 불변 객체로 만들기 위해서는 4가지 조건을 만족해야 한다.
1. 객체의 내부 상태를 변경할 수 있는 메서드는 모두 제거한다.
public class Person {
private String name;
Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
// 상태를 변경할 수 있는 메서드를 제거
// public void setName(String name) {
// this.name = name;
// }
}
2. 모든 필드는 private final로 처리하여 초기화된 이후에 변경할 수 없도록 만든다.
public class Person {
// private String name;
private final String name; // 모든 필드를 private final 키워드로 변경
Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
// 상태를 변경할 수 있는 메서드를 제거
// public void setName(String name) {
// this.name = name;
// }
}
3. 클래스는 상속을 통해서 확장할 수 없도록 만든다. (final 키워드를 사용해서 상속이 불가능하도록 만든다.)
public final class Person { // final 키워드를 추가하여 상속을 통한 확장을 못하게 억제
// private String name;
private final String name; // 모든 필드를 private final 키워드로 변경
Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
// 상태를 변경할 수 있는 메서드를 제거
// public void setName(String name) {
// this.name = name;
// }
}
4. 가변 객체의 참조를 공유해서 사용하면 안 된다. (방어적 복사를 활용)
public final class Team {
private final String teamName;
private final Person person; // 가변 객체
public Team(String teamName, Person person) {
this.teamName = teamName;
this.person = person;
}
public String getTeamName() {
return teamName;
}
public Person getPerson() {
return person;
}
}
- 방어적 복사
만약 위의 코드와 같이 Person 클래스가 가변 객체라고 가정하고, 해당 객체의 참조가 포함된 Team이라는 불변 객체를 생성하였다.
Person person = new Person("Kim");
Team team = new Team("team1", kim);
System.out.println(team.getPerson().name); // Kim
person.name = "Yun";
System.out.println(team.getPerson().name); // Yun
Kim이라는 이름으로 Person 객체를 생성한 뒤, 해당 인원과 team1이라는 이름으로 새로운 Team을 생성하였다.
처음에 Team에 속한 사람의 이름을 출력하게 되면 Kim이라는 결과가 잘 출력되지만, 해당 이름을 Yun으로 변경한 뒤 다시 출력해 보면 Yun으로 출력된다.
이처럼 불변 객체 내부에서 가변 객체의 참조가 존재하게 되면 중간에 값이 변경될 수 있는 위험이 있다.
이러한 위험을 예방할 수 있는 방법이 바로 방어적 복사이다.
public final class Team {
private final String teamName;
private final Person person; // 가변 객체
public Team(String teamName, Person person) {
this.teamName = teamName;
this.person = new Person(person.name);
}
public String getTeamName() {
return teamName;
}
public Person getPerson() {
return new Person(person.name);
}
}
방어적 복사는 기존의 불변 객체를 생성자를 통해서 생성할 때 가변 객체의 참조를 계속 사용하는 것이 아닌 새로운 객체를 생성하여 값을 대입하는 방법이다.
위의 코드를 예시로 방어적 복사를 확인해 보면 기존의 Person 객체의 참조를 그대로 this.person에 저장하는 것이 아닌 해당 Person 객체의 참조로 새로운 Person 객체를 생성하여 참조 값을 대입한다.
이를 통해서 생성자의 매개변수로 받은 Person의 참조 값과 this.person의 참조 값이 서로 달라지면서 값을 수정해도 객체의 불변성을 유지할 수 있게 된다.
마찬가지로 getPerson 메서드도 반환할 때 새로운 Person 객체를 생성하여 반환해야 한다.
Person person = team.getPerson();
person.name = "Jung";
System.out.println(team.getPerson().name);
만약 위와 같은 코드가 있다고 가정해 보면 getPerson 메서드를 통해 기존의 person 참조 값을 그대로 사용하여 값을 Jung으로 수정할 수 있게 된다.
이를 예방하기 위해 getPerson 메서드의 반환 값도 새로운 Person 객체를 생성하여 반환해 줌으로써 객체의 상태가 변경되는 것을 예방할 수 있다.
왜 자바에서는 문자열 클래스가 불변일까?
다른 언어를 찾아보니 C++은 문자열 클래스가 가변 객체로 자바처럼 덮어쓰는 것 없이 기존의 문자열을 변경시킬 수 있다고 한다.
그렇다면 자바 언어에서는 String 타입이 왜 불변 타입일까?
자바에서 String은 거의 모든 자바 프로그램에서 사용되기 때문에 성능과 보안을 강화하기 위해서 불변 객체로 되어있다고 한다.
그럼 어떻게 성능을 높이고 보안을 강화할 수 있는지 하나씩 살펴보자.
- String Pool
자바의 String Pool은 JVM에서 String을 저장하는 특수 메모리 영역이다.
String을 캐싱하고 재사용하면 다른 String 변수가 String Pool에서 동일한 객체를 참조하기 때문에 힙 공간을 많이 절약할 수 있다. String Pool은 정확히 이 목적을 위해 사용된다.
자바에서 String은 불변 객체이므로 JVM은 각 문자열의 사본을 pool에 하나만 저장하여 할당된 메모리 양을 최적화한다.
String str1 = "Hello World";
String str2 = "Hello World";
System.out.println(str1 == str2); // true
예를 들어 위와 같이 코드를 작성했다고 가정해 보자.
위의 그림과 같이 동일한 문자열에 대해서는 String Pool에서 재사용하기 때문에 변수 str1과 str2는 같은 객체를 가리키게 되고, 이를 통해서 비교 연산을 해보면 true 값이 출력되게 된다.
하지만 이와 반대로 new 연산자를 사용해 객체를 만들면 어떻게 될까?
String test1 = "AAA";
String test2 = new String("AAA");
System.out.println(test1 == test2); // false
위와 같이 코드를 작성하고 출력해 보면 test1과 test2는 서로 다르다고 나온다.
분명 똑같은 문자열에 대해서 비교하는 것 같지만 여기서 알아야 할 점은 변수에 저장되는 문자열 객체가 어디에 위치하는지가 중요하다.
그림을 확인해 보면 test1 변수에 저장한 AAA라는 문자열은 String Pool에 저장되지만, new 연산자를 사용하면 String Pool이 아닌 JVM의 힙 메모리 다른 곳에 위치하게 된다.
이로 인해서 test1과 test2는 같은 문자열이라도 서로 다른 객체가 되기 때문에 비교 연산자를 사용하면 false가 나오게 되고, AAA라는 문자열만 비교하기 위해서는 equals() 메서드를 사용해야 한다.
- 보안적인 부분
문자열은 사용자 이름, 비밀번호, URL, 네트워크 연결 등과 같은 민감한 정보를 저장하는데 널리 사용된다. 따라서 String 클래스를 보호하는 것은 일반적으로 전체 애플리케이션의 보안과 관련하여 매우 중요하다.
문자열이 가변적이라면 업데이트를 실행할 때까지 보안 검사를 수행한 후에도 수신한 문자열이 안전할지 확인할 수 없다.
신뢰할 수 없는 호출자 메서드는 여전히 참조를 가지고 있으며 무결성 검사 사이에 문자열을 변경할 수 있다.
또한, 문자열이 다른 스레드에 표시되어 무결성 검사 후 해당 값을 변경할 수도 있다.
- 동기화
일반적으로 불변성을 통해서 값이 변경되지 않을 때 민감한 코드를 처리하기가 더 쉽다. 왜냐하면 결과에 영향을 줄 수 있는 연산의 끼어들기가 적기 때문이다.
특히, 스레드가 값을 변경하면 동일한 값을 수정하는 대신 문자열 풀에 새 문자열이 생성되므로 스레드 안전하기 때문에 따로 동기화 작업이 필요하지 않을 수도 있다.
- 해시코드 캐싱
hashCode() 메서드를 재정의해서 해시가 첫 번째 hashCode() 호출 중에 계산되고 캐시 되어 그 이후로 동일한 값이 반환된다. 이렇게 하면 String 객체로 작업할 때 해시 구현을 사용하는 컬렉션의 성능이 향상된다.
반면에, 가변 문자열의 경우 작업 후에 문자열의 내용이 수정되면 삽입 및 검색 시 두 개의 다른 해시코드를 생성하여 Map에 있는 값 객체가 손실될 가능성이 있다.
Mutable 객체와 Immutable 객체의 사용 방식
가변 객체와 불변 객체 중에 선택할 경우 애플리케이션 요구 사항에 따라 달라진다.
적응성과 빈번한 변경이 필요한 경우 가변 객체를 선택한다. 반대로 일관성, 안전성, 안정적인 상태가 우선순위인 경우 불변성이 최선의 방법이다.
멀티태스킹 환경에서 동시성 측면을 고려해야 한다. 불변성은 동기화의 복잡성 없이 작업 간 데이터 공유를 단순화하지만 가변성은 데이터 무결성을 위해 동시성을 제어하는 작업이 추가적으로 필요하다.
불변 객체는 일반적으로 성능을 향상하지만 데이터 변경이 드물게 발생하는 상황에서 이러한 향상이 가변 객체가 제공하는 유연성보다 더 중요한지 여부를 고려하여 사용 여부를 판단해야 한다.
참고 자료
https://www.baeldung.com/java-string-immutable
https://www.baeldung.com/java-mutable-vs-immutable-objects
'자바' 카테고리의 다른 글
[JAVA] - Call By Value와 Call By Reference (2) | 2024.09.11 |
---|---|
[Java] - GC(Garbage Collection) (1) | 2024.08.29 |
Java - JVM(Java Virtual Machine) (0) | 2023.12.21 |
Java - 스레드 (1) | 2023.11.29 |
Java - 입출력 스트림 (0) | 2023.11.29 |