[출처]
http://aeternum.egloos.com/3013830
http://aeternum.egloos.com/3028192
속성과 메서드, 그리고 캡슐화
은행 도메인에서 계좌(account)의 주된 용도는 고객의 잔액(balance)을 관리하는 것이다. 객체 지향 분석/설계의 핵심은 실세계의 개념과 유사한(그러나 완전히 동일하지는 않은) 추상 모델을 구축하는 것이므로
유비쿼터스 언어(UBIQUITOUS LANGUAGE)에 포함된 어휘인 account와 balance를 사용해서 도메인 모델을 작성할 수 있다.
구현 언어로 Java를 사용할 경우 계좌의 개념을 구현할 수 있는 가장 간단한 방법은 balance를 public 속성으로 가지는 Account 클래스를 추가하는 것이다. 실제 운영 코드였다면 금액을 표현하기 위해 통화와 금액을 하나의 단위로 유지하는 QUANTITY 패턴을 사용하겠지만 여기에서는 설명을 위해 간단히 long 타입을 사용하기로 한다.
public long balance;
public Account() {
}
}
@Test
public void deposit() {
Account account = new Account();
account.balance += 3000;
account.balance += 2000;
assertEquals(5000, account.balance);
}
}
@Test
public void withdraw() {
Account account = new Account();
account.balance += 3000;
account.balance -= 1000;
assertEquals(2000, account.balance);
}
}
“캡슐화 원칙은 어디로 가버린 거지?”
객체 지향의 가장 기본적인 원칙은 속성은 감추고 공용(public) 인터페이스를 통해서만 상태를 변경할 수 있도록 캡슐화 시키라는 것이다. 네 이웃의 것을 탐하지 말라. 그렇다면 객체의 상태를 캡슐화 시켜야 하는 이유는 무엇일까? account.balance와 같이 직관적이면서도 간단한 직접 접근(direct access) 방식에 비해 account.setBalance()나 account.getBalance()와 같이 번거로운 메서드 호출을 통한 간접 접근(indirect access) 방식이 가지는 장점이 무엇일까?
모든 음모의 배후에는 요구사항이 도사리고 있다. 정확하게 말하면 요구사항 변경이라는 소프트웨어의 본질적인 특징과 관련이 있다. 소프트웨어가 출시되고 일정 기간 동안 사용자들이 소프트웨어를 사용하다 보면 소프트웨어에 대한 사용자들의 이해가 깊어진다. 기본적인 기능에 익숙해진 사용자들은 자신의 작업 환경을 개선하기 위해 기능 개선을 요구하게 되기 때문에 높아진 사용자들의 눈높이를 맞추기 위해서는 소프트웨어에 대한 수정이 불가피해 진다.
따라서 요구사항 변경 시 수정되어야 하는 코드 영역을 최소화함으로써 파급 효과(ripple effect)를 줄이기 위한 도구가 필요하다. 이를 해결하기 위한 방법으로 다양한 정보 은닉(information hiding)기법이 소개되어 왔다. 객체 지향의 경우 클래스라는 빌딩 블록을 언어 차원에서 지원함으로써 속성을 인터페이스 뒤로 감추는 장치를 제공한다. 이를 데이터 캡슐화(data encapsulation)라고 한다.
모듈은 서브 프로그램이라기 보다는 책임의 할당이다. 모듈화는 개별적인 모듈에 대한 작업이 시작되기 전에 정해져야 하는 설계 결정들을 포함한다. … 분할된 모듈은 다른 모듈에 대해 감추어야 하는 설계 결정에 따라 특징지어 진다. 해당 모듈 내부의 작업을 가능한 적게 노출하는 인터페이스 또는 정의를 선택한다. … 어려운 설계 결정이나 변화하기 쉬운 설계 결정들의 목록을 사용해서 설계를 시작할 것을 추천한다. 이러한 결정이 외부 모듈에 대해 숨겨지도록 각 모듈을 설계해야 한다.
- Davis Parnas, “On the Criteria To Be Used in Decomposing Systems Into Modules”
Account의 balance 속성을 public 으로 노출시키는 설계는 다음과 같은 두 가지 변경에 대해 취약하다.
- balance 증감 시 추가적인 작업이 필요한 경우
- balance 값을 저장된 값(stored value) 방식에서 계산된 값(computed value) 방식으로 변경하고자 할 경우
balance 증감 시 추가적인 작업이 필요한 경우
계좌의 최종 잔액뿐만 아니라 모든 예금/인출 이력을 조회할 수 있어야 한다는 요구사항이 추가되었다고 가정하자. 새로운 요구사항을 만족시키기 위해서는 Account에 예금 목록과 인출 목록을 관리하는 List 타입의 withdraws, deposits 속성을 추가하고, deposit()과 withdraw() 메서드를 사용해서 balance 값을 변경시키도록 코드를 수정해야 한다. 이처럼 속성의 값에 접근할 때 함께 수행되어야 하는 작업들을 캡슐화시키는 가장 좋은 방법은 메서드를 사용하는 것이다.
public long balance;
private List<Long> withdraws = new ArrayList<Long>();
private List<Long> deposits = new ArrayList<Long>();
public Account() {
}
public void withdraw(long amount) {
withdraws.add(amount);
balance -= amount;
}
public void deposit(long amount) {
deposits.add(amount);
balance += amount;
}
}
deposit()과 withdraw() 메서드는 balance 값을 증가시키거나 감소시키는 작업 외에도 예금 List나 인출 List에 금액을 추가하는 작업도 함께 처리한다. account.balance에 직접 접근해서 잔액을 변경하던 클라이언트 코드를 deposit()과 withdraw() 메서드를 사용하도록 수정하자.
@Test
public void deposit() {
Account account = new Account();
account.deposit(3000);
account.deposit(2000);
assertEquals(5000, account.balance);
}
@Test
public void withdraw() {
Account account = new Account();
account.deposit(3000);
account.withdraw(1000);
assertEquals(2000, account.balance);
}
}
이제 수정된 Account 객체는 “모든 입금액의 합에서 모든 출금액의 합을 뺀 금액은 계좌 잔액과 동일해야 한다”는 불변식(invariant)을 만족시켜야 한다.
그러나 balance가 public 속성이기 때문에 Account 객체 외부에서 deposit()과 withdraw()를 통하지 않고도 balance의 값을 마음대로 변경할 수 있다. 따라서 Account 객체의 불변식은 쉽게 깨지고 만다.
@Test
public void encapsulateionBreak() {
Account account = new Account();
account.deposit(3000);
account.withdraw(1000);
account.balance = 5000;
assertEquals(2000, account.balance);
}
}
불변식을 유지할 수 있는 유일한 방법은 balance 속성의 가시성을 private로 설정해서 외부에서 직접 접근할 수 없도록 감추고, deposit()와 withdraw() 메서드를 통해서만 속성의 값을 변경할 수 있도록 수정하는 것이다. 그러나 클라이언트는 계좌의 잔액을 참조할 수 있어야 하기 때문에 balance의 값을 외부로 제공하기 위한 메서드를 추가해야 한다.
private long balance;
public long getBalance() {
return balance;
}
}
불행하게도 위 변경은 코드의 많은 부분에서 컴파일 에러가 발생하도록 만든다. balance 속성의 가시성이 private으로 변경되었기 때문에 balance 속성에 직접 접근하던 모든 클라이언트 코드에서 컴파일 에러가 발생하고만 것이다. 전체 코드에 대한 소유권을 가지고 있다면 account.balance를 참조하는 모든 부분을 account.getBalance()로 수정하기만 하면 된다. 그러나 만약 Account가 프레임워크에 포함된 클래스이거나 수정 권한이 없는 외부 프로젝트에서 balance 속성을 직접 참조하고 있다면 balance 속성의 가시성을 자유롭게 낮출 수 있는 방법은 존재하지 않는다.
이처럼 요구사항 변경으로 인해 속성을 사용할 때 별도의 작업(여기에서는 금액을 List에 추가하는 작업)을 추가해야 한다면 기존의 public 속성에 직접 접근하는 방식은 변경에 취약할 수 밖에 없다.
balance를 저장된 값에서 계산된 값으로 변경할 경우
balance는 Account 클래스의 속성이다. 세부 구현 측면에서 말하자면 long형인 balance는 메모리 상의 일정 크기를 할당 받아 값을 저장한다. 이처럼 실제로 일정 크기의 메모리를 할당 받아 값을 저장하고 이를 참조하는 방식을 저장된 값(stored value) 방식이라고 한다. 그러나 필요에 따라 실제 메모리를 할당 받지 않고 실행 중에 값을 계산한 후 그 결과를 참조할 수도 있다. 이를 계산된 값(computed value) 방식이라고 한다. 예를 들어 고객의 나이를 참조해야 할 경우 Customer 객체에 실제로 존재하는 age 속성을 사용한다면 저장된 값 방식을 사용하는 것이고 생년월일을 나타내는 속성인 birthDate와 현재 일자 간의 차이를 구한 후 이 값을 사용한다면 계산된 값 방식을 사용하는 것이다.
저장된 값 방식과 계산된 값 방식의 선택은 시간과 공간을 트레이드오프 한 결과다. Customer 객체에 age 속성을 포함시키는 것은 시간을 절약하는 대신 공간을 좀 더 소비하게 되고, 생일을 표현하는 birthDate 속성과 현재 일자 간의 차이를 사용해서 나이를 계산하는 것은 공간을 절약하는 대신 실행 시간이 좀 더 오래 걸린다.
Account 클래스의 balance 속성은 모든 입금액의 합에서 출금액의 합을 뺀 값과 동일해야 한다는 불변식을 만족시켜야 한다. 저장된 값 방식을 사용한 앞의 예제에서는 불변식을 보장하기 위해 withdraw()와 deposit() 메서드 내에서 입금액과 출금액을 추가할 때마다 속성인 balance의 값을 함께 증감시켰다. 계산된 값 방식을 사용하는 경우에는 balance 값이 필요한 시점에 입금액 목록과 출금액 목록에 저장된 금액의 차이를 사용해서 balance 값을 계산할 수 있기 때문에 balance 속성을 유지할 필요가 없다.
private List<Long> withdraws = new ArrayList<Long>();
private List<Long> deposits = new ArrayList<Long>();
public Account() {
}
public void withdraw(long amount) {
withdraws.add(amount);
}
public void deposit(long amount) {
deposits.add(amount);
}
public long getBalance() {
long result = 0;
for(long withdrawAmount : withdraws) {
result += withdrawAmount;
}
for(long depositAmount : deposits) {
result -= depositAmount;
}
return result;
}
}
이 경우 account.balance 속성을 제거했기 때문에 속성에 직접 접근하던 모든 클라이언트 코드에서 컴파일 에러가 발생한다. 즉, 저장된 값 방식으로 구현된 public 속성을 계산된 값 방식으로 변경할 경우 속성에
직접 접근하는 모든 코드를 메서드를 사용해 접근하도록 수정해야 하기 때문에 변경에 취약할 수 밖에 없다.
일반적인 캡슐화 지침
요구사항은 변경된다. 그리고 변경되는 요구사항을 포용하는 능력은 소프트웨어 설계자가 갖추어야 할 가장 중요한 덕목 중 하나다. 앞에서 살펴본 경우처럼 public 속성에 직접 접근하는 방식은 변경에 취약할 수 밖에 없다. 변경에 의한 파급 효과를 줄이기 위해서는 변경될 확률이 높은 public 속성을 안정적인 인터페이스 뒤로 숨겨야 한다. 따라서 Java의 경우 클래스의 모든 속성을 private로 만들어 외부에서 직접 접근하지 못하도록 금지하고 필요한 경우 메서드를 사용해 상태를 변경할 수 있도록 해야 한다.
이것이 C++, Java와 같은 주류 객체 지향 언어를 사용해서 프로그램을 작성할 경우 따라야 하는 가장 기본적인 캡슐화 지침이다.
Account 클래스는 balance를 public 속성으로 노출시키고 있기 때문에 캡슐화의 원칙을 위반하고 있다.
그 결과 변경에 취약한 설계라는 사생아를 낳게 되었으며 하위 호환성을 무시한 채 설계를 변경해야 하는
최악의 상황으로 치닫게 되었다.
Account 클래스를 처음 작성하기 시작하던 시점부터 balance의 가시성을 private로 부여하고 getBalance() 메서드를 통해서만 balance에 접근할 수 있도록 했었다면 요구 사항 변경 시 파급 효과를 최소화시킬 수 있었을 것이다.
private long balance;
public Account() {
}
public long getBalance() {
return balance;
}
}
이제 계좌 잔액을 필요로 하는 모든 클라이언트들은 balance 속성에 직접 접근할 수 없고 getBalance()를 통해야만 속성값을 참조할 수 있다. 따라서 클라이언트에 대한 파급 효과를 염두에 두지 않고도 balance에 대한 설계 결정을 변경할 수 있다. 입출금 이력을 추가하거나 계좌 잔액을 저장된 값 방식에서 계산된 값 방식으로 변경하는 경우에도 getBalance() 메서드를 변경하지 않는 한 Account를 사용하는 클라이언트는 영향을 받지 않는다. 이것이 정보 은닉과 캡슐화의 힘이다.
단일 접근 원칙(Uniform Access Principle)
private long balance;
public Account() {
}
public long getBalance() {
return balance;
}
}
그렇다면 Java에서 속성을 public으로 노출시키면 안 되는 이유가 무엇일까? Java에서 public 가시성을 사용해서 속성을 외부로 노출시킨다는 의미는 속성의 읽기와 쓰기 모두에 있어 제약을 두지 않는다는 것을 의미한다. 만약 속성을 읽기 전용으로 설정해서 외부에서 이를 변경할 수 없도록 할 수만 있다면 속성을 외부로 노출시킨다고 해서 문제가 되지는 않는다. 불행하게도 Java에서는 속성에 대한 읽기, 쓰기 특성을 선택적으로 제어할 수 없기 때문에 변경의 영향을 최소화하기 위해 속성의 가시성을 private로 설정한 후 읽기를 위한 질의(QUERY)를 제공하거나 변경을 위한 명령(COMMAND)을 제공해야 한다.
속성을 직접 노출하는 것은 변경될 여지가 있는 비밀을 감춰야 한다는 정보 은닉 개념을 위반한다. 앞에서 살펴본 바와 같이 외부에서 속성에 직접 접근할 수 있는 경우 함께 수행되어야 하는 작업을 추가하거나 저장된 값 방식을 계산된 값 방식으로 변경하기가 쉽지 않다. 하지만 속성 대신 명령/질의(COMMAND/QUERY)을 사용할 경우 코드의 가독성이 저하되고 복잡도가 상승하게 된다. customer.mileage += 10에 비해 customer.setMileage(customer.getMileage() + 10)을 이해하는데 필요한 인지 과부하가 더 크다.
그렇다면 속성의 직관성과 메소드에 의한 캡슐화라는 장점을 함께 취할 수 있는 방법이 없을까? 단일 접근 원칙(Uniform Access Principle)에 그 해답이 존재한다. 단일 접근 원칙은 Eiffel 언어의 창시자인 Bertrand Meyer에 의해 제안된 설계 원칙 중 하나로 내부 설계 결정에 무관하게 객체의 특징(feature)에 접근할 수 있도록 단일 표기법을 제공하자는 것이다. 특징(feature)이란 객체의 속성(attribute)과 함수(function) 모두를 아우르는 포괄적인 개념이다.
모듈에서 제공되는 모든 서비스는 단일 표기법(uniform notation)을 통해 접근 가능 해야 하며, 기억장치에 저장된 값을 사용해서 구현되는지 아니면 값을 계산하는지의 여부를 누설해서는 안 된다.-- Bertrand Meyer, Object-Oriented Software Construction 2nd Edition
단일 접근 원칙은 단순히 표기법과 관련된 원칙이 아니다. 소프트웨어 설계와 관련된, 그 중에서도 정보 은닉과 캡슐화에 관련된 원칙이다.
단일 접근 원칙을 적용하면 account.balance와 같은 직관적인 표기법을 사용해서 balance의 값을 참조할 수 있다. 클라이언트 입장에서는 balance가 속성인지(즉, 저장된 값인지), 질의(QUERY)인지(단순하게 저장된 값을 반환하는 함수인지 아니면 런타임에 값을 계산해서 반환하는 함수인지) 여부를 구분할 필요가 없다.
물론 Account를 구현하는 입장에서는 balance를 저장된 값으로 구현할 것인지, 계산된 값으로 구현할 것인지 여부를 결정해야 한다. 그러나 어떤 방식을 선택하건 상관없이 account.balance와 유사한 방식의 단일 표기법을 사용할 수 있기 때문에 설계 결정을 변경하더라도 Account를 사용하는 클라이언트는 영향을 받지 않는다. 클라이언트는 계속해서 계좌 잔액의 값을 참조하기 위해 account.balance 형식을 사용할 수 있다.
불행하게도 Java는 단일 접근 원칙을 지원하기 위한 메커니즘을 제공하지 않고 있다. 그러나 Java 이후에 출현한 C#이나 최근 주목을 받고 있는 Ruby의 경우 단일 접근 원칙을 만족시키는 언어적인 특징을 보유하고 있다.
입금 및 출금 목록을 유지하고 저장된 값 방식으로 잔액을 구현하는 C# 코드를 작성해 보자. C#을 사용한 Account 클래스 역시 Java 버전과 마찬가지로 balance의 가시성을 private로 만들어 캡슐화하고 있지만, 함수 대신 프로퍼티를 사용해서 계좌 잔액을 노출시킨다. C# 프로퍼티의 첫문자는 대문자를 사용하는 것이 관례다. 따라서 C# 버전의 클라이언트는 account.balance가 아니라 account.Balance를 사용해서 프로퍼티에 접근할 수 있다.
{
private long balance;
private List<long> withdraws = new List<long>();
private List<long> deposits = new List<long>();
public long Balance
{
get { return balance; }
}
public void withdraw(long amount)
{
withdraws.Add(amount);
balance -= amount;
}
public void deposit(long amount)
{
deposits.Add(amount);
balance += amount;
}
}
public void deposit() {
Accountaccount = new Account();
account.deposit(3000);
account.deposit(2000);
Assert.AreEqual(5000, account.Balance);
}
[Test]
public void withdraw() {
Account account = new Account();
account.deposit(3000);
account.withdraw(1000);
Assert.AreEqual(2000, account.Balance);
}
{
private long balance;
private List<long> withdraws = new List<long>();
private List<long> deposits = new List<long>();
public long Balance
{
get
{
long result = 0;
foreach(long withdrawAmount in withdraws) {
result -= withdrawAmount;
}
foreach(long depositAmount in deposits) {
result += depositAmount;
}
return result;
}
}
public void withdraw(long amount)
{
withdraws.Add(amount);
}
public void deposit(long amount)
{
deposits.Add(amount);
}
}
그럼에도 불구하고 정적 타이핑을 지원하는 C#과 같은 주류 언어에서 단일 접근 원칙을 위한 장치를 제공한다는 점은 주목할 만한 발전이라고 할 수 있다. 그러나 진정한 단일 접근 원칙의 신봉자라면 Ruby를 간과해서는 안 된다.
Ruby에서 모든 인스턴스 변수는 private이다. Ruby의 원칙은 Java나 C#과 달리 프로그래머의 주의에 맡기기 보다는 언어 차원에서의 금지를 통해 코드의 안전성을 추구하는 것이다. Ruby의 경우 Algol 계열 언어에서 함수를 표현하기 위해 사용하는 필수 요소인 ()를 생략할 수 있다. 따라서 메소드인 account.balance()를 account.balance라고 표기할 수 있다. 또한 =()함수의 경우에도 ()가 생략 가능하기 때문에 account.balance=(10)을 account.balance = 10로 표기할 수 있다.
Ruby로 작성된 모든 문장은 실행문이다. 클래스 선언 역시 실행문의 일종으로 이러한 특징은 Ruby의 메타프로그래밍 기능을 사용해 실행 시에 클래스를 확장할 수 있는 다양한 방법을 제공한다. 메타프로그래밍의 대표적인 예가 attr_reader와 attr_writer, attr_accessor를 사용하는 것으로, 속성의 이름을 심볼(symbol)로 전달할 경우 자동적으로 private 속성과 명령(COMMAND) 또는 질의(QUERY)를 클래스 정의에 추가해 준다.
함수 호출 시에 ()가 생략 가능하다는 특징과 명령(COMMAND)과 질의(QUERY)를 자동 생성할 수 있는 메타프로그래밍 기법을 적용하면 언어 차원에서 간단하게 단일 접근 원칙을 만족시킬 수 있다. 저장된 값 방식을 사용해서 계좌 잔액을 관리하는 Account의 Ruby 버전부터 살펴 보자.
attr_reader :balance
def initialize
@withdraws = []
@deposits = []
@balance = 0
end
def withdraw(amount)
@withdraws.push amount
@balance -= amount
end
def deposit(amount)
@deposits.push amount
@balance += amount
end
end
account = Account.new
account.deposit(3000);
account.deposit(2000);
assert_equal(5000, account.balance);
end
def test_withdraw()
account = Account.new
account.deposit(3000);
account.withdraw(1000);
assert_equal(2000, account.balance);
end
def initialize
@withdraws = []
@deposits = []
@balance = 0
end
def withdraw(amount)
@withdraws.push amount
end
def deposit(amount)
@deposits.push amount
end
def balance
balance = @deposits.inject(0) {|sum, amount| sum += amount}
@withdraws.inject(balance) {|sum, amount| sum -= amount}
end
end
초기의 설계 결정이 미래에도 유효할 것이라고 예상한다면 몇 달 후 어마어마한 후폭풍에 시달릴 확률이 높다. 전문가와 초보자의 차이는 변경에 대비하고 그에 따른 파급 효과를 최소화하기 위해 다양한 캡슐화 기법을 사용하고 장단점을 트레이드오프할 수 있는 능력에 달려 있다. 단일 접근 원칙은 객체 속성에 대한 설계 결정이 변경될 경우 변경에 의한 파급효과를 최소화하기 위해 적용할 수 있는 유용한 캡슐화 기법의 하나이다.
Bertrand Meyer가 Eiffel 언어를 통해 소개한 단일 접근 원칙은 현재 C#과 Ruby와 같은 언어로까지 그 영역을 확장하고 있다. C#은 프로퍼티라는 빌딩 블록을 통해, Ruby는 언어 차원의 간결함과 메타프로그래밍 기법을 통해 단일 접근 원칙을 제공하고 있다.
단일 접근 원칙의 모토는 단순하다. 객체 속성에 대한 설계 결정을 변경해도 영향을 최소화할 수 있는 방어막을 구축하는 동시에 직관적인 표기법을 제공하자. 그러면 자연스럽게 설계가 유연해지고 코드는 직관적인 모습을 띠게 될 것이다.
'IT_Architecture > Design Pattern' 카테고리의 다른 글
[펌] System Metaphor (0) | 2014.03.31 |
---|---|
Resource Release Patterns (0) | 2014.03.31 |
Framework (0) | 2014.03.31 |
[펌] 객체지향 개발 5대 원리: SOLID (0) | 2014.03.25 |
[펌] 객체지향 SW 설계의 원칙 (0) | 2013.01.25 |