[객체지향] 캡슐화 - 객체의 값을 꺼내지 말고 메시지를 던져라

2021. 1. 24. 21:36Java

객체지향을 학습하다보면 캡슐화 라는 용어가 나온다.

위키를 보면 캡슐화의 정의는 다음과 같다.

  • 객체의 속성(data fields)과 행위(메서드, methods)를 하나로 묶고
  • 실제 구현 내용 일부를 외부에 감추어 은닉한다.

얼핏보면 무슨 말인지 이해가 잘 되지 않지만, 핵심은 내부 구현을 외부에 드러내지 않는다는 점이다. 

 

처음 캡슐화란 개념을 접하면 위와 같은 표현은 잘 와닿지 않는다. 그렇다면 어떻게 이를 코드에서 실천할 수 있을까?

가장 간단한 방법은 객체에서 값을 꺼내지 말고, 객체에 메시지를 던지는 방법이다.

이를 코드 레벨에서 적용한다면 로직에 getter를 사용하지 않는다라고 할 수 있겠다.

 

아래 예시를 보며 확인해보자.

자동차 경주 게임을 구현한다고 하면 우리는 Car 객체를 다음과 같이 만들 수 있다.

public class Car {
	private int position = 0;
    
	public void move() {
		this.position++;
	}
    
	public int getPosition() {
		return position;
	}
}

Car 객체는 position이라는 내부 변수를 갖고, 움직일 때마다 이 값을 하나씩 늘리는 객체이다.

자동차 경주 게임을 구현하려면 이 중에 우승자들을(우승자는 여럿이 될 수도 있다) 찾을 수 있어야한다. 우승자를 찾는 작업은 곧 position 값이 가장 큰 자동차들을 찾는 작업이다. 이는 다음과 같이 구현할 수 있다.

 

public class Cars {

	private List<Car> cars;
    
	public List<Car> getWinners() {
		int maxPosition = 0;
		for (Car car : cars) {
			int position = car.getPosition();
			if (position > maxPosition) {
				maxPosition = position;
			}
		}

		List<Car> winners = new ArrayList<>();
		for (Car car : cars) {
			if (car.getPosition() == maxPosition) {
				winners.add(car);
			}
		}        
		return winners;
	}
}

위와 같이 getter를 사용해서 우승자를 구하는 로직을 구하면 어떤 부분이 문제가 될까?

만약 Car 내부에서 position의 타입이 int에서 long으로 바뀌었다고 해보자. 그러면 Cars 객체의 getWinner 메서드도 같이 수정돼야 할 것이다. Car 객체 내부의 변화가 외부의 Cars 객체에도 영향을 미치는 것이다. Car::getPosition 메서드를 사용해서 직접 position 값을 꺼내 쓰고 있기 때문이다. 이는 객체지향 원칙 중 개방-폐쇄 원칙에 위반되기 때문에 좋은 패턴이 아니다. 그렇다면 getter를 제거하고 다음과 같이 코드를 수정해보자.

 

public class Car {
	private int position = 0;

	public void move() {
		this.position++;
	}
    
	public int getLeadingPosition(int position) {
		return Math.max(this.position, position);
	}
    
	public boolean isAt(int position) {
		return this.position == position;
	}
}

public class Cars {

	private List<Car> cars;

	public List<Car> getWinners() {
		int maxPosition = 0;
		for (Car car : cars) {
			maxPosition = car.getLeadingPosition(maxPosition);
		}

		List<Car> winners = new ArrayList<>();
		for (Car car : cars) {
			if (car.isAt(maxPosition)) {
				winners.add(car);
			}
		}
		return winners;
	}
}

 

getter를 없애고 Car 객체에 더 앞서있는 포지션을 묻는 getLeadingPosition 메서드와 특정 포지션에 있는지 묻는 isAt 메서드를 만들었다. 이렇게 Car 객체에 메세지를 던져서 물어보게 바꾸니 Car 객체의 책임도 훨씬 분명해지고 코드의 가독성도 향상됐다. 

 

그런데 과연 이대로 만족스러울까?

앞서 말한 Car 내부의 position의 타입이 int에서 long으로 바뀔 경우에 Cars::getWinners 메서드가 수정돼야 한다는 사실은 변함이 없다. Car::getLeadingPosition 메서드를 통해 메세지를 던지도록 변경하긴 했지만 내부 position 이라는 값의 타입과 존재를 알아야하기 때문이다. 여기서 생각해보면 getLeadingPosition이라는 메서드도 살짝 어색한 것 같다. 과연 Car라는 객체가 어떤 position 값을 받아서 더 큰 position을 반환해야하는 책임이 있을까?

 

이 어색함을 해소하고 Car 객체 내부의 변화가 외부에 영향을 미치지 않도록 하기 위해서는 우승자를 구할 때 position이라는 변수를 활용한다는 것을 아예 감춰야한다. 아래 코드를 보자.

public class Car {
	private int position = 0;

	public void move() {
		this.position++;
	}
    
	public boolean isAheadOf(Car car) {
		return position > car.position;
	}
    
	public boolean isDrawWith(Car car) {
		return position == car.position;
	}
}

public class Cars {

	private List<Car> cars;

	public List<Car> getWinners() {
		Car winner = cars.get(0);
		for (Car car : cars) {
			if (car.isAheadOf(winner)) {
				winner = car;
			}
		}

		List<Car> winners = new ArrayList<>();
		for (Car car : cars) {
			if (car.isDrawWith(winner)) {
				winners.add(car);
			}
		}
		return winners;
	}
}

position이라는 내부 구현을 외부에 아예 감추고 Car 객체들끼리 비교해서 앞서 있는지 비교하는 isAheadOf 메서드와 동률인지 확인하는 isDrawWith 메서드를 만들었다. 이렇게 Car 객체를 구성했을 때 얻는 이득은 크게 두 가지다.

 

1. Car 객체의 책임이 분명해진다. 일반적으로 생각했을 때, 자동차 경주에 쓰이는 Car 객체에 다른 차보다 앞서있는지, 동률인지 묻는 것은 일반적으로 자연스럽고 예상가능하다. 이러한 적절한 책임을 갖는 인터페이스로 구성된 객체는 가독성을 크게 향상 시킬 수 있다.

2. Car 객체 내부의 변화가 외부에 전혀 영향을 미치지 않는다. position의 타입이 int에서 long으로 변해도 Car 객체 내부에서만 코드를 수정하면 되며, Cars 객체는 수정할 필요가 없다. 심지어 우승자를 판단하는 방법이 position 순서가 아닌 name의 사전순으로 변경된다고 하더라도 Car 객체 내부에서 name > car.name 등으로만 수정해주면 된다. 

 

따라서, 객체를 캡슐화하려면 다음의 조건에 유의하자.

1. getter를 로직에 사용하는 것을 지양하자. getter는 내부 구현을 바깥으로 노출해서 객체 간의 결합도를 높이는 결과를 낳는다.

2. 객체에 적당한 메시지를 던지자. 단순히 getter를 사용하지 않는 것보다는 객체가 어떤 책임과 인터페이스를 가져야하는지 고민해보자.