불변 객체에 대한 생각 정리

uncle.ra·2023년 8월 14일
4
post-thumbnail

주의!) 많이 고민하고 적은 내용이지만 제 의견도 포함되어 있는 글입니다! 혹시나 잘못된 부분이 있으면 지적 부탁드릴게요🙏

시작하기 전에

클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다. 불변 클래스는 장점이 많으며, 단점이라곤 특정 상황에서의 잠재적 성능 저하뿐이다.
...중간 생략...
한편, 모든 클래스를 불변으로 만들 수는 없다. 불변으로 만들 수 없는 클래스라도 변경할 수 있는 부분을 최소한으로 줄이자.
(참고: Effective Java - 아이템 17 변경 가능성을 최소화하라)

Java를 공부하는 과정 속에 불변 객체를 접하게 되었다. 멀티 스레딩 환경에서 살아남을 수 있는 실마리(?😻)를 찾은 것 같아서 굉장히 행복하면서도 실무에서 이 내용을 알았더라면.. 이라는 반성과 함께 정리 해보기로 마음 먹었다🔥

불변 객체란?

생성된 이후로 상태가 변경되지 않는 객체를 의미한다.
Java의 대표적인 불변 객체로는 String, BigInteger, BigDecimal 등이 있다.

불변객체의 장점

1) Set, Map, Cache 등에서 사용하기 안전하다.

HashSet, HashMap 등은 ReferenceType을 key값으로 설정했을 때 내부에 구현된 equals()와 hashCode() method를 통해 판별한다. 불변 객체는 key값으로 불변객체를 사용하였을 때 안전한 동작을 보장한다.

아래의 코드를 확인해보자!

가변 객체일 경우

  1. MutableMember 객체 memberA를 생성하여 HashMap에 저장한다.
  2. memberA의 이름을 memberAA로 변경한다.
  3. memberA를 key값으로 조회해본다.
class MutableMember {
	private String name;
	private int age;
	
	private MutableMember(String name, int age) {
		this.name = name;
		this.age = age;
	}
	
	public static MutableMember withNameAndAge(String name, int age) {
		return new MutableMember(name, age);
	}
	
	public void changeName(String name) {
		this.name = name;
	}
	
	@Override
	public boolean equals(Object o) {
		if (this == o)
			return true;
		if (o == null || getClass() != o.getClass())
			return false;
		MutableMember that = (MutableMember)o;
		return age == that.age && Objects.equals(name, that.name);
	}
	
	@Override
	public int hashCode() {
		return Objects.hash(name, age);
	}
}

public class TestHashMap {
	
	public static void main(String[] args) {
		TestHashMap test = new TestHashMap();
		test.checkHashMapByMutableMember();
	}
	
	private void checkHashMapByMutableMember() {
		HashMap<MutableMember, Boolean> map = new HashMap<>();
		MutableMember memberA = MutableMember.withNameAndAge("memberA", 20);
		map.put(memberA, true);
		
		// before change Name: map.get(memberA) = true
		System.out.println("before change Name: map.get(memberA) = " + map.get(memberA));
		
		memberA.changeName("memberAA");
		
		// after change name: map.get(memberA) = null
		System.out.println("after change name: map.get(memberA) = " + map.get(memberA));
		
	}
    
}
  
    

memberA를 key값으로 사용했을 때 memberA의 상태를 변경하고 나서는 조회할 수가 없는 상황이 되었다😱

다음은 불변 객체를 key값으로 활용했을 때이다.

불변 객체일 경우

final class ImmutableMember {
	private final String name;
	private final int age;
	
	private ImmutableMember(String name, int age) {
		this.name = name;
		this.age = age;
	}
	
	public static ImmutableMember withNameAndAge(String name, int age) {
		return new ImmutableMember(name, age);
	}
	
	public ImmutableMember changeName(String name) {
		return new ImmutableMember(name, this.age);
	}
	
	@Override
	public boolean equals(Object o) {
		if (this == o)
			return true;
		if (o == null || getClass() != o.getClass())
			return false;
		ImmutableMember that = (ImmutableMember)o;
		return age == that.age && Objects.equals(name, that.name);
	}
	
	@Override
	public int hashCode() {
		return Objects.hash(name, age);
	}
}

public class TestHashMap {
	
	public static void main(String[] args) {
		TestHashMap test = new TestHashMap();
		test.checkHashMapByImmutableMember();
	}
	
	private void checkHashMapByImmutableMember() {
		HashMap<ImmutableMember, Boolean> map = new HashMap<>();
		ImmutableMember memberA = ImmutableMember.withNameAndAge("memberA", 20);
		map.put(memberA, true);
		
		// before change Name: map.get(memberA) = true
		System.out.println("before change Name: map.get(memberA) = " + map.get(memberA));
		
		ImmutableMember memberAA = memberA.changeName("memberAA");
		
		// after change name: map.get(memberA) = true
		System.out.println("after change name: map.get(memberA) = " + map.get(memberA));
		
	}
}

위의 결과와 같이 불변객체를 활용했을 때 변경 전의 memberA에 대한 조회가 가능했다.
😄

2) 안정적인 서비스 개발 및 유지보수에 도움이 된다.

실무에서 자주 겪는 일이다. 서비스 운영 중에 버그가 발생하면, 다른 팀원이 짠 혹은 내가 짠 코드를 볼 수 밖에 없는 상황이 생긴다.(자주..!)

예를 들어 한 Service단의 method에서 상태가 잘못 DB에 반영되는 버그가 발생했다.
해당 메서드는 100줄 정도 되고 100 줄 안에서 사용되는 가변 객체가 5개 이상이라면 객체의 상태가 어떻게 변경되는 지를 파악하느라 Service단 코드 뿐만 아니라 해당 Class 내부의 코드까지 봐야 하는 상황이 펼쳐진다.🥹

그런데, 가변 객체가 아니라 전부 불변 객체였다면 어떨까?

상태에 대한 변경이 없다는 안심이 들고, Class 내부에 들어가서 상태가 어떻게 변경되는지를 파악할 필요성이 현저히 줄어들 것이다.

3) 실패 원자성을 보장한다.

실패 원자성이란?

  • 실행 중인 method에 예기치 못한 Exception이 발생한 경우에도 parameter로 넘어간 객체의 상태는 유효해야 한다는 성질을 말한다.

가변 객체의 경우에는 상태가 변경된 method에서 Exception이 발생했을 때 실패 원자성을 보장하지 못한다. 아래의 코드를 보자.

가변 객체인 경우

package immutable_object;

public class TestExceptionByMutableMember {
	public static void main(String[] args) {
		TestExceptionByMutableMember test = new TestExceptionByMutableMember();
		
		test.checkException();
	}
	
	private void checkException() {
		MutableMember memberA = MutableMember.withNameAndAge("memberA", 20);
		// before memberA.getName() = memberA
		System.out.println("before memberA.getName() = " + memberA.getName());
		try {
			occurException(memberA);
		} catch (RuntimeException e) {
			// after memberA.getName() = memberAA
			System.out.println("after memberA.getName() = " + memberA.getName());
		}
		
	}
	
	private void occurException(MutableMember member) throws RuntimeException {
		
		member.changeName("memberAA");
		throw new RuntimeException("Occured Error!");
	}
}

occurException() method에서 memberAA로 이름을 변경한 뒤에 인위적으로 Exception을 발생을 시켰고 catch block에서 memberA의 이름을 조회 했을 때 변경된 memberAA로 출력이 되었다.

그렇다면 불변 객체는 어떨까?

불변 객체인 경우

package immutable_object;

public class TestExceptionByImmutableMember {
	public static void main(String[] args) {
		TestExceptionByImmutableMember test = new TestExceptionByImmutableMember();
		
		test.checkException();
	}
	
	private void checkException() {
		ImmutableMember memberA = ImmutableMember.withNameAndAge("memberA", 20);
		// before memberA.getName() = memberA
		System.out.println("before memberA.getName() = " + memberA.getName());
		try {
			occurException(memberA);
		} catch (RuntimeException e) {
			// after memberA.getName() = memberA
			System.out.println("after memberA.getName() = " + memberA.getName());
		}
		
	}
	
	private void occurException(ImmutableMember member) throws RuntimeException {
		
		member.changeName("memberAA");
		throw new RuntimeException("Occured Error!");
	}
}

불변 객체인 경우에는 catch block에서 확인했을 때 동일한 memberA로 출력이 되는 걸 볼 수 있다.

즉, 불변 객체는 실패 원자성이 보장이 된다는 것을 확인 할 수 있다.😄

4) Thread-Safe하며 동기화를 고려할 필요가 없다.

이 장점에 대해서는 굉장히 많은 고민을 했다. 정말 Thread-Safe하며 동기화를 고려할 필요가 없을까?

잠시 Thread-Safe와 동기화의 의미에 대해서 짚고 넘어가자.

  • Thread-Safe란, 멀티 스레딩 환경에서 여러 스레드가 공유 자원에 동시에 접근하더라도 정확하게 동작함을 의미한다.
  • 동기화란, 멀티 스레딩 환경에서 공유 자원에 접근하거나 수정할 때 제어하는 메커니즘을 의미한다.

필자는 동기화의 의미 중에 수정할 때에 꽂혀있었다..!

이 부분에 대한 답을 Stack Over Flow에서 찾을 수 있었다.

immutable objects once created, they cannot be modified further. Hence they are essentially read-only. And as we all know, read-only things are always thread-safe. Even in databases, multiple queries can read same rows simultaneously, but if you want to modify something, you need exclusive lock for that.

동일한! 공유 자원에 대해서 불변 객체는 말그대로 읽기만 가능하다.
다시 말하면, 상태를 변경하기 위해 새로운 객체를 생성해서 반환 받는 행위는 논외이다!
(쑥쓰럽지만 이전 까지 공유 자원의 의미를 헷갈렸었다..!)

정리하자면, 불변객체는 읽기만 가능하다. 그렇기 때문에 여러 쓰레드가 동시에 접근하더라도 정확하고 안전하다.

불변객체의 단점

이렇게 좋은 불변 객체이지만, 단순히 장점만 존재하지 않을 것이다. 단점에 대해서도 곰곰히 생각해 보았다.🤔

1) 상태를 변경하기 위해서 객체를 생성해야한다.

약간 극단적인 예시를 들어보자.
내가 작성한 글이 광고가 되어서 10분만에 1000만명이 조회를 하였다.

아래는 Post Entity 관련 코드인데 increaseViewCount() method를 통해 10분만에 1000만개의 객체가 생성되었다.

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

import lombok.Getter;

@Entity
@Table(name = "post")
@Getter
public final class Post {
	@Id
	@Column(name = "post_id")
	private long id;
	
	@Column
	private int viewCount;
	
	@Column
	private String content;
	
	
	private Post(String content) {
		this.content = content;
		this.viewCount = 0;
	}
	private Post(int viewCount) {
		this.viewCount =  viewCount;
	}
	
	public static Post withContent(String content) {
		return new Post(content);
	}
	
	public Post increaseViewCount() {
		return new Post(this.viewCount + 1);
	}
	
}

객체 생성 비용이 많이 들 것이고, 추가적으로 객체의 생성과 해제가 많을 수록 Minor GC 수행 역시 빈번해 질 것이다. 참고 - What are the advantages of mutable objects?

하지만 Oracle에선

Immutable object in Oracle

Programmers are often reluctant to employ immutable objects, because they worry about the cost of creating a new object as opposed to updating an object in place. The impact of object creation is often overestimated, and can be offset by some of the efficiencies associated with immutable objects. These include decreased overhead due to garbage collection, and the elimination of code needed to protect mutable objects from corruption.

객체 생성에 대한 비용이 과대평가 되고 있고, 이는 불변객체를 이용한 효율로 충분히 상쇄 될 수 있다.

그리고 GC의 특성

[Java] 불변 객체(Immutable Object) 에 대해 알아보자
블로그 글과 GC의 동작 방식에 대한 공부를 통해 이해한 부분은 GC는 Weak Generational Hypothesis가설을 기반으로 Heap 영역을 나눴고(G1 GC에서는 Region으로 구분) 짧은 생명 주기를 가진 객체들을 해제 하는 과정은 GC 입장에서 큰 부담은 되지 않는다. 더불어 불변 객체를 이용하면 불변객체 내부의 불변 객체에 대해서는 GC 스캔 대상에서 제외된다. 그렇기 때문에 GC 성능에 도움이 된다.

정리

  • 불변 객체가 무엇인지, 불변객체를 사용했을 때의 장점과 단점에 대해서 작성해보았다.
  • 불변 객체를 사용했을 때 장점
    Set, Map, Cache 등에서 사용하기 안전하다.
    안정적인 서비스 개발 및 유지보수에 도움이 된다.
    • 실패 원자성을 보장한다.
      * Thread-Safe하며 동기화를 고려할 필요가 없다.
  • 불변 객체의 단점
    * 상태를 변경하기 위해서 객체를 생성해야한다.

마치면서

개발 경험이 많지 않은 나에게 있어서 제일 와닿았던 내용은 안정적인 서비스 개발 및 유지보수에 도움이 된다는 장점이였다. 실무에서의 여러 프로젝트를 구현해 봤던 경험이 위 내용을 와닿게 해준건 아닌가 싶다!

추가적으로 불변 객체의 단점으로 떠올리는 것은 JPA에서 영속성 컨텍스트 중 1차 캐시 관련한 내용이 단점이 되지 않을까? 라는 생각도 하고 있는데 이 부분은 자바 프로젝트를 진행하면서 업데이트 해야겠다고 생각했다😳

(불변 객체에 대해서 생각을 공유해주신 JW님 고마워요😇)

6개의 댓글

comment-user-thumbnail
2023년 8월 14일

훌륭한 글 감사드립니다.

답글 달기
comment-user-thumbnail
2023년 8월 17일

우와! 진짜 너무 잘 정리 돼 있네요!

답글 달기
comment-user-thumbnail
2023년 8월 17일

두 번 읽었지만 정말 훌륭한 글이네요. 잘 봤습니다!

1개의 답글
comment-user-thumbnail
2023년 8월 23일

잘 읽었습니다!

1개의 답글