Eternity's Chit-Chat

aeternum.egloos.com



단일 접근 원칙(Uniform Access Principle)을 통한 캡슐화 - (上) Concept & Principle

속성과 메서드, 그리고 캡슐화

은행 도메인에서 계좌(account)의 주된 용도는 고객의 잔액(balance)을 관리하는 것이다. 객체 지향 분석/설계의 핵심은 실세계의 개념과 유사한(그러나 완전히 동일하지는 않은) 추상 모델을 구축하는 것이므로 유비쿼터스 언어(UBIQUITOUS LANGUAGE)에 포함된 어휘인 account와 balance를 사용해서 도메인 모델을 작성할 수 있다.

구현 언어로 Java를 사용할 경우 계좌의 개념을 구현할 수 있는 가장 간단한 방법은 balance를 public 속성으로 가지는 Account 클래스를 추가하는 것이다. 실제 운영 코드였다면 금액을 표현하기 위해 통화와 금액을 하나의 단위로 유지하는 QUANTITY 패턴을 사용하겠지만 여기에서는 설명을 위해 간단히 long 타입을 사용하기로 한다.

public class Account {
  public long balance;
 
  public Account() {
  }
}
예금이란 계좌 잔액에 일정 금액을 더하는 것을 의미한다.
public class AccountTest {
  @Test
  public void deposit() {
    Account account = new Account();
    account.balance += 3000;
    account.balance += 2000;
      
    assertEquals(5000, account.balance);
  }
}
인출이란 계좌 잔액에서 일정 금액을 차감시키는 것을 의미한다.
public class AccountTest {
  @Test
  public void withdraw() {
    Account account = new Account();
    account.balance += 3000;
    account.balance -= 1000;
      
    assertEquals(2000, account.balance);
  }
}
balance가 Account 클래스의 public 속성이기 때문에 account.balance += 3000 또는 account.balance -= 1000과 같이 직접 balabce 속성의 값을 변경시킬 수 있으며 간단하게 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 class Account {
  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() 메서드를 사용하도록 수정하자.

public class AccountTest {
  @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 = sum(deposit) - sum(withdraw)
그러나 balance가 public 속성이기 때문에 Account 객체 외부에서 deposit()과 withdraw()를 통하지 않고도 balance의 값을 마음대로 변경할 수 있다. 따라서 Account 객체의 불변식은 쉽게 깨지고 만다.
public class AccountTest {
 
@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의 값을 외부로 제공하기 위한 메서드를 추가해야 한다.

public class Account {
  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 속성을 유지할 필요가 없다.

public class Account {
  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에 접근할 수 있도록 했었다면 요구 사항 변경 시 파급 효과를 최소화시킬 수 있었을 것이다.

public class Account {
  private long balance;
 
  public Account() {
  }

  public long getBalance() {
    return
balance;
  }
}
이제 계좌 잔액을 필요로 하는 모든 클라이언트들은 balance 속성에 직접 접근할 수 없고 getBalance()를 통해야만 속성값을 참조할 수 있다. 따라서 클라이언트에 대한 파급 효과를 염두에 두지 않고도 balance에 대한 설계 결정을 변경할 수 있다. 입출금 이력을 추가하거나 계좌 잔액을 저장된 값 방식에서 계산된 값 방식으로 변경하는 경우에도 getBalance() 메서드를 변경하지 않는 한 Account를 사용하는 클라이언트는 영향을 받지 않는다. 이것이 정보 은닉과 캡슐화의 힘이다.

핑백

덧글

  • 2학년 2014/03/10 21:52 # 삭제

    고맙습니다. 많이 배우고 갑니다.
  • 이터너티 2016/05/06 15:56 #

    방문해주셔서 감사합니다 ^^
※ 로그인 사용자만 덧글을 남길 수 있습니다.