캡슐화를 깨뜨리지 않는 Getter 사용

4 분 소요

public class Person {
    private List<Food> favoriteFoods;

    public Person(List<Food> favoriteFoods) {
        this.favoriteFoods = favoriteFoods;
    }

    public List<Food> getFavoriteFoods() {
        return favoriteFoods;
    }
}

public class Food {
    private String name;

    public Food(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

위 코드에는 불변 클래스가 아닌 가변 클래스 Person과 Food가 있다.

Person는 Food의 객체를 List로 가지고있다. 그리고 외부로 favoriteFoods의 정보를 반환하는 getFavoriteFoods()를 가지고있다.

Food는 내부에 String타입의 인스턴스 변수 name을 가지고 있고, name 필드를 대상으로 값을 가져오고 수정하는 메서드인 getName(), setName()가 있다.

Food pizza = new Food("Pizza");
Person person = new Person(Arrays.asList(pizza, new Food("Pasta")));
List<Food> favoriteFoods = person.getFavoriteFoods();

// 원본 객체의 상태를 변경
favoriteFoods.get(0).setName("Burger");

// 원본 객체의 상태가 변경됨
System.out.println(person.getFavoriteFoods().get(0).getName()); // "Burger"

문제는 getFavoriteFoods() 메서드를 호출하여 반환된 리스트의 상태를 변경할 수 있다는 것이다. getter를 통해 반환 받은 참조와 Person 객체 내부에 있는 favoriteFoods의 참조가 동일하기 때문에 반환받은 favoriteFoods 리스트에서 원소에 직접 접근하여 0번째 원소인 “Pizza”를 “Burger”로 수정에 성공했다.

객체지향 설계 원칙과 불변성을 지키기 위해서는 이와같은 클래스 외부에서 내부의 민감한 정보에 직접 접근하여 수정하는 문제를 막아야 한다.


가장 첫 번째로 할 수 있는 조치는 getter 메서드를 호출시 방어적 복사를 하여 객체를 반환하는 것이 있다.

public class Person {
    private List<Food> favoriteFoods;

    public Person(List<Food> favoriteFoods) {
        this.favoriteFoods = favoriteFoods;
    }

	public List<Food> getFavoriteFoods() {
		// return favoriteFoods; 원본의 참조를 그대로 반환하는 기존 코드.
		return new ArrayList<>(this.favoriteFoods);
	}
}

수정된 getFavoriteFoods() 메서드를 호출하면 방어적 복사를 하여 원본의 참조와 다른 새로운 참조를 지닌 동등한 값을 지닌 객체를 반환한다. 이렇게 되면 외부에서 getter를 호출하여 얻은 객체를 수정해도 원본 객체의 무결성을 유지할 수 있다.

또 다른 방법으로는 불변 객체를 만드는 방법이 있다.


불변 객체

  • 불변 객체는 생성 시점에 설정된 상태를 변경할 수 없는 객체를 의미한다.

  • 장점 : 상태 변경이 없으므로 스레드 안전성이 보장되고, 방어적 복사가 필요 없다. 따라서 메모리 사용량과 CPU 시간을 절약할 수 있다. 또한, 불변 객체는 자유롭게 공유할 수 있어서 시스템 전반의 복잡성을 줄일 수 있다.
  • 단점 : 상태를 변경하려면 항상 새로운 객체를 생성해야 한다. 따라서 상태 변경이 자주 발생하는 경우에는 적합하지 않다.
  • 적합한 사용 경우 : 상태 변경이 거의 없는 클래스, 특히 가변 상태를 안전하게 공유해야 하는 경우에 사용.

불변 객체가 되기 위한 조건

  1. 모든 필드에 final 키워드를 사용하여 한 번 할당된 값을 변경하지 못하게 한다.
  2. setter 메서드가 없어야 한다. 즉, 필드의 상태를 변경할 수 있는 메소드는 없어야 한다.
  3. 변경을 요구하는 연산이 있을 때 새로운 객체를 반환하는 방식을 사용한다.
  4. 만약, 클래스가 가변 객체를 참조하고 있다면, 그 객체를 외부로 노출시키지 않거나 불변 뷰를 제공해야 한다.
  5. 생성자를 private 혹은 package-private으로 선언하고, 대신 정적 팩토리 메서드를 제공한다. 이 방식을 통해 클래스를 상속받아 변경할 수 없도록 만든다.

5번 조건의 생성자 스코프를 private, package-private으로 선언하고 정적 팩토리 메서드를 제공하는 것은 선택 사항이다. 이는 불변 객체를 만드는 데 있어 추가적인 유연성을 제공할 뿐, 정적 팩토리 메서드가 없어도 객체는 여전히 불변성을 가진다.

그리고 중요한 점은 final 키워드는 재할당만 막아줄 분 reference 타입 또는 collection 내부의 상태의 변화까지 막아주지는 못한다는 것이다.

불변 객체를 만들 때 주의해야 할 점

  1. primitive 타입의 필드는 final로 불변성 보장 가능.
  2. reference 타입이나 collection을 필드로 가질 경우 방어적 복사를 통해 원본 참조를 끊어야 한다.
  3. primitive 타입의 collection은 방어적 복사와 unmodifiable collection을 이용해 불변을 보장할 수 있지만, reference 타입의 collection은 collection 내의 요소들의 불변성도 보장되어야 한다.

불변 클래스 예시

public class Lotto {
	private final List<LottoNumber> lottoNumbers;

	public Lotto(List<LottoNumber> lottoNumbers) {
		this.lottoNumbers = lottoNumbers;
	}
}

public class LottoNumber {
	private final int number;

	public LottoNumber(int number) {
		this.number = number;
	}

	public int getNumber() {
		return this.number;
	}
}

LottoNumber 클래스는 1~45 사이의 로또 번호 하나를 가진다. Lotto 클래스는 각 로또 번호 6자리를 가진다. 로또가 가지는 6개의 로또 번호들이 중복이 없어야한다는 것이 요구사항일 때, Lotto 클래스는 6개의 LottoNumber 객체를 가지고 있지만 값을 모르기 때문에 비교를 위해서 LottoNumber의 값을 getter로 꺼내야 한다.

이 경우의 getter 사용은 캡슐화를 해치지 않는다.

  1. getter 메서드는 단순히 값을 반환할 뿐 객체의 상태를 변화시키는 로직이 없다.
  2. 반환하는 int 타입은 원시 타입이기 때문에 getter를 통해 얻은 후 값을 변경해도, 원본 LottoNumber 객체의 상태에는 영향을 미치지 않는다.
public class Lotto {
	private final List<LottoNumber> lottoNumbers;

	public Lotto(List<LottoNumber> lottoNumbers) {
		this.lottoNumbers = lottoNumbers;
	}

	public List<LottoNumber> getLottoNumbers() {
		return this.lottoNumbers;
	}
}

이번에는 외부에서 Lotto가 가지고있는 6개의 로또 번호를 출력하기 위해서 값을 getter를 사용해서 꺼내야 한다. 하지만 LottoNumber 클래스 처럼 getter를 사용하여 lottoNumbers를 반환하면 어떻게 될까?

getLottoNumbers()를 호출하여 얻은 LottoNumber 리스트의 참조가 Lotto 객체가 가진 참조와 동일하기 때문에 List에 직접 접근하는 것이 가능해진다. LottoNumber는 불변 객체이기 때문에 List에 들어있는 LottoNumber 원소의 값을 바꾸는 것은 불가능하지만, 외부에서 참조를 얻어 List 자체의 메서드로 추가, 수정, 삭제를 할 수 있게 된다. 이 문제를 해결하기 위해서는 어떻게 해야될까?

getter를 통해 반환하는 참조를 불변으로 만든다

public class Lotto {
	private final List<LottoNumber> lottoNumbers;

	public Lotto(List<LottoNumber> lottoNumbers) {
		this.lottoNumbers = lottoNumbers;
	}

	public List<LottoNumber> getNumbers() {  
	    return this.numbers.stream()
            .collect(Collectors.toUnmodifiableList());
	}
}

기존의 getter 메서드와는 달리 lottoNumbers 리스트를 Collectors.toUnmodifiableList() 메서드로 변경 불가능한 리스트로 만들어서 반환한다. 이렇게 불변 리스트를 반환하면 외부에서 리스트의 참조를 통해 리스트를 조작할 수 없게 된다. ImmutableCollections(불변 컬렉션)을 통해 반환받은 ImmutableList(불변 리스트), ImmutableSet(불변 셋), ImmutableMap(불변 맵)은 해당 컬렉션을 조작시 UnsupportedOperationException 에러를 반환한다. 결국 위에서 발생한 List의 조작을 막고 읽기 전용 컬렉션을 반환할 수 있게 된다.

결국 getter를 무조건 사용하면 안된다는 것이 아니다. 무지성으로 getter를 사용해서 도메인 내부에서 캡슐화를 지키면서 처리할 수 있는 비즈니스 로직을 도메인 외부로 꺼내서 처리하지 말라는 것이다.

도메인에서 자신이 책임지는 비즈니스 로직을 모두 처리하고, 불가피하게 외부로 도메인의 정보를 넘겨줄 필요가 있을 때 방어적 복사를 해서 넘기던가 해당 도메인을 불변 클래스로 만들어서 원본의 불변성과 무결성을 지키는 것이 중요하다는 것이다.

댓글남기기