-
단일 접근 원칙(Uniform Access Principle)을 통한 캡슐화 2부[끝][옛날 글들] 설계 이야기 2024. 5. 27. 09:00728x90
단일 접근 원칙(Uniform Access Principle)
은행 계좌 예제가 변경에 취약한 이유는 Account의 balance 속성을 외부에서 직접 변경할 수 있었기 때문이다. 따라서 balance와 관련된 설계 결정을 변경할 경우 public 속성에 의존하고 있는 많은 코드들이 연쇄적으로 영향을 받게 된다. 이를 방지하는 일반적인 방법은 public 메소드를 통해 private 속성을 캡슐화함으로써 파급 효과의 범위를 제한하고, 외부에서 속성의 값에 접근할 필요가 있을 경우 값을 반환하는 함수를 추가하는 것이다. 그러나 이러한 방식은 코드에 불필요한 잡음을 추가한다.public class Account { private long balance; public Account() { } public long getBalance() { return balance; } }
언어 차원에서는 public 속성을 사용할 수 있도록 허용하는 반면 프로그래머들에게는 사용하지 않도록 금지하는 것은 언어 설계에 문제점이 있음을 드러내는 것이다. Bertrand Meyer의 말을 인용하자면 오용을 막기 위해서는 주의(care)보다는 금지(prevention)가 더 좋은 방법이다.그렇다면 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#의 경우 "프로퍼티(property)"를 통해 언어 차원에서 단일 접근 원칙을 지원하고 있다. 프로퍼티를 사용하는 클라이언트는 Java의 public 속성과 유사한 문법을 사용해서 Account의 balance 속성에 접근할 수 있다. 그러나 내부적으로는 명령(COMMAND)이나 질의(QUERY)를 통해 속성을 캡슐화하고 있기 때문에 설계 변경 시 파급 효과를 최소화할 수 있다.입금 및 출금 목록을 유지하고 저장된 값 방식으로 잔액을 구현하는 C# 코드를 작성해 보자. C#을 사용한 Account 클래스 역시 Java 버전과 마찬가지로 balance의 가시성을 private으로 만들어 캡슐화하고 있지만, 함수 대신 프로퍼티를 사용해서 계좌 잔액을 노출시킨다. C# 프로퍼티의 첫문자는 대문자를 사용하는 것이 관례다. 따라서 C# 버전의 클라이언트는 account.balance가 아니라 account.Balance를 사용해서 프로퍼티에 접근할 수 있다.
class Account { 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; } }
여기에서 눈 여겨 보아야 할 부분은 balance의 값을 얻기 위한 QUERY는 제공하지만 COMMAND는 제공하지 않는다는 점이다. 따라서 Java의 속성과 유사한 방식으로 계좌 잔액을 참조할 수 있도록 하면서도 선택적으로 값을 변경하지 못하도록 방지할 수 있다. Account 클래스의 상태를 변경할 수 있는 유일한 방법은 deposit()와 withdraw() 메소드를 사용하는 것뿐이다. 이처럼 C#의 프로퍼티를 사용하면 속성의 직관적인 표기법과 메소드를 통한 캡슐화의 장점을 동시에 얻을 수 있다.[Test] 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); }
이제 저장된 값 방식으로 구현된 Balance 프로퍼티를 계산된 값 방식으로 변경해보자. Java의 경우 balance 속성을 getBalance() 메소드 내부로 캡슐화하고나면 balance 속성을 참조하는 모든 클라이언트 코드를 수정해야 했다. 그러나 C#의 경우 이미 프로퍼티를 통해 변경의 파급 효과를 Account 내부로 고립시켜 놓았기 때문에 Balance 프로퍼티에 의존하는 클라이언트 코드를 수정할 필요가 없다.class Account { 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#은 프로퍼티라는 언어 차원의 장치를 제공함으로써 단일 접근 원칙을 지원한다. 그러나 이를 위해 get/set/value라는 키워드를 문법에 추가했기 때문에 언어 자체의 복잡도가 높아지는 결과를 초래했다. 또한 여전히 public속성을 노출시키는 것도 가능하다. 따라서 C#의 프로퍼티 방식 역시 언어 차원에서의 금지 보다는 프로그래머의 주의에 모든 것을 맡기는 소극적인 방식이라고 할 수 있다.그럼에도 불구하고 정적 타이핑을 지원하는 C#과 같은 주류 언어에서 단일 접근 원칙을 위한 장치를 제공한다는 점은 주목할 만한 발전이라고 할 수 있다. 그러나 진정한 단일 접근 원칙의 신봉자라면 Ruby를 간과해서는 안 된다.Ruby 예제
단일 접근 원칙을 위해 프로퍼티라는 특별한 빌딩 블록을 추가함으로써 복잡도의 길을 선택한 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 버전부터 살펴 보자.class Account 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.balance 질의(QUERY)를 통해 계좌 잔액에 접근할 수 있다.def test_deposit() 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
Account의 balance를 저장된 값 방식에서 계산된 값 방식으로 변경할 경우에도 질의(QUERY)에 의해 변경의 범위가 Account 내부로 고립되기 때문에 클라이언트로 변경이 전파되지 않는다.
lass Account 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
Account의 클라이언트는 변경 후에도 여전히 account.balance를 사용해서 계좌 잔액에 접근 가능하며 따라서 단일 접근 원칙의 장점을 누릴 수 있다.캡슐화와 단일 접근 원칙
객체 지향의 개념적 기반은 실세계의 추상화지만 그 이면에는 클래스라는 빌딩 블록을 통해 제공되는 캡슐화라는 강력한 설계 원칙이 존재한다. 객체의 속성을 메소드 뒤로 감추는 것은 캡슐화다. 복잡한 클래스 계층 구조를 단일 인터페이스 배후로 감추는 것 역시 캡슐화다. 객체 그룹을 AGGREGATE로 묶거나 협력 관계를 단순화시키기 위해 FAÇADE를 사용한 것 역시 캡슐화다.초기의 설계 결정이 미래에도 유효할 것이라고 예상한다면 몇 달 후 어마어마한 후폭풍에 시달릴 확률이 높다. 전문가와 초보자의 차이는 변경에 대비하고 그에 따른 파급 효과를 최소화하기 위해 다양한 캡슐화 기법을 사용하고 장단점을 트레이드오프할 수 있는 능력에 달려 있다. 단일 접근 원칙은 객체 속성에 대한 설계 결정이 변경될 경우 변경에 의한 파급효과를 최소화하기 위해 적용할 수 있는 유용한 캡슐화 기법의 하나이다.Bertrand Meyer가 Eiffel 언어를 통해 소개한 단일 접근 원칙은 현재 C#과 Ruby와 같은 언어로까지 그 영역을 확장하고 있다. C#은 프로퍼티라는 빌딩 블록을 통해, Ruby는 언어 차원의 간결함과 메타프로그래밍 기법을 통해 단일 접근 원칙을 제공하고 있다.단일 접근 원칙의 모토는 단순하다. 객체 속성에 대한 설계 결정을 변경해도 영향을 최소화할 수 있는 방어막을 구축하는 동시에 직관적인 표기법을 제공하자. 그러면 자연스럽게 설계가 유연해지고 코드는 직관적인 모습을 띠게 될 것이다.728x90'[옛날 글들] 설계 이야기' 카테고리의 다른 글
의존성 끊기와 단위 테스트 – 2부 [끝] (0) 2024.05.30 의존성 끊기와 단위 테스트 - 1부 (0) 2024.05.30 명령-쿼리 분리(Command-Query Separation, CQS) 원리 (0) 2024.05.29 DAO와 REPOSITORY 논쟁 (0) 2024.05.28 단일 접근 원칙(Uniform Access Principle)을 통한 캡슐화 1부 (0) 2024.05.24